Multi-backend note (2026-07): everything below applies to the Claude backend specifically; the same concepts (native session id in
sessions.sdk_session_id, resume, fork) are implemented per backend behindnerve/agent/backends/. Thesessions.backendcolumn records which runtime owns a session — resolution is sticky (stored backend beats config) and cross-backend resume is refused (the native id is cleared and the conversation restarts). Codex threads resume viathread/resumeand fork viathread/fork; a stale codex thread id is recovered by falling back to a fresh thread (resume_dropped).
The Claude Agent SDK manages conversation context internally. Never load messages into prompts manually. Use resume and fork_session to give the agent context.
Nerve uses a can_use_tool callback instead of bypassPermissions. This enables mid-turn pausing for interactive tools while auto-approving everything else:
options = ClaudeAgentOptions(
can_use_tool=handler.can_use_tool, # replaces permission_mode
)The SDK sets --permission-prompt-tool stdio automatically, routing all tool permission requests through the control protocol. The InteractiveToolHandler (in nerve/agent/interactive.py) handles each request:
- Non-interactive tools (Read, Bash, MCP, etc.) →
PermissionResultAllow()immediately AskUserQuestion→ Broadcasts question to UI via WebSocketinteractionevent, awaits user answer, returnsPermissionResultAllow(updated_input={...answers})— the SDK injects answers into the tool'sanswersfieldExitPlanMode/EnterPlanMode→ Broadcasts approval request, awaits user decision, returns allow or deny
Handlers are registered per-session in a global registry (interactive._handlers). The WebSocket server routes answer_interaction messages to the correct handler by session ID.
Each conversation session gets an sdk_session_id — a unique identifier the SDK uses to store and restore full conversation state (messages, tool calls, thinking blocks, everything).
When you want to continue an existing conversation:
options = ClaudeAgentOptions(
resume=session.sdk_session_id, # SDK restores full context
fork_session=False,
)The SDK restores the entire conversation history. No manual message loading needed.
Nerve's implementation: engine._get_or_create_client() checks the session's sdk_session_id column and passes it as resume to the SDK options. See engine.py:330-403.
When you want a new session that starts with the parent's full context:
options = ClaudeAgentOptions(
resume=parent.sdk_session_id, # Branch FROM this point
fork_session=True, # Create independent branch
resume_session_at=turn_uuid, # Optional: truncate at a mid-point turn
)The fork inherits all parent context but diverges from that point. New messages don't affect the parent.
Mid-point forks. Both backends can branch at an earlier turn, not just
the end: Claude via resume_session_at (a transcript-entry UUID), Codex via
thread/fork + lastTurnId. To make that possible, each backend records a
per-turn anchor as messages.native_turn_id on the completed assistant row —
Codex reports the turn id natively; the Claude client tracks the last
main-chain transcript-entry uuid streamed during the turn
(ClaudeClient._translate_and_capture). db.get_native_turn_at_message()
maps a UI message id to that anchor. Message forks on rows with no anchor
(history predating this recording) are refused rather than silently forking
the full context.
Display parity. SessionManager.fork_session() copies the source's
message rows up to the fork boundary into the fork
(db.copy_messages_to_session), so the forked chat shows the history the
agent actually remembers. Copies keep native_turn_id (forks are
re-forkable) and get external_id = "forkcopy:<source row id>" for
provenance.
Nerve's implementation: engine.run() detects parent_session_id on first message (status=CREATED) and sets fork_from (+ fork_last_turn_id for message-anchored forks). See _get_or_create_client / _run_inner in engine.py.
New session → first message → ResultMessage.session_id → stored in DB
↓
Subsequent messages → resume=stored_id → SDK continues
↓
Stop/Idle → sdk_session_id preserved → can resume later
↓
Error → sdk_session_id cleared → must start fresh
↓
Fork → parent's sdk_session_id used once → fork gets own new ID
| Scenario | Approach |
|---|---|
| Continue user conversation | resume (automatic in _get_or_create_client) |
| Cron job (no context needed) | New isolated session |
| Webhook handler | New isolated session |
NEVER do this:
# BAD: loading messages manually into the prompt
messages = await db.get_messages(session_id, limit=20)
prompt = "Recent context:\n" + "\n".join(msg["content"] for msg in messages)This loses tool call history, thinking blocks, system prompt context, and the SDK's internal state management.
Do this instead:
# GOOD: fork from the session with context
fork = await engine.fork_session(source_session_id, source="cron")
await engine.run(session_id=fork["id"], user_message=prompt)
# SDK handles all context through the forkThe sessions table has these SDK-related columns:
sdk_session_id— The SDK's internal session identifier (set after first message)parent_session_id— For forks, points to the source sessionforked_from_message— Optional: specific message to branch fromconnected_at— When the SDK client connected (used for memorization watermark)