Skip to main content

Agentic AI: Multi-Agent Systems and Task Handoff

10 min read

The final piece of the [Agentic AI](/blog/tag/Agentic AI) series. We’ve pulled apart reflection loops, prompt chaining, and orchestration-worker patterns. One thing left: multi-agent systems.

Why Multi-Agent Systems Matter

Most AI demos you’ll find online showcase a single LLM tackling a broad task end-to-end. Impressive, sure. Scalable or reliable? Not really.

In the real world, no single agent should try to do everything. Specialisation is the whole point. Think of a hotel: you wouldn’t expect your front-desk concierge to also cook your dinner and make your bed. Same principle applies here. Dedicated agents, each scoped to a specific domain (hotels, restaurants, flights).

Multi-agent systems are about collaboration, context-passing, and controlled handoffs.

Core Principles of Multi-Agent Design

  1. Domain-specific roles: Each agent should have a clearly scoped responsibility.
  2. Explicit handoff mechanisms: Agents must know when to delegate and how to format the context for the next agent.
  3. Shared memory or minimal context passing: Context that persists across agents (e.g., user location, preferences) must be passed or shared carefully.
  4. Autonomy within limits: While agents are autonomous, they operate under fixed schema constraints to ensure reliability.

A Realistic Scenario: Book Me Something

Here’s the user goal:

“Can you book me a hotel for tonight for 2 people?”

Then they bolt on a second request:

“Oh and I’d love a nearby Italian place to eat.”

A generic model might scramble to handle both, possibly hallucinating a restaurant inside the hotel (even if one doesn’t exist). A better design? Route hotel booking to one agent, restaurant booking to another.

The crucial bit: knowing when to switch.

The Handoff Pattern

At the core of this approach is an AI-native version of a call centre operator saying (often dreaded in real life, though hopefully your AI agent does a better job of it):

“Let me transfer you to someone who can better help with that.”

Structured delegation: one agent recognises a task outside its remit, packages context cleanly, and passes it along to another.

In our implementation, this is done via a schema with two keys:

  • message: The agent’s reply to the user.
  • handoff: If needed, the name of the next agent (or "" to stay put).

That keeps both user communication and system routing logic clean.

Design Decisions

We’re using the Gemini Flash model for speed, and running two chat instances:

  • One for the Hotel Agent
  • One for the Restaurant Agent

Each has its own system prompt, its own memory, and adheres to the same schema. If the Hotel Agent detects a restaurant query, it doesn’t fumble. It offloads.

The orchestration logic is stripped back to the essentials:

  • Maintain current agent.
  • If handoff occurs, swap agent chat instance.
  • Loop until the conversation ends.

You can simulate complex interaction paths without needing a heavy orchestrator.

Code Walkthrough

Let’s walk through the code for the Hotel Agent and Restaurant Agent. Bear in mind this is a sample project; in production you’d wire in function calling to hit hotel and restaurant APIs, check calendars, and so on.

Initial Setup

import { GoogleGenAI, Type } from '@google/genai';
import readline from 'readline';

We use:

  • @google/genai: The Gemini SDK to create AI chat agents.
  • readline: Node.js module for terminal-based user input.

Client & Schema Definition

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

const responseSchema = {
  type: Type.OBJECT,
  properties: {
    handoff: {
      type: Type.STRING,
      description: "The name/role of the agent to hand off to. Available agents: 'Restaurant Agent', 'Hotel Agent'",
      default: '',
    },
    message: {
      type: Type.STRING,
      description: 'The response message to the user or context for the next agent',
    },
  },
  required: ['message'],
};

This schema locks down a consistent response structure from all agents, enabling structured delegation logic. handoff can be blank or contain the next agent’s name.

Agent Prompts

const hotelSystemPrompt = `You are a Hotel Booking Agent...`;
const restaurantSystemPrompt = `You are a Restaurant Booking Agent...`;

Each prompt sets strict role boundaries:

  • The Hotel Agent only handles hotel-related requests.
  • The Restaurant Agent handles food bookings and recommendations.

User Input Helper

function getUserInput(question: string): Promise<string> {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
  return new Promise((resolve) =>
    rl.question(question, (input) => {
      rl.close();
      resolve(input);
    })
  );
}

Main Flow

const hotelChat = ai.chats.create({
  model,
  history: [],
  config: {
    responseMimeType: 'application/json',
    responseSchema,
    systemInstruction: `You are Hotel Agent. ${hotelSystemPrompt}
    Current user context: ${JSON.stringify(sharedContext)}`,
  },
});

let chat = hotelChat;

function getAgentChat(agent: string) {
  return ai.chats.create({
    model,
    history: [],
    config: {
      responseMimeType: 'application/json',
      responseSchema,
      systemInstruction:
        agent === 'Hotel Agent'
          ? `You are Hotel Agent. ${hotelSystemPrompt}
             Current user context: ${JSON.stringify(sharedContext)}`
          : `You are Restaurant Agent. ${restaurantSystemPrompt}
             Current user context: ${JSON.stringify(sharedContext)}`,
    },
  });
}

while (true) {
  await extractContextFromInput(userInput);

  const result = await chat.sendMessage({ message: userInput });

  const raw = result.text;
  let output: { message: any; handoff: any; };
  try {
    output = JSON.parse(raw!);
  } catch (e) {
    console.error('❌ Failed to parse structured response:', raw);
    output = { message: '[Malformed response]', handoff: '' };
  }

  console.log(`\n🤖 ${currentAgent}:\n${output.message}`);

  if (output.handoff && output.handoff !== currentAgent) {
    console.log(`🔁 Handoff Triggered: ${currentAgent} ➡️ ${output.handoff}`);
    currentAgent = output.handoff;
    chat = getAgentChat(currentAgent);

    continue;
  }

  const followUp = await getUserInput('\n👤 You: ');
  if (followUp.toLowerCase() === 'exit') {
    console.log('👋 Conversation ended.');
    break;
  }

  userInput = followUp;
}

A few things worth noting: each chat instance gets its own system prompt, and they all share the same response schema.

The real work happens inside the while loop:

  1. Sends userInput to the active agent.
  2. Parses JSON output based on the schema.
  3. Prints the agent’s reply.
  4. Checks if a handoff was triggered:
    • If so, swaps to the appropriate agent and shares the context.
  5. Prompts user for next input.
  6. Ends if user types exit.

We’re also extracting arguments to pass between agents via tool calling:

const extractorSchema = {
  type: Type.OBJECT,
  properties: {
    city: {
      type: Type.STRING,
      description: 'Destination city or town (e.g., London, Tokyo, Rome)',
    },
    people: {
      type: Type.NUMBER,
      description: 'Number of people in the request (e.g., 2)',
    },
    hotelBudget: {
      type: Type.STRING,
      description: 'Hotel price or budget mentioned (e.g., "$200", "under $500")',
    },
    cuisine: {
      type: Type.STRING,
      description:
        'Preferred cuisine type (e.g., Italian, Indian, Japanese, Vegan). Do not use generic terms like "restaurant" or "food". Leave blank if not mentioned.',
    },
    date: {
      type: Type.STRING,
      description: 'When the booking is for (e.g., "tonight", "tomorrow", "2025-06-05")',
    },
  },
};


async function extractContextFromInput(input: string): Promise<void> {
  const result = await ai.models.generateContent({
    model,
    contents: [{ role: 'user', parts: [{ text: input }] }],
    config: {
      tools: [
        {
          functionDeclarations: [
            {
              name: 'extractContext',
              description: 'Extract travel-related context fields',
              parameters: extractorSchema,
            },
          ],
        },
      ],
      toolConfig: {
        functionCallingConfig: {
          mode: FunctionCallingConfigMode.ANY
        }
      }
    },
  });

  try {
    const call = result.candidates?.[0]?.content?.parts?.[0]?.functionCall;
    if (call?.name === 'extractContext' && call.args) {
      let extractedArgs: Record<string, any> = {};
      try {
        extractedArgs = call.args
      } catch (e) {
        console.warn('⚠️ Failed to parse function call args:', call.args);
      }

      for (const [key, value] of Object.entries(extractedArgs)) {
        if (sharedContext[key]) continue; // ✅ Skip if already set
        sharedContext[key] = value;
      }

    } else {
      console.warn('⚠️ No function call was made or it was the wrong one:', call?.name);
    }
  } catch (e) {
    console.error('❌ Failed to extract context from result:', e);
  }
}

const sharedContext: Record<string, any> = {};

Here’s what the conversation looks like in practice:

$

Benefits of Multi-Agent Architectures

Specialisation leads to fewer hallucinations. Testing and debugging agent behaviour in isolation becomes far easier. Schema enforcement gives you standardised handoffs. As your system grows, you can bolt on more agents: “Flight Agent”, “Event Agent”, “Transit Planner”. A future orchestration layer could dynamically discover which agents are needed.

The handoff itself is the critical piece. Sharing the right context at the right moment is what makes or breaks the whole thing.

Conclusion

Multi-agent systems represent a genuine shift in how we architect AI workflows. Instead of asking a single model to do everything, we delegate tasks with purpose: composable AI ecosystems where each agent plays a well-defined role.

In this example, a hotel agent knew its limits and passed the baton to a restaurant agent. In production, these patterns scale to complex tasks: planning software launches, coordinating logistics, even simulating multi-stakeholder negotiations. Structured schemas and specialised roles give you predictability, scalability, and transparency.

When designing your own agentic systems, remember: the goal isn’t one perfect prompt. It’s composing a system that can gracefully handle complexity, delegate with clarity, and adapt as your use case grows.

Thanks for following this series on Agentic AI. Whether you’re experimenting with single-agent reflection loops or wiring up multi-agent orchestration networks, you’re helping shape the future of how AI works with us (not just for us).