HandlersGenerated

Handler SDK reference

Every method available to a handler, generated from the SDK contract.

Handlers receive sdk as their first argument. It is already scoped to the calling app and the acting principal — you never pass an app id, and you cannot reach another tenant's data through it.

Contract version 4.0.0

This page is generated from the same contract the console's editor uses for autocomplete, so what you see here is what the editor will offer you.

Capabilities in one paragraph

Most methods are available to every handler. The ones that reach further — raw SQL, schema changes, sending email, cross-org reads — require a capability granted to that handler. The grant is checked when you save, frozen into the manifest when you publish, and enforced again at runtime. Methods below carry a badge when they need one.

Reading data

Available to every handler without a capability grant.

sdk.read

Read a single row by its uid. Resolves to null when no row matches (a value that is not a uid matches nothing). The row carries uid and no id; each foreign key appears as <name>_uid holding the referenced row's uid.

read<T = Record<string, unknown>>(table: string, uid: RowUid, options?: ReadOptions): Promise<T | null>
ParameterDescription
tableTable name.
uidThe row's uid.
optionsRestrict scope.

Returns. The row, or null if not found.

const order = await sdk.read("orders", ctx.reqParams.orderUid);
// order.customer_uid is the customer's uid, ready to pass to sdk.read("customers", …)

sdk.readList

Read a filtered list of rows.

ALWAYS NORMALIZE THE RESULT

const result = await sdk.readList("orders", { filters: { status: "open" }, limit: 50 });
const rows = Array.isArray(result) ? result : result.items;

This returns a plain array only when you pass no limit, no offset, and no paginate. Any one of those three switches it to an { items, paging } envelope — and limit is on nearly every real call, so in practice the envelope is what you get.

Treating the envelope as an array does not throw; it silently misbehaves. result.length is undefined, result.map is not a function, and result.length ? result[0] : null yields null — so a row that EXISTS reads as absent. The failure surfaces much later as a duplicate-key error on the insert you only made because the lookup "found nothing".

Use readPage instead when you always want the envelope and never want to branch.

readList<T = Record<string, unknown>>(table: string, options?: ReadListOptions): Promise<T[] | ReadListEnvelope<T>>
ParameterDescription
tableTable name, e.g. "orders".
optionsFilters, paging, sorting, and scope. Set scope: { shared: true } to read across ALL orgs (cross-tenant; needs the sdk:shared_read capability).

Returns. A plain array with no limit/offset/paginate; otherwise { items, paging }.

// `limit` means this resolves to an ENVELOPE — normalize before using it.
// A reference is filtered by uid under its `_uid` name; rows carry `uid`, never `id`.
const result = await sdk.readList("orders", {
  filters: { status: "open", customer_uid: data.customerUid, deleted_at: null },
  limit: 50,
  sort: "created_at desc",
});
const rows = Array.isArray(result) ? result : result.items;
// Cross-org read (requires the sdk:shared_read capability):
const all = await sdk.readList("orders", { scope: { shared: true } });

sdk.readPage

Read a paginated list and always get back a normalized page result, including total when includeTotal is set.

readPage<T = Record<string, unknown>>(table: string, options?: ReadListOptions): Promise<ReadPageResult<T>>
ParameterDescription
tableTable name.
optionsFilters, paging, sorting, and includeTotal.

Returns. { items, limit, offset, total, paginated }.

const page = await sdk.readPage("orders", {
  filters: { deleted_at: null },
  limit: 25,
  includeTotal: true,
});
// Every row, a page at a time, in creation order (keyset paging).
let after = null;
do {
  const page = await sdk.readPage("orders", { filters: { status: "open" }, limit: 200, after });
  for (const order of page.items) { … }
  after = page.nextCursor;
} while (after);

Analyzing data

Available to every handler without a capability grant.

sdk.aggregate

Aggregate rows in the DATABASE — COUNT/SUM/AVG/MIN/MAX/COUNT_DISTINCT, optionally grouped (with date-truncation / numeric-bucket / lower derivations), filtered, HAVING, and sorted — returning only the compact grouped result. Token/compute cost scales with the ANSWER, not the dataset. Runs the SAME read governance as (read_list policy + tenancy scope + redaction) and compiles an allow-listed structured spec to one safe, parameterized SELECT: every column is validated against the live, non-redacted catalog (unknown/redacted → 422) and all values are bound params. Results are bounded by MAX_GROUPS with honest truncated meta.

aggregate<T = Record<string, unknown>>(table: string, spec: AggregateSpec): Promise<AggregateResult<T>>
ParameterDescription
tableTable name.
specThe structured aggregate spec (measures / groupBy / filters / having / sort / limit).

Returns. { rows, meta: { groups, matchedGroups, truncated, limit } }.

const byMonth = await sdk.aggregate("orders", {
  groupBy: [{ column: "created_at", truncate: "month" }, { column: "status" }],
  measures: [{ op: "count" }, { op: "sum", column: "total", as: "revenue" }],
  filters: { total: { $gte: 100 } },
  sort: "revenue DESC",
});
// Grouping by a reference answers with uids: rows are { customer_uid, revenue }.
const byCustomer = await sdk.aggregate("orders", {
  groupBy: [{ column: "customer_uid" }],
  measures: [{ op: "sum", column: "total", as: "revenue" }],
});

sdk.summarize

Profile a resource in ONE call — a first-class, DETERMINISTIC recipe that composes : returns the total row count plus a per-column summary (numeric min/max/avg/distinct, date earliest/latest, categorical top-N with counts). Runs the same read_list governance; redacted columns are omitted. Zero improvisation, no model tokens.

summarize(table: string, options?: { topN?: number }): Promise<SummarizeResult>
ParameterDescription
tableTable name.
optionstopN controls how many top categories to return per categorical column.

Returns. { table, rowCount, columns } — one summary entry per non-redacted column.

const profile = await sdk.summarize("orders", { topN: 10 });

Writing data

Available to every handler without a capability grant.

sdk.create

Insert one row into a table and return its uid. A foreign key is written by uid under its <name>_uid name; id, uid, org_id and the raw foreign-key column are refused.

create(table: string, data: Record<string, unknown>, options?: CreateOptions): Promise<CreateResult>
ParameterDescription
tableTable name.
dataColumn → value object to insert.
optionsNarrow the scope the row is attributed to.

Returns. { uid, affectedRows }.

const { uid } = await sdk.create("orders", {
  customer_uid: data.customerUid,
  total: 42.0,
});

sdk.createMany

Insert MANY rows in one atomic, guarded batch. Runs the create authorization + scope once (create policy is row-independent) and issues a single multi-row INSERT per distinct column set — far fewer round-trips than looping create, with identical governance (same ${table}:create policy, per-row scope/owner injection, and change-events). Rolls the whole batch back if any row fails.

createMany(table: string, rows: Array<Record<string, unknown>>, options?: BulkCreateOptions): Promise<BulkCreateResult>
ParameterDescription
tableTable name.
rowsArray of column → value objects to insert.
optionsWrite into a specific org / tenant / user scope. Every row's <name>_uid references are resolved together, one lookup per referenced column.

Returns. { count, uids, affectedRows } — uids aligned to the input order.

const { count, uids } = await sdk.createMany("tickets", [
  { subject: "A", status: "open" },
  { subject: "B", status: "open" },
]);

sdk.update

Update one row, named by its uid, with partial column values.

update(table: string, uid: RowUid, data: Record<string, unknown>, options?: UpdateOptions): Promise<UpdateResult>
ParameterDescription
tableTable name.
uidThe row's uid.
dataColumn → new value object (a reference by uid, as <name>_uid).
optionsRestrict scope.

Returns. { affectedRows }.

await sdk.update("orders", order.uid, { status: "fulfilled" });

sdk.updateWhere

Update EVERY row matching a filter in ONE governed UPDATE — the set-based counterpart to . Use it instead of reading rows and looping: one statement, one policy check (${table}:update), scope clauses always ANDed in, and soft-deleted rows excluded when the table has deleted_at.

SAFETY: an unfiltered call is REJECTED (BULK_WRITE_UNFILTERED, 400) unless you explicitly pass { all: true }, so a whole table is never rewritten by accident.

updateWhere(table: string, filters: Filters, changes: Record<string, unknown>, options?: BulkUpdateOptions): Promise<UpdateResult>
ParameterDescription
tableTable name.
filtersFilter object (same DSL as ) selecting the rows to update.
changesColumn → new value object applied to every matching row.
optionsall to permit an unfiltered update, plus scope.

Returns. { affectedRows }.

const { affectedRows } = await sdk.updateWhere(
  "tickets",
  { status: "open", priority: "low" },
  { status: "archived" },
);

Deleting data

Some methods here need a capability grant (sdk:delete); each one says so.

sdk.delete

Requires the `sdk:delete` capability

Delete data. A handler that calls this will not save unless the capability is granted to it, and the grant is frozen into the version's manifest when you publish. See Capabilities & governance.

Delete one row, named by its uid. Honors soft-delete when deleteStrategy: "soft".

delete(table: string, uid: RowUid, options?: DeleteOptions): Promise<DeleteResult>
ParameterDescription
tableTable name.
uidThe row's uid.
optionsdeleteStrategy ("hard" | "soft") and scope.

Returns. { affectedRows }.

await sdk.delete("orders", order.uid, { deleteStrategy: "soft" });

sdk.deleteWhere

Requires the `sdk:delete` capability

Delete data. A handler that calls this will not save unless the capability is granted to it, and the grant is frozen into the version's manifest when you publish. See Capabilities & governance.

Delete EVERY row matching a filter in ONE governed statement — the set-based counterpart to . One policy check (${table}:delete) with scope clauses always ANDed in. Defaults to SOFT delete (sets deleted_at) when the table has that column; pass deleteStrategy: "hard" for a real DELETE.

SAFETY: an unfiltered call is REJECTED (BULK_WRITE_UNFILTERED, 400) unless you explicitly pass { all: true }, so a whole table is never wiped by accident.

deleteWhere(table: string, filters: Filters, options?: BulkDeleteOptions): Promise<DeleteResult>
ParameterDescription
tableTable name.
filtersFilter object (same DSL as ) selecting the rows to delete.
optionsdeleteStrategy, all to permit an unfiltered delete, plus scope.

Returns. { affectedRows }.

await sdk.deleteWhere("sessions", { expires_at: { $lt: new Date() } });

Bulk and synthetic data

Some methods here need a capability grant (sdk:import); each one says so.

sdk.importCsv

Requires the `sdk:import` capability

CSV import. A handler that calls this will not save unless the capability is granted to it, and the grant is frozen into the version's manifest when you publish. See Capabilities & governance.

Bulk-import rows from a CSV into a table: parse, map headers to columns, coerce and validate every cell against the live schema, then insert.

Every cell is checked against the column that will hold it BEFORE anything is written — enum membership, string length, integer range and signedness, numeric scale, JSON validity — so a bad spreadsheet fails with row 412, column status: 'Pending' is not one of open, closed rather than an opaque MySQL error part-way through the batch. A value that cannot be stored is always an error; it is never silently coerced to a default or a substitute.

Rows go through , so an import is an ordinary governed write: create policy, table lock, db-size gate, scope stamping, and one change-event per row. The file speaks the row shape whichever runtime imports it: a reference is a <name>_uid column of uids, and id, uid and the scope columns (org_id / tenant_id) are not importable (the write path mints and stamps them; pass ignoreUnknownColumns to skip them in an export you re-import). Inside the whole import rolls back with everything else.

A column the table REQUIRES — NOT NULL, no default — that the mapping does not answer is refused before anything is written, and reported as missingRequiredColumns. That is a property of the mapping rather than of any row, so no onError setting skips past it: supply a header, a defaults entry, or a column default.

Start with dryRun: true — it returns the resolved mapping, the coerced preview rows, and any validation errors without writing, which is exactly what an import confirmation screen needs.

importCsv(options: ImportCsvOptions): Promise<unknown>
ParameterDescription
optionsSource (csv or csvBase64), mapping, defaults, dryRun, onError.

Returns. { imported, affectedRows, columns, mapping, totalRows, skippedRows, errors }, or the dry-run shape { dryRun: true, headers, mapping, columns, previewRows, errors, missingRequiredColumns, ... }.

const preview = await sdk.importCsv({ table: "contacts", csv, dryRun: true });
// Both checks: clean cells are not the same thing as a committable mapping.
if (!preview.errorRowCount && !preview.missingRequiredColumns.length) {
  await sdk.importCsv({ table: "contacts", csv });
}

sdk.generateRecords

Generate + insert N synthetic rows from a compact per-column spec — you supply INTENT, the runtime produces the VOLUME. This is the correct way to seed sample/demo/test data at scale: never write a loop that emits rows by hand, which does not scale past a few hundred.

Fully schema-driven, so it seeds ANY table: the row shape comes from the live column catalog (types, enum members, lengths, nullability, defaults). Enum membership and column lengths are enforced for you, and a bad or partial spec degrades to a type-aware default rather than a failed INSERT. Columns the scope owns (org_id / tenant_id) are never generated — the write path stamps them — and neither is uid (minted on insert). A reference is generated only when the spec names it by <name>_uid with uids to pick from. Rows go through , so every governance invariant (create policy, scope injection, table lock, db-size gate, change-events) still applies.

generateRecords(table: string, count: number, spec?: GenerateSpec, options?: GenerateOptions): Promise<GenerateResult>
ParameterDescription
tableTable name.
countHow many rows to generate. Clipped by a per-call ceiling; the result reports capped + remaining so you can loop rather than be silently truncated.
specColumn → generation recipe. Omitted columns are inferred from the schema.
optionsseed for reproducible data, batchSize / chunkSize to tune batching, scope.

Returns. { requested, generated, affectedRows, columns, sample } (+ capped / remaining).

const { generated, sample } = await sdk.generateRecords("tickets", 500, {
  status: { kind: "weighted", values: { open: 3, closed: 1 } },
  subject: { kind: "title" },
  opened_at: { kind: "datetime", within: "90d" },
});

Raw SQL

Some methods here need a capability grant (sdk:query); each one says so.

sdk.sql

Requires the `sdk:query` capability

Raw SQL. A handler that calls this will not save unless the capability is granted to it, and the grant is frozen into the version's manifest when you publish. See Capabilities & governance.

Safe SQL builder — the injection-proof way to write DYNAMIC raw SQL. Used as a tagged template, every ${...} interpolation is bound as a ? parameter automatically, so user input can NEVER inject. This is the sanctioned alternative to string-interpolated query(...) (which is rejected at save).

RAW SQL IS INSIDE THE DATABASE. It sees storage rows — id, raw foreign keys, org_id — and may JOIN on id at full speed; that is the fast path. It is also outside the typed helpers' scope wall, so scope it yourself. Whatever you RETURN to your caller must not carry an id: select uid (and the referenced row's uid through the join) instead.

sql: SqlBuilder
// Values auto-bind — safe even with raw user input. Join on ids; return uids.
const rows = await sdk.sql`
  SELECT o.uid, o.status, c.uid AS customer_uid
    FROM orders o JOIN customers c ON c.id = o.customer_id
   WHERE o.status = ${data.status}
`;
// Dynamic identifier (a value can't be a column name), IN-list, and composition:
const sortCol = sdk.sql.id(data.sortColumn);   // whitelist-validated
const uids = data.orderUids;                    // array → `?, ?, ?`
const openOnly = data.openOnly ? sdk.sql`AND status = ${"open"}` : sdk.sql``;
const rows = await sdk.sql`
  SELECT uid, status FROM orders WHERE uid IN (${uids}) ${openOnly} ORDER BY ${sortCol}
`;

Runs raw SQL, so it needs the `sdk:query` capability and bypasses the typed helpers'
scope/redaction/lock walls — exactly like .

sdk.sql

Tagged template: sdk.sqlSELECT ... WHERE col = ${value}``. Interpolations auto-bind.

<TRow = Record<string, unknown>>(strings: TemplateStringsArray, ...values: SqlInterpolation[]): SqlFragment<TRow>

sdk.sql.id

Wrap a dynamic table/column NAME (validated: letters/numbers/underscore) for interpolation.

id(name: string): SqlIdentifier

sdk.sql.join

Stitch a list of fragments/values together with a separator (default ", ").

join(items: SqlInterpolation[], separator?: string): SqlFragment

sdk.query

Requires the `sdk:query` capability

Raw SQL. A handler that calls this will not save unless the capability is granted to it, and the grant is frozen into the version's manifest when you publish. See Capabilities & governance.

Run a raw parameterized SQL query. Prefer the typed helpers above when possible; use this for joins or aggregates they can't express. Like it runs inside the database on storage rows (ids included): never return an id to a caller.

SECURITY (enforced at save): the sql argument MUST be a STATIC string literal with ? placeholders. Interpolating or concatenating into it — sdk.query(`... ${x}`), sdk.query("..." + x) — or passing a variable / request field (sdk.query(data.sql)) is a SQL-injection hole and is REJECTED at save. For ANY dynamic SQL, use the injection-proof tagged template instead (await it directly).

query<TRow = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<TRow[]>
ParameterDescription
sqlA STATIC SQL string literal with ? placeholders.
paramsValues bound to the placeholders, in order.

Returns. The result rows.

const rows = await sdk.query(
  "SELECT status, COUNT(*) AS n FROM orders WHERE created_at >= ? GROUP BY status",
  [data.since],
);

Transactions and idempotency

Available to every handler without a capability grant.

sdk.withTransaction

Run multiple SDK calls atomically. The callback receives a transactional tx SDK; throwing rolls everything back, returning commits.

withTransaction<T>(work: (txSdk: HandlerSdk) => Promise<T>): Promise<T>
ParameterDescription
workAsync callback given a tx SDK. Return to commit, throw to roll back.

Returns. Whatever the callback returns.

const orderUid = await sdk.withTransaction(async (tx) => {
  const { uid } = await tx.create("orders", { total: 42 });
  await tx.create("order_items", { order_uid: uid, sku: "A-1" });
  return uid;
});

sdk.transaction

Alias of .

transaction<T>(work: (txSdk: HandlerSdk) => Promise<T>): Promise<T>

sdk.withIdempotency

Guard a block with an idempotency key so retries with the same key return the stored result instead of running the work twice.

withIdempotency<T>(options: WithIdempotencyOptions<T>, work: () => Promise<T>): Promise<T>
ParameterDescription
optionsIdempotency key (required), plus route/method/ttlSeconds.
workAsync work to run at most once per key.

Returns. The work result, fresh or replayed.

const result = await sdk.withIdempotency(
  { key: data.idempotencyKey, route: "create-order", method: "POST" },
  async () => sdk.create("orders", data),
);

sdk.idempotent

Alias of .

idempotent<T>(options: WithIdempotencyOptions<T>, work: () => Promise<T>): Promise<T>

Human approvals

Available to every handler without a capability grant.

sdk.awaitApproval

Create (or resume) a human-approval gate and wait up to waitForMs for a decision. Use with a stable key so retries attach to the same request.

An approval is single-use: the call that passes the gate uses it up, so the approved work runs once. A rejection is delivered once: the next call gets APPROVAL_REJECTED and the request closes. Either way, a later call with the same key raises a new request for a human to decide. If the work fails after the gate opened, the approval is still spent. Requests belong to the caller's organization and are never visible to another.

awaitApproval(options?: AwaitApprovalOptions): Promise<ApprovalRecord>

Returns. The approval record (approved / rejected / pending).

sdk.getApprovalStatus

Look up an existing approval by uid or key without waiting.

getApprovalStatus(options?: { uid?: string; key?: string }): Promise<ApprovalRecord | null>

sdk.approveApproval

Approve a pending approval, optionally attaching decisionData/notes.

Authorized against the CALLER — the person or agent who invoked this handler — not the handler: the caller's role must hold "Approve paused work" (approval_requests:decide), and listing that action on the handler's endpoint does not substitute for it. The decision is recorded as the caller's, and a caller cannot approve a request they raised (APPROVAL_SELF_APPROVAL). In a dry run nothing is written and the paused handler is not replayed.

approveApproval(options?: ApprovalDecisionOptions): Promise<ApprovalRecord | null>

sdk.rejectApproval

Reject a pending approval, optionally attaching decisionData/notes. Needs the same authority as approveApproval; the principal that raised the request may reject (withdraw) it.

rejectApproval(options?: ApprovalDecisionOptions): Promise<ApprovalRecord | null>

External services

Some methods here need a capability grant (sdk:external_email, sdk:external_stripe); each one says so.

sdk.sendEmail

Requires the `sdk:external_email` capability

Send email. A handler that calls this will not save unless the capability is granted to it, and the grant is frozen into the version's manifest when you publish. See Capabilities & governance.

Send an email AS THE APP, through an email connection (SMTP) its owner granted it under App settings › Email, in the app's branding. Every message is logged in the app (App › Email), where the owner can preview it exactly as sent, with {{secret}} values masked.

sdk.sendEmail(message) uses the app's default connection; sdk.sendEmail("billing", message) the one granted under that alias (in any case), or the connection's uid; App settings › Email shows both. An app with no such grant is refused (EMAIL_NOT_GRANTED). Needs sdk:external_email and the caller's email:send.

In a scheduled job's ctx.job.each, an email is keyed to its row: a retried page finds the logged message and sends nothing (duplicate: true). A dry run checks everything and sends nothing.

sendEmail(connectionOrMessage: string | EmailMessage, message?: EmailMessage): Promise<SendEmailResult>
await sdk.sendEmail("billing", {
  to: invoice.billing_email,
  subject: "Invoice {number} is overdue",
  heading: "Your invoice is overdue",
  body: ["Invoice {number} for {amount} was due on {due}."],
  action: { label: "Pay now", url: invoice.pay_url },
  data: { number: invoice.number, amount: invoice.amount, due: invoice.due_date },
});
// A secret: in the email, but "•••" in the log.
await sdk.sendEmail({
  to: user.email,
  subject: "Your sign-in code",
  body: ["Hi {name},", "Your code is {{code}}. It expires in 10 minutes."],
  data: { name: user.fname, code },
});

sdk.stripe

Requires the `sdk:external_stripe` capability

Stripe. A handler that calls this will not save unless the capability is granted to it, and the grant is frozen into the version's manifest when you publish. See Capabilities & governance.

Your app's connected Stripe account, scoped to THIS app. Products are isolated per app via metadata.app_uid, so you only ever see/sell your own. The org must have connected Stripe (Integrations) — otherwise calls throw NO_STRIPE_CONNECTION.

stripe: HandlerStripe
// Sell a product to an end customer:
const { products } = await sdk.stripe.listProducts();
const { url } = await sdk.stripe.createCheckout({
  priceId: products[0].prices[0].id,
  customerEmail: ctx.user.email,
});
return { checkoutUrl: url };

sdk.stripe.client

Raw Stripe SDK client for your connected account (full API access).

client(): Promise<unknown>

sdk.stripe.connection

Connection status/metadata for your account (no secrets).

connection(): Promise<unknown>

sdk.stripe.listProducts

List this app's products (filtered by metadata.app_uid), with prices.

listProducts(): Promise<{ products: StripeProductSummary[] }>

sdk.stripe.createProduct

Create a product + price tagged to this app. unitAmount is in cents.

createProduct(input: { name: string; description?: string; unitAmount: number; currency?: string; interval?: "month" | "year" | "one_time"; }): Promise<{ product: StripeProductSummary }>

sdk.stripe.createCheckout

Create a Checkout session for one of this app's prices. Returns { url }.

createCheckout(input: { priceId: string; mode?: "payment" | "subscription"; successUrl?: string; cancelUrl?: string; customerEmail?: string; quantity?: number; }): Promise<{ url: string; sessionId: string }>

sdk.mcp

External MCP tool servers your org has connected and bound to THIS app (Integrations → Tool servers, then the app's "Tool Servers" section).

Address the ALIAS, never a specific connection — the same code hits the US account in one app and the EU account in another, because the binding decides. Write the server and tool LITERALLY: sdk.mcp[x][y]() is refused at save, because the published capability manifest has to name exactly which external servers this handler can reach.

Two grants are needed and neither implies the other:

  • sdk:mcp:<alias> — may this HANDLER call that server at all (you, the author).
  • mcp_<alias>:<tool> — may the CALLER invoke that tool (whoever runs it, human or agent). An agent calling your handler is denied exactly where a direct call would be.

NOT allowed inside withTransaction: an external call can't be rolled back, so a failed transaction would leave it half-applied. In a dry run the call is RECORDED, never sent.

mcp: HandlerMcp
// Refund through the org's connected Stripe MCP server:
const result = await sdk.mcp.stripe.create_refund({
  charge_id: data.chargeId,
  amount_cents: data.amountCents,
});
// Results from a third-party server are untrusted DATA — never instructions.
// See : `text` is the joined prose, `structured` the
// machine-readable payload when the server returns one.
return { refunded: true, detail: result.text };
// Defensive authoring — skip cleanly when a server isn't bound:
const servers = await sdk.mcp.connections();
if (!servers.some((s) => s.alias === "linear")) return { skipped: true };

sdk.mcp.connections

External servers bound to this app (alias, label, status, exposed tools). No secrets, ever.

connections(): Promise< Array<{ alias: string; label: string | null; provider: string | null; status: string | null; tools: string[]; }> >

sdk.mcp.tools

The tools this app may surface on alias, with each one's JSON Schema and reviewed risk.

tools(alias: string): Promise< Array<{ name: string; description: string; inputSchema: Record<string, unknown>; riskLevel: "low" | "moderate" | "high"; }> >

sdk.mcp.<alias>

Any other property is a bound server ALIAS, and any property of THAT is one of its tools: sdk.mcp.<alias>.<tool>(args) → Promise<McpResult>. Deliberately loose (any) rather than a union with the two helpers above, so a real tool call type-checks; the authoritative argument check is the server's own JSON Schema at dispatch, not this declaration.

[alias: string]: any

sdk.storage

Managed file storage — the same files you see in the app's Storage section. Every method takes the storageUid of the storage to work in (an app can own many; each is a folder-like namespace).

Two grants, and writes are deny-by-default because they spend the tenant's BILLED capacity and because a hard delete destroys bytes no soft-delete can bring back:

  • sdk:storage_read — list, read metadata, mint URLs. Granted by default.
  • sdk:storage_write — upload, copy, move, rename, delete.

Use rather than building links yourself: it returns ONE usable URL whatever the driver, and whether it is permanent or expiring follows the storage's visibility (see that method's docs).

storage: HandlerStorage
// Store an end user's avatar and hand back a URL the browser can render:
const file = await sdk.storage.upload("avatars", {
  name: `${ctx.user.uid}.png`,
  contentType: "image/png",
  body: data.imageBase64,          // Buffer | Uint8Array | string | data: URL
  ownerUid: ctx.user.uid,          // makes "only my files" expressible as policy
});
const { url } = await sdk.storage.url(file.uid);
return { avatarUrl: url };
// Nested paths are created for you — no need to make folders first:
await sdk.storage.upload("docs", {
  name: "invoice.pdf",
  path: "/invoices/2026/q1",       // mkdir -p, idempotent, concurrency-safe
  contentType: "application/pdf",
  body: pdfBuffer,
});

sdk.storage.listStorages

Every storage this app owns, with byte/file counts.

listStorages(): Promise<{ storages: unknown[] }>

sdk.storage.list

List one folder's contents. Pass folderUid, or path to address it by name — unlike on the write methods, path here does NOT create anything and throws 404 if it doesn't exist. Omit both for the storage root.

list(storageUid: string, options?: { folderUid?: string | null; path?: string; limit?: number; offset?: number; }): Promise<{ folder: StorageFolder | StorageCrumb; folders: StorageFolder[]; files: StorageFile[] }>

sdk.storage.get

One file's metadata. Throws 404 (FILE_NOT_FOUND) when it doesn't exist.

get(fileUid: string): Promise<StorageFile>

sdk.storage.search

Substring search over file and folder names within one storage.

search(storageUid: string, query: string, options?: { limit?: number; offset?: number }): Promise<{ files: StorageFile[]; folders: StorageFolder[] }>

sdk.storage.breadcrumb

The trail from the storage root down to folderUid, root first.

breadcrumb(folderUid: string): Promise<StorageCrumb[]>

sdk.storage.url

ONE usable URL for a file — never branch on the driver.

What you get follows the STORAGE's visibility, not this call:

  • public storage → a permanent, cacheable URL (kind: "public", expiresAt: null). Right for avatars, logos, and anything an <img> re-requests or a CDN should cache.
  • private storage → a short-lived signed URL (kind: "signed"). If you show it to a human, say when it expires — a link pasted into a document will be dead within the hour.

Don't email a signed link: 15 minutes is gone before the mail is opened. Put the file in a public storage, or email a link to your own page that mints a fresh URL on load.

url(fileUid: string, options?: { /** Signed-URL lifetime in seconds. Ignored for public storages. */ expiresIn?: number; /** Force a download (`Content-Disposition: attachment`) over inline display. */ download?: boolean; }): Promise<StorageUrl>

sdk.storage.download

Read a file's bytes into memory. For large files prefer handing out a url.

download(fileUid: string): Promise<{ file: StorageFile; body: Buffer }>

sdk.storage.upload

Store bytes. Consumes the tenant's billed capacity, and is refused (413 FILE_TOO_LARGE, 415 MIME_NOT_ALLOWED) by the storage's own per-file limit and MIME allow-list before anything is written.

In a dry run this is RECORDED, not performed — an object write can't be rolled back with the transaction.

upload(storageUid: string, file: StorageUploadInput): Promise<StorageFile>

sdk.storage.uploadFromUrl

Fetch an http(s) URL and store the response. Same limits as upload.

uploadFromUrl(storageUid: string, options: { url: string; name?: string; contentType?: string; folderUid?: string | null; path?: string; ownerUid?: string | null; metadata?: Record<string, unknown>; }): Promise<StorageFile>

sdk.storage.presignUpload

Mint direct-to-bucket upload targets for YOUR caller, so large files never round-trip through the API. The client PUTs to upload.url, then you call with the returned key.

proxyUpload: true means the driver can't presign — send the bytes to instead.

presignUpload(storageUid: string, options: { files: Array<{ name: string; mimeType?: string; size?: number }>; folderUid?: string | null; path?: string; expiresIn?: number; }): Promise<{ driver: string; targets: Array<{ name: string; key: string; /** The resolved destination: hand it straight back to `confirmUpload`. */ folderUid: string | null; contentType: string; proxyUpload: boolean; upload: { url: string; method: string; headers: Record<string, string>; expiresIn: number; } | null; }>; }>

sdk.storage.confirmUpload

Persist metadata for objects uploaded via presignUpload. Verifies each exists.

confirmUpload(storageUid: string, options: { files: Array<{ key: string; name: string; mimeType?: string; ownerUid?: string | null; metadata?: Record<string, unknown>; }>; folderUid?: string | null; path?: string; ownerUid?: string | null; }): Promise<{ confirmed: number; files: StorageFile[] }>

sdk.storage.createFolder

Create ONE folder under parentUid (omit for the root). name is a single segment.

createFolder(storageUid: string, options: { name: string; parentUid?: string | null }): Promise<StorageFolder>

sdk.storage.ensureFolder

mkdir -p: resolve /a/b/c, creating whatever is missing, and return the leaf. Idempotent and safe when several callers race the same path, so call it unconditionally rather than checking first.

ensureFolder(storageUid: string, path: string): Promise<StorageFolder | null>

sdk.storage.copy

Duplicate a file, bytes and all. Defaults to the source's own folder.

copy(fileUid: string, options?: { folderUid?: string | null; path?: string; name?: string; ownerUid?: string | null; }): Promise<StorageFile>

sdk.storage.move

Move a file (or folder, with type) to another folder, named by folderUid or path (neither = the storage root). Metadata only — bytes stay put.

move(uid: string, options?: { folderUid?: string | null; path?: string; type?: "file" | "folder"; }): Promise<StorageFile | StorageFolder>

sdk.storage.rename

Rename in place. Name collisions in the destination are auto-deduped.

rename(uid: string, newName: string, options?: { type?: "file" | "folder" }): Promise<StorageFile | StorageFolder>

sdk.storage.update

Update a file's metadata and/or ownerUid (null clears it). Use rename for the name.

update(fileUid: string, changes: { metadata?: Record<string, unknown>; ownerUid?: string | null }): Promise<StorageFile>

sdk.storage.delete

Delete a file, or a folder with type: "folder".

Soft by default (recoverable). hard: true also removes the underlying object — that frees billed bytes and CANNOT be undone. A non-empty folder needs recursive: true.

delete(uid: string, options?: { type?: "file" | "folder"; recursive?: boolean; hard?: boolean; }): Promise<{ freedBytes: number; removedFiles: number }>

sdk.crons

Scheduled jobs — run a PUBLISHED handler on an interval or a cron expression.

The thing to understand before using this: a job runs as its OWN principal, not as you and not as whoever created it. A newly created job holds NO roles, so it can do nothing at all until a person arms it in the console. That is deliberate — a schedule outlives the people who set it up, so its access has to be a standing decision somebody made on purpose, not an inheritance from whoever happened to run this handler.

Two grants, and writes are HIGH risk rather than medium:

  • sdk:crons_read — list jobs, read run history, preview a schedule. Granted by default.
  • sdk:crons_write — create, change, pause, delete, trigger. Deny-by-default, because changing an EXISTING job decides what code runs under a principal someone else armed, and fires a job as that principal rather than as the caller.

Binding roles to a job is deliberately NOT here. Arming a job stays on the governed action surface (crons:assign_cron_role), so handler code can create a schedule but can never grant it power — which keeps those two decisions separate.

Missed windows are SKIPPED, never replayed: an outage owes one run, not six. And a job fires at or after its due time, within about the scheduler tick (30s by default) — if you need sub-minute precision, you want a queue, not a schedule.

crons: HandlerCrons
// Give a new customer their own nightly digest, in THEIR timezone:
const job = await sdk.crons.create({
  name: `Nightly digest — ${data.customerName}`,
  targetHandlerUid: data.digestHandlerUid, // must already exist; publish it to run it
  payload: { customerId: data.customerId },
  scheduleKind: "cron",
  cronExpression: "0 7 * * *",             // 7am local, correct across DST
  timezone: data.timezone,
});
// It is PAUSED and powerless until someone arms it in the console.
return { jobUid: job.uid, status: job.status };
// Check a schedule before saving it — real fire times, not a restatement:
const runs = await sdk.crons.preview({
  scheduleKind: "cron",
  cronExpression: "0 0 1 * mon",           // the 1st AND every Monday, not the overlap
  timezone: "America/New_York",
});
// Report which jobs are failing, and what they were allowed to do when they failed:
const failures = await sdk.crons.listRuns({ status: "failed", limit: 20 });
return failures.map((run) => ({
  job: run.cronName,
  when: run.startedAt,
  why: run.errorText,
  couldDo: run.principalRoles,             // the roles held AT THE TIME it ran
}));

sdk.crons.list

Scheduled jobs the caller reaches, newest first: its own org's inside the app; every org's for the app's owner reaching it from outside (each job names its org). Needs sdk:crons_read.

list(options?: { status?: "active" | "paused"; limit?: number; offset?: number; }): Promise<CronJob[]>

sdk.crons.get

One job by uid. Needs sdk:crons_read. An unknown or malformed uid is a 404.

get(cronUid: string): Promise<CronJob>

sdk.crons.listRuns

Past runs, newest first. Omit cronUid for every job in the org. Needs sdk:crons_read.

listRuns(options?: { cronUid?: string; status?: CronRun["status"]; limit?: number; offset?: number; }): Promise<CronRun[]>

sdk.crons.getRun

One run in full, with its result and the roles its principal held. Needs sdk:crons_read.

getRun(runUid: string): Promise<CronRun>

sdk.crons.listRoles

The roles bound to a job's principal — exactly what it may do when it runs. Needs sdk:crons_read.

listRoles(cronUid: string): Promise<{ uid: string; keyName: string; display: string }[]>

sdk.crons.preview

Validate a schedule and get back the instants it would fire at, as ISO strings. Reads and writes nothing. Real timestamps PROVE what a schedule does where a description of it only restates what you typed — and prose cannot show you a wrong DST assumption. Needs sdk:crons_read.

preview(input: CronScheduleInput & { count?: number }): Promise<string[]>

sdk.crons.create

Create a job. It is created PAUSED with a principal holding NO roles, so it can do nothing until a person arms it in the console. Needs sdk:crons_write.

create(input: CronScheduleInput & { name: string; description?: string | null; /** * The uid of the handler to run. It runs the PUBLISHED version, so publish one before * arming. */ targetHandlerUid: string; payload?: Record<string, unknown>; timeoutSeconds?: number; }): Promise<CronJob>

sdk.crons.update

Change a job. nextRunAt is recomputed only when the SCHEDULE changes or the job is armed, so a rename never silently pushes a running job's next fire.

HIGH risk: if the job is already armed, changing targetHandlerUid changes what code runs under a principal someone else granted. Needs sdk:crons_write.

update(cronUid: string, patch: Partial<CronScheduleInput> & { name?: string; description?: string | null; targetHandlerUid?: string; payload?: Record<string, unknown>; timeoutSeconds?: number; status?: "active" | "paused"; }): Promise<CronJob>

sdk.crons.resume

Arm a job — it starts firing on its schedule. Needs sdk:crons_write.

resume(cronUid: string): Promise<CronJob>

sdk.crons.pause

Hold a job without losing its schedule or history. Needs sdk:crons_write.

pause(cronUid: string): Promise<CronJob>

sdk.crons.remove

Delete a job and RETIRE its principal, removing that principal's role bindings so nothing is left holding standing access. The run history is kept. Needs sdk:crons_write.

remove(cronUid: string): Promise<{ deleted: boolean; cronUid: string }>

sdk.crons.runNow

Fire a job now, FOR REAL, as that job's OWN principal — not as the caller. Rows written and mail sent are permanent. It is also RESUME: a job whose firing stopped or is still going carries on from where it is (resumed), never from the beginning. It takes a few turns while you wait; if work remains, an active job carries on in the background (continues: "background") and a paused one stops for the next Resume ("resume"). It never moves the schedule's health. Refused with CRON_BUSY while a turn of the job is running. Needs sdk:crons_write.

runNow(cronUid: string): Promise<{ status: string; resumed: boolean; continues: "background" | "resume" | null; run: CronRun | null; }>

sdk.roles

Give a PERSON a role in one of this app's orgs — the roster flow, from your own handler.

Three walls, and you need all three:

  • sdk:roles_read (granted by default) to read; sdk:roles_write (deny-by-default, HIGH) to assign or unassign, so "this handler hands out roles" is visible when it is published.
  • the CALLER's own policy (users:bind-role), evaluated as whoever called the handler.
  • the grant ceiling: you may only hand out a role carrying what you hold yourself. A handler is never a way around it.

PEOPLE ONLY. Roles for agents and scheduled jobs are granted through their own governed actions, which halt for a human — creating a principal and giving it power stay two decisions.

roles: HandlerRoles
// Someone joined a customer's org: give them the role your app decided on.
await sdk.roles.assign({ userUid: member.uid, roleKey: "dispatcher" });

const held = await sdk.roles.list({ userUid: member.uid });

sdk.roles.assign

Give a person a role in the org THIS RUN belongs to. Needs sdk:roles_write.

Everything is named by uid: the person (userUid), the role (roleUid, or its roleKey) and the org. orgUid may only restate the caller's own org — a run never reaches into another org's roster, however the handler got the uid (ROLE_ORG_MISMATCH). To change another org's roster, run as a principal of that org. The person must already belong to the org, and must be a PERSON: agents and scheduled jobs are armed through their own governed actions (PRINCIPAL_NOT_HUMAN). An unknown or malformed uid is a 404.

assign(input: { userUid: string; roleUid?: string; roleKey?: string; orgUid?: string | null }): Promise<{ assigned: boolean; userUid: string; roleUid: string; /** Null only when the caller's org is not a row of this app's database. */ orgUid: string | null; }>

sdk.roles.unassign

Take a role away, in the caller's own org. Needs sdk:roles_write; removing needs no ceiling.

unassign(input: { userUid: string; roleUid?: string; roleKey?: string; orgUid?: string | null }): Promise<{ unassigned: boolean; removed: number; }>

sdk.roles.list

The roles a person holds in the caller's org (plus any global binding, whose org_uid is null). Needs sdk:roles_read.

list(input: { userUid: string; orgUid?: string | null }): Promise< Array<{ uid: string; key_name: string; display: string | null; org_uid: string | null }> >

sdk.ai

Semantic verbs over your own rows — the questions SQL cannot answer because they need the model to read row CONTENT ("what are these tickets about", "pull the vendor and amount out of this email body", "which of these complaints are about shipping").

Reads go through the SAME governed read as : policy gate, tenancy scope, and redaction are applied before anything reaches a provider. There is no privileged read path here.

Every mode reads a BOUNDED sample (default 100 rows, hard cap 500), so cost is predictable and the result carries sampled when the table had more matching rows than were analyzed. Runs on your organization's fast model by default. Pass tier: "default" for a harder one-off question. Handlers never name a model; the AI connection decides which model each tier uses, so changing it is one edit, not one per handler.

In a handler dry run these make NO provider call: the intent is recorded in the test ledger and an empty result is returned, so testing a handler never bills the org.

ai: HandlerAi
// What are the open tickets about?
const { text } = await sdk.ai.digest("tickets", { filters: { status: "open" } });

// Group them, with a closed label set
const { byLabel } = await sdk.ai.classify("tickets", {
  instruction: "Classify the support theme.",
  labels: ["billing", "shipping", "bug", "other"],
});

sdk.ai.digest

Prose summary of what a set of rows is ABOUT. Reads a bounded sample, summarizes each batch, then rolls those partials into one set of points.

digest(table: string, options?: AiCommonOptions & { instruction?: string }): Promise<AiDigestResult>

Returns. { text, points, analyzed, matched, sampled, batches, model }.

sdk.ai.classify

Label each row and return COMPACT counts per label — not a row-proportional payload. Supply labels to close the set; omit it to let the model choose consistent labels.

classify(table: string, options: AiCommonOptions & { instruction: string; labels?: string[] }): Promise<AiClassifyResult>

Returns. { byLabel, perRow, analyzed, unmapped, matched, sampled, model }.

sdk.ai.extract

Pull structured fields out of free text. The one deliberately row-proportional verb — that IS the product — bounded by the sample cap. A field the row does not state comes back null rather than guessed.

extract(table: string, options: AiCommonOptions & { instruction: string; fields: string[] }): Promise<AiExtractResult>

Returns. { rows: [{ uid, fields }], analyzed, unmapped, matched, sampled, model }.

sdk.ai.filter

Semantic predicate: the uids of rows that satisfy instruction. Returns uids rather than rows so you fetch what you need through the governed read path.

filter(table: string, options: AiCommonOptions & { instruction: string }): Promise<AiFilterResult>

Returns. { uids, analyzed, unmapped, matched, sampled, model }.

sdk.ai.annotate

Label rows and STORE the label in a real column. The one verb that writes.

This is what makes AI affordable at scale: the model runs once per row, and every later question about that column is a free indexed SQL aggregate instead of another model call. Rows whose source text has not changed since they were labeled are skipped, so calling this repeatedly costs nothing for rows that already settled.

A value a person edited by hand is never overwritten — not by a re-run, not by a model change. Editing a SOURCE column clears that protection, because that is the person asking for a fresh read.

Always runs on the fast tier and refuses model / tier: a stored value keeps one model per column so the column never mixes provenance. Change the fast model on your AI connection to move it.

annotate(table: string, options: { into: string; from: string[]; instruction: string; labels?: string[]; filters?: Record<string, unknown>; /** Rows examined per call. Default 200, hard cap 500. */ batchSize?: number; }): Promise<AiAnnotateResult>
ParameterDescription
options.intothe column to write into — an ordinary column you created normally.
options.fromthe columns the model may read. Redacted columns are refused.

Returns. { column, examined, enriched, settledEmpty, skippedOverride, fresh }.

await sdk.ai.annotate("tickets", {
  into: "theme",
  from: ["subject", "body"],
  instruction: "Classify the support theme.",
  labels: ["billing", "shipping", "bug", "other"],
});
// From here on it is just SQL:
await sdk.aggregate("tickets", { groupBy: [{ column: "theme" }], measures: [{ op: "count" }] });

sdk.ai.redact

Mask personal details inside stored text.

The other write verb, and the only destructive one: it overwrites the column it read unless into names somewhere else. That is deliberate — a redaction that leaves the original sitting in the next column has not redacted anything. Because of that, it only ever touches the column you name, and a row the model returns nothing usable for keeps its original text rather than being blanked.

of is required. Masking everything by default would be a guess about which parts of the customer's data matter, made destructively.

redact(table: string, options: { column: string; of: string[]; into?: string; instruction?: string; filters?: Record<string, unknown>; sampleSize?: number; }): Promise<AiRedactResult>
ParameterDescription
options.columnthe text column to read.
options.intowhere to write the masked text. Defaults to column (in place).
options.ofkinds to mask, e.g. ["person_name", "phone", "email"].

Returns. { column, into, examined, rewritten, unmapped, model }.

// Mask in place
await sdk.ai.redact("support_notes", {
  column: "body",
  of: ["person_name", "phone", "email"],
});

// Or keep the original and write the masked copy elsewhere
await sdk.ai.redact("support_notes", {
  column: "body",
  into: "body_public",
  of: ["person_name", "phone"],
});

sdk.ai.embed

Compute and store embeddings for a set of rows, so they can be searched by meaning.

Requires a provider that offers embeddings. Anthropic does not; OpenAI does — you get AI_EMBEDDINGS_UNSUPPORTED rather than a confusing failure if the org's provider cannot.

Re-running is safe: an existing embedding for a row is replaced, not duplicated.

embed(table: string, options?: { columns?: string[]; filters?: Record<string, unknown>; sampleSize?: number; dimensions?: number; }): Promise<AiEmbedResult>

Returns. { table, embedded, skipped, model, dimensions }.

sdk.ai.similar

Find rows that mean something similar — for "related items", dedupe, and semantic search.

Give it either to (an existing row's uid, which reuses that row's stored vector) or text (free text, which is embedded on the fly). The row you searched from is never returned as its own match.

minScore (default 0.3) drops weak matches. A nearest-neighbour search always returns something; without a floor the least-unrelated row comes back looking like a match.

Scale: this is a scan over the rows of one table, not an index lookup — MySQL has no approximate-nearest-neighbour index. Comfortable to roughly 100k embedded rows per table; beyond that you want a dedicated vector store behind this same call.

similar(table: string, options: { to?: RowUid; text?: string; limit?: number; minScore?: number; }): Promise<AiSimilarResult>

Returns. { table, matches: [{ uid, score }], scanned } — best score first.

Utilities

Available to every handler without a capability grant.

sdk.hashPassword

Hash a plaintext password (bcrypt-style) for safe storage.

hashPassword(password: string): Promise<string>

sdk.app

The app itself: its branding (App settings › Branding), for what a handler renders in the app's look. Read-only.

app: HandlerApp
const brand = await sdk.app.branding();
return { logo: brand.logoUrl, primary: brand.colors.light.primary ?? "#111111" };

sdk.app.branding

The app's branding (App settings › Branding), with images as public URLs: for a handler that renders something of its own (a PDF, a page) in the app's look. Email from sendEmail wears it already.

branding(): Promise<AppBranding>

The handler context (ctx)

Every handler receives ctx as its second argument. It describes who is calling and in what circumstances.

ctx.sdk

sdk: HandlerSdk

The same SDK object handed to the handler as its first argument, already scoped to this app and the calling principal. Both runtimes.

ctx.req

req?: { appUid?: string; body?: Record<string, unknown>; query?: Record<string, unknown>; params?: Record<string, string>; headers?: Record<string, string | string[] | undefined>; method?: string; path?: string; originalUrl?: string; }

The raw Express request.

Node runtime only — the Deno worker never receives one. Use reqParams and reqQuery, which both runtimes populate, and take the request payload from the handler's first argument.

ctx.res

res?: unknown

The raw Express response.

Node runtime only. Writing to it directly bypasses the normal response envelope — return a value from the handler instead and let the platform serialize it.

ctx.reqQuery

reqQuery?: Record<string, unknown>

The parsed query string of the request that reached this handler. Both runtimes.

ctx.reqParams

reqParams?: Record<string, string>

Route path parameters — /orders/:orderUid arrives as { orderUid: "…" }. Both runtimes.

ctx.appConfig

appConfig?: Record<string, unknown> | null

This app's configuration object: the JSON held in the app_config row of the app's own database. Both runtimes.

This is the channel for app-level configuration, because a handler has no environment of its own to read. The Deno sandbox grants --allow-env for a fixed allowlist of platform-internal variables only, so there is no process.env / Deno.env holding values you set.

ctx.user

user?: { /** The user's handle outside this database (e.g. `sdk.storage` `ownerUid`). */ uid?: string; email?: string; fname?: string; lname?: string; /** The acting organization's uid. */ orgUid?: string | null; [key: string]: unknown; } | null

The end user this request is acting as, with password fields stripped. null for a caller that is not a user. Both runtimes.

ctx.userContext

userContext?: { /** The calling principal's uid. */ actorUid?: string | null; actorType?: string | null; /** The acting organization's uid. */ orgUid?: string | null; roles?: string[]; scopes?: string[]; } | null

The caller in handles, and its roles/scopes. No ids (SDK 3.0.0).

Node runtime only. The Deno worker forwards the caller into the SDK's own scoping — so it governs what your sdk.* calls may do — but does not place it on ctx.

ctx.requestId

requestId?: string | null

Correlation id for this request, matching the requestId on any error envelope it produces. Node runtime only — on Deno it is carried in the SDK context and shows up in logs, but is not placed on ctx.

ctx.identityType

identityType?: string

What kind of principal is calling — "user", an agent, or another identity type. Node runtime only.

ctx.handlerDelegation

handlerDelegation?: { entryAction?: string; allowedActions?: string[]; deniedActions?: string[]; enabled?: boolean; } | null

Set when this handler was reached BY ANOTHER HANDLER rather than directly, describing the delegated authority it inherited: the action that entered the chain and the actions allowed or denied within it. Both runtimes.

Read-only bookkeeping — narrowing happens in the platform, not by editing this.

ctx.audience

audience?: { mode: "org" | "all" | "orgs" | "none"; orgUids: string[]; } | null

Which of the app's organizations this run reaches, so code can check it rather than infer it. Both runtimes.

  • org: one org. A caller inside the app (its own org), or a job whose org reach is one org, which runs AS that org: rows carry no org column, and new rows are that org's.
  • all: every org, including ones created later (orgUids is empty). Acting for the app's owner.
  • orgs: the listed orgs. Acting for the app's owner within an org reach.
  • none: no org's data, only the owner's own rows.

In the last three modes the run acts for the app's owner and reaches the owner's own rows too: every row it reads says which org it belongs to as org_uid (null for the owner's own), a new row in an org's table names one with org_uid (ROW_ORG_REQUIRED without it, and ROW_ORG_NOT_FOUND for one outside the reach), and filters and aggregate groupings take org_uid too. A row's org_uid cannot be changed by an update.

null on the platform's own plane.

ctx.job

job?: HandlerJob

Present when the handler runs as a scheduled job (and when its owner tries a job-only handler from the console). Deno.

Types

The option and result shapes named in the signatures above.

JsonPrimitive

type JsonPrimitive = string | number | boolean | null;

JsonValue

type JsonValue = JsonPrimitive | JsonObject | JsonValue[];

JsonObject

interface JsonObject {
	[key: string]: JsonValue;
}

SortDirection

type SortDirection = "asc" | "desc";

DeleteStrategy

type DeleteStrategy = "hard" | "soft";

ScopeType

type ScopeType = "org" | "tenant" | "user";

Identifier

type Identifier = string | number;

RowUid

THE ROW SHAPE (SDK 3.0.0). Every CRUD method speaks the shape a row has outside its database:

  • A row is named by its uid (a string). It never carries id, and read / update / delete take a uid. Sorting by id is allowed (creation order); sorting by uid is not.
  • A foreign key travels as <name>_uid holding the referenced row's uid: customer_id is read, written and filtered as customer_uid. Sending id, uid, org_id, or the raw customer_id is a 400 (ROW_FIELD_NOT_ACCEPTED) naming the field to use; a _uid that names no row is a 400 (ROW_REFERENCE_NOT_FOUND).
  • org_id / tenant_id and integer created_by / updated_by / deleted_by / user_id columns without a declared foreign key are stamped and applied by the platform and never appear.

Raw SQL (sdk.sql, sdk.query) is NOT the edge: it runs inside the database and may join on id at full speed. That is what it is for. But whatever your handler RETURNS to its caller must not carry an id: select the uid instead.

type RowUid = string;

ComparableValue

type ComparableValue = JsonPrimitive | Date | Identifier;

FilterComparison

Comparison operators for a column filter: { amount: { $gte: 100, $lt: 500 } }.

interface FilterComparison {
	$eq?: ComparableValue | null;
	$ne?: ComparableValue | null;
	$gt?: ComparableValue;
	$gte?: ComparableValue;
	$lt?: ComparableValue;
	$lte?: ComparableValue;
	$in?: ComparableValue[];
	$between?: [ComparableValue, ComparableValue];
}
PropertyDescription
$eqEqual to. null matches IS NULL.
$neNot equal to. null matches IS NOT NULL.
$gtStrictly greater than.
$gteGreater than or equal to.
$ltStrictly less than.
$lteLess than or equal to.
$inMatches any value in the list. An empty list matches nothing.
$betweenInclusive range: [low, high].

FilterValue

type FilterValue =
	| JsonPrimitive
	| Date
	| Identifier
	| Array<JsonPrimitive | Date | Identifier>
	| FilterComparison
	| { operator?: string; value?: JsonValue | Date | Identifier | Identifier[] };

Filters

type Filters = Record<string, FilterValue>;

ReadListPaging

type ReadListPaging = {
	limit: number;
	offset: number;
	total?: number | null;
};

ReadListEnvelope

type ReadListEnvelope<T = Record<string, unknown>> = {
	items: T[];
	paging?: ReadListPaging;
};

ScopeOptions

interface ScopeOptions {
	type?: ScopeType;
	allowTenantScope?: boolean;
	allowUserScope?: boolean;
	shared?: boolean;
}
PropertyDescription
typeWhich scope column the query is narrowed by: org, tenant, or user. The scope always follows the caller's own org and user: there is no pin to another org by id (SDK 3.0.0 refuses orgId / tenantId / userId here with ROW_FIELD_NOT_ACCEPTED).
allowTenantScopePermit tenant-scoped narrowing. Granted automatically to a caller holding a global role.
allowUserScopePermit user-scoped narrowing.
sharedRead across ALL org / tenant / user scopes (cross-tenant) instead of only the acting org — e.g. scope: { shared: true }. This is the ungated cross-org read power and is HIGH RISK: it requires the sdk:shared_read capability to save or publish, and flags the handler with the "Cross-org" badge.

ReadListOptions

interface ReadListOptions {
	filters?: Filters;
	columns?: string[];
	limit?: number;
	offset?: number;
	paginate?: boolean;
	sort?: string;
	orderBy?: string;
	defaultSort?: string;
	allowedSortColumns?: string[];
	includeTotal?: boolean;
	scope?: ScopeOptions;
	after?: string | null;
}
PropertyDescription
filtersWhich rows to match. Equality, null, $and/$or, and the comparison operators.
columnsReturn ONLY these columns (validated identifiers) instead of every column. Redaction still applies.
limitPage size.
offsetRows to skip. Always pair with a sort — without one, pages may overlap or skip rows.
paginateReturn the { items, paging } envelope instead of a bare array.
sort"column" or "column DESC". An unknown column is rejected, not ignored.
orderByAlias of sort, for callers shaped like the REST layer. sort wins if both are set.
defaultSortFallback ordering used only when neither sort nor orderBy was supplied.
allowedSortColumnsRestrict which columns may be sorted on. A sort outside the list throws rather than silently ordering by something else.
includeTotalAlso run the COUNT that fills paging.total. Costs a second query, so it is opt-in.
scopeNarrow (or, with shared, widen) which tenant's rows are visible.
afterreadPage only. Page in creation order with a cursor instead of an offset (keyset paging): null for the first page, then the previous page's nextCursor. A row added or removed between pages never shifts another onto a page twice or off them all, and a deep page costs what the first does: the way to walk a whole table. Takes no sort or offset (PAGE_CURSOR_INVALID). The cursor is opaque and sealed: it works only for the table it came from, and only as given.

ReadPageResult

interface ReadPageResult<T = Record<string, unknown>> {
	items: T[];
	limit: number | null;
	offset: number | null;
	paginated: boolean;
	total: number | null;
	nextCursor?: string | null;
}
PropertyDescription
itemsThe rows for this page.
limitPage size applied, or null when the call was not paginated.
offsetRows skipped, or null when the call was not paginated.
paginatedWhether a page was actually cut, as opposed to the whole result being returned.
totalTotal matching rows, or null when the total was not requested. With after: the rows from this page on.
nextCursorWith after: where the next page starts (pass it as after), or null after the last page. Absent on an offset page.

AggregateMeasure

One aggregate measure: an allow-listed op over an (optional) column, with an output alias.

interface AggregateMeasure {
	op: "count" | "count_distinct" | "sum" | "avg" | "min" | "max";
	column?: string;
	as?: string;
}
PropertyDescription
opThe aggregate to compute. Allow-listed — arbitrary SQL functions are not accepted.
columnRequired for every op except count. Must be a real, non-redacted column. A uid column (uid, customer_uid) takes only count_distinct; id is never measured.
asOutput key for this measure (safe charset; falls back to a derived default otherwise).

AggregateGroupBy

One group-by key: a column, optionally derived via a FIXED template (never model SQL).

interface AggregateGroupBy {
	column: string;
	truncate?: "year" | "quarter" | "month" | "week" | "day" | "hour" | "date";
	bucket?: number;
	lower?: boolean;
	as?: string;
}
PropertyDescription
columnThe column to group on. Validated against the table's real columns. Group by a reference under its <name>_uid name and the group key comes back as the referenced row's uid (never truncated, bucketed or lower-cased). id is never a group key.
truncateDate truncation bucket for a date/datetime column.
bucketNumeric range bucket width, e.g. 25 groups 0-24, 25-49, …
lowerCase-fold the group key (LOWER(col)).
asOutput key for this group column (defaults to the column name).

AggregateSpec

interface AggregateSpec {
	filters?: Filters;
	groupBy?: AggregateGroupBy[];
	measures?: AggregateMeasure[];
	having?: Record<string, number | { $gt?: number; $gte?: number; $lt?: number; $lte?: number; $eq?: number; $ne?: number }>;
	sort?: string;
	limit?: number;
}
PropertyDescription
filtersReuses the read filter DSL (equality, IS NULL, $and/$or, and the comparison operators).
groupByGroup keys, outermost first. Omit for a single total row over the whole filtered set.
measuresWhat to compute per group. Omit for a plain row count.
havingThreshold on a measure alias, e.g. { revenue: { $gt: 1000 } }.
sort"<group-or-measure-name> ASC|DESC".
limitMax groups to return (clamped to MAX_GROUPS).

AggregateResult

interface AggregateResult<T = Record<string, unknown>> {
	rows: T[];
	meta: {
		groups: number;
		matchedGroups: number | null;
		hasMore: boolean;
		truncated: boolean;
		limit: number;
	};
}
PropertyDescription
rowsOne row per group, carrying the group keys and each measure under its output name.
metaHow complete the result is — read this before treating rows as the whole picture.

SummarizeColumn

One column's entry in a — shape varies by the column's kind.

interface SummarizeColumn {
	column: string;
	type: string;
	kind?: "numeric" | "date" | "categorical" | "reference" | "other";
	references?: string;
	redacted?: boolean;
	min?: unknown;
	max?: unknown;
	avg?: number | null;
	distinct?: number;
	earliest?: unknown;
	latest?: unknown;
	topValues?: Array<{ value: unknown; count: number }>;
	moreCategories?: boolean;
	note?: string;
}
PropertyDescription
columnColumn name.
typeThe column's SQL type, as declared.
kindWhich family of statistics the fields below carry. A reference is a <name>_uid column.
referencesReference: the table it points at.
redactedThe column is masked by redaction, so no values are profiled — only that it exists.
minNumeric: smallest value.
maxNumeric: largest value.
avgNumeric: mean, or null when there is nothing to average.
distinctNumeric: count of distinct values.
earliestDate: earliest value.
latestDate: latest value.
topValuesCategorical: the most common values with their counts.
moreCategoriesCategorical: more distinct values exist than topValues shows.
noteWhy this column was profiled thinly or not at all.

SummarizeResult

interface SummarizeResult {
	table: string;
	rowCount: number;
	columns: SummarizeColumn[];
}
PropertyDescription
tableThe table profiled.
rowCountRows in the table at the time of the call.
columnsOne entry per column, in the table's own column order.

CreateResult

interface CreateResult {
	uid: RowUid | null;
	affectedRows?: number;
}
PropertyDescription
uidThe new row's uid: the handle to store, link and pass around. Minted by the platform; a uid in the row you pass is refused. null only for a table with no uid column.
affectedRowsRows written — 1 for a successful single insert.

BulkCreateResult

interface BulkCreateResult {
	count: number;
	uids: Array<RowUid | null>;
	affectedRows: number;
}
PropertyDescription
countNumber of rows requested.
uidsThe new rows' uids, aligned to the input row order (null for a table with no uid column).
affectedRowsTotal rows inserted.

GenerateResult

interface GenerateResult {
	requested: number;
	generated: number;
	affectedRows: number;
	columns: string[];
	sample: Record<string, unknown> | null;
	capped?: true;
	perCallMax?: number;
	remaining?: number;
}
PropertyDescription
requestedRows asked for.
generatedRows actually generated + inserted (< requested only when the per-call cap clipped it).
affectedRowsTotal rows inserted across every batch.
columnsColumn names present on the generated rows.
sampleThe first generated row as written, with its uid, so a caller can eyeball the shape. null when nothing was generated.
cappedPresent only when the per-call cap clipped the request — call again for remaining.
perCallMaxThe per-call ceiling that clipped the request.
remainingRows still owed after the cap.

UpdateResult

interface UpdateResult {
	affectedRows?: number;
}
PropertyDescription
affectedRowsRows changed. 0 means the filter matched nothing — it is not an error.

DeleteResult

interface DeleteResult {
	affectedRows?: number;
}
PropertyDescription
affectedRowsRows removed, or soft-deleted when the table's strategy is "soft".

WithIdempotencyPersistOptions

interface WithIdempotencyPersistOptions {
	pick?: string[];
	omit?: string[];
}
PropertyDescription
pickStore ONLY these keys of the result. Everything else is dropped before it is saved.
omitStore the result except these keys — the way to keep a secret out of the replay.

WithIdempotencyOptions

interface WithIdempotencyOptions<TWorkResult = unknown> {
	key: string;
	route?: string;
	method?: string;
	body?: Record<string, unknown>;
	query?: Record<string, unknown>;
	persistResponse?:
		| ((result: TWorkResult) => unknown)
		| WithIdempotencyPersistOptions;
	ttlSeconds?: number;
	allowRetryOnFailure?: boolean;
}
PropertyDescription
keyThe client-supplied key that identifies this attempt. Same key = same outcome.
routeRoute recorded alongside the key, so the same key on a different route is a different operation.
methodHTTP method recorded with the key, for the same reason as route.
bodyRequest body fingerprinted with the key. A retry whose body differs is rejected rather than silently replayed.
queryQuery fingerprinted with the key, as body is.
persistResponseWhat to store for replay: a function returning the value to keep, or a pick/omit filter over the result. Leave unset to store nothing and only guard against a duplicate run.
ttlSecondsHow long the key stays reserved before an identical call may run again.
allowRetryOnFailureLet a retry re-run the work after a failure, instead of replaying the failure.

ApprovalRecord

interface ApprovalRecord {
	uid: string;
	key: string;
	status: "pending" | "approved" | "rejected" | string;
	requestType: string | null;
	reason: string | null;
	requestData: unknown;
	resumeData: unknown;
	decisionData: unknown;
	decisionNotes: string | null;
	expiresAt: string | null;
	decidedAt: string | null;
	consumedAt: string | null;
	createdAt: string | null;
	updatedAt: string | null;
	approved: boolean;
	rejected: boolean;
	pending: boolean;
	consumed: boolean;
}
PropertyDescription
uidThe approval's uid: its handle, stable for the life of the request (a key is reused once the request closes).
keyThe stable key this approval was created under — how a resumed run finds it again.
statusWhere the decision stands. Branch on the approved/rejected/pending booleans instead of string-matching.
requestTypeCaller-defined label for what kind of thing is being approved, for grouping in the inbox.
reasonThe human-readable justification shown to whoever decides.
requestDataWhat was asked for — the payload an approver reviews before deciding.
resumeDataState the handler stashed so it can pick up exactly where it paused.
decisionDataAnything the approver attached to the decision. Who raised and who decided are not carried (SDK 3.0.0): they are internal principal ids. The decider is always the caller who decided, and never the principal that raised the request.
decisionNotesFree-text note the approver left with the decision.
expiresAtWhen the request stops being actionable, if it was given a deadline.
decidedAtWhen the decision was made.
consumedAtWhen the request closed: its approval used by the one gate pass it authorizes, or its rejection delivered to the next pass.
createdAtWhen the request was raised.
updatedAtWhen the row last changed.
approvedDecided, and the answer was yes.
rejectedDecided, and the answer was no.
pendingStill waiting on a human.
consumedThe request is closed — approval used, or rejection delivered — and the next call under its key raises a new one.

AwaitApprovalOptions

interface AwaitApprovalOptions {
	key?: string;
	requestType?: string;
	reason?: string;
	requestData?: unknown;
	data?: unknown;
	resumeData?: Record<string, unknown>;
	expiresInSeconds?: number;
	waitForMs?: number;
	pollIntervalMs?: number;
	throwOnReject?: boolean;
}
PropertyDescription
keyStable identifier for this gate. Reuse it to resume the same approval rather than raising a second one. Once an approval under this key has been used, the next call raises a new request.
requestTypeCaller-defined label for what kind of approval this is, used to group the inbox.
reasonWhy this needs a person. Shown to the approver, so write it for them.
requestDataThe payload under review. Show the actual arguments — an approver should not have to trust a summary.
dataAlias of requestData.
resumeDataState to hand back to the handler when the run resumes, so it continues instead of restarting.
expiresInSecondsHow long the request stays actionable before it expires.
waitForMsHow long to hold this call open waiting for a decision. On timeout the approval stays pending and the run pauses.
pollIntervalMsHow often to re-check for a decision while waiting.
throwOnRejectThrow on rejection instead of returning the rejected record for the handler to deal with.

ApprovalDecisionOptions

interface ApprovalDecisionOptions {
	key?: string;
	uid?: string;
	decisionData?: unknown;
	decisionNotes?: string;
}
PropertyDescription
keyIdentify the approval by its key (the OPEN request under it). Supply this or uid.
uidIdentify the approval by its uid (ApprovalRecord.uid). Supply this or key.
decisionDataStructured payload to record with the decision.
decisionNotesFree-text note to record with the decision.

EmailMessage

An email, sent as the app (sdk.sendEmail). Give the content as STRUCTURED fields, laid out in the app's branding (App settings › Branding), or as raw html / text, sent as written. Not both.

Any text field (subject included) may name values from data by placeholder:

  • {key} is filled in the email and kept in the app's email log;
  • {{key}} is filled in the email but REDACTED (•••) in the log: use it for codes, tokens, anything secret;
  • \{ is a literal brace. A key may be a path ({order.number}). A placeholder naming no key in data refuses the send (EMAIL_PLACEHOLDER_MISSING). Pass secrets through data: one written straight into the text cannot be told apart, and is logged.
interface EmailMessage {
	to: string | string[];
	cc?: string | string[];
	bcc?: string | string[];
	subject: string;
	from?: string;
	fromName?: string;
	replyTo?: string;

	heading?: string;
	body?: string | string[];
	action?: { label: string; url: string };
	code?: string;
	footnotes?: string | string[];
	preheader?: string;

	html?: string;
	text?: string;

	data?: Record<string, unknown>;
}
PropertyDescription
toRecipient address, or several (at most 50 recipients counting cc and bcc).
subjectSubject line. Placeholders allowed.
fromSender address. Omitted: the connection's default sender. Must be one of the senders the connection may send as (EMAIL_FROM_NOT_ALLOWED otherwise).
fromNameThe sender's display name. Omitted: the app's branding name, else the app's name.
replyToWhere replies go. Omitted: the branding's reply-to, else the connection's.
headingThe big line at the top. Defaults to the subject.
bodyParagraphs of plain text (escaped for you).
actionA button. url must be http(s) or mailto.
codeA one-time code, shown large instead of a button.
footnotesSmall print under a rule.
preheaderThe inbox preview line. Defaults to the heading.
dataValues for the {key} / {{key}} placeholders.

SendEmailResult

What sdk.sendEmail returns.

interface SendEmailResult {
	uid: string;
	status: "sent" | "sending" | "failed";
	duplicate: boolean;
	messageId: string | null;
	from: string;
	to: string[];
	alias: string;
	redacted: boolean;
}
PropertyDescription
uidThe message in the app's email log (App › Email).
duplicateTrue when a retried job item sent this already (the same item, connection, recipients, sender and subject): nothing was sent this time.
messageIdThe provider's message id, when it gave one.
aliasThe connection's alias in this app.
redactedTrue when {{key}} values were masked in the logged copy.

AppBranding

The app's branding, as sdk.app.branding() returns it. Every field may be null.

interface AppBranding {
	displayName: string | null;
	logoUrl: string | null;
	logoDarkUrl: string | null;
	iconUrl: string | null;
	colors: {
		light: { primary: string | null; accent: string | null; background: string | null };
		dark: { primary: string | null; accent: string | null; background: string | null };
	};
	footer: {
		company: string | null;
		address: string | null;
		supportEmail: string | null;
		website: string | null;
		social: { label: string; url: string }[];
	};
	email: { replyTo: string | null; footerNote: string | null };
}
PropertyDescription
logoUrlPublic URLs of the logo, its dark-mode variant and the square icon.
colors#rrggbb colors, for light and dark mode.

ImportCsvOptions

interface ImportCsvOptions {
	table: string;
	csv?: string;
	csvBase64?: string;
	mapping?: Record<string, string>;
	defaults?: Record<string, unknown>;
	batchSize?: number;
	maxBytes?: number;
	maxRows?: number;
	dryRun?: boolean;
	emptyAsNull?: boolean;
	ignoreUnknownColumns?: boolean;
	onError?: "abort" | "skip";
	scope?: ScopeOptions;
	encoding?: string;
}
PropertyDescription
tableDestination table.
csvThe CSV itself, as text. Supply this or csvBase64.
csvBase64The CSV base64-encoded, for content that does not survive being passed as text.
mappingRename incoming headers to real columns: { "Email Address": "email" }.
defaultsValues written to every row for columns the CSV does not carry. This is the answer to a column the table REQUIRES (NOT NULL, no default) that no header maps onto — without one the import is refused, because there is no row it could succeed for. Each value is coerced and checked against its column exactly like a cell from the file.
batchSizeRows per insert batch.
maxBytesReject the import if the payload exceeds this many bytes.
maxRowsReject the import if it carries more than this many rows.
dryRunParse and validate everything, report what WOULD happen, write nothing.
emptyAsNullTreat an empty cell as NULL rather than an empty string.
ignoreUnknownColumnsSkip CSV columns with no matching table column instead of failing the import.
onErrorWhat to do with a row holding a value the table cannot store (bad enum member, over-long string, non-numeric number, out-of-range integer). - "abort" (default) — import nothing and throw with the offending rows listed. - "skip" — import the clean rows and report the rest in skippedRows / errors. A bad value is never silently repaired or replaced under either setting. Neither setting rescues a required column with nothing mapped onto it: that fails every row identically, so it is refused outright rather than skipped row by row.
scopeNarrow (or, with shared, widen) the tenant scope stamped on every imported row.
encodingCharacter encoding of the incoming CSV.

ReadOptions

interface ReadOptions {
	idColumn?: "uid";
	scope?: ScopeOptions;
}
PropertyDescription
idColumnRows are addressed by uid only (SDK 3.0.0): any other value is refused. To find a row by another column, use with a filter.
scopeNarrow (or, with shared, widen) which tenant's rows are visible.

UpdateOptions

interface UpdateOptions extends ReadOptions {}

DeleteOptions

interface DeleteOptions extends ReadOptions {
	deleteStrategy?: DeleteStrategy;
}
PropertyDescription
deleteStrategyOverride the table's configured strategy for this call: remove the row, or mark it deleted.

CreateOptions

interface CreateOptions {
	scope?: ScopeOptions;
}
PropertyDescription
scopeWhich tenant the new row is attributed to. Defaults to the acting scope.

BulkCreateOptions

interface BulkCreateOptions extends CreateOptions {
	chunkSize?: number;
}
PropertyDescription
chunkSizeMax rows per INSERT statement (batches larger than this are split). Defaults to 500; clamped to 1..1000. Lower it for very wide rows; it never caps the total row count.

GenerateColumnSpec

One column's generation recipe. kind picks the generator; the remaining keys are that kind's options. Omit a column entirely and its kind is inferred from the live schema (enum → a real member, varchar → text clamped to length, int → a number, date → a recent date), so a bare generateRecords(table, 100) still produces valid rows.

interface GenerateColumnSpec {
	kind?:
		| "oneOf"
		| "weighted"
		| "int"
		| "float"
		| "bool"
		| "date"
		| "datetime"
		| "name"
		| "firstName"
		| "lastName"
		| "email"
		| "phone"
		| "company"
		| "city"
		| "country"
		| "url"
		| "uuid"
		| "title"
		| "sentence"
		| "paragraph"
		| "words"
		| "streetAddress"
		| "template"
		| "sequence"
		| "sample"
		| "constant"
		| "enum"
		| "set"
		| "json";
	values?: unknown[] | Record<string, number>;
	min?: number;
	max?: number;
	precision?: number;
	trueRate?: number;
	within?: string;
	from?: string | number | Date;
	to?: string | number | Date;
	template?: string;
	start?: number;
	step?: number;
	pad?: number;
	prefix?: string;
	suffix?: string;
	count?: number;
	value?: unknown;
	nullRate?: number;
}
PropertyDescription
kindWhich generator to use. Omit to infer one from the column's real type in the live schema.
valuesoneOf / sample: the pool to draw from. weighted: { value: weight }.
minint / float / sample / sentence / paragraph: lower bound.
maxint / float / sample / sentence / paragraph: upper bound.
precisionfloat: decimal places.
trueRatebool: probability of true (0..1).
withindate / datetime: a relative window like "90d", counted back from now.
fromdate / datetime: explicit range start, as an alternative to within.
todate / datetime: explicit range end.
templatetemplate: "Order {i}" — {i} is the row number, {col} another column in the same row.
startsequence: the first number in the run.
stepsequence: how much each row advances the counter.
padsequence: zero-pad the number to this width.
prefixsequence: text placed before the number.
suffixsequence: text placed after the number.
countwords: how many words.
valueconstant: the fixed value to write.
nullRateFraction of rows (0..1) that get NULL instead of a generated value.

GenerateSpec

Column name → its generation recipe (or a bare kind string, e.g. { status: "oneOf" }).

type GenerateSpec = Record<string, GenerateColumnSpec | string>;

GenerateOptions

interface GenerateOptions extends BulkCreateOptions {
	seed?: number;
	batchSize?: number;
}
PropertyDescription
seedSeed the PRNG for reproducible output. Omit for varied data.
batchSizeRows generated + inserted per slice, so memory stays bounded for very large counts. Defaults to 2000; clamped to 1..5000. Independent of chunkSize (rows per INSERT).

BulkUpdateOptions

interface BulkUpdateOptions {
	scope?: ScopeOptions;
	all?: boolean;
}
PropertyDescription
scopeNarrow which tenant's rows the statement may touch.
allRequired to run with NO filter. Without it an unfiltered call is rejected (BULK_WRITE_UNFILTERED, 400) so a whole table is never rewritten by accident.

BulkDeleteOptions

interface BulkDeleteOptions extends BulkUpdateOptions {
	deleteStrategy?: DeleteStrategy;
}
PropertyDescription
deleteStrategy"soft" (default) sets deleted_at when the table has that column; "hard" — or any table without deleted_at — issues a real DELETE. Note this is the OPPOSITE default from , which is hard by default.

SqlIdentifier

A validated dynamic identifier (table/column name) produced by sdk.sql.id(name).

interface SqlIdentifier {
	readonly name: string;
}
PropertyDescription
nameThe validated identifier text, safe to splice into SQL because it was checked, not escaped.

SqlFragment

A composable SQL fragment produced by the sdk.sql`` tagged template. Await it to RUN the query (it resolves to the result rows); or interpolate it into another sdk.sql`` to compose (e.g. build a conditional WHERE). Carries the compiled text + bound values.

interface SqlFragment<TRow = Record<string, unknown>> extends PromiseLike<TRow[]> {
	readonly text: string;
	readonly values: unknown[];
}
PropertyDescription
textThe compiled SQL, with every interpolation replaced by a ? placeholder.
valuesThe values bound to those placeholders, in order.

SqlInterpolation

Values you may interpolate into a sdk.sql`` template (everything is bound as a ?` param).

type SqlInterpolation =
	| JsonPrimitive
	| Date
	| Identifier
	| Array<JsonPrimitive | Date | Identifier>
	| SqlIdentifier
	| SqlFragment;

McpResult

What a tool call returns. The payload is capped and marked untrusted on the way out: it is foreign DATA, never instructions — never let it decide what your handler does next. A tool that reports failure throws instead of returning this.

interface McpResult {
	source: { mcp: string; tool: string };
	untrusted: true;
	note: string;
	text: string;
	structured?: Record<string, unknown>;
	attachments?: Array<Record<string, unknown>>;
	truncated?: {
		keptBytes: number;
		maxBytes: number;
		structuredDropped: boolean;
	};
}
PropertyDescription
sourceWhich server + tool produced this.
untrustedAlways true. The marking travels with the payload so it can't be lost downstream.
noteThe rider spelling out that this is third-party data, not instructions.
textText blocks from the server, joined. Truncated with an explicit marker if oversized. ALWAYS PRESENT — this is the field to read. Most servers answer with JSON as text, so the usual shape is JSON.parse(result.text). Read structured only as an optimisation, and never as your only path: it is absent far more often than it is present (see below).
structuredThe server's structuredContent, when it returned one (and it fit the cap). OPTIONAL, AND USUALLY ABSENT. structuredContent is an opt-in part of the MCP response and many production servers never send it — X's does not, for one. A handler written as result.structured.data therefore reads undefined on the first real call while looking perfectly correct, and a dry run cannot catch it because external calls are stubbed. Do this instead: const payload = result.structured ?? JSON.parse(result.text);
attachmentsNon-text blocks summarized by type — binary is never inlined.
truncatedPresent only when the payload was cut: what was kept, the cap, and whether structured was dropped.

AiEmbedResult

interface AiEmbedResult {
	table: string;
	embedded: number;
	skipped: number;
	model?: string | null;
	dimensions?: number;
	testMode?: boolean;
}
PropertyDescription
skippedRows with no usable text, or that the provider returned no vector for.

AiSimilarResult

interface AiSimilarResult {
	table: string;
	matches: Array<{ uid: RowUid | null; score: number }>;
	scanned?: number;
	testMode?: boolean;
}
PropertyDescription
matchesBest first. score is cosine similarity in [-1, 1].
scannedEmbedded rows compared — the honest size of the scan.

AiRedactResult

What one redact pass did.

interface AiRedactResult {
	column: string;
	into: string;
	examined: number;
	rewritten: number;
	unmapped?: number;
	model?: string;
	testMode?: boolean;
}
PropertyDescription
intoWhere the masked text was written — equal to column for an in-place run.
rewrittenRows actually rewritten. Rows the model skipped keep their original text.

AiAnnotateResult

What one annotate pass did. Every field is a count, so it reports honestly.

interface AiAnnotateResult {
	column: string;
	examined: number;
	enriched: number;
	settledEmpty: number;
	skippedOverride: number;
	fresh: number;
	testMode?: boolean;
}
PropertyDescription
examinedRows looked at.
enrichedRows the model labeled and that were written.
settledEmptyRows with no source content — settled to null without spending tokens.
skippedOverrideRows a human owns. Never touched.
freshRows already up to date.
testModePresent only in a handler dry run, where no provider call was made.

AiTier

The kind of model a sdk.ai call runs on. "fast" is low-cost and the default; "default" is your organization's more capable model. The models behind both are chosen on the AI connection.

type AiTier = "fast" | "default";

AiCommonOptions

Options shared by every sdk.ai verb.

interface AiCommonOptions {
	filters?: Record<string, unknown>;
	columns?: string[];
	sampleSize?: number;
	tier?: AiTier;
}
PropertyDescription
filtersNarrow the rows analyzed — same filter DSL as .
columnsWhich columns the model may read. Defaults to all non-redacted, non-audit columns.
sampleSizeRows to analyze. Default 100, hard cap 500.
tierWhich kind of model answers: "fast" (the default — low-cost, right for most work) or "default" (your organization's more capable model). You never name a model: which model each tier uses is set once on your organization's AI connection, so every handler keeps working when that choice changes. Passing model is refused with AI_TIER_INVALID.

AiResultBase

Fields every sdk.ai verb returns.

interface AiResultBase {
	mode: "digest" | "classify" | "extract" | "filter";
	analyzed: number;
	matched: number;
	sampled: boolean;
	model?: string;
	testMode?: boolean;
}
PropertyDescription
analyzedRows actually analyzed by the model.
matchedRows the read matched in total — compare with analyzed to see what was left out.
sampledTrue when matched exceeded the sample: the answer describes a sample, not the table.
modelThe model that produced this answer.
testModePresent only in a handler dry run, where no provider call was made.

AiDigestResult

interface AiDigestResult extends AiResultBase {
	text: string;
	points: string[];
	batches: number;
}
PropertyDescription
textThe points joined as a bulleted block, ready to show.

AiClassifyResult

interface AiClassifyResult extends AiResultBase {
	byLabel: Array<{ label: string; count: number }>;
	perRow: Array<{ uid: RowUid; label: string }>;
	unmapped: number;
	batches: number;
}
PropertyDescription
byLabelCompact count table, highest first.
perRowPer-row assignments, in the order the rows were read.
unmappedRows the model returned no usable answer for (a failed batch never sinks the run).

AiExtractResult

interface AiExtractResult extends AiResultBase {
	rows: Array<{ uid: RowUid; fields: Record<string, string | null> }>;
	unmapped: number;
	batches: number;
}

AiFilterResult

interface AiFilterResult extends AiResultBase {
	uids: RowUid[];
	unmapped: number;
	batches: number;
}
PropertyDescription
uidsUids of the rows that satisfied the predicate.

StorageFile

A stored file's metadata row. Named by uid, like everything it references.

interface StorageFile {
	uid: string;
	name: string;
	folder_uid: string | null;
	storage_uid: string | null;
	storage_key: string;
	storage_driver: string;
	bucket: string | null;
	mime_type: string | null;
	size: number | null;
	owner_uid: string | null;
	metadata: Record<string, unknown> | null;
	created_at: string;
	updated_at: string;
	sharing?: StorageSharing | null;
	sharedByOwner?: true;
}
PropertyDescription
uidThe file's handle: every other storage method names a file by this.
nameFile name within its folder. Duplicates are auto-deduped (a.pdf → a (1).pdf).
folder_uidContaining folder's uid, or null at the storage root.
storage_keyPhysical object key. Stable across renames and moves — those only touch metadata.
owner_uidThe END USER this file belongs to, in your app's own user space. Distinct from created_by (whoever performed the write — an admin, a handler, an agent). Set it on upload (ownerUid) so ownership can be enforced by policy.
metadataYour own data travelling with the file (alt text, caption, status …).
sharingPresent only on the app owner's own items (no org), and only to the owner's side (the owner's agents and jobs): which of the app's organizations see this item, and whether that is its own setting or inherited from a folder above it.
sharedByOwnertrue on the app owner's items that were shared with the caller's org. They can be read and downloaded, never renamed, moved, changed or deleted (STORAGE_SHARED_READ_ONLY).

StorageFolder

A folder node. path is materialized (e.g. /invoices/2026).

interface StorageFolder {
	uid: string;
	name: string;
	parent_uid: string | null;
	path: string;
	depth: number;
	storage_uid: string | null;
	sharing?: StorageSharing | null;
	sharedByOwner?: true;
}
PropertyDescription
uidThe folder's handle (folderUid / parentUid elsewhere).
parent_uidParent folder's uid, or null directly under the storage root.
sharingPresent only on the app owner's own items (no org), and only to the owner's side (the owner's agents and jobs): which of the app's organizations see this item, and whether that is its own setting or inherited from a folder above it.
sharedByOwnertrue on the app owner's items that were shared with the caller's org. They can be read and downloaded, never renamed, moved, changed or deleted (STORAGE_SHARED_READ_ONLY).

StorageSharing

Which of the app's organizations see one of the owner's folders or files. Only me (only_me, the default), every organization including later ones (all), or the listed ones (orgs). An item without its own setting takes its nearest folder's; an organization's own uploads are never shared by it. Set from the console.

interface StorageSharing {
	mode: "only_me" | "all" | "orgs";
	orgs: { uid: string; name: string }[];
	inherited: boolean;
	from: { uid: string; name: string; path: string } | null;
}
PropertyDescription
fromThe folder an inherited setting comes from; null when it is the item's own, or the default.

StorageCrumb

One step of . The root crumb's uid is null.

interface StorageCrumb {
	uid: string | null;
	name: string;
	path: string;
	depth: number;
}

StorageUrl

What returns.

interface StorageUrl {
	url: string;
	kind: "public" | "signed";
	expiresAt: string | null;
}
PropertyDescription
urlUse directly as an <a href>, <img src>, or fetch target.
kind"public" — permanent and cacheable (public storages). "signed" — expires; see expiresAt.
expiresAtISO timestamp when a signed URL dies, or null when it never does.

StorageUploadInput

interface StorageUploadInput {
	name: string;
	body: Buffer | Uint8Array | string;
	encoding?: "utf8" | "base64";
	contentType?: string;
	folderUid?: string | null;
	path?: string;
	ownerUid?: string | null;
	metadata?: Record<string, unknown>;
}
PropertyDescription
nameFile name (one path segment — no /).
bodyBytes. A string is decoded per encoding; data: URLs are understood.
encodingHow to read a string body. Default "utf8"; use "base64" for binary.
folderUidDestination folder's uid. Takes precedence over path when both are given.
pathDestination path (/a/b/c). Missing folders are created — see ensureFolder.
ownerUidUid of the end user (a users row) who owns the file.

CronJob

One scheduled job. principalUserUid is the identity it runs as — never its creator.

interface CronJob {
	uid: string;
	name: string;
	description: string | null;
	status: "active" | "paused";
	targetKind: string;
	targetHandlerUid: string | null;
	handlerName: string | null;
	payload: Record<string, unknown>;
	scheduleKind: "interval" | "cron";
	intervalSeconds: number | null;
	cronExpression: string | null;
	timezone: string;
	timeoutSeconds: number;
	nextRunAt: string | null;
	lastRunAt: string | null;
	lastStatus: string | null;
	lastRunUid: string | null;
	consecutiveFailures: number;
	principalUserUid: string | null;
	orgUid: string | null;
	orgName: string | null;
	firing: {
		state: "running" | "retrying" | "stopped";
		windowStart: string | null;
		runUid: string | null;
		turns: number;
		attempts: number;
		doneCount: number | null;
		failedCount: number | null;
		lastError: string | null;
	} | null;
	createdAt: string | null;
	updatedAt: string | null;
}
PropertyDescription
uidThe job's handle: every other crons method names a job by this.
statusactive = eligible to fire. paused = held, schedule and history intact.
targetHandlerUidThe uid of the handler it runs (the uid the handler registry gives it).
payloadThe fixed request body handed to the handler on every run.
nextRunAtnull while paused — a paused job holds no claim on the scheduler.
lastRunUidPass to .
consecutiveFailuresA counter, never an auto-disable — a broken job is surfaced, not silently switched off.
principalUserUidThe job's own principal. What it may do is the roles bound to THIS user.
orgUidThe app org the job belongs to, by uid and name. Null for the app owner's own jobs.
firingThe occurrence this job has unfinished, or null. A firing is carried over TURNS (a page each) until it completes. running: it carries on by itself. retrying: a turn failed and is retried shortly from the same place. stopped: it failed after its retries (or was paused part-way); runNow (Resume), or its next scheduled time, carries on from where it stopped.

CronRun

One attempt to run one job — including the attempts that were refused.

interface CronRun {
	uid: string;
	cronUid: string | null;
	cronName: string | null;
	triggerSource: string;
	principalUserUid: string | null;
	principalRoles: string[];
	handlerName: string | null;
	handlerVersionUid: string | null;
	payload: Record<string, unknown>;
	scheduledFor: string | null;
	startedAt: string | null;
	finishedAt: string | null;
	durationMs: number | null;
	status: "running" | "succeeded" | "partial" | "failed" | "stopped" | "denied" | "skipped";
	httpStatus: number | null;
	result?: unknown;
	errorText: string | null;
	missedWindows: number;
	runner: string | null;
	windowStart: string | null;
	turns: number;
	attempts: number;
	doneCount: number;
	failedCount: number;
	failures?: Array<{ uid: string | null; message: string; code?: string }>;
	orgUid: string | null;
	orgName: string | null;
}
PropertyDescription
uidThe run's handle: pass to .
cronUidThe job it belongs to. Still set after that job is deleted: run history is kept.
triggerSourceschedule, manual (Run now) or resume (Run now carrying on a stopped firing). A person's run never moves the schedule's health (last status, failure count).
principalRolesThe roles the principal held AT THE MOMENT IT RAN — not what it holds now.
handlerVersionUidThe handler version that executed (its uid).
scheduledForWhat the schedule said, as opposed to when the sweep got to it.
statuspartial: every item was visited and some failed (listed in failures). stopped: the job was paused with work left; Resume carries on. failed: it failed after its retries; Resume carries on from the last page that succeeded. running is a LEASED state; a run abandoned by a dead process is carried on by the next turn, never lost.
missedWindowsScheduled instants passed over to get here. Skipped, never replayed.
windowStartThe occurrence this run belongs to. Runs of one firing (stopped, then resumed) share it.
turnsTurns taken (a page each), and failed turns retried.
doneCountItems done and failed: "197 done, 3 failed".
failuresThe first 100 failed items (getRun only): the row's uid and what went wrong.
orgUidThe app org the run belongs to, by uid and name. Null for the app owner's own.

CronScheduleInput

interface CronScheduleInput {
	scheduleKind: "interval" | "cron";
	intervalSeconds?: number | null;
	cronExpression?: string | null;
	timezone?: string;
}
PropertyDescription
intervalSecondsMinimum 60 — the scheduler tick bounds resolution, so shorter is REFUSED, not rounded.
cronExpressionFive fields: minute hour day-of-month month day-of-week. Macros like @daily work too.
timezoneIANA name. 0 2 * * * then means 2am THERE, correct across daylight saving.

StripeProductSummary

interface StripeProductSummary {
	id: string;
	name: string;
	description: string | null;
	active: boolean;
	appUid: string | null;
	defaultPriceId: string | null;
	prices: Array<{
		id: string;
		unitAmount: number | null;
		currency: string | null;
		interval: string | null;
		recurring: boolean;
		active: boolean;
	}>;
}
PropertyDescription
idStripe's product id.
nameProduct name as it appears in Stripe.
descriptionProduct description, when one was set.
activeWhether the product is currently sellable.
appUidThe app this product is tagged to, from its Stripe metadata.app_uid.
defaultPriceIdThe price used when a checkout does not name one.
pricesEvery price attached to this product.

HandlerJob

ctx.job: a scheduled run, told about itself.

interface HandlerJob {
	cronUid: string | null;
	runUid: string | null;
	windowStart: string | null;
	position: unknown;
	attempt: number;
	each<T = Record<string, unknown>>(
		table: string,
		filters: Filters,
		fn: (row: T) => unknown | Promise<unknown>,
		options?: { pageSize?: number; columns?: string[] },
	): Promise<HandlerJobEachResult>;
	next(position: unknown): unknown;
}
PropertyDescription
cronUidThe job's uid. null when its owner is trying the handler from the console.
runUidThis run's uid. null when its owner is trying the handler from the console.
windowStartWhich firing this is: the time it was scheduled for, or when a Run now started (ISO 8601). Use it in idempotency keys, so a retried firing repeats nothing: ${ctx.job.windowStart}:${row.uid}.
positionWhere a hand-written loop stopped: the value it last gave next(). null at the beginning. (each keeps its own place; this is only for loops it cannot express.)
attemptFailed turns of this firing so far: 0 on a first try, so a retried turn can be told apart.
eachTHE way to write a job that loops: say which rows, and what to do with one. The platform pages through the matching rows within the job's own reach, in creation order, and calls fn once per row. ONE PAGE A TURN: when the page is done and rows remain, the run ends there, the platform saves where it stopped, and runs the handler again FROM THE TOP to carry on, interleaved with every other app's work. So code before each runs on every turn (keep it cheap, with no side effects) and code after it runs once, after the last row. A crash or a deploy costs at most the page in hand: a failed turn is retried from the same place after 1, 5 and 15 minutes, then the job stops for Resume, and the owner is told. A row whose fn throws (or runs past 60 seconds) is recorded and skipped, and the rest carry on, so one bad row (a bad address) never stops the job: it ends partial. Every email or Stripe call fn makes is keyed to its row, so a retried page never sends one twice. Several loops in one job each keep their place.
nextThe lower-level shape, for a loop each cannot express: return ctx.job.next(position) ends this turn, and the handler runs again with ctx.job.position set to position (any JSON-serializable value). Return without it when the work is finished.

HandlerJobEachResult

What ctx.job.each returns.

interface HandlerJobEachResult {
	done: number;
	failed: number;
	pages: number;
	failures: Array<{ uid: string | null; message: string; code?: string }>;
	failuresTruncated: boolean;
	skipped?: boolean;
}
PropertyDescription
doneRows whose fn finished.
failedRows whose fn threw.
pagesPages read.
failuresThe first 100 failures: the row's uid and what went wrong.
failuresTruncatedTrue when more rows failed than failures lists.
skippedTrue when an earlier turn already finished this loop (a job with several).

On this page

Capabilities in one paragraphReading datasdk.readsdk.readListALWAYS NORMALIZE THE RESULTsdk.readPageAnalyzing datasdk.aggregatesdk.summarizeWriting datasdk.createsdk.createManysdk.updatesdk.updateWhereDeleting datasdk.deletesdk.deleteWhereBulk and synthetic datasdk.importCsvsdk.generateRecordsRaw SQLsdk.sqlsdk.sqlsdk.sql.idsdk.sql.joinsdk.queryTransactions and idempotencysdk.withTransactionsdk.transactionsdk.withIdempotencysdk.idempotentHuman approvalssdk.awaitApprovalsdk.getApprovalStatussdk.approveApprovalsdk.rejectApprovalExternal servicessdk.sendEmailsdk.stripesdk.stripe.clientsdk.stripe.connectionsdk.stripe.listProductssdk.stripe.createProductsdk.stripe.createCheckoutsdk.mcpsdk.mcp.connectionssdk.mcp.toolssdk.mcp.<alias>sdk.storagesdk.storage.listStoragessdk.storage.listsdk.storage.getsdk.storage.searchsdk.storage.breadcrumbsdk.storage.urlsdk.storage.downloadsdk.storage.uploadsdk.storage.uploadFromUrlsdk.storage.presignUploadsdk.storage.confirmUploadsdk.storage.createFoldersdk.storage.ensureFoldersdk.storage.copysdk.storage.movesdk.storage.renamesdk.storage.updatesdk.storage.deletesdk.cronssdk.crons.listsdk.crons.getsdk.crons.listRunssdk.crons.getRunsdk.crons.listRolessdk.crons.previewsdk.crons.createsdk.crons.updatesdk.crons.resumesdk.crons.pausesdk.crons.removesdk.crons.runNowsdk.rolessdk.roles.assignsdk.roles.unassignsdk.roles.listsdk.aisdk.ai.digestsdk.ai.classifysdk.ai.extractsdk.ai.filtersdk.ai.annotatesdk.ai.redactsdk.ai.embedsdk.ai.similarUtilitiessdk.hashPasswordsdk.appsdk.app.brandingThe handler context (ctx)ctx.sdkctx.reqctx.resctx.reqQueryctx.reqParamsctx.appConfigctx.userctx.userContextctx.requestIdctx.identityTypectx.handlerDelegationctx.audiencectx.jobTypesJsonPrimitiveJsonValueJsonObjectSortDirectionDeleteStrategyScopeTypeIdentifierRowUidComparableValueFilterComparisonFilterValueFiltersReadListPagingReadListEnvelopeScopeOptionsReadListOptionsReadPageResultAggregateMeasureAggregateGroupByAggregateSpecAggregateResultSummarizeColumnSummarizeResultCreateResultBulkCreateResultGenerateResultUpdateResultDeleteResultWithIdempotencyPersistOptionsWithIdempotencyOptionsApprovalRecordAwaitApprovalOptionsApprovalDecisionOptionsEmailMessageSendEmailResultAppBrandingImportCsvOptionsReadOptionsUpdateOptionsDeleteOptionsCreateOptionsBulkCreateOptionsGenerateColumnSpecGenerateSpecGenerateOptionsBulkUpdateOptionsBulkDeleteOptionsSqlIdentifierSqlFragmentSqlInterpolationMcpResultAiEmbedResultAiSimilarResultAiRedactResultAiAnnotateResultAiTierAiCommonOptionsAiResultBaseAiDigestResultAiClassifyResultAiExtractResultAiFilterResultStorageFileStorageFolderStorageSharingStorageCrumbStorageUrlStorageUploadInputCronJobCronRunCronScheduleInputStripeProductSummaryHandlerJobHandlerJobEachResult