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():
// 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:
// 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:
| Field | Type | Description |
|---|---|---|
ok | function | Wraps your return value with type checking |
params | object | Typed parameters from the lexicon schema |
input | object | Request body (procedures only), typed from the lexicon's input schema |
db.query | function | Run SQL queries against your SQLite database |
db.run | function | Execute SQL statements (INSERT, UPDATE, DELETE) |
viewer | { did: string; handle?: string } | null | The authenticated user, or null |
limit | number | Requested page size |
cursor | string | undefined | Pagination cursor |
resolve | function | Resolve AT URIs into full records |
getRecords | function | Fetch records by URI from another collection |
lookup | function | Look up records by a field value |
count | function | Count records by field value |
exists | function | Check if a record exists matching field filters |
search | function | Full-text search a collection |
labels | function | Query labels for a list of URIs |
blobUrl | function | Resolve a blob reference to a CDN URL |
packCursor | function | Encode a (primary, cid) pair into a cursor string |
unpackCursor | function | Decode a cursor back into { primary, cid } |
isTakendown | function | Check if a DID has been taken down |
filterTakendownDids | function | Filter a list of DIDs, returning those taken down |
createRecord | function | Write a record to the viewer's PDS and index locally |
putRecord | function | Create or update a record on the viewer's PDS |
deleteRecord | function | Delete 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:
// 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:
// 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:
if (!viewer) throw new Error("Authentication required");Error handling
Import error classes from your generated types to throw standard XRPC errors:
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.tsThe 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:
hatk generate xrpc dev.hatk.unspecced.getPlayThis creates the handler file with the right imports and structure.