Hooks
Run plugin code when something happens — guarding a tool call, a session starting, a run beginning or ending.
plural.on(event) runs a function when something happens instead of when someone calls it.
Guarding tool calls
A tool_call hook sees every tool call the agent makes before the host's policy is applied, and
can make the host stricter, never looser:
def setup(plural):
@plural.on("tool_call")
async def guard(call, ctx):
if (
call.name == "bash"
and "git push" in call.args.get("command", "")
and "main" in call.subject
):
return plural.deny("pushes to main go through review")
if call.name == "edit" and call.subject.startswith("migrations/"):
return plural.ask("migrations need a second look")call.name, call.risk, call.subject, call.args | the tool, read/write/exec, what it acts on, and its arguments |
call.author_id, call.role | who started the run the call belongs to |
return plural.deny(reason) | refuses the call; the agent is told the reason and not to retry |
return plural.ask(reason) | the host approves it even in auto mode; the card shows <plugin> plugin: <reason> |
| return nothing | the policy decides as usual |
Any deny wins
There is no allow: a call the policy refuses, or one plan mode forbids, never reaches the
hook. A hook gets 5 seconds; one that raises or takes longer counts as ask, so a broken
guard makes the host ask rather than let a call through. ctx.reply(text) and
ctx.run(*argv) work as in commands.
When the plugins load
A session_start hook runs after the plugins load: when the host starts and after every
/plugins reload, new or rm. It gets session.room and session.workspace, and
ctx.reply and ctx.run work. Use it to check the environment or set the first status line:
@plural.on("session_start")
async def ready(session, ctx):
plural.status("watching tests")It has 120 seconds; if it fails the room sees why.
Before a run
A before_run hook can hand the agent context just before a run starts, such as the branch or
the tests that fail:
def setup(plural):
@plural.on("before_run")
async def branch(prompt, ctx):
name = (await ctx.run("git", "branch", "--show-current")).strip()
return f"current branch: {name}"It gets prompt.prompt_id, prompt.text, prompt.author_id and prompt.role, and returns
text or nothing. The text goes to the agent as a note from the plugin right before the prompt,
never into the system prompt, and is kept in the session log, so a restored session gives the
agent the same note. A note that repeats the plugin's latest one is left out, so returning the
same branch every run costs nothing after the first. The room sees a short line saying the
plugin added context. Notes from all plugins together are capped at 8,000 characters; a hook
gets 5 seconds and one that fails or is late adds nothing. It runs when a run starts, not for a
prompt that joins a run already going. Unlike a briefing, a plugin's note does not make the
agent ask before every change: it is the host's own code.
After a run
A run_end hook runs after every run of the agent, in the background:
def setup(plural):
@plural.on("run_end")
async def tests(run, ctx):
if run.reason == "stop" and any(path.endswith(".py") for path in run.files):
output = await ctx.run("pytest", "-q")
await ctx.reply(output.strip().splitlines()[-1])run.reason, run.error | stop, aborted, error or limit, and the error if any |
run.author_id, run.role | who started the run |
run.files | files the agent edited or wrote in this run, relative to the workspace |
run.input_tokens, run.output_tokens | what the run used |
await ctx.reply(text), await ctx.run(*argv) | as in commands |
await ctx.prompt(text, follow_up=True, display=None) | prompts the agent as the host |
A run started by a run_end prompt does not fire run_end again, so hooks cannot keep the agent
going on their own. Such a prompt is the host's, so it is not reviewed and does not taint the
run; do not paste a guest's text into it. A hook has 120 seconds; if it fails the room sees why
and the other hooks still run.
If a handler raises, the room sees /name failed: … and the host keeps running. A file that
fails to import, has no setup, or registers a taken name is listed as failed by /plugins
with the reason. Replies are scrubbed of known secrets like every other event, and both the
call (command.invoked) and the reply (command.replied) are kept in the session log.