|
| 1 | +--- |
| 2 | +title: How GraphCode Works |
| 3 | +description: Graphs of live, steerable Claude Code sessions on macOS — the mental model, and the machinery underneath it. |
| 4 | +--- |
| 5 | + |
| 6 | +# How GraphCode works |
| 7 | + |
| 8 | +You can run one Claude Code session in a terminal. GraphCode lets you run ten — |
| 9 | +connected, unattended, and still yours to attach to and correct mid-run. |
| 10 | + |
| 11 | +**[⬇ Download the latest release](https://github.com/scgopi/GraphCode/releases/latest)** |
| 12 | +· [Source on GitHub](https://github.com/scgopi/GraphCode) · Requires macOS 15+ on Apple |
| 13 | +Silicon, with Claude Code on your `PATH`. |
| 14 | + |
| 15 | +<!-- VIDEO SLOT: demo video goes here when ready --> |
| 16 | + |
| 17 | +This article is in two parts. The first explains the mental model — what a loop is, what |
| 18 | +an edge does, why the sessions are real terminals. The second opens the hood for |
| 19 | +contributors: the app/daemon/CLI split, how sessions are created and reattached, and how |
| 20 | +edges actually fire. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Part 1 — The mental model |
| 25 | + |
| 26 | +### The problem |
| 27 | + |
| 28 | +A coding agent in a terminal is powerful but *singular*: one session, one task, and it |
| 29 | +stops mattering the moment you close the window. The obvious fix — cron jobs firing |
| 30 | +`claude -p` headlessly — throws away the thing that made the terminal session good: you |
| 31 | +could watch it, interrupt it, and redirect it. |
| 32 | + |
| 33 | +GraphCode's premise is that you shouldn't have to choose. Work is arranged as a **graph |
| 34 | +of loops**: each node is a unit of agentic work running inside a real CLI session, and |
| 35 | +each edge is a relationship between two of them. The sessions stay real, attachable |
| 36 | +terminals the whole time — automation comes from the graph around them, not from taking |
| 37 | +the terminal away. |
| 38 | + |
| 39 | +### Loops: what you hand off |
| 40 | + |
| 41 | +Every loop type is "an agent runs repeatedly" — they differ in what *you* stop doing: |
| 42 | + |
| 43 | +| Loop type | You hand off | Runs until | |
| 44 | +|---|---|---| |
| 45 | +| **Turn-based** | the check | you end it — each turn pauses for your review inside the session | |
| 46 | +| **Goal-based** | the stop condition | the goal is met (optionally: a shell command exits 0) | |
| 47 | +| **Time-based** | the trigger | you stop it — cadence lives in the prompt (`/loop 1h …`) | |
| 48 | +| **Proactive** | the prompt | a composite sub-graph runs it end to end | |
| 49 | + |
| 50 | +The hand-off framing is load-bearing. A turn-based loop exists because *a person is in |
| 51 | +the sequence* — its session is told to stop after every turn for review, and the |
| 52 | +criterion you wrote travels into the session's opening prompt so you never retype it. A |
| 53 | +goal-based loop is handed a statement of "done" (and the creation form refuses an empty |
| 54 | +one — a goal without a stop condition is a modelling error, not a style choice). A |
| 55 | +time-based loop is handed a trigger, and a proactive node is handed the whole prompt: it |
| 56 | +is the orchestrator running a graph *inside* a graph. |
| 57 | + |
| 58 | +### The first design choice: GraphCode schedules nothing |
| 59 | + |
| 60 | +A time-based loop's recurrence lives *inside* its session, written into the prompt with |
| 61 | +the agent's own `/loop` or `/schedule` skill. GraphCode holds no interval of its own, |
| 62 | +runs no timers, and never fires a headless `claude -p`. |
| 63 | + |
| 64 | +An early version did the opposite — a timer per node, a headless invocation per tick, |
| 65 | +output discarded. It worked, and it was worthless: there was nothing to attach to, |
| 66 | +nothing to watch, nothing to steer. Moving the cadence into the session is what makes a |
| 67 | +running loop an ordinary interactive terminal that happens to be busy, rather than a job |
| 68 | +that already finished somewhere else. The daemon's only remaining duty for such a loop is |
| 69 | +**liveness**: making sure the session exists. |
| 70 | + |
| 71 | +### The second design choice: sessions outlive everything |
| 72 | + |
| 73 | +Each loop's terminal is a [`zmx`](https://zmx.sh) session — a PTY kept alive by a |
| 74 | +session daemon, the same idea as tmux. It survives closing the window, quitting the app, |
| 75 | +and restarting GraphCode's own daemon. Reattaching restores full scrollback. |
| 76 | + |
| 77 | +This is also what makes unattended and attended the *same thing*. When the daemon starts |
| 78 | +a goal-based loop at 3 a.m., and you click its node at 9, you are not reading a log of |
| 79 | +what happened — you are in the session that did it, live, with its history above you and |
| 80 | +its process still running. |
| 81 | + |
| 82 | +### Edges: the three ways loops talk |
| 83 | + |
| 84 | +Drag between two nodes and you get an edge. There are three kinds: |
| 85 | + |
| 86 | +| Kind | What it does | Blocks its target? | |
| 87 | +|---|---|---| |
| 88 | +| **Hand-off** | fires when the source resolves, unblocking the target | yes — the target waits for it | |
| 89 | +| **Message** | injects text into a running peer's live session | no — peers run concurrently | |
| 90 | +| **Spawn** | instantiates a new node from a template — the one kind allowed to cross into another project's graph | no — a template isn't waiting | |
| 91 | + |
| 92 | +Each edge carries a **condition** — always, on success, or on failure — evaluated |
| 93 | +against how the source resolved. An `on failure` hand-off is how you build "if the |
| 94 | +nightly build breaks, wake the fixer." |
| 95 | + |
| 96 | +### Cycles are opt-in, and bounded by construction |
| 97 | + |
| 98 | +An unguarded edge fires exactly once. To make a cycle actually loop, you attach a |
| 99 | +**cycle guard** — and a guard must carry a bound: a maximum iteration count, an `until` |
| 100 | +shell condition, or both. The feature that lets a loop repeat is the same feature that |
| 101 | +bounds it, so there is no way to express "loop forever, unattended, spending tokens" by |
| 102 | +accident. |
| 103 | + |
| 104 | +### Knowing when a goal is done |
| 105 | + |
| 106 | +A goal-based loop's stop condition has two halves. The **summary** is the human statement |
| 107 | +of done, and it is required. The **predicate** is an optional shell command — the machine |
| 108 | +version — and the daemon polls it (once a minute by default), resolving the node the |
| 109 | +moment it exits 0. The same predicate is written into the session's opening prompt, so |
| 110 | +the agent and the daemon are never working to two different definitions of done. Goals |
| 111 | +with no honest shell equivalent ("the design doc reads clearly") simply omit the |
| 112 | +predicate and resolve when their session exits. |
| 113 | + |
| 114 | +A loop moves through a small lifecycle — *idle → running → succeeded / failed / stalled / |
| 115 | +stopped* — and **stopped** is deliberately distinct from **failed**: work someone chose |
| 116 | +to end didn't go wrong, and filing it as a failure would bury real failures in noise. |
| 117 | + |
| 118 | +--- |
| 119 | + |
| 120 | +## Part 2 — Under the hood |
| 121 | + |
| 122 | +### Four pieces, one socket |
| 123 | + |
| 124 | +| Piece | Role | |
| 125 | +|---|---| |
| 126 | +| `graphcode.app` | SwiftUI app — sidebar, graph canvas, per-loop terminal workspace (tabs, splits), rendering terminals with GhosttyKit | |
| 127 | +| `graphcoded` | The orchestrator daemon, kept alive by launchd. Owns every project's graph and all automation | |
| 128 | +| `graphcode` | CLI speaking to the same daemon — `graphcode status <project>`, `graphcode node create …` | |
| 129 | +| `GraphcodeKit` | The shared framework all three link — the domain model, persistence, IPC, and session plumbing live here once, so the app and daemon can never disagree about what a loop is | |
| 130 | + |
| 131 | +The app and CLI are both *clients*. They connect to `graphcoded` over a Unix domain |
| 132 | +socket (`~/.graphcode/graphcoded.sock`) speaking length-framed JSON. Commands flow in; |
| 133 | +after every mutation the daemon broadcasts the updated graph to every connected client, |
| 134 | +so two app windows and a CLI watching the same project always agree. Validation happens |
| 135 | +in the daemon, not just the app's forms — a rule only one client enforces isn't a rule. |
| 136 | + |
| 137 | +### GraphStore: the automation core |
| 138 | + |
| 139 | +Inside the daemon, one `GraphStore` actor per open project owns that project's graph. |
| 140 | +It is the whole of what makes `graphcoded` load-bearing: |
| 141 | + |
| 142 | +- **Edge firing.** When a node resolves, the store walks its outgoing edges, evaluates |
| 143 | + each condition against how the node resolved, and fires the ones that pass — unblocking |
| 144 | + hand-off targets, delivering messages, instantiating spawns. |
| 145 | +- **Goal polling.** One background task per goal predicate, at the node's poll interval. |
| 146 | +- **Liveness.** Unattended loops (time-based and goal-based) get their sessions started |
| 147 | + at creation and re-ensured when the daemon restarts — nothing else would restart them. |
| 148 | +- **Honest failure surfaces.** A message edge whose target has no live session isn't |
| 149 | + silently dropped; it's recorded as undelivered and shown. |
| 150 | + |
| 151 | +Notably, `GraphStore` knows nothing about zmx, sockets, or subprocesses — every effect |
| 152 | +is an injected closure the daemon wires up at startup. That's why the automation core is |
| 153 | +fully unit-testable without spawning a process. |
| 154 | + |
| 155 | +Cross-project spawn edges can't be handled by a store that owns exactly one graph, so |
| 156 | +they're handed up to the `ProjectRegistry` — the daemon layer that holds one store per |
| 157 | +open project and does the routing. |
| 158 | + |
| 159 | +### How a session starts, and why attaching just works |
| 160 | + |
| 161 | +When the daemon starts an unattended loop, it runs the node's backend command inside a |
| 162 | +detached zmx session. Three details carry most of the weight: |
| 163 | + |
| 164 | +- **Shared identity is the mechanism.** The session is named after the node's ID — |
| 165 | + exactly the name the app's terminal surface attaches to. `zmx attach` joins an existing |
| 166 | + session rather than starting a second one, so "the daemon started it" and "you opened |
| 167 | + it" converge on the same live PTY, scrollback intact. There is no attach *protocol*; |
| 168 | + there is one session and two doors. |
| 169 | +- **The login-shell trick.** launchd gives the daemon a bare `PATH`. Commands are |
| 170 | + wrapped in `/bin/zsh -i -l -c` — and `-i` is load-bearing, not decoration: a |
| 171 | + developer's real `PATH` is typically set in `~/.zshrc`, which zsh only reads when |
| 172 | + interactive. Without it, `claude` is `command not found` for every daemon-started loop. |
| 173 | +- **Prompts are arguments, not syntax.** The node's opening prompt rides in as `"$@"` |
| 174 | + rather than being interpolated into the shell script, so a goal containing quotes, |
| 175 | + `;`, or `$(…)` is one argument and cannot become shell code. |
| 176 | + |
| 177 | +The opening prompt itself is computed in exactly one place (`LoopNode.sessionPrompt`), |
| 178 | +shared by the daemon and the app, so the two can never disagree about what a loop starts |
| 179 | +with. |
| 180 | + |
| 181 | +### Presence and usage: reported, never estimated |
| 182 | + |
| 183 | +GraphCode cannot see inside a running `claude`. Rather than guess, it reads per-session |
| 184 | +labels that the backend's own lifecycle hooks can set (`zmx set "$ZMX_SESSION" |
| 185 | +presence=busy`, and similarly for token usage). With hooks installed, readings are |
| 186 | +*reported*; without them, the UI says "not reported" instead of showing a zero nobody |
| 187 | +measured. GraphCode deliberately doesn't install those hooks itself — that would mean |
| 188 | +editing your Claude Code settings behind your back. |
| 189 | + |
| 190 | +### State: one directory, plain JSON |
| 191 | + |
| 192 | +Everything lives in `~/.graphcode/` — per-project graphs, recents, terminal layouts, the |
| 193 | +daemon socket and logs, and the installed binaries under `bin/`. **Nothing is ever |
| 194 | +written inside a project folder you open.** |
| 195 | + |
| 196 | +It's a dotfile directory rather than `~/Library/Application Support` for two concrete |
| 197 | +reasons: the state is plain JSON a developer will want to `cat` and `jq`, and it sits |
| 198 | +where developers already look (`~/.claude`, `~/.ssh`); and a Unix socket path is capped |
| 199 | +at 104 bytes on Darwin — the Application Support path burns most of that and grows with |
| 200 | +the username, while `~/.graphcode/graphcoded.sock` leaves real headroom. |
| 201 | + |
| 202 | +Persistence is defensive by design: every domain type decodes missing fields to |
| 203 | +defaults, hand-written, because a decode failure reads to a user as *their project got |
| 204 | +wiped*. A graph saved by last month's build always loads in today's. |
| 205 | + |
| 206 | +### What runs today |
| 207 | + |
| 208 | +Claude Code is the only backend wired end to end (Copilot CLI and Codex exist in the |
| 209 | +model; the picker refuses pairings it can't host). The full current list of rough edges |
| 210 | +lives in the README's |
| 211 | +[Known limitations](https://github.com/scgopi/GraphCode#known-limitations). |
| 212 | + |
| 213 | +--- |
| 214 | + |
| 215 | +## Try it |
| 216 | + |
| 217 | +1. **[Download the latest release](https://github.com/scgopi/GraphCode/releases/latest)** — |
| 218 | + drag GraphCode to Applications; that's the whole install. Releases are Developer ID |
| 219 | + signed and notarized. |
| 220 | +2. Add a folder, create a loop, and connect a second one with an edge. |
| 221 | +3. Close the app whenever you like — the loops won't notice. |
| 222 | + |
| 223 | +Building from source, development commands, and limitations are in the |
| 224 | +[README](https://github.com/scgopi/GraphCode#readme). GraphCode is |
| 225 | +[MIT-licensed](https://github.com/scgopi/GraphCode/blob/main/LICENSE), built on the |
| 226 | +independent open-source projects [Ghostty](https://ghostty.org) and |
| 227 | +[zmx](https://zmx.sh). |
0 commit comments