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
| Tool | Purpose |
|---|
switchAgent | Transfer 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
| Item | Target Package | Path |
|---|
| Handoff types | @erikdakoda/agent | packages/@erikdakoda/agent/handoff/types.ts |
| switchAgent AI Tool | @erikdakoda/agent | packages/@erikdakoda/agent/ai-tools/switchAgent.ts |
| Tool registration | @erikdakoda/agent | packages/@erikdakoda/agent/ai-server-tools/switchAgent.ts |
| ChatPostHandler handoff integration | @erikdakoda/inference | packages/@erikdakoda/inference/ChatPostHandler.ts |
| BotCraft import | apps/bot-craft | apps/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
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),
}),
};
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
botId → SwitchAgentResult + 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;
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
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
- Handoff: Generic Bot → Project Bot — Convo.botId updated, user’s message re-processed by Project Bot, single continuous stream
- Non-existent Bot: error result, no re-call, no crash
- Handoff AiMessage persisted with correct metadata
- Handoff context injected into target Bot’s system prompt
- 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.