> ## Documentation Index
> Fetch the complete documentation index at: https://docs.layercode.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-agents and agent transfers

> How to orchestrate multiple, task-specific AI SDK agents while keeping one consistent Layercode voice

Some voice projects need **focused sub-agents** (quotes, policy expertise, escalations, etc.) so you can narrow prompts, tools, and guardrails per task — but callers should still feel like they are speaking to a single person.\
This guide shows how to build an **AI SDK orchestrator** that loops over specialized sub-agents, then pipes the final response into **Layercode** for voice delivery.

***

## Prerequisites

* A Next.js (or plain Fetch) endpoint that already handles [Layercode server webhooks](https://docs.layercode.com/intro).
* Familiarity with [AI SDK streamText](https://sdk.vercel.ai/docs/reference/ai) and JavaScript tool calling.
* (Optional) [Custom webhook metadata](https://docs.layercode.com/how-tos/custom-webhook-metadata-and-headers) if you want to preload caller info.

***

## 1. Define a shared persona + sub-agent config

Each sub-agent reuses a common **voice persona** so the caller hears a consistent tone, then narrows to a specific responsibility and tool set.

```tsx theme={null}
import { streamText, stepCountIs, tool, type ModelMessage } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import z from 'zod';

const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const model = openai('gpt-4o-mini');

const VOICE_PERSONA = `
You are Agent Smith from Matrix Car Insurance.
Your output will be read aloud in a phone call.
Be concise, friendly, and never mention routing, tools, or sub-agents.
`.trim();

type SubAgentId = 'quote' | 'policy' | 'escalations';

type SubAgent = {
  id: SubAgentId;
  description: string;
  system: string;
  getTools: (conversationId: string) => Record<string, ReturnType<typeof tool>>;
  canTransferTo?: 'any' | readonly SubAgentId[];
};
```

Example “quote” sub-agent:

```tsx theme={null}
const QuoteInfoSchema = z.object({
  car_registration: z.string().optional(),
  driver_age: z.number().optional()
});

const SUB_AGENTS: Record<SubAgentId, SubAgent> = {
  quote: {
    id: 'quote',
    description: 'Introductions + general policy questions.',
    canTransferTo: 'any',
    system: `
${VOICE_PERSONA}
Handle introductions and general questions about the insurance process.
If a user asks about coverage details, transfer to "policy".
If they complain or say "stop calling", transfer to "escalations".
`.trim(),
    getTools: () => ({
      recordQuoteProgress: tool({
        description: 'Record basic quote details (stub).',
        inputSchema: QuoteInfoSchema,
        async execute({ car_registration, driver_age }) {
          // TODO: persist in your CRM or DB
          return { ok: true, car_registration, driver_age };
        }
      })
    })
  },
  // policy, escalations...
};
```

Repeat for `policy` and `escalations`, each with its own Zod schema + tooling.

***

## 2. Build an internal transfer tool

Transfers are just **tool calls** that tell the orchestrator to switch sub-agents. They never surface to the caller.

```tsx theme={null}
function makeTransferTool(args: {
  from: SubAgentId;
  allowed: SubAgentId[];
  messages: ModelMessage[];
  setNext: (to: SubAgentId, handoff?: string) => void;
}) {
  const { from, allowed, messages, setNext } = args;

  return tool({
    description: `INTERNAL: route to another sub-agent. Allowed from "${from}": ${allowed.join(', ')}`,
    inputSchema: z.object({
      to: z.enum(['quote', 'policy', 'escalations']),
      handoff: z.string().optional()
    }),
    async execute({ to, handoff }) {
      if (!allowed.includes(to)) return 'NOT_ALLOWED';

      if (handoff?.trim()) {
        messages.push({
          role: 'system',
          content: `HANDOFF (${from} -> ${to}): ${handoff}`
        });
      }

      setNext(to, handoff);
      return 'OK';
    }
  });
}
```

Notice the **handoff notes**: they become new `system` messages so the next agent instantly knows why the call switched.

***

## 3. Orchestrator loop

The orchestrator is a loop that keeps calling the same LLM with **different system prompts + tools** until no transfer is requested.

```tsx theme={null}
export async function runOrchestrator(conversationId: string, userText: string) {
  const messages: ModelMessage[] = [{ role: 'user', content: userText }];

  let active: SubAgentId = 'quote';
  let transfers = 0;
  const maxTransfers = 3;

  while (true) {
    const sub = SUB_AGENTS[active];
    const allowed =
      sub.canTransferTo === 'any'
        ? (Object.keys(SUB_AGENTS) as SubAgentId[]).filter((id) => id !== active)
        : [...(sub.canTransferTo ?? [])];

    let next: SubAgentId | undefined;
    const transferToSubAgent = makeTransferTool({
      from: active,
      allowed,
      messages,
      setNext: (to) => {
        next = to;
      }
    });

    const tools = { ...sub.getTools(conversationId), transferToSubAgent };

    const { text, response } = await streamText({
      model,
      system: sub.system,
      messages,
      tools,
      toolChoice: 'auto',
      stopWhen: stepCountIs(10),
      onFinish: ({ response }) => messages.push(...response.messages)
    });

    if (!next) return text; // final answer for this Layercode turn

    transfers++;
    if (transfers >= maxTransfers) return 'Sorry, something went wrong with routing.';
    active = next;
  }
}
```

Persist `messages` per `conversationId` (database, KV, Durable Object, etc.) to maintain full history across turns.

***

## 4. Connect to Layercode

Wrap the orchestrator inside your Layercode webhook handler. Each webhook turn can stream TTS back to the caller.

```tsx theme={null}
import { streamResponse } from '@layercode/node-server-sdk';

export async function POST(request: Request) {
  const payload = (await request.json()) as WebhookRequest;

  if (!isSignatureValid(request, payload)) {
    return new Response('Invalid layercode-signature', { status: 401 });
  }

  return streamResponse(payload, async ({ stream }) => {
    if (payload.type === 'session.start') {
      stream.tts(
        "Hi, this is Agent Smith from Matrix Car Insurance. I'm calling about your quote. Who am I speaking with today?"
      );
      stream.end();
      return;
    }

    if (payload.type !== 'message') {
      stream.end();
      return;
    }

    const replyText = await runOrchestrator(payload.conversation_id, payload.text);
    stream.tts(replyText);
    stream.end();
  });
}
```

Layercode will invoke this endpoint again for every new user utterance. Store the conversation state by `conversation_id` so the orchestrator can pick up where it left off.

***

## 5. Pass caller context with metadata

Before the call even starts, you often know the **lead’s name, quote ID, or campaign**.\
Attach that information via [custom webhook metadata](https://docs.layercode.com/how-tos/custom-webhook-metadata-and-headers) so it arrives inside every `session.start` and `message` payload.\
You can then preload the orchestrator’s history with system messages like `Lead name: Jordan Carter` or seed tool inputs with the quote identifier.

***

## Next steps

* Replace stub tool logic with real CRM / policy lookups.
* Persist orchestrator state (messages, active sub-agent, outstanding tasks) in a durable store.
* Tune prompts for stricter routing rules (e.g., escalate only when specific keywords appear).
* Add interrupt handling + barge-in support from the [voice quick start](https://docs.layercode.com/how-tos/outbound-calls).

With these pieces in place, callers experience one consistent persona while you retain the control and safety of dedicated task-specific agents.
