How ainb-hooks, ainb-notifyd, and the Inbox screen work in agents-in-a-box
notify.sh) handles every Claude Code and Codex CLI
hook event, builds a JSON envelope, and writes it to a Unix domain socket. A small Rust daemon
(ainb-notifyd) listens on that socket, persists each envelope to a SQLite database with
WAL, replays a fallback file at startup, and emits OS notifications for user-facing events. The
ainb-tui Inbox screen polls the same DB on every render tick, shows two-pane
list + detail, and uses cwd-based correlation to render per-session row badges and
jump straight to the matching tmux pane. Three PRs shipped to main.
Architecture in one picture
Two host agents fire hooks at distinct lifecycle moments. The same script absorbs both shapes, hands off to a daemon that owns the SQLite file, and the TUI polls that SQLite for live display. Every box below is a real file on disk; arrows are the actual transport.
The seven components
Every box in the diagram is a real artifact in the repo. Each card below is one file (or one tight module) and what it owns.
component · bash
One script for both agents. Detects whether JSON arrives on stdin (Claude) or as
argv[1] (Codex), pulls hook_event_name or type, builds a
normalised envelope, and pipes it to the socket. Exits 0 unconditionally so a delivery failure
never blocks the host agent.
component · json
Standard Claude Code plugin marketplace manifest. Wires every event
(SessionStart, UserPromptSubmit, PostToolUse,
Notification, Stop, PreCompact) to
${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh with AINB_AGENT=claude
prepended.
component · json
Codex doesn't have a plugin marketplace, so we merge a managed block into the user's
~/.codex/hooks.json. Each entry carries _ainb_managed: true so
uninstall strips only what we added; pre-existing user hooks survive untouched.
component · rust
Tokio UnixListener + per-connection task. install,
uninstall, status, run, stop verbs from one
binary. Embeds the bash script + manifests via include_str! so the install verb has no
repo dependency at runtime.
component · sqlite
WAL mode + synchronous=NORMAL. Five indexes (ts, session_id, project, agent, partial
on unread). Retention sweep on every insert: deletes rows older than 7 days or beyond the
10k-row cap. Notifyd-owned — does not share the usage.db file that
session-reader plugin owns.
component · ratatui
Two-pane list + detail. Eager Store open on TUI startup so the global menu-bar badge
works from frame one. session_list.rs renders a per-row ● N using
unread_for_workspace_path. Sidebar tile 📥 Inbox [I] closes the
discoverability gap.
component · bash
One-command host smoke. Installs for both agents, starts the daemon, fires synthetic Claude +
Codex hooks (or pauses for a real CLI session with --real-session), and prints the
rows that landed in SQLite. The only thing that proves the live-host seam end-to-end.
The event path, step by step
From hook fire to a visible row in the TUI — five steps, end to end in single-digit milliseconds when
the daemon is up. Each step's file:line citation is on the right.
1 · The agent fires a hook claude/codex internals
When the host agent hits a lifecycle moment — turn complete, idle prompt, permission request —
it dispatches the registered hook command. Claude pipes a JSON object on stdin with a
hook_event_name field; Codex passes the JSON as the first argv argument
with a type field. Same wire shape, two different delivery channels.
2 · notify.sh builds + sends the envelope plugins/ainb-hooks/hooks/notify.sh
The script auto-detects the delivery channel ($# > 0 means argv-mode Codex; else
read stdin). It extracts session_id, cwd, the raw event name, optionally
a matcher (concatenated as EventName:matcher), then assembles a
normalised envelope with jq. The envelope is piped to nc -U notify.sock;
if the socket doesn't exist the script writes the envelope to
notify.fallback.jsonl for the daemon to replay later. exit 0 always.
3 · ainb-notifyd parses + persists crates/ainb-plugin-notifyd/src/listener.rs
Each accepted connection spawns a tokio task that reads newline-terminated JSON. Parsing
(Envelope::from_bytes) validates protocol_version and required fields;
malformed lines are routed to .corrupt.jsonl for forensics. The blocking SQLite
insert + retention prune runs on the blocking pool via spawn_blocking. After every
successful persist, osnotify::notify decides whether to fire a native notification
(gated by is_user_facing + a per-(session, raw_event) 60-second
debounce).
4 · SQLite stores the row crates/ainb-plugin-notifyd/src/store.rs
One row per envelope. The retention sweep runs in the same transaction: rows older than
retention_days are deleted, then the oldest rows beyond max_rows are
deleted (ORDER BY ts ASC). WAL mode means the TUI reader never blocks the daemon writer.
unread_by_cwd() and unread_by_session() are grouped-count queries that
use the partial unread index, so they stay sub-millisecond even with 10k rows.
5 · ainb-tui renders crates/ainb-core/src/components/inbox.rs
Three render surfaces share the same store handle:
· The Inbox screen calls Store::list on every render tick and shows the rows in a
two-pane list + detail. Keys drive mark_read, dismiss,
dismiss_visible, plus filter cycling.
· The menu bar (layout.rs) queries unread_count once per frame and
renders ● N · I inbox. The hint is always visible; the ● N only when
unread > 0.
· The session list (session_list.rs) queries unread_by_cwd once per
render and looks up each session's workspace_path in the resulting map to render the
per-row ● N.
Wire format & install
One envelope shape, three install surfaces. The placeholders below are exactly what's on the wire
and on disk after a clean ainb-notifyd install --all.
// one JSON line per envelope on the socket / in fallback.jsonl { "protocol_version": 1, "agent": "claude", // or "codex" "raw_event": "Notification:idle_prompt", "session_id": "7f2a-...", "cwd": "/Users/stevie/d/git/ai-coder-rules", "project": "ai-coder-rules", // basename(cwd) "ts": 1717000000000, "payload": { /* verbatim original hook JSON */ } }
// ~/.claude/plugins/ainb-hooks/.claude-plugin/plugin.json { "name": "ainb-hooks", "version": "0.1.0", "hooks": { "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "AINB_AGENT=claude ${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh", "timeout": 5 }] }] // + SessionStart, UserPromptSubmit, PostToolUse, // Notification, PreCompact — same shape } }
// ~/.codex/hooks.json — managed block merged in-place { "hooks": { "Stop": [ { /* user's pre-existing hook — untouched */ }, { "_ainb_managed": true, // uninstall strips only these "matcher": "", "hooks": [{ "type": "command", "command": "AINB_AGENT=codex /Users/stevie/.agents-in-a-box/hooks/notify.sh", "timeout": 5 }] } ] } }
// ~/.agents-in-a-box/install.json — written by install verb { "agents": ["claude", "codex"], "hook_script": "/Users/stevie/.agents-in-a-box/hooks/notify.sh", "claude_plugin_dir": "/Users/stevie/.claude/plugins/ainb-hooks", "codex_hooks_json": "/Users/stevie/.codex/hooks.json" }
~/.agents-in-a-box/hooks/notify.sh. Claude's
plugin dir symlinks to it (with a copy fallback on platforms where symlink fails); Codex's
hooks.json references it by absolute path. One source of truth, no drift between agents.
cwd-based correlation — bridging two ID spaces
ainb internally identifies a session by a Uuid it generates when spawning a worktree.
The host agents' hooks emit their own session_id strings — Claude's session ID, Codex's
session ID — which sit in a different namespace. There's no shared key.
The workaround is cwd: every envelope already carries the agent's working directory at
hook-fire time, and every ainb Session already carries a workspace_path
(the repo root). Match the two and the bridge is done:
Lifecycle, retention & gotchas
- Lazy daemon spawn. Hook scripts don't require the daemon to be
running — if the socket is missing, the envelope is appended to
notify.fallback.jsonl, and the next daemon startup ingests + clears the file. Zero loss on cold systems. - PID file + SIGTERM. The daemon writes
~/.agents-in-a-box/notify.pidat startup.ainb-notifyd stopsendsSIGTERM; the listener drops the socket file + PID file before exiting. A stale PID is cleaned up on the next startup viakill(pid, 0)liveness check. - Retention on every insert. Defaults: 7 days, 10 000 rows. Tuned
in
~/.agents-in-a-box/config.toml [notifyd]. The sweep runs in the same SQLite transaction as the insert. - OS-notify debounce. Per-
(session_id, raw_event), 60-second window. Telemetry events (SessionStart,UserPromptSubmit,PostToolUse) never fire native notifications even if the debounce would allow them — they're stored for the inbox but never disturb you. - Hook script always exits 0. A delivery failure must never block the host agent. If the socket is broken AND the fallback write fails, the envelope is dropped — deliberately, because the alternative is freezing claude/codex.
- Reversible install.
install.jsonrecords the exact paths used.uninstallreads it back and removes the Claude plugin dir + strips_ainb_managedentries from Codex'shooks.jsonwhile leaving user-authored hooks intact.
How it was validated
Five test layers in order of cost-to-confidence. CI runs the bottom three on every push across macOS + Linux.
// crates/ainb-plugin-notifyd/src/{envelope,store,fallback,pid,osnotify}.rs // crates/ainb-core/src/components/{inbox,sidebar}.rs $ cargo test -p ainb-plugin-notifyd --lib test result: ok. 44 passed; 0 failed $ cargo test -p ainb --lib components::{inbox,sidebar} test result: ok. 15 passed; 0 failed
// crates/ainb-plugin-notifyd/tests/end_to_end.rs // real bash → real socket → real notifyd → real SQLite $ cargo test -p ainb-plugin-notifyd --test end_to_end hook_script_into_real_daemon_persists_both_agents .. ok hook_falls_back_when_daemon_down_then_daemon_replays .. ok
// crates/ainb-core/tests/tripwire_inbox_opens_and_renders.rs // real ainb binary in a tmux pane · capture-pane assertions $ cargo test -p ainb --test tripwire_inbox_opens_and_renders inbox_opens_and_renders_seeded_notifications .. ok ✓ HomeScreen sidebar shows 📥 Inbox [I] (discoverability) ✓ Shift+I opens 📥 Inbox screen ✓ Both seeded events render with detail pane ✓ Help bar visible
# Driven by hand against an isolated $HOME via tmux capture $ HOME=/tmp/ainb-test ./target/debug/ainb tui → HomeScreen sidebar shows 📥 Inbox tile between Sessions and Recovery → Shift+I opens Inbox with both seeded events → Detail pane shows event, agent, session, cwd, payload JSON → Menu-bar shows "I inbox" hint always; "● N · I inbox" when unread
# THE one unproven seam — requires real Claude or Codex CLI session $ scripts/live-validate-ainb-hooks.sh --real-session ==> install + daemon + pause for real session ... # requires Stevie's machine: a real claude/codex CLI process # firing its own hook with its real payload shape ==> rows that landed: agent raw_event session_id project ????? ??????????? ?????????? ???????
hook_event_name / type field names and event values I
expect. Run live-validate-ainb-hooks.sh --real-session to close that gap.FAQ
- Where is the SQLite file? Can I poke around with
sqlite3? ~/.agents-in-a-box/notifications.db. Yes — WAL means the daemon can keep writing while you read.sqlite3 -header -column ~/.agents-in-a-box/notifications.db "SELECT agent, raw_event, session_id, project FROM notifications ORDER BY ts DESC LIMIT 20".- Why
Shift+Ifor the Inbox? Why notn? nalready opens NewSession; lowercaseialready opens Stats.Shift+Iwas the next free letter that still mnemonically ties to "inbox". The home-screen sidebar tile and the menu-bar hint both advertise it as[I]. On Linux tmuxShift+imay arrive asKeyCode::Char('i') + SHIFTrather thanChar('I'); the handler accepts both forms (PR #165).- Why not just identify sessions by
session_id? - The host agent's
session_idand ainb'sSession.idlive in different namespaces — Claude generates its own UUID, Codex generates its own, ainb generates its own. There is no shared key. The cheapest correlation that already exists at both ends is the working directory: every envelope hascwd, every ainb session hasworkspace_path. Exact match or prefix-with-trailing-slash for worktree subdirs. - What happens if I install once, run, then install again?
- Idempotent.
installwrites/overwrites the canonical hook script, re-creates the Claude plugin dir, re-merges the Codex managed block (replacing any prior_ainb_managedentries — never duplicating). Running twice produces the same on-disk state as running once. - How do I confirm it works on my real Claude Code or Codex sessions?
cd ainb-tui && cargo install --path crates/ainb-core --force(your existing~/.cargo/bin/ainbis the pre-merge build), thenscripts/live-validate-ainb-hooks.sh --real-session. The script pauses after starting the daemon — launch a realclaudeorcodexsession, do something, then come back. It prints the rows that actually landed.- Why does the discoverability test exist if the keystroke test already passes?
- Because the original tripwire only proved pressing
Iopens the inbox. It never asserted there was a visible cue thatIwas a shortcut. The follow-up regression: an undiscoverable feature can pass green CI. The new pre-assertion checks for📥,Inbox, and[I]in the home-screen capture before firing the keystroke.