Skip to content

The Function Context ​

A server function reaches the platform through ctx: records and documents, prompts, secrets and config vars, live sends and channels, email, analytics and outside services. This page covers each of those calls and the few capabilities a function declares for them.

Calling the platform from a function ​

ctx.api is a typed client for the platform's own API, generated from its OpenAPI document. Function code acts as the system. Every call runs with the app's own authority — a function reads and writes what the app may, in every family the gateway exposes, whoever invoked it. The caller is attribution: ctx.user names them (or is null for a trigger fire), the rows the function writes are attributed to them, and the function.invoke event carries their id — but their role, their database permissions and their document grants decide nothing inside the function.

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { documentId: string }, ctx) => {
  // Reads the document whoever owns it; the caller's own access does not enter.
  const doc = await ctx.api.documents.get({ documentId: input.documentId });
  return { title: doc.title, askedBy: ctx.user?.userId ?? null };
});

That is the point of a server call: the reason to move logic to a function is usually to reach what the caller may not, and the access gate is where you say who may ask for that. It is also why the gate fails closed and why push refuses a function without one.

Checking a user's access ​

A function that writes into a document the CALLER named has to decide for itself whether they may edit it — the platform will not refuse the write, because the write is the app's. Ask for the caller's access by name:

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(
  async (input: { documentId: string; page: string }, ctx) => {
    const access = await ctx.api.documents.validateAccess({
      documentId: input.documentId,
      body: { userId: ctx.user!.userId },
    });

    // A content write needs a real grant. `appRole` is NOT one.
    if (access.permission !== "read-write" && access.permission !== "owner") {
      throw new Error("You may not write to this document");
    }

    await ctx.api.documents.update({
      documentId: input.documentId,
      body: { title: input.page },
    });
    return { written: true };
  },
);

The answer is the platform's own: direct grants, group grants (collection membership included — a collection's sharing is materialized as group grants), and "anyone with the link" access, resolved exactly as the document routes resolve them for that user. It comes back as { hasAccess, permission?, accessSource?, appRole? }, where accessSource is owner, grant, group or link — where the winning level came from.

Two rules are worth holding on to:

  • Name the user, or you are asking about the app. With no body, the same call answers the DISPATCH's access, which is the app's own owner-level authority — permission: "owner" for every document of the app, whoever invoked. That form is still the right one when you want to know whether the document is reachable at all; it is never a check on the caller.
  • appRole is not a grant. It reports the subject's role in the app (owner, admin, member) beside the grant, and it never licenses a content write: PUT documents/{id}, block writes and blob writes all require a read-write or owner grant from everybody, administrators included. The writes a role does admit without a grant are the tag routes (documents.addTag / documents.removeTag), so those — and only those — may read access.appRole === "admin" || access.appRole === "owner" as permission to proceed.

A member may name only themselves; an app owner or admin may name anyone. From inside a function neither limit applies — the dispatch carries the app's authority — so the userId you pass is the question, and ctx.user is normally what you pass.

A sandbox has no network access by default. Its only route out is the platform, and only for the operations it publishes to functions: every family of the API — analytics, blobBuckets, channels, collections, configVars, connections, databases, documents, email, groups, integrations, locks, notifications, prompts, resourceMetadata, secrets, users — plus two operations of functions: functions.start, which ctx.functions.start calls, and functions.logs, which reads a function's invocation logs. Not published: the rest of functions (a function cannot invoke a function over HTTP), iterations, and the database-type catalogue. A prompt runs through ctx.prompts.run and an integration is called through ctx.integrations.call; any other route to either one answers FUNCTION_ROUTE_NOT_ALLOWED.

Capabilities ​

Because the code already acts as the system, a capability is never about data. It is declared only where it configures something, and there are exactly three kinds:

KindWhat it configures
integration:<key>The egress allowlist — which integrations ctx.integrations.call may reach.
secret:<NAME>Which secret values may enter the sandbox through ctx.secret.
The high-blast opt-insOperations that destroy something, hand out persistent authority, or provision a resource.

The high-blast list, 1:1 with the operation:

OperationCapability
databases.create / databases.delete / databases.transferOwnershipdatabases:create / databases:delete / databases:transferOwnership
databases.addManager / databases.revokePermissiondatabases:addManager / databases:revokePermission
databases.grantGroupPermission / databases.revokeGroupPermissiondatabases:grantGroupPermission / databases:revokeGroupPermission
users.setRoleusers:setRole
blobBuckets.createBucket / blobBuckets.deleteBucketblobBuckets:createBucket / blobBuckets:deleteBucket
toml
# primitive/dev/functions/provision.toml
[function]
key = "provision"
entry = "functions/provision/index.ts"
access = "user.role == 'admin'"
capabilities = ["databases:create", "integration:stripe", "secret:PARTNER_TOKEN"]

An undeclared call in any of the three answers a structured permission error naming the exact string to add (FUNCTION_HIGH_BLAST_GRANT_MISSING, FUNCTION_INTEGRATION_GRANT_MISSING, FUNCTION_SECRET_GRANT_MISSING), and the declaration is read from the version that is running — a re-push that drops databases:delete refuses the same code on its next call. The capability is the function's, not the caller's: system is owner-equivalent, not super-admin, so a route's own rules still apply — users.setRole cannot touch the app owner from a function any more than from an owner's own request.

Nothing else is declared — a database model, a prompt, a config var, a channel, a send, an email, an analytics write are all reachable on the app's authority.

config push checks the list before anything is applied. It refuses a malformed string, an integration: naming a key the app has no active object for, and any string outside the three kinds, naming the string. secret: is checked for grammar only: secret values are provisioned per environment, outside the config tree, so the ordinary order of work is to push the function and then provision the value. A name with no value is a structured error when the function reads it (FUNCTION_SECRET_NOT_FOUND), not a push failure.

Anything else answers a structured error you can catch:

errorCodeMeaning
FUNCTION_EGRESS_DENIEDfetch to a host other than the platform.
FUNCTION_ROUTE_NOT_ALLOWEDA platform route not published to functions.
FUNCTION_INTEGRATION_GRANT_MISSINGAn integration call the function's capabilities do not cover. The message names the string that would.
FUNCTION_SECRET_GRANT_MISSINGA secret read the function's capabilities do not cover.
FUNCTION_HIGH_BLAST_GRANT_MISSINGA high-blast operation the function's capabilities do not cover. The message names the exact string.
FUNCTION_SECRET_NOT_FOUNDThe capability is declared; this environment has no secret of that name.
FUNCTION_VAR_NOT_FOUNDThis environment has no config var of that name.
FUNCTION_SEND_TARGET_NOT_FOUNDThe user is not a member of this app, or the connection is not one of this app's.
FUNCTION_SEND_PAYLOAD_TOO_LARGEA send payload over the 64 KiB ceiling. Nothing was delivered.
FUNCTION_CHANNEL_GRANTEE_REQUIREDctx.channels.authorize from a trigger fire with no userId — there is no caller to default to.
FUNCTION_CHANNEL_NAME_INVALIDA channel name outside the grammar.
FUNCTION_CHANNEL_GRANTEE_NOT_FOUNDThe userId a grant names is not a member of this app — the same answer as a user that never existed.
QUERY_IN_LIST_TOO_LARGEA single $in or $nin list over 1 000 values. The message names the field, the count and the cap. Split the list across queries or narrow the filter.
QUERY_FILTER_TOO_MANY_BINDSA query whose statement would bind more than 100 parameters. The message names the bound and the count. An $or of equality branches on the same fields costs one parameter between them, as an $in list does; anything else has to be narrowed or split.

Calling an outside service ​

A function cannot fetch the internet. What it can do is call an integration it has declared — the egress allowlist — and the platform makes that request on its behalf:

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { orderId: string }, ctx) => {
  const response = await ctx.integrations.call("stripe", {
    method: "POST",
    path: "/v1/refunds",
    body: { payment_intent: input.orderId },
  });
  return { status: response.status, refund: response.body };
});
toml
# primitive/dev/functions/refund.toml
[function]
key = "refund"
entry = "functions/refund/index.ts"
access = "true"
capabilities = ["integration:stripe"]

The request is issued by the platform, not by your code, and that is the point: the integration's {{secrets.*}} and {{vars.*}} templates are resolved into it server-side, so the credential reaches the upstream service and never reaches the function. Your code sees the response; it never sees the key.

Everything the integration declares still applies — its host, its allowedMethods and allowedPaths, its redirect rules and its timeout, which is also bounded by your invocation's remaining time — and applies to what the request really asks for rather than to the string you wrote. Those rules are the integration's own; see Calling It From a Function.

An integration has no caller of its own: nothing but a function reaches it. Who may make that call is the function's access gate, and which integrations the function may reach at all is the integration:<key> line in its reviewed TOML — the egress allowlist. Together they are the whole authorization. An integration that is disabled or deleted refuses every call (INTEGRATION_INACTIVE).

Every call is recorded on the integration's own log with the function's key, so primitive integrations logs <integration-id> answers "what did this function send out, and when". When the invocation has a run behind it — a task run or a trigger fire — the row names that run too, and primitive integrations logs <integration-id> --run <run-id> narrows the log to exactly the calls that run made. A call from an HTTP request invocation has no run to name, since a request invocation writes no run row.

Running a prompt ​

ctx.prompts.run runs one of the app's saved prompts, with nothing to declare:

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { text: string }, ctx) => {
  const result = await ctx.prompts.run("summarize", {
    variables: { text: input.text },
  });
  if (!result.success) throw new Error(result.error ?? "prompt failed");
  return { summary: result.output };
});

The envelope it answers, the options its second argument takes, and who may run a prompt are on the Prompts page — see Running a Prompt From a Function. A configId that is not one of the prompt's configs answers PROMPT_NO_CONFIG, and a prompt key the app does not have is not found. There is no capability line to declare. A function that times out does not leave a model request running behind it.

Typed, parsed output ​

When the prompt declares a [prompt.outputSchema], its success arm also carries parsed: the model's answer as JSON, already validated against that schema and typed from it, so the function never parses by hand:

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { payload: unknown }, ctx) => {
  const answer = await ctx.prompts.run("categorize", {
    variables: { payload: input.payload },
  });
  if (!answer.success) {
    throw new Error(answer.error ?? "categorize failed");
  }
  // `parsed` is the JSON value of the model's answer, validated against the
  // prompt's declared schema and typed from it.
  return { suggested: answer.parsed.suggested_transactions };
});

parsed is on the success arm only, which is why the success check is what unlocks it. How to declare the schema, the error codes an answer of the wrong shape comes back with, and what outputFormat = "json" alone gives you are on the Prompts page — see Typed, Structured Output.

The types come from functions/primitive-prompt-types.d.ts, which config push renders from the prompts in your tree beside the function declarations (Typed from the declaration), typing ctx.prompts.run("<key>") from each prompt's [prompt.outputSchema]. What it declares is on the Prompts page — see The generated declaration.

Running a decisions prompt ​

A prompt declared kind = "decisions" runs OpenRouter's decisions endpoint instead of a chat completion. Its input is a single state variable — the value its named typed questions are asked about — and its answer is the typed answers object, so parsed is there with nothing declared. Declare [prompt.outputSchema] to get it TYPED, exactly as above:

toml
# config/prompts/transaction-categorizer.toml
[prompt]
kind = "decisions"
key = "transaction-categorizer"

[prompt.outputSchema]
type = "object"
required = ["category"]

[prompt.outputSchema.properties.category]
type = "object"
required = ["type", "choice", "probabilities", "confidence"]

[prompt.outputSchema.properties.category.properties.type]
const = "choice"

[prompt.outputSchema.properties.category.properties.choice]
enum = ["gas-and-fuel", "groceries"]

[prompt.outputSchema.properties.category.properties.probabilities]
type = "object"

[prompt.outputSchema.properties.category.properties.confidence]
type = "number"

[[configs]]
name = "jev"
active = true
provider = "openrouter"
model = "typesafe/jev-1.13"

[configs.decisions.questions.category]
type = "choice"
instructions = "Which household category does this bank transaction belong to?"
criteriaSource = "static"          # the options are the table below

[configs.decisions.questions.category.criteria]
gas-and-fuel = "Gas & Fuel (Auto & Transport)"
groceries = "Groceries (Food & Restaurants)"
ts
import { defineFunction } from "primitive-functions";

export default defineFunction(
  async (input: { transaction: unknown }, ctx) => {
    const answer = await ctx.prompts.run("transaction-categorizer", {
      // `state` is the whole input, passed to the provider verbatim. Omitting
      // it is refused before the provider call, so nothing is billed.
      variables: { state: { transaction: input.transaction } },
    });
    if (!answer.success) {
      throw new Error(answer.error ?? "categorize failed");
    }
    return {
      category: answer.parsed.category.choice,
      confidence: answer.parsed.category.confidence,
      // What the call cost, in USD, as the provider reported it.
      cost: answer.metrics?.cost,
    };
  },
);

See Decisions models for the three question types and the answer shape of each.

When a choice question's options are data rather than a fixed list — the previous transactions that might be the same merchant, say — declare it criteriaSource = "dynamic" instead of criteriaSource = "static", leave out its criteria table, and pass the options with the run as variables.criteria.<question>, beside state:

ts
const answer = await ctx.prompts.run("precedent-matcher", {
  variables: {
    state: { transaction: input.transaction },
    criteria: {
      // One option per candidate, each described by a string or an object.
      precedent: {
        p1: { bank_description: "CHEVRON 00938", category: "gas-and-fuel" },
        p2: { bank_description: "SHELL 4411", category: "gas-and-fuel" },
        none: "No previous transaction is the same merchant",
      },
    },
  },
});
if (!answer.success) {
  // `PROMPT_CRITERIA_INVALID`: the options were refused before the provider
  // call, and nothing was billed. `error` names the question and the rule.
  throw new Error(answer.error ?? "precedent match failed");
}

Options that are missing, fewer than two, keyed by an empty or whitespace-padded key, or described by an empty value — or options for a question the config does not declare "dynamic" — come back as success: false with errorCode: "PROMPT_CRITERIA_INVALID". The rule and the test-case spelling are under Options supplied per run.

Reading secrets and config vars ​

A secret:<NAME> capability lets a function read one app secret by name; config vars need no declaration:

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (_input, ctx) => {
  const region = await ctx.configVar("REGION");
  const token = await ctx.secret("PARTNER_TOKEN");
  return { region, tokenLength: token.length };
});
toml
capabilities = ["secret:PARTNER_TOKEN"]

Two things worth knowing before you reach for ctx.secret:

  • Prefer an integration when the secret is a credential for one. An integration's {{secrets.*}} templates are resolved by the platform into the outbound request, so the value never enters your sandbox at all. ctx.secret is for the cases an integration cannot express.
  • Secrets and config vars are provisioned per environment, out of band from your push. So a secret: capability is checked for spelling at push and for existence at call time: a declared name with no value in this environment answers FUNCTION_SECRET_NOT_FOUND (or FUNCTION_VAR_NOT_FOUND for a var), naming the key so you can set it.

The two namespaces are separate. ctx.secret("X") never reads a config var named X, and ctx.configVar("X") never reads a secret.

Secret values are read through a short stale-while-revalidate cache, so a secret you have just rotated may serve its previous value for up to about a minute. That is the platform's read cache, not a property of your function — a retry a minute later sees the new value.

Config vars are cached harder, and deliberately: ctx.configVar reads each name once per function version and answers from the sandbox after that — no round trip on later invocations. So a var you change reaches a function reliably only on its next pushed version; a re-push starts with a cold cache. A miss is not cached (provision the value and the next read sees it), and ctx.secret keeps no per-version cache of its own, so a rotated secret needs no push: it reaches the function once the platform's read cache above has refreshed.

Sending to a connected client ​

ctx.users.send and ctx.connections.send push a message to a client that is connected right now, with nothing to declare:

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { userId: string }, ctx) => {
  const result = await ctx.users.send(input.userId, {
    kind: "order-ready",
    orderId: "o-1",
  });
  return { delivered: result.connections };
});

The client receives a direct.message frame carrying your payload verbatim and the key of the function that sent it — client.on("directMessage", …) in JS, the DirectMessageEvent stream in Swift:

ts
client.on("directMessage", (event) => {
  // event.payload is what the function passed; event.functionKey is who sent it
  console.log(event.functionKey, event.payload, event.sentAt);
});
swift
for await event in client.stream(for: DirectMessageEvent.self) {
  // event.payload (JSONValue?) is what the function passed;
  // event.functionKey is who sent it; event.sentAt is when.
  print(event.functionKey, event.sentAt)
  if let message = event.payload?["message"]?.stringValue {
    print(message)
  }
}

What the API promises, and what it deliberately does not:

  • Presence is not guaranteed. A user with no connected client answers { connections: 0, truncated: false } — a success, not an error. There is no durable record behind the frame: a client that was offline does not receive it later. Use a notification if the message has to survive being missed.
  • One socket, one frame. A client with several documents open is one connection, counted once and delivered once.
  • The fanout is bounded at 64 unique connections per user, and the lookup behind it is bounded too. Past either bound the send delivers what it found and answers truncated: true — treat that flag as "there may be more", not as an error.
  • Payloads are capped at 64 KiB serialized. Over it, the call answers 413 naming the bound and nothing is delivered.
  • ctx.connections.send(connectionId, payload) addresses one connection. An id belonging to another app answers exactly as an id that never existed — connection ids are not a way to reach another tenant.

Channels ​

A direct send addresses a user or a connection you already know. A channel addresses whoever is listening to a named topic — an order's status, a game table, a document's presence lane — without your function knowing who that is.

Two calls make one, and neither needs a declaration:

toml
# primitive/dev/functions/order-room.toml
[function]
key = "order-room"
entry = "functions/order-room.ts"
access = "true"
ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { orderId: string }, ctx) => {
  // 1. Authorize the caller for one channel. The grant names the CALLER, so it
  //    is not a credential anybody else can use.
  const { grant, expiresAt } = await ctx.channels.authorize(
    `orders:${input.orderId}`
  );
  return { grant, expiresAt };
});

The client presents that grant on the socket it already has with subscribeToChannel, and every publish to the channel then arrives as a channelMessage event (client.on("channelMessage", …) in JS, the ChannelMessageEvent stream in Swift):

ts
// 1. Ask the authorizing function for a grant; it names this caller and one channel.
const result = await client.functions.invoke<{ grant: string; expiresAt: number }>(
  "order-room",
  { input: { orderId } }
);
if (result.status !== "completed" || !result.output) return;

// 2. Present the grant on the socket the client already has.
const subscription = await client.subscribeToChannel(
  `orders:${orderId}`,
  result.output.grant
);

client.on("channelMessage", (event) => {
  // event.channel, event.payload, event.functionKey, event.sentAt
  console.log(event.channel, event.payload);
});

// A grant that expired while the socket was down is refused on reconnect with
// nothing waiting on it — invoke the function again and re-subscribe.
client.on("channelSubscribeFailed", (event) => {
  console.warn("renew", event.channel, event.message);
});

// Later: leave. Idempotent — the same as client.unsubscribeFromChannel(channel).
subscription.unsubscribe();
swift
struct Grant: Decodable, Sendable { let grant: String; let expiresAt: Int }

// 1. Ask the authorizing function for a grant; it names this caller and one channel.
let result: FunctionResult<Grant> = try await client.functions.invoke(
  "order-room",
  input: ["orderId": orderId]
)
guard result.status == "completed", let output = result.output else { return }

// 2. Present the grant on the socket the client already has.
let subscription = try await client.subscribeToChannel(
  "orders:\(orderId)",
  grant: output.grant
)

// Later: leave. Idempotent — the same as client.unsubscribeFromChannel(channel).
subscription.unsubscribe()

Any function can then publish to it — including the one that just wrote the row it is telling the client about, in the same handler (see Following up on a write), which is the combination the realtime story is built for:

ts
await ctx.channels.publish(`orders:${orderId}`, { status: "shipped" });

publish answers { connections, truncated }: how many connections received the frame, and whether the fanout stopped at its ceiling of 500 connections per channel. Zero connections is a success, not an error — nobody was listening.

Channel names ​

A channel name is one or more segments of [A-Za-z0-9_-] joined by :, at most 200 characters — orders, orders:42, orders:42:chat. A name outside the grammar is a 400 about the name (FUNCTION_CHANNEL_NAME_INVALID), decided before anything else.

Expiry is the revocation ​

A grant is a short-lived signed token, not a row, so there is nothing to delete: expiry is the only revocation, and it ends MEMBERSHIP, not just the join.

  • The default TTL is 300 seconds and the ceiling is 900. Ask for more with ctx.channels.authorize(channel, { ttlSeconds }) and you get the ceiling, not an error.
  • expiresAt comes back with the grant so a client can renew ahead of time. Renewing is re-subscribing with a fresh grant: same channel, same socket, new expiry.
  • Past expiresAt the server stops delivering even though the socket is still open. A client that keeps its connection but lets its grant lapse simply stops hearing the channel until it re-subscribes.

Renew by invoking the authorizing function again and re-subscribing with the fresh grant — the same two calls as the first join, timed a minute ahead of expiresAt:

ts
setTimeout(async () => {
  const result = await client.functions.invoke<{ grant: string; expiresAt: number }>(
    "order-room",
    { input: { orderId } }
  );
  if (result.status !== "completed" || !result.output) return;

  await client.subscribeToChannel(`orders:${orderId}`, result.output.grant);
}, expiresAt - Date.now() - 60_000);
swift
struct Grant: Decodable, Sendable { let grant: String; let expiresAt: Int }

let delayMillis = max(0, expiresAt - nowMillis - 60_000)
// Spelled `_Concurrency.Task` because a generated model named `Task` would
// otherwise shadow it — which is easy to hit, since `tasks` is a common
// model name.
try await _Concurrency.Task.sleep(nanoseconds: UInt64(delayMillis) * 1_000_000)

let result: FunctionResult<Grant> = try await client.functions.invoke(
  "order-room",
  input: ["orderId": orderId]
)
guard result.status == "completed", let output = result.output else { return }

_ = try await client.subscribeToChannel("orders:\(orderId)", grant: output.grant)

Everything else about a channel is a refusal you cannot distinguish from another: an expired grant, a tampered one, one minted for another app, one minted for another user and one naming a different channel all get the same error frame with the same message. The frame echoes the channel you asked for, so a client with several subscriptions in flight knows which one failed — but the REASON is uniform, because a different message for each would be an oracle.

On reconnect the client re-issues every subscribe it is holding with its stored grant. A grant that has expired in the meantime is refused, and only that channel's registration is dropped — the client reports it on the channelSubscribeFailed event ({ channel, message }), which is your cue to invoke the authorizing function again and re-subscribe.

A grant is a bearer token for one user

ctx.channels.authorize always names a grantee. An HTTP invocation defaults to its caller; a trigger fire has no caller and must pass { userId } explicitly (FUNCTION_CHANNEL_GRANTEE_REQUIRED otherwise), and a grant is refused at subscribe time if the socket's user is not the one it names. There is no app-wide channel grant.

Sending email ​

ctx.api.email.send sends as the app, with nothing to declare — a cron-fired function sending a digest is the point of it. Template mode and inline mode are exclusive, as are to and toUserId; a toUserId must be a member of this app.

ts
await ctx.api.email.send({
  body: {
    toUserId: input.userId,
    subject: "Your receipt",
    htmlBody: `<p>Thanks, ${input.name}.</p>`,
    textBody: `Thanks, ${input.name}.`,
    variables: { appName: "Acme" },
  },
});

The route is POST /app/{appId}/api/emails/send and it is reachable ONLY from a function: a member, admin or owner calling it directly is refused, because sending as the app is what function code does and a role is not.

The app has ONE hourly email budget; passing it answers 429 with errorCode: "EMAIL_RATE_LIMITED".

Writing analytics for a user ​

ctx.api.analytics.writeForUser records an event attributed to a SUBJECT user rather than to whoever is running, with nothing to declare. Its route, POST /app/{appId}/api/analytics/write-for-user, is function-only for the same reason as email. The call, its fields and its rules are on the Analytics page: see Writing events from a server function.

Bounded fan-out, ids, and step policies ​

Three helpers ship in primitive-functions:

ts
import { pMap, ulid, stepPolicy } from "primitive-functions";

// Bounded concurrency — an unbounded Promise.all over a page of rows spends
// the sandbox's whole subrequest budget in one line.
const results = await pMap(rows, (row) => handle(row), { concurrency: 4 });

// Generate ids INSIDE step.do. A task run re-executes its handler on every
// resume and memoizes only what a step returned, so an id minted at the top of
// a handler is a different id after a sleep.
const id = await step.do("mint-id", async () => ulid());

// The platform's timeout and retry policy for non-idempotent calls.
// `retries: 0` on email and the AI families is deliberate: a retried timeout
// would send the same email twice or bill the same generation twice.
await step.do("send-receipt", stepPolicy.email, () =>
  ctx.api.email.send({ body: { to, subject, htmlBody } })
);

stepPolicy carries email, llm, gemini and database. It is a default you apply, not a policy the engine forces on your own step.do calls.

step.do's config parameter is a StepConfig — { retries?: { limit, delay, backoff? }, timeout? }, Cloudflare Workflows' own step config, which each stepPolicy.<family> is one of. The shape is closed: a key outside it, such as retry for retries, fails the config push typecheck rather than being carried along and ignored.

Databases and documents, on the app's authority ​

There is no database grant. A function reaches every model of every database of the app, and every model of every document, on the app's own authority — the access gate decided who may ask. The whole records surface is open: query, count, aggregate, save, batch, patch, delete, increment, the string-set verbs and the index routes, through the typed handle or through ctx.api.databases.records.*.

The database's own tenant check still runs on every call: the platform hands the database a signed statement of whose database the call was minted for, and a database refuses a call minted for another tenant, whatever the sandbox sent. That is the boundary; the model list is not.

Documents are first-class beside databases. ctx.api.documents.records.query takes filter (an object, or its JSON string), limit and cursor, and answers the same { items, hasMore, nextCursor } page the route and the CLI do; count takes filter too:

ts
const page = await ctx.api.documents.records.query({
  documentId: input.documentId,
  model: "Order",
  filter: { status: "open" },
  limit: 50,
});
const rest = await ctx.api.documents.records.query({
  documentId: input.documentId,
  model: "Order",
  filter: { status: "open" },
  cursor: page.nextCursor,
});

A member's root document and user-scoped aliases ​

A function that acts on behalf of each user — a nightly summary fired by cron, say — has no caller, so nothing that defaults to "the caller" has anyone to default to. Name the user instead.

ctx.api.users.getRootDocument({ userId }) answers the member's root document id without creating one: { userId, rootDocId }, with rootDocId: null for a member who has never had a root document (they have never signed in). A user who is not a member of this app throws a PrimitivePlatformError with status 404. Read and write the document's records through ctx.doc(rootDocId):

ts
const { rootDocId } = await ctx.api.users.getRootDocument({ userId: input.userId });
if (rootDocId) {
  const prefs = await ctx.doc(rootDocId).model("UserPref").query({ limit: 1 });
  const optedOut = prefs.items[0]?.emailOptOut === true;
}

ctx.api.documents.aliases.resolve and .delete take the same userId for a user-scoped alias. Without it they act for the caller, and a function with no caller finds no alias (404 alias_not_found):

ts
const bound = await ctx.api.documents.aliases.resolve({
  scope: "user",
  aliasKey: "user-prefs",
  userId: input.userId,
});
const prefs = ctx.doc(bound.documentId).model("UserPref");

Over REST, GET /app/{appId}/api/users/{userId}/root-document needs an owner or admin; a function reaches it on the app's authority, like the rest of this section.

Reading and writing records ​

The typed handle ​

ctx.db(databaseId, "<type>") gives you the models that database type declares, typed from its schema:

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { databaseId: string }, ctx) => {
  const orders = ctx.db(input.databaseId, "orders").model("Order");
  const open = await orders.query({ filter: { status: "open" } });

  // Every row a read hands back carries its id, so a row you just queried is
  // a row you can change or remove.
  await orders.patch(open.items[0].id, { data: { label: "renamed" } });
  await orders.delete(open.items[1].id);

  return { open: open.items.length };
});

The second argument is the database type key, and it selects which models the handle knows. A model the type does not declare is a compile error, not a runtime surprise. It is a typing selector only: the platform reads the database's real type from its own row. The declarations behind the types are written by config push — see Typed from the declaration.

Every row a read hands back is typed with id: string, whatever its schema declares — the record identity belongs to the platform, not to your schema — so a row you queried is a row you can change or remove by its id.

Every query, write, conditional write and aggregate the handle offers, the untyped ctx.api.databases.records.* form, the $in/$nin cap and how to chunk past it, atomic increments and string-set operations, and registered queries (defineQuery / defineMutation) are all on Working with Databases. The type's timestamps directive stamps a handle write the same way — see Server-Stamped Fields.

One paged envelope ​

Every paged read the SDK exposes answers the same shape — { items, hasMore, nextCursor? }. The database handle, the document handle (ctx.doc(id)), ctx.users.list and the raw ctx.api.databases.records.query all use it, so paging code is written once and handed any of them. The raw operation adds a prevCursor, and it is the only surface that has one.

ts
let page = await orders.query({ options: { limit: 50 } });
const rows = [...page.items];
while (page.hasMore) {
  page = await orders.query({ options: { limit: 50, uniqueStartKey: page.nextCursor } });
  rows.push(...page.items);
}

The database handle's own list and filter caps are on Working with Databases.

The typed document handle ​

Documents get the same handle as databases. ctx.doc(documentId) addresses one document; .model("<Model>") binds one of its record models, and the six operations answer the record routes' own shapes — { items, hasMore, nextCursor }, { count }, { record }, { deleted }:

ts
import { defineFunction } from "primitive-functions";

export default defineFunction(async (input: { documentId: string }, ctx) => {
  const orders = ctx.doc(input.documentId).model("Order");

  const open = await orders.query({ filter: { status: "open" }, limit: 50 });
  const rest = await orders.query({ filter: { status: "open" }, cursor: open.nextCursor });
  const { count } = await orders.count({ filter: { status: "closed" } });

  const { record } = await orders.save({ id: "o-1", data: { label: "first", status: "open" } });
  await orders.patch("o-1", { data: { label: "renamed" } });
  const { deleted } = await orders.delete("o-1");

  return { open: open.items.length + rest.items.length, count, record, deleted };
});

A large document (documentFormat: 2) can be stated as such: ctx.doc(documentId, { documentFormat: 2 }) applies that expectation to every verb of the handle. When it disagrees with the format the platform resolved, the call is refused with 409 DOCUMENT_FORMAT_MISMATCH — error.errorCode on the thrown PrimitivePlatformError — before any table is read or written, rather than answered out of the wrong tables. The one-argument form states nothing and is answered exactly as before.

Its rows carry id: string for the same reason a database row does: the record identity is the platform's, so query().items[0].id, save(...).record.id and patch(...).record.id are all there to hand straight back to patch or delete, whatever the schema declares.

The rows are typed from your project's models/models.toml — the document schema primitive init wires (the web client's src/models/models.toml is the fallback). config push renders it into functions/primitive-document-types.d.ts beside the other declarations, so a model the schema does not declare is a compile error and a declared field has its declared type. A project with no schema file gets the open form: any model name, rows as open records. Like ctx.db, the handle confers nothing — every call is one of the ctx.api.documents.records.* operations on the app's own authority, and a document id from another app is the uniform not-found.

batch applies an ordered blob of ONE model's ops in a single transaction — all of it commits or none of it does, and a connected client sees one update:

ts
await ctx.doc(input.documentId).model("Order").batch([
  { action: "create", id: firstUlid, data: { label: "one", status: "open" } },
  { action: "patch", id: "o-1", data: { status: "closed" } },
  { action: "delete", id: "o-2" },
]);

Every op's model is the handle's, so an op that names another one is overwritten. A blob that genuinely spans models is ctx.api.documents.records.bulk — the raw operation each op names its own model on.

A function may be a document's first writer ​

A document's record models carry their schema inside the document (_meta_<model>), and a collection-typed field — a stringset — cannot be written without it. A function can write that metadata itself, so a task can project into a document no client has opened yet. config push carries your project's models/models.toml inside the function's version, and the first write to a model that version declares seeds the model's metadata from it and writes the record in the same operation — one transaction, one persisted update, one broadcast. The record and its stringset read back from a client exactly as if a client had saved the model first; the write that seeds is coerced, defaulted, stamped and uniqueness-checked against the declaration it just wrote.

Five things are worth knowing:

  • A model your schema does not declare is not seeded. A scalar write to it succeeds, and a collection value is refused:

    Cannot write array value to field "bankAccountIds" on model "institution":
    its declared type could not be resolved to a collection (stringset).

    Nothing in a function's request body can supply a schema — the platform builds it from the version that is running.

  • A client's declaration wins. If the document already declares the model, the function's write leaves that declaration exactly as it is.

  • Editing models/models.toml re-pushes every function. The schema is part of each version's identity, so config diff reports every function Modified after a schema edit and the next config push mints a new version of each. That is what makes a running version's seed source knowable.

  • A schema declaring a reserved field name is refused at the push. A model field named type, or one starting with _, collides with the storage engine's own columns, and a version is immutable — so the push answers 400 naming the block rather than minting a version that would seed it. See Reserved field names.

  • config pull writes the schema back as the generated tree file functions/primitive-document-schema.generated.toml, so a fresh pull into a project that has no models/models.toml still pushes the same version. Push resolves the project file first and that copy only as the fallback, and regenerates the copy from the project file on every push.

primitive functions get prints the models the running version can seed, so "why did the first write not seed institution?" has an answer that does not involve opening the bundle.

Following up on a write ​

A function that writes a record is the place to do the work that follows the write. It has the row in hand, it knows why the write happened, and its caller can see what it did — so write the row, then publish, notify or write again, in the same handler:

ts
// primitive/dev/functions/place-order.ts
export default async function (input, ctx) {
  const saved = await ctx
    .db(input.databaseId, "orders")
    .model("Order")
    .save({ id: input.orderId, data: { label: input.label } });

  // …then publish, in the same handler, with the row in hand. `save` answers
  // `{ success, id, appliedFields? }` — not the row: the values you wrote are
  // the values you have, and `appliedFields` carries anything the platform
  // stamped on top of them (timestamps, trigger-computed fields).
  await ctx.channels.publish(`orders:${saved.id}`, {
    op: "save",
    label: input.label,
  });

  // …or write another type, notify, call an integration — whatever the write
  // was FOR.
  await ctx
    .db(input.shipmentsDatabaseId, "shipments")
    .model("Shipment")
    .save({ data: { orderId: input.orderId } });

  return { placed: input.orderId };
}

If the follow-up is slow — a long fan-out, a call that may take minutes — hand it to a task rather than making the caller wait:

ts
await ctx.functions.start(
  "reconcile-order",
  { orderId: input.orderId },
  { runKey: `reconcile-${input.orderId}` }
);

The follow-up runs in order, it cannot loop, and it fails where you can see it.

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30