Agents
An agent is a typed config object passed to defineAgent(). It names a model, its instructions, the triggers that may start it, its tools, a budget, approval policies, typed state and a notifier.
// defined once at module scope: each call gets its run's context
const lastReport = defineTool({
description: 'Returns the date of the previous report.',
inputSchema: z.object({}),
execute: async (_input, { context }) => (await context.state.get<string>('lastReport')) ?? 'never',
});
const saveReport = defineTool({
description: 'Remembers that a report was written now.',
inputSchema: z.object({}),
execute: async (_input, { context }) => {
await context.state.set('lastReport', context.run.trigger.receivedAt.toISOString());
return { saved: true };
},
});
export const reporter = defineAgent({
name: 'weekly-report',
model,
instructions: 'Summarize the open work items in the trigger payload.',
triggers: [
http(),
cron('0 8 * * 1', { timezone: 'Europe/Berlin' }),
],
budget: {
maxSteps: 10,
maxTokens: 50_000,
timeoutMs: 5 * 60 * 1000,
},
tools: { lastReport, saveReport },
});What defineAgent() checks
defineAgent() validates the definition and returns { name, definition } with defaults applied. It throws InvalidAgentDefinitionError when:
- the name does not match
^[a-z0-9]+(-[a-z0-9]+)*$(lowercase words joined by single hyphens), or is longer than 64 characters, - two triggers of the agent share an id,
- a
toolsContextentry names a chamvi tool or no tool of a plain tool set (see Third-party tools with their own context).
The engine checks the rest when you construct it: agent names must be unique within one Engine, and every notify must name a notifier registered on the engine.
const agent = defineAgent({
name: 'echo',
model,
instructions: 'Repeat the payload.',
});
agent.name; // 'echo'
agent.definition.budget; // { maxSteps: 20, timeoutMs: 900_000 }Fields
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | Unique per engine, lowercase words joined by -, at most 64 characters |
model | LanguageModel | required | Any AI SDK model |
instructions | string | required | System instructions |
triggers | TriggerDefinition[] | none | From cron(), http() and webhook(). See Triggers |
tools | ToolSet | (ctx) => ToolSet | none | A plain tool set, or a factory called for each execution. See Tools and their context |
toolsContext | Record<string, unknown> | none | Per tool name, the context of a third-party tool. See Tools |
toolPolicy | (toolName, { run }) => boolean | none | Tools the run may use, e.g. requireToolPermission(). See Restricting tools per invoker |
state | Record<string, StandardSchemaV1> | none | Typed state collections. See Agent State |
budget | Partial<Budget> | { maxSteps: 20, timeoutMs: 900_000 } | See Budgets & Timeouts |
approvals | Record<string, ApprovalPolicy> | none | Tools that need approval. See Approvals |
approvalTimeoutMs | number | 86_400_000 (24 h) | After this, a pending approval is denied |
notify | string | none | Name of a notifier registered on the engine |
output | Output | none | AI SDK structured output, e.g. Output.object({ schema }). See Structured output |
The budget you pass is merged over the defaults, so budget: { maxSteps: 5 } keeps the 15 minute timeout.
Tools and their context
tools is usually a plain AI SDK ToolSet, defined once at module scope (see the example above). Tools built with defineTool() (or defineEffectTool()) get the run context as context, the second argument of execute:
| Field | Type | Description |
|---|---|---|
state | AgentState | The agent's state |
run.runId | string | Id of the run being executed |
run.agent | string | Agent name |
run.trigger | TriggerEnvelope | The envelope that started the run; run.trigger.invoker is who started it, if an authenticator knew |
run.signal | AbortSignal | Aborts on cancel, drain, a lost lease or the run timeout, and once the execution has ended |
artifacts | IRunArtifacts | The run's files: put(), get(id), list(). See Files |
The engine builds one ToolContext per execution and hands it, through the AI SDK's toolsContext, to every tool whose contextSchema is chamvi's toolContextSchema, which is what defineTool() sets. Other tools (a plain tool(), a third-party tool) get no chamvi context. Every call of an execution gets the same object: treat it as immutable. Pass context.run.signal on to fetch and other cancellable work.
For typed state, name the schema: defineTool<Input, Output, typeof schema>({ ... }) types context.state by the agent's collections. The agent does not check that schema against its own state: a tool typed for other collections still compiles, and at run time reads of an undeclared collection are empty and writes fail as tool errors.
tools can also be a factory (ctx) => ToolSet, called once per execution with the same ToolContext. Use it for dynamic sets: tools from an MCP client, or descriptions built from async state (a tool's description function runs synchronously, so it cannot read the store itself).
The tools stay plain AI SDK tools. When the agent declares state, the engine adds generated state tools; your tools win on a name clash. Every effect tool is then wrapped so it applies at most once per effect key.
Structured output
By default a completed run's output is the text of the model's last answer. With output, an AI SDK Output, output is the parsed value instead:
const labeler = defineAgent({
name: 'labeler',
model,
instructions: 'Label the issue in the trigger payload.',
triggers: [http()],
// run.output is { label, reason } instead of the final text
output: Output.object({
schema: z.object({
label: z.enum(['bug', 'feature', 'question']),
reason: z.string(),
}),
}),
});The model may call tools first; only the final answer is parsed. A final answer that is no valid JSON or fails the schema fails the run with ValidationError (run.error.code validation_error, the message from the AI SDK's cause, its quoted text cut to 500 characters; the whole answer stays in the llm step). Two loop ends have no answer to parse and fail the run with IncompleteResponseError (incomplete_response) instead: an answer cut off at the output token limit (finish reason length), and a loop that ended on a tool call nothing executed (a tool without execute, outside a budget stop). A run whose step budget an earlier execution used up parses the last answer in its checkpoint the same way. An agent without output keeps completing with the text it got in both cases.
The ToolLoopAgent gets your Output with one change: its parsePartialOutput is not called while the answer streams (chamvi reads no partial output, and re-parsing the growing answer on every delta costs time). run.delta events arrive per model delta, as without output.
Files
A run can start with files (attachments) and tools can store files of their own. Both are artifacts of the run: metadata (id, runId, agent, mediaType, filename?, size, createdAt) plus the bytes, kept in the store's IArtifactStore. The artifacts of the tool context is scoped to the run: put({ mediaType, filename?, data }) stores a Uint8Array and returns the Artifact, get(id) returns { artifact, data } (or undefined, also for another run's artifact) and list() returns the run's artifacts by createdAt (attachments in the order they were given, ids <runId>.0000, ...; tool files get random UUIDs).
// an effect tool: a recovered run does not store the chart twice
const saveChart = defineEffectTool({
description: 'Renders the points as a PNG chart and stores it with the run.',
inputSchema: z.object({ points: z.array(z.number()) }),
execute: async ({ points }, { context }) => {
const artifact = await context.artifacts.put({
mediaType: 'image/png',
filename: 'chart.png',
data: await renderChart(points),
});
return { artifactId: artifact.id };
},
});
export const charter = defineAgent({
name: 'charter',
model,
instructions: 'Chart the numbers in the attached CSV.',
triggers: [http()],
tools: { saveChart },
});put() is not an effect by itself: a recovered run repeats the step in progress, so call it inside a defineEffectTool to store a file exactly once. Over HTTP, GET /runs/:id/artifacts lists them and GET /runs/:id/artifacts/:artifactId downloads one (see HTTP API).
Many agents per engine
One engine runs any number of agents. They share the store, the concurrency limit and the notifiers:
const triage = defineAgent({
name: 'triage',
model,
instructions: 'Triage the issue.',
});
const digest = defineAgent({
name: 'digest',
model,
instructions: 'Summarize the day.',
notify: 'console',
});
const engine = new Engine({
store: new MemoryStore(),
agents: [triage, digest],
notifiers: { console: new ConsoleNotifier() },
resumeSecret: 'change-me',
maxConcurrentRuns: 8,
claimTtlMs: 30_000,
});dispatch() names the agent per run. An unknown agent name throws InvalidAgentDefinitionError.