Skip to main content
A function is server TypeScript, RPC-callable from the client. Pylon runs them in a Bun process managed by the runtime. Three flavors:
  • 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 in functions/:
The filename becomes the RPC name — createMessage is callable at POST /api/fn/createMessage.

Context object

ctx gives you:
The three flavors expose different context surfaces:
  • Queryctx.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.
  • Actionno ctx.db. Reach the database through ctx.runQuery(name, args) / ctx.runMutation(name, args) (each runs as its own transaction). Actions also get ctx.scheduler, ctx.error, ctx.email, ctx.llm, ctx.stream, ctx.rooms, ctx.connections, and ctx.request (raw HTTP request — the only ctx that has it, so webhook signature checks live in actions).
A mutation handler IS the transaction — every ctx.db.* call inside the handler shares one BEGIN/COMMIT, and a thrown error rolls everything back atomically. There’s no ctx.db.transact([...]) because the handler already wraps your writes; if you need batch atomicity from outside a mutation, use the HTTP /api/transact endpoint or call runMutation from an action.

Auth (secure by default)

Every function declares who can call it. The framework enforces this before the handler runs — a missing if (!ctx.auth.userId) check no longer leaks data, because the runtime made the check first.
Four modes: 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).
Action authentication. Policies gate 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:
When strict mode is on, the canonical Pylon-shaped IDOR — “I wrote 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:
Rules of thumb:
  • 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 modeauth.isAdmin === true skips the gate the same way it bypasses entity-route policies. Ops scripts and the ctx.auth.elevate({ admin: true, reason: "..." }) path inside verified webhooks still work everywhere (the reason is mandatory and audited).
Strict mode is opt-in for now (one minor cycle) so existing apps can migrate at their own pace. The rollout plan:
  1. Now (v0.3.161+)ctx.db.unsafe.* is callable. Mark known cross-tenant paths with it. Plain ctx.db.* still bypasses policies (existing behavior); strict mode is opt-in via env.
  2. v0.4 — strict mode default-on. Apps that didn’t migrate get POLICY_DENIED on the calls that genuinely need cross-tenant access; the fix is to mark those unsafe.

Validators

v.* describes expected argument shapes:
The same validators work in query, mutation, and action arguments.

Queries

Queries are live by default — the React client subscribes and re-runs on relevant changes. See Live queries.

Actions

Use for side effects outside the database:
Actions have no 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.
The client reads the chunks with 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:
The loop bound matters: a model can keep requesting tools indefinitely, and without a cap the only thing that stops it is the call deadline killing the worker mid-turn.

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.
It resolves { 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 with db.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.
Doing both in one handler is normal — write to the caller for the low-latency path, broadcast so every other watcher stays in sync:
On the client, join the room with useRoom and read the pushed messages off the sync engine:
Server broadcasts arrive with an empty 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:
The client receives { 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.