README
¶
Agent Pi
Generic, domain-agnostic Pi CLI runner. Receives a task from the agent pipeline, spawns the pi CLI (from @earendil-works/pi-coding-agent) with configurable tools and instructions, and returns a structured JSON result.
New agents are created by swapping instructions (agent AGENTS.md guardrails) and ALLOWED_TOOLS — no Go code changes needed.
How It Works
- Agent pipeline ([[task/controller]] → Kafka → [[task/executor]]) spawns a K8s Job with the
agent-piimage. - The Job receives
TASK_CONTENT,TASK_ID,BRANCH,ALLOWED_TOOLS,MODEL,PHASE, etc. via env vars. main.goassembles the prompt vialib/pi(embeddedworkflow.md+output-format.md+ task content).- Runs
pi --print --mode jsonwith the allowed tools and selected model — adding--no-sessionunless the agent is a service agent, whose sessions must persist (see below). - Parses the JSON result and publishes to Kafka via
lib/delivery.KafkaResultDeliverer(whenTASK_IDset), or falls back toNoopResultDelivererfor local runs.
Service Agents
A Config with spec.type: service is a long-running identity rather than a task runner: the executor stamps AGENT_TYPE=service, and the binary then stays alive instead of running one task and exiting. It serves these endpoints on LISTEN (default :9090):
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/readiness |
GET | public | 200 when the provider is reachable, 503 when it is not (or unparseable); skipped, and said to be skipped, when PROVIDER_BASE_URL is unset |
/metrics |
GET | public | Prometheus registry |
/.well-known/agent-card.json |
GET | public | The A2A Agent Card: the agent's name and the externally reachable A2A endpoint it advertises |
/prompt |
POST | bearer | Runs one prompt through the Pi runner and returns its answer as plain text |
/a2a |
POST | bearer | The A2A JSON-RPC binding; a managing session sends SendMessage here and holds one conversation by conversation id |
/prompt is what makes the shape addressable, and it is the seam a chat transport would use. Its body is the prompt and its response is the runner's result; the handler itself persists nothing. Session continuity belongs to the runner, via PiRunnerConfig.PersistSession, which is on for a service agent and off for a task-routed one — a task-routed agent's runs are unrelated tasks sharing one volume, so resuming one task's conversation inside another would be a defect rather than a feature.
Authentication and the A2A surface
Every gated route — /prompt and /a2a — requires Authorization: Bearer <INTERACTIVE_AUTH_TOKEN>. A request without it, or with the wrong token, is refused with 401 before its body is read and before any session is constructed. Only /readiness, /metrics and the Agent Card at /.well-known/agent-card.json stay unauthenticated: a kubelet probe and a Prometheus scrape cannot carry a token without writing it into the pod spec and the scrape configuration, and the card is public by design — it advertises the endpoint and the agent's name and carries no credential.
The A2A surface lets a managing session discover this agent (through the card), authenticate to it, and hold a multi-turn conversation with it at /a2a, addressed by one conversation id it chooses up front. The protocol, the JSON-RPC binding, the session cache and the auth gate are implemented by github.com/bborbe/agent/interactive; this repository only configures them.
A service agent must now set three variables — INTERACTIVE_AUTH_TOKEN, A2A_PUBLIC_URL and A2A_AGENT_NAME — and a service agent that does not set them fails to start. This is a breaking change for every existing service deployment: the shared service mounts the A2A routes unconditionally, and its zero auth refuses every gated route including /prompt, so leaving the token unset would not preserve the old unauthenticated intake — it would answer 401 everywhere. The other two are this repository's own gate rather than the library's — validateServiceConfig rejects them, while the library defaults the card's name to interactive and accepts an empty public URL. A2A_PUBLIC_URL must be an address a managing session can actually dial (not the bind address), and A2A_AGENT_NAME is the name the card advertises.
Choosing a session
X-Session-Id is an optional request header on /prompt naming the conversation the prompt belongs to. A request that omits it is served from the default session, exactly as it was before the header existed, so an existing caller needs no change. A request that sends it is served from its own conversation, created on first use.
The accepted format is [A-Za-z0-9_][A-Za-z0-9_-]{0,63} — one to 64 characters, the first of which is not -. Anything else, including a present-but-empty value, is answered 400 before the agent process is invoked. The format is a security boundary rather than a style preference: the id reaches the pi CLI as a command-line argument, where a leading - would be read as a flag.
Requests on different session ids run at the same time. Requests on one session id are serialized — one turn at a time — because a session's transcript is a single store on the mounted volume, and two interleaved runs there would corrupt the continuity the session exists to keep.
A session id is an address, not a credential: the header does not isolate a caller from anyone else who can reach the endpoint with the bearer token. The service cannot list, rename, or delete sessions.
The endpoint is reachable only from inside the namespace (there is no Ingress), and every gated route requires the bearer token above. The request body is bounded at 1 MiB, and the prompt content is never logged — only its length and a short digest. The session id does appear, and only in the per-turn turn start id=<id> / turn end id=<id> pair logged at V(2), which is what makes per-session serialization visible from outside the process.
Two conversations on one pod
Four calls: give each session a different word, then ask each one back. They do not see each other.
AUTH="Authorization: Bearer $INTERACTIVE_AUTH_TOKEN"
curl -s -X POST -H "$AUTH" -H 'X-Session-Id: session-a' --data 'Remember this word: pelican. Reply with just: OK' http://localhost:9090/prompt
curl -s -X POST -H "$AUTH" -H 'X-Session-Id: session-b' --data 'Remember this word: walrus. Reply with just: OK' http://localhost:9090/prompt
curl -s -X POST -H "$AUTH" -H 'X-Session-Id: session-a' --data 'What word did I give you? Reply with just that word.' http://localhost:9090/prompt
curl -s -X POST -H "$AUTH" -H 'X-Session-Id: session-b' --data 'What word did I give you? Reply with just that word.' http://localhost:9090/prompt
The four answers come back OK, OK, pelican, walrus — session-a never sees walrus, and session-b never sees pelican. Drop the -s to see each response with its status code.
Swap localhost:9090 for whatever reaches the pod: in-cluster that is http://pi-service-0:9090, and from a workstation a kubectl port-forward pod/pi-service-0 9090:9090 makes it localhost:9090 as written.
Env Vars
| Var | Required | Default | Purpose |
|---|---|---|---|
TASK_CONTENT |
yes | — | Raw task markdown |
BRANCH |
yes | — | dev/prod — used as Kafka topic prefix |
TASK_ID |
no | — | Required when publishing results via Kafka |
MODEL |
no | MiniMax-M2.7-highspeed |
Model name passed to pi --model |
ALLOWED_TOOLS |
no | — | Comma-separated pi tool allowlist (e.g. Read,Grep,Bash) |
AGENT_DIR |
no | agent |
Directory containing AGENTS.md guardrails (used as pi cwd) |
PROVIDER_API_KEY |
no | — | MiniMax API key, threaded into the pi subprocess as MINIMAX_API_KEY |
ENV_CONTEXT |
no | — | Comma-separated KEY=VAL pairs injected into the prompt |
PHASE |
no | execution |
Agent phase: planning | execution | ai_review |
KAFKA_BROKERS |
no | — | Required when TASK_ID is set |
SENTRY_DSN |
no | — | Error reporting |
PUSHGATEWAY_URL |
no | http://pushgateway:9090 |
Prometheus PushGateway URL |
TASK_TYPE |
no | unknown |
Task type label for metric grouping |
INTERACTIVE_AUTH_TOKEN |
service agents | — | Bearer token every gated route requires; the shared service refuses every gated route (/prompt included) without it |
A2A_PUBLIC_URL |
service agents | — | Externally reachable A2A endpoint the Agent Card advertises (not the bind address); required by this repository's own validateServiceConfig |
A2A_AGENT_NAME |
service agents | — | Name the Agent Card advertises; required by this repository's own validateServiceConfig |
Pi's own config (auth state, sessions) lives in $HOME/.pi. The K8s manifest mounts a PVC at /home/pi/.pi; locally pi resolves it from your home dir. There is no CLAUDE_CONFIG_DIR equivalent — pi does not have OAuth, it uses MINIMAX_API_KEY.
Creating a New Agent
To add a domain-specific agent that reuses this binary:
- Create a task file in the OpenClaw vault with
assignee: pi-agent(or a new assignee routed to this image via a Config CRD). - Mount a PVC or Secret containing the domain-specific
AGENTS.mdguardrails and any API credentials. - Set
ALLOWED_TOOLSon the Config CRD to the minimum tools the agent needs. - Set
ENV_CONTEXTto inject domain context (e.g. API URLs) into the prompt without modifying the binary.
Config CRD env pattern
The Config CRD's spec.env map becomes pod env vars, which main.go consumes via struct tags. Example from k8s/agent-pi.yaml:
spec:
env:
ALLOWED_TOOLS: Read,Bash,Grep,Glob,Write,Edit
MODEL: MiniMax-M2.7-highspeed
Tune ALLOWED_TOOLS per task shape (minimum viable set):
| Task shape | Minimum tools |
|---|---|
| Web research | WebSearch,WebFetch,Read,Grep |
| Vault I/O via scripts | Bash(scripts/vault-read.sh:*),Bash(scripts/vault-write.sh:*),Bash(scripts/vault-list.sh:*),Grep |
| API query via script | Bash(scripts/trading-api-read.sh:*),Grep |
| Code edit | Read,Write,Edit,Grep,Glob,Bash(go:*),Bash(make:*) |
Prefer constrained Bash(path:*) forms over bare Bash to minimize shell attack surface.
Pi subprocess env allowlist
lib/pi/pi-runner.go strips pod env down to a safe allowlist (HOME,PATH,USER,TZ,… plus known provider API keys: ANTHROPIC_API_KEY, OPENAI_API_KEY, MINIMAX_API_KEY, GEMINI_API_KEY, PROVIDER_API_KEY, …) before spawning pi. Custom env vars beyond that list must be threaded explicitly via PiRunnerConfig.Env map[string]string in main.go. Don't expect arbitrary pod env to reach pi by default.
Local Quick Test
cd ~/Documents/workspaces/agent/agent/pi
go run . \
--task-content "$(cat /path/to/task.md)" \
--model MiniMax-M2.7-highspeed \
--allowed-tools "Read,Write,Edit,Bash,Grep,Glob" \
--agent-dir agent \
--branch dev
Skips K8s, task controller, task executor, git writeback. Useful for iterating on prompts.
For a file-based local run (reads the task from disk, writes the result back), see cmd/run-task/.
Links
Admin endpoints:
- Dev: https://dev.quant.benjamin-borbe.de/admin/agent-pi/setloglevel/3
- Prod: https://prod.quant.benjamin-borbe.de/admin/agent-pi/setloglevel/3
Related
pkg/prompts/— embedded prompts (workflow.md,output-format.md)agent/AGENTS.md— default agent guardrailslib/pi/— shared prompt assembly + pi CLI invocationlib/delivery/— shared Kafka result publishingtask/controller/— Obsidian→Kafka event sourcetask/executor/— Kafka→K8s Job spawner
License
This project is licensed under the BSD-style license. See the LICENSE file for details.
Documentation
¶
Overview ¶
Command agent-pi is the canonical AI-heavy agent: one Pi invocation per phase, all logic in the prompt + allowed tools.
This binary is the Kafka entry point — spawned as a K8s Job by task/executor with TASK_CONTENT + TASK_ID + PHASE + KAFKA_BROKERS env. For local CLI mode (file-based), see cmd/run-task/main.go.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
run-task
command
Command run-task is the local-CLI entry point for agent-pi.
|
Command run-task is the local-CLI entry point for agent-pi. |
|
pkg
|
|
|
factory
Package factory wires concrete dependencies for the agent-pi binary.
|
Package factory wires concrete dependencies for the agent-pi binary. |