Skip to content

Chats are Claude Code sessions

A chat in Paddock is not a Paddock-invented construct — it is a Claude Code session, persisted on disk as a transcript and resumable across page reloads, socket reconnects, server restarts, and even different devices. Paddock adds the UI, the streaming transport, and the project attribution; the session itself is Claude Code’s.

Each chat is one JSONL transcript file, <sessionId>.jsonl, written by the Claude Code CLI — Paddock only reads it. Claude Code stores transcripts under <claudeHome>/projects/<encoded-cwd>/, where the encoded name is the agent’s absolute working directory with non-alphanumeric characters replaced by -. So the working directory is the session key — no separate database of chats. Paddock always runs Claude Code against a Claude home it owns — <dataDir>/claude-home — so that path is <dataDir>/claude-home/projects/<encoded-cwd>/.

Paddock then symlinks that encoded directory, and claude.transcripts decides where the symlink points (ensureProjectChats() in transcripts.ts). Under the default transcripts: own it targets the project’s .chats/ folder, so the transcript physically lives inside the project directory and rides the same backup. Under transcripts: host it targets the user’s real ~/.claude/projects/<encoded-cwd>/ instead, so chats are shared with the machine’s own terminal claude history.

Listing, reading, and resuming resolve transparently through the symlink either way. Deleting does not. Under transcripts: host the transcript is the user’s own claude history rather than Paddock’s copy, so HerdctlService.deleteSession releases the chat instead of removing it ({removed: false, retained: true}) and the file stays on disk. Releasing only drops the adoption record, so the chat is still listed afterwards — the engine rediscovers the transcript structurally on the next listing. That gap is tracked as issue #693.

The transcript is the authoritative record of the conversation. Everything else about a chat is either derived from it (previews, token/context usage, the rendered message list) or a small piece of side-metadata in a server sidecar — its archived flag (ArchiveStore), your last-seen timestamp (ReadStateStore), and any queued follow-up message (QueuedMessageStore).

Starting a chat sends chat:send with sessionId: null; the session id is minted by Claude Code and arrives mid-stream (Paddock captures it and attributes the running session to the project so the chat appears in the sidebar before the turn finishes — issue #100). Every later turn on that chat sends the same sessionId, and Claude resumes the existing session (resume: <sessionId>).

Resumption is robust to interruptions at several layers:

  • Reload / new device — the client hydrates the chat from the REST transcript endpoint; because the transcript is on disk and per-user read-state is a server sidecar, the same chat (and its unread state) appears anywhere you log in.
  • Mid-turn reconnect — the client re-attaches over the WebSocket with chat:subscribe { wantReplay: true, lastSeq }, and the SessionHub replays the buffered frames it missed (or tells it to re-hydrate from REST if the buffer aged out). A live turn keeps streaming to whoever attaches.
  • Server restart — the transcript and all sidecars are on disk, so chats survive; a resumed turn picks up from the persisted session.

A reply can accrete into the live bubble token-by-token as the model produces it, rather than landing in one drop when the turn ends. This is a property of the runtime, not the transport:

  • Session mode (SDK runtime) opts into partial (streaming) assistant messages — herdctl surfaces incremental text_delta chunks, which the WebSocket layer forwards as chat:response frames that append to the bubble as they arrive.
  • Batch mode (CLI runtime) renders each assistant message whole when it completes; there’s no intra-message streaming.

The drive mode is set by PADDOCK_DRIVE_MODE (with a per-project driveMode override) — see Agents. Everything else about a chat is identical either way: the transport was already delta-shaped, so re-attach and replay behave the same whether or not tokens stream.

A chat can be forked into a parallel child: forkSession copies the transcript and mints a new session id, so the child diverges without touching the parent. (Contrast with promotion, which moves a root chat into a project of its own — see Agents.) Forked children run under the same agent (up to KEEPER_MAX_CONCURRENT in parallel) and are full chats in their own right — resumable, forkable, archivable.

In the sidebar a fork is filed underneath the chat it was forked from, so splitting a conversation three ways gives you one foldable family rather than three unrelated rows. That’s true however the fork was made — by you from the message hover rail, or by Claude calling fork_chat — and note it nests under its source, which for a Claude-made fork isn’t necessarily the chat that made it. See Provenance for how that edge is recorded, and for the spawn-depth consequence of forking a fork.

A chat is a resumable Claude Code session whose transcript lives on disk inside its project; Paddock streams it live and lets you pick it back up from anywhere.