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

HookFiresWaits
tui:bootConfig is loaded, before the TUI is interactiveyes
tui:shutdownBefore shutdown proceeds — the last chance to flushyes
tui:readyThe TUI is liveno
agent:readyAn instance finished bootingno
agent:stoppedAn instance shut downno
agent:focusedA different instance came on screenno
message:sentYou sent a messageno
message:receivedThe agent finished a wakeno
mode:changedThe active mode changedno

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.