Plugins
How stella plugins work — participation levels, the plugin.toml manifest, and how to write one in Python, TypeScript, or Rust without an SDK.
A plugin is a directory with a plugin.toml manifest, and usually a
program. The manifest states exactly how much say the plugin gets in your
turn loop, and what it wants to reach outside it. You read that once, when
you install it, and say yes or no. The manifest is the whole contract.
stella never guesses what a plugin does from its code. You can write one
without knowing Rust or reading stella's own source code.
What a plugin is
A plugin can answer up to four points in your turn loop. Two of them can receive a message from stella. The other two never receive one, in any language:
| Point | Who runs it | Shape |
|---|---|---|
before_turn | the plugin, outside stella | Async. Adds context, narrows scope, names a role, or sends a signal for a later stage. |
after_turn | the plugin, outside stella | Async. Gathers evidence: runs a test, reads a diff, spends a declared model role, and reports the result. |
judge | stella, inside the process | Synchronous. Takes evidence in, returns a verdict. |
again? | stella, inside the process | Synchronous. Takes a verdict in, decides what happens next: another turn with a correction, or stop. |
A plugin can never be sent a judge or again? message. The wire only has
two request/response pairs: before_turn and after_turn. Neither one is a
verdict. A plugin declares its verdict rule
as data in plugin.toml: the requirements it wants met, the flip policy,
and the checks. stella evaluates that rule against the evidence the plugin
reports. judge can't wait on anything, spawn a subprocess, or call a
model — that path doesn't exist for it.
Here's why that matters. If a verification plugin could write its own
judge, it could quietly call a model to decide "done." In the output,
that would look identical to a real, independent check. Keeping judge
inside stella, where a plugin can never reach it, makes it impossible for a
plugin to grade its own work with a model. No language can get around
this — it's built into how the wire works, not a rule someone has to
remember to enforce.
Every capability a plugin needs arrives in the request. A plugin can't reach for anything else: no environment variable beyond what its manifest declared, no working directory, no terminal, no credential. That's what lets the same plugin process run unchanged under the CLI, a headless server, or an app that embeds the loop.
Here's what that looks like in practice. A red gate can't be talked past by the model. It responds with a proposed plan revision that names the cause, the merge stays blocked until the board is green, and nothing runs until you approve it.
Participation levels
[loop].participation sets how much say a plugin gets, on a scale of four
levels. Each level includes everything the levels below it can do. A
plugin can't quietly do more than its declared level allows — stella
rejects anything above it.
noneNo say at all. Just a bundle of skills, commands, agents, or custom tools — never called during a turn. This is the default when a manifest has no [loop] block.
observerCan watch the turn event stream. Can't change anything.
steeringCan act at its declared hook points and at before_turn / after_turn: add context, rewrite tool input, decide permissions, gather evidence. Cannot decide when a turn is done.
arbiterEverything steering can do, plus it controls the Stop gate. At each point where a turn would finish, stella asks the plugin's declared rule for a verdict. If a requirement isn't met, the turn goes back into the loop, up to max_holds times. This is the strongest level.
These rules hold at every level:
- An undeclared hook is never called. If
[loop].hooksdoesn't namePreToolUse, registering a process that listens for it does nothing. The manifest decides what runs, not what the process itself is built to do. - An undeclared wrapper point is never called.
[loop].pointslists every point this plugin answers, out ofbefore_turnandafter_turn. A plugin that only gathers evidence declarespoints = ["after_turn"]. stella never asks it forbefore_turn. - An unknown key always fails to load. Every table in
plugin.tomlrejects fields it doesn't recognize. A typo in a grant fails loudly at install, instead of silently granting nothing.
[[capabilities]] (what a plugin wants to reach outside the turn, such as
a tool, at a named risk level, with a reason a human can read) is kept
separate from participation on purpose. A none-level content bundle that
ships one custom tool running git push is asking for more than an
observer that only watches. If the two were tied together, the widest
grant could hide behind the weakest participation level.
The manifest
Here's a complete, working manifest: an arbiter that holds a turn open until a test command it's given goes from failing to passing.
name = "verify"
description = "Runs the test the candidate grant names and reports the fail->pass flip it observed."
# How much say this plugin has in the loop. Arbiter binds the Stop gate, so a
# turn whose tests did not flip is held open rather than completed.
[loop]
participation = "arbiter"
hooks = ["Stop"] # exhaustive — an undeclared hook is never invoked
points = ["after_turn"] # exhaustive — this plugin has nothing to say before a turn runs
max_holds = 2 # the plugin's ASK; the host clamps it against its own ceiling
# The definition of done, enumerated. A hold cites these names, and the
# install prompt shows this table to a human before any of it is true.
[requirements]
tests-flip = "the test the grant names failed before this turn and passes after it"
tests-pass = "the test the grant names exits zero"
# The plugin's own oracle: how it decides the requirements above, and what it
# reports back. The plugin RUNS this; the host evaluates the RULE against
# what comes back — see "How much a plugin can do" below.
[oracle]
flip = "required" # this oracle's evidence is a fail->pass flip
measurements = ["test-command-exit-code"] # the names of the numbers this plugin reports
[[oracle.checks]]
requirement = "tests-pass"
check = "test-command-exit-code <= 0"
# How the host starts this plugin's process. A list, never a shell string.
# ${plugin_dir} interpolation is the host's job. There is deliberately no
# `language` field — argv already says which interpreter.
[runtime]
argv = ["python3", "${plugin_dir}/main.py"]
timeout_secs = 300
env = ["PATH"] # default-deny: the child sees exactly these names, nothing else
# What the plugin asks to reach outside the turn. A request, never a grant —
# a human reads this and says yes before any of it is true.
[[capabilities]]
tool = "process_spawn"
risk = "high"
purpose = "run the test invocation the request's candidate grant names"
scope = ["the program and args in `candidate.test`, spawned in `candidate.root`"]Here's every block the manifest parser accepts:
[loop]participation sets the level, described above. hooks lists which lifecycle events this plugin acts at: SessionStart, PreToolUse, PostToolUse, Stop, PreCompact. points lists which of before_turn / after_turn it answers. max_holds (arbiter only) sets the most times it can veto completion per turn. If [loop] is missing entirely, the manifest defaults to none: a content bundle with no say in the loop.
[requirements]Arbiter only. A table of name = "human-readable statement" pairs that define what "done" means. An unmet hold cites these keys by name. If you declare [requirements], it can't be empty, and every statement must have text.
[oracle]Arbiter only, and it must also declare points = ["after_turn"] — that's the one response its evidence rides on. flip is either "required" (the plugin reports a fail-to-pass change) or "not-applicable" (the evidence is a measurement, not a change — every requirement must then be decided by a [[oracle.checks]] entry, or stella refuses to load the manifest). measurements names the numbers the plugin's process reports. [[oracle.checks]] states a rule over one of them (<measurement> <op> <integer>) that decides one named requirement. command is optional when [runtime] is declared. Leaving it out means "the oracle is this plugin's own process," which is the common case, and saves you from writing the same argv twice.
[runtime]Observer and above. argv is the program and its arguments, never a shell string. stella replaces ${plugin_dir} with the plugin's install directory. timeout_secs must be at least 1. env is an allowlist: the child process starts with no environment variables at all, and gets only the exact names listed here. There's no language field — ["python3", "${plugin_dir}/main.py"] already says everything stella needs to know.
[wrapper]Steering and above, optional. Declares this plugin as a turn-loop wrapper: an id (the variant name recorded on the run, for A/B comparison), and an ordered [[wrapper.stages]] list. Each stage has a name and an optional if condition over a fixed set of published signals (questions > 0, no-conversational, and others). If a condition names a signal stella doesn't publish, or one that only a later or conditional stage produces, stella refuses to load the manifest rather than run a stage that quietly does nothing.
A stage name can be one of stella's own stages (triage, recall, research, plan, scope, execute, witness, verify, verdict, reflect, contextwrite, complete), or a name your plugin makes up. name = "triage-lite" runs a stage stella has never seen, under the name you gave it, shown in its own color in the statline and transcript. A name you make up must use lowercase letters, digits, and -, start with a letter, be 32 characters or fewer, and can't match the internal spelling of a real stella stage (context_recall, scope_review, context_write, judge, verifier) — that would make your stage look like one of stella's own. A stage you name can react to any published signal, but it can't publish signals of its own.
[roles]Requires [subloop] (steering and above): a declared list of stages stella runs as bounded child turns, separate from [wrapper]. Each [roles.<name>] declares a routing intent for one subloop stage, such as tier = "cheap" — never a model ID, provider, or credential. stella resolves that intent against your configured providers at run time, and refuses any intent the manifest didn't declare.
[[capabilities]]Zero or more entries. tool is free text — the tool name, matched against stella's registry at install. risk is low, medium, high, or destructive — the same scale the authorization gate uses. purpose is required: why the plugin needs it, shown when you're asked to approve it. scope is the plugin's own claimed limit, shown to you word for word and labeled as a claim stella does not check. This is kept separate from [loop] on purpose — see participation levels above.
Package contents
plugin.toml declares what the plugin can do. The rest of its directory
carries three kinds of content, each read by the part of stella that
already understands that format:
<plugin_dir>/tools/*.toml— custom script tools, shown to the model alongside the built-in tools, and run as the plugin, never as you.<plugin_dir>/skills/<slug>/SKILL.md— skills, matched against your prompt and added as context. Never enforced.<plugin_dir>/rules/*.toml— context records, in the same format.stella/rules/uses. Advisory only: a plugin's rule can't block a tool call on its own.
Every file under these directories is named in the install prompt before
any of it lands on your machine. If a tools/ file exists but the manifest
doesn't declare it, or the manifest declares one that doesn't exist,
loading fails.
${plugin_dir} in a package's tools
A shipped tool doesn't know where it'll be installed. So its manifest uses
the same placeholder that [runtime].argv uses, and stella expands it to
the package's own directory, in every command element and every [env]
value:
name = "lint-fix"
description = "Run the package's linter over the workspace and apply what it can fix."
command = ["${plugin_dir}/scripts/lint-fix.sh"]
[env]
RULESET = "${plugin_dir}/rules/strict.yaml"Ship scripts/lint-fix.sh as an executable file. stella runs it directly,
never through a shell, and never invokes an interpreter you didn't name in
command.
stella only expands the placeholder for a manifest that comes from a
package. Your own .stella/tools/ and ~/.stella/tools/ manifests are
left exactly as written, because there's no package directory to expand it
to. Silently turning ${plugin_dir}/x.sh into /x.sh would run the wrong
file instead of failing with a clear error.
Installing a plugin
stella plugin install <dir> [--scope project|user] [--yes]
stella plugin list
stella plugin remove <name>install reads and checks plugin.toml, then prints the whole grant it's
asking for: the participation level, every hook point, the process it runs
as, the exact environment variables it gets, every requirement it can hold
a turn open for, and every capability it wants outside the turn. Nothing
installs until you accept it:
Install `verify`?
Say in your turn loop: arbiter, the strongest grant — everything steering may
do, and it also decides whether your turn is finished
- runs at these hook points: Stop
- may refuse to let a finished turn end, up to 2 times per turn
- holds a turn open until it can say each of these is met:
tests-flip: the test the grant names failed before this turn and passes after it
tests-pass: the test the grant names exits zero
decided by: test-command-exit-code <= 0
- runs as a process on your machine: `python3 ${plugin_dir}/main.py` (killed after 300s)
it inherits these environment variables and no others: PATH
`verify` decides when your turn is done, and it reports its own evidence for
that. Stella does not run the oracle itself and does not check what comes
back: whether a test went fail→pass, and every number a declared check
compares against a budget, are what `verify`'s own process said happened.
The widest thing it asks for is graded HIGH — it reaches outside your
workspace, or costs something a `git checkout` cannot undo.
Every tool call `verify` makes is attributed to it, not to you.
Nothing above is granted until you accept.
Install it? [y/N]stella plugin list shows what's installed, and separately, what stella
would actually call based on that declaration. stella plugin remove <name>
deletes it from every tier that has it. Full flag reference:
stella plugin.
Project trust gate
stella reads plugins from two places as one list: ~/.stella/plugins/
(installed once, visible in every project) and
<workspace>/.stella/plugins/ (this project only). The second one is
something a git clone can bring with it. A plugin is more powerful than a
hook or an MCP server: it declares a [runtime] process that stella runs,
and at the arbiter level, a grant that can hold your turn loop open
indefinitely. So a cloned project's plugins don't load until you trust the
workspace:
export STELLA_TRUST_PROJECT=1Without it, stella skips <workspace>/.stella/plugins/ and prints a notice
on stderr naming what it skipped — the same pattern used for
project-scope hooks and
.stella/mcp.toml. The user tier isn't gated,
because nothing lands there except through stella plugin install's own
consent prompt, which you already approved.
Writing one in Python
A plugin needs no SDK, in any language. The verify plugin above is real
and ready to use — plugins/verify-py/
in stella-examples — and its
main.py
is about 320 lines of Python, using only json, subprocess, sys, and
time from the standard library. If you want to learn the protocol, that
file is the shortest complete example there is.
Each call is one exchange. stella spawns [runtime].argv directly, never
through a shell, writes one JSON request to stdin, closes it, and reads one
JSON response from stdout.
// stdin — an after_turn request
{"point": "after_turn",
"body": {"protocol_version": 1,
"wrapper": "verify-v1",
"round": 0,
"goal": "make the failing test pass",
"candidate": {"handle": "candidate-1",
"root": "/var/folders/.../candidate-1",
"test": {"program": "pytest",
"args": ["-q", "tests/test_flip.py"],
"baseline": "failed"}},
"turn": {"completed": true, "changed_files": ["src/lib.rs"]}}}
// stdout — the response
{"point": "after_turn",
"body": {"protocol_version": 1,
"evidence": {"flip": "achieved",
"measurements": {"test-command-exit-code": 0}}}}- Every table rejects fields it doesn't know, including the envelope itself.
protocol_versionis on every message, and the contract only ever adds fields over time. But a field stella doesn't recognize at a version it accepts is a typo. A message that quietly does nothing is worse than one that fails outright. - There's no way to report an error in the response itself. A plugin that can't answer fails outright: a non-zero exit code, one line on stderr, nothing on stdout. stella then fills in an "unobservable" evidence set for it, so
judgeabstains instead of blaming the plugin for evidence that was never collected. - Every capability arrives in the request. The
candidatefield is a grant: a handle, the workspaceroot, and, when stella has one to give, thetestto run there — a program, its arguments, and what that same test reported before the turn (baseline). A wrapper never reaches for a terminal, a git checkout, a credential, an environment variable, or a working directory of its own. - A plugin can't report a tamper finding. The response type has no field for one, in any language. Checking witness-artifact identity is stella's job, and stella adds its own finding before
judgeever runs. A plugin vouching for its own witness is exactly what this split prevents.
Here's the whole shape, condensed to what main.py actually does:
import json, subprocess, sys, time
PROTOCOL_VERSION = 1
def main():
envelope = json.loads(sys.stdin.read())
assert envelope["point"] == "after_turn"
body = envelope["body"]
assert body["protocol_version"] == PROTOCOL_VERSION
candidate = body.get("candidate")
plan = candidate.get("test") if candidate else None
if plan is None:
evidence = {"flip": "unobservable"} # nothing to run — say so, don't guess
else:
started = time.monotonic()
result = subprocess.run(
[plan["program"], *plan.get("args", [])],
cwd=candidate["root"], timeout=240,
)
exit_code = result.returncode if result.returncode >= 0 else 1
flip = (
"achieved" if plan["baseline"] == "failed" and exit_code == 0 else
"not-achieved" if plan["baseline"] in ("failed", "passed") else
"unobservable"
)
evidence = {
"flip": flip,
"measurements": {"test-command-exit-code": exit_code},
}
response = {"point": "after_turn",
"body": {"protocol_version": PROTOCOL_VERSION, "evidence": evidence}}
sys.stdout.write(json.dumps(response) + "\n")
if __name__ == "__main__":
main()The real main.py is stricter than this: it rejects unknown fields at
every level, tells a timed-out test apart from a signal-killed one, and
never guesses at a command it wasn't given. But the shape above is the
whole protocol. Read the real file
and its README
for the full version, including how to run it by hand with
echo | python3 main.py, and how its tests check it against the same
reference cases the Rust and TypeScript versions use.
Install it the same way as any plugin:
stella plugin install plugins/verify-py # this workspace
stella plugin install plugins/verify-py --scope user # every workspaceTypeScript and Rust
The same plugin also ships in TypeScript and Rust in stella-examples, to
prove the wire itself, not an SDK, is what makes a plugin work. All three
manifests come from one template and differ only in the [runtime].argv
line naming the program. A script called check-manifests-identical.py
checks this stays true.
verify-tsplugins/verify-ts/. No runtime dependencies at all, not even @types/node. The small part of Node it uses is declared by hand in a 14-line .d.ts file. npm run build compiles src/main.ts to dist/main.js, and [runtime].argv is ["node", "${plugin_dir}/dist/main.js"]. Build it before you install it — stella never runs a compiler for you.
verify-rsplugins/verify-rs/. The reference version, and the one tested over the actual wire protocol rather than in-process, so that path stays exercised. It depends only on serde and serde_json, not on any of stella's own code — a third-party Rust author couldn't link against stella's internals either. cargo build --release produces the binary that [runtime].argv names directly, the simplest thing stella can run.
What plugins can do
Read this before installing a verification plugin, and compare it with what the install prompt tells you. This is how the system is built, not an excuse.
Verification is delivered by an installed plugin. A plugin's [oracle]
runs in the plugin's own process. stella doesn't run that oracle itself,
and it doesn't double-check the result: the fail-to-pass change, and every
number a check compares against a limit, come from what the plugin's own
process reports. stella applies the declared rule ([requirements], the
flip policy, the checks) to those reported claims, and won't credit a
requirement the plugin leaves undecided. What stella can't do is tell a
result the plugin actually earned from one it just typed in.
These stay under stella's own control, no matter what a manifest declares:
judgestill runs inside stella, with no I/O, over the evidence the plugin reported. A plugin can't decide "done" for itself, in any language — see What a plugin is above. What changes is where the evidence comes from, not who decides.- The tamper check is always stella's, never the plugin's. Checking witness-artifact identity happens inside stella, and a plugin's response has no field for a tamper finding, in any language. A plugin can't vouch for its own witness even if it tried.
Installing a verification plugin means trusting it to report what it actually saw. That's the same trust you already place in a third-party test runner or a CI check you didn't write yourself.
What's not built yet
A plugin doesn't run during a real turn yet. stella plugin install, list, and remove are complete: they read and check manifests, show you the full consent text, write to .stella/plugins/ or ~/.stella/plugins/, and, for list, show you the hook and wrapper dispatches that would happen. But nothing in a real stella run or Command Deck session reads that installed list yet. No PreToolUse, Stop, or other hook process gets started, and no before_turn or after_turn request gets sent, during an actual turn. The wrapper system itself — the wire contract, the transports, judge, and again? — is built and tested on its own. The three example plugins are checked against a shared test harness that replays the wire protocol directly, not against a live session. You can check this yourself: today, a plugin manifest only changes what stella plugin list prints, nothing else.
Smaller gaps in the wire contract itself, worth knowing before you rely on it:
unobservablecovers several different problems with one value. A root that doesn't resolve, a program that isn't there, a run that went over its time budget, and a baseline that never checked anything in the first place all report the sameFlipObservation::Unobservable. stella can't currently tell them apart.- A
TestPlandoesn't name an environment or a timeout. The grant says which program to run and where, but not what environment it ran in or how long it waited. So "the same invocation" means the same command and arguments, not provably the same run. Each plugin author has to pick their own timeout. - Checking the version is up to the reader, not the wire format. A message claiming
protocol_version: 2still decodes as a valid request. stella, or a careful plugin, has to check the number itself — decoding won't fail on its own. - A plugin can't mark a measurement as "left out on purpose." A number the plugin's oracle didn't compute this run, and a number the author simply forgot to report, both show up the same way: missing.
See also
stella plugin— the full command reference forinstall/list/remove, including scopes and shadowing.- Hooks — the config-driven, no-code lifecycle hooks a plugin's own hook grants are not merged into.
- Permissions — the authorization gate that sees a plugin as a caller of its own, distinct from you.
stella-examples— all three reference plugins, their shared conformance vectors, and the CI that runs them on every pull request.
Agent Engine in Your App
Run stella's agent loop inside your own product. The engine handles orchestration; your app owns every model call, tool call, and credential. Includes a complete working example.
Agent Engine Paths
Every way to reach stella's agent engine — one-shot runs, the REPL, the Command Deck, goal rounds, CI monitoring, sub-agents, and fleets — and when to use each.