Defining Agents
An agent is a configuration object that defines how an AI assistant behaves. This guide covers all configuration options available when defining agents.
Basic Agent Definition
Use defineAgent() to create an agent:
import { defineAgent } from '@helix-agents/sdk';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const MyAgent = defineAgent({
name: 'my-agent',
systemPrompt: 'You are a helpful assistant.',
llmConfig: {
model: openai('gpt-6-luna'),
},
});Agent Definition is Just Data
defineAgent() returns a configuration object - it doesn't execute anything. You pass this configuration to a runtime (like JSAgentExecutor) to actually run the agent. This separation enables the same agent definition to work across different runtimes.
Configuration Reference
name (required)
A unique identifier for this agent type. Used for logging, state storage, and sub-agent identification.
const agent = defineAgent({
name: 'research-assistant', // Must be unique across your application
// ...
});description
Optional description of what the agent does. Used in sub-agent tools when no custom description is provided.
const agent = defineAgent({
name: 'analyzer',
description: 'Analyzes text for sentiment and key topics',
// ...
});systemPrompt (required)
Instructions for the LLM. Can be a static string or a function that receives the current custom state.
Static string:
const agent = defineAgent({
name: 'helper',
systemPrompt: `You are a helpful assistant.
Be concise but thorough in your responses.
Always cite sources when making factual claims.`,
// ...
});Dynamic function:
The function receives the agent's custom state, enabling dynamic prompts:
const agent = defineAgent({
name: 'contextual-helper',
stateSchema: z.object({
userName: z.string().default('User'),
expertise: z.enum(['beginner', 'intermediate', 'expert']).default('intermediate'),
}),
systemPrompt: (state) => `You are helping ${state.userName}.
Their expertise level is ${state.expertise}.
${state.expertise === 'beginner' ? 'Explain concepts simply.' : ''}
${state.expertise === 'expert' ? 'Use technical terminology freely.' : ''}`,
// ...
});State During Initialization
During the first LLM call, customState contains default values from your schema. If your dynamic prompt relies on state set by tools, handle the initial case gracefully.
Models whose thinking is bound to history (Opus 5.5, Sonnet 5.5, Fable 5.1, Mythos 5.1)
A dynamic prompt is re-rendered on every step. On these models a thinking block is bound to the exact system prompt and tools it was produced with, so once the model has produced signed thinking in a session, a rendered prompt (or tool set) that differs from the one that thinking was produced with fails the call with framework_prompt_prefix_changed (non-retryable) instead of an opaque provider 400. Keep the rendered prompt stable for the session's lifetime and put changing context in a message instead. Earlier models are unaffected. See Preserved thinking.
tools
Array of tools the agent can use. Includes both regular tools and sub-agent tools.
import { defineTool, createSubAgentTool } from '@helix-agents/sdk';
const searchTool = defineTool({
name: 'search',
description: 'Search the web',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ results: z.array(z.string()) }),
execute: async (input) => ({ results: ['Result 1', 'Result 2'] }),
});
const analyzerSubAgent = createSubAgentTool(AnalyzerAgent, z.object({ text: z.string() }), {
description: 'Analyze text for sentiment',
});
const agent = defineAgent({
name: 'orchestrator',
tools: [searchTool, analyzerSubAgent],
// ...
});stateSchema
Zod schema for custom state data that persists across agent steps. Tools can read and modify this state.
const agent = defineAgent({
name: 'research-assistant',
stateSchema: z.object({
// Primitives with defaults
searchCount: z.number().default(0),
currentPhase: z.enum(['searching', 'analyzing', 'summarizing']).default('searching'),
// Arrays default to empty
findings: z.array(z.string()).default([]),
// Nested objects
metadata: z
.object({
startedAt: z.number().optional(),
topic: z.string().optional(),
})
.default({}),
}),
// ...
});Guidelines:
- Always provide
.default()for fields - state is initialized from defaults - State must be JSON-serializable (no functions, Dates become strings, etc.)
- Keep state minimal - only store what tools need to coordinate
outputSchema
Zod schema for structured output. When provided, a __finish__ tool is automatically injected (unless you have a finishWith tool). The agent completes when the LLM calls this tool with valid data.
const agent = defineAgent({
name: 'analyzer',
outputSchema: z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number().min(0).max(1),
topics: z.array(z.string()),
summary: z.string(),
}),
// ...
});Without outputSchema, the agent runs until the model gives a final answer or a stopWhen condition fires, both of which complete the run. Running out of maxSteps first fails it; see When maxSteps runs out. (Temporal and DBOS do not evaluate stopWhen or fail the cut-off yet; see the caveats below.)
finishWith Alternative
If you need side effects when completing (like saving to a database), use a finishWith tool instead of relying on __finish__. When a finishWith tool exists, __finish__ is not injected. See Finishing Agents for details.
llmConfig (required)
Configuration for the language model:
const agent = defineAgent({
name: 'my-agent',
llmConfig: {
// Required: Vercel AI SDK model instance
model: openai('gpt-6-luna'),
// Optional parameters. Sampling parameters (temperature, topP, topK, penalties)
// are for non-reasoning models: reasoning models such as gpt-6-luna ignore them,
// and Claude 5.5 models reject non-default values.
temperature: 0.7, // 0-2, higher = more creative
maxOutputTokens: 4096, // Max tokens in response
topP: 0.9, // Nucleus sampling
topK: 40, // Top-k sampling
presencePenalty: 0.1, // Discourage repetition
frequencyPenalty: 0.1, // Discourage frequent tokens
stopSequences: ['END'], // Stop generation on these
seed: 12345, // For deterministic outputs
maxRetries: 3, // API retry attempts
// Prompt caching is opt-in; use a helper matching your provider.
// import { anthropicCache } from '@helix-agents/core';
cache: anthropicCache({ ttl: '1h' }),
// Provider-specific options
providerOptions: {
// OpenAI reasoning models (e.g. gpt-6-luna)
openai: {
reasoningSummary: 'detailed',
reasoningEffort: 'high',
},
// Anthropic extended thinking (Claude)
anthropic: {
thinking: { type: 'adaptive' },
},
},
},
// ...
});Additional structured-output knob:
strictOutput(boolean, defaultfalse): force the structured__finish__output into the provider's constrained decoding so a capable model emits schema-conforming JSON. Capability-gated and a safe no-op when unsupported. See Finishing Agents → Strict structured output.
llmConfigOverride
Function to override LLM config per-step based on state and step count. Useful for adaptive behavior:
const agent = defineAgent({
name: 'adaptive-agent',
stateSchema: z.object({
complexity: z.enum(['simple', 'complex']).default('simple'),
}),
llmConfig: {
model: openai('gpt-6-luna'), // Default to smaller model
},
llmConfigOverride: (customState, stepCount) => {
// Switch to larger model for complex tasks
if (customState.complexity === 'complex') {
return {
model: openai('gpt-6.1-sol'),
};
}
// Think less per step as we progress
if (stepCount > 5) {
return { providerOptions: { openai: { reasoningEffort: 'low' } } };
}
return {}; // Use defaults
},
// ...
});The override is merged with llmConfig - override values take precedence.
llmConfigOverride also accepts an optional third ctx argument exposing the execution phase — existing two-argument callbacks keep working unchanged:
llmConfigOverride: (
customState,
stepCount,
ctx: { executionPhase: 'work' | 'forced_completion'; forcedCompletion?: ForcedCompletionState }
) => {
if (ctx.executionPhase === 'forced_completion') {
return { maxOutputTokens: 400 }; // e.g. a separate output budget for the forced call
}
return {};
};See Finishing Agents → Forced completion.
The forced-completion strategy (forced_tool or native_output) is resolved after the override runs, from the capabilities of the model the merged config selects. An override that switches a forced call to another model therefore switches the strategy with it, and an override that switches to a model with no strategy fails that call with framework_completion_strategy_unavailable. The early refusal at execute() / resume() / retry() checks the base llmConfig, and skips an agent that has an llmConfigOverride on the runtimes that run it (runtime-js, the Cloudflare DO): there the override may pick the forced-call model, so the forced call itself is checked.
Runtime coverage caveat: llmConfigOverride (including its forced-completion ctx) is currently applied only on runtime-js and the Cloudflare Durable Object runtime. The Cloudflare Workflows, Temporal, and DBOS runtimes do not yet invoke llmConfigOverride at all — this is a pre-existing gap, not something forced completion introduced. See docs/migration/v0.46-deterministic-forced-completion.md.
maxSteps
Maximum number of LLM calls before the agent stops. Default is 50 (DEFAULT_MAX_STEPS), applied on every runtime when maxSteps is unset. Set maxSteps: Infinity to run with no step cap.
const agent = defineAgent({
name: 'quick-responder',
maxSteps: 5, // Stop after 5 LLM calls
// ...
});This is a safety limit. Agents with outputSchema typically finish sooner by calling __finish__.
maxSteps is a PER-TURN budget
maxSteps is the step budget for a single turn, not a cumulative lifetime cap. Every turn starts with a fresh budget and the step count restarts from zero: a fresh execute() and a persistent-companion re-consult / continuation (execute({ sessionId }) against a completed run, or a companion__spawnAgent / companion__sendMessage re-consult of a completed child) each begin a new turn with the full maxSteps budget. A multi-turn session can therefore take many more than maxSteps LLM calls in total — the cap bounds each turn, not the conversation. This is consistent across all runtimes (JS, Cloudflare DO, Cloudflare Workflows, Temporal, DBOS).
A resume() is not a new turn: it continues the suspended turn and keeps the step count the run suspended at, so it gets only the turn's remaining budget. A run that suspended on its cap step resumes with none left. What happens then still differs per runtime: runtime-js and the Cloudflare DO run one more step before the cut-off, and Cloudflare Workflows runs none (FU-RESUME-AT-CAP-JS-CFW-DIVERGENCE). A retry() of a run cut off by maxSteps restarts the budget at zero (see Retry after a maxSteps cut-off).
The default of 50 is applied everywhere
An agent with no maxSteps stops after 50 steps on JS, the Cloudflare DO, Cloudflare Workflows, Temporal and DBOS, and in a custom loop built on shouldStopExecution() (resolveMaxSteps() resolves an unset value to DEFAULT_MAX_STEPS). Before this was applied everywhere (DI-28), JS, the DO and Temporal ran an unset maxSteps uncapped. Set maxSteps: Infinity to opt out of a cap; on Temporal and DBOS, whose workflow input is JSON, "no cap" travels as Number.MAX_SAFE_INTEGER.
One exception remains: on Temporal, a sub-agent or companion child workflow never receives its agent's maxSteps (the parent workflow has no agent registry), so child workflows run without a step cap, as before. Tracked as FU-TEMPORAL-CHILD-MAX-STEPS.
When maxSteps runs out
After each step, every runtime makes one stop decision, core resolveStopDecision(). It checks three causes in this order:
- Terminal. Structured output, or a text or error step that ends the run by itself. A final answer on the last allowed step is therefore a normal completion.
stopWhen. The agent's predicate returnstrue(see stopWhen). A predicate that fires on the last allowed step counts as astopWhenstop, not a cut-off.maxSteps.stepCount >= maxSteps, and the model had not finished: it called a tool, or wrote a non-final step, on the last allowed step.
A maxSteps cut-off of an agent without outputSchema ends the run failed, with a typed error:
result.status; // 'failed'
result.errorDetail;
// {
// message: 'Max steps (5) reached',
// code: 'framework_max_steps_exhausted',
// category: 'framework',
// retryable: false,
// }The same detail reaches the persisted SessionState.errorDetail, the failed stream chunk and onAgentFail, and the run record carries completionReason: 'max_steps'. A parent agent sees a cut-off sub-agent as a failed tool result. An agent with outputSchema does not fail at the cap: it enters forced completion (see maxCompletionRetries below). A step that stops with an error stop reason (content_filter, refusal, max_tokens, …) keeps its own failure even on the last allowed step.
To stop successfully before the budget runs out, use a stopWhen predicate, a finishWith tool or an outputSchema. To give a cut-off run more steps, call retry(): after a framework_max_steps_exhausted failure it continues from the last committed step with a fresh maxSteps budget and needs no message (a message you pass is appended as new input). See Interrupt and Resume → Retry.
const handle = await executor.execute(agent, 'Index every file');
const result = await handle.result();
if (result.status === 'failed' && result.errorDetail?.code === 'framework_max_steps_exhausted') {
// Not an error in the agent's work: it ran out of budget. Continue it.
const retried = await executor.retry(agent, handle.sessionId);
await retried.result();
}Temporal and DBOS still complete a cut-off run (DI-25)
This contract holds on JS, the Cloudflare DO and Cloudflare Workflows. Temporal and DBOS still end a cut-off run of an agent without outputSchema completed, with no output and no error code, so retry() refuses it (it is not failed). There, a run that ends with no output right after a tool result is the only hint. The fix is tracked as FU-CFD1C-DI29-DI25-TEMPORAL (CF-D1 MR 2) and FU-CFD1C-DI29-DI25-DBOS (CF-D1 MR 3). See DI-25 in the conformance findings.
maxCompletionRetries
Only relevant for agents with an outputSchema. Bounds the deterministic forced-completion phase — the recovery mechanism the framework enters when the agent reaches terminal text, max_tokens truncation, or maxSteps exhaustion without having produced schema-valid output. This is a separate, independent budget from maxSteps: maxSteps bounds ordinary work, maxCompletionRetries bounds forced-completion attempts once a trigger has fired.
const agent = defineAgent({
name: 'strict-reporter',
outputSchema: z.object({ summary: z.string() }),
maxCompletionRetries: 0, // fail immediately on any trigger — no forced call is made
// ...
});- Default:
2. 0fails immediately withframework_forced_completion_exhaustedthe moment a trigger fires — it does not disable forced completion or restore old prose-retry behavior; it means there is no recovery budget.- Forced calls still increment
stepCount, so a run can finish withstepCountup tomaxSteps + maxCompletionRetries. - Each attempt is enforced by the strategy resolved for the model: a named tool choice on the completion tool (
forced_tool), or native structured output (native_output, Anthropic's 5.5 generation). The budget is the same for both.
See Finishing Agents → Forced completion for the full lifecycle.
stopWhen
A custom predicate that stops the run successfully. It is called once per step with the step's StepResult (see below for when, per runtime):
const agent = defineAgent({
name: 'conditional-stopper',
// Stop once the model has called `final_answer` (that tool still runs first).
stopWhen: (result) =>
result.type === 'tool_calls' && result.toolCalls.some((tc) => tc.name === 'final_answer'),
// ...
});- Precedence. A terminal step (a final answer, structured output) ends the run first;
stopWhenis checked beforemaxSteps, so a predicate that fires on the last allowed step is astopWhenstop. See WhenmaxStepsruns out. - Without
outputSchema, the run endscompletedwith no output, and the run record carriescompletionReason: 'stop_when'. - With
outputSchema, a predicate that fires before the agent has produced output enters forced completion, exactly as atmaxSteps.completionReasonstays unset. - A predicate that throws fails the run once with the thrown error's detail. It is never retried. On JS and the Cloudflare DO it runs after the step's tools; on Cloudflare Workflows it runs inside the LLM step's
step.do, so a throw fails the run before that step's tools run, and a replay reuses the recorded decision instead of calling the predicate again. - A step that suspends (a client-executed or approval-gated tool call) suspends before the decision is applied. The resumed run does not re-check
stopWhenfor that step.
Keep the predicate pure and cheap: it sees only the step result, not the agent's state.
stopWhen is not evaluated on Temporal and DBOS yet (DI-29)
JS, the Cloudflare DO and Cloudflare Workflows evaluate stopWhen. Temporal and DBOS check only maxSteps, so an agent keeps calling the LLM and running tools after its stopWhen fires, until the model stops on its own or maxSteps is reached. Nothing reports that the predicate was skipped. Until CF-D1 MR 2 (Temporal, FU-CFD1C-DI29-DI25-TEMPORAL) and MR 3 (DBOS, FU-CFD1C-DI29-DI25-DBOS) land, end the loop with a finishWith tool or an outputSchema on those runtimes. See DI-29 in the conformance findings.
memory
Optional memory configuration. Enables the agent to extract, store, and recall information across sessions.
import { InMemoryMemoryStore } from '@helix-agents/memory';
import { VercelEmbeddingAdapter } from '@helix-agents/embedding-vercel';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
const agent = defineAgent({
name: 'remembering-agent',
memory: {
sources: [
{
name: 'user-prefs',
description: 'User preferences',
store: new InMemoryMemoryStore(),
entityId: (ctx) => ctx.sessionId,
},
],
llmAdapter: new VercelAIAdapter(),
llmConfig: { model: openai('gpt-6-luna') },
embeddingAdapter: new VercelEmbeddingAdapter(openai.embedding('text-embedding-3-small'), 1536),
autoInject: { enabled: true },
generation: { mode: 'realtime' },
},
// ...
});See the Memory guide for full configuration details.
workspace
Optional workspace configuration. Declares a provider plus the capabilities (fs, shell, code, snapshot) the agent can use; the framework auto-injects the matching workspace_<op> tools. The companion workspaceOpenStrategy field ('lazy' default vs 'eager') controls when the workspace opens.
const agent = defineAgent({
name: 'file-agent',
workspace: {
provider: { kind: 'in-memory' },
capabilities: { fs: true },
},
workspaceOpenStrategy: 'lazy',
// ...
});See the Workspaces guide for providers, capabilities, and runtime support.
Complete Example
Here's a fully configured agent:
import { defineAgent, defineTool } from '@helix-agents/sdk';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// State schema
const StateSchema = z.object({
searchCount: z.number().default(0),
findings: z
.array(
z.object({
query: z.string(),
results: z.array(z.string()),
})
)
.default([]),
currentPhase: z.enum(['searching', 'analyzing', 'complete']).default('searching'),
});
// Output schema
const OutputSchema = z.object({
summary: z.string(),
keyFindings: z.array(z.string()),
sourcesUsed: z.number(),
});
// Tool
const searchTool = defineTool({
name: 'search',
description: 'Search for information',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ results: z.array(z.string()) }),
execute: async (input, context) => {
// Track usage in state
context.updateState<z.infer<typeof StateSchema>>((draft) => {
draft.searchCount++;
draft.findings.push({ query: input.query, results: [] });
});
// Perform search...
return { results: ['Result 1', 'Result 2'] };
},
});
// Agent definition
const ResearchAgent = defineAgent({
name: 'research-assistant',
description: 'Researches topics and provides summaries',
systemPrompt: (state) => `You are a research assistant.
Current phase: ${state.currentPhase}
Searches performed: ${state.searchCount}
Instructions:
1. Use the search tool to find information
2. Analyze findings thoroughly
3. Call __finish__ with your summary when done`,
tools: [searchTool],
stateSchema: StateSchema,
outputSchema: OutputSchema,
llmConfig: {
model: openai('gpt-6-luna'),
maxOutputTokens: 4096,
},
llmConfigOverride: (state, stepCount) => {
// Think harder while analyzing
if (state.currentPhase === 'analyzing') {
return { providerOptions: { openai: { reasoningEffort: 'high' } } };
}
return {};
},
maxSteps: 20,
// Wrap up early (forced completion asks for the summary) once the model
// searches with an empty query, its sign that it has run out of leads.
stopWhen: (result) =>
result.type === 'tool_calls' &&
result.toolCalls.some((tc) => tc.name === 'search' && tc.arguments.query === ''),
});Type Safety
Agent definitions are fully typed. TypeScript infers types from your schemas:
// State type is inferred from stateSchema
type State = z.infer<typeof StateSchema>;
// { searchCount: number; findings: {...}[]; currentPhase: 'searching' | 'analyzing' | 'complete' }
// Output type is inferred from outputSchema
type Output = z.infer<typeof OutputSchema>;
// { summary: string; keyFindings: string[]; sourcesUsed: number }
// Agent type includes these
const agent: Agent<typeof StateSchema, typeof OutputSchema> = ResearchAgent;Next Steps
- Defining Tools - Create tools for your agents
- State Management - Deep dive into state patterns
- Sub-Agents - Multi-agent orchestration