Writing a plugin
A plugin is a Python file on the host's machine that adds slash commands, tools and context to a room.
A plugin adds slash commands to a room. It is a Python file on the host's machine; everyone in the room sees its commands in the suggestions and can run the ones the plugin allows.
A plugin runs with the host's permissions
Install only code you trust. Plugins load from the data directory (~/.plural/plugins/),
never from the workspace, so a cloned repository cannot add one.
Create one
The easiest way is to ask the agent: it has a built-in plural-plugins skill with this whole
guide, loads it when you ask for a plugin, and writes the file for you to install.
In the terminal client, as the host:
/plugins new hello creates ~/.plural/plugins/hello.py from a template
/plugins lists plugins, their commands and file paths
/plugins rm hello deletes ~/.plural/plugins/hello.py and unloads it
/plugins reload applies your edits without restarting the hostOpen the file, edit it, run /plugins reload, then /hello in the room.
Write one
A plugin file defines setup(plural) and registers commands with plural.command:
def setup(plural):
@plural.command("hello", description="Say hello")
async def hello(ctx):
await ctx.reply(f"Hello {ctx.author}! You wrote: {ctx.args or 'nothing'}")
@plural.command("review", description="Review the current diff", host_only=True)
async def review(ctx):
diff = await ctx.run("git", "diff")
await ctx.prompt(f"Review these changes:\n{diff}")plural.command(name, description="", usage="", host_only=False) registers an async handler
for /name. Names use a-z, 0-9, - and _, and cannot reuse a built-in command or another
plugin's command. host_only=True refuses the command to everyone but the host. Viewers cannot
run commands.
The handler gets a context:
ctx.args | the text after the command |
ctx.author, ctx.author_id, ctx.role | who ran it |
ctx.workspace | the workspace directory |
await ctx.reply(text) | posts a reply the whole room sees |
await ctx.prompt(text, follow_up=False, display=None) | sends a prompt to the agent as the person who ran the command |
await ctx.show(title, text) | opens a window only the person who ran the command sees; nothing is logged |
await ctx.confirm(title) | asks the person who ran it yes or no; closing or five minutes without an answer is False |
await ctx.select(title, options) | lets them pick one of up to 20 options; None if they close it |
await ctx.input(title, default="") | lets them type a line; None if they close it |
await ctx.notify(text) | a short toast only they see |
await ctx.ask(question, history=True, model="fast") | asks the session's model one question outside the conversation and returns the answer |
await ctx.run(*argv, timeout_s=120) | runs a program in the workspace without a shell and returns its output; a non-zero exit raises |
A side question that should not touch the conversation, answered only to the person who asked:
@plural.command("btw", description="Ask something on the side", usage="/btw <question>")
async def btw(ctx):
answer = await ctx.ask(ctx.args)
await ctx.show("btw", answer)Asking before acting:
@plural.command("deploy", host_only=True)
async def deploy(ctx):
target = await ctx.select("where to?", ["staging", "prod"])
if target is None or not await ctx.confirm(f"deploy to {target}?"):
return
await ctx.notify("deploying")
await ctx.reply(await ctx.run("./deploy.sh", target))A question goes only to the person who ran the command: the terminal opens it under the
composer (esc closes it), the web client puts it first in the inbox. One question is open per
person at a time.
ctx.ask sees the conversation so far, cannot call tools, and adds nothing to the history; its
tokens count as session usage. ctx.prompt goes through the same path as a typed prompt: in
review mode a guest's prompt becomes a proposal, write and exec actions still wait for the
host, and PLURAL_GUEST_RUN_TOKENS applies.
Folding a long prompt
When a plugin wraps a short request in longer instructions, pass the short one as display:
the room sees what is overfitting? marked via /btw, and the full text the agent got stays
folded under it, one click or ctrl+o away. A proposal shows the host the full text.
Tools for the agent
A plugin can also give the agent a tool. The agent calls it like any other tool, and its return value is the tool's result:
def setup(plural):
@plural.tool(
"ticket",
description="Look up a ticket by key: title, status and assignee",
parameters={"key": "Ticket key, for example PLR-123"},
risk="read",
)
async def ticket(args, ctx):
ctx.log("asking the tracker")
return await ctx.run("tracker", "show", args["key"])plural.tool(name, description, parameters=None, risk="read", exposure="deferred"):
name | a-z, 0-9 and _; it cannot reuse a built-in tool or another plugin's tool |
parameters | a mapping of name to description (required strings), or a full JSON schema object |
risk | read, write or exec. Like every tool the call goes through the host's policy: a tool without a rule asks the host, and write and exec in a guest's run always do. Allow one for good with /policy allow ticket * |
exposure | deferred (default): the agent finds it with tool_search. core: declared on every request |
The handler receives the arguments as a dict and a context with ctx.workspace,
ctx.log(text), which streams progress to the room, and await ctx.run(*argv). A string result
goes to the model as is, any other value as JSON. If the handler raises, the model gets the
error as the tool's result.
Keeping state
Module variables reset on /plugins reload. What must last goes in plural.store, a JSON file
of the plugin's own at ~/.plural/plugins/.data/<name>.json:
runs = plural.store.get("runs", 0)
plural.store.set("runs", runs + 1)
plural.store.delete("old")
plural.store.all()Values are anything JSON can hold. Every set and delete writes the file at once, through a
temporary file, and a store holds at most 1 MB. /plugins rm leaves the file, so a plugin added
back finds its data.
A line in the side panel
plural.status(text) keeps a short line for the plugin in the side panel, under the session,
for everyone in the room: ci tests 3/3 ✓. Call it from a command, a tool or a hook; an empty
text removes the line. It is one line of at most 80 characters, the host shows at most one
change a second (the latest wins), and the line goes away when the plugin is removed or fails to
load.