SoW: DREW chats — a Slack thread as a Claude Code session

Date: 2026-09-22

Tracking: A-4984. This SoW replaces the thread-turns SoW under the same ticket. It follows the review lane, A-4979, and the PR questions work, A-4993.

Objective. DREW is a Claude Code harness whose terminal is a Slack thread. A chat is one persistent agent session with one checkout and a list of watched PRs. Each @DREW message in the thread is one turn. Each event on a watched PR is one turn. The review flow, the question flow, and the no-PR flow are default behaviors of the same chat, set by its system prompt. A chat can hold any number of PRs, including none.

Concept

Slack thread ──@DREW reply──▶ listener ──────────┐
                                                 ▼
GitHub PR ──phase change──▶ tick ──watch event──▶ turn(chat, message)
                                                 │
                                                 ▼
                    docker run --rm drew-worker: claude -p --resume <session>
                      mounts:  the chat's checkout and config dir
                      token:   read, or write to the chat's write set
                                                 │
                       final message ────────────┼──▶ post in the thread
                       new PRs on the write set ─┴──▶ new watches

Definitions

Chat. The unit of work. A chat has a DREW-minted id, a profile, an optional Slack binding, a session id, a directory, a capability, a write set, a list of watches, a spend cap, and a state. The ledger keys chats by id.

Binding. The Slack channel and thread that carries the chat. A chat has at most one binding. A thread has at most one chat.

Turn. One container run that resumes the chat's session, handles one message, and exits. The message is a human reply or a watch event.

Watch. One PR the chat tracks. A watch holds the PR number, its mode, the last fingerprint, the phase, the arm time, and the last dispatch time. The phase machine runs per watch.

Capability. read or write. read gives the turn a token that cannot write and a git guard that refuses every push. write gives the turn a token with contents and pull-request write and a git guard that allows pushes to the write set only.

Write set. The branches a write chat may push to. A live PR's own branch is in the set. The drew/ namespace is in the set. Nothing else is.

Profile. The fixed part of a chat's setup: the system prompt frame, the default capability, the egress allowlist, and the mounts. This SoW builds the review profile. The incident responder (A-4750) and the dependency lanes are profiles of the same chat type. They are out of scope here.

What exists on main

At 34b02fe80.

Area State
Ledger py/drew/drew/ledger.py. One Job per thread, keyed channel:thread_ts. A job holds exactly one PR. by_pr allows one active review job per PR. Unknown keys are preserved in extra.
Lanes review and ask on Job.lane. drew.router picks the lane from the first message with a Sonnet 5 call. A bare link is review.
First touch py/drew/drew/intake.py. The listener and the tick both call first_touch. Only top-level messages that @-mention DREW and carry a PR link start a job.
Thread replies The tick reads them every two minutes. In a review thread the text is appended to instructions and a worker runs with a fresh clone. In an ask thread the router runs again.
Phase machine py/drew/drew/tick.py. fingerprint, phase, decide, and describe are pure functions of one PR. A phase change posts one line. Red CI, a conflict, new comments, and changes requested dispatch a worker. ready arms auto-merge.
Worker One docker run --rm per run. The session file is inside the container and is lost at exit. The entrypoint clones unless the workspace has .git.
Git guard py/drew/worker/git-guard. Refuses force pushes and remote deletes. With DREW_READ_ONLY=1 refuses every push. No branch allowlist.
Question turn py/drew/drew/ask.py. A read-only turn with a fixed frame. The answer is the worker's final message, posted without a footer.
Status line A review worker ends with DREW_STATUS: <word>. drew.dispatch moves the job on it.
Blocked A job state. A blocked job is not re-dispatched until a reply or a push.

Ledger

The ledger file is <audit_dir>/chats.json. Its lock and transaction rules are the ones Ledger has now. Chat replaces Job.

Field Meaning
id a ULID minted by the supervisor at creation
profile review
binding {channel, thread_ts} or null
requester Slack user id of the person who started the chat
session_id the Claude Code session id; replaced by a cold turn
chat_dir host path of the checkout, the config dir, and the turn dirs
capability read or write
write_set branch names and prefixes the git guard allows
watches list of Watch
run {owner, container, started_at} while a turn runs, else null
pending replies received while a turn runs: [{ts, user, text}]
last_reply_ts the newest thread reply a turn has consumed
turns count of turns run
cost_usd realized spend across turns
spend_cap_usd the chat's current cap
state active or done
outcome why the chat is done: cancelled, idle, spend cap
created_at, updated_at unix timestamps
extra unknown keys preserved across writers

Watch:

Field Meaning
pr the PR number
mode live or post_merge
source_pr for a post-merge fix PR, the merged PR whose comments it addresses
fingerprint the last fingerprint, as tick.fingerprint computes it
phase the last phase
armed_at when the chat armed auto-merge, else null
last_dispatch_at when the last watch event ran a turn

The ledger has two indexes, both derived at read time: binding to chat, and PR number to the write chat that watches it.

review_max_dispatches, Job.lane, Job.instructions, Job.phase, Job.fingerprint, and the blocked state do not exist in the new ledger.

Conversion. The first tick that finds reviews.json and no chats.json converts every active job into a chat. A review job becomes a write chat with one watch. An ask job becomes a read chat with no watches. The converted chat has no session, so its first turn is a cold turn.

Chat creation

A top-level message in #ax-drew that @-mentions DREW creates a chat. The listener creates it. The tick's history backstop creates the ones the listener missed. The binding is the message's thread.

The router reads the message text with links and the mention removed. It returns the capability.

Message Capability Watches First turn's message
A PR link and no text write the PR the review prompt
A PR link and a request write the PR the review prompt plus the request
A PR link and a question read none the question
No PR link read none the text

A write chat on an open PR has the PR's branch in its write set. A write chat on a merged PR has drew/review/ in its write set and no watch until the fix PR exists. A PR from a fork gets a read chat and a reply that says why.

One write chat per PR. A second message that links a PR another write chat watches gets the reply "handled in " and no chat. Any number of read chats may name the same PR.

Promotion. A reply in a read chat that the router classifies as a request raises the chat to write. The supervisor sets the write set from the PRs the reply names, and adds those PRs as watches. A chat never lowers its capability.

Turns

One container per turn. A turn is one docker run --rm that mounts the chat's checkout and config dir, sets CLAUDE_CONFIG_DIR to the config dir, and runs claude -p --resume <session_id>. The first turn runs claude -p --session-id <session_id> instead. The entrypoint skips the clone when the workspace has .git. The egress jail, the wall-clock kill, the container name in the ledger, and the reap step do not change.

The input is the message. The user message is the reply text, or the watch event's reason. The rules live in the profile's frame, passed with --append-system-prompt. The frame carries a per-turn header: the chat id, the thread, the requester's Slack id, the capability, the write set, and each watch with its phase. The agent knows its PRs without querying.

The output is the final assistant message. The supervisor posts it to the thread, converted to Slack mrkdwn. Every post in a write chat carries the footer drew: chat=<id> prs=<n,n>, so the ledger can be rebuilt from the thread. Posts in a read chat carry no footer.

No status line. The supervisor observes what a turn did. After a write turn it lists open PRs authored by DREW created since the turn started whose head branch is in the write set. Each becomes a watch. A push to a watched PR's branch appears in that watch's next fingerprint. When the agent needs a decision from a person, it mentions the requester in its message. The frame gives it the id.

The listener owns turns. The tick is the backstop. The listener accepts a human reply that @-mentions DREW in a bound thread. It reacts with 👀, takes the ledger lock, and records the reply's ts as last_reply_ts. If no turn is running, it starts one on its thread pool. If a turn is running, it appends the reply to pending. When a turn ends, the supervisor starts the next turn with the pending replies joined as one message. The tick's steer step calls the same turn function for replies newer than last_reply_ts. The lock and last_reply_ts make a double pickup a no-op.

Commands. The supervisor handles three replies without a turn.

Reply starts with Effect
stop removes the running container by name; the chat stays active and the session resumable
stop watching #<n> drops that watch; the chat stays active
cancel, drop it, never mind retires the chat

All three work while a turn is running, because the listener handles them immediately.

Timeouts. A read turn has chat_read_timeout_s (default ten minutes). A write turn has worker_timeout_s (forty minutes). Capability is known before the turn starts, so the supervisor picks the timeout without reading the message.

Spend. Each chat gets chat_spend_cap_usd. When a turn's cost crosses it, the chat posts a line that mentions the requester and stops running turns. A reply from the requester raises the cap by chat_spend_step_usd and runs the turn. Watch events on a capped chat post their phase line and run no turn.

Reap. A chat whose run.container is gone after the grace period loses its run. The next reply or watch event resumes the session. The reap posts one line in the thread.

Watches

The tick evaluates every watch of every active chat. fingerprint, phase, and describe do not change. decide becomes a per-watch function that returns one of the events below. A phase change posts describe's line.

Event Condition Turn message
merged PR state is merged "PR #n merged." The watch is dropped after the turn.
closed PR state is closed "PR #n was closed without merging." The watch is dropped after the turn.
conflict mergeable is conflicting "PR #n conflicts with main."
red a check failed "PR #n: check x failed."
comments newest comment is newer than the last fingerprint's "New review comments on PR #n."
changes_requested review decision entered changes requested "A reviewer requested changes on PR #n."
ready approved, green, not armed none; the supervisor arms auto-merge and posts
none any other phase change none; the phase line is posted

A post-merge watch's fingerprint includes the source PR's unresolved threads and newest comment, as _snapshot computes today.

red, conflict, comments, and changes_requested respect review_cooldown_s per watch. The merge queue owns a PR in the queue phase; no event fires there.

A merged turn on a chat with other watches is how a stack advances. The agent sees "PR #1 merged" with #2 and #3 still in its header, retargets #2 to main, and pushes. The push appears in #2's fingerprint.

A read chat has no watches. It cannot push, so it has nothing to shepherd.

Default behaviors

The frame is short. The behaviors live in the checkout as skills, so a PR changes them without an image rebuild.

The frame says:

/drew-review is today's review prompt as a skill: the work list, the fix flow, replying on threads, resolving them, and the push. /drew-answer is today's ask frame as a skill. /shepherd exists and the review skill calls it. The skills live under .claude/skills/ in the repository.

Lifecycle and cleanup

A chat is active from creation. It becomes done on cancel, or when it has no watches and no reply for chat_idle_s (default one day). A watch keeps its chat active.

A done chat's checkout and config dir are deleted after chat_ttl_s (default seven days). Turn dirs stay, as run dirs do today.

chat_max_workspaces (default eight) bounds disk. When a new chat needs a directory and the count is at the cap, the checkout of the active chat with the oldest updated_at is deleted. Its next turn is a cold turn.

Cold turn. When the session file or the checkout is missing, the turn clones the branch, starts a new session with a new id, and seeds the first message with the thread's messages so far. The reply text follows. The ledger records the new session id. The chat id does not change.

Worker changes

The entrypoint takes DREW_SESSION_ID, DREW_SESSION_RESUME (1 after the first turn), DREW_SYSTEM_PROMPT_FILE, DREW_CAPABILITY, and DREW_WRITE_SET (a comma-separated list of branch names and prefixes ending in /). It sets CLAUDE_CONFIG_DIR to the mounted config dir. The git guard reads DREW_CAPABILITY and DREW_WRITE_SET. With read it refuses every push. With write it refuses a push whose refspec is outside the write set. The force and delete rules stay.

The supervisor keeps the transcript as today: stream-json on stdout, written by the host. The result event's text is the reply.

PR breakdown

PR A — chat ledger and session plumbing

Chat and Watch, chats.json, the conversion from reviews.json, the chat directory, the session id, the resume env contract, the CLAUDE_CONFIG_DIR mount, and worker.run_turn. First touch creates chats with watches. The shepherd step evaluates watches and runs turns for events. A drew chat --id <ulid> --turn "<text>" command runs a turn from a checkout for local testing. Thread replies still take the tick's steer path. No Slack changes.

PR B — turn semantics

The frame with the per-turn header, /drew-review and /drew-answer as skills, the observation step that turns new PRs into watches, the removal of the status line, the spend cap, the mrkdwn post with the chat footer, and capability-based timeouts.

PR C — listener-owned turns

The listener accepts thread replies, the pending queue, stop, stop watching, and cancel, promotion from read to write, and the tick's steer step as backstop only.

PR D — no-PR chats, write set, cleanup

A message with no PR link creates a read chat on main. The git guard's write-set allowlist. The idle retire, the TTL, the workspace cap, and the cold turn. Mini rollout: the audit dir is already mirror-mounted, so compose does not change. The drew-slack image is pulled and restarted after each PR. The worker image is rebuilt after PR B and PR D.

Gates

  1. Round trip. A reply in a bound thread gets an answer in the thread within thirty seconds. The answer refers to the chat's earlier work. No clone runs.
  2. Queue. Two replies during a turn produce one follow-up turn. A chat never has two containers.
  3. Interrupt. stop during a turn removes the container within seconds. The next reply resumes the same session.
  4. Stack. A chat with two watches. The first PR merges. The chat runs one turn, retargets the second PR, and pushes. The second watch's phase line follows.
  5. New PR. A write chat on a merged PR opens a fix PR. The fix PR is a watch before the next tick posts anything.
  6. Capability. A read turn's push is refused by the guard. A write turn's push outside the write set is refused by the guard. Both refusals appear in the transcript.
  7. No PR. @DREW hello creates a chat and gets a greeting. A follow-up question about the repository gets an answer from the same session.
  8. Cold turn. With the checkout deleted, a reply still gets an answer, and the answer knows what the thread said. The chat id is unchanged.
  9. Disconnect paths. Listener down: the tick runs the turn. Supervisor dies mid-turn: the reap step clears run, and the next reply resumes the session. Mini restart: the ledger and the chat directories are intact.
  10. Spend cap. A chat at its cap posts the mention and runs no turn. A requester reply raises the cap and runs the turn.
  11. Conversion. A reviews.json with one review job and one ask job becomes a chats.json with one write chat and one read chat. The next reply in each thread is a cold turn.

Open questions

Deliberately deferred