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:

Signed file URLs

GET /api/files/<id> serves a private file only to its owner (or an unscoped admin). A file uploaded with visibility: "public" needs no signed URL. When your app’s own rules say someone else should see it — an organizer reviewing a member’s upload — mint a signed URL from a function that enforces those rules:
The returned path (/api/files/<id>?sig=<hmac>&exp=<unix>) is anonymously fetchable until it expires. It works in <img src> and download links. ttlSecs defaults to 300 and is capped at 24 hours; a signed URL is a bearer capability, so keep lifetimes short. Invalid or expired signatures fall back to the ordinary owner check, so nothing new becomes enumerable.

Storing files from an action

ctx.files.store writes bytes to the app’s file storage (the same backend /api/files uses: local disk, S3, or Stack0) and returns { id, url, size }. ctx.files.delete(fileId) removes a file and returns { deleted }, which is false when the file was already gone.
  • data is a Uint8Array, an ArrayBuffer, or a string (stored as UTF-8). The limit is 25 MiB, or PYLON_MAX_UPLOAD_BYTES when that is lower.
  • The file is owned by the calling user and tenant, as if that user had uploaded it. Called with no user (a scheduled job, a public webhook), the file is system-owned: readable through ctx.files.signedUrl, by an unscoped admin, or by anyone when public: true.
  • Both run only in actions. A query or mutation that calls them gets FILES_WRITE_NOT_ALLOWED, because a file write cannot roll back with the transaction.

Reading stored files

ctx.files.info(fileId) works in every function. It returns { id, contentType, ownerId, public, ownerTracked }. On a backend that records owners (local disk, a private S3 bucket), ownerTracked is true, ownerId is who uploaded or stored the file, and a file that does not exist returns null. The Stack0 CDN records no owner (ownerTracked: false, ownerId: null): there a file id works like an unguessable link, so check who may use a file id yourself, for example by recording uploads in your own rows. ctx.files.read(fileId) returns { id, contentType, size, data }, with data a Uint8Array. It reads with server trust, so the function decides who may read. It runs only in actions and returns at most the upload limit.

Shard tickets

A shard ticket lets a client join one realtime shard as a given subscriber id, with claims the shard’s authorization hooks read without a database call. Check what the user may do in the function, then mint the ticket:
The client passes the ticket when it connects: connectShard(shard, { subscriberId: c.id, token, ticket }) in TypeScript, or ShardClientConfig(ticket:) in Swift. The shard refuses a ticket for another shard or another subscriber id, or one past its expiry. In Rust, SimState::authorize_subscribe and authorize_input read the claims with auth.claim("realm").
  • subscriberId defaults to the calling user’s id.
  • ttlSecs defaults to 60 and is capped at 3600. The ticket only gates joining. A subscriber stays connected after its ticket expires.
  • A client with a fixed ticket stops reconnecting when the shard refuses it (expired, or signed with an old key). Pass a ticket function (TypeScript ticket: (shardId) => ..., Swift ticketProvider) to get a new ticket on each connection attempt.
  • The host signs tickets with PYLON_SHARD_TICKET_SECRET, or a key derived from PYLON_JWT_SECRET. When shards run on several machines, set one of them to the same value on every machine.
The three flavors expose different context surfaces:
  • Query — ctx.db (read-only), ctx.auth, ctx.env, ctx.requireMember, ctx.files, ctx.shards (ticket, get, list).
  • 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 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). A guest session carries a userId — a stable anonymous id, not an account — so in a "public" or "guest" handler, ctx.auth.userId != null does not mean the caller is signed in. Check ctx.auth.isGuest wherever the answer depends on the caller having an account they can sign back into: entitlements, purchases, anything keyed to identity rather than to a device.
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 checks database operations with the caller’s identity. It checks each returned row, including graph relations. Enable it per deployment:
A get or lookup of a forbidden row returns POLICY_DENIED. List, query, pagination, and graph results exclude forbidden rows. Graph reads check policies on included entities too. Admin callers with an active tenant must pass read policies. Search and vector search require read policies that do not depend on row data. This prevents search counts or rankings from revealing forbidden rows. Strict function reads retain server-only fields on permitted rows. Select the fields that the function can return to clients. Server-rendered serverData reads remove protected fields. Graph reads remain unavailable during page rendering. For the legitimate cross-tenant cases (admin tools, webhook receivers post signature verification, scheduled cron sweeps), use the explicit escape hatch:
Guidelines:
  • 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 === 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 gradually. 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.

Timeouts

timeout on a function is an idle timeout in seconds: how long the call may go without activity before Pylon cancels it. Activity is any ctx.db operation, stream chunk, LLM event, or other ctx.* call, and each one restarts the budget. The total run is capped at 10 times the timeout, however active the call is. Stack0 Cloud sets PYLON_FN_CALL_TIMEOUT=300 on each machine. A project env var overrides it. There is no separate Cloud maximum: a function with timeout: 600 can go 10 minutes without activity and run for up to 100 minutes. When a call is cancelled, its next ctx.* call throws CALL_CANCELLED and ctx.signal aborts. A mutation’s transaction rolls back. External work already in flight, such as a fetch, finishes unless you pass { signal: ctx.signal }. For work that can outlast an HTTP request, return early and continue in ctx.scheduler.runAfter or a workflow step.

Receiving webhooks

POST /api/webhooks/<action> calls an action with the raw request on ctx.request: The action’s auth mode applies. A provider does not sign in, so a receiver declares auth: "public" and verifies the provider’s signature itself. By default the return value is sent as JSON with status 200. Return ctx.response({ status, contentType, headers, body }) to send a different status, content type, or body. Only /api/webhooks/<action> sends it raw; /api/fn/<action> returns the same value as JSON. status must be 200-599. The server sets Content-Length, Connection, Transfer-Encoding, CORS, and its security headers, so an action cannot set those, and it cannot set Set-Cookie. Header values with CR, LF, or non-ASCII characters are rejected. This Twilio receiver verifies X-Twilio-Signature, hands the message to a workflow, and answers with TwiML:
ctx.request.url is the URL the provider called. Behind a proxy, Pylon takes the host from X-Forwarded-Host, then Host, but only when that host is trusted: loopback, the PYLON_PUBLIC_URL host, PYLON_CANONICAL_HOST, a PYLON_TRUSTED_HOSTS entry, or one of the app’s domains. The scheme comes from X-Forwarded-Proto (http or https), else https (http for loopback). When the host is not trusted, the PYLON_PUBLIC_URL origin is used instead. Set PYLON_PUBLIC_URL in production so the URL matches what the provider signed. ctx.request.clientIp is the same client IP Pylon uses for its own rate limits and audit entries, so an action can rate-limit by IP without reading headers. Pylon takes it from the first header in PYLON_CLIENT_IP_HEADER that holds a valid IP, then from X-Forwarded-For with PYLON_TRUST_PROXY_HOPS trusted proxies, then from the socket address. CF-Connecting-IP counts only when the address that connected to the app is a Cloudflare edge address (on Fly, the Fly-Client-IP address), so a request sent straight to the origin cannot choose its IP. Stack0 Cloud sets PYLON_CLIENT_IP_HEADER=CF-Connecting-IP,Fly-Client-IP. A self-hosted app behind Cloudflare with another hop in front of Pylon (Cloudflare Tunnel, a load balancer) connects from that hop, not from a Cloudflare address, so CF-Connecting-IP is ignored. Set PYLON_TRUST_PROXY_HOPS to the number of hops in front of Pylon; the client IP then comes from X-Forwarded-For. Other edge headers you add to PYLON_CLIENT_IP_HEADER, such as True-Client-IP, are used as sent, so allow traffic to the origin from that edge only.

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. 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 should not re-run on every dep invalidation.
The client reads the chunks with db.streamFn:
The Accept: text/event-stream header alone does not force an SSE response. A handler that returns without ever calling ctx.stream.write answers with plain JSON (the raw return value), exactly like a JSON-only call. db.streamFn handles both. Serving MCP from an action relies on this: MCP clients advertise SSE support on every POST but expect a plain JSON body when the server doesn’t stream. Every fn stream is resumable: the server buffers frames under a stream id, so a dropped connection reconnects from its cursor and misses nothing, including the final result after the handler finished. See Resumable Streams.

Stopping a stream

Pass an AbortSignal to stop a generation early:
When the signal aborts, the promise rejects with err.code === "LLM_CANCELLED" and the server closes the provider connection, so the model stops generating and billing tokens. A stream that is still running when the function returns or times out is cancelled the same way. ctx.signal aborts when the host cancels the call, so { signal: ctx.signal } ties a stream to its call.

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 keeps a call alive

The timeout is an idle timeout (see Timeouts). Every stream event resets it, so a streaming call runs as long as it keeps producing, up to 10 times the timeout in total. A multi-turn agent that runs tools can be silent for longer than 30 seconds between events (a slow tool call), so raise the timeout:

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:
A person’s message can also carry images and documents: { type: "image", source } (png, jpeg, gif or webp) and { type: "document", source, title? } (a PDF or plain text), each with source: { type: "base64", media_type, data }. Other types are refused with LLM_INVALID_CONTENT before the request is sent. The OpenAI provider takes images and refuses documents with LLM_UNSUPPORTED_CONTENT. The loop bound matters: a model can keep requesting tools indefinitely. Without a cap, the only thing that stops it is the call deadline ending the worker during a turn.

Server push to a room

ctx.stream.write writes to the HTTP response of the call in progress. 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, then 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.

Function traces

Successful function traces store small return values. A trace omits the return value if it exceeds the 4 KiB content and value-size budget, 128 JSON values, or 16 nesting levels. The client still receives the complete response. Each trace stores up to 128 DB operations and 128 scheduled calls. Their names and row IDs share a 32 KiB limit. Entries that exceed these limits are omitted. ops_omitted and schedules_omitted report the omitted counts. Add each count to its stored array length to get the total. Stored error codes and messages have a 4 KiB limit each. Shortened text ends with [truncated]. The caller still receives the full error. Trace result values also have size and depth limits. These limits do not change function responses.

Next

Live queries

How query subscriptions stay in sync.

Validators

All argument shapes v.* supports.