Never write a daemon for a Claude Code tool again. cc-interact extracts cc-review's daemon, event log, SSE, edit-gate, and MCP plumbing into a reusable Go framework; the echo example is 381 lines.
go get github.com/yasyf/cc-interactThat run is examples/echo, the whole framework exercised in 381 lines: two domain handlers, one REST mount, one MCP tool. A human POSTs an item, the agent replies through its channel, and both events come back off the same /events plane a browser would read. Regenerate the capture with docs/scripts/demo.sh.
Driving with an agent? Paste this:
Run `go get github.com/yasyf/cc-interact` in my Go project.
Study https://github.com/yasyf/cc-interact/tree/main/examples/echo — a complete headless consumer in 381 lines — then stand up a human-in-the-loop surface for my domain: one reducer, start/reply handlers, and one MCP channel tool.
Verify the round trip: POST an item to the daemon's REST plane and confirm the agent's reply streams back over /events.
A review tool that can't stop the agent from editing mid-review is a suggestion box. Inject the verdict as one function:
daemon.Config{
// cc-review: block while a review is open.
Gate: func(ctx context.Context, s subject.Subject, tool daemon.ToolCall) (bool, string) {
return s.Status != "open", "a human is still reviewing — reply to their comments first"
},
GateErrorReason: "review state unreadable; blocking edits",
}The guard-edit hook routes every edit the Claude session attempts through this verdict: while the subject is open, Claude sees your reason instead of a completed write. Errors reading the subject fail closed (GateErrorReason), and a missing daemon fails open — a crashed daemon never bricks the session.
Two realtime paths — a socket for the UI, polling for the agent — drift, drop events, and disagree after a reconnect. Here both roles read one log:
go run ./examples/echo watchwatch streams the same append-only log as the browser's /events, one JSON event per line, with exclude_origin=agent so the agent never reacts to its own echo. Delivery is at-least-once with a persisted per-consumer cursor, so a dropped connection resumes where it left off, and the consumer survives a daemon swap.
The binary is half the ship surface; the plugin payload — MCP server wiring, binary installer, session hooks, a start skill — is its own pile of boilerplate. Render it instead:
./plugin-template/render.sh out/ PLUGIN_NAME=mytool DISPLAY_NAME=MyTool \
BINARY_NAME=mytool RELEASE_REPO=you/mytool MCP_SUBCOMMAND=channel \
SKILL_NAME=mytool:startYou get cc-review's plugin payload with the review strings swapped for yours: the channel MCP server wiring, a release-binary installer, session-record and guard-edit hooks, and a start skill. plugin-template/ documents every variable.
One process model, shipped: a lazily spawned daemon owns a single-writer SQLite (WAL) holding an append-only per-subject event log; every consumer — browser, watch, MCP channel — reads the same SSE plane; newest-wins eviction upgrades the daemon in place when a newer binary lands. You register domain ops against a generic control envelope and the framework routes the rest.
A headless consumer never imports it. When you want a diff-style web client:
npm install @cc-interact/reactMount sse.StaticHandler on the daemon and the React package's stream and query primitives read the same /events plane as everything else.
Everything lives under ~/.<app>/ — state.db, daemon.sock, http.json, daemon.log. The core schema is versioned by your migrate hook; the echo example carries none, so its reset is rm -rf ~/.cc-echo.
Status: pre-1.0 — the API moves with cc-review, and every break lands in the changelog. Licensed under PolyForm Noncommercial 1.0.0.


