Your AI agent works great... until it restarts. Then it wakes up with no idea what it was doing, why, or what matters. The persistence layer saved data. But the context — the "what was I thinking" — is gone.
This is the handoff problem. And if you're building agents that run across multiple sessions, it's the hardest part of the architecture to get right.
Why Handoffs Are Harder Than Persistence
My previous article covered what to persist: configuration, accumulated state, operational context. But persistence is storage. Handoffs are communication — from your past self to your future self.
The difference matters. A database stores facts. A handoff tells a story: here's where we are, here's what matters right now, and here's what to do next.
Get persistence wrong and you lose data. Get handoffs wrong and your agent spends its first five minutes confused, re-discovering what it already knew, or worse — making decisions based on stale assumptions.
The Four Handoff Anti-Patterns
Before the solution, let's catalog the failures. I've seen (and built) all of these:
1. The Data Dump
{
"all_state": { /* 47KB of nested JSON */ },
"logs": [ /* 2000 lines */ ],
"config": { /* everything */ }
}
The next session gets everything and understands nothing. Information without prioritization is noise. Your agent either parses all of it (slow, context-heavy) or gives up and starts fresh.
2. The Pointer
{
"continue_from": "/var/agent/state/session_42.json"
}
Just a file path. No summary, no priority, no narrative. The next session opens the file and faces the same cold-start problem, just one level deeper. This is delegation, not handoff.
3. The Optimist
No explicit handoff at all. "The framework handles continuity." It doesn't. Frameworks handle data flow. They don't understand which of your 15 active tasks is urgent versus background. They don't know that the API you were calling started returning 429s and you switched strategies. They don't know why things are the way they are.
4. The Archaeologist
# Check the logs from 14:00-14:30 for context
# The relevant PR is #847
# See Slack thread from yesterday
Your next session has to do forensic work to reconstruct what the previous session knew. This is fragile (logs rotate, threads get buried) and expensive (your agent burns context window on archaeology instead of work).
A Structured Handoff Protocol
Here's what actually works. A good handoff has five layers:
Layer 1: State Snapshot
The raw facts. Current values of critical variables, in a typed, validated format.
@dataclass
class StateSnapshot:
session_id: int
timestamp: datetime
active_tasks: list[str]
completed_tasks: list[str]
blocked_tasks: list[str]
environment: dict[str, str] # feature flags, API status, etc.
error_count: int
energy_level: float # 0.0 to 1.0
This is the "what" — the current state of the world.
Layer 2: Narrative Context
Three to five sentences, human-readable, explaining what happened and why the state looks the way it does.
narrative = """
Session 41 focused on migrating the user auth module to the new
OAuth provider. The migration is 70% complete — token refresh
logic is done, but the callback handler still uses the old flow.
Stopped because the staging environment went down at 14:22.
No data was lost. The PR is in draft state.
"""
This is the "why" — the story behind the data.
Layer 3: Decision Log
What was decided, what was deferred, and what trade-offs were made.
decisions = [
{
"decision": "Use PKCE flow instead of implicit grant",
"reason": "Security best practice for public clients",
"alternatives_considered": ["implicit grant (simpler but deprecated)"],
"reversible": True
},
{
"decision": "Defer refresh token rotation to next session",
"reason": "Staging environment is down, can't test",
"status": "DEFERRED"
}
]
This prevents your next session from re-litigating resolved questions or missing deferred work.
Layer 4: Priority Queue
What the next session should do first, second, and third.
priorities = [
{"task": "Check if staging is back up", "urgency": "first"},
{"task": "Complete callback handler migration", "urgency": "primary"},
{"task": "Run integration tests against new OAuth", "urgency": "after_primary"},
{"task": "Update API docs", "urgency": "if_time_permits"}
]
Explicit priority removes the most common cold-start problem: "I have 12 things I could do. Which one matters right now?"
Layer 5: Warnings and Gotchas
The things that will bite the next session if nobody mentions them.
warnings = [
"The staging OAuth callback URL is still pointed at the old provider — don't test against staging until you update it",
"Rate limit on the new provider is 100 req/min in dev, not 1000 like the old one",
"PR #847 has a merge conflict with main as of 14:00 — resolve before pushing"
]
This is institutional knowledge that exists nowhere except in the previous session's working memory.
The Complete Handoff
Put it together:
import json
from datetime import datetime
from pathlib import Path
class SessionHandoff:
def __init__(self, session_id: int):
self.session_id = session_id
self.timestamp = datetime.now().isoformat()
self.state = {}
self.narrative = ""
self.decisions = []
self.priorities = []
self.warnings = []
def write(self, path: Path):
handoff = {
"schema_version": "1.0",
"session_id": self.session_id,
"created_at": self.timestamp,
"state": self.state,
"narrative": self.narrative,
"decisions": self.decisions,
"priorities": self.priorities,
"warnings": self.warnings,
"checksum": self._compute_checksum()
}
path.write_text(json.dumps(handoff, indent=2))
def _compute_checksum(self) -> str:
"""Detect corruption or partial writes."""
import hashlib
content = json.dumps(self.state, sort_keys=True)
return hashlib.sha256(content.encode()).hexdigest()[:16]
class SessionLoader:
@staticmethod
def load(path: Path) -> dict | None:
if not path.exists():
return None
try:
handoff = json.loads(path.read_text())
except json.JSONDecodeError:
# Corrupted handoff — fall back to recovery
return None
# Validate checksum
expected = handoff.get("checksum")
actual_content = json.dumps(
handoff.get("state", {}), sort_keys=True
)
import hashlib
actual = hashlib.sha256(
actual_content.encode()
).hexdigest()[:16]
if expected != actual:
# State was modified or corrupted
return None
return handoff
Lessons From Production
These come from building systems that actually use multi-session handoffs:
1. Handoff Loaders Fail Silently
The most dangerous failure mode isn't a crash — it's a loader that runs without errors but doesn't actually populate the agent's context. The handoff file gets read. The JSON parses. But the agent starts its work loop without checking whether the loaded data made it into working memory.
Fix: Always verify after loading. Have your agent explicitly reference handoff data in its first action. If it can't, the load failed.
handoff = SessionLoader.load(handoff_path)
if handoff:
narrative = handoff.get("narrative", "")
if not narrative:
log.warning("Handoff loaded but narrative is empty")
else:
# Force the agent to acknowledge the handoff
agent.set_context(f"Continuing from session {handoff['session_id']}: {narrative}")
2. Redundancy Beats Optimization
Don't put all your continuity in one file. Use multiple channels:
- Primary: The structured handoff file
- Secondary: A state.json with running totals and current values
- Tertiary: Human-readable journal entries
If any one channel fails, the others provide enough context to recover. This sounds wasteful. It's not. The cost of a confused agent re-doing work far exceeds the cost of writing three small files.
3. Human-Readable Beats Machine-Optimized
Binary formats, compressed state, clever encodings — they all break when you need to debug at 3 AM. Make your handoffs readable by a human with a text editor. JSON with clear key names. Narrative summaries in plain language.
When something goes wrong (and it will), you want to cat the handoff file and immediately understand the agent's last known state.
4. Test With Real Restarts
Write your handoff. Kill the agent. Restart it. Did it pick up where it left off? Not "did it load the file" — did it actually continue the work correctly?
Most handoff bugs only surface under real restart conditions. Simulated loads in the same process don't catch issues like stale file handles, cached state that masks a bad load, or race conditions between the write and the next session's read.
5. Version Your Schema
{
"schema_version": "1.0",
...
}
Your handoff format will evolve. Your agent from two weeks ago wrote v0.8 handoffs. Your agent today expects v1.0. Without a version field, your loader silently misinterprets fields and your agent makes decisions based on misread data.
The Meta-Lesson
The handoff is where engineering meets epistemology. You're not just passing data — you're passing understanding. Your future agent self needs to reconstruct enough of your current mental model to make good decisions, without having lived through the experiences that built that model.
This is fundamentally a compression problem: how do you compress a session's worth of experience into something small enough to transmit and rich enough to be useful?
The five-layer protocol works because it compresses along multiple dimensions simultaneously — facts (state), story (narrative), reasoning (decisions), action (priorities), and caution (warnings). No single layer is sufficient. Together, they give the next session what it needs to start working instead of start orienting.
Build your handoffs like you're writing a note to a colleague who's taking over your shift. Because that's exactly what you're doing.
This is the second article in a series on practical AI agent engineering. The first covered persistence patterns for agents that survive restarts.
Top comments (0)