Invoking and Triggers
A server function runs when something calls it: a client or the CLI over HTTP, a provider's webhook, or a cron schedule. This page covers each way in, what the call answers, and the typed invokers your app code calls. What each runtime does once the code is running is on Tasks and Requests.
Invoking
POST /app/{appId}/api/functions/{key}Any signed-in member of the app may call it — subject to the function's own access gate. From a client, client.functions.invoke takes the key and the input and answers the envelope below; the JS and Swift calls are the same shape:
const result = await client.functions.invoke<{ message: string }>("greet", {
input: { name: "Ada" }, // travels as rootInput
timeoutMs: 10_000, // default 5 000, ceiling 30 000
});
if (result.status === "completed") {
console.log(result.output?.message); // "hello Ada"
} else {
// "failed" — the handler threw or its output failed outputSchema — or "timeout"
console.error(result.status, result.error);
}struct Greeting: Decodable, Sendable { let message: String }
let result: FunctionResult<Greeting> = try await client.functions.invoke(
"greet",
input: ["name": "Ada"], // travels as rootInput
timeout: 10 // seconds; default 5, ceiling 30
)
if result.status == "completed" {
print(result.output?.message ?? "") // "hello Ada"
} else {
// "failed" — the handler threw or its output failed outputSchema — or "timeout"
print(result.status, result.error ?? "")
}The response envelope
{ "status": "completed", "output": { "message": "hello Ada" } }status is one of:
status | What happened |
|---|---|
completed | The handler returned. output is its value, JSON-serialized. |
failed | The handler threw, or its output did not match outputSchema. error says which. |
timeout | The handler did not finish inside the request's budget. error says so and errorCode is FUNCTION_TIMEOUT. |
All three are HTTP 200: the call reached the function and the function has an answer. An HTTP error means the platform refused before your code ran — 403 for the gate or a disabled function, 404 for an unknown key, 409 for a function with no pushed code, 429 for the rate ceiling, and 503 with errorCode: "FUNCTION_RATE_UNAVAILABLE" when the platform cannot consult that ceiling at all. The last one is retryable: the platform refuses rather than run your function unmetered.
There is no runId, because the request path writes no run row. Nothing to poll, nothing to clean up.
Every envelope the platform recorded carries an invocationId — the id of the invocation-log record for that call, minted before your code ran so the answer and the record share it:
{
"status": "failed",
"error": "Cannot read properties of undefined",
"errorCode": "FUNCTION_THREW",
"invocationId": "01K4ANRRG0Q7VZ3M8T6XW2H5NB"
}Hand it straight to the logs surface:
primitive functions logs <function-id> --invocation 01K4ANRRG0Q7VZ3M8T6XW2H5NBIt is present exactly when the platform wrote a record, which is every outcome your function itself produced. A gate refusal — 403, 404, 409, 400, 429, 503 — carries none, because it wrote none.
An envelope from a handler that ran to an answer — completed, or failed because it threw or its output failed outputSchema — also carries limits: the ceilings that invocation actually ran under, which is your [function.limits] clamped element-wise to the platform's own values:
{
"status": "completed",
"output": { "message": "hello Ada" },
"limits": { "cpuMs": 2000, "subRequests": 128, "ratePerMinute": 1200 }
}It is there so you can tell what the platform resolved from what you declared, without guessing: a config asking for more than a ceiling gets the platform's value silently, and this is where you see that happen. A timeout, an output over the size ceiling, or a refusal that never reached your code — 403, 404, 409, 429 — carries no limits.
Choosing the runtime
The route is how a caller says which runtime it means. There is no config key for it and no body field: a function is a function, and every pushed function takes both.
| Call | Runtime | What you get back |
|---|---|---|
POST /app/{appId}/api/functions/{key} | request | The result, inside the call. |
POST /app/{appId}/api/functions/{key}/start | task | A run id, immediately; the code goes on running. |
client.functions.invoke and the CLI's primitive functions invoke post to the first; client.functions.start and primitive functions start post to the second. Both take the same prologue — the access gate, the disabled and unpushed refusals, the input schema and the rate ceiling — so nothing about a function's authorization moves with the runtime.
A function that must not run under one of the two says so in its own code with assertRuntime, which is the only lock there is. See Tasks and Requests.
Request fields
Every field is optional:
| Field | Meaning |
|---|---|
rootInput | The value your handler receives as input. Validated and coerced against inputSchema when the function declares one. |
contextDocId | A document the invocation is about. |
meta | Caller metadata, at most 1 KB encoded. |
timeoutMs | Wall-clock budget. Defaults to 5 000 ms and clamps silently to the 30 000 ms ceiling. |
runKey | Used by task starts (below); validated and otherwise ignored here — there is no run row to deduplicate against. |
Starting one and polling it
POST /app/{appId}/api/functions/{key}/start, which answers 201 with a run id instead of a result:
{ "runId": "01J…", "runKey": "01J…", "instanceId": "app-doc-01J…", "status": "running" }From a client, client.functions.start answers that envelope, and client.functions.getStatus / client.functions.waitFor poll the run by its id:
const run = await client.functions.start("order-sync", {
input: { orderId },
runKey: `order-${orderId}`, // a repeat replays the existing run (existing: true)
});
// Poll once…
const now = await client.functions.getStatus(run.runId);
console.log(now.status);
// …or wait for a terminal state.
const settled = await client.functions.waitFor<{ confirmedAt: number }>(run.runId, {
timeoutMs: 60_000, // throws WORKFLOW_WAIT_TIMEOUT past it
});
// A failed run RESOLVES — branch on the status, don't catch. `error.message`
// is the reason, a platform refusal leads it with its code, and `error.code`
// is that code on its own.
if (settled.status === "failed") {
console.error("order-sync failed:", settled.error?.message ?? "no reason given");
} else {
console.log(settled.status, settled.output?.confirmedAt);
}
// The function key goes where a workflow key would.
await client.functions.terminate({ functionKey: "order-sync", runKey: run.runKey });struct Confirmation: Decodable, Sendable { let confirmedAt: Int }
let run = try await client.functions.start(
"order-sync",
input: ["orderId": orderId],
runKey: "order-\(orderId)" // a repeat replays the existing run (existing == true)
)
// Poll once…
let now = try await client.functions.getStatus(runId: run.runId)
print(now.status)
// …or wait for a terminal state.
let settled = try await client.functions.waitFor(
runId: run.runId,
as: Confirmation.self,
options: FunctionWaitOptions(timeout: 60) // throws workflowWaitTimeout past it
)
// A failed run RESOLVES — branch on the status, don't catch. `error.message`
// is the reason, a platform refusal leads it with its code, and `error.code`
// is that code on its own.
if settled.isFailure {
print("order-sync failed:", settled.error?.message ?? "no reason given")
} else {
print(settled.status, settled.output?.confirmedAt ?? 0)
}
// The function key goes where a workflow key would.
_ = try await client.functions.terminate(
FunctionRunRef(functionKey: "order-sync", runKey: run.runKey)
)These three answer a function run status: FunctionRunStatus in the JS client (FunctionRunSettled once a wait has settled), FunctionRunStatus and FunctionRunResult<Output> in Swift — a run block carrying the function run's own fields. functions.waitFor takes FunctionWaitOptions. Which routes the client polls is its business, not yours. A run that failed resolves rather than throws — what its error carries is under When a run fails.
functions.terminate takes the function key and the run key. A run it cannot find is NOT_FOUND / .notFound; a run that exists and that the engine could not stop is UNAVAILABLE / .unavailable, carrying the engine's own diagnostic after the message — the two are different answers, and only the second one means "try again".
timeoutMs is accepted and ignored on a task start: the task engine owns how long a run may take, and a per-request budget is a promise this path cannot keep across a step.sleep.
Neither verb is wrong on any function: that is what "the caller picks" means. A function that must not be started this way — or must not be invoked — says so in its own code.
A running function starts another as a task run with ctx.functions.start — see Starting a task run from a function.
Typed client invokers
The same config push that types a function's own code (Typed from the declaration) renders a typed invoker per function for the app that calls it:
functions/generated/<key>.generated.ts— a typed client invoker for your app's code, importing only fromjs-bao-wss-client. Every function's invoker carries both verbs —invoke,start,getStatus,waitForandterminate— because the caller picks the runtime at each call.inputis required exactly when the schema rejects{}, and thewaitForresult'soutputis<Key>Output.tsimport { greet } from "../primitive/dev/functions/generated/greet.generated"; import { orderSync } from "../primitive/dev/functions/generated/order-sync.generated"; const answered = await greet(client).invoke({ input: { name: "Ada" } }); answered.output?.greeting; // string | undefined const run = await orderSync(client).start({ input: { orderId } }); const settled = await orderSync(client).waitFor(run.runId); settled.output?.confirmedAt; // typed from order-sync's outputSchema // greet(client).start(...) runs the same function as a task: every invoker carries both verb setsfunctions/generated/<key>.generated.swift— the same invoker for a Swift app, emitted byprimitive functions codegen --lang swift. It carries<Key>Input/<Key>OutputasCodabletypes and a<Key>Functionstruct reached through a<key>(client)factory, bound over the genericclient.functionsoverloads. Every invoker carries both verb sets, as in TypeScript:invokeruns the function inside the request;start,getStatus,waitForandterminatedrive it as a task. A function with no declared schema getstypealias <Key>Input = JSONValuerather than an empty struct, and a key that is not a legal Swift identifier is mangled (123-job→_123JobFunction,_123Job(client)) while the call still passes the original key.swiftlet answered = try await greet(client).invoke(input: GreetInput(name: "Ada")) answered.output?.greeting // String? let run = try await orderSync(client).start(input: nil, contextDocId: docId) let settled = try await orderSync(client).waitFor(runId: run.runId) settled.output?.confirmedAt // typed from order-sync's outputSchema // greet(client).start(...) runs the same function as a task: every invoker carries both verb setsbashprimitive functions codegen --lang swift -o Sources/App/Functions/GeneratedSwift mode writes no TypeScript artifact and no tsconfig wiring;
--checknames the stale files and its hint carries--lang swift. The two languages can sharefunctions/generated/: each sweeps only the files carrying its own banner. The Swift app template runs this fromscripts/codegen.shon every build path, so the committed invokers never drift.On the Swift client there is no untyped entry point:
client.functionstakes anEncodableinput and decodes into aDecodableoutput. A caller with no per-key type names the witness —client.functions.invoke(key, input: nil as JSONValue?)boundas FunctionResult<JSONValue>.The invokers land under
functions/generated/by default;primitive functions codegen -o <dir>writes them somewhere else, and a function whose TOML is removed loses its invoker on the next push. They're generated and never pushed like every other fileconfig pushwrites intofunctions/— see Server Functions — Typed from the declaration for the rewrite rule, the tsconfig exclusion, andcodegen --check.
Triggers
A function does not have to wait for an HTTP call. Its config block can declare an inbound webhook and any number of cron schedules, and the platform creates and operates whatever they need — there is no separate webhook or cron object to manage. Which runtime a fire uses is the door's, not the declaration's — see Runtimes. A webhook-fired function hands long work on with ctx.functions.start.
# primitive/dev/functions/stripe-events.toml
[function]
key = "stripe-events"
entry = "functions/stripe-events/index.ts"
access = "false" # only its triggers run it; no member calls it over HTTP
# One webhook per function. It answers on the function's own key.
[function.triggers.webhook]
verificationScheme = "stripe"
signingSecret = "{{secrets.STRIPE_WEBHOOK_SECRET}}"
[[function.triggers.cron]]
name = "nightly"
cron = "0 3 * * *"
timezone = "UTC"
rootInput = { scope = "full" }
[[function.triggers.cron]]
name = "hourly-sweep"
cron = "0 * * * *"
overlapPolicy = "skip"// primitive/dev/functions/stripe-events/index.ts
import { defineFunction } from "primitive-functions";
export default defineFunction(async (input: { type?: string }, ctx) => {
if (ctx.trigger.kind === "webhook") {
// webhookKey (the function key), webhookId, externalEventId (the
// provider's X-Webhook-Event-Id, or null)
return { received: input.type, event: ctx.trigger.externalEventId };
}
if (ctx.trigger.kind === "cron") {
// name, triggerId, scheduledFor (ISO 8601)
return { swept: ctx.trigger.name, at: ctx.trigger.scheduledFor };
}
return { invokedBy: ctx.user?.userId ?? null };
});ctx.trigger is a union narrowed on kind, and each arm's fields are typed — they are the fields the platform builds, held equal to the declaration by a test, so a field you read is one the fire really sets. ctx.user is FunctionUser | null: a function a trigger can fire reads it as ctx.user?.userId.
primitive config push applies it, and primitive functions get <function-id> shows the result — the receiver URL to give the provider, the schedule the platform holds, and when each last fired.
The webhook trigger
The receiver path, the verification schemes, signing secrets, deduplication and the delivery log are on Inbound Webhooks. Accepted keys are verificationScheme, signingSecret, toleranceSeconds, deduplicationEnabled, deduplicationWindowMs, maxBodyBytes, secretGracePeriodMs, and a [function.triggers.webhook.verification] table for scheme-specific settings (a Discord public key, a JWT's JWKS, …).
A verified delivery runs the function inline, under the request runtime, and answers 200 {"received": true} whatever the function did — the delivery was accepted, and the run row records the outcome. A delivery that cannot run right now (the function is disabled, or the burst is past its rate ceiling) is answered 202 with the reason in the delivery log and no dedup key stored, so the provider's redelivery still runs once the condition clears.
To exercise the trigger end to end without a provider, take the webhookIdprimitive functions get <key> prints and run primitive webhooks test <webhook-id> --payload '{…}' --deliver: it signs the payload and posts it to the webhook's receive endpoint, so verification, dedup and dispatch all run for real, and it reports the run id to inspect with primitive functions runs <function-id>. That is a real delivery — it writes a delivery row and spends the body's dedup key — so see Delivering the payload for real before using it on a scheme whose suppression never expires.
Work that outlasts the delivery
A webhook delivery runs under the request runtime. When the work a delivery kicks off is longer than one bounded invocation, the pattern is two functions: the one the provider calls verifies and acknowledges, and hands the work on with ctx.functions.start.
# primitive/dev/functions/provider-events.toml — the function the provider calls
[function]
key = "provider-events"
entry = "functions/provider-events.ts"
access = "false"
[function.triggers.webhook]
verificationScheme = "custom"
signingSecret = "{{secrets.PROVIDER_WEBHOOK_SECRET}}"# primitive/dev/functions/process-batch.toml — the function that does the work
[function]
key = "process-batch"
entry = "functions/process-batch.ts"
access = "false" # started only by provider-events; the callee's gate is not consulted// functions/provider-events.ts
export default async function (delivery, ctx) {
// Verification already happened: the receiver would not have run this.
return await ctx.functions.start(
"process-batch",
{ batchId: delivery.batchId },
// The run key makes the start idempotent. Use the provider's own event id,
// or an id from the body when a redelivery should replay the same work:
// a second delivery with the same key answers with the FIRST run rather
// than starting a second one.
{ runKey: delivery.batchId }
);
}The delivery is answered as soon as the run exists — usually in tens of milliseconds — and the task run keeps going for as long as it needs. primitive functions runs <process-batch-id> lists each run with the webhook function as its parent.
Cron triggers
Each entry needs a name; it is half of the trigger's identity and appears on every run. cron is a standard five-field expression, timezone is IANA (default UTC), overlapPolicy is skip (default) or allow, and rootInput is the object the function receives. A function may declare up to ten.
Every fire runs as a task run — a run row, the task runtime's budgets, the same run routes and the same primitive functions runs listing as a run somebody started by hand, and overlapPolicy asked of the engine. There is no per-entry choice to make.
# primitive/dev/functions/categorise-sweep.toml
[function]
key = "categorise-sweep"
entry = "functions/categorise-sweep.ts"
access = "false"
[[function.triggers.cron]]
name = "nightly"
cron = "0 3 * * *"
overlapPolicy = "skip"
# The same function on a second schedule. Both fires start task runs.
[[function.triggers.cron]]
name = "hourly-touch"
cron = "0 * * * *"primitive functions get <key> prints each entry's schedule, timezone and overlap policy; primitive functions runs <id> lists the runs its fires started, with task in the RUNTIME column (--json items carry it as runtime).
// functions/categorise-sweep.ts — ctx.user is null; nobody called this.
export default async function (input, ctx, step) {
for (let batch = 0; batch < 30; batch++) {
await step.do(`batch-${batch}`, async () => {
/* one batch of the sweep; the platform yields between steps when a slice runs low */
});
}
}With overlapPolicy = "skip", a scheduled fire that arrives while the previous run is still going is counted as a skip instead of starting a second one. That question is put to the engine, not only to the run row: a run settles its own row as it ends, but a settlement that could not be written is logged rather than retried, so a row can still read running after its run is over — and a schedule that trusted the row would stall behind it. A sleeping run counts as live — a run part-way through a step.sleep is still going — and so does a run the platform cannot be asked about, because a probe that cannot be answered must not become a second copy of a job that may still be running. Such a cycle is counted as a skip and the next one asks again.
overlapPolicy = "allow" starts one run per fire.
Have the cron-fired function start the work with ctx.functions.start when you want something a cron entry cannot express: a run key, so a fire that arrives twice in a period replays the first run instead of starting a second ({ runKey: \digest-${today}` }), or an input computed at fire time rather than the fixed rootInput` — the Iterate every user example is exactly that shape.
What a trigger fire records
Unlike an HTTP invocation, a webhook or cron fire writes a run row — that is the only record it leaves, since nobody is waiting for an answer.
primitive functions runs <function-id>Runs come back newest first with the status, what fired them, the timings, the failure code and — for a task run — how many times its slice was refreshed (REFRESHES). A trigger fire has no caller, so ctx.user is null inside the function; the run's executionPrincipal records the app's system principal.
Editing and removing triggers
The declaration in the file is the whole truth. Removing a cron entry cancels its alarm; re-adding it schedules it again. Removing and restoring the webhook block, and rotating its signing secret, are covered on Inbound Webhooks — see Limits and Removal and Signing Secrets.