Docs / Reference / Spec: Carry context on Claude account switch
Documentation

Spec: Carry context on Claude account switch

## Problem

6 min read Updated Jun 14, 2026 Source Edit History

Spec: Carry context on Claude account switch#

Problem#

Switching a live Claude session to another account (PATCH /api/v1/sessions/{id}/claude-account) starts a fresh conversation — the new account's CLI has no memory of what the session was doing. This is intentional (PR #331): claude --resume <uuid> validates the UUID against the target account's own session registry, so a UUID minted under account A fails with "No conversation found" under account B and the CLI exits immediately. #331 cleared ClaudeSessionID on switch to keep the session alive at the cost of conversation continuity.

We cannot resume the same conversation across accounts — that's a Claude Code constraint, not ours. But we can carry the meaning forward: read the prior transcript and inject it as context into the fresh session's system prompt, the same channel skills / memory / project-docs already use.

Goal#

An opt-in "carry context" mode on account switch that seeds the new conversation with the prior one's content, so the operator doesn't have to re-explain what they were working on.

Non-goal: byte-exact resume (impossible across accounts). Non-goal: changing the default — fresh-session stays the default; carry-over is explicit.

Constraint recap (why injection, not resume)#

  • claude --resume is account-scoped; cross-account resume is rejected.
  • The old transcript-migration hard-link (removed in #331) didn't help: Claude needs the session in the account's registry, not just a .jsonl on disk.
  • The only portable channel is --append-system-prompt, already used for: skills index, memory guidance, ambient memory, project docs (internal/catalog/adapter.go, case "claude"out.Args = append(out.Args, "--append-system-prompt", text)).

Existing infrastructure we reuse#

Piece Location Role in this feature
Manager.SwitchClaudeAccount internal/session/manager.go:952-1027 where we read the old transcript before clearing ClaudeSessionID (line 994)
claudeTranscript() / findClaudeProjectDir() / findLatestClaudeJSONL() internal/session/claude_jsonl.go locate + read the old account's .jsonl for the old UUID
Context threading into Prepare() internal/session/provider.go (WithCwd, WithSessionID, WithResumeClaudeSessionID) add a one-shot WithCarryoverContext key
--append-system-prompt injection internal/catalog/adapter.go Resolve() (case "claude") the sink that puts the carried text into the spawn
Switch request body internal/session/handler.go:111-116 (SwitchAccountRequest) add carry_context bool

Design#

1. Capture (before the switch tears down the old binding)#

In SwitchClaudeAccount, before line 994 clears ClaudeSessionID, while we still hold current.ClaudeAccountID (old) and current.ClaudeSessionID (old):

[object Promise]

buildCarryover reuses claude_jsonl.go to:

  1. Resolve the old account's projects root + findClaudeProjectDir(cwd).
  2. Read <dir>/<oldUUID>.jsonl (fail-closed on missing — same M22 defense the existing reader uses; do NOT fall back to latest-mtime, that risks pulling an unrelated session).
  3. Parse the JSONL turns, keep user + assistant text content, drop tool-call/tool-result noise.
  4. Take the tail up to a byte/token cap (see Budget).
  5. Wrap in a labeled block (see Prompt format).

The transcript file persists on disk after Stop(), so capture can happen either side of the stop; doing it first means a read failure can abort cleanly before we touch the running process.

2. Inject (one-shot, into the respawn only)#

Thread the captured text into the respawn via a new transient context key — NOT a persisted Session field:

[object Promise]

In SwitchClaudeAccount, when calling spawn:

[object Promise]

(spawn currently builds its own prepareCtx; thread the carryover through it.)

In adapter.go Resolve(), after the existing skill/memory injections, add:

[object Promise]

One-shot is correct and important. The context key is set only by SwitchClaudeAccount, so the carryover is injected only into the first spawn under the new account. The new account mints its own fresh UUID; later restarts --resume that UUID, whose transcript already contains the seeded context — so continuity persists naturally without re-injecting (and without re-injecting on every daemon-restart resume).

3. Prompt format#

[object Promise]

4. Budget + truncation#

  • Cap the injected block at ~6 000 tokens (≈ 24 KB) — large enough for meaningful continuity, small enough to not dominate the new context window or spike cost. Configurable.
  • Tail-truncate (keep the most recent turns); if the first kept turn is mid-thread, prepend an explicit …[earlier turns omitted]… marker.
  • Strip tool_use / tool_result blocks; keep their text outcomes only if cheap. v1 can drop tool content entirely.

5. Failure handling — degrade to fresh, never fail the switch#

The switch must keep working even if carryover can't be built:

Failure Behavior
transcript file missing log debug, inject nothing, fresh session
parse error / malformed jsonl log warn, inject nothing
over budget truncate to cap
carry_context=false (default) skip entirely — current behavior

A carryover failure logs and proceeds; it never returns an error from SwitchClaudeAccount.

API change#

SwitchAccountRequest (internal/session/handler.go):

[object Promise]

Default false preserves #331 behavior. No new route, no path change.

UI change (web + mobile)#

The account switcher gains a checkbox: "Carry over conversation context" (default off), with a one-line helper:

Seeds the new account's session with your recent conversation. The prior conversation content is sent to Anthropic under the new account.

That second sentence is the consent surface — see Privacy.

Privacy / data-boundary note (important)#

Carrying context means conversation content created under account A is fed into a prompt processed under account B — i.e. sent to Anthropic billed/governed by account B's terms. For most operators (personal multi-account pools) this is fine, but it's a real cross-boundary data flow and must be explicit, opt-in, and labeled at the point of action. Never default it on. The UI helper text above is the consent surface; the API default false is the backstop.

Phasing#

  • Phase 1 (this spec): raw transcript-tail injection, opt-in via carry_context, byte/token-capped, tool-noise stripped, fail-open. No extra LLM call — deterministic and cheap. UI checkbox + consent helper. Ships the 80% value.
  • Phase 2 (optional, separate PR): summarized carryover — generate a compact recap instead of raw tail. Open question: which credential summarizes? Cleanest is a short headless claude -p under the old account before Stop(), so the summary is produced under the account that owns the data. Adds latency + token cost; gate behind a config flag carry_context_mode: raw|summary.
  • Phase 3 (optional): global default in config + per-account policy (e.g. "never carry across these two accounts").

Test plan#

  • Switch with carry_context=true → new session's first turn shows it has prior context (ask "what were we doing?" → coherent answer).
  • Switch with carry_context=false (default) → fresh session, no injection (byte-for-byte the current #331 behavior).
  • Old transcript missing (e.g. brand-new session, no .jsonl yet) → switch still succeeds, fresh session, debug log only.
  • Oversized transcript → injected block capped at budget, truncation marker present.
  • Later restart of the switched session → resumes the new UUID, does NOT re-inject carryover (verify only one --append-system-prompt carryover block ever appears).
  • Catalog unit test locking: carryover context key present → --append-system-prompt arg emitted; absent → not emitted.

Files touched (implementer checklist)#

  • internal/session/handler.goSwitchAccountRequest.CarryContext
  • internal/session/manager.goSwitchClaudeAccount capture + thread carryover into respawn; spawn accepts the carryover ctx
  • internal/session/provider.goWithCarryoverContext / CarryoverContext
  • internal/session/claude_jsonl.gobuildCarryover helper (reuses existing locate/read/parse)
  • internal/catalog/adapter.go — inject --append-system-prompt when carryover present (claude case)
  • internal/catalog/*_test.go — lock the inject contract
  • app/web/src/components/sessions/AccountSwitcher.tsx + mobile equivalent — checkbox + consent helper
  • app/shared/src/lib/* — pass carry_context in the switch call
  • i18n: en/es/zh strings for the checkbox + helper