Configuration
The installer writes ~/.config/peen/.env for you, and peen config edit opens it. Without the installer, start from .env.example at the top of the repository, or let the Peen image write the same commented file for your directories with config init, as deployment shows. config init never overwrites an existing file. Then set the provider values that fit your machine. Peen checks every PEEN_ value before opening its listener, so a bad setting fails at startup instead of halfway through a task. Peen starts with no sessions. A client opens a workspace through POST /api/v1/sessions/open, and PEEN_WORKSPACE_ROOT bounds which directories it may name.
For Docker, .env is input for docker run --env-file. Do not source it from Bash, because PEEN_UPSTREAMS is raw JSON. For a bare binary, set the same values through your shell, service manager, or secret manager. This page is the full reference, and .env.example is the commented starting point.
LOG_LEVEL, LOG_FORMAT, and LOG_ADD_SOURCE control application logging. Peen adds a JSON audit log configured by PEEN_LOG_DIRECTORY and PEEN_LOG_RETENTION.
| Variable | Default | Values |
|---|---|---|
LOG_LEVEL | info | debug, info, warn, or error. |
LOG_FORMAT | text | text or json. .env.example sets json. |
LOG_ADD_SOURCE | false | true adds the source file and line to each record. |
Start here
These values decide where Peen keeps its state and which model handles a task. Start Peen from the directory that holds your projects, the workspace root, or set PEEN_WORKSPACE_ROOT. Docker users set that directory with docker run --workdir, and must use a literal host path when they want Docker agent spaces. Bare-process users change directory before launching Peen.
| Variable | Default | Meaning |
|---|---|---|
PEEN_CONFIG_DIR | required, absolute | Global configuration and harness layer. Every Docker worker receives it read-only. A control worker can write it. See directories. |
PEEN_STATE_DIR | required, absolute | The controller’s own durable state: SQLite, audit logs, worker sockets. No Docker worker receives it. A control worker can read it. See directories. |
PEEN_WORKSPACE_ROOT | process working directory | Absolute directory a client may open workspaces inside. See workspace root. |
PEEN_DOCKER_PROFILES | empty, so only the built-in default profile | JSON array of named Docker profiles. Each one is the agent space docker:<name>. See where it runs. |
PEEN_DEFAULT_AGENT_SPACE | docker:default when the controller can reach a Docker socket, else control | Agent space a workspace opened without naming one uses: control or docker:<profile>. Startup fails when it is control and the controller can reach a Docker socket. See the Docker socket rule. |
PEEN_WORKER_SOCKET_DIR | PEEN_STATE_DIR/workers | The directory holding one directory per session worker. A worker receives only its own. |
PEEN_DOCKER_SOCKET | empty | An extra Docker socket path to check. Peen also checks a unix:// DOCKER_HOST and /var/run/docker.sock, and creates worker containers through the first socket where the Docker API answers. It refuses the control agent space when any of them, or a tcp:// DOCKER_HOST, answers. See the Docker socket rule. |
PEEN_WORKER_IMAGE | empty | Image for every Docker worker, replacing each profile’s image. Empty uses each profile’s own image, and a profile without one runs the image published alongside this build. The installer sets it to the release it installed. Set it yourself to run workers from a local build. |
PEEN_HOST_USERNAME | empty | Host account name for a Docker controller started with numeric --user IDs. Set it together with PEEN_HOST_HOME. The installer sets both. |
PEEN_HOST_HOME | empty | Absolute host home for a Docker controller started with numeric --user IDs. Set it together with PEEN_HOST_USERNAME. |
PEEN_AGENT | core:general-purpose | The agent every root turn runs as. A plain name picks a custom agent first and a core agent second, and core:<name> picks only the core one. Startup fails when a core: name is unknown or disabled. A plain name is resolved at each turn. See Agents. |
PEEN_DISABLED_CORE_SKILLS | empty | Comma-separated core skills to remove, without the core: prefix, or all. The core skills are delegation, freshness, plan-and-delegate, planning, writing-rules, and writing-skills. An unknown name stops startup. See core and custom skills and agents. |
PEEN_DISABLED_CORE_AGENTS | empty | Comma-separated core agents to remove, without the core: prefix, or all. The only core agent is general-purpose, and it is the default PEEN_AGENT. To disable it, first set PEEN_AGENT to one of your own agents. Startup fails on an unknown name, and when PEEN_AGENT names a disabled core: agent. A plain PEEN_AGENT name passes startup, and a turn fails when the workspace defines no agent with that name. |
PEEN_DISABLE_CORE_INSTRUCTIONS | false | true leaves Peen’s embedded operating rules, the built-in AGENTS.md, out of every system prompt. See a blank agent. |
PEEN_DISABLE_BASE_SYSTEM_PROMPT | false | true leaves Peen’s embedded base system prompt out. PEEN_CONFIG_DIR/SYSTEM.md and APPEND_SYSTEM.md still apply when present. See a blank agent. |
PEEN_BLANK_AGENT | false | true sets both settings above, disables every core skill and agent, and runs the root with no agent instructions while PEEN_AGENT is the default. A PEEN_AGENT naming one of your own agents still applies. See a blank agent. |
PEEN_AUTO_EVOLVE | false | true tells the agent to save the rules and skills it learns in the workspace’s .agents/learned/, and mounts the global learned directory read-only into Docker workers. See auto-evolve. |
PEEN_LEARNED_DIR | PEEN_CONFIG_DIR/.agents/learned | The global learned directory, an absolute path every session reads while auto-evolve is on. It may sit inside PEEN_CONFIG_DIR but may not be or contain it, and may not overlap PEEN_STATE_DIR, PEEN_WORKER_SOCKET_DIR, or PEEN_LOG_DIRECTORY. The controller creates it at startup. |
PEEN_WRITABLE_LEARNED_DIR | false | With auto-evolve on, true mounts the global learned directory writable into every Docker worker and tells the agent it may save rules and skills there. Any Docker session can then change what every session loads. The controller logs a warning at startup. |
PEEN_WRITABLE_CONFIG_DIR | false | true mounts PEEN_CONFIG_DIR writable into every Docker worker, and with auto-evolve on tells the agent it may change the files there. Any Docker session can then change the global harness, hooks included, that every session loads. The controller logs a warning at startup. |
PEEN_MCP | true | false turns MCP servers off. With it on, every session starts the servers in PEEN_CONFIG_DIR/.agents/mcp.json. See MCP servers. |
PEEN_WORKSPACE_MCP | false | true also reads <workspace>/.agents/mcp.json. Opening a session then runs any stdio server the workspace names. A workspace server gets no secrets. |
PEEN_MCP_ENV | empty | Comma-separated variables that mcp.json may hand a server through envFrom or bearerTokenEnv. Peen passes them to workers and removes them from every command the agent runs. A PEEN_ setting is refused. |
PEEN_MCP_START_TIMEOUT | 30s | Limits starting one MCP server and listing its tools. Zero takes the default. |
PEEN_UPSTREAMS | required, JSON | Named provider list. See providers. |
PEEN_DEFAULT_MODEL | required | provider/model for the root agent and, unless PEEN_COMPACTION_MODEL is set, compaction. |
PEEN_COMPACTION_MODEL | PEEN_DEFAULT_MODEL | provider/model used only for the summarization call. See compaction. |
PEEN_HTTP_LISTEN_ADDRESS | :8080 | Listener address. :8080 listens on every interface. The installer writes 127.0.0.1:8080, which only this machine can reach. |
PEEN_API_TOKEN | empty | Bearer token. Empty turns authentication off. |
PEEN_METRICS_LISTEN_ADDRESS | 127.0.0.1:9090 | Separate loopback-only Prometheus listener. See metrics. |
PEEN_METRICS_RETENTION | 168h | How long metrics.db keeps observations. See stored observations. |
PEEN_SYSTEM_AGENT | false | Offers the system agent, which runs in the control agent space. That is host root when the controller can reach a Docker daemon. |
Settings the peen command reads
The installer’s peen command keeps three settings of its own in .env. The controller ignores them, and a control worker never receives them.
| Variable | Set by | Meaning |
|---|---|---|
PEEN_CONTROL_SPACE | peen setup --control-space | Where Peen runs: host for the binary in ~/.local/lib/peen, or /usr/local/lib/peen after an install with --system, or docker for a container. peen config set refuses it, because changing it needs peen setup to fetch the binary or the image. |
PEEN_IMAGE | peen setup, peen upgrade | The release image this install runs, such as psyb0t/peen:v0.20.0. The peen command runs the controller container from it, takes the host binary out of it, and moves PEEN_WORKER_IMAGE along with it while the two still match. |
PEEN_CONTAINER_NAME | peen setup --container-name | The controller container’s name in the docker control space. The default is peen-<your user name>. |
The peen command also reads PEEN_HTTP_LISTEN_ADDRESS as the address Peen is reachable at on this machine. In the docker control space it publishes that address and runs the controller on :8080 inside the container. Deployment describes the command.
Directories
Peen keeps two directories apart, and refuses to start if they overlap.
PEEN_CONFIG_DIR is the global configuration and harness layer: AGENTS.md, .agents/, and the optional SYSTEM.md, APPEND_SYSTEM.md, and COMPACTION.md. Every Docker worker receives this directory read-only, so anything inside it is readable by every session. A control worker runs as the controller’s user and can write it.
PEEN_STATE_DIR is the controller’s own durable state: peen.db, workflows.db, metrics.db, the audit logs, and the worker socket directories. No Docker worker receives it. A control worker runs as the controller’s user and can read it, every session’s transcript included. See where it runs.
Peen refuses to start when the two are the same path, or when either sits inside the other. Without that rule, the read-only configuration mount would carry SQLite into every worker, and one session could read every other session’s transcript.
The check resolves both paths through their symlinks before comparing, so a state directory that is a link into the configuration directory is refused even though the two strings share no prefix. A directory Peen has not created yet is normal, so Peen resolves the deepest part that exists and adds the rest of the path back. A path that cannot be resolved for any other reason stops startup.
PEEN_CONFIG_DIR=/absolute/path/to/peen/config
PEEN_STATE_DIR=/absolute/path/to/peen/state
Siblings under a shared parent are fine. Nesting is not.
Workspace root
The controller has one workspace root. Each chat opens a workspace, which is the root itself or any existing directory inside it, and every workspace gets its own agent session. Point the root at the directory that holds your projects:
PEEN_WORKSPACE_ROOT=/srv/work
With that root, a client can open /srv/work/api and /srv/work/web as two separate workspaces. Peen resolves the requested path through its symlinks before checking it, so an alias of an allowed directory opens the same session as the real path, and a symlink inside the root that points outside it is refused. A path outside the root returns 403 WORKSPACE_NOT_ALLOWED and creates no session.
Peen also refuses a workspace that is, contains, or sits inside PEEN_STATE_DIR, and one that is or contains PEEN_CONFIG_DIR, with the same 403 WORKSPACE_NOT_ALLOWED. With a root of $HOME and state under ~/.config/peen, ~ itself cannot be opened, and the project directories inside it can. See where it runs.
Leaving the variable unset uses the process working directory as the root. The docker run commands in deployment set the working directory with -w to the same directory. The installer sets PEEN_WORKSPACE_ROOT to your home directory or to --workspace-root.
Peen refuses to start when the root is relative or missing, so a typo fails at startup rather than when a client first opens a workspace.
Agent spaces
A session’s agent runs in an agent space: control, which runs it where Peen runs, or docker:<profile>, which runs it in its own container. A client names an agent space when it opens a workspace and never sends an image, mount, network setting, or capability. Where it runs is the reference for both kinds, for every Docker profile option and its default, and for the rule that refuses control when the controller can reach a Docker socket. The settings are PEEN_DOCKER_PROFILES and PEEN_DEFAULT_AGENT_SPACE in the table above.
Naming an agent space the deployment does not offer returns 403 and creates nothing, even when the workspace already has a session. Opening an existing session never changes the agent space it already runs in. Moving a session is POST /api/v1/session/reconfigure. See choosing an agent space.
Docker workers
A docker:<profile> agent space runs the session worker in a sibling container named peen-worker-<session-uuid>. The control agent space runs it as a child process of the controller, using the same Peen binary.
Peen picks the worker image in this order: PEEN_WORKER_IMAGE if it is set, then the Docker profile’s own image, then the image published alongside the running controller. That last one is psyb0t/peen tagged with the controller’s version, which is the tag the release was published under, so a release runs workers from its own image with no setting at all.
A build from source reports the version dev, and no registry holds psyb0t/peen:dev. Set PEEN_WORKER_IMAGE to run Docker workers from a local build, such as the peen image make docker-build creates.
Peen accepts any reference the daemon can resolve, by tag or by digest, from any repository. The image must contain the same Peen release as the controller, because the controller and its workers talk a protocol that changes between releases, so build a custom worker image FROM psyb0t/peen:<the controller's version>. A worker container starts as root so the Peen entrypoint can create the controller’s host account and drop to it, so an image without that entrypoint fails when the worker starts.
Peen pulls an image the daemon does not hold yet. Creating a container through the Docker API does not pull the way the docker CLI does, so without this a worker could not start on a host that had never seen the image.
Peen records the repository digest the daemon reports for the image with each worker it starts, so naming a tag still leaves a record of the exact image that ran. A locally built image has no repository digest until it is pushed, and Peen then records none rather than the local image ID, which no registry could resolve.
A Docker agent space needs the controller to reach a Docker socket. Peen decides that at startup, as the Docker socket rule describes. Without one, a session in a Docker agent space is refused. Peen never falls back to the control agent space, because that would run the model’s tools next to Peen after the operator asked for a container. With one, Peen refuses to start when it cannot prepare to start worker containers, because the Docker agent spaces are then the only ones offered.
A Docker worker runs as the controller’s own host UID, GID, and username, so files it writes in a mounted workspace keep host ownership. Peen refuses to start a Docker worker as root. The workspace is mounted writable at its literal host path, the configuration directory read-only at its literal path, and the session’s own directory writable, so a path in a transcript means the same thing inside and outside the container.
A controller on the host reads its username and home from the operating system. A controller running in Docker with --user uid:gid has no account entry for that host user, so set PEEN_HOST_USERNAME and PEEN_HOST_HOME together. Peen uses those values with the controller’s numeric UID and GID to recreate the same account inside each worker. Setting only one of them, or a relative home, fails startup.
A Docker worker receives only the runtime configuration it needs to run a turn: the provider definitions, model selection, limits, and the provider credentials named by apiKeyEnv. It never receives PEEN_STATE_DIR, PEEN_API_TOKEN, the controller’s Docker access, or the entrypoint’s identity settings. Commands the agent runs, in any agent space, start with an environment that has PEEN_API_TOKEN and every apiKeyEnv variable removed. They still run as the same user as the worker process that holds the provider credential. The worker is non-dumpable on Linux, so they cannot read the credential from its /proc files, but an agent with root, as sudo: true gives, may reach it. Do not give a Docker profile a workspace containing secrets you would not expose to it. A hook command action gets only PATH and the variables its environment declares.
Sudo
sudo: true in a Docker profile gives the agent working sudo inside its own container. The image installs sudo but ships no sudoers rule, so sudo authorizes nobody until a profile asks for it.
Every Docker worker starts as root only to set up its account. The entrypoint creates or updates the controller’s host UID, GID, and username inside the container, then drops to that account with setpriv before the agent starts. This keeps host file ownership and the host username correct inside a worker.
For a sudo: true profile, the entrypoint also writes /etc/sudoers.d/peen-worker for that account. Other Docker profiles write no sudoers rule and run with Docker’s no-new-privileges guard. The control agent space has no sudo option, because it has no entrypoint to grant anything.
This is root inside the container. Combined with mounts, it reaches whatever those mounts expose. It is a separate decision from docker: full, and neither implies the other.
Docker access
docker: full is separate from the controller’s own Docker access. The controller’s socket lets it create worker containers. This option also gives the worker its own access to the daemon, which is host root. Peen mounts the controller’s socket at /var/run/docker.sock in the worker and adds the socket’s owning group, read from the socket itself, to the worker’s groups, so the worker reaches the daemon without running as root. Peen refuses to start while a profile sets docker: full and the socket is owned by group 0. See Docker profiles.
docker: readonly gives the worker no socket. The controller starts a proxy for that worker at docker.sock in the worker’s own session directory, forwards only read requests to its own socket, and sets DOCKER_HOST in the worker to the proxy. The proxy stops with the worker. docker: readonly lists what it answers and what it refuses.
Worker sockets
PEEN_WORKER_SOCKET_DIR defaults to PEEN_STATE_DIR/workers. Each session gets its own directory inside it, <directory>/<session-uuid>/worker.sock, and a Docker worker gets that one session directory. It never gets the parent, because the parent holds every live session’s socket.
A Unix socket path holds at most 107 bytes, and the directory plus the session directory and file name has to fit, so the directory itself can be at most 58 bytes. Peen measures this at startup and refuses to start with the length it computed, and peen doctor runs the same check. A deep state directory is fixed by setting a shorter PEEN_WORKER_SOCKET_DIR. In the Docker control space the peen command mounts that directory into the controller container, and creates it with mode 0700 when it is missing. It does the same for a PEEN_LOG_DIRECTORY outside the state directory.
Logging and audit trail
LOG_LEVEL controls which structured application records go to stdout. The audit log keeps every record at debug level and above in one file per UTC day, PEEN_LOG_DIRECTORY/YYYYMMDD-000000.log. When PEEN_LOG_DIRECTORY is empty, the directory is PEEN_STATE_DIR/logs. Set a path to move it, and keep it out of PEEN_CONFIG_DIR, which every worker can read. At LOG_LEVEL=debug, records also carry message text and every line of command output, so stdout and the audit log hold them too. Debug never logs credentials. Keep info in production.
PEEN_LOG_RETENTION is a Go duration, default 336h, and keeps that many days of daily files, so 336h keeps at most 14 files. Go durations have no day unit, so write days as hours, such as 168h for 7 days and 720h for 30 days. The value must be a whole number of days, a multiple of 24h, because files are per UTC day. 0 means the default. Startup fails on a negative value or one that is not a multiple of 24h. Peen creates the directory with mode 0700 and the files with 0600.
ctxscope carries request, session, turn, child-agent, model, tool-call, and service fields through every record. Audit records name the resolved harness files, skill activation, hook actions, child-agent lifecycle, and provider and tool outcomes, plus content byte counts and SHA-256 digests.
The agent loop runs in a session’s worker, so those records come from another process. Every worker gets the controller’s LOG_LEVEL and LOG_ADD_SOURCE and always writes JSON, whatever LOG_FORMAT the controller uses. The controller reads each worker’s output and writes it through its own logging at the level the worker used, tagged with session_id and worker_generation_id. A line that is not a JSON record, such as a race report, a panic, or a subprocess’s output, is written as a warning with reason unstructured_worker_output. That is what puts them in this file. A worker receives neither the audit directory nor the state directory, so it never writes here itself and never sees another session’s records.
Raw user prompts, model thinking, tool arguments, tool results, environment values, and credentials are not copied into logs. Hook records may include small operational counters such as the estimated size of the active context. The SQLite transcript keeps the full, verbatim record for authorized debugging, including model requests, responses, usage, costs, tool definitions, child-agent runs, and compactions. SQL statement previews are turned off, because an expanded statement can contain stored sensitive content.
Providers
PEEN_UPSTREAMS is a JSON array with one entry per provider. The docs call an entry an upstream or a provider, and the API calls its name connectionName. See Terms.
PEEN_UPSTREAMS=[{"name":"aigate","type":"openai","baseUrl":"https://aigate.example/v1","apiKeyEnv":"AIGATE_TOKEN"}]
AIGATE_TOKEN=your-token-here
A Z.ai Coding entry, as .env.example has it, looks like this:
PEEN_UPSTREAMS=[{"name":"zai","type":"zai-coding","baseUrl":"https://api.z.ai/api/coding/paas/v4","apiKeyEnv":"ZAI_TOKEN"}]
ZAI_TOKEN=your-token-here
| Field | Required | Meaning |
|---|---|---|
name | yes | Local name, the prefix in name/model, such as aigate/your-model-id. No /. Unique across entries. |
type | yes | Wire protocol: openai, anthropic, or zai-coding. |
baseUrl | no | API base URL. Left out, the protocol’s own default endpoint is used. |
apiKeyEnv | no | Name of the environment variable that holds the key. Never put the key itself here. If set, that variable must be set and non-empty, or Peen refuses to start. |
imageInput | no | false marks every model of the entry as text-only. Peen then refuses images in message.send and from readFile for those models, and GET /api/v1/models reports imageInput: false. Left out or true, Peen trusts what the provider driver reports for each model. |
Peen asks every listed provider for its models at startup. Delete every entry you do not use, together with its key variable. An entry whose key variable is missing or empty stops Peen from starting. An entry with a placeholder key such as REPLACE_ME fails discovery. Peen logs a warning and offers none of its models, and startup fails if PEEN_DEFAULT_MODEL or PEEN_COMPACTION_MODEL names one of them.
Model selection
PEEN_UPSTREAMS accepts openai, anthropic, and zai-coding provider types. .env.example has an OpenAI-compatible AIGate entry and Z.ai’s Coding endpoint. zai-coding keeps Z.ai thinking state through tool rounds and applies its model-specific thinking controls through Elelem.
Set PEEN_DEFAULT_MODEL to the model that runs every turn and PEEN_COMPACTION_MODEL to the one that writes compaction summaries, each as provider/model. With the Z.ai entry above, that is PEEN_DEFAULT_MODEL=zai/glm-5.3 and PEEN_COMPACTION_MODEL=zai/glm-5.3-flash. A client can pick another model for one message. Peen never picks a model for a request on its own.
Peen’s Z.ai Coding catalog has GLM 5.3 and GLM 5.3 Flash, each with a 1,000,000-token context window. PEEN_MAX_CONTEXT_TOKENS keeps its 32768 default, so set it up to 1000000 to use that window. The prompt then gets that minus PEEN_OUTPUT_RESERVE_TOKENS. Both models keep their thinking between rounds and accept a reasoning effort, which Z.ai maps to low, high, or max. Thinking cannot be turned off, so none is refused.
Metrics
GET /metrics serves Peen’s Prometheus metrics on PEEN_METRICS_LISTEN_ADDRESS. It never appears on the API listener, is not part of /api/v1, and does not check PEEN_API_TOKEN, because the loopback-only listener is the access boundary.
The value must be a loopback IP address and a port other than 0, such as 127.0.0.1:9090 or [::1]:9090. Peen refuses to start with a wildcard or non-loopback address. Run a scraper in the same network namespace as Peen. A Docker -p rule does not make a listener on the container’s loopback interface reachable from the host.
/metrics covers the whole controller: model requests, tokens, retries, tool calls, jobs, hooks, turns, child agents, compactions, worker starts and exits, HTTP, SQLite, database file sizes, connected WebSocket clients in peen_websocket_clients, messages waiting for delivery in peen_queued_messages, and the Go runtime. Each worker sends its model, tool, job, and hook measurements to the controller over its session socket every five seconds and when it stops, so a worker crash can lose its last few seconds of them. The counters restart at zero when the controller restarts, which Prometheus treats as a counter reset.
Stored observations
The controller also writes those measurements, except HTTP and SQLite, to metrics.db in PEEN_STATE_DIR, one row per observation in the metric_observations table. A row holds the time in observed_at_ms, the kind, the session_id, worker_generation_id, turn_id, and agent_run_id, and the durations and token counts, so you can break time and cost down per session, worker, and child agent. Rows never hold message text, tool output, commands, or credentials. Workers never open the file. The controller writes every row and takes the session and worker generation from the connection that sent it.
kind | What it records |
|---|---|
model | One provider request. name is the model, and stage and function_name say what it was for. |
tool | One tool call. name is the tool. |
job, jobStarted, jobOutputDropped | A supervised process ending and starting, and the output lines its buffer dropped. |
hook | One hook event that matched a hook. name is the event. |
turn, turnStarted | A turn reaching a terminal state, and a turn admitted. |
agentRun, agentRunStarted | A child agent reaching a terminal state, and a child agent recorded. |
compaction | A stored compaction. input_tokens is what it summarized, output_tokens the summary, and count the source messages. |
workerStarted, workerExited | A worker launch attempt and a worker process ending. |
workerSample | A worker’s Go runtime every 30 seconds. count is goroutines and bytes is heap in use. |
eventDropped | An event a bounded delivery path dropped. |
Read it with any SQLite tool while Peen runs. This lists the slowest tools of the last hour:
sqlite3 "$PEEN_STATE_DIR/metrics.db" "
SELECT name, count(*), max(duration_ms) FROM metric_observations
WHERE kind = 'tool'
AND observed_at_ms > (unixepoch() - 3600) * 1000
GROUP BY name ORDER BY max(duration_ms) DESC"
PEEN_METRICS_RETENTION is a Go duration, default 168h. Peen deletes older rows at startup and every hour. 0 means the default, and a negative value stops startup. The file is disposable: stop Peen, delete metrics.db, metrics.db-wal, and metrics.db-shm, and Peen creates it again. When a build finds a metrics.db with a different schema, it logs a warning, moves the file to metrics.db.stale, replacing an earlier one, and starts a new file. Agents read their own session’s numbers with the readMetrics tool. Telemetry never fails a turn. If the file cannot be opened, Peen logs a warning and keeps serving /metrics, and peen_metrics_observations_dropped_total counts observations it could not store. An embedding Go program gets no metrics.db.
Context and turn limits
Every time setting is a Go duration such as 90s, 10m, or 1h30m. A bare number such as 120 and an empty value are rejected at startup. PEEN_MODEL_REQUEST_TIMEOUT must be positive. For PEEN_TOOL_TIMEOUT, PEEN_DEFAULT_TOOL_COMMAND_TIMEOUT, and PEEN_DEFAULT_HOOK_COMMAND_TIMEOUT, 0 means the default and a negative value fails startup. A setting whose name starts with PEEN_DEFAULT_ is a default that the call or file it applies to can override; the others are limits. For PEEN_LOG_RETENTION, 0 means the default, as logging explains.
| Variable | Default | Meaning |
|---|---|---|
PEEN_MAX_CONTEXT_TOKENS | 32768 | The whole context of one request, prompt and reply together. The prompt gets this minus PEEN_OUTPUT_RESERVE_TOKENS. It is also the context size for a model whose provider publishes none, and startup rejects a value larger than a published window. |
PEEN_OUTPUT_RESERVE_TOKENS | 4096 | Room kept for the model’s reply inside PEEN_MAX_CONTEXT_TOKENS. 0 keeps none. |
PEEN_COMPACTION_MODE | drop-oldest | drop-oldest or summarize. See compaction. |
PEEN_COMPACTION_SUMMARY_PERCENT | 30 | Share of PEEN_MAX_CONTEXT_TOKENS a generated summary gets, from 1 to 50, never more than PEEN_COMPACTION_MAX_OUTPUT_TOKENS. That summary room and PEEN_OUTPUT_RESERVE_TOKENS together must be smaller than PEEN_MAX_CONTEXT_TOKENS, and the check runs even when drop-oldest is active. |
PEEN_COMPACTION_MAX_OUTPUT_TOKENS | 16384 | Ceiling on the summary room. Keep it within the output limit of PEEN_COMPACTION_MODEL. |
PEEN_MODEL_REQUEST_TIMEOUT | 10m | Limit on each model request: every round of a turn and every compaction summary. A turn has no overall time limit; PEEN_MAX_TOOL_ROUNDS bounds how many model requests it makes. |
PEEN_CRASH_RECOVERY | pause | What happens to work a crashed controller or worker left unfinished, for every workspace this controller serves. pause keeps it paused until a turn asks the agent to continue it. resume lets the next worker continue it by itself, for deployments that run unattended. |
PEEN_MAX_CONCURRENT_TURNS | 16 | Turns that may run at once, across every session in the process. |
PEEN_MAX_QUEUED_USER_MESSAGES | 16 | Messages sent while a turn is running wait until the model’s current round ends. At most this many can wait at once, counting each skill message or already-loaded note that follows a queued message. A long turn can accept more in total, because delivery frees slots. Each one is reported with userMessage.queued and then userMessage.delivered. See queued messages. |
Compaction
A conversation grows until its prompt no longer fits in PEEN_MAX_CONTEXT_TOKENS minus PEEN_OUTPUT_RESERVE_TOKENS. Compaction is what Peen does then, and PEEN_COMPACTION_MODE picks how:
drop-oldestleaves whole old exchanges out of the request, each a user message with everything up to the next one, and stores nothing. The transcript keeps every message.summarizereplaces the oldest turns that have ended, including ones that failed or were stopped, with a summary made byPEEN_COMPACTION_MODEL, stores the summary, and never deletes transcript rows. The summary room,PEEN_COMPACTION_SUMMARY_PERCENTof the context up toPEEN_COMPACTION_MAX_OUTPUT_TOKENS, is kept free in the request and is the summarization call’s output limit. When the ended turns cannot free that much, for example when the system prompt and the current turn fill most of a small context, Peen tries half the room, then a quarter, down to 512 tokens. When no ended turn can free the room, as in one long turn that outgrows the context, Peen summarizes the oldest tool rounds of the turn in progress instead, keeping the turn’s opening message and its newest round as they are, and folds an earlier summary of the same turn into the next one. That summary lives only in the running turn’s context: Peen stores no compaction for it, and the transcript keeps every round. Peen leaves out the oldest history without a summary only when none of this fits. The model’s reasoning shares that limit, so Peen asks the model for the least reasoning it accepts. A later compaction summarizes the previous summary together with the messages after it. When the previous summary already fills three quarters of the room, Peen tells the model to write a tighter one, keeping open work, requirements, decisions, and exact identifiers ahead of finished detail. When a summary still stops at the limit, Peen asks once more for one in half the room and logs a warning with reasonsummary_over_limit_retried.PEEN_MODEL_REQUEST_TIMEOUTlimits each call. An optionalCOMPACTION.mdinPEEN_CONFIG_DIRreplaces the built-in summarizer instructions. Only this mode runs thepreCompactandpostCompacthooks.
The budget is a target. When nothing more can be left out, for example when one round’s tool results alone exceed it, the request goes out over the budget with a warning in the log, and the provider decides whether it fits its real window.
Under summarize a child agent compacts its own transcript into its own stored summaries, separate from the session’s. GET /api/v1/session/compactions and GET /api/v1/session/agents/{agentRunId}/compactions read them. Peen checks the summary settings in both modes, so switching modes cannot expose a setting startup already accepted.
Message size bounds
| Variable | Default | Meaning |
|---|---|---|
PEEN_MAX_MESSAGE_BYTES | 262144 | Limits the message field in a message.send WebSocket frame. |
PEEN_MAX_SYSTEM_PROMPT_BYTES | 65536 | Limits the optional systemPrompt.content field of a message.send frame. |
PEEN_MAX_SYSTEM_PROMPT_PERCENT | 50 | Refuses a turn, or a child agent launch, whose system prompt is estimated at more than this percent of PEEN_MAX_CONTEXT_TOKENS. The system prompt includes the base prompt, every rule and AGENTS.md file that loads on every turn, learned rules, and the agent’s instructions. Rules scoped with paths do not count. Peen estimates with the o200k tokenizer, so the count is close to, not equal to, what a provider bills. The refusal is message.failed with HARNESS_CONFIGURATION_INVALID, and the controller log names the three largest sections. 0 turns the check off. See The prompt budget. |
PEEN_MAX_IMAGES_PER_MESSAGE | 4 | Limits the images one message.send frame carries. |
PEEN_MAX_IMAGE_BYTES | 5242880 | Limits one image, decoded, in a message.send frame or one readFile call. Startup refuses a value that, times PEEN_MAX_IMAGES_PER_MESSAGE, comes to more than 28 MiB of base64, because the message crosses the worker socket in one frame. Raising both raises the largest frame the WebSocket reads. |
PEEN_MAX_STORED_MESSAGE_BYTES | 1048576 | Limits any stored message of any role, since an assistant or tool message has no caller-facing limit of its own. It does not limit a turn’s whole context, which Peen saves under its own 12 MiB limit, or this value if it is larger. That holds a 1,000,000-token context. |
Tool execution limits
| Variable | Default | Meaning |
|---|---|---|
PEEN_MAX_TOOL_ROUNDS | 32 | Tool-call rounds allowed in one turn. |
PEEN_MAX_CONCURRENT_TOOLS | 4 | Tool calls one turn may run at once. |
PEEN_TOOL_TIMEOUT | 15m | Limit on one call of any tool except runCommand and waitJob, its hooks included. Those two wait as long as the call asks; see process jobs. |
PEEN_MAX_TURN_TOKENS | 0 | Tokens one turn may spend, prompt and output of every model round together, failed provider attempts included. Before each round Peen checks what the turn’s rounds have billed so far and fails the turn when that reached the limit, so the last round can go over it. A child agent’s run counts on its own, against the same limit. 0 sets no limit. |
PEEN_MAX_TOOL_RESULT_TOKENS | 8192 | Limits a tool result before it goes back to the model, including its <toolOutput> wrapper. |
PEEN_TOOL_MAX_LIST_ENTRIES | 1000 | listFiles entry limit. |
PEEN_TOOL_MAX_LIST_DEPTH | 16 | listFiles recursion depth limit. |
PEEN_TOOL_MAX_SEARCH_MATCHES | 200 | searchText match limit. |
PEEN_TOOL_MAX_SEARCH_FILE_BYTES | 2097152 | Largest file searchText will scan. |
PEEN_TOOL_MAX_READ_BYTES | 262144 | readFile byte limit per call. |
PEEN_TOOL_MAX_READ_LINES | 2000 | readFile line limit per call. |
PEEN_TOOL_MAX_WRITE_BYTES | 4194304 | writeFile size limit. |
PEEN_TOOL_MAX_EDITS | 64 | Replacements allowed in one editFile call. |
PEEN_TOOL_MAX_DIFF_BYTES | 65536 | Size limit on the diff editFile returns. |
PEEN_TOOL_MAX_REMOVE_ENTRIES | 20000 | removePath recursive entry limit. |
PEEN_TOOL_MAX_COMMAND_OUTPUT_BYTES | 65536 | runCommand captured stdout and stderr limit. |
PEEN_TOOL_MAX_PDF_BYTES | 33554432 | Largest PDF readFile reads to extract its text. A larger PDF is refused. |
PEEN_TOOL_MAX_FETCH_BYTES | 2097152 | How much of a response body webFetch reads. The text it returns is then limited by PEEN_MAX_TOOL_RESULT_TOKENS. |
PEEN_DEFAULT_TOOL_COMMAND_TIMEOUT | 10m | How long runCommand and waitJob wait when a call passes no timeout. A call may ask for any wait. The command is never killed when the wait ends. A command still running returns a job handle instead. |
Hook execution limits
| Variable | Default | Meaning |
|---|---|---|
PEEN_DEFAULT_HOOK_COMMAND_TIMEOUT | 30s | Limit on a command hook action that sets no timeout of its own. |
PEEN_MAX_HOOK_COMMAND_OUTPUT | 65536 | Maximum stdout or stderr captured from one hook command. |
See hooks for the file format, matching rules, event order, and how actions run.
Session notices
Background jobs, child agents, hooks, and outside callers report what happened through session notices, also called session events. Peen hands them to the model at the next turn or tool boundary, or starts a turn when an event handler allows a wake.
| Variable | Default | Meaning |
|---|---|---|
PEEN_MAX_PENDING_EVENTS | 256 | Per-session limit on the in-memory copy of pending notices. The oldest copy is dropped and counted in metrics. Delivery to the model reads stored notices from SQLite, so this limit never drops what the model receives. |
PEEN_MAX_EVENT_SUMMARY_BYTES | 4096 | Largest summary on a notice. POST /api/v1/session/notices rejects a longer one with 400 VALIDATION_FAILED, and a hook that emits one fails its action. Peen shortens its own job.* and agent.* summaries to fit, without splitting a character. |
PEEN_MAX_EVENT_DATA_BYTES | 65536 | Largest encoded data on a notice. POST /api/v1/session/notices rejects larger data with 400 VALIDATION_FAILED, and a hook that emits it fails its action. Peen leaves over-size data out of its own job.* and agent.* events. |
PEEN_MAX_EVENT_WAKES_PER_HOUR | 60 | Per-session limit on turns started by a wake. An event over the limit stays queued for the next turn or tool boundary. 0 removes the limit. Peen keeps the count in memory, so it starts over when Peen restarts. |
See session events for event types, delivery, wakes, and event handlers.
Child agent limits
| Variable | Default | Meaning |
|---|---|---|
PEEN_MAX_CHILD_AGENT_DEPTH | 5 | How many launchAgent calls may nest. A child at that depth does not receive launchAgent. |
PEEN_MAX_CHILD_AGENT_TURNS | 16 | Tool rounds one child conversation may run. |
PEEN_MAX_CONCURRENT_AGENT_RUNS | 4 | Agent runs one session may have in flight at once. |
PEEN_MAX_AGENT_RUN_EVENT_COUNT | 2000 | How many of a running child’s recent events Peen keeps in memory for clients following it live. SQLite keeps every event regardless. |
PEEN_MAX_AGENT_RUN_EVENT_BYTES | 65536 | Byte limit on that same in-memory list. |
PEEN_MAX_ADHOC_AGENT_INSTRUCTION_BYTES | 65536 | Size limit on an inline agentDefinition.instructions string. |
PEEN_ASK_TIMEOUT | 0 | How long the ask tool waits for an answer, as a Go duration such as 30m. The model cannot change it. 0 sets no limit of its own, so ask waits up to the 30-day cap every messaging tool has, and a child whose question nobody answers holds one of the PEEN_MAX_CONCURRENT_AGENT_RUNS slots until someone answers or stops it. The root’s ask waits the same way. A negative value stops startup. |
A child’s inbox holds at most PEEN_MAX_QUEUED_USER_MESSAGES messages that it has not read yet, the same bound as the root’s queue. See running children.
Harness layering
Rules, skills, named agents, event handlers, and hooks are files Peen reads from the configuration directory and from every directory between / and the agent’s current directory, fresh at the start of every turn and on each changeDirectory call. The harness guide covers the layer order, how layers combine, what the model sees, and the limits. Each piece has its own page: rules, skills, named agents, session events and event handlers, and hooks.
Every root and child turn also receives the server’s current local time, timezone, operating system, CPU architecture, logical CPU count, and Go runtime as trusted context. The built-in freshness skill tells the model to check local project facts and verify outside facts that may have changed.
Process jobs
runCommand never kills a process because a wait ran out. A command that exits within its wait returns its exit status and output normally. A command still running when the wait ends keeps running, and the tool returns a job handle, the output captured so far, and a note telling the model to call waitJob with that job and a longer timeout to keep waiting, rather than run the command a second time. The background argument skips the wait and returns the handle at once.
Stopping or cancelling the turn while runCommand is still awaiting a foreground command kills that command’s process group. Peen records the kill request and waits for job finalization within the job stop bound. A signal-recording failure still kills the process and is returned with the cancellation error. Cancellation keeps foreground completion out of detached-job wake events.
runCommand and waitJob take timeout as a Go duration string such as "90s", "5m", or "1h30m". Leaving it out uses PEEN_DEFAULT_TOOL_COMMAND_TIMEOUT, and 0s, negative values, and bare numbers are rejected. There is no upper limit, so an agent can wait hours for a build in one call, and the tool descriptions tell the model the default and that it may ask for longer. These two tools are exempt from PEEN_TOOL_TIMEOUT; only the 30-day cap the waiting tools share bounds them.
Detached jobs belong to the session, not the turn. Once runCommand returns a running job handle, that command stays listable, readable and signalable from a later turn; cancelling the earlier turn does not stop it. Peen writes every stdout and stderr line to SQLite before it enters a small in-memory buffer, which only the active tool call reads. REST output replay, job details, and signal history come from SQLite and survive reconnects and restarts. On shutdown, Peen stops every running job, waits a grace period, then kills its process group, so no job outlives Peen. A job that finishes after its runCommand call returned publishes a job.exited, job.signalled, or job.failed session event.
Agent run observability
Each launchAgent call creates a SQLite run record and stored event records. Peen updates the record when a cancel is requested and when the run ends. Use the session agent-run REST endpoints to inspect or cancel a run, list the runs waiting on a question, and read a run’s events during and after it. SQLite is the only stored replay record. Peen keeps no JSONL copy of transcripts.