- 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:
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:
/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.
datais aUint8Array, anArrayBuffer, or a string (stored as UTF-8). The limit is 25 MiB, orPYLON_MAX_UPLOAD_BYTESwhen 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 whenpublic: 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: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").
subscriberIddefaults to the calling user’s id.ttlSecsdefaults 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) => ..., SwiftticketProvider) to get a new ticket on each connection attempt. - The host signs tickets with
PYLON_SHARD_TICKET_SECRET, or a key derived fromPYLON_JWT_SECRET. When shards run on several machines, set one of them to the same value on every machine.
- 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 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).
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.
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:
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:
- 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.
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.
db.streamFn:
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 anAbortSignal to stop a generation early:
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:{ 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.
{ 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.
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.