Claude Code Decoded: The Handoff Protocol
You've just spent an hour with Claude Code making solid progress on a feature. Everything's in context. Claude understands your architecture, your naming conventions, the specific files that matter. Then you hit your usage limit, your IDE probably crashes mid-session.
Now you're starting over. And the process looks like this:
You: "I'm working on the authentication service. Here are the files..."*Pastes 3 files: 8,000 tokens*You: "We decided to use JWT instead of sessions because..."*Explains architecture decisions: 1,500 tokens*You: "The bug is in the token validation logic around line 247..."*More context and explanation: 2,000 tokens*Total: 11,500+ tokens just to get back to where you were
Every session handoff is a 10,000+ token tax. And unlike the token waste we covered in The Token Tax, this one feels unavoidable. How else would Claude know what you were working on?
The answer: a structured Handoff Protocol.
The anatomy of a handoff
What makes a good handoff?
Think about how you'd hand off work to another developer on your team. You wouldn't dump your entire git history on them. You'd give them:
- What you're doing: "Fixing authentication bug in the token validation logic"
- Where it lives: "auth-service/src/validators/token.ts, lines 240-260"
- What you decided: "Using JWT over sessions for stateless scaling"
- What's left: "Need to add rate limiting and write tests"
That's maybe 200 tokens of actual information. But when we hand off to Claude, we send:
- Full file contents (even when only 20 lines matter)
- Entire conversation history (even when only 3 decisions matter)
- Repeated explanations (because we're not sure what's still in context)
The insight: Most of what we send during handoffs is redundant or unnecessary. We need a protocol that captures just the essential state.
The Handoff Protocol: Design principles
What to capture
interface HandoffState { // Task context task: { objective: string; // What you're trying to accomplish status: "in_progress" | "blocked" | "needs_review"; progress_summary: string; // What's been done }; // File references files: Array<{ path: string; relevance: string; // Why this file matters focus_ranges?: Array<{ // Specific lines that matter start: number; end: number; note: string; }>; }>; // Decision log decisions: Array<{ decision: string; rationale: string; timestamp: string; }>; // Next steps next_steps: Array<{ action: string; priority: "high" | "medium" | "low"; }>;}
What NOT to capture
- Full file contents (just references and line ranges)
- Entire conversation history (just key decisions)
- Implementation details already in code (trust the code)
- Intermediate failed attempts (only what worked)
Target compression: 10,000+ tokens → 1,000-2,000 tokens (80-90% reduction)
Building the Handoff Protocol MCP Server
Architecture overview
┌─────────────────┐│ Claude Code ││ ││ /handoff save │ ← Captures current session state│ /handoff load │ ← Restores previous session state└────────┬────────┘ │ │ MCP Protocol │┌────────▼────────┐│ Handoff MCP ││ Server ││ ││ • State store ││ • Compression ││ • Validation │└────────┬────────┘ │ │┌────────▼────────┐│ ~/.handoffs/ │ ← JSON files on disk│ ││ project-123.json│ feature-abc.json└─────────────────┘
Implementation: The MCP server
1. Project setup
mkdir handoff-mcp-servercd handoff-mcp-servernpm init -ynpm install @modelcontextprotocol/sdk zod
2. Core server implementation
// src/index.tsimport { Server } from "@modelcontextprotocol/sdk/server/index.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { CallToolRequestSchema, ListToolsRequestSchema,} from "@modelcontextprotocol/sdk/types.js";import fs from "fs/promises";import path from "path";import os from "os";import { z } from "zod";// Handoff state schemaconst HandoffStateSchema = z.object({ task: z.object({ objective: z.string(), status: z.enum(["in_progress", "blocked", "needs_review"]), progress_summary: z.string(), }), files: z.array(z.object({ path: z.string(), relevance: z.string(), focus_ranges: z.array(z.object({ start: z.number(), end: z.number(), note: z.string(), })).optional(), })), decisions: z.array(z.object({ decision: z.string(), rationale: z.string(), timestamp: z.string(), })), next_steps: z.array(z.object({ action: z.string(), priority: z.enum(["high", "medium", "low"]), })), metadata: z.object({ created_at: z.string(), updated_at: z.string(), session_id: z.string(), }),});type HandoffState = z.infer<typeof HandoffStateSchema>;class HandoffServer { private server: Server; private handoffsDir: string; constructor() { this.server = new Server( { name: "handoff-protocol", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); this.handoffsDir = path.join(os.homedir(), ".handoffs"); this.setupHandlers(); } private async ensureHandoffsDir(): Promise<void> { await fs.mkdir(this.handoffsDir, { recursive: true }); } private getHandoffPath(sessionId: string): string { return path.join(this.handoffsDir, `${sessionId}.json`); } private setupHandlers(): void { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "save_handoff", description: "Save current session state for later restoration. Captures task context, file references, decisions, and next steps in a compressed format.", inputSchema: { type: "object", properties: { session_id: { type: "string", description: "Unique identifier for this handoff (e.g., 'auth-bug-fix', 'payment-feature')", }, task_objective: { type: "string", description: "What you're trying to accomplish", }, task_status: { type: "string", enum: ["in_progress", "blocked", "needs_review"], description: "Current status of the task", }, progress_summary: { type: "string", description: "Summary of what's been completed so far", }, files: { type: "array", description: "Relevant files and their context", items: { type: "object", properties: { path: { type: "string" }, relevance: { type: "string" }, focus_ranges: { type: "array", items: { type: "object", properties: { start: { type: "number" }, end: { type: "number" }, note: { type: "string" }, }, }, }, }, required: ["path", "relevance"], }, }, decisions: { type: "array", description: "Key decisions made during this session", items: { type: "object", properties: { decision: { type: "string" }, rationale: { type: "string" }, }, required: ["decision", "rationale"], }, }, next_steps: { type: "array", description: "What needs to be done next", items: { type: "object", properties: { action: { type: "string" }, priority: { type: "string", enum: ["high", "medium", "low"], }, }, required: ["action", "priority"], }, }, }, required: [ "session_id", "task_objective", "task_status", "progress_summary", "files", "next_steps", ], }, }, { name: "load_handoff", description: "Load a previously saved session state. Returns compressed context for efficient session restoration.", inputSchema: { type: "object", properties: { session_id: { type: "string", description: "The session identifier to load", }, }, required: ["session_id"], }, }, { name: "list_handoffs", description: "List all saved handoff sessions with their metadata", inputSchema: { type: "object", properties: {}, }, }, ], })); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { await this.ensureHandoffsDir(); switch (name) { case "save_handoff": { const state: HandoffState = { task: { objective: args.task_objective as string, status: args.task_status as "in_progress" | "blocked" | "needs_review", progress_summary: args.progress_summary as string, }, files: args.files as HandoffState["files"], decisions: (args.decisions as Array<{ decision: string; rationale: string }> || []).map((d) => ({ ...d, timestamp: new Date().toISOString(), })), next_steps: args.next_steps as HandoffState["next_steps"], metadata: { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), session_id: args.session_id as string, }, }; // Validate HandoffStateSchema.parse(state); // Save const filePath = this.getHandoffPath(args.session_id as string); await fs.writeFile(filePath, JSON.stringify(state, null, 2)); return { content: [ { type: "text", text: `Handoff saved successfully: ${args.session_id}\n\nSaved to: ${filePath}\n\nToken estimate: ~${this.estimateTokens(state)} tokens`, }, ], }; } case "load_handoff": { const filePath = this.getHandoffPath(args.session_id as string); try { const content = await fs.readFile(filePath, "utf-8"); const state = JSON.parse(content) as HandoffState; // Validate HandoffStateSchema.parse(state); // Format for efficient restoration const restored = this.formatForRestoration(state); return { content: [ { type: "text", text: restored, }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error: Handoff '${args.session_id}' not found`, }, ], isError: true, }; } } case "list_handoffs": { const files = await fs.readdir(this.handoffsDir); const handoffs = await Promise.all( files .filter((f) => f.endsWith(".json")) .map(async (f) => { const content = await fs.readFile( path.join(this.handoffsDir, f), "utf-8" ); const state = JSON.parse(content) as HandoffState; return { session_id: state.metadata.session_id, objective: state.task.objective, status: state.task.status, updated_at: state.metadata.updated_at, }; }) ); return { content: [ { type: "text", text: `Available handoffs:\n\n${handoffs .map( (h) => `• ${h.session_id}\n ${h.objective}\n Status: ${h.status}\n Updated: ${h.updated_at}` ) .join("\n\n")}`, }, ], }; } default: return { content: [ { type: "text", text: `Unknown tool: ${name}`, }, ], isError: true, }; } } catch (error) { return { content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); } private formatForRestoration(state: HandoffState): string { let output = "# Session Restoration\n\n"; // Task context output += `## Task: ${state.task.objective}\n`; output += `**Status:** ${state.task.status}\n`; output += `**Progress:** ${state.task.progress_summary}\n\n`; // File references output += `## Relevant Files\n\n`; for (const file of state.files) { output += `### ${file.path}\n`; output += `${file.relevance}\n`; if (file.focus_ranges && file.focus_ranges.length > 0) { output += `\nFocus areas:\n`; for (const range of file.focus_ranges) { output += `- Lines ${range.start}-${range.end}: ${range.note}\n`; } } output += `\n`; } // Decisions if (state.decisions.length > 0) { output += `## Key Decisions\n\n`; for (const decision of state.decisions) { output += `**Decision:** ${decision.decision}\n`; output += `**Rationale:** ${decision.rationale}\n\n`; } } // Next steps output += `## Next Steps\n\n`; const sortedSteps = state.next_steps.sort((a, b) => { const priority = { high: 0, medium: 1, low: 2 }; return priority[a.priority] - priority[b.priority]; }); for (const step of sortedSteps) { output += `- [${step.priority.toUpperCase()}] ${step.action}\n`; } return output; } private estimateTokens(state: HandoffState): number { // Rough estimate: 1 token ≈ 4 characters const json = JSON.stringify(state); return Math.ceil(json.length / 4); } async run(): Promise<void> { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Handoff Protocol MCP Server running on stdio"); }}const server = new HandoffServer();server.run().catch(console.error);
3. Configure Claude Desktop
Add to ~/Library/Application Support/Claude/claude_desktop_config.json
:
{ "mcpServers": { "handoff-protocol": { "command": "node", "args": ["/absolute/path/to/handoff-mcp-server/build/index.js"] } }}
4. Build and test
# Add build script to package.json{ "scripts": { "build": "tsc" }}# Install TypeScriptnpm install -D typescript @types/node# Create tsconfig.json{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }}# Buildnpm run build# Restart Claude Desktop to load the MCP server
Using the Handoff Protocol
Saving a handoff
During a Claude Code session:
You: Use the save_handoff tool to capture our current state.Session ID: auth-token-validationTask: Fixing JWT token validation bug in authentication serviceStatus: in_progressProgress: Identified the bug in token.ts line 247, root cause is expired token check logicFiles: - auth-service/src/validators/token.ts (Contains the buggy validation logic, focus on lines 240-260) - auth-service/src/config/jwt.ts (JWT configuration, focus on expiry settings)Decisions: - Using 15-minute token expiry instead of 1 hour (Security requirement) - Adding refresh token logic in next phase (Not blocking this fix)Next steps: - [high] Fix the token expiry comparison in token.ts:247 - [high] Add unit tests for expiry validation - [medium] Update error messages to be more specific - [low] Document the token validation flow
Result: Session state saved in ~1,200 tokens
Loading a handoff
In a new Claude Code session:
You: Use the load_handoff tool with session_id: auth-token-validation
Claude receives:
# Session Restoration## Task: Fixing JWT token validation bug in authentication service**Status:** in_progress**Progress:** Identified the bug in token.ts line 247, root cause is expired token check logic## Relevant Files### auth-service/src/validators/token.tsContains the buggy validation logicFocus areas:- Lines 240-260: Token expiry validation logic### auth-service/src/config/jwt.tsJWT configurationFocus areas:- Token expiry settings## Key Decisions**Decision:** Using 15-minute token expiry instead of 1 hour**Rationale:** Security requirement**Decision:** Adding refresh token logic in next phase**Rationale:** Not blocking this fix## Next Steps- [HIGH] Fix the token expiry comparison in token.ts:247- [HIGH] Add unit tests for expiry validation- [MEDIUM] Update error messages to be more specific- [LOW] Document the token validation flow
Token cost: ~800 tokens (vs 10,000+ for manual re-explanation)
The results: Before and after
Traditional handoff
Session 1: Working on feature Initial context: 5,000 tokens Conversation: 15,000 tokens Total: 20,000 tokensSession 2: Resume after break Re-establish context: 10,000 tokens Continue work: 8,000 tokens Total: 18,000 tokensCombined cost: 38,000 tokensTime to restore context: 5-10 minutes
Handoff Protocol
Session 1: Working on feature Initial context: 5,000 tokens Conversation: 15,000 tokens Save handoff: 100 tokens (tool call) Total: 20,100 tokensSession 2: Resume after break Load handoff: 800 tokens Continue work: 8,000 tokens Total: 8,800 tokensCombined cost: 28,900 tokensToken savings: 24% (9,100 tokens)Time to restore context: 30 seconds
For a team making 5 context switches per day:
- Token savings: 45,000+ tokens/day per developer
- Cost savings: $1.35/day per developer (~$35/month)
- Time savings: 25-40 minutes/day per developer
- Quality improvement: No context degradation from re-explaining
When to use the Handoff Protocol
High-value scenarios:
- End of day handoffs: Capture state before signing off, restore in the morning
- Machine switches: Working from laptop → desktop → laptop
- Collaboration: Hand off work to another developer or to Claude in a different context
- Long-running features: Checkpoint progress on multi-day features
- Context resets: When you need a fresh session but want to preserve state
Lower-value scenarios:
- Quick questions: Single-turn interactions don't need handoffs
- Read-only exploration: Just browsing code doesn't accumulate state
- Completed work: If the task is done, no need to save state
Extending the protocol
Future enhancements
1. Smart compression
// Automatic detection of what's already in filesinterface SmartFile { path: string; focus_ranges: Array<{ start: number; end: number; note: string; changed_since_save: boolean; // Only re-read if changed }>;}
2. Diff-based updates
// Only capture what changed since last handoffinterface DeltaHandoff { base_session: string; files_added: string[]; files_removed: string[]; decisions_added: Decision[]; progress_delta: string; // What changed, not full summary}
3. Team handoffs
// Share handoffs across team membersinterface TeamHandoff extends HandoffState { owner: string; collaborators: string[]; visibility: "private" | "team" | "public";}
4. Integration with prompt caching
// Mark cacheable content in handoffsinterface CacheableHandoff { static_context: { content: string; cache_control: { type: "ephemeral" }; }; dynamic_context: { content: string; };}
The compounding benefits
Token savings are just the beginning. The Handoff Protocol creates a virtuous cycle:
Better context → Better outputs → Better decisions → Better handoffs
- Context quality improves over time: Each handoff refines what matters
- Decision log becomes documentation: Your rationale is captured automatically
- Onboarding accelerates: New team members can load handoffs to understand work
- Technical debt decreases: Explicit decision tracking prevents "why did we do this?" moments
What's next
The Handoff Protocol solves session transitions, but there's more token waste to eliminate:
- File reading efficiency: Only loading the lines that matter
- Search optimization: Smarter strategies for finding relevant code
- Dependency analysis: Understanding what context is actually needed
- Automatic handoff generation: Having Claude detect when to save state
In our next post, we'll explore Smart Context Loading - building MCP tools that automatically determine the minimal context needed for any task.
Stop paying the 10,000-token tax. Start using structured handoffs.
Series navigation
← Previous: Claude Code Decoded: The Token Tax - Understanding the cost of token waste
Coming next: Claude Code Decoded: Smart Context Loading - Automatically determining minimal context
Related reading:
- Building Scalable Data Pipelines - Apply similar efficiency patterns to data architecture
- Data Platform Observability Best Practices - Measuring and optimizing system performance
Want to build custom MCP tools for your development workflow? Our team at Black Dog Labs helps engineering teams implement AI-enhanced development practices with practical, production-ready solutions. Let's discuss your specific needs and build tools that actually work.