The handler context
What a handler is given — its input, the SDK, the caller, its reach, and in a scheduled run the job itself.
A handler receives two arguments, data and ctx. data is the input. ctx is everything the
platform knows about this run, and it is built by the platform: nothing in a request can set or
override a field of it.
export default async function handler(data, ctx) {
const { sdk, user, audience, job } = ctx;
// …
}data: the input
| Run by | data is |
|---|---|
| An endpoint | The request body |
| An agent or MCP tool | The tool call's arguments |
| A scheduled job | The job's payload, the same fixed object on every run |
| Run live / dry run in the console | What you type in the run panel |
Treat it as untrusted input, whoever sends it. Validate what you use, and never build SQL from it
except through sdk.sql.
What ctx carries
| Field | What it is |
|---|---|
ctx.sdk | The SDK, scoped to this app and this run. The same object as the destructured sdk. |
ctx.user | The caller, in handles: { uid, email, fname, lname, orgUid }. No id, no password fields. null when the caller is not a user. |
ctx.reqParams | Route path parameters: /orders/:orderUid arrives as { orderUid }. |
ctx.reqQuery | The parsed query string of the request. |
ctx.appConfig | The app's configuration object, or null. |
ctx.audience | Which of the app's organizations this run reaches. See below. |
ctx.job | Present in a scheduled run. See below. |
ctx.handlerDelegation | Set when another handler reached this one: the action that entered the chain and what it may call. Read-only bookkeeping. |
There is no environment. The sandbox exposes no environment variables you set: process.env
and Deno.env hold nothing of yours. Configuration belongs in ctx.appConfig; secrets for an
outside service belong in the integration that holds them (an AI connection, an email connection,
Stripe), which the SDK uses on your behalf.
ctx.user: who is calling
ctx.user.uid is the caller's handle in this app, for anything that records who did something
(sdk.storage's ownerUid, a created_by_uid column of your own). ctx.user.orgUid is the
organization the call acts in.
In a scheduled run, ctx.user is the job's own principal and the organization it runs as, never
the person who created the job.
ctx.audience: which organizations this run reaches
An app's data belongs to the app's organizations. Most runs act inside one of them; a run acting
for the app's owner may reach several. ctx.audience says which, so your code can check it rather
than infer it:
// { mode: "org" | "all" | "orgs" | "none", orgUids: string[] }
if (ctx.audience?.mode !== "org") {
// Acting for the app's owner: rows say which org they belong to.
}mode | Meaning |
|---|---|
org | One organization. A caller inside the app, or a job whose org reach is exactly one org, which runs as that org. Rows carry no org column; new rows are that org's. |
all | Every organization, including ones created later (orgUids is empty). |
orgs | The organizations listed in orgUids. |
none | No organization's data: only the owner's own rows. |
In all, orgs and none the run acts for the app's owner, and three things change:
- every row it reads says which org it belongs to as
org_uid(nullfor the owner's own rows); - filters and
sdk.aggregategroupings acceptorg_uid; - a new row in an organization's table must name one with
org_uid. Without it the write is refused withROW_ORG_REQUIRED, and an org outside the reach withROW_ORG_NOT_FOUND. An update never changes a row's org (ROW_FIELD_NOT_ACCEPTED).
ctx.audience is null on the platform's own plane. Which mode a job or agent gets is set by its
org reach.
ctx.job: a scheduled run, told about itself
Present when a scheduled job runs the handler, and when the app's owner tries a job-only handler from the console.
| Field | What it is |
|---|---|
cronUid | The job's uid. null in a console try. |
runUid | This run's uid. null in a console try. |
windowStart | Which firing this is (ISO 8601): the time it was scheduled for, or when a Run now started. |
attempt | How many turns of this firing have failed so far; 0 on a first try. |
position | Where a hand-written loop stopped: the value it last passed to next(). |
each(table, filters, fn, options?) | Page through rows, one page per turn, resumable. |
next(position) | End this turn and carry on from position on the next one. |
windowStart is the right ingredient for your own idempotency keys, because every turn and every
retry of one firing shares it:
await sdk.withIdempotency({ key: `${ctx.job.windowStart}:${invoice.uid}` }, () =>
sdk.create("reminders", { invoice_uid: invoice.uid }),
);How turns, retries and each work is in Jobs that loop.