Black Dog Labs
Back to Blog

Claude Code Decoded: The Handoff Protocol

Session handoffs waste 10,000+ tokens re-explaining context. Learn how to build an MCP server that implements a Handoff Protocol to reduce context transfer to under 2,000 tokens.

Black Dog Labs Team
10/11/2025
13 min read
claude-codeai-developmentmcpcost-optimizationproductivity

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.

claude-code

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:

  1. What you're doing: "Fixing authentication bug in the token validation logic"
  2. Where it lives: "auth-service/src/validators/token.ts, lines 240-260"
  3. What you decided: "Using JWT over sessions for stateless scaling"
  4. 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-server
cd handoff-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod

2. Core server implementation

// src/index.ts
import { 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 schema
const 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 TypeScript
npm 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
}
}
# Build
npm 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-validation
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
Files:
- 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.ts
Contains the buggy validation logic
Focus areas:
- Lines 240-260: Token expiry validation logic
### auth-service/src/config/jwt.ts
JWT configuration
Focus 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 tokens
Session 2: Resume after break
Re-establish context: 10,000 tokens
Continue work: 8,000 tokens
Total: 18,000 tokens
Combined cost: 38,000 tokens
Time 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 tokens
Session 2: Resume after break
Load handoff: 800 tokens
Continue work: 8,000 tokens
Total: 8,800 tokens
Combined cost: 28,900 tokens
Token savings: 24% (9,100 tokens)
Time to restore context: 30 seconds
Real-world impact

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:

  1. End of day handoffs: Capture state before signing off, restore in the morning
  2. Machine switches: Working from laptop → desktop → laptop
  3. Collaboration: Hand off work to another developer or to Claude in a different context
  4. Long-running features: Checkpoint progress on multi-day features
  5. Context resets: When you need a fresh session but want to preserve state

Lower-value scenarios:

  1. Quick questions: Single-turn interactions don't need handoffs
  2. Read-only exploration: Just browsing code doesn't accumulate state
  3. 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 files
interface 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 handoff
interface 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 members
interface TeamHandoff extends HandoffState {
owner: string;
collaborators: string[];
visibility: "private" | "team" | "public";
}

4. Integration with prompt caching

// Mark cacheable content in handoffs
interface 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

  1. Context quality improves over time: Each handoff refines what matters
  2. Decision log becomes documentation: Your rationale is captured automatically
  3. Onboarding accelerates: New team members can load handoffs to understand work
  4. 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:


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.