Skip to content

XRPC Handlers

XRPC handlers are typed API endpoints that extend your hatk server's API. They come in two kinds: queries (read-only GET requests) and procedures (POST requests that can modify data). Each handler maps to a lexicon that defines its parameter types, input/output schemas, and error cases.

Defining a query

Use defineQuery() for read-only GET endpoints. The handler receives a typed context object and returns a response via ctx.ok():

typescript
// server/xrpc/getPlay.ts
import { defineQuery, NotFoundError, views, type Play, type Profile } from "$hatk";

export default defineQuery("xyz.appview.unspecced.getPlay", async (ctx) => {
  const { ok, params, resolve, lookup, blobUrl } = ctx;
  const { uri } = params;

  const records = await resolve<Play>([uri]);
  if (records.length === 0) throw new NotFoundError("Play not found");

  const record = records[0];
  const profiles = await lookup<Profile>("app.bsky.actor.profile", "did", [record.did]);
  const profile = profiles.get(record.did);

  return ok({
    play: views.playView({
      record: {
        uri: record.uri,
        did: record.did,
        handle: record.handle,
        ...record.value,
      },
      author: profile
        ? {
            did: profile.did,
            handle: profile.handle,
            displayName: profile.value.displayName,
            avatar: blobUrl(profile.did, profile.value.avatar, "avatar"),
          }
        : undefined,
    }),
  });
});

The params object is typed from your lexicon's parameter definitions. In this case, params.uri is a string because the lexicon declares it. The ok() function enforces the output schema at the type level -- if your return value doesn't match, TypeScript will error.

Defining a procedure

Use defineProcedure() for POST endpoints that modify data. The request body is available via ctx.input, typed from your lexicon's input schema:

typescript
// server/xrpc/doSomething.ts
import { defineProcedure } from "$hatk";

export default defineProcedure("dev.hatk.unspecced.doSomething", async (ctx) => {
  const { ok, db, viewer, input } = ctx;

  if (!viewer) throw new Error("Authentication required");

  const { name, value } = input;

  await db.run(
    `INSERT INTO my_table (did, name, value, created_at) VALUES ($1, $2, $3, $4)`,
    viewer.did,
    name,
    value,
    new Date().toISOString(),
  );

  return ok({});
});

Context reference

Both defineQuery and defineProcedure handlers receive the same context object:

FieldTypeDescription
okfunctionWraps your return value with type checking
paramsobjectTyped parameters from the lexicon schema
inputobjectRequest body (procedures only), typed from the lexicon's input schema
db.queryfunctionRun SQL queries against your SQLite database
db.runfunctionExecute SQL statements (INSERT, UPDATE, DELETE)
viewer{ did: string; handle?: string } | nullThe authenticated user, or null
limitnumberRequested page size
cursorstring | undefinedPagination cursor
resolvefunctionResolve AT URIs into full records
getRecordsfunctionFetch records by URI from another collection
lookupfunctionLook up records by a field value
countfunctionCount records by field value
existsfunctionCheck if a record exists matching field filters
searchfunctionFull-text search a collection
labelsfunctionQuery labels for a list of URIs
blobUrlfunctionResolve a blob reference to a CDN URL
packCursorfunctionEncode a (primary, cid) pair into a cursor string
unpackCursorfunctionDecode a cursor back into { primary, cid }
isTakendownfunctionCheck if a DID has been taken down
filterTakendownDidsfunctionFilter a list of DIDs, returning those taken down
createRecordfunctionWrite a record to the viewer's PDS and index locally
putRecordfunctionCreate or update a record on the viewer's PDS
deleteRecordfunctionDelete a record from the viewer's PDS and local index

ctx.ok()

Every handler must return ctx.ok(data). This wraps your response with type checking against the lexicon's output schema. If the shape doesn't match, TypeScript catches it at compile time.

ctx.db.query() and ctx.db.run()

Run SQL against your SQLite database. Use db.query() for SELECT statements that return rows, and db.run() for INSERT/UPDATE/DELETE:

typescript
// Query — returns rows
const rows = await db.query(
  `SELECT CAST(COUNT(*) AS INTEGER) AS play_count
   FROM "fm.teal.alpha.feed.play"
   WHERE did = $1`,
  [params.actor],
);

// Run — executes a statement
await db.run(
  `INSERT INTO my_table (did, value) VALUES ($1, $2)`,
  viewer.did,
  input.value,
);

ctx.resolve() and ctx.lookup()

These helpers fetch records without writing raw SQL:

typescript
// Resolve AT URIs into full records
const records = await resolve<Play>([uri]);

// Look up records by a field value — returns a Map keyed by the field
const profiles = await lookup<Profile>("app.bsky.actor.profile", "did", [did1, did2]);
const profile = profiles.get(did1);

ctx.viewer

viewer is { did: string; handle?: string } when the request comes from an authenticated user, or null for unauthenticated requests. Check it to protect endpoints that require authentication:

typescript
if (!viewer) throw new Error("Authentication required");

Error handling

Import error classes from your generated types to throw standard XRPC errors:

typescript
import { NotFoundError, InvalidRequestError } from "$hatk";

// 404 — record not found
throw new NotFoundError("Play not found");

// 400 — bad request
throw new InvalidRequestError("Missing required field");

These map to standard XRPC error responses that clients can handle predictably.

Lexicon pairing

Each handler must have a matching lexicon definition. The file path mirrors the NSID:

lexicons/dev/hatk/unspecced/getPlay.json  →  server/xrpc/getPlay.ts

The lexicon defines parameter types, input/output schemas, and whether the endpoint is a query or procedure. See the AT Protocol lexicon docs for schema details.

Generating a handler

Use the CLI to scaffold a new handler:

bash
hatk generate xrpc dev.hatk.unspecced.getPlay

This creates the handler file with the right imports and structure.