You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents — an independent model checks each…

<img src="docs/screenshots/logo.svg" alt="great_cto" width="280" />
Ship products with the coding agent you already have.
<a href="https://greatcto.systems/proof"><img src="https://greatcto.systems/assets/one-real-run.gif" alt="One real run, end to end: prompt, architect, human gate, parallel implementers, a reviewer's PARTIAL and the fix, 47 passing assertions, the ship gate, the merged PR — 1h 26m, $3.40" width="720" /></a>
npx great-cto init
Website · One real run → · Blog · Changelog
Русский · 简体中文 · 繁體中文 · 日本語 · 한국어 · Español · Português · Deutsch · Français
Your coding agent ships code. This is what checks it.
Describe a product or a feature. 72 agents with narrow jobs take it through brief, architecture, build, review and security; a second model from another family reads the same diff. Three decisions stay yours — what gets built, how, and whether it ships — and what lands is a repository you own and a URL that works. You pay your own LLM provider; great_cto is MIT and bills nothing.
npx great-cto init
Restart Claude Code. Day to day there are three things:
| In Claude Code | In the terminal | |
|---|---|---|
| Start work | /start "add Google login" | great-cto run "add Google login" |
| See what needs you | /inbox | great-cto status |
| Continue | /resume | great-cto resume |
/start takes a new product or a task in an existing project and picks the workflow itself. /resume continues only what you already approved; a pending decision still waits for you. Everything else — /review, /spec, /save, /digest and the rest — is there when you need it: all commands.
<img src="docs/screenshots/board.png" alt="The board at localhost:3141" width="900" />
The board at localhost:3141 opens on Work — your tasks, the decisions waiting on you, and what already shipped. Cost, agents and reviewers are under Tools. Nothing on it renders an absence as a pass: a check that could not decide reads unverifiable, a cost nobody measured unmeasured, a reviewer that could not run unavailable.
npx great-cto init --host codex gives Codex the skills, the MCP server and six safety guards as plugin hooks (approve them once: run codex in a terminal and choose Trust all and continue). Codex has no native plugin surface for slash commands or role agents, so the pipeline runs through the CLI instead:
great-cto run "add Google login" --host codex --allow src,tests,docs
great-cto status --host codex
great-cto resume --host codex
Codex never updates the plugin by itself — great-cto upgrade does. Details, mixed Claude + Codex runs and Codex as the second reviewer: Codex host guide.
One line in .great_cto/PROJECT.md:
approval-level | Stops you at | Stops |
|---|---|---|
ship-only | the deploy — and briefs you on what gets built | 1 |
product-only | what we build · whether it ships | 2 |
gates-only (default) | what we build · the design · the deploy | 3 |
strict | the design · code review · the deploy | 3 |
auto | nothing in the pipeline | 0 |
Regulated products — fintech, healthcare, gov — keep their security, compliance and ship gates at every level. How the gates work.
| One feature, end to end, fully traced | 1h 26m · $3.40 in tokens — the receipts |
| A whole product — 7 built in the open benchmark | median $171 in tokens · 70/100 quality, measured 2026-07-10 — reproduce it |
| Typical month, 20 pipeline runs | ~$34 — you pay your own LLM provider, nothing else |
For one builder, not a team; not a hosted app builder — it needs your coding agent; not a CI/CD system; compliance scaffolds are starting points, not certifications; LLM output is not deterministic. The honest version of each, and what it refuses to claim: docs/DETAILS.md.
Docs · Getting started · Commands · Gates · Agents · FAQ · Everything else · Issues · Security · Contributing · Privacy — telemetry is off by default
MIT — LICENSE. Built by @avelikiy. If it saved you time, a star helps other solo builders find it.
hooks/gates-pane.tsx 174 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Gate, View } from '../types'
5
6// The gates waiting on the person, in the session they are working in.
7//
8// The board stays the one door an approval goes through: this reads the gates and
9// their ADR-024 tokens from the board's /api/inbox and posts a decision to its
10// /api/gates/<id>, so the token check, the binding to the tree as it is, the
11// decision log and the wake-up all stay where they are. Nothing here writes a gate.
12//
13// A gate that is expensive to undo (or unclassified) keeps the board's ritual: the
14// person types the gate's name, and the text goes to the board as `confirm`, where
15// the server compares it. The pane never fills it in, and a button is never offered
16// in its place: a second press is not the same act as typing what you are approving.
17
18// The board's address: BOARD_PORT, as packages/board/lib/config.mjs reads it, else 3141.
19let BOARD = 'http://127.0.0.1:3141'
20async function locate($: any): Promise<void> {
21 const port = Number(await $.env.get('BOARD_PORT'))
22 BOARD = `http://127.0.0.1:${Number.isInteger(port) && port > 0 ? port : 3141}`
23}
24const PANE = 'great-cto-gates'
25const view = atom({ plugin: 'great-cto', key: 'view' } as const, { state: 'loading' } as View)
26const busy = atom({ plugin: 'great-cto', key: 'busy' } as const, null as string | null)
27const seen = atom({ plugin: 'great-cto', key: 'seen' } as const, 0)
28
29const basename = (p: string) => p.replace(/\/+$/, '').split('/').pop() || p
30
31// The project travels as the session root's absolute path, which the board resolves
32// for any project under HOME — registered or not, and never confused with another
33// project whose directory has the same name. A path the board cannot honour comes
34// back from /api/inbox as a fallback, and the pane then offers no decision at all.
35async function projectOf($: any): Promise<string> {
36 return await $.session.root()
37}
38
39function gatesOf(body: any): Gate[] {
40 return (body?.pending_gates || []).map((g: any) => ({
41 id: String(g.id),
42 title: String(g.title || g.id),
43 gate: g.reversibility?.gate ? `gate:${g.reversibility.gate}` : String(g.id),
44 guarded: ['expensive', 'unclassified'].includes(g.reversibility?.state),
45 token: g.token ?? null,
46 }))
47}
48
49async function refresh($: any): Promise<void> {
50 const project = await projectOf($)
51 let next: View
52 try {
53 const r = await $.http.fetch(`${BOARD}/api/inbox?project=${encodeURIComponent(project)}`)
54 if (r.headers['x-project-resolved'] === 'fallback') next = { state: 'not-on-board', project: basename(project) }
55 else if (!r.ok) next = { state: 'error', why: `board answered ${r.status}` }
56 else next = { state: 'ok', project: basename(project), gates: gatesOf(JSON.parse(r.text)), at: new Date().toISOString() }
57 } catch (err) {
58 next = { state: 'board-down', why: String((err as Error)?.message || err) }
59 }
60 await update($, view, () => next)
61
62 const count = next.state === 'ok' ? next.gates.length : 0
63 $.ui.status(count ? `great_cto: ${count} gate${count === 1 ? '' : 's'} waiting — /gates` : undefined)
64 const before = await read($, seen)
65 if (count > before) {
66 $.ui.toast(`great_cto: ${count - before} new gate${count - before === 1 ? '' : 's'} waiting on you`)
67 void $.ui.open({ id: PANE, title: 'Gates' })
68 }
69 await update($, seen, () => count)
70}
71
72async function decide($: any, gate: Gate, action: 'approve' | 'reject', confirm?: string): Promise<void> {
73 if ((await read($, busy)) !== null) return
74 await update($, busy, () => gate.id)
75 try {
76 const project = await projectOf($)
77 const r = await $.http.fetch(`${BOARD}/api/gates/${encodeURIComponent(gate.id)}`, {
78 method: 'POST',
79 headers: { 'Content-Type': 'application/json' },
80 body: JSON.stringify({ action, token: gate.token, project, ...(confirm !== undefined ? { confirm } : {}) }),
81 })
82 let body: any = {}
83 try { body = JSON.parse(r.text) } catch { /* the status still says what happened */ }
84 $.ui.toast(r.ok
85 ? `${gate.gate} ${action === 'approve' ? 'approved' : 'rejected'}`
86 : `${gate.gate} not ${action}d: ${body.error || `board answered ${r.status}`}`,
87 { timeoutMs: 8000 })
88 } catch (err) {
89 $.ui.toast(`${gate.gate}: the board could not be reached — ${String((err as Error)?.message || err)}`, { timeoutMs: 8000 })
90 } finally {
91 await update($, busy, () => null)
92 await refresh($)
93 }
94}
95
96export const register: Register = on => {
97 on('session.start', async ($, e, next) => {
98 await $.command.register({ name: 'gates', description: 'great_cto gates waiting on you, in a pane' })
99 await locate($)
100 $.clock.every(30_000, () => { void refresh($) })
101 void refresh($)
102 return next(e)
103 })
104
105 // Asked for, the pane takes the keyboard: 1 / 2 press at once, Esc goes back to the
106 // prompt. Opened by a new gate, it does not — it must not catch keys being typed.
107 on('command.run', { command: 'gates' }, async $ => {
108 await $.ui.open({ id: PANE, title: 'Gates', focus: true })
109 await refresh($)
110 return { text: 'Gates pane opened.' }
111 })
112
113 on('turn.complete', async ($, e, next) => {
114 const done = await next(e)
115 void refresh($)
116 return done
117 })
118
119 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
120 const { Box, Text, Button, Input } = $.ui.resolve(e)
121 const v = await read($, view)
122 const working = await read($, busy)
123
124 if (v.state === 'loading') return <Text dimColor>Asking the board…</Text>
125 if (v.state === 'board-down') {
126 return (
127 <Box flexDirection="column">
128 <Text color="yellow">The board is not answering at {BOARD} — gates not checked.</Text>
129 <Text dimColor>Start it with /board. This is not the same as no gates.</Text>
130 </Box>
131 )
132 }
133 if (v.state === 'not-on-board') {
134 return <Text color="yellow">{v.project} is not a project the board knows — gates not checked.</Text>
135 }
136 if (v.state === 'error') return <Text color="red">Gates not checked: {v.why}</Text>
137 if (!v.gates.length) return <Text dimColor>No gates waiting on you in {v.project}.</Text>
138
139 const one = v.gates.length === 1
140 return (
141 <Box flexDirection="column" gap={1}>
142 <Text dimColor>{v.project} · {v.gates.length} waiting · checked {v.at.slice(11, 19)} UTC</Text>
143 {v.gates.map(g => (
144 <Box flexDirection="column">
145 <Text bold>{g.gate} — {g.title}</Text>
146 <Text dimColor>{g.id}</Text>
147 {!g.token
148 ? <Text color="yellow">The board issued no approval token for it — decide on the board ({BOARD}).</Text>
149 : working === g.id
150 ? <Text dimColor>Sending…</Text>
151 : g.guarded
152 ? (
153 <Box flexDirection="column">
154 <Text color="yellow">Expensive to undo. To approve, type {g.gate} and press Enter.</Text>
155 <Input key={`confirm-${g.id}`} label="approve:" placeholder={g.gate} submitLabel="approve"
156 onSubmit={value => { void decide($, g, 'approve', value) }} />
157 <Button key={`reject-${g.id}`} onPress={() => { void decide($, g, 'reject') }}>Reject</Button>
158 </Box>
159 )
160 : (
161 <Box flexDirection="row" gap={2}>
162 <Button key={`approve-${g.id}`} variant="primary" hotkey={one ? '1' : undefined}
163 onPress={() => { void decide($, g, 'approve') }}>Approve</Button>
164 <Button key={`reject-${g.id}`} hotkey={one ? '2' : undefined}
165 onPress={() => { void decide($, g, 'reject') }}>Reject</Button>
166 </Box>
167 )}
168 </Box>
169 ))}
170 </Box>
171 )
172 })
173}
174types/index.d.ts 26 lines1/** A gate as the board's /api/inbox serves it, cut to what the pane draws. */
2export type Gate = {
3 id: string;
4 title: string;
5 /** `gate:ship`, `gate:plan`, … — what the board's typed-name ritual asks for. */
6 gate: string;
7 /** Expensive to undo, or unclassified: the board asks for the typed name. */
8 guarded: boolean;
9 /** ADR-024 token bound to the project as it is now; null when none was issued. */
10 token: string | null;
11};
12
13/** What the board said, never collapsed: "no gates" and "could not ask" differ. */
14export type View =
15 | { state: 'loading' }
16 | { state: 'ok'; project: string; gates: Gate[]; at: string }
17 | { state: 'board-down'; why: string }
18 | { state: 'not-on-board'; project: string }
19 | { state: 'error'; why: string };
20
21declare module 'claude-code' {
22 interface PluginState {
23 'great-cto': { view: View; busy: string | null; seen: number };
24 }
25}
26