Raw capture โ raw/pi-observers.md
url: https://github.com/erans/pi-observers date_fetched: 2026-08-07
File-defined observer agents for pi. Observers watch one axis of quality each, propose at most a short advisory, and a reconciler decides what reaches the main agent. They are read-only and never answer on the agent's behalf.
The pattern is inspired by the persistent background agents of Meta's Muse Code (Meta's own docs are login-gated and name no observer roster; the four bundled here are this project's design). They ship as worked examples -- plain files in the same format any observer uses.
pi install npm:pi-observers
Published as pi-observers on npm;
requires pi >= 0.83. The four bundled observers load immediately -- /observers in
any pi session shows them. To remove:
pi uninstall npm:pi-observers
For development, clone erans/pi-observers and load the extension straight from the checkout:
git clone https://github.com/erans/pi-observers.git
cd pi-observers && npm install
pi -e ./src/index.ts
| Observer | Watches | Default |
|---|---|---|
memory-recall |
a relevant note in .pi/memory/ |
on |
skill-recall |
a skill the task should load first | on |
goal-tracker |
whether a declared goal is actually met (may veto) | on |
verification |
whether claimed work matches the tool record | off |
Drop a markdown file in .pi/observers/. Definitions are read from three directories,
lowest precedence first:
| Layer | Directory | Loaded |
|---|---|---|
| bundled | this package's observers/ |
always |
| user | <agent dir>/observers/ -- ~/.pi/agent/observers/ unless you moved it |
always |
| project | .pi/observers/ in the working directory |
only when the project is trusted |
A later layer beats an earlier one, so a project definition wins over a user one, which
wins over a bundled one. Precedence keys on the name: field, not the filename --
to replace the shipped goal-tracker, give any file you like name: goal-tracker and
yours is used in its place, entirely: prompt, trigger, model, permissions. Overriding
one observer never disturbs the others.
Layers are additive, not exclusive. A bundled observer you have not overridden still
loads -- there is no "use only mine" switch. To run only your own, name the ones you do
not want in disable (see Settings).
See Trust for why the project layer is gated and what you see when it is skipped.
---
name: my-observer
description: What single axis this watches
on: turn_end # before_agent_start | turn_end | tool_execution_end | agent_settled
sees: [last_user_message] # last_assistant_message | tool_calls_this_turn | transcript | skills
tools: [read, grep] # read-only only: read, grep, find, ls
can: [advise] # advise, veto
deliver: next_prompt # next_prompt | next_turn (both: steer) | settle (follow-up)
model: anthropic/claude-haiku-4-5
fallback: [openai-codex/gpt-5.5]
priority: 50
---
Your system prompt. Call `propose` once, or nothing at all.
Other frontmatter fields, all optional: enabled (default true), thinking
(off | minimal | low | medium | high | xhigh | max, default low),
max_advisory_chars (default 300), and timeout_ms (default 20000).
Observers cannot write. Anything an observer needs beyond its sees: slices, it
fetches with read/grep.
on: and deliver:Observers never block a turn, and their proposals are delivered the moment they are
ready through pi's own message queues -- there is no waiting for a fixed drain point.
deliver: chooses which queue an advisory rides:
deliver: |
pi queue | Arrives |
|---|---|---|
next_prompt, next_turn |
steering | before the run's next model call; appended to the session immediately when idle |
settle |
follow-up | once the agent has no more tool calls -- after the work, before the run closes |
The two steering values are aliases: the distinction between them belonged to an older
drain-point design and has no arrival-driven analogue. Use next_prompt for guidance
the agent should see as soon as possible, and settle for commentary on finished work.
A veto always rides the follow-up queue with a turn trigger, whatever deliver: says:
formed mid-run, it holds the run open -- pi will not settle a run past a queued
follow-up -- and formed after the run has settled, it reopens it. At most one veto is
delivered per turn; further vetoes in the same turn are dropped without spending any
budget, and the goal still being unmet next turn is what re-raises them.
Pick on: for what the observer needs to see, not for when its advice can land:
before_agent_start is the only trigger that sees the request about to run.turn_end fires once per model round-trip, so a run doing real work kicks the
observer several times while there is still run left for the advice to land in.agent_settled is the only trigger that sees the agent's final message.tool_calls_this_turn accumulates over a whole agent run (your request through the
agent's final answer), not one model round-trip, so an observer reading it at
turn_end sees everything the agent has done so far in that run. It is reset when you
send a new request. It is not reset when an accepted veto reopens a turn: that path
resumes the existing run rather than starting a new one, so the redo's tool calls are
appended to the ones that preceded the veto. An observer judging a post-veto redo sees
the whole run including the vetoed attempt, which is usually what you want and is worth
knowing if you write a prompt that counts calls.
Three limitations of the older fixed-drain design are gone or reduced:
goal-tracker veto joins the run it judges. A veto formed while the agent is
still working is queued as a follow-up, and pi holds the run open for it -- the agent
addresses the unmet goal inside the same run instead of being sent back after it
thought it had finished. Only on a single-round-trip answer does the veto form after
the settle; it then reopens the turn, which is latency, not incorrectness: the goal
is still unmet when it lands.skill-recall reaches the request it read. Its run starts at
before_agent_start and finishes a few seconds in; the suggestion is steered into
the run and precedes the next model call. On a single-round-trip answer the run is
over before the suggestion is ready, and it is appended to the session for the next
request instead -- the old behaviour, now the fallback rather than the rule.verification's report on the run that just finished is on screen moments
after the settle.What remains, and why:
pi -p, or if you
quit the instant the answer lands, shutdown aborts observer runs still in flight and
their verdicts are never formed. Catching them would mean holding shutdown open on a
model call, which the non-blocking design refuses.Delivery is capped at maxAdvisoriesPerTurn per turn (the counter resets at each new
request and each settle). Advice over the cap, and advisories that shared a flush with
a veto, are deferred to a later flush rather than dropped -- oldest first, released
into whatever budget the next turn has. The deferral is bounded at 100 proposals with
the oldest evicted first; it does not survive a /reload. When an advisory is evicted
the observer's /observers row counts it as dropped, with the reason, and the
observer is free to raise the same point again -- it is not silently recorded as
delivered.
| Command | Effect |
|---|---|
/observers |
Status: resolved model, runs, failures, accepted/dropped counts, and why any observer is silent |
/observers enable\|disable <name> |
Toggle for this session |
/goal <text> |
Declare the goal goal-tracker enforces (empty clears) |
/remember <text> |
Write a note to .pi/memory/ |
{
"observers": {
"enabled": true,
"maxAdvisoriesPerTurn": 2,
"vetoBudget": 3,
"defaultModel": "anthropic/claude-haiku-4-5",
"disable": ["verification"]
}
}
maxAdvisoriesPerTurn and vetoBudget are both capped at 10 however high you set them.
Every observer is resolved through the same chain, in order, stopping at the first hit:
model:, or defaultModel if it declares nonefallback:, in order/observersSteps 1 and 2 each try an exact match, then a fuzzy one, then the same id under any
provider. So you do not have to set defaultModel at all: leave it out and every
observer runs on your session model. Setting it is worth doing anyway -- observers are
small, frequent, throwaway calls, and pointing them at something cheap and fast keeps
them off your main model's bill.
A model is only considered for steps 1 and 2 if its provider has configured auth. That is what makes the chain useful: naming a model you have no credentials for falls through to your fallbacks and then to the session model, rather than resolving to something that cannot run.
The gap worth knowing: auth is not the same as working. A model that is in pi's
catalog, with auth configured, but which the provider refuses at call time -- a
retired id, a model your account is not entitled to -- resolves successfully. The
observer is then reported active on that model, every run fails, and it is disabled
after three consecutive failures with the provider's error in /observers. Nothing
can predict that without making the call; the three-strike disable is the backstop,
and it is why /observers reports the last error rather than only a count.
vetoBudget is per observer per fingerprint -- the string the observer uses to
identify the thing it is objecting to. It is not a bound on its own, because the
fingerprint is chosen by the observer's model: vary it and you get a fresh budget. Two
ceilings derived from vetoBudget are the actual bound, and neither is separately
configurable:
| Ceiling | Value | Default | Max |
|---|---|---|---|
| Per observer, any fingerprint | vetoBudget * 2 |
6 | 20 |
| Session-wide, all observers | vetoBudget * 4 |
12 | 40 |
They are derived rather than exposed because the only reason anyone raises a backstop is
to get past it, and deriving them keeps the cap of 10 on vetoBudget hard-capping them
too. Both survive a /reload. When one stops a veto, /observers shows it as a dropped
proposal with the ceiling named in the reason.
Observer definitions are loaded from three places: the bundled observers/ directory,
<agent dir>/observers/, and the project's own .pi/observers/. The project layer is
loaded only when the project is trusted.
A definition is not configuration that this extension renders -- it is an agent that
runs, on your credentials, at a trigger the file chooses, reading whatever the process
can read. Precedence keys on the name field, so an untrusted project file could
otherwise replace a shipped observer outright rather than merely adding one.
When the layer is skipped and .pi/observers/ contains at least one .md file, you get
a warning at session start and a not loaded: line in /observers naming the directory
and the reason. An empty .pi/observers/, or one holding no definitions, says nothing --
there is nothing that would have loaded. The same two surfaces report a definition that
failed to parse, or a directory that could not be read.
See docs/superpowers/specs/2026-08-05-pi-observers-design.md.