Talk to Claude inside a file instead of the chat: you save, Claude answers beneath what you wrote, in your own editor, with the whole thread versioned.

Talk to your coding agent inside a file, in your own editor, instead of the chat.
<img src="docs/demo.gif" width="900" alt="Claude Code on the left, decisions.md open in Neovim on the right. Claude writes two open calls about cart.py into the file and starts watching it, and a band above the prompt reads 'decisions.md, turn 1, your turn, watching'. The author types 'yes' under the first item and 'clamp it to 0-100, and add a test for both' under the second, then saves. Claude's replies appear in the editor under each answer, the code is changed and tested, and the dialog closes with a summary line while the band reads 'closed after 5 turns'.">
Some work is a list of decisions: five open calls on a release, a review with a dozen comments, a plan with questions on every step. In the chat, each answer scrolls the last one away. You can't answer item 3 before item 1, and a week later the decisions are buried in a transcript.
With margins, Claude writes the open questions into a file, each with its context and a default. You answer in your editor, in any order, and save. Each save is a turn. Claude reads what you changed, replies beneath your words in the file, and acts on what you decided. The file keeps the whole thread under each item, and it has a git history of its own, so every turn can be undone.
## 1. Database for the job queue?
sqlite locks on concurrent writers, and the workers now run four at a time.
Default: postgres. OK?
Nikos: yes, but keep sqlite for the tests, CI has no postgres.
Claude: Done. Postgres in prod, sqlite in tests (a3f9e1c), 212 tests pass.
Closed.
## 2. Ship on Friday?
Two flaky tests left on main. Default: ship, mark them flaky. Ship?
Nikos: what are they?
Above the prompt, Claude Code shows where each dialog stands:
✎ release-2.0.md · turn 6 · your turn · watching
In Claude Code, install the plugin:
/plugin marketplace add nkyriazis/margins
/plugin install margins@margins
Restart Claude Code. For other agents, or just the skill without the plugin, see More ways to install.
Ask for it when a task turns into a list of decisions:
review cart.py and put your open calls in decisions.md with margins, then watch it
Or hand Claude a file you already have:
watch notes/review.md, I'll answer in the file
Then work in your editor. Answer, ask back, write "yes", tick a box, delete a line you disagree with, and save. Claude answers in the file and writes one line per item in the chat. When every item is closed, or you write "done", Claude writes a closing line with the outcome and stops watching.
.<name>.dialog.git next to it, one commit per turn, separate from your project's repository. git --git-dir=.<name>.dialog.git log -p shows every turn, and nothing is ever squashed.margins.sh does the mechanical parts, and the skill tells the agent how to hold the conversation.
| Command | What it does |
|---|---|
margins init <file> | Gives the file its own git history, .<name>.dialog.git, beside it |
margins watch <file> | Prints one line per real save. Claude Code arms it as a Monitor. A save counts once the file has been still for two seconds and differs from the last turn, so Claude's own replies never fire it |
margins turn <file> | Commits your edit as a turn and prints each line you added or changed with its number, plus what you removed |
margins reply <file> <line> <text-file> | Inserts a reply after that line and commits it. It refuses while you have edits it hasn't read |
margins end <file> "<outcome>" | Writes the closing line, commits, and tags the history closed. The watch sees the tag and exits |
margins reopen <file> | Lifts the close |
Each command also appends a line to a small state file for the agent session, in $XDG_RUNTIME_DIR/margins or /tmp/margins-<uid>. The plugin reads it once a second to draw the band, and the Stop hook reads it to find dialogs with no live watch. Nothing goes over the network.
Set these in the environment, or under env in Claude Code's settings.json.
| Variable | Default | Effect |
|---|---|---|
MARGINS_BAND | 1 | 0 hides the band above the prompt |
MARGINS_REARM | 1 | 0 turns off the Stop hook that keeps a dialog watched |
MARGINS_DIR | Where the session state files go |
Linux and macOS, bash 3.2 or newer, git and python3. The band and the Stop hook need Claude Code 2.1.287 or newer. The skill works in any agent that reads skills. Without a Monitor tool the agent runs the watch in the background and checks it between turns.
Other agents. npx skills add nkyriazis/margins installs the skill for Codex, Gemini CLI, opencode, Cursor, Goose and the other agents skills knows about.
From a clone. Copy the skill into your skills folder:
git clone https://github.com/nkyriazis/margins
cp -R margins/skills/margins ~/.claude/skills/
Developing. claude --plugin-dir ./margins loads the plugin from a clone. tests/run.sh runs the tests.
/plugin uninstall margins@margins, or remove ~/.claude/skills/margins. Dialog histories stay next to their files as .<name>.dialog.git until you delete them.
MIT
hooks/register.ts 129 lines1// margins as a Claude Code mod: a band above the prompt with one row per
2// dialog this session holds, saying whose turn it is and whether the file is
3// watched, redrawn from the session's state file once a second.
4//
5// MARGINS_BAND=0 turns it off.
6import { parse, row, type Dialog, type Seg } from './dialogs.ts'
7
8let sid = ''
9let file = ''
10let seen = -1
11let busy = false
12let show = true
13let hasProc = false
14let ownNs = ''
15let dialogs: Dialog[] = []
16let live = new Set<string>()
17let checked = 0
18// Closed dialogs leave the band at the first prompt the person sends after
19// they closed; the watch's own end notification is not one.
20let promptedAt = 0
21
22// The session's state file, resolved the way margins.sh resolves it
23async function locate($) {
24 show = (await $.env.get('MARGINS_BAND')) !== '0'
25 hasProc = await $.fs.exists('/proc/self/stat')
26 let dir = (await $.env.get('MARGINS_DIR')) ?? ''
27 if (dir === '') {
28 const xdg = (await $.env.get('XDG_RUNTIME_DIR')) ?? ''
29 if (xdg !== '' && (await $.fs.exists(xdg))) {
30 dir = xdg + '/margins'
31 } else {
32 const tmp = (await $.env.get('TMPDIR')) || '/tmp'
33 const id = await $.process.run(['id', '-u'])
34 dir = tmp.replace(/\/$/, '') + '/margins-' + id.stdout.trim()
35 }
36 }
37 if (hasProc) {
38 const link = await $.process.run(['readlink', '/proc/self/ns/pid'])
39 ownNs = link.stdout.replace(/[^0-9]/g, '')
40 }
41 file = dir + '/' + sid.replace(/[^A-Za-z0-9._-]/g, '_') + '.log'
42 seen = -1
43 dialogs = []
44}
45
46// A watch whose process is gone no longer sees saves. Across pid namespaces
47// (a sandbox) the pid means nothing here, so it counts as alive.
48async function alive($, d: Dialog): Promise<boolean> {
49 if (!d.pid) return false
50 if (d.ns && ownNs && d.ns !== ownNs) return true
51 if (hasProc) return $.fs.exists('/proc/' + d.pid)
52 const r = await $.process.run(['kill', '-0', d.pid])
53 return r.exitCode === 0
54}
55
56async function refresh($) {
57 if (busy) return
58 busy = true
59 try {
60 await tick($)
61 } finally {
62 busy = false
63 }
64}
65
66async function tick($) {
67 const now = (await $.clock.now()) / 1000
68 const id = String(await $.session.id())
69 if (id !== sid) {
70 sid = id
71 await locate($)
72 }
73 const stat = await $.fs.stat(file).catch(() => undefined)
74 let changed = false
75 if (!stat) {
76 changed = dialogs.length > 0
77 dialogs = []
78 } else if (stat.mtimeMs !== seen) {
79 seen = stat.mtimeMs
80 dialogs = parse(await $.fs.read(file).catch(() => ''))
81 changed = true
82 }
83 if (dialogs.length > 0 && (changed || now - checked >= 2)) {
84 checked = now
85 const next = new Set<string>()
86 for (const d of dialogs) if (await alive($, d)) next.add(d.path)
87 if (next.size !== live.size || [...next].some((p) => !live.has(p))) changed = true
88 live = next
89 }
90 if (changed) $.ui.invalidate('ui.render')
91}
92
93const shown = () => dialogs.filter((d) => d.phase !== 'closed' || d.at >= promptedAt)
94
95export function register(on) {
96 on('session.start', async ($, e, next) => {
97 $.clock.every(1000, () => refresh($))
98 return next(e)
99 })
100
101 on('prompt.submit', async ($, e, next) => {
102 try {
103 if (e.origin?.kind !== 'composer' && e.origin?.kind !== 'bridge') return next(e)
104 promptedAt = (await $.clock.now()) / 1000
105 if (dialogs.some((d) => d.phase === 'closed')) $.ui.invalidate('ui.render')
106 } catch {
107 // The band is a courtesy; the prompt goes through regardless.
108 }
109 return next(e)
110 })
111
112 // One row per dialog, with anything other mods draw there kept below.
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 const rows = shown()
115 if (!show || rows.length === 0 || e.props.hasSurvey) return next(e)
116 const { Box, Text } = $.ui.resolve(e)
117 const draw = (seg: Seg) =>
118 seg.color || seg.dim || seg.bold
119 ? Text({ color: seg.color, dimColor: seg.dim, bold: seg.bold, children: [seg.text] })
120 : seg.text
121 const room = Math.max(1, e.props.maxRows - 1)
122 const lines = rows
123 .slice(-room)
124 .map((d) => Text({ wrap: 'truncate-end', children: row(d, live.has(d.path), e.props.isWorking).map(draw) }))
125 const theirs = await next(e)
126 return Box({ flexDirection: 'column', children: theirs ? [...lines, theirs] : lines })
127 })
128}
129hooks/dialogs.ts 83 lines1// The state file margins.sh appends to, read back as one entry per dialog.
2// Each line is `<epoch> TAB <event> TAB <absolute path> TAB <detail>`.
3
4export type Phase = 'yours' | 'saved' | 'claude' | 'closed'
5
6export type Dialog = {
7 path: string
8 name: string
9 phase: Phase
10 turn: number
11 // The watch armed last, while it has not said it ended
12 pid: string
13 ns: string
14 at: number
15}
16
17export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
18
19export function parse(text: string): Dialog[] {
20 const byPath = new Map<string, Dialog>()
21 for (const line of text.split('\n')) {
22 const [at, event, path, detail = ''] = line.split('\t')
23 if (!path) continue
24 let d = byPath.get(path)
25 if (!d) {
26 d = { path, name: path.split('/').pop() ?? path, phase: 'yours', turn: 0, pid: '', ns: '', at: 0 }
27 byPath.set(path, d)
28 }
29 d.at = Number(at) || d.at
30 const n = Number(detail)
31 switch (event) {
32 case 'open':
33 case 'reply':
34 d.phase = 'yours'
35 break
36 case 'save':
37 d.phase = 'saved'
38 break
39 case 'turn':
40 d.phase = 'claude'
41 break
42 case 'closed':
43 d.phase = 'closed'
44 d.pid = ''
45 break
46 case 'watch': {
47 const [pid = '', ns = ''] = detail.split(' ')
48 d.pid = pid
49 d.ns = ns
50 if (d.phase === 'closed') d.phase = 'yours'
51 continue
52 }
53 case 'unwatch':
54 if (d.pid === detail) d.pid = ''
55 continue
56 default:
57 continue
58 }
59 if (event !== 'save' && Number.isFinite(n) && detail !== '') d.turn = n
60 }
61 return [...byPath.values()]
62}
63
64// One row of the band: what the dialog waits for, and whether a save will be seen.
65export function row(d: Dialog, watching: boolean, working: boolean): Seg[] {
66 const head: Seg[] = [
67 { text: d.phase === 'closed' ? '✔ ' : '✎ ', color: d.phase === 'closed' ? 'green' : 'magenta' },
68 { text: d.name, bold: d.phase !== 'closed', dim: d.phase === 'closed' },
69 ]
70 if (d.phase === 'closed') {
71 return [...head, { text: ` · closed after ${d.turn} turns`, dim: true }]
72 }
73 const segs: Seg[] = [...head, { text: ` · turn ${d.turn} · `, dim: true }]
74 // A turn that needed no reply leaves no event behind, so Claude only holds
75 // the turn while it is working.
76 if (d.phase === 'yours' || (d.phase === 'claude' && !working)) segs.push({ text: 'your turn', color: 'green' })
77 if (d.phase === 'saved') segs.push({ text: 'you saved, Claude is reading', color: 'yellow' })
78 if (d.phase === 'claude' && working) segs.push({ text: 'Claude is answering in the file', color: 'cyan' })
79 segs.push({ text: ' · ', dim: true })
80 segs.push(watching ? { text: 'watching', dim: true } : { text: 'not watched, a save goes unanswered', color: 'red' })
81 return segs
82}
83