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 (defineAgent from @voltro/ai/agent): just name + input schema. Browser-safe, so codegen value-imports it into rpcGroup.generated.ts — the web client is typed end-to-end for the synthesized routes.
  • *.agent.server.tsx — the executor (defineAgentExecutor from @voltro/ai, default-exported): system prompt, tools (which import server services), per-agent model + API key, and maxSteps. 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 to agent_messages). Wire input: the descriptor's input fields + 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 model entirely → the agent inherits AI_PROVIDER / AI_MODEL from 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.Literal in the descriptor's input and map to server-owned configs (don't do (input) => ({ model: input.model })). BYOK is the legitimate exception: returning apiKey: input.apiKey is the whole point — the cost is on the user's key. The key is NOT persisted (only the prompt is stored in agent_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 row

Multiple 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_messages rows 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 with streaming: true and empty parts; returns its id.
  • patchStreamingMessage(store, id, parts) — patch the row's parts (mirrors text to content) 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, consumes streamText, accumulates token deltas into a text part + records tool calls/results as tool parts, throttle-patches the row (default 100ms), then flips streaming: false with the final parts.

The pattern to feature:

  1. A send action appends the user message and calls runAssistant.
  2. A reactive query with source: 'agent_messages' streams the persisted rows — including the live streaming: true row 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 down

When 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.