- Query — read-only, can subscribe to changes
- Mutation — writes through the transactional path
- Action — arbitrary side effects (HTTP calls, emails, file ops)
Writing a function
Create a file infunctions/:
createMessage is callable at POST /api/fn/createMessage.
Context object
ctx gives you:
- Query —
ctx.db(read-only),ctx.auth,ctx.env,ctx.requireMember. - Mutation — the above plus writes on
ctx.db,ctx.scheduler,ctx.error,ctx.llm,ctx.connections,ctx.stream,ctx.rooms. - Action — no
ctx.db. Reach the database throughctx.runQuery(name, args)/ctx.runMutation(name, args)(each runs as its own transaction). Actions also getctx.scheduler,ctx.error,ctx.email,ctx.llm,ctx.stream,ctx.rooms,ctx.connections, andctx.request(raw HTTP request — the only ctx that has it, so webhook signature checks live in actions).
A mutation handler IS the transaction — everyctx.db.*call inside the handler shares one BEGIN/COMMIT, and a thrown error rolls everything back atomically. There’s noctx.db.transact([...])because the handler already wraps your writes; if you need batch atomicity from outside a mutation, use the HTTP/api/transactendpoint or callrunMutationfrom an action.
Auth (secure by default)
Every function declares who can call it. The framework enforces this before the handler runs — a missingif (!ctx.auth.userId) check no longer leaks data, because the runtime made the check first.
When the mode doesn’t match the caller, the request is rejected before the handler runs —
401 AUTH_REQUIRED for "user" / "guest", 403 FORBIDDEN for "admin". Admin sessions bypass every mode (same convention as policies).
ctx.db.* reads and writes, but they do not gate action handlers. An action that charges Stripe, sends email, or calls a private API relies on its auth setting, so Pylon requires authentication by default.
internal: true functions ignore auth — they’re unreachable over HTTP and inherit the wrapping handler’s context.
ctx.db honors policies (strict mode)
By default, ctx.db.* inside a function bypasses entity policies because server code is trusted. This remains the default for compatibility.
Strict mode flips the default: every ctx.db.get/query/insert/update/delete/lookup/search runs through the policy engine using the function’s caller auth, exactly as if the same operation came in through /api/entities/*. Enable per deploy:
getRecording.ts that does ctx.db.get('Recording', args.id) and forgot to check tenant ownership” — becomes impossible. The policy on Recording fires, sees that the caller isn’t a member of the row’s org, and the call returns POLICY_DENIED before the row leaves the database.
For the legitimate cross-tenant cases (admin tools, webhook receivers post signature verification, scheduled cron sweeps), use the explicit escape hatch:
- Plain
ctx.db.*— the default. Acts as the caller. Use for anything that should reflect the user’s view of the data. ctx.db.unsafe.*— explicit bypass. Use for webhooks, cron sweeps, admin tooling, and anything that genuinely needs cross-tenant reads. Every call should have a justifying comment.- Admin contexts bypass strict mode —
auth.isAdmin === trueskips the gate the same way it bypasses entity-route policies. Ops scripts and thectx.auth.elevate({ admin: true, reason: "..." })path inside verified webhooks still work everywhere (thereasonis mandatory and audited).
- Now (v0.3.161+) —
ctx.db.unsafe.*is callable. Mark known cross-tenant paths with it. Plainctx.db.*still bypasses policies (existing behavior); strict mode is opt-in via env. - v0.4 — strict mode default-on. Apps that didn’t migrate get
POLICY_DENIEDon the calls that genuinely need cross-tenant access; the fix is to mark thoseunsafe.
Validators
v.* describes expected argument shapes:
Queries
Actions
Use for side effects outside the database:ctx.db. They reach the database through ctx.runQuery(name, args) / ctx.runMutation(name, args) — each of those runs as its own transaction, but the action as a whole is not atomic. If a sequence of writes must commit or roll back together, do them inside a single mutation and have the action call it once.
Streaming LLM output
ctx.llm.complete(request) sends a completion and resolves once the model has finished generating. ctx.llm.stream(request, onEvent) sends the same request but calls onEvent for each event as the provider emits it, then resolves with the same assembled response complete would have returned — so a handler can push text to the client as it arrives and still inspect stop_reason afterwards.
Both live on mutation + action ctx. Neither is on query ctx: a subscribed query re-runs whenever its ctx.db reads change, and a paid, non-deterministic call has no business re-running on every dep invalidation.
db.streamFn:
Stream events
onEvent receives one of four shapes:
Everything else matches
complete: same auth gating, same model allowlist (PYLON_AI_MODELS_ALLOWED / the manifest llm() helper), same thrown errors carrying err.code (LLM_NOT_CONFIGURED, MODEL_NOT_ALLOWED, PROVIDER_HTTP_429, …). Streaming can’t reach a model complete would refuse.
Streaming does not extend the deadline
The call deadline is absolute wall clock from invocation —PYLON_FN_CALL_TIMEOUT, 30 seconds by default. Emitting events does not reset it. A multi-turn agent that runs tools will blow through 30s, so declare a timeout on the function:
Agent tool loop
Stream the text out as it’s generated, run tools when the model asks, feed the results back, repeat until the model stops asking:Server push to a room
ctx.stream.write writes to the HTTP response of the call in flight. It reaches exactly one client — the one that made the request. Close the tab and the output is gone; a second device never sees it.
ctx.rooms.broadcast(room, topic, data) pushes to every subscriber of a presence room over the WebSocket, independent of who made the call. Same rooms clients join with useRoom, same delivery path a member’s own broadcast() uses.
{ delivered: false } when the room has no members. Broadcasting into an empty room is a no-op, not an error — an agent doesn’t have to know whether anyone is watching.
Available on mutation + action ctx, not on queries: a reactive query re-runs on every dep change, which would re-broadcast each time.
Pick based on who needs the output:
- One caller, one screen (a chat box waiting on its own response) —
ctx.stream.write, read withdb.streamFn. - Everyone watching, including tabs that weren’t the caller (agent output that must survive a reload, a shared session on a second device, a job with no HTTP caller at all) —
ctx.rooms.broadcast.
useRoom and read the pushed messages off the sync engine:
from (no sender user id) — a member’s own broadcasts carry theirs, so m.from === userId is how you filter your own echoes.
Calling functions from the client
Errors
Throw typed errors that propagate to the client with structured codes:{ code, message } and can render different UI for each code.
Next
Live queries
How query subscriptions stay in sync.
Validators
All argument shapes
v.* supports.