Agents
`*.agent.tsx` files — definition, system prompts, tool wiring, streaming responses, and turn structure.
A Voltro agent is a server-side LLM workflow with a typed input, a system prompt, an optional tool list, and a streaming response. The file convention is *.agent.tsx.
Agents bridge two worlds: they're chat-completion-shaped (messages in, tokens out) but they live inside the framework's executor model — the synthesized <name>.send is an action, so it gets ctx and can do external I/O. (Actions are not transactional; an agent run is not rolled back.)
Defining an agent — descriptor + executor
Like queries/mutations, an agent is two files paired by basename — the browser/server boundary again:
*.agent.tsx— the descriptor (defineAgentfrom@voltro/ai/agent): justname+inputschema. Browser-safe, so codegen value-imports it intorpcGroup.generated.ts— the web client is typed end-to-end for the synthesized routes.*.agent.server.tsx— the executor (defineAgentExecutorfrom@voltro/ai, default-exported): system prompt, tools (which import server services), per-agent model + API key, andmaxSteps. Server-only — secrets never reach the browser.
// apps/api/agents/support.agent.tsx — DESCRIPTOR (browser-safe)
import { defineAgent } from '@voltro/ai/agent'
import { Schema } from 'effect'
export const support = defineAgent({
name: 'support', // → support.send / support.messages
input: Schema.Struct({ prompt: Schema.String, plan: Schema.optional(Schema.String) }),
})// apps/api/agents/support.agent.server.tsx — EXECUTOR (server-only)
import { defineAgentExecutor } from '@voltro/ai'
import { support } from './support.agent'
import { searchDocs } from '../tools/searchDocs.tool'
export default defineAgentExecutor(support, {
system: (input) => `You are a friendly support agent for the Voltro framework.
Be concise. The user is on the ${input.plan ?? 'free'} plan.`,
tools: { searchDocs },
model: { name: 'openai', model: 'gpt-5.5', apiKey: process.env.SUPPORT_OPENAI_KEY },
maxSteps: 8,
// Provider-specific knobs forwarded to the SDK — most importantly REASONING
// EFFORT (see "Reasoning effort" below). Pin it low for a routing/help agent.
providerOptions: { openai: { reasoningEffort: 'low' } },
})The framework pairs the two by basename and synthesizes, per agent name, two procedures:
<name>.send— an action that appends the user turn + streams an assistant turn (delta-persisted toagent_messages). Wire input: the descriptor'sinputfields +threadId+order.<name>.messages— a reactive query (source: 'agent_messages') that streams the persisted turns — including the live one being typed — to the browser.
Codegen emits both routes into rpcGroup.generated.ts, so useAction / useSubscription resolve them with full types. (Older code hand-wrote thread/send/list actions just to get a typed client — that's no longer needed; the agent path is the typed, supported way.)
tools is a Record<string, AnyTool> (the same shape runAssistant takes), keyed however you like — the tool's own name is what the model sees.
Per-agent model + key
model on the executor overrides the global default per agent — and carries its own key:
defineAgentExecutor(support, {
model: {
name: 'openai', // 'openai' | 'anthropic' | 'gateway' | 'mock'
model: 'gpt-5.5', // for gateway: a 'creator/model' id, e.g. 'openai/gpt-5.5'
apiKey: process.env.SUPPORT_OPENAI_KEY, // OPTIONAL — see fallback below
},
// …
})Env is the fallback. Each model field is optional:
- Omit
apiKey→ the provider reads its standard env var (OPENAI_API_KEY/ANTHROPIC_API_KEY/AI_GATEWAY_API_KEY). - Omit
modelentirely → the agent inheritsAI_PROVIDER/AI_MODELfrom env (the global default).
So one agent can run GPT-5.5 on a dedicated key while another inherits the env default — no global-only constraint. The key lives in *.agent.server.tsx, which never reaches the browser.
Dynamic model — a function of the input (model picker / routing / BYOK)
model may also be a function of the decoded input (like system) — it returns a full ProviderConfig (incl. apiKey), runs server-side, and undefined falls back to env for that call. Two patterns:
Model picker / routing — the input selects among server-owned configs (UI picker, cost/size routing, plan tiers):
// support.agent.tsx — DESCRIPTOR: the picker is constrained to server-known tiers
import { defineAgent } from '@voltro/ai/agent'
import { Schema } from 'effect'
export const support = defineAgent({
name: 'support',
input: Schema.Struct({ prompt: Schema.String, tier: Schema.Literal('fast', 'smart') }),
})
// support.agent.server.tsx — EXECUTOR: map the tier onto server-owned configs
import { defineAgentExecutor } from '@voltro/ai'
export default defineAgentExecutor(support, {
model: (input) => input.tier === 'smart'
? { name: 'openai', model: 'gpt-5.5' }
: { name: 'openai', model: 'gpt-4o-mini' },
})BYOK (bring your own key) — the caller supplies their OWN key; you fold it into the returned config:
// support.agent.tsx — DESCRIPTOR: the caller's own key is part of the input
import { defineAgent } from '@voltro/ai/agent'
import { Schema } from 'effect'
export const support = defineAgent({
name: 'support',
input: Schema.Struct({ prompt: Schema.String, apiKey: Schema.String }),
})
// support.agent.server.tsx — EXECUTOR: fold the caller's key into the config
import { defineAgentExecutor } from '@voltro/ai'
export default defineAgentExecutor(support, {
model: (input) => ({ name: 'openai', model: 'gpt-5.5', apiKey: input.apiKey }),
})Security. The input is client-controlled, so the real footgun is letting it pick an expensive model on a key YOU pay for — for a picker, constrain the choice with a
Schema.Literalin the descriptor'sinputand map to server-owned configs (don't do(input) => ({ model: input.model })). BYOK is the legitimate exception: returningapiKey: input.apiKeyis the whole point — the cost is on the user's key. The key is NOT persisted (only the prompt is stored inagent_messages); just make sure your logging/tracing doesn't capture it.
Reasoning effort + provider tuning (providerOptions)
providerOptions on the executor is forwarded verbatim to the underlying SDK call — the outer key is the provider id, the inner record its options. The headline use is reasoning effort: a reasoning model (gpt-5.x) defaults to a HIGH effort that spends many seconds "thinking" before the first token — even for a one-line answer. A navigation / help / Q&A assistant doesn't need that; pin it low (or minimal) for a dramatically faster first token at negligible quality cost:
export default defineAgentExecutor(support, {
// …
providerOptions: { openai: { reasoningEffort: 'low' } }, // or 'minimal'
// anthropic equivalent: { anthropic: { thinking: { type: 'disabled' } } }
})The same providerOptions field is accepted by the one-shot free functions too — generateText, generateObject, generateObjectWithTools, and streamText — for the same per-call provider tuning.
Conversation memory — automatic
The synthesized <name>.send sends the whole persisted thread to the model on every turn (the prior user/assistant turns + the new prompt), so the assistant remembers earlier messages. You don't thread history yourself — appending the user turn before the model call (which the synthesized send does) is enough. A custom runAssistant caller can pass an explicit messages array for the same effect; with none, it falls back to the single prompt.
Anatomy of an agent run
client → <name>.send({ threadId, prompt, order })
│
▼ append user turn → runAssistant(store, { threadId, prompt, system, tools, order })
│
▼ streamText runs the loop: model calls tool? → server runs tool → result back to model
│ (loop until done, up to maxSteps)
│
▼ token/tool deltas throttle-patched onto the live agent_messages row
│
▼ <name>.messages subscription re-fires → client renders the next chunk
│
▼ done → streaming:false on the rowMultiple rounds of tool calls are handled inside runAssistant / streamText — you don't loop manually unless you write a custom executor.
The system prompt
System prompts go in the agent definition's system field, NOT in the messages array. Why:
- It keeps the prompt out of the per-turn message history — the durable
agent_messagesrows hold the conversation, not the boilerplate instructions. - The framework can apply prompt caching to it automatically (Anthropic supports cached system prompts).
- Versioning is easier — change the prompt, deploy, every call uses the new one.
The system field lives on the executor. Template it with per-call values (locale, persona, plan-specific instructions) — the function receives the input typed from the descriptor's schema:
// support.agent.tsx — DESCRIPTOR: every field `system` reads must be declared here
import { defineAgent } from '@voltro/ai/agent'
import { Schema } from 'effect'
export const support = defineAgent({
name: 'support',
input: Schema.Struct({ prompt: Schema.String, plan: Schema.optional(Schema.String) }),
})
// support.agent.server.tsx — `plan` comes from the descriptor's input schema
import { defineAgentExecutor } from '@voltro/ai'
export default defineAgentExecutor(support, {
system: (input) => `You are speaking with a user on the ${input.plan ?? 'free'} plan.`,
})The system function receives the typed input + can return a string. It's called fresh on every request.
Wiring tools
import { searchDocs } from '../tools/searchDocs.tool'
import { createTicket } from '../tools/createTicket.tool'
export const supportAgent = defineAgent({
name: 'support',
// …
tools: { searchDocs, createTicket },
})Tool definitions are typed — the agent doesn't need any string-keyed routing. The agent's input/output is Schema-validated; so is every tool call's input/output. See Tools.
Turn structure — the zero-config default
For the persisted, reactive chat (what defineAgent synthesizes), the client subscribes to the agent's <name>.messages query and sends turns through its <name>.send action. No token-stream handling — tokens land as throttled patches on the live agent_messages row, which the subscription re-fires:
// client side — the durable pattern
const { data: messages = [] } = useSubscription('app', 'support.messages', { threadId })
const send = useAction('app', 'support.send')
const sendMessage = (prompt: string) =>
send.run({ threadId, prompt, order: messages.length })The server side is generated — you don't write the send action or the messages query when you declare a defineAgent. The thread tables (agent_threads, agent_messages) are auto-provided + auto-migrated.
Transient runs — useAgent
When you DON'T want persistence (an ephemeral playground, a one-off completion), wrap a defineStream rpc with the useAgent client hook — it derives tokens from the run's token events and accumulates history:
// client side — transient
const support = useAgent('app', 'support.run')
const sendMessage = (text: string) => {
support.send({ message: text, history: support.history })
// support.tokens streams in; on done it folds into support.history
}Fully custom run loop — defineStream
The agent path (descriptor + executor) is the default for the persisted, reactive chat. When you need a fully custom loop (mix conversation with bespoke side effects, your own persistence, a non-standard stream shape), don't try to bend the agent synthesis — write a defineStream rpc and drive it with the streamText free function, then consume it client-side with useAgent (transient) or persist deltas yourself:
// streams/thread.run.stream.ts (+ thread.run.stream.server.ts)
import { streamText } from '@voltro/ai'
import { Stream } from 'effect'
export default (input: { prompt: string }, ctx) =>
streamText({ prompt: input.prompt, system: 'You are concise.' }).pipe(
Stream.tap((event) => /* persist / forward `event` */ Stream.empty),
)Persisted, reactive chat (under the hood)
The durable pattern above is built on a delta-persistence layer that makes the conversation itself the durable, reactive record. Tokens reach the browser via a reactive query, not a raw socket.
Thread + message CRUD: createThread, appendMessage, getMessages, getThread. Streaming-turn helpers:
appendStreamingMessage(store, { threadId, tenantId?, order })— insert one assistant row withstreaming: trueand emptyparts; returns its id.patchStreamingMessage(store, id, parts)— patch the row'sparts(mirrors text tocontent) as deltas arrive; throttle at the call site.runAssistant(store, { threadId, tenantId?, prompt, system?, tools?, order, throttleMs? })— does the whole turn: inserts the streaming row, consumesstreamText, accumulates token deltas into a text part + records tool calls/results as tool parts, throttle-patches the row (default 100ms), then flipsstreaming: falsewith the finalparts.
The pattern to feature:
- A send action appends the user message and calls
runAssistant. - A reactive query with
source: 'agent_messages'streams the persisted rows — including the livestreaming: truerow being patched — to the browser. Each throttled patch mutates the row → the subscription re-fires → the client sees the next chunk.
// actions/chat.send.action.server.ts
import { appendMessage, runAssistant } from '@voltro/ai'
import { Effect } from 'effect'
export default (input: { threadId: string; text: string; order: number }, ctx) =>
Effect.gen(function* () {
yield* appendMessage(ctx.store, { threadId: input.threadId, role: 'user', content: input.text, order: input.order })
yield* runAssistant(ctx.store, { threadId: input.threadId, prompt: input.text, order: input.order + 1 })
return { ok: true }
})// queries/chat.messages.query.ts → source: 'agent_messages'
// queries/chat.messages.query.server.ts
import { getMessages } from '@voltro/ai'
export default async (input: { threadId: string }, ctx) => getMessages(ctx.store, input.threadId)// client — a normal subscription; no token-stream handling
const { data: messages } = useSubscription('app', 'chat.messages', { threadId })
const send = useAction('app', 'chat.send')The agent_messages row carries streaming (live typewriter flag), order (thread position), stepOrder (sub-position within a turn, for LLM↔tool steps), and parts (the structured text + tool payload the feed renders). Both agent_threads and agent_messages are tenant-scoped — the active org id is stamped on every row. Full worked example in Streaming.
Constraining responses (JSON, schema)
For non-chat agents where you want structured output:
import { Schema } from 'effect'
const SummarySchema = Schema.Struct({
title: Schema.String,
bullets: Schema.Array(Schema.String),
priority: Schema.Literal('low', 'medium', 'high'),
})
import { generateObject } from '@voltro/ai'
const { object: summary } = await Effect.runPromise(generateObject({
schema: SummarySchema,
system: 'Summarise the input into a JSON object.',
prompt: input.text,
}))
// summary is typed { title: string, bullets: string[], priority: 'low' | 'medium' | 'high' }generateObject converts the Effect Schema to JSON Schema for the provider, then decodes the model output back through the schema, so refinements/brands hold and malformed output surfaces as a typed AiError({ reason: 'decode' }).
generateObject is single-shot — it takes no tools. When a batch agent needs BOTH adaptive tool-calling (fetch detail on demand) AND a final schema-constrained object, use generateObjectWithTools:
import { generateObjectWithTools } from '@voltro/ai'
const { object: summary } = yield* generateObjectWithTools({
schema: SummarySchema,
system: 'Drill into the ticket with the tools, then summarise.',
prompt: input.text,
tools: { getIssueComments, getIssueChangelog }, // looped on demand
maxSteps: 16, // LLM↔tool round-trips, default 8
})It runs the LLM↔tool loop (stopWhen at maxSteps) and constrains the terminal answer to the schema, decoding it through the same Effect Schema as generateObject. The tools reach the caller's runtime services (see Tools → Effect tool bodies reach app services).
Cancelling mid-stream
For the transient useAgent path, cancel() interrupts the in-flight run; the server-side stream scope tears down (which aborts the upstream model call):
const support = useAgent('app', 'support.run')
support.cancel() // interrupts the run; the server-side scope tears downWhen agents are the wrong tool
- Single-shot summarisation / classification — use an action with
generateText/generateObject. Agents shine for multi-turn / tool-using flows. - Background processing — use a workflow. Agents are request-scoped; workflows survive crashes + can run for hours.
See RAG for the canonical "agent + tool + vector search" pattern.