pi setup

Config

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#

LevelSettingsMCP servers
globalpi-config/agent/settings.json (the only place for packages)pi-config/agent/mcp.json
domainconfig/pi-config-<domain>/settings.jsonconfig/pi-config-<domain>/mcp.json
each level belowconfig/pi-config-<domain>/<path>/settings.jsonconfig/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:

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.

Config levels. Settings, MCP servers and instructions are read from every level on the project's path and merged on the host in order. The nearest level wins. The session sees only the merged result.
In the nearer levelResult
an objectmerged key by key
nullremoves the inherited value (pi’s default applies)
skills, prompts, extensions, themescombined with the inherited list, duplicates removed
defaultTools with only +name / -nameapplied to the inherited list (pi’s own rule)
any other list or valuereplaces the inherited one
packagesrefused (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:

  1. from MCP_<ID>_<NAME>_CMD in .env if set (a command whose output is the value, like the other *_CMD settings; <ID> = domain name in uppercase, - → _), otherwise
  2. from the Keychain item service pi-sandbox, account mcp-<domain>-<NAME>: security add-generic-password -s pi-sandbox -a mcp-work-TICKETS_TOKEN -U -w, otherwise
  3. for all domains: from MCP_<NAME>_CMD in .env, otherwise
  4. 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:

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:

bash
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):

json
{ "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:

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:

bash
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).

A kubectl request in a session. kubectl in the pi container talks to the kube-proxy sidecar. Its filter answers 403 to unusual paths, to secrets and tunnels unless kube.json opts in, and to writes the access mode does not allow. With tunnels: true, exec, attach and port-forward pass in every access mode. Allowed requests pass kubectl proxy, which alone holds the kubeconfig, to the API server.

What the agent can do through the filter, by access:

accessAllowed in addition to every GET/HEAD (resources, events, logs, watches, top)Refused (HTTP 403)
readnothingevery write
restartkubectl rollout restart of deployments, statefulsets, daemonsets (a PATCH whose body is exactly the restartedAt annotation)any other write
writePOST, PUT, PATCH on any resource (apply, create job, scale, label)DELETE
admineverything above, including DELETEnone 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.

json
{ "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:

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, ., _, -)
hostNamename or IP address of the server (required)
user, portoptional; default: ssh’s (pi, 22)
proxyJumpoptional: another alias on the same path to jump through
keyrequired: "${NAME}" of the private key
hostKeysoptional: 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)
enabledfalse 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:

bash
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 folderWhat it adds
<level>/AGENTS.mdInstructions for this level
<level>/skills/<name>/SKILL.mdA skill (<name>: lowercase letters, digits, single -, at most 64 characters)
<level>/prompts/<name>.mdA 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.