Server Functions
A server function is TypeScript you author in your config tree and push to the platform. It lives beside your prompts and integrations as config-as-code: a functions/<key>.toml file states the gate and the entry point, the code sits next to it, and primitive config push builds and ships both.
This page covers writing, pushing, monitoring and operating a function. Three more pages cover the rest:
- Invoking and Triggers — every way to call a function: a client invoke, a task start, a webhook and a cron schedule.
- Tasks and Requests — the two runtimes a function runs under, and how a task run behaves: steps, resets, budgets, run status and run error codes.
- The Function Context — the platform API a function calls through
ctx: records, documents, prompts, secrets, channels, email and outside services.
Authoring a function
Scaffold the file, then write the code its entry names:
primitive config create function greet# primitive/dev/functions/greet.toml
[function]
key = "greet"
description = "Says hello"
# The TypeScript entry point, relative to the config tree root.
entry = "functions/greet/index.ts"
# CEL gate. Required before code can be pushed — a function with no gate fails
# closed at invoke. "true" allows any authenticated caller. The gate is the
# authorization: inside the function, the code acts as the system.
access = "true"
# cpuMs, subRequests, ratePerMinute — each may lower a platform ceiling, never
# raise one. The wall-clock budget is a request field, not a config key.
[function.limits]
cpuMs = 2000// primitive/dev/functions/greet/index.ts
import { defineFunction } from "primitive-functions";
import { salute } from "./greeting.js";
export default defineFunction(async (input: { name?: string }) => {
return { message: salute(input.name ?? "world") };
});Run primitive config fields function for the full key list with types and defaults.
Pushing
primitive config pushconfig push builds the bundle with esbuild, inlines your npm dependencies, and stores the built bundle together with the authored TOML bytes and every source file it read. primitive-functions is the one import left external: the platform supplies it at invoke time, so your function always runs against the SDK the platform is running.
Every push that changes anything creates a new immutable version and repoints the function at it. "Anything" means the whole envelope, not just the compiled output: a comment-only or line-ending-only edit to a source file makes a new version too, so primitive config pull on a fresh clone always hands back the bytes you wrote — comments, key order and all. Pointing a function back at an earlier version is covered under Versions and rollback.
The same push also typechecks your sources against the declarations it generates, and refuses a function that does not compile — see Typechecked before it ships.
Imports
- Relative and absolute imports must stay inside the config tree. An entry or an import that resolves outside it — through a symlink included — is refused at push, so a push can never upload a file your tree does not contain.
- npm packages are imported by name and inlined into the bundle. They resolve through Node like any other import, and
node_modulescontent is never stored as your source or written back byconfig pull.
A missing entry file or a build error names the file and the message, and nothing is pushed for that function — the rest of your config tree still applies.
What a function touches is documented, not declared
config push collects a manifest with every version: the queries it registered, the models its code names — through the typed handle, the direct records API and the document records family alike — and the families of ctx.api and the ctx helpers it reaches. primitive functions get shows it under MANIFEST, and a model name the scan could not resolve (a computed modelName) is marked "resolved at run time" rather than silently dropped.
It is documentation, never authorization: a reviewer reads it to see what a change reaches, and nothing on the platform decides anything from it.
What config push runs
Collecting the manifest means running your tree's module scope: config push builds the bundle a second time with primitive-functions replaced by a recording stub, and executes it in a separate Node process with a scrubbed environment, your config tree as its working directory, and a hard timeout. It is the same trust you extend by running the project's own build — but it is worth knowing before you push a project you did not write. The models and families the manifest lists come from a static scan of the built bundle beside that run, so a literal modelName in a direct records call is recorded with no registration in sight.
A push whose collection FAILS is refused, naming the error: the manifest is part of the version's identity, so there is no "push now, collect later".
The gate runs on every call
access is evaluated on every HTTP call, and it fails closed:
- a caller the expression denies gets
403witherrorCode: "FUNCTION_ACCESS_DENIED"; - a function with no
accessexpression denies everyone (which is why push refuses to ship code for one); - app owners and admins bypass it;
- a trigger fire and a nested start do not consult it — the gate governs the HTTP route. A function that only its triggers or another function should run declares
access = "false", a gate no member passes, which closes that route.
The gate is checked before any answer about the function's operational state, so a caller it rejects cannot tell a disabled function from a never-pushed one from a live one — and cannot map your app's functions by probing keys.
The gate is the authorization. Once a caller is through it, the code runs with the app's own authority (see Calling the platform from a function): who may invoke is decided here, once, by the expression you reviewed, and nothing inside the function asks the question again. Write the gate for what the function can do, not for who happens to be the caller of the first line.
Typed from the declaration
config push writes the declarations that type the database handle, the document handle and the SDK into your config tree's functions/ directory, beside your sources, along with a functions/tsconfig.json that includes them — the full file list is on Configuring Primitive Services — The Functions Directory. There is nothing to wire up: open the folder in your editor and the types are there. config diff tells you when a schema change has left them stale, and the next config push refreshes them. Treat them as build output — an edit to one is discarded by the next push. None of the generated files is part of what gets pushed.
Push does not just write them: it holds your code to them, so a model, a schema or an SDK type that moved under a function is a refused push rather than a run-time surprise (see Typechecked before it ships).
In CI, ask the question directly — it exits non-zero when the generated files are out of date or the tsconfig no longer loads them:
primitive functions codegen --check # or without --check to regenerateThe tsconfig.json is yours to adjust — push writes it once and then leaves your settings alone. The parts it keeps are the ones that make the types load at all: the primitive-functions path mapping, the generated declarations being in the program, and the generated/** exclusion (the invokers are for your app's code, not the function's own program). If any of them goes missing, config diff reports the file and the next push puts them back, alongside everything else you have written in it.
A function's inputSchema and outputSchema are enforced at every door — an HTTP invoke, a webhook or cron fire — and since the file declares them, the code and the caller can be typed from them too. On every push (and with primitive functions codegen), the CLI renders more files from functions/<key>.toml: the typed client invokers your app calls, covered under Typed client invokers, and the declaration that types the function's own code:
functions/primitive-function-types.d.ts—<Key>Inputand<Key>Outputfor every function, from its declared schemas, plus aFunctionSchemasaugmentation keyed by function key. This is what types the keyeddefineFunction:ts// primitive/dev/functions/greet/index.ts import { defineFunction } from "primitive-functions"; // `input` is GreetInput; the return is checked against GreetOutput; a key // this tree does not declare is a compile error, and a key that names // another file's function is refused at push. export default defineFunction("greet", async (input, ctx) => { return { greeting: `hello ${input.name}` }; });The unkeyed
defineFunction(async (input: MyInput, ctx) => …)still works, with the types you write. In a tree that has not pushed yet the keyed form compiles withany, so nothing is blocked on the first push.
The same push also types ctx.prompts.run from your prompts' declared output schemas — see Typed, parsed output.
Typechecked before it ships
The same push that writes the declarations above typechecks your sources against them, and refuses the function with the compiler's own diagnostics when they do not hold:
✗ 1 change(s) could not be applied:
FAILED function: greet
function 'greet' does not typecheck against the declarations `config push` generates:
greet/index.ts(4,9): error TS2322: Type 'string' is not assignable to type 'number'.
Reproduce with `tsc -p functions/tsconfig.json --noEmit`; push with --no-typecheck to ship it unchecked.esbuild erases types, so a bundle that builds says nothing about whether the code still holds: a function that dereferences ctx.user without checking for null builds cleanly and throws on the request path. The check is the compiler your editor is already running — the tree's own functions/tsconfig.json decides the options, and reproducing a refusal by hand is tsc -p functions/tsconfig.json --noEmit.
It is per function: a broken one is refused by name and everything else in the tree still lands. --dry-run reports the same diagnostics and ships nothing. primitive config push --no-typecheck skips the check, for the push you need out before the types are fixed.
Recording
Every HTTP invocation writes one function.invoke analytics event, attributed to the caller — the signed-in user who made the request, never the app itself, though the code ran as the app. It carries the function key, the version that ran, the terminal status and the duration. A trigger fire has no caller and writes no per-user event; its record is the run row.
The event is a COUNT — it carries no message and no error — so it answers "how often" and never "why". For why, read the invocation records below. A failed invocation's event does report outcome: "error" through the shared inspection contract, so failures are countable.
Debugging a failing function
Every invocation the platform dispatched toward the sandbox leaves a record, and console.log is where you read it back:
primitive functions logs <function-id> # newest first
primitive functions logs <function-id> --json # the shared inspection items
primitive functions logs <function-id> --follow # tail as invocations happen
primitive functions logs <function-id> --limit 50 --cursor <cursor>Reading one record by the invocationId an invoke answered with (--invocation), and one task run's records (--run), is covered in Triaging a failing function. --invocation names one record, so it cannot be combined with --run, --follow, --cursor or --limit, which all shape a listing. --run narrows to one run's records and cannot be combined with --follow, because a run's records are a bounded set and a tail walks the whole index.
How --follow tails — its polling, its NDJSON output, and how it still shows a slow invocation that settled after a faster one — is covered in Watching live.
An id that names nothing, one belonging to another function, and one whose seven days are up all answer the same not-found: the platform does not say whether a record it will not show you ever existed.
A record carries what the invocation printed (console.log/info/debug/ trace as stdout, warn/error as stderr), the thrown error with its code and a bounded stack, the runtime it ran under (request or task), and the correlation keys: the app, the function, the config version that ran, the run id for a trigger fire or a task run, and what triggered it. Records are kept for seven days.
Output from inside a step
A line printed inside a step.do body is captured like any other, and it carries which step wrote it: the step's name, which call of that name it was (counting from 0 across every named step call in the slice — do, sleep, sleepUntil and waitForEvent alike, so a label lines up with the row of the same name in primitive functions runs steps), and which attempt of that body it was. A line printed between steps carries none of the three, which is how you tell them apart.
If a body throws, the platform records the throw under that step as an err line — step "<name>" attempt <n> threw: <message> — so a failed run shows where it failed as well as what it printed on the way. The throw itself is unchanged: the engine still sees it, still retries it if you configured retries, and still fails the run if it runs out.
An attempt counts the times that body actually ran, from 1, and resets for the next call of the name. A retried body's lines therefore sit under one name and one occurrence with ascending attempts, and a step the engine answers from its memo on a replay runs no body and records nothing new.
--run prints the run's step trace and then, per record oldest first, the record's summary and its lines under step headers:
primitive functions logs fn_01ABC --run 01M2M8...STEP STATUS DURATION
load completed 12ms
charge completed 84ms
ship failed 9ms
04:39:45 failed http cfg_01XYZ 01M2M8... FUNCTION_THREW the depot is closed
+0ms out between: starting
step load #0 attempt 1
+4ms out loading the order
step charge #0 attempt 1
+19ms out charging the card
step ship #0 attempt 1
+103ms out about to ship
! +104ms err step "ship" attempt 1 threw: the depot is closedLines print in the order they were printed, with stdout and stderr interleaved as they happened — a console.error immediately followed by a console.log comes back in that order even though the record stores the two streams separately. A record whose output hit a cap ends with a visible marker, … truncated: the platform's per-record cap dropped later lines, so a shortened record never reads as a complete one.
Every door writes one — an HTTP invocation, a webhook delivery, a cron fire, and a task run's settling slice. A timed-out invocation writes one too, carrying what the function printed before it hung, which is usually the whole story.
A gate refusal writes none. A 403 from the access rule, a 404 for an unknown key, a 409 for a version that was never pushed, a 400 from the input schema and a 429 from the rate ceiling are all answered in the HTTP response and debugged from there; they never reached your code, and rows a caller can make on demand are rows a caller controls.
A function can read its own records too — ctx.api.functions.logs({ functionKey, limit }) — and so can the admin API. Both are owner and admin only.
Secrets are redacted, best-effort
A value ctx.secret() returned is replaced with [REDACTED:<NAME>] in the captured lines, in the lines forwarded to the live console, and in the error message and stack, on both sides of the sandbox boundary. It is best-effort by nature: a secret your code transformed before printing — sliced, encoded, interpolated into a signature — is not detectable. Do not print credentials.
Task runs have a narrower guarantee, and it is about SLICES, not about steps. A task run's record is written when the slice that SETTLES it finishes, and it carries everything that slice printed — between steps and inside step bodies alike. What it cannot carry is what an EARLIER slice printed: a slice that hibernated at a step.sleep never settles, so it writes no record, and a step.do body that ran in it does not re-execute on replay. Output from before a hibernation is not recoverable. The same applies to a fallback yield the platform takes between steps when a slice runs low (see Budgets in a task run), which is a hibernation like any other sleep.
So a run that sleeps leaves one record per slice that settled, and a run that never sleeps leaves one record with everything in it. If you need progress from across a long run, print it in the slice that settles or write it as data — and note that a local development server never hibernates, so a task run there is one slice and one record however many times it sleeps.
Three markers are worth knowing. truncated says a line or the whole channel hit its cap (2 KiB per line, 16 KiB and 256 entries per invocation); the invocation itself is never failed for logging too much. logsUnavailable says the platform could not retrieve the buffer at all — an evicted isolate, or a dispatch refused before any code ran — which is different from a function that printed nothing. An attempt that ended in the engine before the handler printed anything carries endedIn: "engine" on its record, and functions logs --run prints it as ended in the engine — no handler output (or logs unavailable / output suppressed when a marker applies). contentSuppressed says the platform could not load every secret the version declares when it came to write the record — the store did not answer, or a declared secret:<NAME> has no value in this environment — so it kept the correlation and the status and dropped the console and the error fields rather than publish them unredacted. A declared secret that is not provisioned suppresses every record of that version, including the FUNCTION_SECRET_NOT_FOUND it would otherwise show you: provision the secret, or drop the declaration, and invoke again.
Records outlive the function. Archiving a function does not remove its records, and both read surfaces keep answering for it until the seven days are up — which is the point, since a function is usually archived because it was failing.
Operating a function
primitive functions list # keys, status, and which carry a webhook or crons — each one's status, schedule, next and last fire
primitive functions get <function-id> # …plus the active version, its capabilities, its manifest, and the triggers' receiver URL, ids and secret state
primitive functions configs <function-id> # every version this function's pushes made, newest first
primitive functions activate <function-id> <config-id> # point it at one of them (rollback); makes no version
primitive functions runs <function-id> # what its webhook and schedules fired
primitive functions runs steps <function-id> <run-id> # one task run, step by step
primitive functions runs terminate <function-id> <run-id> # end a run that will not settle
primitive functions logs <function-id> # what it printed, and what it threw
primitive functions disable <function-id>
primitive functions enable <function-id>
primitive functions archive <function-id>Reading a task run step by step, and ending one that will not settle, are covered on Tasks and Requests — see Reading a task run step by step and Ending a run that will not settle.
Running one
primitive functions invoke <key> --input '{"n":21}' # request runtime: run it inside the request, print the result
primitive functions start <key> --input '{"n":21}' # task runtime: start a run, print its id
primitive functions start <key> --wait # …and wait for it
primitive functions runs wait <function-id> <run-id> # wait for a run already startedinvoke and start take the key — what you wrote in functions/<key>.toml — because that is what the public route takes. runs, runs wait, runs steps, runs terminate and logs take the function id, and every verb prints the ids it produced with the command that takes each one underneath, so you never have to look one up:
Status completed
Output
{
"doubled": 42
}
Ran as 01K3XV… (owner)
Function ID 01K3XW…
→ primitive functions logs 01K3XW…
Invocation ID 01K4ANRRG0Q7VZ3M8T6XW2H5NB
→ primitive functions logs 01K3XW… --invocation 01K4ANRRG0Q7VZ3M8T6XW2H5NBBoth verbs work on every function. invoke posts to the invoke route and runs the function inside the request; start posts to /start and runs it as a task. There is no wrong verb to be refused — unless the function's own code says so with assertRuntime, and then the refusal comes back as a failed invocation with errorCode: "FUNCTION_RUNTIME_REFUSED" rather than as a mistake the CLI could have caught.
Exit codes, so a script can branch on them:
| Code | Meaning |
|---|---|
0 | The invocation completed, or the run you waited for completed. |
1 | It failed, timed out, was terminated, or the platform refused the call. |
124 | runs wait spent its budget with the run still going. It prints the command that resumes the wait. |
130 | Ctrl-C. Same resume line; a second Ctrl-C exits at once. |
--timeout <seconds> is the request budget for invoke (the platform clamps at 30 s) and the WAIT budget for runs wait and start --wait, which defaults to 900.
Who it runs as
By default, your own app user — the one the platform provisions for an admin on first use. The output says so (Ran as <user-id> (<role>)), and the role matters: owners and admins bypass a function's access expression, so your own invocation does not exercise the gate. Use --user when you want it exercised.
primitive functions invoke <key> --user <user-id> # as that app user
primitive functions invoke <key> --as system # with no caller at all--user mints a ten-minute API token for that user, makes the call with it, and revokes it afterwards — on every path, including Ctrl-C. The token's value is never printed. If the revoke itself fails you get a warning naming the token and primitive tokens revoke <id>; the exit code is still the invocation's. Owner and admin only, enforced by the server.
--as system runs the function the way a cron or webhook fire does: ctx.user is null, ctx.trigger is { kind: "manual", userId: <you> }, and the code carries the app's system authority. It is how you exercise a trigger-fired code path without faking the trigger. Owner and admin only, and a task started this way is keyed to the system exactly as a trigger-fired root is — FIRED BY manual, with you recorded as the initiator.
The two flags are mutually exclusive, and --as accepts only system.
A task start mints your root document
functions start needs a context document, and the app user the platform provisions for an admin has none until something creates one. The CLI asks for it — through the same idempotent route sign-in uses — so start works on a fresh app without you doing anything first. Pass --context-doc-id to name one yourself. --as system uses the synthetic fn:<function-id> context that every trigger-rooted run uses, and asks for nothing.
Versions and rollback
Every push whose code differs from every version this function already has makes a new version and points the function at it. List them newest first:
primitive functions configs <function-id>CONFIG ID PUSHED ACTIVE CONTENT ENVELOPE TRIGGERS BY
01K4C2MJ8Q1V7ZP3RN6TWXH5DA 2026-09-15 14:02 * 9f31ab7c2d04 4e08bb61a9f7 cron×2 01K3XV…
01K4BZ0PR5KC9WQ2HM4YD8GTNE 2026-09-15 11:47 71c4de05ba18 2a9f7c30e58d cron×2 01K3XV…To go back to an earlier one, point the function at it:
primitive functions activate <function-id> <config-id>That is a repoint, not a new version. Nothing is created, the version's own bytes are what they always were, and the function's version list is the same length afterwards.
What changes at the next call, and what does not:
- Request calls and trigger fires use the activated version from the next call. They resolve the active version per call, so there is no window and nothing to restart.
- The activated version's own capabilities, limits and triggers apply. A version IS its declaration: roll back to one that declared a cron entry a later version dropped and that schedule is live again; roll back past a
secret:capability and the function stops being able to read that secret. - A task run already in flight finishes on the version it started on. Its code is pinned by content hash and reloaded from the same immutable bundle at every wake, so a rollback never changes what a running task is executing.
Because a version IS its declaration, a rollback asks for that version's triggers back — and a trigger is admitted against the app, not against the version. If a trigger that version declared can no longer be admitted against the app, the activation is refused with the reason and nothing moves: the function keeps running whatever it was running. Resolve the refusal and activate again.
Two things follow from versions being content-addressed per function:
- Pushing a tree that matches a version you already have re-activates that version and makes none. So after a rollback, pushing the unchanged tree puts the newest version back — which is how you undo the rollback without editing anything.
- Pushing an edited tree makes a new version and activates it, as always.
primitive config pull writes the active version's bytes, because the tree should describe what runs. On a function you have rolled back it says so, naming the newer version it did not write:
Wrote functions/greet.toml from the active version 01K4BZ0P…; a newer version
01K4C2MJ… (pushed 2026-09-15 14:02) exists — `primitive functions activate`
switches, `config push` makes the tree the newestprimitive config diff names the same situation rather than reporting an edit you did not make: the tree matches version <newest>, which is not the active one (<active>).
The version each run and each invocation executed shows on primitive functions runs, primitive functions logs and the invocation log record, and the active version's id and content hash show on primitive functions get — beside the key, and with who activated it and when.
Disabling and archiving a function
primitive functions disable takes a function out of service — a call a gate admits answers 403 FUNCTION_DISABLED — and enable puts it back. Availability is server-owned: a config push to a disabled function still updates its code and leaves it out of service, so shipping a fix never puts an endpoint back in service on its own.
archive takes a function out of service without destroying it, and pushing code to an archived function is refused. Reclaiming the key is the hard delete described in Archive vs Prune, which for a function destroys every version and their stored bundles.
Deleting a function with live runs
A hard delete destroys the stored bundles a sleeping run reloads when it wakes, so it is refused while any of a function's runs has not settled — including through primitive config push --prune. The refusal names the live runs. Wait for them, or terminate them, and retry. To stop new runs meanwhile, archive the function: an archived function keeps its bundles, so a run already in flight finishes on the version it pinned.
While a hard delete that got past that check is running, the function stops accepting work: an invoke answers 404 and a task start that was already in flight answers 409 with errorCode: "FUNCTION_DELETE_IN_PROGRESS" rather than beginning a run whose code is about to be destroyed.
One key namespace per app
A function's key is unique per app, case-insensitively. Pushing a function whose key another object in the app already holds fails naming the holder. Keys must be URL-path-safe, because a function key doubles as the path segment that addresses it.