Skip to content
Helix Docs

Agent architecture and patterns

When to use the built-in agent loop, when to chain single calls, how delegation between agents works, and how to drive your own loop with ctx.ai.

For developers Updated Oct 20, 2026
View as Markdown

ctx.agent(name).generate() runs a complete loop: the model reads the conversation, calls tools, reads their results, and repeats until it has an answer. That loop is enough for most assistants. When the work has a fixed shape, a chain of single calls is cheaper and easier to test; when one agent needs help from another, delegation keeps each one small; and when you need control over every step, the agent hands you its model and tools to drive yourself.

Choosing a shape

The taskUseWhy
Open-ended conversation with tools, a user in the loopThe built-in loopSession handling, approval pauses, limits, and cost tracking come for free
One question in, one typed object outA single ctx.ai callNo loop, no session, no tools; generateObject returns the shape you asked for
A fixed pipeline: classify, then draft, then checkA chain of callsEach step is testable on its own and the control flow is in your code, not the model’s
A broad assistant that sometimes needs a specialistDelegationEach agent keeps a short tool list and a focused prompt
Custom step logic, your own history store, unusual stop rulesYour own loopThe agent exposes its model, tools, and instructions as AI SDK values

The built-in loop

One call, one conversation turn, any number of model steps:

// functions/api/v1/assist.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  message: z.string().min(1),
  sessionId: z.string().optional(),
});

export default defineFunction<typeof input>(async (ctx) => {
  const result = await ctx.agent('support-agent').generate({
    prompt: ctx.input.message,
    sessionId: ctx.input.sessionId,
  });

  if (result.status === 'awaiting-approval') {
    return { sessionId: result.sessionId, pending: result.pendingApprovals };
  }
  return { sessionId: result.sessionId, answer: result.text };
});

Per step, the runtime sends the instructions, the session history, and the new turn to the model; runs any tools the model asked for with the caller’s ctx; and feeds the results back. It stops at a final answer, at limits.maxIterations steps, at limits.maxToolCalls tool executions, or when a tool needs approval. Everything the turn produced is appended to the session before the call returns.

Reach for this when the user drives the conversation and you cannot predict which tools will be needed.

A single structured call

Not every AI feature is an agent. Extracting fields from text, classifying a message, or writing one summary is a single model call with no tools and no memory. Use ctx.ai directly:

const triage = await ctx.ai('anthropicKey').generateObject({
  model: 'claude-haiku-4-5',
  schema: z.object({
    category: z.enum(['billing', 'shipping', 'technical', 'other']),
    urgency: z.enum(['low', 'normal', 'high']),
  }),
  prompt: `Classify this support message:\n\n${ctx.input.message}`,
});

See calling AI models.

A chain of calls

When the work has known stages, write the stages as code and call an agent, or a plain model, at each one. The control flow stays in your function, each stage can use a different model, and each stage is testable alone:

// functions/api/v1/reply-draft.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  customerId: z.string(),
  message: z.string(),
});

export default defineFunction<typeof input>(async (ctx) => {
  // Stage 1: a cheap model decides what kind of request this is.
  const triage = await ctx.ai('anthropicKey').generateObject({
    model: 'claude-haiku-4-5',
    schema: z.object({ category: z.enum(['billing', 'shipping', 'other']) }),
    prompt: `Classify: ${ctx.input.message}`,
  });

  // Stage 2: the matching specialist agent gathers facts with its tools.
  const specialist = triage.object.category === 'shipping' ? 'shipping-agent' : 'billing-agent';
  const facts = await ctx.agent(specialist).generate({
    prompt: `Customer ${ctx.input.customerId} asks: ${ctx.input.message}. Gather the facts needed to reply; do not write the reply.`,
  });

  // Stage 3: a writer agent turns facts into a reply, with no tools at all.
  const reply = await ctx.agent('reply-writer').generate({
    prompt: `Facts:\n${facts.text}\n\nWrite a reply to: ${ctx.input.message}`,
  });

  return {
    category: triage.object.category,
    draft: reply.text,
    cost: facts.helix.cost.amount + reply.helix.cost.amount,
  };
});

Each generate call without a sessionId gets a fresh session, so stages do not see each other’s history unless you pass it in the prompt. That isolation is usually what you want in a pipeline: the writer should see the facts, not the specialist’s tool chatter.

Delegation between agents

List other agents under agents and the runtime gives the model one tool per entry, named agent_<name>, whose description is the target’s description:

// functions/_agents/concierge.ts
import { defineAgent } from '@trayai/helix-sdk';

export default defineAgent({
  name: 'concierge',
  description: 'Front-door assistant. Routes to specialists.',
  model: 'claude-sonnet-4-5',
  auth: 'anthropicKey',
  instructions: 'Answer general questions yourself. For anything about an order, delegate to the shipping agent. For anything about an invoice, delegate to the billing agent.',
  agents: ['shipping-agent', 'billing-agent'],
});

When the concierge calls agent_shipping_agent with a prompt, the shipping agent runs its own full loop with its own tools and its own fresh session, and its final text comes back as the tool result. The child sees only the prompt the parent passed, not the parent’s conversation.

Rules the runtime enforces:

  • Depth is capped at three. A delegate that delegates that delegates stops there; the model gets a structured error telling it to answer with what it has.
  • Delegates cannot pause for approval. An agent listed under agents must not hold a needsApproval tool. Keep approval-gated actions on the top-level agent.
  • References resolve at first use. Two agents may list each other. A name that matches no agent fails when the parent first runs.

Delegation costs a full model loop per handoff. Use it when the specialist genuinely needs its own tools and prompt. If the parent could call the tool itself, give it the tool.

Your own loop

The agent exposes model, tools, and instructions as plain AI SDK values. Pass them to ctx.ai.generateText or ctx.ai.streamText and you own the loop: your stop conditions, your prepareStep, your history store, your handling of each step. Use agent.model as the model, not a string id and not a model you built yourself: the top-level ctx.ai.* functions accept only model instances minted through ctx.ai, and agent.model is one, fallback chain included.

// functions/api/v1/research.post.ts
import { z } from 'zod';
import { defineFunction, stepCountIs } from '@trayai/helix-sdk';

export const input = z.object({ topic: z.string() });

export default defineFunction<typeof input>(async (ctx) => {
  const agent = ctx.agent('research-agent');
  const kv = ctx.kv();
  const key = `research:${ctx.input.topic}`;

  const history = (await kv.getItem<any[]>(key)) ?? [];

  const result = await ctx.ai.generateText({
    model: agent.model,
    system: agent.instructions,
    tools: agent.tools,
    messages: [...history, { role: 'user', content: `Research: ${ctx.input.topic}` }],
    stopWhen: stepCountIs(8),
    prepareStep: async ({ stepNumber }) => {
      // Let the model search freely for a few steps, then force an answer.
      if (stepNumber >= 6) return { toolChoice: 'none' };
      return {};
    },
    onStepFinish: ({ toolCalls }) => {
      ctx.log.info('step', { tools: toolCalls.map((c) => c.toolName) });
    },
  });

  await kv.setItem(key, [...history, ...result.response.messages]);
  return { summary: result.text, cost: result.helix.cost.amount };
});

What you give up: session handling, the approval pause and resume cycle, the agent.generate span, and per-step fallback pricing. agent.model still carries the fallback chain, and agent.tools still validate input and run with the caller’s ctx, so governance is the same whichever loop drives them.

Reach for this when you need to inspect or change the conversation between steps, when history belongs in a store of your own, or when the stop rule is something stepCountIs and totalToolCallsIs cannot express.

Per-call control without a custom loop

Before writing your own loop, check whether a per-call option on generate or stream does what you need:

OptionDoes
stopWhenReplaces the agent’s limits for this call with any AI SDK stop condition, or an array of them
activeToolsRestricts this call to a subset of the agent’s tools, by name
toolChoice'auto', 'none', 'required', or { type: 'tool', toolName }. It applies to every step, so 'required' alone never lets the model give a final answer: the run continues until a limit stops it
prepareStepRuns before each step and can change the model, tools, tool choice, or messages for that step
providerOptionsProvider-specific settings passed through unchanged

To make the model look something up before it answers, require a tool on the first step only and leave the rest to it:

const result = await ctx.agent('support-agent').generate({
  prompt: ctx.input.message,
  sessionId: ctx.input.sessionId,
  activeTools: ['lookup_order'],
  prepareStep: ({ stepNumber }) => (stepNumber === 0 ? { toolChoice: 'required' } : {}),
});

Signatures for all of these are in the ctx.agent() reference.

Loading search…

Jump to a section

tab to move · esc to close