/board opens or closes the AMQ fleet board panel: what waits on you, counts, source status and age, refreshed every minute while open

Messaging between coding agents.
AMQ lets two coding agents exchange messages through a shared directory. One agent asks for a review; the other reads the message and replies in the same thread. Messages are plain files, so the local queue needs no messaging server or database—and you do not have to relay each request yourself.
Try the queue in a fresh shell on macOS. This example uses two mailbox names; it does not start agents. Other installation methods →
brew install avivsinai/tap/amq
amq init --root .agent-mail/demo --agents alice,bob # Create two mailboxes for demo.
amq send --root .agent-mail/demo --me alice --to bob --body "Please review the parser."
amq drain --root .agent-mail/demo --me bob --include-body

<a id="quick-start"></a>
This walkthrough connects Claude Code and Codex CLI on one machine. Install both agent CLIs and put claude and codex on PATH first. Use macOS, Linux, or WSL; native Windows supports a subset (see the platform matrix).
On macOS:
brew install avivsinai/tap/amq
On Linux, or without Homebrew, use the installer script or a release download from Installation. Confirm the install with amq --version.
Install the agent instructions so each agent knows how to use AMQ:
npx skills add avivsinai/agent-message-queue -g -y
Restart the agents after installing. Other skill installation methods are available if you do not use npm. agy (Antigravity CLI) reads skills from ~/.gemini/config/skills, so copy the skill there as INSTALL.md shows.
In the repository where the agents will work:
amq setup
Select the agents and launcher, then confirm the preview. Setup writes the project configuration and creates the default session and its mailboxes. An agent's handle is its harness name (claude, codex) unless you enter handle=harness, for example reviewer=claude. Only the handle goes into the roster and the mailboxes.
amq launch
The first launch asks you to trust the launch plan. With tmux, cmux, or ghostty, AMQ starts the agents in that terminal app. With the commands backend, it prints one command per agent and exits with code 6 (action required). Paste each complete command into a separate terminal; do not shorten or rebuild it.
Optional, for Claude Code: amq wake install-dialog-hook keeps the wake from typing into an open AskUserQuestion, plan, or permission dialog. Add --harness codex or --harness cursor (or --harness all) for their approval prompts.
Start both agents before sending the first message. Messages that arrive before a recipient's wake starts remain unread but do not trigger that new wake. The recipient can still get them with amq drain --include-body.
<a id="messaging"></a>
Ask Claude to run this command through its shell tool:
amq send --to codex --subject "Hello" --body "Can you see this?"
Ask Codex to check its inbox if it has not already responded to the notification:
amq list --new
amq drain --include-body
list previews new messages. drain reads them, moves them out of the new inbox, and writes receipts. The output includes the sender and message body. To reply in the same thread, use the ID from that output:
amq reply --id "<message-id>" --body "Yes, I can see it."
The launched agents already have their identity and session set. You do not need to repeat --me or --root inside those sessions.
Each agent has an inbox and an outbox under a local queue root. A message contains a JSON header and a Markdown body. AMQ writes and syncs a temporary file, then publishes it with a rename: a reader sees a complete message or no message, not a partly written body.
A session is a separate queue. Routing checks help prevent a shell pinned to one session from changing another by mistake. A wake notification asks an agent to check its inbox; it is not evidence that the agent read or completed the request. See routing and message tracing for those contracts.
Optional priority holds can delay the first notification for routine mail while urgent mail skips the hold. Wake live settings have a machine default for this user (amq wake config --machine --hold-normal 5m) and per-agent overrides; use amq wake config --me <handle> --hold-normal 5m to change a running wake.
AMQ is for local agent coordination, not a distributed broker. The optional bridge companion exchanges messages between hosts; each host keeps control of its own queue.
The optional remote companion gives a running Claude Code or Codex session its own Buzz agent, so you can DM that session from the Buzz app or Buzz Desktop. Inside the session, type /amq-remote (in Codex, $amq-remote); /amq-remote off disconnects it. Setup, commands, and the trust model are in that README.
| I want to… | Read |
|---|---|
| Install, update, or use another platform | Installation |
| Run agent pairs, isolate sessions, or supervise notifications | Co-op operations |
| Batch routine wake notifications by priority | Wake operations |
| Look up a command or flag | CLI reference |
| Diagnose delivery or wake problems | Wake operations · Trace |
| Attach to a running harness session | amq-remote |
| Build an integration or understand the design | Documentation index |
| Understand the trust model | Security |
<a id="extension-metadata"></a>
Building on AMQ? Use the adapter contract, launch API, and extension metadata. amq-squad is a role-aware team launcher built on these primitives.
See the contribution guide for development and review. Contributor clones should install the git hooks (./scripts/install-hooks.sh) so pushes run the static checks locally. Use GitHub issues for questions and bug reports. Follow the code of conduct and report security issues through the security policy.
MIT.
hooks/board.tsx 381 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardStrip, BoardView } from '../types'
5
6// The pane and its refresh period while it is open.
7const PANE = 'amq-board'
8const EVERY_MS = 60_000
9const RUN_TIMEOUT_MS = 90_000
10// More than any board prints: a run past it is stopped.
11const MAX_OUTPUT = 4 * 1024 * 1024
12const STALE_MS = 5 * 60_000
13// The first Claude Code whose plugin API this module is written against.
14const MIN_VERSION = [2, 1, 308] as const
15
16const EMPTY: BoardView = {
17 status: 'loading the board…',
18 strips: [],
19 waiting: 0,
20 inFlight: 0,
21 blocked: 0,
22 unclaimed: 0,
23 sources: '',
24 project: '',
25 generatedAt: '',
26 isStale: false,
27}
28
29const view = atom({ plugin: 'amq-board', key: 'view' } as const, EMPTY)
30
31// Module state: a reload drops it, as it drops the engine's timers and
32// kills the module's children.
33let timer: { cancel: () => void } | undefined
34// Bumped by start and stop: a refresh started under an older value belongs
35// to a pane that has closed since, and draws and starts nothing.
36let generation = 0
37let isRunning = false
38// Kills the amq-board run in flight.
39let killRun: (() => void) | undefined
40let last: BoardModel | undefined
41
42// The parts of the amq/board/v1 model the pane reads. Every field is
43// optional: the model comes from another process and is checked here.
44type BoardModel = {
45 generated_at?: unknown
46 dir?: unknown
47 project?: unknown
48 sources?: unknown
49 columns?: unknown
50}
51
52/**
53 * Makes one value safe to draw: tabs and line breaks become spaces, and
54 * every other control (C0, DEL, C1) or format character is dropped, so an
55 * escape sequence in a title cannot restyle or blank the pane.
56 */
57export function clean(value: unknown, limit = 160): string {
58 if (typeof value !== 'string') return ''
59 const text = value
60 .replace(/[\t\n\r\v\f\u2028\u2029]/g, ' ')
61 .replace(/[\p{Cc}\p{Cf}]/gu, '')
62 const chars = Array.from(text)
63 return chars.length > limit ? chars.slice(0, limit - 1).join('') + '…' : text
64}
65
66function list(value: unknown): Record<string, unknown>[] {
67 return Array.isArray(value)
68 ? value.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
69 : []
70}
71
72function field(value: unknown, key: string): unknown {
73 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined
74}
75
76function time(value: unknown): number | undefined {
77 if (typeof value !== 'string') return undefined
78 const ms = Date.parse(value)
79 // Go's zero time (year 1) means unknown.
80 return Number.isFinite(ms) && ms > 0 ? ms : undefined
81}
82
83function age(ms: number): string {
84 if (ms < 60_000) return '<1m'
85 if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`
86 if (ms < 48 * 3_600_000) return `${Math.floor(ms / 3_600_000)}h`
87 return `${Math.floor(ms / 86_400_000)}d`
88}
89
90/** Projects a model onto what the pane draws; ages are as of generation. */
91export function project(model: BoardModel, now: number): BoardView {
92 const generated = time(model.generated_at)
93 const columns = list(model.columns)
94 const cards = (id: string) => list(columns.find(c => c.id === id)?.cards)
95 const strips: BoardStrip[] = cards('waiting_on_you').map(card => {
96 const since = time(field(card.reason, 'since'))
97 const command = list(card.actions)[0]?.command
98 return {
99 title: clean(card.title),
100 reason: clean(field(card.reason, 'text')),
101 age: since !== undefined && generated !== undefined ? `${age(generated - since)} ago` : 'since unknown',
102 command: clean(command, 4096),
103 }
104 })
105 const sources = list(model.sources)
106 .map(s => `${clean(s.name, 40)} ${clean(s.status, 20)}${s.partial === true ? ' (partial)' : ''}`)
107 .join(' · ')
108 return {
109 status: '',
110 strips,
111 waiting: strips.length,
112 inFlight: cards('in_flight').length,
113 blocked: cards('blocked').length,
114 unclaimed: cards('unclaimed').length,
115 sources,
116 project: clean(model.project) || clean(model.dir),
117 generatedAt: clean(model.generated_at, 40),
118 isStale: generated === undefined || now - generated > STALE_MS,
119 }
120}
121
122function within(cwd: string, dir: unknown): boolean {
123 if (typeof dir !== 'string' || dir === '') return false
124 const root = dir.replace(/\/+$/, '')
125 return cwd === root || cwd.startsWith(root + '/')
126}
127
128/** Whether pid is a live process; true where no kill command can tell. */
129async function isAlive($: EngineInterface, pid: number): Promise<boolean> {
130 try {
131 const ran = await $.process.run(['kill', '-0', String(pid)], { timeoutMs: 5_000 })
132 return ran.exitCode === 0
133 } catch {
134 // No kill command (Windows): the token on the fetch still has to match.
135 return true
136 }
137}
138
139/** Reads the model from a running amq-board serve for this project, if any. */
140async function fromServe($: EngineInterface, cwd: string): Promise<BoardModel | undefined> {
141 const home = await $.env.get('HOME')
142 if (!home) return undefined
143 let record: unknown
144 try {
145 const text = await $.fs.read(`${home}/.amq/board/serve.json`)
146 record = JSON.parse(typeof text === 'string' ? text : '')
147 } catch {
148 return undefined
149 }
150 const url = field(record, 'url')
151 const token = field(record, 'token')
152 const pid = field(record, 'pid')
153 // Loopback only, and a token that is one path segment.
154 if (typeof url !== 'string' || !/^http:\/\/127\.0\.0\.1:\d+$/.test(url)) return undefined
155 if (typeof token !== 'string' || !/^[0-9a-f]+$/.test(token)) return undefined
156 // A record whose serve is gone is stale.
157 if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return undefined
158 if (!(await isAlive($, pid))) return undefined
159 try {
160 const res = await $.http.fetch(`${url}/${token}/model.json`)
161 if (!res.ok) return undefined
162 const model = JSON.parse(res.text) as BoardModel
163 // serve reads one project; another project's board is not this one's.
164 return within(cwd, model.dir) ? model : undefined
165 } catch {
166 return undefined
167 }
168}
169
170/**
171 * Runs amq-board --json. stop() or the 90 s deadline ends the refresh at
172 * once and asks the engine to kill the run; the refresh never waits on a
173 * child that does not answer.
174 */
175async function fromRun($: EngineInterface, cwd: string): Promise<BoardModel | string> {
176 const run = $.process.spawn({ argv: ['amq-board', '--json'], cwd })
177 let abort: (why: string) => void = () => {}
178 const aborted = new Promise<never>((_, reject) => {
179 abort = why => reject(new Error(why))
180 })
181 aborted.catch(() => {})
182 const kill = (why: string) => {
183 abort(why)
184 void run.return({ code: null, signal: 'SIGTERM' }).catch(() => {})
185 }
186 const deadline = $.clock.after(RUN_TIMEOUT_MS, () => kill('amq-board: no answer in 90 s'))
187 const stopped = () => kill('amq-board: stopped')
188 killRun = stopped
189 let stdout = ''
190 let stderr = ''
191 try {
192 let step = await Promise.race([run.next(), aborted])
193 while (!step.done) {
194 if (step.value.stream === 'stdout') stdout += step.value.text
195 else if (stderr.length < 4096) stderr += step.value.text
196 if (stdout.length > MAX_OUTPUT) {
197 kill('amq-board: --json printed more than 4 MiB')
198 return 'amq-board: --json printed more than 4 MiB'
199 }
200 step = await Promise.race([run.next(), aborted])
201 }
202 const code = step.value?.code
203 if (code !== 0) {
204 const first = stderr.split('\n').find(line => line.trim() !== '') ?? `exit ${code ?? 'by signal'}`
205 return `amq-board: ${clean(first)}`
206 }
207 } catch (err) {
208 const text = err instanceof Error ? err.message : String(err)
209 if (text.startsWith('amq-board: ')) return text
210 return /ENOENT|not found|no such file/i.test(text) ? 'amq-board not found' : `amq-board: ${clean(text)}`
211 } finally {
212 deadline.cancel()
213 if (killRun === stopped) killRun = undefined
214 }
215 try {
216 return JSON.parse(stdout) as BoardModel
217 } catch {
218 return 'amq-board: --json printed no JSON'
219 }
220}
221
222/**
223 * Whether a refresh started under gen may go on: no stop since, and the
224 * pane still open by the engine's record, which a close the module never
225 * heard of (an unload) also ends.
226 */
227async function isCurrent($: EngineInterface, gen: number): Promise<boolean> {
228 if (gen !== generation || !timer) return false
229 if (!(await $.ui.panes()).some(pane => pane.id === PANE)) {
230 if (gen === generation) stop($)
231 return false
232 }
233 return gen === generation
234}
235
236async function tick($: EngineInterface): Promise<void> {
237 if (isRunning) return
238 isRunning = true
239 const gen = generation
240 try {
241 if (!(await isCurrent($, gen))) return
242 const cwd = await $.session.cwd()
243 const served = await fromServe($, cwd)
244 if (!(await isCurrent($, gen))) return
245 const got = served ?? (await fromRun($, cwd))
246 const now = await $.clock.now()
247 // Every await is a point where the pane may have closed: check before
248 // the update and again before the status, with nothing awaited between
249 // the last check and the call it guards.
250 if (!(await isCurrent($, gen))) return
251 let next: BoardView
252 if (typeof got === 'string') {
253 // Keep the last board on screen, marked by the failure.
254 next = last ? { ...project(last, now), status: got } : { ...EMPTY, status: got }
255 } else {
256 last = got
257 next = project(got, now)
258 }
259 await update($, view, () => next)
260 if (!(await isCurrent($, gen))) return
261 $.ui.status(`board: ${next.waiting} waiting · ${next.inFlight} in flight`)
262 } catch (err) {
263 if (gen === generation && timer) {
264 await update($, view, old => ({ ...(old ?? EMPTY), status: `amq-board: ${clean(String(err))}` }))
265 }
266 } finally {
267 isRunning = false
268 // A pane opened again while this refresh ran gets its own refresh now.
269 if (gen !== generation && timer) void tick($)
270 }
271}
272
273function start($: EngineInterface): void {
274 if (timer) return
275 generation++
276 timer = $.clock.every(EVERY_MS, () => void tick($))
277 void tick($)
278}
279
280function stop($: EngineInterface): void {
281 generation++
282 timer?.cancel()
283 timer = undefined
284 killRun?.()
285 killRun = undefined
286 $.ui.status(undefined)
287}
288
289async function isSupported($: EngineInterface): Promise<boolean> {
290 const base = (await $.session.version()).base
291 // A build that names no release is a development build: let it run.
292 if (!base) return true
293 const parts = base.split(/[.-]/).slice(0, 3).map(Number)
294 for (let i = 0; i < MIN_VERSION.length; i++) {
295 const have = parts[i] ?? 0
296 if (Number.isNaN(have)) return true
297 if (have !== MIN_VERSION[i]) return have > MIN_VERSION[i]!
298 }
299 return true
300}
301
302export const register: Register = on => {
303 on('session.start', async ($, e, next) => {
304 try {
305 if (await isSupported($)) {
306 await $.command.register({ name: 'board', description: 'Open or close the AMQ fleet board panel' })
307 // Only after a reload can the pane already be open: resume its refresh.
308 if ((await $.ui.panes()).some(pane => pane.id === PANE)) start($)
309 }
310 } catch {
311 // An engine without these calls keeps loading everything else.
312 }
313 return next(e)
314 }).catch(($, e, next) => next(e))
315
316 on('command.run', { command: 'board' }, async ($, e) => {
317 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
318 await $.ui.close({ id: PANE })
319 return (await $.ui.panes()).some(pane => pane.id === PANE)
320 ? { text: 'AMQ board is still open.' }
321 : { text: 'AMQ board closed.' }
322 }
323 const opened = await $.ui.open({ id: PANE, title: 'AMQ board' })
324 start($)
325 return opened.isPlaced
326 ? { text: 'AMQ board opened; run /board again to close. It refreshes every minute while open.' }
327 : { text: `AMQ board opened; it waits for a surface that can show it (${opened.reason}).` }
328 }).catch(() => ({ text: 'AMQ board could not open or close.' }))
329
330 // An older engine that refuses these events still loads the two above.
331 try {
332 registerPane(on)
333 } catch {
334 // No pane on this engine; skills and other plugins load as before.
335 }
336}
337
338function registerPane(on: Parameters<Register>[0]): void {
339 on('ui.close', { id: PANE }, async ($, e, next) => {
340 stop($)
341 return next(e)
342 }).catch(($, e, next) => next(e))
343
344 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
345 const { Box, Text, Button } = $.ui.resolve(e)
346 const v = await read($, view)
347 const width = Math.max(20, e.props.bodyColumns ?? 40)
348 return (
349 <Box flexDirection="column" width={width}>
350 {v.project !== '' && <Text weight="bold">{v.project}</Text>}
351 {v.status !== '' && <Text color="warning">{v.status}</Text>}
352 <Text weight="bold">Waiting on you ({v.waiting})</Text>
353 {v.generatedAt !== '' && v.strips.length === 0 && <Text dimColor>nothing waits on you</Text>}
354 {v.strips.map((strip, i) => (
355 <Box flexDirection="column" marginBottom={1}>
356 <Text>{strip.title}</Text>
357 <Text dimColor>
358 {strip.reason} · {strip.age}
359 </Text>
360 {strip.command !== '' && (
361 <Button key={`copy-${i}`} plain onPress={press => void $.ui.copy({ text: strip.command, surface: press.surface })}>
362 copy command
363 </Button>
364 )}
365 </Box>
366 ))}
367 <Text>
368 In flight {v.inFlight} · Blocked {v.blocked} · Unclaimed {v.unclaimed}
369 </Text>
370 {v.sources !== '' && <Text dimColor>sources: {v.sources}</Text>}
371 {v.generatedAt !== '' && (
372 <Text dimColor={!v.isStale} color={v.isStale ? 'warning' : undefined}>
373 generated {v.generatedAt}
374 {v.isStale ? ' (stale)' : ''}
375 </Text>
376 )}
377 </Box>
378 )
379 })
380}
381types/index.d.ts 31 lines1/** One Waiting on you strip, every string already stripped of control and format characters. */
2export type BoardStrip = {
3 title: string
4 reason: string
5 age: string
6 command: string
7}
8
9/** What the pane draws: the board model projected and cleaned at each refresh. */
10export type BoardView = {
11 /** Empty when the last refresh read a model; else the one line to show. */
12 status: string
13 strips: BoardStrip[]
14 waiting: number
15 inFlight: number
16 blocked: number
17 unclaimed: number
18 sources: string
19 project: string
20 /** The model's generated_at as given; empty before the first model. */
21 generatedAt: string
22 /** True once the model is more than 5 minutes older than the last refresh. */
23 isStale: boolean
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'amq-board': { view: BoardView }
29 }
30}
31