agent() turns an LLM tool loop into a one-file definition. The framework runs the loop. It streams tokens over a resumable stream. It runs your tools with validated arguments. It persists every turn (user input, assistant text, tool calls, tool results) into synced entities that update live on all of the user’s devices.
streamFn:
What a run is
Every invocation creates (or continues) anAgentRun with an ordered AgentMessage transcript. Both are ordinary synced entities. The framework injects them into your manifest when any agent exists:
AgentRun—agent,status(idle | running | completed | failed | cancelled),title,streamId(the resumable stream of the current generation),error,pendingInput(messages queued mid-generation),cancelRequested,steps(cumulative round-trips),context(what server code passed toctx.agents.run), timestamps. Owner-scoped: readable only by the user the run belongs to.AgentMessage—runId,seq,role,content(a string for user turns; content blocks for assistant turns, includingtool_useandtool_resultrecords).
pylon codegen types them, pylon migrate creates them on Postgres, and policies fence them (clients can never write them; the loop’s server-side mutations are the only writer).
If your app declares its own AgentRun or AgentMessage entity (to add columns), yours wins, but the loop still writes through it, so keep the framework’s columns intact: AgentRun needs agent, status, userId, title, streamId, error, pendingInput, cancelRequested, steps, createdAt, updatedAt, plus context (JSON) if you pass a context to ctx.agents.run; AgentMessage needs runId, userId, seq, role, content, createdAt. The same goes for policies: declaring your own AgentRun policy replaces the injected one, so keep reads owner-scoped with the auth.userId != null && guard (bare data.userId == auth.userId matches anonymous callers against null-owner rows).
Conversations
PassrunId to continue with full history:
running longer than the agent’s timeout is a dead generation; the next turn takes it over. A run belonging to another user is indistinguishable from a missing one (RUN_NOT_FOUND).
Runs always have an owner: an unauthenticated or system caller gets AGENT_REQUIRES_USER. To invoke an agent from a cron job or another server-side path, run it under a service user’s auth.
Steering a run in progress
Sending to a run that is already generating queues the message instead of refusing it, so a chat UI never has to disable its composer:tool_result blocks of the turn it is answering, or as a fresh user turn if the model had already stopped. A message that arrives just as the model stops still extends the same run — the drain and the terminal status write happen in one transaction, so nothing is stranded on a completed run.
Queued text lives on run.pendingInput until it is drained. Render it as pending below the last message. The queue is capped (32 messages, 32KB); past that, AGENT_INPUT_QUEUE_FULL.
Stopping a run
cancelled once it sees the request:
- A model response in flight is aborted. The server closes the provider connection, so no more tokens are generated or billed. The text streamed so far is kept as the assistant turn, and anything queued is appended after it.
- A running tool handler sees
ctx.signalabort, so a handler that threadsctx.signalintofetchstops with the run. A tool that ignores the signal finishes first, and the tools after it do not start.
tool_use still receives a tool_result when a batch stops early, so the transcript stays replayable.
Cancelling a run that is not generating settles it as cancelled immediately and drops anything queued. Starting a new turn on a cancelled run works like any continuation.
Watching from another device
The transcript arrives per-message via sync. For token-level liveness, the run row records the current generation’s resumable stream id:resumeStream replays the buffered tokens from the start, then live-tails. It still delivers the final result if the run finished while you were connecting.
The stream
While generating, the SSE stream carries:- plain data frames — the assistant’s text deltas,
event: toolframes —{ name, input, isError }as each tool executes, so a UI can render “searching docs…” without waiting for the message row,event: queuedframes —{ runId, queued }when the call steered a generation already in flight instead of starting a turn,- the terminal
event: result—{ runId, text, steps, usage }, pluscancelled: truewhen the run was stopped.
Tools
Toolargs are the same v.* validators as functions, converted to JSON Schema for the model. The model’s arguments are validated before your handler runs; invalid input, a thrown handler error, or an unknown tool all become is_error tool results the model can react to. A bad tool call never ends the run. Handlers get the full action ctx: runQuery/runMutation for data, ctx.llm for sub-calls, ctx.email, ctx.workflows, everything.
The third argument is the run: { runId, agent, userId, context }. context is what server code passed to ctx.agents.run, or null for a run a client started. Only server code can set it, so a tool can use it to decide what it acts on:
Running an agent from server code
A webhook, a scheduled job, or a workflow has no signed-in user, but a run needs an owner.ctx.agents.run runs an agent from an action as a user you name:
as.userIdowns the run: it shows up in that user’s syncedAgentRun/AgentMessagerows, and the agent and its tools run with that user’sctx.auth.as.admin: trueadds admin rights, which an agent declaredauth: "admin"needs.contextis JSON the tools receive asrun.context. It is stored on the run, so later turns of the same run (including ones the owner sends from the UI) see it. The owner can read it, so keep secrets out of it.runIdcontinues a run;titlenames a new one.- The promise resolves with the same result a
streamFncall ends with ({ runId, text, steps, usage }). The tokens are not streamed to the caller.
internal: true (no client can call it; jobs, workflows, and other server code start it) or run with admin rights: an admin session, or a webhook that verified its signature and called ctx.auth.elevate. as.admin needs an admin caller. Anything else throws AGENT_RUN_FORBIDDEN. The target must be an agent() (AGENT_NOT_FOUND otherwise), and the identity must satisfy the agent’s own auth mode (FORBIDDEN / UNAUTHENTICATED), exactly as an HTTP call would.
Options: model (subject to the server’s allowlist), maxSteps (default 64; reaching the cap fails the run instead of looping forever), maxTokens, auth ("user" default; agents require a signed-in caller so runs always have an owner; server code names one with ctx.agents.run), timeout (default 600s idle).
maxSteps bounds one invocation, including the extra turns steering adds to it. The run row’s steps counts every invocation and is not capped — read it to budget a long-lived conversation.
Failure semantics
A provider error, amaxSteps overrun, or a crash marks the run failed with the error message on the row, visible to the UI through the same sync channel as every other change. Completed and failed runs are permanent history; start a new turn on the same runId to continue after a failure. If a crash interrupted a tool call in progress, the next turn repairs the transcript with an is_error tool result (“tool execution was interrupted”) so the model’s history stays replayable. The repair rides in the same user message as that turn’s input, because two user messages in a row are rejected by the Messages API just as a dangling tool_use is. Tool results are capped at 64KB per result; anything longer persists with a [truncated] suffix.
For work that must survive a server restart in progress, wrap the agent call in a workflow. Runs are durable state, but a generation in progress lives with the process, exactly like any action.