Skip to Content
Plural
Plugins

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.argsthe tool, read/write/exec, what it acts on, and its arguments
call.author_id, call.rolewho 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 nothingthe 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.errorstop, aborted, error or limit, and the error if any
run.author_id, run.rolewho started the run
run.filesfiles the agent edited or wrote in this run, relative to the workspace
run.input_tokens, run.output_tokenswhat 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.

On this page