Extension hooks
An event bus that lets extensions watch what stella does, and approve, block, or change sensitive actions before they run.
stella's engine (stella-core) exposes an event bus that extensions and
embedders use to watch everything the agent does. For a specific, listed
set of sensitive actions, they can also intercept it. Every event uses the
same format, and every subscription cleans up after itself.
stella uses "hooks" for two different things. Lifecycle hooks
are shell commands you write in settings.json — no code needed.
Extension hooks (this page) are code you write and register when you
embed stella's engine as a library. Both use the same event names, but
they connect differently.
The event envelope
Every event, whether it's just a notification or a point where you can make
a decision, arrives as a HookEvent:
type HookEvent<TPayload = unknown> = {
id: string; // "evt_<session_id>_<sequence>"
name: string; // dotted, e.g. "file.updated"
timestamp: string; // ISO 8601 UTC, millisecond precision
session_id: string;
turn_id?: string;
agent_id?: string;
sequence: number; // monotonic within a session, from 1
payload: unknown;
};sequence always increases within a session, so sort by it if you need a
strict order across threads. The timestamp has a fixed width, so sorting it
as text gives you time order too.
Two kinds of handler
Observers
Observers watch everything and change nothing. Subscribe with on, and
stella notifies you through emit. An observer cannot block, fail, or
corrupt the action it's watching. If an observer returns an Err, stella
logs it, raises an extension.error event, and skips it — the original
operation continues either way.
Observers run inline on the same thread that emitted the event, so a
slow handler delays the operation it's watching. Move expensive work
off-thread (for example with forward_to) instead of blocking inside the
handler.
Return Err to signal a failure — that's the safe way. A panic is only
caught in debug and test builds. In a release build, where the process is
set to abort on panic, a panicking observer takes down the whole process.
// exact name, namespace wildcard, or "*" for everything
let sub = bus.on("file.*", |event| {
println!("{} touched {}", event.name, event.payload["path"]);
Ok(()) // Err(msg) reports a handled failure — isolated, never fatal
});Matching:
file.readMatches only that exact event.
file.*Matches every event in that group: file.read, file.created,
file.diff.computed, and more.
tool.*Same idea: tool.call.requested, tool.call.completed, and more.
*Every event the host emits.
Policy hooks
Policy hooks gate sensitive actions. Subscribe with on_blocking, and each
hook runs in order, one after another, before the sensitive action
happens. Each hook returns a decision:
type HookDecision =
| { action: "allow" }
| { action: "deny"; reason: string }
| { action: "require_approval"; reason: string }
| { action: "modify"; payload: unknown };Each modify decision updates the payload before the next handler sees it.
The chain stops at the first deny or require_approval. If every
handler only allows or modifies, the final result is allow. A policy
handler that panics fails safely only in debug and test builds. In a
release build, a panic aborts the whole process instead of denying the
action — so always signal refusal by returning deny, never by panicking.
// Require a human for production deploys.
bus.on_blocking("deployment.*", |_event| HookDecision::RequireApproval {
reason: "production deploys need a human".into(),
});
// Redirect any write under /etc to a quarantine path.
bus.on_blocking("file.updated", |event| {
if event.payload["path"].as_str().unwrap_or("").starts_with("etc/") {
let mut payload = event.payload.clone();
payload["path"] = "quarantine/blocked.txt".into();
return HookDecision::Modify { payload };
}
HookDecision::Allow
});Only these events can be intercepted:
tool.call.requested file.created file.updated file.deleted
command.started git.commit.requested git.push.requested
pull_request.requested deployment.requestedEvery gated action logs its outcome as a policy.evaluated event, plus one
of policy.allowed, policy.blocked, or approval.requested.
Payload hygiene
The bus never exposes secrets or full file contents to observers by default:
- The raw payload of a blocking event, which can include a whole file or a shell command, only reaches blocking handlers — never an observer.
- Tool events sent to observers carry sanitized input. Fields that hold
content (
content,new_string,old_string) are replaced with"<omitted: N bytes, M lines>". - Writes are scanned for secret patterns (PEM keys, AWS, GitHub, Slack,
Google, and
sk-tokens). A match raises asecret.detectedevent that names only the kind of secret, never the actual value. Writes to credential-shaped paths (.env*,*.pem,id_rsa, and similar) raisesensitive_operation.detectedwith just the path.
Disposal
on and on_blocking both return a subscription. Dropping it removes the
handler automatically. Call .detach() to keep a handler for the whole
session, or .unsubscribe() to remove it yourself. Removing a handler is
safe to call more than once, and safe even after the bus itself is gone.
let sub = bus.on("*", audit);
// ... later
sub.unsubscribe(); // or: drop(sub); — same effectThe event catalog
stella emits events grouped into these categories. Extensions can also emit their own event names — this list only covers what stella itself emits.
session.*created, started, paused, resumed, cancel_requested, cancelled,
completed, failed.
agent.* / transcript.*agent.turn.started/completed, agent.thinking.*, agent.message.*, agent.error,
transcript.entry.created/updated.
model.*request.started/completed/failed, response.started/delta/completed,
rate_limited, context.compacted.
tool.*registered, call.requested, call.validated, call.started, call.progress,
call.completed, call.failed, call.cancelled.
policy.* / approval.*policy.evaluated/allowed/blocked, approval.requested/granted/denied/expired,
secret.detected, sensitive_operation.detected.
file.* / workspace.* / search.*file.read/created/updated/deleted/renamed/diff.computed, files_touched.updated,
workspace.opened, workspace.index.started/completed,
search.started/completed/failed.
command.* / build.* / test.*command.started/stdout/stderr/completed/failed, build.*, test.*,
diagnostic.detected/resolved.
git.* / deliverygit.status.changed, git.diff.created, git.commit.requested/created,
git.push.requested/completed, pull_request.requested/created,
deployment.requested/completed/failed.
extension.* / telemetry.*extension.loaded, extension.unloaded, extension.error,
extension.quarantined, telemetry.event.queued/flushed/failed.
extension.quarantined is worth knowing about ahead of time. If an observer
repeatedly takes too long to run, stella quarantines it — skips it for
the rest of the session — and raises this event to tell you. Since observers
run on the same thread that triggered them, this is what stops a slow
extension from silently stalling the whole agent. If your handler does real
work, move it off-thread now, rather than finding out through a quarantine
event later.
File events and telemetry
The bus is how you react to file changes as they happen. The same
file-touch telemetry stella records also drives
file.created, file.updated, file.deleted, and files_touched.updated
events. Each one carries the path, the reason, and the line changes, but
never the file's actual contents.
The extension hook bus is the in-process API that stella-core exposes
for embedders. If you just need to run a shell command on a tool call, use
lifecycle hooks instead — no code, no build
required.