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>| Parameter | Description |
|---|---|
table | Table name. |
uid | The row's uid. |
options | Restrict 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>>| Parameter | Description |
|---|---|
table | Table name, e.g. "orders". |
options | Filters, 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>>| Parameter | Description |
|---|---|
table | Table name. |
options | Filters, 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>>| Parameter | Description |
|---|---|
table | Table name. |
spec | The 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>| Parameter | Description |
|---|---|
table | Table name. |
options | topN 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>| Parameter | Description |
|---|---|
table | Table name. |
data | Column → value object to insert. |
options | Narrow 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>| Parameter | Description |
|---|---|
table | Table name. |
rows | Array of column → value objects to insert. |
options | Write 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>| Parameter | Description |
|---|---|
table | Table name. |
uid | The row's uid. |
data | Column → new value object (a reference by uid, as <name>_uid). |
options | Restrict 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>| Parameter | Description |
|---|---|
table | Table name. |
filters | Filter object (same DSL as ) selecting the rows to update. |
changes | Column → new value object applied to every matching row. |
options | all 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>| Parameter | Description |
|---|---|
table | Table name. |
uid | The row's uid. |
options | deleteStrategy ("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>| Parameter | Description |
|---|---|
table | Table name. |
filters | Filter object (same DSL as ) selecting the rows to delete. |
options | deleteStrategy, 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>| Parameter | Description |
|---|---|
options | Source (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>| Parameter | Description |
|---|---|
table | Table name. |
count | How many rows to generate. Clipped by a per-call ceiling; the result reports capped + remaining so you can loop rather than be silently truncated. |
spec | Column → generation recipe. Omitted columns are inferred from the schema. |
options | seed 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): SqlIdentifiersdk.sql.join
Stitch a list of fragments/values together with a separator (default ", ").
join(items: SqlInterpolation[], separator?: string): SqlFragmentsdk.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[]>| Parameter | Description |
|---|---|
sql | A STATIC SQL string literal with ? placeholders. |
params | Values 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>| Parameter | Description |
|---|---|
work | Async 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>| Parameter | Description |
|---|---|
options | Idempotency key (required), plus route/method/ttlSeconds. |
work | Async 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]: anysdk.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>| Parameter | Description |
|---|---|
options.into | the column to write into — an ordinary column you created normally. |
options.from | the 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>| Parameter | Description |
|---|---|
options.column | the text column to read. |
options.into | where to write the masked text. Defaults to column (in place). |
options.of | kinds 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: HandlerAppconst 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: HandlerSdkThe 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?: unknownThe 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> | nullThis 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; } | nullThe 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[]; } | nullThe 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 | nullCorrelation 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?: stringWhat 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; } | nullSet 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[]; } | nullWhich 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 (orgUidsis 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?: HandlerJobPresent 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 carriesid, andread/update/deletetake a uid. Sorting byidis allowed (creation order); sorting byuidis not. - A foreign key travels as
<name>_uidholding the referenced row's uid:customer_idis read, written and filtered ascustomer_uid. Sendingid,uid,org_id, or the rawcustomer_idis a 400 (ROW_FIELD_NOT_ACCEPTED) naming the field to use; a_uidthat names no row is a 400 (ROW_REFERENCE_NOT_FOUND). org_id/tenant_idand integercreated_by/updated_by/deleted_by/user_idcolumns 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];
}| Property | Description |
|---|---|
$eq | Equal to. null matches IS NULL. |
$ne | Not equal to. null matches IS NOT NULL. |
$gt | Strictly greater than. |
$gte | Greater than or equal to. |
$lt | Strictly less than. |
$lte | Less than or equal to. |
$in | Matches any value in the list. An empty list matches nothing. |
$between | Inclusive 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;
}| Property | Description |
|---|---|
type | Which 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). |
allowTenantScope | Permit tenant-scoped narrowing. Granted automatically to a caller holding a global role. |
allowUserScope | Permit user-scoped narrowing. |
shared | Read 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;
}| Property | Description |
|---|---|
filters | Which rows to match. Equality, null, $and/$or, and the comparison operators. |
columns | Return ONLY these columns (validated identifiers) instead of every column. Redaction still applies. |
limit | Page size. |
offset | Rows to skip. Always pair with a sort — without one, pages may overlap or skip rows. |
paginate | Return the { items, paging } envelope instead of a bare array. |
sort | "column" or "column DESC". An unknown column is rejected, not ignored. |
orderBy | Alias of sort, for callers shaped like the REST layer. sort wins if both are set. |
defaultSort | Fallback ordering used only when neither sort nor orderBy was supplied. |
allowedSortColumns | Restrict which columns may be sorted on. A sort outside the list throws rather than silently ordering by something else. |
includeTotal | Also run the COUNT that fills paging.total. Costs a second query, so it is opt-in. |
scope | Narrow (or, with shared, widen) which tenant's rows are visible. |
after | readPage 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;
}| Property | Description |
|---|---|
items | The rows for this page. |
limit | Page size applied, or null when the call was not paginated. |
offset | Rows skipped, or null when the call was not paginated. |
paginated | Whether a page was actually cut, as opposed to the whole result being returned. |
total | Total matching rows, or null when the total was not requested. With after: the rows from this page on. |
nextCursor | With 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;
}| Property | Description |
|---|---|
op | The aggregate to compute. Allow-listed — arbitrary SQL functions are not accepted. |
column | Required 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. |
as | Output 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;
}| Property | Description |
|---|---|
column | The 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. |
truncate | Date truncation bucket for a date/datetime column. |
bucket | Numeric range bucket width, e.g. 25 groups 0-24, 25-49, … |
lower | Case-fold the group key (LOWER(col)). |
as | Output 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;
}| Property | Description |
|---|---|
filters | Reuses the read filter DSL (equality, IS NULL, $and/$or, and the comparison operators). |
groupBy | Group keys, outermost first. Omit for a single total row over the whole filtered set. |
measures | What to compute per group. Omit for a plain row count. |
having | Threshold on a measure alias, e.g. { revenue: { $gt: 1000 } }. |
sort | "<group-or-measure-name> ASC|DESC". |
limit | Max 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;
};
}| Property | Description |
|---|---|
rows | One row per group, carrying the group keys and each measure under its output name. |
meta | How 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;
}| Property | Description |
|---|---|
column | Column name. |
type | The column's SQL type, as declared. |
kind | Which family of statistics the fields below carry. A reference is a <name>_uid column. |
references | Reference: the table it points at. |
redacted | The column is masked by redaction, so no values are profiled — only that it exists. |
min | Numeric: smallest value. |
max | Numeric: largest value. |
avg | Numeric: mean, or null when there is nothing to average. |
distinct | Numeric: count of distinct values. |
earliest | Date: earliest value. |
latest | Date: latest value. |
topValues | Categorical: the most common values with their counts. |
moreCategories | Categorical: more distinct values exist than topValues shows. |
note | Why this column was profiled thinly or not at all. |
SummarizeResult
interface SummarizeResult {
table: string;
rowCount: number;
columns: SummarizeColumn[];
}| Property | Description |
|---|---|
table | The table profiled. |
rowCount | Rows in the table at the time of the call. |
columns | One entry per column, in the table's own column order. |
CreateResult
interface CreateResult {
uid: RowUid | null;
affectedRows?: number;
}| Property | Description |
|---|---|
uid | The 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. |
affectedRows | Rows written — 1 for a successful single insert. |
BulkCreateResult
interface BulkCreateResult {
count: number;
uids: Array<RowUid | null>;
affectedRows: number;
}| Property | Description |
|---|---|
count | Number of rows requested. |
uids | The new rows' uids, aligned to the input row order (null for a table with no uid column). |
affectedRows | Total rows inserted. |
GenerateResult
interface GenerateResult {
requested: number;
generated: number;
affectedRows: number;
columns: string[];
sample: Record<string, unknown> | null;
capped?: true;
perCallMax?: number;
remaining?: number;
}| Property | Description |
|---|---|
requested | Rows asked for. |
generated | Rows actually generated + inserted (< requested only when the per-call cap clipped it). |
affectedRows | Total rows inserted across every batch. |
columns | Column names present on the generated rows. |
sample | The first generated row as written, with its uid, so a caller can eyeball the shape. null when nothing was generated. |
capped | Present only when the per-call cap clipped the request — call again for remaining. |
perCallMax | The per-call ceiling that clipped the request. |
remaining | Rows still owed after the cap. |
UpdateResult
interface UpdateResult {
affectedRows?: number;
}| Property | Description |
|---|---|
affectedRows | Rows changed. 0 means the filter matched nothing — it is not an error. |
DeleteResult
interface DeleteResult {
affectedRows?: number;
}| Property | Description |
|---|---|
affectedRows | Rows removed, or soft-deleted when the table's strategy is "soft". |
WithIdempotencyPersistOptions
interface WithIdempotencyPersistOptions {
pick?: string[];
omit?: string[];
}| Property | Description |
|---|---|
pick | Store ONLY these keys of the result. Everything else is dropped before it is saved. |
omit | Store 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;
}| Property | Description |
|---|---|
key | The client-supplied key that identifies this attempt. Same key = same outcome. |
route | Route recorded alongside the key, so the same key on a different route is a different operation. |
method | HTTP method recorded with the key, for the same reason as route. |
body | Request body fingerprinted with the key. A retry whose body differs is rejected rather than silently replayed. |
query | Query fingerprinted with the key, as body is. |
persistResponse | What 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. |
ttlSeconds | How long the key stays reserved before an identical call may run again. |
allowRetryOnFailure | Let 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;
}| Property | Description |
|---|---|
uid | The approval's uid: its handle, stable for the life of the request (a key is reused once the request closes). |
key | The stable key this approval was created under — how a resumed run finds it again. |
status | Where the decision stands. Branch on the approved/rejected/pending booleans instead of string-matching. |
requestType | Caller-defined label for what kind of thing is being approved, for grouping in the inbox. |
reason | The human-readable justification shown to whoever decides. |
requestData | What was asked for — the payload an approver reviews before deciding. |
resumeData | State the handler stashed so it can pick up exactly where it paused. |
decisionData | Anything 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. |
decisionNotes | Free-text note the approver left with the decision. |
expiresAt | When the request stops being actionable, if it was given a deadline. |
decidedAt | When the decision was made. |
consumedAt | When the request closed: its approval used by the one gate pass it authorizes, or its rejection delivered to the next pass. |
createdAt | When the request was raised. |
updatedAt | When the row last changed. |
approved | Decided, and the answer was yes. |
rejected | Decided, and the answer was no. |
pending | Still waiting on a human. |
consumed | The 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;
}| Property | Description |
|---|---|
key | Stable 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. |
requestType | Caller-defined label for what kind of approval this is, used to group the inbox. |
reason | Why this needs a person. Shown to the approver, so write it for them. |
requestData | The payload under review. Show the actual arguments — an approver should not have to trust a summary. |
data | Alias of requestData. |
resumeData | State to hand back to the handler when the run resumes, so it continues instead of restarting. |
expiresInSeconds | How long the request stays actionable before it expires. |
waitForMs | How long to hold this call open waiting for a decision. On timeout the approval stays pending and the run pauses. |
pollIntervalMs | How often to re-check for a decision while waiting. |
throwOnReject | Throw 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;
}| Property | Description |
|---|---|
key | Identify the approval by its key (the OPEN request under it). Supply this or uid. |
uid | Identify the approval by its uid (ApprovalRecord.uid). Supply this or key. |
decisionData | Structured payload to record with the decision. |
decisionNotes | Free-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 indatarefuses the send (EMAIL_PLACEHOLDER_MISSING). Pass secrets throughdata: 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>;
}| Property | Description |
|---|---|
to | Recipient address, or several (at most 50 recipients counting cc and bcc). |
subject | Subject line. Placeholders allowed. |
from | Sender address. Omitted: the connection's default sender. Must be one of the senders the connection may send as (EMAIL_FROM_NOT_ALLOWED otherwise). |
fromName | The sender's display name. Omitted: the app's branding name, else the app's name. |
replyTo | Where replies go. Omitted: the branding's reply-to, else the connection's. |
heading | The big line at the top. Defaults to the subject. |
body | Paragraphs of plain text (escaped for you). |
action | A button. url must be http(s) or mailto. |
code | A one-time code, shown large instead of a button. |
footnotes | Small print under a rule. |
preheader | The inbox preview line. Defaults to the heading. |
data | Values 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;
}| Property | Description |
|---|---|
uid | The message in the app's email log (App › Email). |
duplicate | True when a retried job item sent this already (the same item, connection, recipients, sender and subject): nothing was sent this time. |
messageId | The provider's message id, when it gave one. |
alias | The connection's alias in this app. |
redacted | True 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 };
}| Property | Description |
|---|---|
logoUrl | Public 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;
}| Property | Description |
|---|---|
table | Destination table. |
csv | The CSV itself, as text. Supply this or csvBase64. |
csvBase64 | The CSV base64-encoded, for content that does not survive being passed as text. |
mapping | Rename incoming headers to real columns: { "Email Address": "email" }. |
defaults | Values 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. |
batchSize | Rows per insert batch. |
maxBytes | Reject the import if the payload exceeds this many bytes. |
maxRows | Reject the import if it carries more than this many rows. |
dryRun | Parse and validate everything, report what WOULD happen, write nothing. |
emptyAsNull | Treat an empty cell as NULL rather than an empty string. |
ignoreUnknownColumns | Skip CSV columns with no matching table column instead of failing the import. |
onError | What 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. |
scope | Narrow (or, with shared, widen) the tenant scope stamped on every imported row. |
encoding | Character encoding of the incoming CSV. |
ReadOptions
interface ReadOptions {
idColumn?: "uid";
scope?: ScopeOptions;
}| Property | Description |
|---|---|
idColumn | Rows 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. |
scope | Narrow (or, with shared, widen) which tenant's rows are visible. |
UpdateOptions
interface UpdateOptions extends ReadOptions {}DeleteOptions
interface DeleteOptions extends ReadOptions {
deleteStrategy?: DeleteStrategy;
}| Property | Description |
|---|---|
deleteStrategy | Override the table's configured strategy for this call: remove the row, or mark it deleted. |
CreateOptions
interface CreateOptions {
scope?: ScopeOptions;
}| Property | Description |
|---|---|
scope | Which tenant the new row is attributed to. Defaults to the acting scope. |
BulkCreateOptions
interface BulkCreateOptions extends CreateOptions {
chunkSize?: number;
}| Property | Description |
|---|---|
chunkSize | Max 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;
}| Property | Description |
|---|---|
kind | Which generator to use. Omit to infer one from the column's real type in the live schema. |
values | oneOf / sample: the pool to draw from. weighted: { value: weight }. |
min | int / float / sample / sentence / paragraph: lower bound. |
max | int / float / sample / sentence / paragraph: upper bound. |
precision | float: decimal places. |
trueRate | bool: probability of true (0..1). |
within | date / datetime: a relative window like "90d", counted back from now. |
from | date / datetime: explicit range start, as an alternative to within. |
to | date / datetime: explicit range end. |
template | template: "Order {i}" — {i} is the row number, {col} another column in the same row. |
start | sequence: the first number in the run. |
step | sequence: how much each row advances the counter. |
pad | sequence: zero-pad the number to this width. |
prefix | sequence: text placed before the number. |
suffix | sequence: text placed after the number. |
count | words: how many words. |
value | constant: the fixed value to write. |
nullRate | Fraction 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;
}| Property | Description |
|---|---|
seed | Seed the PRNG for reproducible output. Omit for varied data. |
batchSize | Rows 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;
}| Property | Description |
|---|---|
scope | Narrow which tenant's rows the statement may touch. |
all | Required 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;
}| Property | Description |
|---|---|
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;
}| Property | Description |
|---|---|
name | The 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[];
}| Property | Description |
|---|---|
text | The compiled SQL, with every interpolation replaced by a ? placeholder. |
values | The 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;
};
}| Property | Description |
|---|---|
source | Which server + tool produced this. |
untrusted | Always true. The marking travels with the payload so it can't be lost downstream. |
note | The rider spelling out that this is third-party data, not instructions. |
text | Text 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). |
structured | The 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); |
attachments | Non-text blocks summarized by type — binary is never inlined. |
truncated | Present 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;
}| Property | Description |
|---|---|
skipped | Rows 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;
}| Property | Description |
|---|---|
matches | Best first. score is cosine similarity in [-1, 1]. |
scanned | Embedded 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;
}| Property | Description |
|---|---|
into | Where the masked text was written — equal to column for an in-place run. |
rewritten | Rows 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;
}| Property | Description |
|---|---|
examined | Rows looked at. |
enriched | Rows the model labeled and that were written. |
settledEmpty | Rows with no source content — settled to null without spending tokens. |
skippedOverride | Rows a human owns. Never touched. |
fresh | Rows already up to date. |
testMode | Present 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;
}| Property | Description |
|---|---|
filters | Narrow the rows analyzed — same filter DSL as . |
columns | Which columns the model may read. Defaults to all non-redacted, non-audit columns. |
sampleSize | Rows to analyze. Default 100, hard cap 500. |
tier | Which 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;
}| Property | Description |
|---|---|
analyzed | Rows actually analyzed by the model. |
matched | Rows the read matched in total — compare with analyzed to see what was left out. |
sampled | True when matched exceeded the sample: the answer describes a sample, not the table. |
model | The model that produced this answer. |
testMode | Present only in a handler dry run, where no provider call was made. |
AiDigestResult
interface AiDigestResult extends AiResultBase {
text: string;
points: string[];
batches: number;
}| Property | Description |
|---|---|
text | The 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;
}| Property | Description |
|---|---|
byLabel | Compact count table, highest first. |
perRow | Per-row assignments, in the order the rows were read. |
unmapped | Rows 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;
}| Property | Description |
|---|---|
uids | Uids 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;
}| Property | Description |
|---|---|
uid | The file's handle: every other storage method names a file by this. |
name | File name within its folder. Duplicates are auto-deduped (a.pdf → a (1).pdf). |
folder_uid | Containing folder's uid, or null at the storage root. |
storage_key | Physical object key. Stable across renames and moves — those only touch metadata. |
owner_uid | The 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. |
metadata | Your own data travelling with the file (alt text, caption, status …). |
sharing | Present 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. |
sharedByOwner | true 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;
}| Property | Description |
|---|---|
uid | The folder's handle (folderUid / parentUid elsewhere). |
parent_uid | Parent folder's uid, or null directly under the storage root. |
sharing | Present 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. |
sharedByOwner | true 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;
}| Property | Description |
|---|---|
from | The 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;
}| Property | Description |
|---|---|
url | Use directly as an <a href>, <img src>, or fetch target. |
kind | "public" — permanent and cacheable (public storages). "signed" — expires; see expiresAt. |
expiresAt | ISO 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>;
}| Property | Description |
|---|---|
name | File name (one path segment — no /). |
body | Bytes. A string is decoded per encoding; data: URLs are understood. |
encoding | How to read a string body. Default "utf8"; use "base64" for binary. |
folderUid | Destination folder's uid. Takes precedence over path when both are given. |
path | Destination path (/a/b/c). Missing folders are created — see ensureFolder. |
ownerUid | Uid 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;
}| Property | Description |
|---|---|
uid | The job's handle: every other crons method names a job by this. |
status | active = eligible to fire. paused = held, schedule and history intact. |
targetHandlerUid | The uid of the handler it runs (the uid the handler registry gives it). |
payload | The fixed request body handed to the handler on every run. |
nextRunAt | null while paused — a paused job holds no claim on the scheduler. |
lastRunUid | Pass to . |
consecutiveFailures | A counter, never an auto-disable — a broken job is surfaced, not silently switched off. |
principalUserUid | The job's own principal. What it may do is the roles bound to THIS user. |
orgUid | The app org the job belongs to, by uid and name. Null for the app owner's own jobs. |
firing | The 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;
}| Property | Description |
|---|---|
uid | The run's handle: pass to . |
cronUid | The job it belongs to. Still set after that job is deleted: run history is kept. |
triggerSource | schedule, 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). |
principalRoles | The roles the principal held AT THE MOMENT IT RAN — not what it holds now. |
handlerVersionUid | The handler version that executed (its uid). |
scheduledFor | What the schedule said, as opposed to when the sweep got to it. |
status | partial: 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. |
missedWindows | Scheduled instants passed over to get here. Skipped, never replayed. |
windowStart | The occurrence this run belongs to. Runs of one firing (stopped, then resumed) share it. |
turns | Turns taken (a page each), and failed turns retried. |
doneCount | Items done and failed: "197 done, 3 failed". |
failures | The first 100 failed items (getRun only): the row's uid and what went wrong. |
orgUid | The 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;
}| Property | Description |
|---|---|
intervalSeconds | Minimum 60 — the scheduler tick bounds resolution, so shorter is REFUSED, not rounded. |
cronExpression | Five fields: minute hour day-of-month month day-of-week. Macros like @daily work too. |
timezone | IANA 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;
}>;
}| Property | Description |
|---|---|
id | Stripe's product id. |
name | Product name as it appears in Stripe. |
description | Product description, when one was set. |
active | Whether the product is currently sellable. |
appUid | The app this product is tagged to, from its Stripe metadata.app_uid. |
defaultPriceId | The price used when a checkout does not name one. |
prices | Every 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;
}| Property | Description |
|---|---|
cronUid | The job's uid. null when its owner is trying the handler from the console. |
runUid | This run's uid. null when its owner is trying the handler from the console. |
windowStart | Which 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}. |
position | Where 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.) |
attempt | Failed turns of this firing so far: 0 on a first try, so a retried turn can be told apart. |
each | THE 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. |
next | The 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;
}| Property | Description |
|---|---|
done | Rows whose fn finished. |
failed | Rows whose fn threw. |
pages | Pages read. |
failures | The first 100 failures: the row's uid and what went wrong. |
failuresTruncated | True when more rows failed than failures lists. |
skipped | True when an earlier turn already finished this loop (a job with several). |