Plugins & Hooks
Commands and keys run when someone asks. Hooks run when something happens — an agent finishes booting, a message goes out, the terminal is shutting down.
Plugins
Every .ts file in plugins/ is loaded on launch. No export, no wrapper — call
tui.hook() at the top level and it registers as the file runs.
// plugins/worklog.ts
const sent: string[] = []
tui.hook("message:sent", ({ content, instance }) => {
sent.push(`${instance.name}: ${content}`)
})
tui.hook("tui:shutdown", async () => {
if (sent.length === 0) return
await Bun.write(`${process.env.HOME}/worklog.md`, sent.join("\n"))
})
Files load alphabetically. Each is independent: one that throws disables itself and nothing else.
plugins/ is a convention, not a capability — tui.hook() works from main.ts too. The
folder exists so lifecycle code has somewhere obvious to live, and so it loads without
you importing it. Just don't do both: a file that is auto-loaded and imported
registers twice.
The two kinds of hook
Whether Axon waits for you is a property of the event, not of how you registered.
tui:boot and tui:shutdown gate. There is a meaningful "before" to run in, and the
TUI waits. Flushing to disk on shutdown only works because something waits for it.
Everything else notifies. It announces something that already happened. Waiting would
delay the interface for nothing, so these return void and nothing is awaited.
If a notification handler needs to do async work, own it yourself:
tui.hook("agent:ready", instance => {
void warmCacheFor(instance) // fire-and-forget, and visibly so
})
That is why there is one hook() verb rather than a blocking one and a non-blocking one.
Two nearly identical names differing only in a consequence you cannot see is how you get
either a race or a hang, depending on which you picked.
Gates are bounded
A gating hook that takes too long does not hang the terminal — Axon reports which handler blocked and continues. A config must never be able to freeze the thing you would use to fix it.
A gate gives your handler its moment; it does not hand your config a veto. tui:shutdown
throwing is reported, and the shutdown proceeds.
The events
| Hook | Fires | Waits |
|---|---|---|
tui:boot | Config is loaded, before the TUI is interactive | yes |
tui:shutdown | Before shutdown proceeds — the last chance to flush | yes |
tui:ready | The TUI is live | no |
agent:ready | An instance finished booting | no |
agent:stopped | An instance shut down | no |
agent:focused | A different instance came on screen | no |
message:sent | You sent a message | no |
message:received | The agent finished a wake | no |
mode:changed | The active mode changed | no |
message:received carries no content, deliberately. Reacting to what a model said is
an agent's job — see Cognets. This tells you only that a
wake completed, which is enough for a spinner, a timer, or a notification.
A worked example
Warn before quitting while an agent is mid-thought:
// plugins/focus-guard.ts
tui.hook("tui:shutdown", async () => {
const working = agents.list().filter(a => a.activity === "working")
if (working.length === 0) return
tui.warn(`${working.map(a => a.name).join(", ")} still working`)
})
tui:shutdown gates, so the warning is shown before the terminal goes away. The palette
primitives work here too — a gating hook is an ordinary async function, so it can ask a
question and wait for the answer.
Reference
tui.hook(name, handler) // → Disposer
Available from plugins/, main.ts, and an extension alike. There is no capability
difference between those places — only a difference in when the file runs.
What's next
Extensions — packaging all of this so someone else can install it.