Skip to content

Hooks and the warden

Filipe Soares edited this page Aug 29, 2026 · 2 revisions

Getting it read

A store is only read if something asks for it. Three mechanisms do, in order of how little they ask of anyone.

Server instructions. Sent in the MCP handshake and injected into the model's context by hosts that support it — a paragraph naming the read tools and the write ones. Nothing to configure.

The warm_up prompt. An MCP prompt is invoked by the person, not by the agent. Hosts that surface prompts show it as a command; it returns the same brief the hook below emits.

memai-hook. A console script that reads the SQLite store directly, with no MCP server involved. It reads the hook payload on stdin and writes one JSON object on stdout, and it never fails loudly: no store, an unreadable one, a payload that is not JSON, all exit 0 with no output. guard is the one exception, and the only event that reads the call rather than the store.

event what it emits
session-start the store's state as context — counts, active domains, latest checkpoint, open handoffs, pitfalls, recent notes, documented flows — ending in the instruction to call pulse(domain) for the subject before the session's first tool call
pre-compact a reminder that what should outlive the transcript belongs in the store
stop a nudge to checkpoint, and only when nothing was written recently
guard refuses a memai write whose required text never arrived — PreToolUse, exit 2, and the reason on stderr

The session-start context carries the instruction to call pulse rather than a separate per-prompt hook, and nothing searches on the prompt's words: the agent is told to open the subject and picks the domain itself.

What the guard checks. A tag opened without the antml: prefix is dropped by the parser before the call leaves the client, so the server never sees the parameter and the text it held is gone. The guard refuses such a call on PreToolUse — exit 2, the cause on stderr — and says to type the tags again rather than paste the block back. A parameter the tool does not require is reported as a systemMessage and the write goes through. The required set is read from the tool signatures and tested against them; everything else — another server's tool, one it does not guard, a payload it cannot read, an error of its own — goes through untouched.

Register all four with:

memai-hook install            # the user's ~/.claude/settings.json
memai-hook install --skills   # copy the bundled skills into ~/.claude/skills/
memai-hook install --agents   # copy the bundled subagents into ~/.claude/agents/
memai-hook install --check    # hooks, skills and agents; exit 1 unless every
                              #   hook is registered as this version writes it
memai-hook install --print    # what it would write, writing nothing
memai-hook install --settings <path>   # write that block somewhere else

Hooks on the same event that memai did not write are left alone, memai's own entries are replaced rather than appended, and an existing settings file is copied to <name>.bak-<stamp> first. The command is registered as an absolute path with forward slashes — a command hook is handed to a shell, and a POSIX shell reads the backslashes of a Windows path as escapes.

One scope: the user's settings

~/.claude/settings.json, with the skills directory beside it, is the scope memai installs into and the only one it reads back. One registration covers every project.

--settings <path> writes the same block into any other file — a repository's .claude/settings.local.json included. Nothing reads that file back, and nothing reports it as out of date; keeping it current is yours.

While the user's settings register no memai hook, the server's MCP instructions carry a line asking for memai-hook install. Once they do, the instructions stay quiet until the installation is out of date, and then they name what drifted and the command that fixes it: a host event that is not registered, a registration whose command has left the disk, one that fires this version's command through an entry it would write differently, or a bundled skill whose installed copy is untouched while the bundle has moved on. An install does not keep itself current, and nothing writes to ~/.claude without being asked.

A registration that fires some other memai-hook still on disk is left alone — edit the command by hand and it stays yours.

--domain narrows the session brief, --budget caps the characters it emits, --quiet-minutes how recent a write has to be for stop to stay quiet. The registration itself, which install writes for you:

{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "memai-hook session-start" }] }
    ],
    "PreCompact": [
      { "hooks": [{ "type": "command", "command": "memai-hook pre-compact" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "memai-hook stop" }] }
    ],
    "PreToolUse": [
      {
        "matcher": "mcp__[Mm]em[Aa][Ii]__(note|reasoning|handoff|checkpoint|anti_pattern)",
        "hooks": [{ "type": "command", "command": "memai-hook guard" }]
      }
    ]
  }
}

The hook reads MEMAI_HOME from its own environment, so a store outside ~/.memai has to be set where the hook can see it — the host's env block reaches the MCP server, not a hook process.

Writes carry a session stamp derived per server process unless one is passed, so a conversation's memories group together in the dashboard without an agent having to remember an id.

The status line

memai-hook statusline reads the same store the hook events do and writes one plain line — not the JSON object an event emits — for a host that renders a status line:

memai 128 mem | acme/x100 | cp 3h ago

How much is stored, the busiest domain, and how old the latest checkpoint is, in under 80 characters. The domain path is the field that gives way when the line would be too long. --domain scopes all three. Same tolerance as the events: an empty store, an unreadable one, junk on stdin — no line, exit 0.

The domain is ranked on the memories naming that exact path, filed there or cross-listed there, and not on its subtree, so a parent holding nothing of its own never outranks the child doing the work.

Bundled skills

The package ships agent skills as Markdown under memai/skills/, one directory per skill: how to use the store, and the curation pass as an orchestrator plus one skill per decision it makes. memai-hook install --skills copies them into the skills/ directory beside the settings file it would otherwise register hooks in — ~/.claude/skills, or beside a --settings target.

Only the names memai ships are read or written: a skill directory it does not ship is left untouched, and a file it would overwrite is copied to <name>.bak-<stamp> first. A file already holding the bundled bytes is left alone, so a second run copies nothing.

--check reports each bundled skill as installed, outdated or missing alongside the hooks, but only the hooks decide its exit code: a missing hook puts the store out of reach. --check --skills moves the gate to the skills.

Each run leaves a receipt in that directory — .memai-skills.json, holding one sha256 per installed file and the memai version that wrote it — and the states are read against it:

state what it means
installed the bundled bytes are there
outdated what is there is the copy the receipt recorded, and the bundle has moved on — an update waiting to be copied
edited somebody changed it after it was installed
missing the directory is not there

Only outdated reaches the MCP instructions; missing and edited are left alone. --check prints the version that installed them, and says when an edited skill is also behind the bundle. Re-installing takes the update and leaves the local copy in <name>.bak-<stamp> beside it.

Both sides are compared over the same file names, so a backup sitting inside a skill directory is not a difference, and edited is judged against the names the receipt holds, so a bundle that gained or lost a file can still tell an untouched copy. Without a receipt, a skill whose bytes differ reads as outdated, and the next install records the hashes.

The warden subagent

The store is only worth what a session actually reads from it, and a session busy with the work does not stop to ask. The warden is a subagent that asks on its behalf: it reads the turns since it last ran, searches the store, and reports back only what bears on what is happening — a pitfall the work is walking into, a decision it is about to contradict, a fact it is about to measure again. It reports nothing the rest of the time, which is most of the time.

Nothing launches it but the session itself, so the Stop hook asks. Once the interval has passed it adds one line to the end of a turn naming the subagent, the transcript and where to start reading; the session launches it in the background and carries on, and the findings arrive as a task notification. The ask is recorded when it is made, so the next turns are silent until the interval comes round again.

Turning it off, and setting the interval

The warden is the one part of MemAI that spends tokens whether or not it finds anything, so it has a switch. Maintenance -> Warden in the dashboard holds both controls: on/off, and how long a session goes before it is asked for again (20 minutes by default). Off means the Stop hook never asks, and the warden costs nothing; the interval stays on file while it is off, so turning it back on does not lose the choice.

Both live in the store's meta table, which makes them store-wide: one store serves every project on the machine, so turning the warden off anywhere turns it off everywhere. That is the scope the question deserves -- it is a standing preference about what an agent costs, not a fact about one repository.

memai-hook stop --warden-minutes <n> overrides the interval for a single run; the store holds the standing answer.

Three conditions keep it quiet rather than wrong. A session whose id the host did not send is never asked, because nothing could record the ask and it would repeat every turn. Neither is one whose host has no warden installed. And neither is one that started before the definition was installed: a host reads its agents once, when it starts, so a file that arrives mid-session is on disk without being launchable, and asking for it would spend the turn on a launch error. SessionStart records when the session began and the Stop hook compares the two, which is why installing the warden takes effect in the next session rather than the one that installed it.

Its tools: list carries the store's four read tools under mcp__memai__ and again under mcp__MemAI__, because a host builds a tool's name out of the name the server is registered with and matches an allowlist literally. A server registered under some other name leaves the warden launchable and storeless, and its silence then means it had nothing to search rather than nothing to say.

memai-hook install --agents           # ~/.claude/agents/memai-warden.md
memai-hook install --check --agents   # exit 1 until it is installed

agents/ is the sibling of the skills/ directory, and everything the skills get applies here too: bundled bytes already in place are left alone, anything else in the way is backed up to <name>.bak-<stamp> first, and each run leaves a receipt — .memai-agents.json, one sha256 per file — that separates the four states the same way. installed, outdated (an update waiting, and the only one that reaches the MCP instructions), edited (yours, so it stays quiet), missing. Without a receipt a copy whose bytes differ reads as outdated, because that is the verdict that offers the update; reading it as edited would leave it to age in silence.

An edited definition still counts as installed when the hook decides whether to ask — the prompt is yours to change.

Clone this wiki locally