pi
Use this guide to observe local pi sessions and to gate pi tool calls and model calls on a NeMo Relay policy. The pi integration is a proof of concept. Tool and turn activity reach Relay when two things hold: the gateway is reachable, and no other pi extension preempts the hook first. The extension defaults to failing open, so an unavailable gateway lets pi carry on with that activity unrecorded; and pi runs every extension’s handler for a hook, so an extension registered ahead of this one can decide a tool call before Relay’s gate ever sees it.
Model traffic reaches Relay when the gateway forwards to the endpoint the selected model would otherwise call, which is what Model Redirection explains.
pi has no native hook-configuration file, and its external event stream is
observation-only, so hook calls cannot be injected from outside the process. They
originate inside a pi extension, which Relay ships in this repository. The
extension is a thin HTTP client: it forwards pi’s lifecycle to POST /hooks/pi
and gates tool calls on the gateway’s verdict. All policy and all span
construction stay in the gateway.
pi ships breaking changes through minor releases and has no major-release
channel. The integration is verified against pi v0.84.0. Re-verify hook
signatures after a pi upgrade; a silent shape change appears as missing spans,
not as an error.
Requirements
Install pi 0.84.x, and confirm the NeMo Relay extension is reachable. Anything
below 0.84.0 is rejected; anything above 0.84.x is accepted but reported as
unverified, because pi can change a hook shape in a minor release and the
symptom is missing spans rather than an error. doctor reports it in its output;
the launcher records it as a warning-level log line, so nemo-relay pi shows it
only at --log-level warn or lower:
That command reports two things nothing else can: which path the extension will load from and whether pi will trust it, and whether the gateway is answering at the URL the extension posts to. Run it first whenever Relay does not seem to be doing anything.
Install The Extension
Install it once. The install is persistent: pi loads the extension on every
later run, and both nemo-relay pi and nemo-relay run --agent pi find it
without any variable being set.
This writes the extension into ~/.pi/agent/extensions/nemo-relay, which pi
auto-discovers. The nemo-relay binary carries the extension, so no repository
checkout is needed. pi does not have to be on PATH either — the install writes
a directory rather than driving pi.
You may not need to run it explicitly. When nemo-relay pi or nemo-relay config runs the first-time setup wizard and pi is among the selected agents,
setup offers this install if pi has no copy of the extension. The wizard only
runs when no Relay configuration exists yet, so on a machine already configured
for another agent, run the command above.
pi has no plugin marketplace — no marketplace manifest, no pi plugin verb, and
no MCP client for a plugin-owned server to serve — so this is not a marketplace
install like the Claude Code and Codex ones. --install-dir therefore does not
apply: pi decides where its extensions live. Set PI_CODING_AGENT_DIR to move
pi’s whole configuration.
Remove it with:
Uninstall removes only the files the install recorded, and only while they still match what was written. A file you edited is kept, and so is the directory holding it.
Install It Yourself Instead
Two manual routes, both untrusted-project-proof, if you would rather manage the extension yourself:
Both copy from crates/cli/assets/pi-extension, which is where the extension’s
source lives and the only self-contained copy of it. integrations/pi holds the
extension’s README, TypeScript configuration and test suite, and reaches the
source through symlinks that a copy may or may not follow.
Relay resolves either route too — or NEMO_RELAY_PI_EXTENSION, if you set it —
and passes what it finds to -e, which loads first and is never trust-gated. It
needs one of them: it never promotes a project-scoped install to -e.
Do not install a second copy. pi de-duplicates its extension set by path
rather than by package, so a copy in one place and a copy in another are two
packages: both register hooks, every turn, tool and inline-shell event is
reported twice, each turn closes as superseded by its own duplicate, and the
inline-shell gate decides one command twice. nemo-relay install pi refuses
when another copy would load beside it, and the launcher refuses to start. Keep
one copy.
Project scope is the exception, and the two commands treat it differently.
nemo-relay install pi refuses when the current directory holds a project-scoped
copy, because installing would be the act that creates the second one. The
launcher does not refuse over it — it launches and prints a note — because a
project copy loads only in a trusted project, and refusing would block every
launch in an untrusted one over a copy that will not run.
Both checks read only the current directory, nemo-relay doctor pi included. Run
from outside the project, none of the three sees the copy.
Because the manual routes look exactly like the managed one, nemo-relay install pi never overwrites a directory it did not write, --force included, and
nemo-relay uninstall pi never removes one. Every managed install records what
it wrote, and anything without that record is left alone — whether it is a
package with its own package.json, a bare index.ts (which pi loads too), or
leftovers pi would not load at all. The recovery in every case is to remove the
directory yourself.
Do not install from a git URL. pi has no subdirectory syntax for a git
source: it clones the repository root and looks there for a pi manifest key or
a top-level extensions/ directory. This extension lives further down the tree,
so the install reports success and loads nothing — while the clone’s root
skills/ directory is picked up, giving you NeMo Relay’s skills in pi and none
of the gating.
The extension is not published to npm, deliberately: nemo-relay install pi
and the two manual routes already cover user scope, and publishing would add a
namespace, a build step and release wiring for one more route to the same files.
Do not install into .pi/extensions/ or with pi install --local. pi adds
project-scoped extensions to its candidate set only when the project is trusted,
and -p, --mode json and --mode rpc never prompt for trust. Under the
default policy the extension is dropped by a bare conditional — not an error
path — so pi does not treat it as a failure and never reports it, and the
extension cannot report it either, because it is not running. The only symptom
is that Relay appears to do nothing. nemo-relay doctor pi warns about it.
Transparent Run
Use the wrapper for local observability that leaves your pi configuration untouched. It is not install-free: the extension has to be present already, because a hook can only originate inside it, so run the install above first. On a clean machine the first-time setup wizard offers that install; once a Relay configuration exists the wizard no longer runs, and this command fails instead and names the routes that fix it.
Pass pi arguments after --:
This shortcut is equivalent to nemo-relay run --agent pi. The wrapper starts a
gateway on a dynamic 127.0.0.1 port, loads the NeMo Relay extension with
pi -e <path>, and passes the gateway URL to it through
NEMO_RELAY_PI_GATEWAY_URL. pi -e is the reliable loader here: it is
trust-ungated, loads before extension discovery, and survives --no-extensions,
so a launched session is instrumented regardless of the user’s own pi
configuration.
The launcher resolves the extension from the same places nemo-relay doctor pi
looks — NEMO_RELAY_PI_EXTENSION first, then a user-scope install — so any
install route above is enough and no variable is required. That includes the one
nemo-relay install pi writes: pi extensions live in the user’s own
configuration directories, so Relay writes into one instead of managing a plugin
root of its own as it does for Claude Code and Codex.
A project-scoped install is deliberately never used. -e is not trust-gated,
so passing one would load repository code pi itself declined to trust. Launch
fails instead, and names the install routes that are not gated.
The launcher also passes this invocation’s proxy credential through
NEMO_RELAY_PROXY_CREDENTIAL, and the extension sends it as
x-nemo-relay-proxy-token on a redirected provider. A gateway started by run
authenticates its own client before any intercept can rewrite the route, so
without it a redirect would succeed and every model call would come back 401. A
standalone nemo-relay --bind daemon sets no credential and requires none.
Inspect what would be launched without starting pi:
Standalone Gateway
Run a long-lived gateway and point pi at it by hand:
Run headless pi with < /dev/null. pi drains piped standard input before the
session starts and returns early only for a TTY, so under an automated harness
it can block indefinitely before any hook fires.
Captured Events
The extension posts 15 event types, covering session, turn, tool and
inline-shell activity. Twelve carry a pi hook’s own name. The other three —
model_redirect, tool_arguments_transformed and user_bash_end — are
synthesized by the extension. The table below names where each one comes from.
pi reports both ends of a turn, so Relay opens the turn scope at pi’s own
turn_start instead of inferring one, and a mark that arrives between turns is
recorded on the session scope rather than opening an empty turn to hold it.
For pi’s structured tool results, the extension joins text parts in order and
omits image and other non-text parts. Tool-result text is truncated at 2000
characters before the extension forwards it. The overflow is replaced by a
... [truncated N chars] suffix. A guardrail or subscriber that reads a tool
result sees this bounded text projection, so do not write policy that depends on
the tail of a long result or on non-text content.
pi’s tool_execution_start is deliberately not forwarded: it fires before
argument validation and also for calls that never execute. tool_result is not
forwarded either, because it does not fire at all for blocked calls and in the
parallel path it fires before tool_execution_end.
A blocked call still produces a well-formed tool span. The guardrail rejects
before the span opens, but pi fires tool_execution_end for blocked calls too,
and the gateway synthesizes the missing pair, tagged
tool_correlation_status: "agent_fallback".
Tool Gating
For model-invoked tools, tool_call is the only pre-execution decision point
that sees arguments: pi applies --tools, --exclude-tools, --no-tools, and
the runtime setActiveTools at tool-registry construction, never per call. The
user’s own inline shell takes a different path and is gated separately. See
Inline Shell Gating.
A guardrail rejection surfaces as HTTP 403 with
error.type = "nemo_relay_guardrail_rejected" and the guardrail’s own words in
error.reason. The extension turns that into pi’s {block, reason}.
pi passes the block reason to the model verbatim, with no framing. Write guardrail reasons as guidance rather than as error codes: a reason that names a safe alternative produces a model that adapts, and a bare denial produces one that gives up.
The default is fail-open, so an unreachable gateway does not brick the agent.
NEMO_RELAY_PI_FAIL=closed opts in to blocking, and the block reason then says
explicitly that it is an infrastructure fault rather than a judgment about the
request — and which fault, because the four are debugged in four different
places: nothing answered, nothing answered in time (the gateway may be up and
slow), the gateway answered without a decision (a rejected payload, an unreadable
body, a 403 with no guardrail marker), or the gate itself failed before the
gateway was asked.
Argument Transforms
A Relay request intercept can rewrite a tool call’s arguments. The gateway never
executes the tool, so the rewrite travels back in the allow response and the
extension applies it to pi’s event.input in place.
tool_call_id is echoed so the extension can prove the rewrite belongs to the
call it just posted. It applies the transform only on an exact string match. A
body that carries input but names a different call, or no call at all, is
refused — and a refused transform blocks, exactly as a shape violation does,
because the alternative is running arguments the policy never approved.
The rewrite is constrained, not validated. pi validates tool arguments
before the hook and never re-validates. The extension could read the tool’s
schema — pi.getAllTools() exposes it for built-ins too — but deliberately does
not: pi’s tool set is per-session mutable, so a schema read once can go stale
mid-session. It enforces a shape invariant instead: a transform
may rewrite the values of existing keys, preserving each value’s JSON type. It
may not add or remove keys, change a type, or change an array’s length.
That keeps the required keys and types intact, but it is not schema validation —
pattern, enum and range constraints are not checked. A transform that
rewrites a value to one the schema would reject will still execute.
Conditional-execution guardrails decide on the arguments pi proposed, before the rewrite, and are not re-run on the result — the same order a managed tool call uses. A request intercept can therefore rewrite a value a guardrail would have refused. Put the decision in the guardrail, not in a transform that outruns it.
A transform that violates the invariant blocks the call, with a reason stating that a policy could not be applied rather than that the request was refused. Running the original arguments would silently discard the policy.
The shipped examples/rust-native-plugin demonstrates the mechanism rather than
a policy: its tool request intercept inserts plugin_tag and plugin_tool into
the arguments, which adds keys and so blocks every pi tool call. Those two names
are what you will see in the refusal reason. Enable it to see the refusal, not as
a template to copy.
Inline Shell Gating
pi’s bang prefix runs a command outside the tool registry: !git status runs it
and shows the model the output, and !!git status runs it and keeps the output
out of the model’s context. Neither fires tool_call, so tool gating does not
cover them. Both reach pi’s user_bash hook, which the extension gates through
the same guardrail chain by posting the command as a tool span start.
The tool name is user_bash, not bash. A guardrail receives only the tool name
and the arguments, so a policy can distinguish a command the user typed from one
the model proposed only if the two arrive under different names. A policy that
must cover both has to name both — a rule written for bash alone does not
gate the bang prefix.
The posted arguments are command, cwd, and exclude_from_context, the last
of which is true for the !! form.
pi’s user_bash hook has no block-and-reason contract, so a refusal is a
synthetic failed command result that pi records as though the command had run:
NEMO_RELAY_PI_FAIL governs this path as well. A rewritten command is refused
rather than run: pi’s user_bash result can replace the result or the execution
backend, but never the command itself.
Two limits are worth planning around. The gate records the decision, not the
command — pi reports no completion for inline shell, so the span closes as soon
as the verdict arrives and measures the policy round trip. And the hook fires
only in the interactive TUI and in RPC mode; headless -p has no input loop to
type a bang prefix into.
Model Redirection
pi has no base-URL flag and no generic environment override — it resolves a base
URL per model from a generated catalog — so the extension points the active
model’s provider at the gateway directly, with
registerProvider(provider, { baseUrl }). pi rewrites the URL of every existing
model for that provider and keeps their API, headers, costs and context windows.
The gateway forwards to one statically configured upstream per API family, set by
--openai-base-url and --anthropic-base-url. Redirecting a model whose
endpoint is not that upstream would send the request to the wrong provider —
which does not produce a trace without spans, it breaks the session.
Under nemo-relay pi or nemo-relay run --agent pi, the extension names the
endpoint instead, in an x-nemo-relay-upstream-base-url header on each
request, so the gateway forwards to a provider it was never configured for. The
gateway honors that header only from a request carrying this invocation’s proxy
credential, which the launcher mints per run and gives only to the process it
starts, and strips it before forwarding.
A named endpoint receives only the credential the request already carried.
Naming a destination does not obtain one: the gateway never attaches its own
configured Authorization value, OPENAI_API_KEY or ANTHROPIC_API_KEY to an
endpoint the client chose — those belong to the upstream it was configured for.
pi sends the provider’s own key for the provider it named, and that key travels;
nothing else does.
A named endpoint must use https, unless it is loopback. Because that
credential travels to it, plain http to anything reachable from off the machine
is refused. A local model server — http://127.0.0.1:11434/v1 and the like — is
allowed, since its traffic never reaches a network.
A named endpoint that cannot be used fails the request, with 400 and a
reason, rather than falling back to the gateway’s configured upstream. The
request was addressed to the endpoint it named, and quietly sending it somewhere
else would deliver the prompt and the provider key to a different provider.
Redirects from a named endpoint are not followed. The checks above apply to
the URL that was named; a redirect names a different one. A 3xx is returned to
pi as it is, so a provider that redirects to a canonical host has to be named at
that host instead.
A standalone nemo-relay --bind daemon issues no credential, so it ignores
the header and keeps the static behavior below. Point it at a provider with
--openai-base-url / --anthropic-base-url, or launch through the wrapper.
registerProvider rewrites every model of the provider, not only the selected
one, so the check is applied to the whole provider either way. A provider that
mixes endpoints — Fireworks serves anthropic-messages at /inference and
openai-completions at /inference/v1 — is left alone even when the selected
model matches, because redirecting it would move its siblings to an endpoint that
has never heard of them.
The way through is configuration, not naming. Point --openai-base-url and
--anthropic-base-url at that provider’s respective endpoints, and every model
then already targets the gateway’s upstream for its own family, so the whole
provider redirects. That works on a launched session and a standalone daemon
alike. Naming cannot substitute for it: one endpoint is named per provider, so a
provider spanning two has no single value to send. It still fails if a sibling
speaks an API the gateway has no route for at all.
Each of these outcomes is recorded as a model_redirect mark on the session
scope, so a trace with no LLM spans states its own reason. The decision is
re-made on each model switch; the two skips that explain nothing — no model
resolved yet, and a provider already redirected — are evaluated but not marked,
because one per session_start is noise in every trace.
To capture a specific provider, point the gateway at it:
32 of pi’s 39 built-in providers speak an API the gateway serves. The seven that do not are Amazon Bedrock, Azure OpenAI Responses, Google, Google Vertex, Mistral, OpenAI Codex, and Radius.
The denominator is builtinProviders(), not the 38 catalog files in
providers/data/. Radius is a purely dynamic provider with no static catalog
entry, so counting files loses it.
Point pi at the gateway root, not the root plus /v1. The Anthropic SDK
appends /v1/messages itself, while the OpenAI SDK appends /chat/completions;
the gateway serves both shapes from the root.
Agent-Run Attribution
One pi prompt can re-enter the agent run several times — provider retry,
post-compaction recovery, or a queued follow-up — and pi’s own turnIndex
resets to 0 on each re-entry, so turn indices collide within one prompt.
Relay’s session model is flat (session → turn → tool) and is shared with Codex and Claude Code, so it gains no attempt level for pi. Re-entry is therefore not nested: two attempts of one prompt appear as more turns under one session. To keep them distinguishable, the extension sends two counters on every attributable hook, and the gateway promotes both into event metadata:
attempt_index— which agent-run attempt this event belongs to.turn_seq— a session-monotonic turn counter, where pi’sturn_indexresets.
Read them from metadata on turn scopes and tool spans, and from either
metadata or the mark’s data on marks. The turn scope’s own turn_index is
assigned by Relay and is monotonic; pi’s colliding value stays in the payload.
turn_seq can repeat within one session. The counters live in the extension
runtime, and pi rebuilds that runtime on /reload while the session id stays
the same, so the counter restarts at 0. It orders turns within a runtime, not
strictly within a session.
Subagents are not represented. pi has no nested-agent hook of its own, and a child pi process running this extension posts under its own session id, so it appears as an unrelated session rather than as a subagent.
Smoke Test
Check hook forwarding directly. A 200 is an allow; a 403 with the guardrail marker is a block:
Verify Export
Complete a pi turn, then confirm the exporter output. With an ATOF file sink
configured in plugins.toml, a single-tool turn produces a pi session scope,
one pi-turn scope with turn_source: turn_start, and the tool span nested
under it.
If turn scopes are missing entirely, the extension is not loading. pi 0.84 prints
the load failure on standard error and exits before the session starts, so read
stderr first — the failure is reported, not silent. Running pi with -e pointing
at the entry point directly isolates it.
Troubleshoot LLM Lifecycle
Look for the model_redirect mark on the session scope: it names the outcome and
the reason.
upstream-mismatch and unknown-upstream appear only on a standalone daemon,
which cannot be told where to forward. Launch through nemo-relay pi and the
endpoint is named for you; otherwise point the gateway at the provider with
--openai-base-url or --anthropic-base-url, or set
NEMO_RELAY_PI_REDIRECT=force if you know the gateway is already correct.
unserviceable-api means the model’s provider speaks an API the gateway has no
route for, so pick another model. provider-mixed-endpoints names the sibling
model that blocked it — its provider’s models do not share one endpoint, and a
provider is redirected as a whole; select a model elsewhere.
Tool and turn activity are unaffected by any of these.
Limitations
What The Gate Is Authoritative Over
pi runs every tool_call handler unless one returns block, and all of them
share the same mutable input object with no re-validation before execution.
Loading with -e puts this gate first, which prevents an earlier extension
pre-empting it — but an extension loaded after it can rewrite arguments Relay has
already authorized, and those execute unreviewed.
pi exposes no ordering API and no post-chain hook, so this cannot be prevented from inside an extension. In a mixed extension stack, the tool gate is authoritative over the model, not over the other extensions.
Tool-Result Policy Is Not Available
Relay’s only tool middleware that can change what a tool returned is the execution intercept, which wraps the callback and so owns execution. pi runs its tools in its own process and reports the outcome, and the gateway builds spans from hook posts rather than executing anything, so neither side ever holds that callback.
This is broader than pi. A tool execution intercept registered by any plugin
does not run under the CLI gateway at all — the registry’s only consumer is
tool_call_execute, which the gateway never calls, because it applies policy
through the hook path instead. Conditional-execution guardrails and request
intercepts do run there, since both have standalone runners the gateway invokes
directly; there is no response-phase equivalent to invoke.
Interrupted Sessions
pi registers no SIGINT handler in any mode. All three modes install handlers
for SIGTERM, plus SIGHUP off Windows, and raw mode is set only by the
interactive TUI — so under -p, --mode json and --mode rpc, Ctrl+C is a real
SIGINT that terminates the process with teardown never running. pi’s
session_shutdown never fires, and the extension never drains its queue.
The loss is bounded. Every hook the extension awaits has already reached the gateway: both gates and both turn boundaries block on their round trip. An interrupt can therefore drop only observability marks queued since the last awaited hook, and the gateway keeps everything already delivered — the session scope is left open rather than the trace being lost.
Gateway Round Trips
pi awaits extension handlers on its critical path, so a blocking hook is
synchronous by construction. Observability-only hooks are queued rather than
awaited and drained at shutdown, so they do not charge that path — while the
gateway answers. Posts go out one at a time in hook order, so a gating hook also
waits for whatever is queued ahead of it: against a gateway that holds requests,
the first gate of a session pays NEMO_RELAY_PI_TIMEOUT_MS once per queued post,
not once.