Configuration Cookbook
On this page
Everything in the distribution is tuned with small JSON files. This page is recipes: find the one that matches what you want, copy it, adjust, restart pi.
Where your settings live:
| File | What it controls |
|---|---|
|
Your personal overlay on the modes system — models, rules, the judge, guards. Merges over the package defaults (rules can only get stricter). |
|
Your model catalog for Vertex — Claude, Gemini and the MaaS models: which models, which publisher kind, which regions, thinking budgets, auth-retry behaviour. |
|
Operational knobs for the workflow guards: the reflection review’s time bound, the review-context caps and the reviewer’s snapshot caps. Optional; the guards' decisions are not tunable. |
|
A different model per seeded helper (Explore, Research, Verify). Optional;
the file stays managed and keeps receiving prompt updates — only the
|
|
Ordinary pi settings — including your theme. |
|
Project-specific guidance injected for everyone working in that repo. |
After editing, restart pi (mode and model changes need a fresh session).
Back to the package defaults
Package upgrades never touch the files above, which is also how tuning
drifts: an overlay written for one preference keeps applying long after
the reason is forgotten. /gadhs-reset lists the local tuning in force -
each file, what it changes in your terms (the modes overlay’s carve-outs,
the helper model overrides, the catalog entries) - and
/gadhs-reset --apply, after a confirm, moves each file to a dated
.bak beside it. Nothing is deleted; restart pi and the packages re-seed
the agents and the permission config and read their packaged defaults.
Two things it will not move: a file reached through an environment
variable (GADHS_PI_MODES_CONFIG, VERTEX_CONFIG) - it names the
variable and leaves the file, since moving it would leave the variable
pointing at nothing - and a seeded agent file that still carries its
gadhs_managed marker, which the package updates already. The files are
found by name (gadhs-pi-<package>.json, the catalog override, agent
files without the marker, the seeded permission config), not by asking
each package; a new package’s tuning file joins the list by following the
name.
Modes
Change which model a mode uses
You prefer Sonnet for everyday work in auto mode:
{
"modes": [
{ "id": "auto", "label": "Auto",
"model": "vertex-anthropic/claude-sonnet-4-6", "effort": "medium" }
]
}
Only the fields you set change; everything else about auto (its rules, judge, guards) comes from the package.
Start every session in plan mode, applied immediately
{
"applyOnStartup": true,
"cycle": ["plan", "auto", "manual", "yolo"]
}
The first entry in cycle is the startup mode. With applyOnStartup off
(the default) a fresh session keeps whatever model you had; a mode you were
in when you closed pi is always restored either way.
Keep your chosen model when switching modes
By default a mode applies its declared model — plan pairs deep reasoning with
read-only investigation, and that pairing is part of what the mode is. The
switch is announced, never silent — to you as a notice, and to the model as
a message in its context (gadhs-mode-change, naming both modes and any model
swap), because a model cannot tell a recomposed system prompt from its own
assumptions and once insisted it was still in plan mode after a /mode auto.
Startup is the exception: the mode a session opens in is applied quietly. If
you would rather a model you chose
in-session survive mode switches:
{ "stickyModel": true }
With sticky on, a model you picked (with /model, or your configured pi
default) rides through mode switches, and the notice tells you what the mode
default would have been. A model that a mode itself set still gives way to the
next mode’s default — sticky protects choices, not defaults.
Change or remove the mode-cycling shortcut
{ "cycleShortcut": "alt+m" }
Set it to null to have no shortcut. (Shift+Tab is taken by pi itself.)
Add your own mode
A "demo" mode: fast model, no thinking, judge on a short leash:
{
"cycle": ["auto", "demo", "plan", "manual", "yolo"],
"modes": [
{
"id": "demo", "label": "Demo",
"model": "vertex-anthropic/claude-haiku-4-5", "effort": "off",
"systemPrompt": "Demo mode: prefer short answers and visible steps.",
"workspaceWrites": "allow",
"judge": { "model": "vertex-anthropic/claude-haiku-4-5", "authority": "deny-only" }
}
]
}
Remember to add the new id to cycle, or /mode next will skip it.
The high-consequence list
Name the actions that should always stop and ask you — before any model gets an opinion. This list is empty by default because every team’s is different: a team that ships on merge has a different list from one that ships on tags.
{
"modes": [
{ "id": "auto", "label": "Auto",
"highConsequence": [
"git push*main*",
"git push*master*",
"glab mr merge*",
"npm publish*",
"*terraform apply*",
"*production*"
] }
]
}
Patterns are simple globs (* matches anything) tested against the whole
command and against file paths. A match is never refused outright — it just
becomes a question for you.
Permission rules
Block something outright in auto mode
Your team never wants the agent touching Kubernetes:
{
"modes": [
{ "id": "auto", "label": "Auto",
"permission": { "bash": { "kubectl *": "deny", "helm *": "deny" } } }
]
}
deny is instant and final — no model, no prompt. The agent is told why.
Send something to the judge instead of allowing it
ask means "let the judge look at it" (or you, in manual mode):
{
"modes": [
{ "id": "auto", "label": "Auto",
"permission": { "bash": { "docker *": "ask" } } }
]
}
Carve an exception out of a shipped rule
Rules are matched most-specific-wins, so a longer pattern can carve an
exception out of a shorter one. The shipped rules refuse reads under
/.ssh/; this lets the harmless known_hosts file through while keys stay
refused:
{
"modes": [
{ "id": "auto", "label": "Auto",
"permission": { "read": { "*/.ssh/known_hosts": "allow" } } }
]
}
known_hosts reads allow and id_ed25519 reads still
deny). Such a carve-out is a loosening of the baseline, by your choice,
and the session says so: at start it lists each overlay pattern that
narrows a shipped rule to allow something the shipped rule would refuse
(modes overlay carves 1 exception into the baseline: auto: read
"/.ssh/known_hosts" allow inside "/.ssh/*" deny). The detection covers
that refinement shape only — write exceptions as refinements, not as
sibling globs (*known_hosts would carve the same hole and go
unreported). If the list ever names something you did not mean, the
overlay has drifted.
Two rules for authoring patterns:
-
A pattern containing a pipe can never fire. Bash rules match the decomposed units of a command (
echo x | shis two units), and no unit ever contains a pipe. Match the sink (sh), not the pipeline. -
Extend allowlists from evidence, not guesswork.
node tools/task.mjs verdictsshows what the judge was actually asked; the entries worth allowing are the routine ones that dominate that list.
The judge
Use a different judge model
The shipped judge is Sonnet 4.6 at low; Haiku 4.5 reads within three
verdicts of it on the battery’s corpus and is what shipped before #102, so
it is the natural alternative. A judge block replaces the mode’s whole
judge, so name every field you want kept.
{
"modes": [
{ "id": "auto", "label": "Auto",
"judge": { "model": "vertex-anthropic/claude-haiku-4-5", "effort": "low", "authority": "advise" } }
]
}
Make the judge stricter (it can refuse, never approve)
{ "modes": [ { "id": "auto", "label": "Auto",
"judge": { "model": "vertex-anthropic/claude-sonnet-4-6", "effort": "low", "authority": "deny-only" } } ] }
With deny-only, anything the judge would have approved comes to you
instead. Good while you are building trust in a new setup.
Give the judge your own rubric
{ "modes": [ { "id": "auto", "label": "Auto",
"judge": { "model": "vertex-anthropic/claude-sonnet-4-6", "effort": "low",
"prompt": "/home/me/.pi/agent/my-rubric.md" } } ] }
The prompt can be a file path or inline text. Keep it short and mechanical —
rubric attention is finite, and a verbose clause spends it on the wrong
cases: we have measured a five-line addition to one bullet flipping the
verdict on an unrelated case, and halving the wording removing the
regression. After any rubric change, re-run the whole corpus
(task eval --repeat 3), not just the cases you were aiming at.
Keep certain actions interactive even in yolo
{ "safetyValve": ["*publish*", "git push --force*", "*deploy*"] }
Valve patterns stay interactive in every mode, including yolo. The shipped
list covers force pushes, pushes to main, tags, publishes, merges, and
--no-verify.
Tune the denial-loop guard
If the agent gets refused 3 times in a row (or 20 in a session), everything starts coming to you instead — a sign something is off. Adjust or disable:
{ "modes": [ { "id": "auto", "label": "Auto",
"judge": { "model": "vertex-anthropic/claude-sonnet-4-6", "effort": "low",
"maxConsecutiveDenials": 5, "maxTotalDenials": 0 } } ] }
(0 disables that limit.)
Models on Vertex
Full reference: Models on Vertex AI. The highlights:
What is there out of the box
Three provider ids from one package, one config file, the same ADC, no
login: vertex-anthropic/… (Claude), vertex-gemini/… (Gemini 3.8, 3.7,
3.5 Flash, 3.1 Pro Preview, 2.5 Pro) and vertex-maas/… (gpt-oss 120B, Grok
4.20, Kimi K2 Thinking, Qwen3 Coder, Qwen3-Next Thinking, Qwen3 235B, MiniMax
M2). /model lists them all on install. A model’s publisher kind in the
catalog decides which id it lives under; you never configure Gemini one way
and Claude another.
Point at your own GCP project
export ANTHROPIC_VERTEX_PROJECT_ID=my-team-project
gcloud auth application-default login
Switching models mid-session
Any model, any time — with one rule: the context has to fit. Switching a
long session into a model with a smaller window gets a warning with the
numbers and an offer to compact where you came from first; /new is the
clean slate. Details: modes.adoc#_switching_to_a_model_the_context_does_not_fit.
Add or remove models, change regions
~/.pi/agent/vertex-models.json replaces the catalog. Run node probe.mjs
(ships with the package; --publisher google|maas|anthropic for one kind)
to discover which models your project can actually reach, and in which
regions — it prints a ready-to-use catalog with the publisher kind filled
in. An entry for a Gemini or MaaS model is three lines:
{ "id": "gemini-3.8-flash", "publisher": "google", "region": "us",
"cost": { "input": 0.75, "output": 3.75, "cacheRead": 0.075, "cacheWrite": 0 } }
(That one moves 3.8 Flash from global to the us multi-region, which it
also answers from — a US-residency posture in one edit.)
Auth expiry: how long to wait for you to re-login
When your Google session expires mid-task, pi does not fail the task — it
tells you to run gcloud auth application-default login and retries on a
timer until you have, for as long as the turn is alive; pressing escape is
what ends it. Tune the poll, cap the wait, or disable:
{ "adcRetry": { "intervalMs": 30000, "maxWaitMs": 600000 } }
(Defaults: every 60 s, no cap. maxWaitMs is opt-in: set, the turn ends on
the original error after that long and the notice says it gave up.
intervalMs: 0 disables.)
Headless hosts: how long a host gets to answer
Under pi --mode rpc — an IDE plugin, pi-web-ui, a daemon, the pivot
client — there may be no one to answer a dialog, or someone on a phone who
needs a minute. Two waits, in the modes overlay
(~/.pi/agent/gadhs-pi-modes.json or GADHS_PI_MODES_CONFIG):
{ "unattended": { "gateTimeoutMs": 30000, "dialogTimeoutMs": 600000 } }
gateTimeoutMs (default 30 s) is how long the host gets to confirm a
permission ask the gate deferred; silence denies. dialogTimeoutMs
(default 10 min) is how long it gets to approve a plan or answer ask;
silence refuses. Either at 0 means do not ask at all - the gate denies
without the confirm, the approval and ask refuse without the dialog
(pi itself would read a zero timeout as none). Neither applies in
the terminal, where a human is at the keyboard and dialogs do not time out.
See what changes at a headless host.
Compaction: the summarizer of last resort
When the session’s provider refuses the summarization request - a policy classifier blocking it, not a model failing; Anthropic’s has done this to a long session full of quoted model output - retrying there is futile, and pi’s default compaction is the same call. The summary is then written once on a fallback from another provider family:
{ "compaction": { "fallbackModel": "vertex-gemini/gemini-3.8-flash" } }
That is the default. null turns the fallback off; the warning then names
the way out by hand (/model to another provider, /compact, /model
back). Any provider/model the registry knows is accepted; the session’s
own model is never used as its own fallback. A transport error or a
length stop is not a refusal and does not reach this. See
when the provider
refuses the request.
The pivot client (/remote-control)
One file, ~/.pi/agent/gadhs-pi-remote.json:
{ "relay": "wss://pivot.dhs.example/ws", "boxName": "chris-laptop" }
relay (or the GADHS_PIVOT_RELAY environment variable, which wins) is
the DHS relay’s WebSocket URL; it must be wss:// except for a localhost
relay in tests. boxName is what the phone shows for this machine and
defaults to the hostname. Nothing else is tunable: the pairing window is
the relay’s two minutes, the reconnect ladder is 1 s doubling to 30 s, and
the waits a phone gets at a headless host are the unattended ones above.
The identity and the trusted devices live beside it in
~/.pi/agent/gadhs-pi-remote/; edit neither by hand — /remote-control
forget is the way to drop a device. GADHS_PI_REMOTE_DEBUG=/path.jsonl
writes a diagnostic trail (routing ids and reasons, never a token).
The workflow guards
The guards themselves are not configurable — they run in every mode and
cannot be opted out of. A few operational knobs are, in
~/.pi/agent/gadhs-pi-workflow.json. The file is optional; a key the
package does not recognise is reported at session start and the file is
ignored (defaults apply), so a typo cannot quietly change what a guard does.
Give the reflection reviewer more (or less) time
The cold review is an upper-bounded call. The default bound is ten minutes — a backstop against a dead connection, not a budget: a reviewer thinking hard over a large diff is doing its job, and a model with a real problem returns an error rather than hanging. If the bound ever fires, the commit proceeds with no cold read (the block message says so), which is why the default is generous. Milliseconds, positive integer:
{ "reviewTimeoutMs": 1200000 }
Every review logs its duration and outcome (git_guard.reflection_done in the
debug log), so tune from evidence: if reviews of your typical diffs take
three minutes, the bound should not be four.
While a review runs you can see it: a notice when it starts ("Cold review:
plan draft + 8 files (180 KB) by claude-fable-5-1 — bound 10 min"), the
working row and the footer carrying the same while it runs, and a notice with
the verdict and the elapsed time when it lands. Pressing escape ends the
review at once; nothing is decided, no attempt is spent, and the next commit
or exit_plan_mode reviews in full.
Answer a flagged review in the commit message
When the cold review returns FLAGS and you re-run the same commit, the message must carry the disposition of each flagged question (#96):
fix: bound the retry budget Body as usual. Review-Response: J3 disputed - the branch at limiter.ts:40 is covered by the existing case Review-Response: J5 accepted - the doc comment overstates; #77 filed Review-Context: test/limiter.test.ts
One line per question; disputed or accepted; the reason after a dash
or colon, never empty. Write it in the message body (-m, or -F on a
file) — not with git’s --trailer, and not in an editor the guard cannot
read. fixed is not an answer here: change the diff, and the fresh review
says whether it is fixed. The lines are stripped before the review key is
hashed and before the reviewer reads the message, so adding them does not
cost a second review; they land in git history with the commit.
Size the review context to your repository
Review-Context: (commits) and reviewPaths (plans) let the author hand
the reviewer unchanged files. The guard caps that list, and the shipped caps
were sized against a few thousand lines of TypeScript. A million-line Rust
tree is not that: its files are bigger and it takes more of them to explain
a change. All three caps are yours.
{
"reviewContextMaxFileBytes": 400000,
"reviewContextMaxTotalBytes": 1500000,
"reviewContextMaxFiles": 40
}
(Defaults: 80 000 bytes per file, 200 000 total, 12 files. Each is a positive integer; a refusal always quotes your number, not the default.)
What raising them costs. The reviewer’s input context is finite. Past some
size the provider refuses the call, or the reviewer’s answer stops before its
VERDICT: line. Neither is silent: both become an unreviewed outcome with
the reason attached, the attempt is counted, and the commit or plan is told
it has no cold read. So a cap set too high buys a visible non-review rather
than an invisible bad one — raise it, watch reflection_done, and lower it
if reviews start coming back unreviewed.
Size the reviewer’s own reads
Beyond the declared files the reviewer may read the candidate snapshot
itself (#97) — read_file, grep, list_files over the index, the
worktree under -a, or the disk for a plan. Two caps bound it per review;
the per-result cap is reviewContextMaxFileBytes.
{
"reviewSnapshotMaxCalls": 24,
"reviewSnapshotMaxBytes": 600000
}
(Defaults: 12 calls, 200 000 bytes. Positive integers.) Past the budget every
call answers "budget exhausted … answer from what you have"; a reviewer that
keeps asking is stopped two rounds later and the pause is unreviewed with
that reason. reflection_done and plan_review.done carry
snapshotCalls and snapshotBytes: a repository whose reviews routinely
exhaust the calls wants a higher cap; one whose reviewer reads nothing wants
none. Each read is a provider round trip on top of the review’s own, so the
calls cap is also a latency cap.
Guidance
Add project-specific guidance for everyone in a repo
Create <repo>/.pi/gadhs-guidance.md:
## This repo
- The API layer is generated; edit the OpenAPI spec, never src/api/.
- Integration tests need the docker compose stack up.
It is injected into the system prompt for anyone working in that repo with the package installed (trusted repos only).
Appearance
Use the agency theme
/settings → Theme → gadhs-human-services-dark (or -light,
or the gadhs-foundation-* pair). To follow your terminal’s light/dark
automatically, put this in ~/.pi/agent/settings.json:
{ "theme": "gadhs-human-services-light/gadhs-human-services-dark" }
themes array in settings.json is inert: the key
validates but pi’s resource loader never reads it, so it silently does
nothing. Themes load only from packages (pi.themes in package.json), the
--theme CLI flag, and the scanned directories (~/.pi/agent/themes,
.pi/themes).
Subagents
Restrict which helpers a mode may launch
{ "modes": [ { "id": "auto", "label": "Auto",
"canSpawn": ["Explore", "Verify"] } ] }
An empty (or missing) list means no delegation at all in that mode.
Run a helper on a different model
The seeded helpers ship with Explore and Verify on Gemini 3.8 Flash (both
argued on the model-battery page) and Research on Sonnet. To run one
elsewhere without owning its file, name the model in
~/.pi/agent/gadhs-pi-agents.json:
{ "models": { "Explore": "vertex-anthropic/claude-haiku-4-5",
"Research": "vertex-gemini/gemini-3.8-flash" } }
The seed is rendered with your model in place of the packaged one; the
file keeps its gadhs_managed marker, so the next package update still
lands its prompt and only the model: line is yours. Remove the entry and
the packaged model comes back on the next start. Keys must name packaged
helpers and values must be provider/model; a malformed file, or a key
naming a helper the package does not ship, is reported at start and the
whole file ignored — never obeyed in part.
Why you might: the read-heavy helpers want a large window and adequate reasoning, not adversarial rigour, and Gemini 3.8 Flash offers a 1M-token window at Flash prices. It measured Sonnet-class on the agency’s review corpus and is not used for the reviewer or the auto-mode judge — see Reviewer evaluation: does a second vendor add value? for the numbers and the reasons.
There is nothing to configure for the model itself: vertex-gemini is the
agency’s own Vertex provider, on the same ADC and project as Claude, and its
models are in /model from the first start (Models on Vertex AI (@gadhs/pi-vertex)).
One thing to know about the fallback: pi-subagents resolves a helper’s model against the models available in the session and, when it cannot, runs the helper on the session’s own model without saying so. The seeding extension checks every configured model at session start and warns by name — "Explore is configured for vertex-gemini/gemini-3.8-flash, which is not available in this session" — so a missing project or expired login is a visible line, not a quiet bill on the frontier model.
Make a helper’s results machine-checkable
In the helper’s definition file (<repo>/.pi/agents/Verify.md), declare what
its final answer must look like:
output_schema:
{"type": "object", "required": ["passed", "evidence"],
"properties": {"passed": {"type": "boolean"}, "evidence": {"type": "string"}}}
If the helper’s answer does not match, you are told the result is unverified — instead of silently trusting a confident-sounding summary.
Overriding an agency helper in your repo (a .pi/agents/Verify.md of your
own) replaces its prompt and its model, but not its contract unless you
declare one: leave output_schema out and the agency’s schema still applies,
and any violation message tells you that and which file to change.
Memory
The distribution remembers small, local facts — scoped to this repo, this machine, and you — and injects them leanly (hard budget, visible contents, loud failure). Work state belongs in issues and repo truths in AGENTS.md; memory is for what has no better home.
/remember the integration suite needs the docker stack up # repo-scoped
/remember box: docker compose needs sudo here # this machine
/remember dev: I prefer tables over prose # you, everywhere
/memory # list + budget
/memory promote [<id>] # confirm an agent proposal
/memory forget [<id>] # remove (archived, never vanished)
Nobody copies a hash by hand: typing /memory promote ` completes the
pending proposals in place (id, text, scope, age, hits — the same
completion `/model gives you), and a bare /memory promote or /memory
forget opens a list to scroll and pick from. Dismissing the list changes
nothing. Scopes are literal: a dev: note is keyed to your git identity
and follows you into every repo; repo is keyed to the origin URL; box
to the hostname — so a note that is really about one repository belongs
at repo scope, whichever session proposed it.
Agents can propose memories with their remember tool; proposals are
labelled unconfirmed, capped at 10, expire after 7 days, and never become
permanent without your /memory promote. Everything evicted or expired goes
to ~/.pi/agent/gadhs-memory-archive.jsonl, not oblivion.
The store is one file, ~/.pi/agent/gadhs-memory.json, shared by every pi
session on the box. Each command and each prompt reads it fresh and each
write replaces it whole by rename (#158), so two sessions open at once see
each other’s memories on their next turn, and a write in one no longer
drops what the other stored since it started - the only way to lose one is
two sessions writing in the same instant, a window of one command rather
than a session, and there is no lock closing it. A crash mid-write leaves
the previous store, never a torn one. A file that is unreadable disables
memory for the session, loudly, and is not written over.
Seeing what happened
node tools/task.mjs verdicts 20 --why # recent decisions, with the ask and the reason
node tools/task.mjs policy-diff # has your permission seed drifted from the default?
node tools/task.mjs gitlab-check ID # which GitLab identity is actually in effect
GADHS_PI_MODES_DEBUG=/tmp/d.jsonl pi # low-level diagnostics for a session
Troubleshooting quick hits
| Symptom | Likely cause and fix |
|---|---|
"The user denied this call" but you didn’t |
A policy or the judge decided. The sentence is the permission system’s and
opens the same way for every authorizer; the |
Model reverts after restart |
Fixed in current versions — resumed sessions continue on the model they left on. Update the package. |
Everything suddenly needs your approval |
The denial-loop guard tripped (3 refusals in a row). The status line shows
|
|
Export |
Google auth expired mid-task |
Run |
Commit blocked with a review of your diff |
That is the reflection pause: an independent model read your staged diff cold and answered the J1–J8 checklist, citing file and line. Address what you agree with (or say why you disagree), then run the same commit again — the identical diff passes on retry, and staging any fix earns a fresh review. Every commit gets exactly one review attempt; it advises, it does not veto. |
Commit blocked saying the reviewer "could not be reached" |
The reviewer call failed (provider error, or the time bound). That is not a review, so re-running the same commit tries the reviewer again. The message says which attempt it was: after two consecutive failures on the same diff, the next run proceeds on your own J1–J8 pass. A fresh staged change starts the count over. |
Commit or |
The author declared a file for the reviewer (a |
|
That is the plan draft’s cold-review pause (P1–P4), the same one commits get. The
agent should address material findings by editing the draft and calling
|
Commit refused because it stages and commits at once |
Run the staging and the commit as two separate commands. The review runs before your command does, so combined staging would let the reviewer see the wrong diff. |
A new file gained a license header you didn’t type |
Working as intended — new source files get the SPDX header automatically. |