Skip to main content
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.
That is the whole backend. The agent is a normal action named after its file. Call it from the client with streamFn:

What a run is

Every invocation creates (or continues) an AgentRun 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 to ctx.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, including tool_use and tool_result records).
They are plain synced entities, so the usual rules apply: they replicate into the owner’s local replica live, 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

Pass runId to continue with full history:
The loop reloads the transcript, appends the new user turn, and the model sees everything, including its own earlier tool calls. A run stuck in 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:
The loop drains the queue at its next turn boundary and folds the text into the transcript where it stays replayable: behind the 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

Cancel is durable state on the run row, not a stream teardown. That matters twice: dropping the SSE connection is not a cancel (a detached run is supposed to survive the window closing), and the request reaches a loop running on a different machine than the one serving the click. The loop polls the run row every 2 seconds while the model streams and while tools run, and settles the run as 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.signal abort, so a handler that threads ctx.signal into fetch stops with the run. A tool that ignores the signal finishes first, and the tools after it do not start.
Every 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: tool frames — { name, input, isError } as each tool executes, so a UI can render “searching docs…” without waiting for the message row,
  • event: queued frames — { runId, queued } when the call steered a generation already in flight instead of starting a turn,
  • the terminal event: result — { runId, text, steps, usage }, plus cancelled: true when the run was stopped.

Tools

Tool args 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.userId owns the run: it shows up in that user’s synced AgentRun/AgentMessage rows, and the agent and its tools run with that user’s ctx.auth. as.admin: true adds admin rights, which an agent declared auth: "admin" needs.
  • context is JSON the tools receive as run.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.
  • runId continues a run; title names a new one.
  • The promise resolves with the same result a streamFn call ends with ({ runId, text, steps, usage }). The tokens are not streamed to the caller.
Only server code can do this. The calling action must be 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, a maxSteps 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.