Skip to content
draft Visibility internal Owner erik@uvilo.com Approver _ Created 2026-06-08 Updated 2026-06-08

Migrate to uvilo-mono Plan 3

Scope: Build switchAgent as a native AI Tool. When a Bot calls switchAgent, the current turn completes, then the target Bot re-processes the user’s original message with its own config — prompt, model, and tools. All messages remain in the same Convo. Distinct from Plan 2’s spawnAgent (which creates a separate sub-agent conversation and returns the result); switchAgent is an in-conversation transfer of control. Depends on Plan 1 (McpServerManager) for MCP tool integration in the receiving Bot; native AI tools work independently.

Use case: User tells a Generic Bot “Do xyz on project Whatever” → Generic Bot detects that the Project Bot is needed → Generic Bot switches to Project Bot → The user’s message is resubmitted for inference using Project Bot → Project Bot’s response streams to the user.

One handoff per turn. After the handoff, the target Bot responds to the user’s original message. If the target Bot also calls switchAgent, that handoff takes effect on the next user turn — not within the same response.


Functional Requirements

ToolPurpose
switchAgentTransfer control of the current conversation to a different Bot. The target Bot re-processes the user’s message with its own system prompt, model, and tools. All messages persist in the same Convo.

switchAgent is a native AI Tool registered in @erikdakoda/ai-tool’s aiToolRegistry — resolved in-process, no separate conversation.

Key distinction from spawnAgent: spawnAgent creates a child conversation and returns the result. switchAgent transfers control of the current conversation — no parent/child, no job tracking, no async mode.

No authorization check. Handoff routing is prompt-driven — the Bot’s system prompt specifies when to hand off and to which Bot IDs. Invalid botId values return “Bot not found.”


Architecture

User sends "Do xyz on project Whatever"

  ├─ ChatPostHandler → Bot A (Generic Bot)
  │    ├─ Bot A calls switchAgent(botId=projectBot, reason="requires project tools")
  │    ├─ tool.execute: validates Bot B exists, updates Convo.botId
  │    ├─ tool.execute: returns SwitchAgentResult
  │    └─ Bot A finishes its turn (may add a brief handoff message to the user)

  ├─ ChatPostHandler detects SwitchAgentResult via onStepFinish
  │    ├─ persists handoff AiMessage
  │    ├─ resolves Bot B config (model, system prompt, tools)
  │    ├─ appends handoff instructions to Bot B's system prompt
  │    └─ re-calls streamText() with Bot B config + original user messages
  │         └─ Bot B processes the user's original message with its own capabilities

  └─ Client sees: Bot A's handoff message, then Bot B's response — single continuous stream

One re-call only. The pipeline re-calls streamText exactly once with the target Bot’s config. If the target Bot calls switchAgent within that turn, the tool updates Convo.botId for the next turn — no recursive re-calling.


Source Repository Locations

ItemTarget PackagePath
Handoff types@erikdakoda/agentpackages/@erikdakoda/agent/handoff/types.ts
switchAgent AI Tool@erikdakoda/agentpackages/@erikdakoda/agent/ai-tools/switchAgent.ts
Tool registration@erikdakoda/agentpackages/@erikdakoda/agent/ai-server-tools/switchAgent.ts
ChatPostHandler handoff integration@erikdakoda/inferencepackages/@erikdakoda/inference/ChatPostHandler.ts
BotCraft importapps/bot-craftapps/bot-craft/src/aiServerTools.ts

Task 1 — Define Handoff Types

Create packages/@erikdakoda/agent/handoff/types.ts:

export interface SwitchAgentResult {
  type: 'switchAgent';
  targetBotId: string;
  targetBotName: string;
  reason: string;
  sourceBotId: string;
  sourceBotName: string;
}

export function isSwitchAgentResult(result: unknown): result is SwitchAgentResult {
  return (
    typeof result === 'object' &&
    result !== null &&
    (result as SwitchAgentResult).type === 'switchAgent'
  );
}

Verify

  • Types compile without circular dependencies

Task 2 — Build the switchAgent AI Tool

2.1 Create tool definition

Create packages/@erikdakoda/agent/ai-tools/switchAgent.ts:

import { tool, zodSchema } from 'ai';
import { z } from 'zod';
import type { AiTool } from '@dakoda/agent/types';

export const switchAgentInputSchema = z.object({
  botId: z.string().describe('The ID of the Bot to hand off to.'),
  reason: z.string().describe(
    'A brief explanation of why you are handing off. Shown to the receiving Bot.'
  ),
});

export const switchAgentOutputSchema = z.object({
  type: z.literal('switchAgent'),
  targetBotId: z.string(),
  targetBotName: z.string(),
  reason: z.string(),
  sourceBotId: z.string(),
  sourceBotName: z.string(),
});

export type SwitchAgentInput = z.infer<typeof switchAgentInputSchema>;
export type SwitchAgentOutput = z.infer<typeof switchAgentOutputSchema>;

export const switchAgent: AiTool<unknown, SwitchAgentInput, SwitchAgentOutput> = {
  id: 'switchAgent',
  group: 'handoff',
  userSelect: false,
  tool: tool({
    description:
      'Transfer control of this conversation to a different Bot. ' +
      'Use this when the task requires a different Bot\'s specialized capabilities, model, or tools. ' +
      'The receiving Bot will process the user\'s message with its own configuration.',
    inputSchema: zodSchema(switchAgentInputSchema),
    outputSchema: zodSchema(switchAgentOutputSchema),
  }),
};

2.2 Create server-side tool registration

Create packages/@erikdakoda/agent/ai-server-tools/switchAgent.ts:

import { switchAgent } from '../ai-tools/switchAgent';
import { registerAiTool } from '@dakoda/agent/aiToolRegistry';
import { getSpawnContext } from '@dakoda/agent/spawnContext';
import { getEnhancedExtendedPrisma } from '@dakoda/database/server/getEnhancedExtendedPrisma';
import type { SwitchAgentResult } from '@dakoda/agent/handoff/types';

switchAgent.tool.execute = async (input) => {
  const context = getSpawnContext();
  const db = await getEnhancedExtendedPrisma();
  const targetBot = await db.bot.findUnique({
    where: { id: input.botId },
    select: { id: true, name: true },
  });

  if (!targetBot) {
    return {
      type: 'switchAgent' as const,
      targetBotId: input.botId,
      targetBotName: '(unknown)',
      reason: `Handoff failed: Bot with ID "${input.botId}" not found.`,
      sourceBotId: context.currentBotId,
      sourceBotName: context.currentBotName,
    };
  }

  // Update Convo.botId — next turn and the upcoming re-call will use the target Bot's config
  await db.convo.update({
    where: { id: context.convoId },
    data: { botId: targetBot.id },
  });

  return {
    type: 'switchAgent',
    targetBotId: targetBot.id,
    targetBotName: targetBot.name,
    reason: input.reason,
    sourceBotId: context.currentBotId,
    sourceBotName: context.currentBotName,
  } satisfies SwitchAgentResult;
};

registerAiTool(switchAgent);

Verify

  • Tool appears in aiToolRegistry
  • Valid botIdSwitchAgentResult + Convo.botId updated
  • Non-existent botId → error result (no crash), Convo unchanged
  • type: 'switchAgent' discriminator set correctly

Task 3 — Extend SpawnContext

Add convoId to SpawnContext (if not already present from Plan 2):

// In packages/@erikdakoda/agent/spawnContext.ts
export interface SpawnContext {
  // ... existing fields ...
  currentBotId: string;
  currentBotName: string;
  convoId: string;
}

Populate in ChatPostHandler:

currentBotId: bot.id,
currentBotName: bot.name,
convoId: convo.id,

Verify

  • getSpawnContext() returns convoId

Task 4 — Implement Handoff Pipeline in ChatPostHandler

4.1 Detect handoff via onStepFinish

let pendingHandoff: SwitchAgentResult | null = null;

const result = streamText({
  model,
  system: systemPrompt,
  messages: modelMessages,
  tools,
  maxSteps: 25,
  ...modelParameters,
  onStepFinish: async ({ toolResults }) => {
    for (const toolResult of toolResults) {
      if (isSwitchAgentResult(toolResult.result)) {
        pendingHandoff = toolResult.result;
      }
    }
  },
  abortSignal: request.signal,
});

4.2 After stream completes, re-call with target Bot if handoff detected

// After the initial streamText completes:
if (pendingHandoff && pendingHandoff.targetBotName !== '(unknown)') {
  const handoff = pendingHandoff;

  // Persist handoff AiMessage
  await persistHandoffMessage(handoff, context);

  // Resolve target Bot config
  const targetBot = await resolveBotForHandoff(handoff.targetBotId);
  const { integration, modelName } = resolveModel(targetBot);
  const targetModel = getLanguageModel(integration, modelName, 'responses', {
    posthogDistinctId: session.user.id,
    posthogTraceId: convo.id,
  });
  const targetSystemPrompt = await computeBotSystemPrompt(targetBot);
  const targetTools = resolveHandoffTools(targetBot);
  const targetModelParams = getModelParameters(targetBot);

  // Re-call streamText with target Bot, same user messages
  const handoffResult = streamText({
    model: targetModel,
    system: appendHandoffInstructions(targetSystemPrompt, handoff),
    messages: modelMessages,
    tools: targetTools,
    maxSteps: 25,
    ...targetModelParams,
    abortSignal: request.signal,
  });

  // Merge both streams for the client
  return mergeStreams(result.stream, handoffResult.stream);
}

return result;

4.3 Resolve target Bot and tools

async function resolveBotForHandoff(botId: string) {
  const db = await getEnhancedExtendedPrisma();
  return db.bot.findUniqueOrThrow({
    where: { id: botId },
    include: { childPrompts: true },
  });
}

function resolveHandoffTools(bot: any): Record<string, any> {
  const tools = getAiTools();
  // When Plan 1 is complete, add MCP tools for this Bot here
  return tools;
}

4.4 Stream merging

The client receives a single continuous stream: Bot A’s response (including the switchAgent tool call and any handoff message), then Bot B’s response. The Vercel AI SDK’s UIMessageStream supports multiple assistant message parts in a single response — merge both streams into one.

Verify

  • Handoff changes system prompt, model, and tools for the re-call
  • Convo botId updated
  • Bot B processes the user’s original message
  • Client sees a single continuous stream (Bot A + Bot B)
  • Non-existent Bot IDs: error result, no re-call, no crash

Task 5 — Persist Handoff Events

Persist the handoff as an AiMessage so it appears in conversation history:

async function persistHandoffMessage(handoff: SwitchAgentResult, context: any) {
  const db = await getEnhancedExtendedPrisma();
  await db.aiMessage.create({
    data: {
      id: generateId('msg'),
      convoId: context.convoId,
      role: 'system',
      content: [
        `[HANDOFF] Control transferred from "${handoff.sourceBotName}" to "${handoff.targetBotName}".`,
        `Reason: ${handoff.reason}`,
      ].join('\n'),
      metadata: {
        handoff: {
          sourceBotId: handoff.sourceBotId,
          sourceBotName: handoff.sourceBotName,
          targetBotId: handoff.targetBotId,
          targetBotName: handoff.targetBotName,
          reason: handoff.reason,
        },
      },
    },
  });
}

Verify

  • Handoff messages appear in conversation history with source/target Bot metadata
  • No handoff message persisted for non-existent Bot (no re-call occurs)

Task 6 — Append Handoff Instructions to System Prompt

Inject handoff context into the target Bot’s system prompt so it knows it was handed off to:

function appendHandoffInstructions(systemPrompt: string, handoff: SwitchAgentResult): string {
  return systemPrompt + `

---

## Agent Handoff

Control was transferred from "${handoff.sourceBotName}" to you. Reason: ${handoff.reason}.
Process the user's original message using your own capabilities and tools.
`;
}

The routing logic (which Bots to hand off to and when) is authored in the source Bot’s system prompt (via child prompts). This is a content concern, not a code concern.

Verify

  • Handoff context injected into target Bot’s system prompt
  • Bots whose prompts don’t mention handoff targets won’t call switchAgent

Task 7 — Register Tool in BotCraft

Add to apps/bot-craft/src/aiServerTools.ts:

import '@dakoda/agent/ai-server-tools/spawnAgent';      // Plan 2
import '@dakoda/agent/ai-server-tools/checkAgent';      // Plan 2
import '@dakoda/agent/ai-server-tools/abortAgent';      // Plan 2
import '@dakoda/agent/ai-server-tools/switchAgent';     // Plan 3

Verify

  • switchAgent in aiToolRegistry after BotCraft starts

Task 8 — Tests

8.1 Unit tests

  • isSwitchAgentResult correctly identifies / rejects results
  • switchAgent tool: valid botId → correct SwitchAgentResult + Convo.botId updated
  • Non-existent botId → error result, Convo unchanged

8.2 Integration tests

  1. Handoff: Generic Bot → Project Bot — Convo.botId updated, user’s message re-processed by Project Bot, single continuous stream
  2. Non-existent Bot: error result, no re-call, no crash
  3. Handoff AiMessage persisted with correct metadata
  4. Handoff context injected into target Bot’s system prompt
  5. Target Bot’s response reflects its own config (model, tools, prompt)

Task 9 — Documentation

  • packages/@erikdakoda/agent/handoff/README.md — architecture, one-handoff-per-turn model, stream merging, difference from spawnAgent, prompt-driven routing
  • JSDoc on all public functions and types

Dependency Order

Task 1 (Types) → Task 2 (Tool) → Task 3 (Context) → Task 4 (Pipeline) → Task 5 (Persist)
                                                         → Task 6 (Prompt)
                                                         → Task 7 (Register)
                                                         → Task 8 (Tests) → Task 9 (Docs)

Plan 1 dependency: Not required. Without McpServerManager, receiving Bot uses native AI tools only.

Plan 2 dependency: Requires SpawnContext / getSpawnContext. If Plan 2 not yet implemented, Task 3 creates it directly.