Layered settings, MCP servers, instructions, skills and prompt templates
pi’s settings and MCP servers are set per context (pi-config spec 007): global defaults in
pi-config/agent/, overridden per domain, host, customer/group and project. This page explains
where the files live, how they are merged and how MCP credentials reach a session.
Where the files live#
| Level | Settings | MCP servers |
|---|---|---|
| global | pi-config/agent/settings.json (the only place for packages) | pi-config/agent/mcp.json |
| domain | config/pi-config-<domain>/settings.json | config/pi-config-<domain>/mcp.json |
| each level below | config/pi-config-<domain>/<path>/settings.json | config/pi-config-<domain>/<path>/mcp.json |
config/pi-config-<domain>/ (under PI_ROOT) is one git repo per domain, created by
pi-sandbox domain add (or pi-sandbox config init <domain> for an existing domain) and pushed by you to a private remote. Its folders mirror
ws/<domain>/: for the project ws/work/host.example/customer-a/shop the levels are
config/pi-config-work/, …/host.example/, …/host.example/customer-a/ and
…/host.example/customer-a/shop/. Every file is optional; levels without files are skipped and a
domain without a store uses only the global files.
The store is read on the host and never mounted: a session sees only the merged result, another domain’s store is never visible, and the agent cannot change any configuration that applies to later sessions.
Example (synthetic) — config/pi-config-work/host.example/customer-a/mcp.json:
{ "mcpServers": {
"tickets": { "url": "https://tickets.example/mcp",
"headers": { "Authorization": "Bearer ${TICKETS_TOKEN}" } },
"docs": { "enabled": false } } }Merge rules#
Levels are applied in order (global, domain, each level, project); the nearest level wins.
| In the nearer level | Result |
|---|---|
| an object | merged key by key |
null | removes the inherited value (pi’s default applies) |
skills, prompts, extensions, themes | combined with the inherited list, duplicates removed |
defaultTools with only +name / -name | applied to the inherited list (pi’s own rule) |
| any other list or value | replaces the inherited one |
packages | refused (exit 12): packages are global only, where the supply-chain check runs |
MCP servers merge by name: an entry with command, url or type replaces the inherited
server; an entry with only enabled, exposure or toolExposure changes just those fields
({"enabled": false} switches an inherited server off). An override of a server that no level
above defines is ignored with a warning. A project’s own trusted .pi/settings.json and
.pi/mcp.json still apply on top, as in pi.
Files must be JSON objects (mcp.json: an object mcpServers; server names letters, digits,
_, -); anything else stops the start with exit 12 naming the file.
Credentials#
Never write a secret into a configuration file. Write a placeholder ${NAME} (NAME: capital
letters, digits, _, starting with a letter). For every placeholder used by an enabled
server of the merged configuration, bin/pi-sandbox reads the value on the host:
- from
MCP_<ID>_<NAME>_CMDin.envif set (a command whose output is the value, like the other*_CMDsettings;<ID>= domain name in uppercase,-→_), otherwise - from the Keychain item service
pi-sandbox, accountmcp-<domain>-<NAME>:security add-generic-password -s pi-sandbox -a mcp-work-TICKETS_TOKEN -U -w, otherwise - for all domains: from
MCP_<NAME>_CMDin.env, otherwise - from the Keychain item
mcp-<NAME>.
Steps 3–4 are for one secret every domain shares, e.g. the key of a global server that only one
key may use (MCP_GATEWAY_GLOBAL_TOKEN_CMD for ${GATEWAY_GLOBAL_TOKEN} in
pi-config/agent/mcp.json); a domain can still override it with its own setting. A domain
command that is set but fails does not fall back to the shared one.
Each value is passed only to this session, in one of 16 fixed variables PI_MCP_SECRET_1 …
PI_MCP_SECRET_16 (the rendered configuration says ${PI_MCP_SECRET_<n>}, pi expands it).
A server whose secret cannot be read is switched off for the session with a warning naming the
item — or, for a *_CMD that failed or printed nothing, the command and the likely cause (a
locked password manager: bw unlock, op signin); more than 16 different placeholders stop the start (exit 12). Placeholders of servers that
are switched off are not read. A value under env/headers that looks like a literal secret
(key contains token, secret, password, key, auth, bearer or cookie, value without ${ and not a
!command) produces a warning naming the file and key.
Accepted risk: a secret passed into a session is readable by the agent during that session,
like the model key with --key-from-keychain. For servers that support it, the model proxy MCP
gateway keeps credentials on the server side instead.
Gateway model key. When the merged settings define model-proxy.providers.gateway (the model
provider of the Gateway gateway, see pi-config docs/config-reference.md), bin/pi-sandbox
reads its key with the same lookup as an MCP secret under the name GATEWAY_API_KEY
(MCP_<ID>_GATEWAY_API_KEY_CMD, Keychain mcp-<domain>-GATEWAY_API_KEY, then
MCP_GATEWAY_API_KEY_CMD, Keychain mcp-GATEWAY_API_KEY), and failing that as
GATEWAY_GLOBAL_TOKEN, so the MCP key is reused when nothing else is set. The command can print
a static key (printf <key>) or ask a password manager (bw get password gateway-api). The
value reaches the session only as PI_GATEWAY_API_KEY, which the settings reference as
"apiKey": "$PI_GATEWAY_API_KEY"; it is scrubbed from the host environment first. Without a
configured provider nothing is read; without a key the session starts with a warning and the
provider has no models.
OAuth sign-ins (/mcp → sign in) are stored per domain: ~/.pi/agent/mcp-auth.json is a
link into the domain’s volume pi-domain-state-<domain> (/home/pi/.pi/domain). A sign-in made
in an work session is not available in private sessions. A shared mcp-auth.json from before
spec 007 is renamed to mcp-auth.json.pre-007 on the first start (sign in again where needed).
In the session#
The merged settings and MCP servers reach the container as PI_LAYERED_SETTINGS and
PI_LAYERED_MCP (base64 JSON, no secret values); the entrypoint writes them to
/run/pi-session/settings.json and /run/pi-session/mcp.json (tmpfs) and links
~/.pi/agent/settings.json and mcp.json there. Changes made in a session (/settings, /mcp
toggles, pi mcp add) are discarded when it ends; parallel sessions never share these files.
PI_CONFIG_LEVELS lists the contributing levels; the session context shows them under
“Configuration”.
AWS API access (Agent Toolkit for AWS)#
The AWS MCP Server (https://aws-mcp.us-east-1.api.aws/mcp) answers documentation, region and
skill requests without credentials — a plain url entry is enough for those. Its API tools
(run_script, get_presigned_url, get_tasks) need requests signed with AWS credentials
(SigV4). The image therefore contains AWS’s proxy mcp-proxy-for-aws-cli (pinned, see
dependencies.md); a configuration level runs it as a stdio server and passes
short-term credentials read on the host for each session. Put it at the customer level whose
AWS account it is, so no other customer ever gets the credentials.
Example (synthetic) — config/pi-config-work/host.example/customer-a/mcp.json:
{ "mcpServers": {
"aws-api": {
"command": "mcp-proxy-for-aws-cli",
"args": ["https://aws-mcp.us-east-1.api.aws/mcp", "--metadata", "AWS_REGION=eu-central-1",
"--disable-telemetry"],
"env": { "AWS_ACCESS_KEY_ID": "${CUSTOMER_A_AWS_ACCESS_KEY_ID}",
"AWS_SECRET_ACCESS_KEY": "${CUSTOMER_A_AWS_SECRET_ACCESS_KEY}",
"AWS_SESSION_TOKEN": "${CUSTOMER_A_AWS_SESSION_TOKEN}" },
"exposure": "deferred",
"toolExposure": { "*search_documentation": "hidden", "*read_documentation": "hidden",
"*retrieve_skill": "hidden", "*list_regions": "hidden",
"*get_regional_availability": "hidden" },
"description": "AWS API access to customer-a's account through the AWS MCP Server" } } }The documentation tools are hidden here because a credential-free aws server at the domain
level already offers them. --metadata AWS_REGION=… is the account’s default Region for API
calls; the endpoint itself is only in us-east-1. --read-only would hide the tools that may
write.
Credentials (IAM Identity Center). Use a dedicated permission set for the agent and an AWS
CLI profile for it on the Mac (e.g. pi-customer-a in ~/.aws/config). Each placeholder reads
one field of the exported role credentials in pi-docker-sandbox/.env:
MCP_WORK_CUSTOMER_A_AWS_ACCESS_KEY_ID_CMD="aws configure export-credentials --profile pi-customer-a --format process | jq -r .AccessKeyId"
MCP_WORK_CUSTOMER_A_AWS_SECRET_ACCESS_KEY_CMD="aws configure export-credentials --profile pi-customer-a --format process | jq -r .SecretAccessKey"
MCP_WORK_CUSTOMER_A_AWS_SESSION_TOKEN_CMD="aws configure export-credentials --profile pi-customer-a --format process | jq -r .SessionToken"The AWS CLI caches the role credentials, so the three calls return the same set. Run
aws sso login --profile pi-customer-a before starting a session; without a valid sign-in the
commands print nothing and aws-api is switched off for that session with a warning (the
credential-free documentation server keeps working). The credentials last as long as the
permission set’s session duration (1–12 hours); after that, API calls fail with “expired or
invalid AWS credentials” — sign in again and restart the session. Nothing from ~/.aws is ever
mounted.
Keep the credentials useless outside the MCP server. The agent can read them (see
security.md). Add this statement to the agent’s permission set, so they work only
for calls the AWS MCP Server makes on its behalf (those carry aws:ViaAWSMCPService; direct
calls with the same credentials do not):
{ "Sid": "OnlyThroughAwsMcpServer", "Effect": "Deny", "Action": "*", "Resource": "*",
"Condition": { "BoolIfExists": { "aws:ViaAWSMCPService": "false" } } }AWS does not document whether calls made inside run_script carry the key; check once with a
harmless read (e.g. listing S3 buckets). If they are denied, the statement cannot be used and the
credentials are also usable directly. Grant the permission set only what the agent’s tasks need
(write access only in accounts where agent changes are acceptable), deny destructive actions
explicitly, and enable CloudTrail data events to see the agent’s calls.
Kubeconfig per level#
~/.kube lives in the domain volume, so every session of a domain sees the same kubeconfig.
For customer or project clusters put a kube.json at the level whose clusters they are; the
nearest level with one wins, and the session uses only that kubeconfig.
Example (synthetic) — config/pi-config-work/host.example/customer-a/kube.json:
{ "kubeconfig": "${CUSTOMER_A_KUBECONFIG}", "access": "write" }Optional keys (defaults in brackets): access (read, restart, write, admin) [restart]
sets what the agent may write, see the table below. secrets and tunnels (true/false)
[false] lift the refusal of secrets and of exec, attach, port-forward and proxy. Set them
only for a project that needs them: they weaken the protection described below. With tunnels, kubectl exec, attach and port-forward work in every access mode (the filter relays the protocol upgrade); the session context tells the agent which of these are allowed. Any other value
exits 12.
The placeholder is read on the host at every start with the same lookup as an MCP secret
(MCP_<ID>_<NAME>_CMD in .env, Keychain mcp-<domain>-<NAME>, then the all-domain forms). The
command prints the whole kubeconfig, e.g. a file kept for kubectl-switch or one a cloud CLI
generates for this session:
MCP_WORK_CUSTOMER_A_KUBECONFIG_CMD="cat ~/.kube/configs/customer-a.yaml"
MCP_WORK_CUSTOMER_B_KUBECONFIG_CMD="stackit ske kubeconfig create customer-b -p <project-id> --expiration 8h --disable-writing --output-format yaml"The agent never sees the kubeconfig. The wrapper passes it (base64, at most 64 KiB) only
to a kube-proxy sidecar: a second container of the session, from the same image, without
mounts, read-only, all capabilities dropped. It writes the kubeconfig to its own tmpfs, runs
kubectl proxy on its loopback and container/kube-filter.py in front of it on the session’s
network. The pi container gets PI_KUBE_LEVEL only; its entrypoint writes
/run/pi-session/kubeconfig with server: http://kube-proxy:8001 and no credentials and sets
KUBECONFIG to it. kubectl works as usual, with the identity of the kubeconfig’s
current-context (one cluster per level; set the namespace with -n).
What the agent can do through the filter, by access:
access | Allowed in addition to every GET/HEAD (resources, events, logs, watches, top) | Refused (HTTP 403) |
|---|---|---|
read | nothing | every write |
restart | kubectl rollout restart of deployments, statefulsets, daemonsets (a PATCH whose body is exactly the restartedAt annotation) | any other write |
write | POST, PUT, PATCH on any resource (apply, create job, scale, label) | DELETE |
admin | everything above, including DELETE | none of the writes |
In every mode: secrets and exec, attach, port-forward, proxy are refused unless the opt-in
is set; encoded or dotted paths (%, //, /.) and any Host other than kube-proxy are
refused.
Why the default is narrow: whoever can change a workload or exec into a pod can read its
secrets (mount one and print it to the log, or run env). Blocking only the secrets API would
not keep them from the agent, so write and admin need a project decision in its kube.json.
kubectl proxy itself also refuses secrets and every tunnel, as a second line, unless the
opt-in is set. The rules are fixed in container/kube-filter.py (unit tests:
tests/kube-filter.bats). Values outside secrets stay readable: configmaps, literal env
values in pod specs, custom resources and whatever an application prints to its log. Still give
the kubeconfig a dedicated identity with only what is needed (read, patch on the workloads,
no secrets); the filter is a second line, not the only one.
Pod logs can contain personal or customer data. The agent then processes them; on WORK customer systems that is restricted by the organisation’s data-protection rules (end-user and consumer data must not be processed). The session context tells the agent to read only what the task needs.
Parallel sessions. Each session with a kubeconfig runs in its own compose project
(<project>-s<pid>), so two sessions of the same project have separate networks and sidecars.
The wrapper removes the sidecar and its network when the session ends, also after Ctrl-C, a
closed terminal or TERM; pi-sandbox stop stops it too. If the sidecar does not start, the
session does not start (exit 23).
If the value cannot be read, the session starts with a warning and no sidecar: kubectl then
reaches nothing and never falls back to the domain’s ~/.kube/config. kube.json must be a
regular file with exactly this one placeholder (exit 12 otherwise); the global level has none.
Without kube.json nothing changes: kubectl uses ~/.kube/config of the domain volume
(credentials.md), readable by the agent.
Toolsets per level#
A project that needs tools of its own (for example a Node version, Python, gitleaks, prek)
puts a tools.json at the level it belongs to. The nearest level with one wins.
{ "tools": ["node-22.11.0", "python3-3.12.3", "gitleaks-8.30.0", "prek-0.5.7"] }Each entry is an exact <package>-<version> from the pinned package index (up to 16 entries).
Channels (node@22), bare names and anything that is not a plain selector exit 12 on the host.
The wrapper passes the list in PI_TOOLS (base64 JSON) to the entrypoint, which creates a Hermit environment in the session
dir (outside the repo) and installs the entries there. Its bin/ goes first on PATH, so the
tools win over the image’s and are visible to every shell of the session.
- Package index and Hermit are pinned in the image (credentials.md). Each package is checked against the SHA-256 in its manifest.
- A repo’s own Hermit pins (
bin/hermit,hermit.hcl) are not changed. Toolsets are for projects that have no pins of their own, or as a fixed set on top of them. - The installs use the domain’s shared Hermit cache. The per-project user installs (pip, npm globals) are not in that cache and stay in the session’s environment.
- If the install fails, the session starts without the tools and says so in a warning line.
Validation: lib/tools.jq (unit tests: tests/tools.bats).
SSH hosts per level#
Servers pi may log in to, per customer or project. Put an ssh.json at any level below the
domain; the files of every level on the project’s path merge: a nearer level adds hosts,
replaces an alias of a farther one, or switches it off with "enabled": false.
Example (synthetic) — config/pi-config-work/host.example/customer-a/ssh.json:
{ "hosts": {
"app1": { "hostName": "app1.example.net", "user": "deploy", "key": "${CUSTOMER_A_APP1_SSH_KEY}",
"hostKeys": ["ssh-ed25519 AAAAC3…"] },
"db": { "hostName": "10.0.0.5", "user": "pi", "port": 2222, "proxyJump": "app1",
"key": "${CUSTOMER_A_DB_SSH_KEY}" } } }and in …/customer-a/shop/ssh.json (this project only): {"hosts": {"db": {"enabled": false}}}.
| Field | |
|---|---|
| alias (the key) | what pi types: ssh app1, scp x app1:, rsync … db: (a-z, 0-9, ., _, -) |
hostName | name or IP address of the server (required) |
user, port | optional; default: ssh’s (pi, 22) |
proxyJump | optional: another alias on the same path to jump through |
key | required: "${NAME}" of the private key |
hostKeys | optional: the server’s public host keys as a list of "<type> <base64>" (ssh-keyscan -t ed25519 <host>, checked by you), or "${NAME}": a command that prints known_hosts lines (below) |
enabled | false removes an alias of a farther level |
Every value is checked strictly (no spaces, no newlines, known fields only, proxyJump to an
alias on the path without loops, at most 16 hosts): anything else stops the start with exit 12
naming the file and alias. A symlinked ssh.json is refused too.
Keys. Each ${NAME} is read on the host at every start with the same lookup as an MCP
secret (MCP_<ID>_<NAME>_CMD in .env, Keychain mcp-<domain>-<NAME>, then the all-domain
forms); the command prints the private key, e.g.
MCP_WORK_CUSTOMER_A_APP1_SSH_KEY_CMD="cat ~/.ssh/pi-customer-a-app1" or a password manager
read. Use a dedicated key without a passphrase per server or customer, never your own. A key
that cannot be read, has a passphrase or is no private key skips its host with a warning, and
every host that jumps through it; the session still starts. ssh-keygen on the host derives
the public key.
The agent never sees the private keys. The wrapper passes them (base64, at most 64 KiB) only
to an ssh-agent sidecar: a second container of the session, same image, no network, no
other mounts, read-only, all capabilities dropped. It loads them into an ssh-agent whose socket
sits on an in-memory volume shared with the pi container (/run/pi-ssh, mount 11 in
path-contract.md). The pi container gets PI_SSH_HOSTS (aliases, targets,
host keys and public keys); its entrypoint writes /run/pi-session/ssh_config, which the
image’s /etc/ssh/ssh_config.d/pi-sandbox.conf includes: per alias the agent socket, only its
own key (IdentitiesOnly with the public key), StrictHostKeyChecking yes against the
hostKeys and the keys of pi-sandbox known-hosts add, BatchMode yes, ForwardAgent no.
pi can use the keys for the session (to log in, also to other servers that accept them) but
not copy them out: the agent protocol has no export. Keys are not bound to their host inside the
agent (ssh-add -h); give each key access only where it belongs.
Host keys are pinned. An unknown or changed host key refuses the connection; there is no
trust on first use. Add the server’s key to hostKeys, or once for every session with
pi-sandbox known-hosts add <host> (commands.md).
Reusing your known_hosts. "hostKeys": "${NAME}" reads the keys at every start with the
same lookup as an MCP secret (MCP_<ID>_<NAME>_CMD, Keychain); the command prints known_hosts
lines. ssh-keygen -F finds a host’s lines, also hashed ones and other ports:
MCP_WORK_MM_APP1_HOSTKEYS_CMD="ssh-keygen -F app1.example.net -f ~/.ssh/known_hosts"
MCP_WORK_MM_DB_HOSTKEYS_CMD="ssh-keygen -F '[10.0.0.5]:2222' -f ~/.ssh/known_hosts"Only the <type> <base64> part of each line is kept and pinned to the alias’ hostName (and
port), whatever host name, hashed or not, the line had. Comments, @cert-authority and
@revoked lines are dropped: host certificates are not supported. A command that fails or prints
no key gives a warning; the host stays, with only the keys of pi-sandbox known-hosts. The
lines come from your machine: they are only as trustworthy as your known_hosts.
Parallel sessions and cleanup work as for the kubeconfig: the session runs in its own
compose project (<project>-s<pid>, shared with a kube-proxy sidecar if both are used), and the
wrapper removes the sidecar, its network and the socket volume when the session ends, also after
Ctrl-C, a closed terminal or TERM. A sidecar that does not start or stops right away stops the
session start (exit 23). Without ssh.json nothing changes.
Whoever can log in can read what the server holds. Use an account on the server with only what the task needs.
Instructions, skills and prompt templates per level#
pi-config spec 008. Every level of the store can also hold:
| File or folder | What it adds |
|---|---|
<level>/AGENTS.md | Instructions for this level |
<level>/skills/<name>/SKILL.md | A skill (<name>: lowercase letters, digits, single -, at most 64 characters) |
<level>/prompts/<name>.md | A prompt template (/<name> in pi) |
Instructions stack. The session’s AGENTS.md is the global pi-config/agent/AGENTS.md,
followed by every non-empty level file in path order (domain, host, customer/group, project), each
under the heading # Instructions for <level> (<file>). Nearer levels come last, so their rules
read as the more specific ones; nothing is replaced. Another customer’s or domain’s instructions
are never included. Together they may be at most 64 KiB; more stops the start (exit 12) with the
size of every part. Rules for a domain belong in its store, not in the global file: the WORK rules
for work and customer projects live in config/pi-config-work/AGENTS.md. If a domain has no
AGENTS.md, the session context says so under “Configuration”.
Skills and templates: nearest wins. For each start the wrapper copies the global
pi-config/agent/skills/ and prompts/ and then every level’s folders into
pi-docker-sandbox/.run/<run-id>/ (gitignored); an entry with the same name at a nearer level
replaces the farther one. The folder is mounted read-only at /opt/pi-layers, and the merged
settings’ skills / prompts point there instead of /opt/pi-config/agent/… (they are added if
the global settings do not list them). The folder is removed when the session ends; leftovers
older than a day (e.g. after a crash) are removed at the next start. Without any level skills or
templates nothing is copied and the empty placeholder empty/ is mounted.
The start stops (exit 12) for: an entry that is not a regular file or folder (symlinks, devices) —
also a level folder, a level AGENTS.md or a level’s skills/ / prompts/ that is a symlink, so
nothing outside the store is ever read —, an invalid skill name, a skill folder without
SKILL.md, a template name that does not match [A-Za-z0-9][A-Za-z0-9._-]*.md, or more than
10 MiB / 2,000 files in total (the message names the size and file count of every level).
As with settings, the store is never mounted: the session sees only the stacked text and the copied files, read-only, and cannot change what later sessions get.
Checking the result#
pi-sandbox config show <project-path> prints the contributing files, every setting with the
level that set it, every MCP server with its state, level and secrets (NAME→slot and the
source keychain, cmd or missing), the instruction files with their size (instructions:)
and every skill and prompt template with the level it comes from (skills:, prompts:), the
level and source of the kubeconfig (kubeconfig:) and every SSH host with its target, level and
key source (ssh hosts:). It never reads a secret value, never runs a *_CMD, copies
nothing and starts no container.