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

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

:::note{title="Version"}
Needs `@trayai/helix-sdk` and `@trayai/helix-cli` newer than 1.6.0.
:::

## Choosing a shape

| The task | Use | Why |
|---|---|---|
| Open-ended conversation with tools, a user in the loop | The built-in loop | Session handling, approval pauses, limits, and cost tracking come for free |
| One question in, one typed object out | A single `ctx.ai` call | No loop, no session, no tools; `generateObject` returns the shape you asked for |
| A fixed pipeline: classify, then draft, then check | A chain of calls | Each 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 specialist | Delegation | Each agent keeps a short tool list and a focused prompt |
| Custom step logic, your own history store, unusual stop rules | Your own loop | The 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:

```typescript
// 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:

```typescript
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](/documentation/guides/ai/).

## 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:

```typescript
// 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`:

```typescript
// 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.

```typescript
// 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:

| Option | Does |
|---|---|
| `stopWhen` | Replaces the agent's `limits` for this call with any AI SDK stop condition, or an array of them |
| `activeTools` | Restricts 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 |
| `prepareStep` | Runs before each step and can change the model, tools, tool choice, or messages for that step |
| `providerOptions` | Provider-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:

```typescript
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](/documentation/reference/context/agent/).

---

Canonical: https://helix.tray.ai/documentation/guides/agent-patterns/
Any link on this page is available as markdown by appending .md to its URL.
Full corpus: https://helix.tray.ai/documentation/llms-full.txt