Skip to main content
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


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.
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:
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.
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.
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.
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 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.
With these pieces in place, callers experience one consistent persona while you retain the control and safety of dedicated task-specific agents.