Pi Sidecar HTTP API
@myk-org/pi-sidecar wraps the Pi SDK in a small HTTP service. Your app sends prompts over HTTP; the sidecar creates a Pi session, streams the turn, and returns the result. A Python client (pi-sidecar-client) and a Node client are both published from the same package, so both sides always agree on the wire format.
Use it when you want model calls from a non-Pi process — a backend service, a script, or a bot — with Pi's session handling, project context, and tool execution, without embedding the whole TUI agent.
Prerequisites
| Requirement | Value | Source |
|---|---|---|
| Node.js | >=22.19.0 |
packages/pi-sidecar/package.json engines.node |
@earendil-works/pi-coding-agent |
>=1.4.0 |
MIN_PI_VERSION in packages/pi-sidecar/src/pi-version.ts |
@earendil-works/pi-ai |
>=1.4.0 |
peer dependency in packages/pi-sidecar/package.json |
| Python (client only) | >=3.10 |
requires-python in packages/pi-sidecar/pyproject.toml |
The sidecar enforces the Pi SDK floor at startup and exits if the installed @earendil-works/pi-coding-agent is older than 1.4.0 or cannot be resolved. A stale global pi on PATH only produces a warning, but subagent calls spawn that binary and may still fail.
Getting Started
1. Install
The npm package and the Python client share one version number and are released together. Bump both, and use a range rather than an exact pin so upstream fixes can reach you.
npm install @myk-org/pi-sidecar
# or: pip install pi-sidecar-client
CLIs after install: pi-sidecar (server) and pi-sidecar-start (dev start script).
2. Start the sidecar
Production-style (default port 9100):
npx pi-sidecar
# or: node dist/server.js
Local dev helper (default port 9201):
npx pi-sidecar-start
# foreground: npx pi-sidecar-start --foreground
# stop: npx pi-sidecar-start --stop
| Variable | Default (pi-sidecar) |
Default (pi-sidecar-start) |
Effect |
|---|---|---|---|
SIDECAR_PORT |
9100 |
9201 |
Listen port |
SIDECAR_HOST |
127.0.0.1 |
127.0.0.1 |
Bind address (DEV_MODE=true → 0.0.0.0 if host unset) |
SIDECAR_URL |
— | — | Client base URL (Python default http://127.0.0.1:9100) |
PI_SIDECAR_LOG_LEVEL |
info |
info |
Server / client log level |
Tip: Point the client at the port you actually started. Dev script uses 9201; the Node server and Python client default to 9100.
export SIDECAR_URL=http://127.0.0.1:9201 # when using pi-sidecar-start
3. Wait until healthy
curl -s http://127.0.0.1:9100/health
status |
Meaning |
|---|---|
ok |
Ready (model discovery finished) |
starting |
Discovery still running (HTTP 503) |
degraded |
Discovery failed; sessions may still work for already-known models |
from pi_sidecar_client import check_sidecar_available
ok, msg = await check_sidecar_available()
print(ok, msg)
4. Discover models
from pi_sidecar_client import list_models
all_models = await list_models()
gemini = await list_models(provider="gemini")
Friendly Python provider aliases map to sidecar ids:
| Client alias | Sidecar provider |
|---|---|
gemini |
google |
cursor |
acpx-cursor (model prefixed with cursor: if needed) |
claude |
google-vertex-claude |
Refresh after changing agents or auth:
curl -X POST http://127.0.0.1:9100/models/refresh
Diagnose a single provider:
from pi_sidecar_client import get_sidecar_client
client = get_sidecar_client()
status = await client.get_model_provider_status("google")
print(status["registered"], status["modelCount"])
List registered providers even when they expose no models or credentials yet:
providers = await get_sidecar_client().get_providers()
Note: On non-loopback binds, auth fields on provider status are redacted. Prefer
127.0.0.1for local diagnostics. For Vertex auth setup, see Google Vertex Claude Provider.
5. Run a session
Single-shot (auto cleanup): use call_ai_once as in the Quick Example.
Multi-turn:
from pi_sidecar_client import call_ai, get_sidecar_client
result = await call_ai(
"I'm building a REST API in Python. What framework should I use?",
ai_provider="gemini",
ai_model="gemini-2.5-flash",
system_prompt="You are a senior Python developer. Be concise.",
)
session_id = result.session_id
result = await call_ai(
"Show me a minimal example with that framework.",
ai_provider="gemini",
ai_model="gemini-2.5-flash",
session_id=session_id,
)
await get_sidecar_client().delete_session(session_id)
Session lifecycle:
- Create session (
POST /sessions) with provider, model, system prompt, cwd. - Prompt (
POST /sessions/:id/prompt) — one in-flight prompt per session. - Optional abort (
POST /sessions/:id/abort). - Delete (
DELETE /sessions/:id) when finished.
Default built-in tools when you omit tools: read, grep, find, ls, bash.
cwd loads project resources from {cwd}/.pi/ and AGENTS.md. Optional agent_dir points at a global agent directory (e.g. ~/.pi/agent); it is rejected on non-loopback binds unless DEV_MODE=true (then ignored).
Advanced Usage
HTTP API surface
| Method | Path | Effect |
|---|---|---|
GET |
/health |
Readiness (ok / starting / degraded) |
GET |
/providers |
Registered providers, even with no models or credentials |
GET |
/models |
List discovered models |
POST |
/models/refresh |
Re-run discovery |
POST |
/models/for-api-key |
Discover models for a caller-supplied api_key |
GET |
/models/:provider/status |
Registration, model count, auth (redacted off-loopback) |
POST |
/sessions |
Create session → { session_id } |
POST |
/sessions/:id/prompt |
Send { "message": "..." } |
POST |
/sessions/:id/abort |
Cancel in-flight prompt |
DELETE |
/sessions/:id |
Destroy session |
Body size limit: 1 MiB. Concurrent prompts on the same session return conflict (busy).
Custom HTTP-backed tools
Pass custom_tools when creating a session. Entries with an http block are executed as outbound HTTP; {param} placeholders interpolate from tool args.
result = await call_ai_once(
"Look up user 42",
ai_provider="gemini",
ai_model="gemini-2.5-flash",
custom_tools=[{
"name": "fetch_data",
"description": "Fetch user data by ID",
"parameters": {
"type": "object",
"properties": {"userId": {"type": "string"}},
},
"http": {
"method": "GET",
"url": "https://api.example.com/users/{userId}",
},
}],
)
http field |
Type | Default | Effect |
|---|---|---|---|
method |
GET | POST | PUT | DELETE | PATCH |
(required) | HTTP method |
url |
string | (required) | URL template with {params} |
headers |
object | — | Header templates |
query_params / queryParams |
object | — | Query templates |
body_template / bodyTemplate |
object | string | — | Body template |
timeout_ms / timeoutMs |
number | 30000 |
Request timeout |
Restrict the built-in tool allowlist with tools=["read", "grep"] (or [] for none).
ACPX and CLI model sources
export ACPX_AGENTS=cursor
export CLI_AGENTS=claude,gemini
npx pi-sidecar
Then use providers such as acpx-cursor / cli-claude (or Python aliases cursor / mapped names). Refresh with POST /models/refresh after changing env. See Configuration & Settings for project-level agent lists used by interactive Pi.
Custom providers and settings (agent dir)
By default the sidecar's internal runtime keeps its catalog in the ephemeral,
non-configurable scratch dir /tmp/pi-sidecar-agent. Deployments that need to
register custom pi providers or supply pi settings point the sidecar at a
persistent agent dir instead:
# Environment variable (honored by npx pi-sidecar / pi-sidecar-start / node dist/server.js)
export PI_SIDECAR_AGENT_DIR=/etc/pi-sidecar/agent
npx pi-sidecar
// Or programmatically
import { startSidecar } from "@myk-org/pi-sidecar";
startSidecar({ agentDir: "/etc/pi-sidecar/agent" });
Precedence: explicit agentDir option → PI_SIDECAR_AGENT_DIR (trimmed,
non-empty) → /tmp/pi-sidecar-agent. Resolution is logged at startup. Use an
absolute path — a relative path resolves against the sidecar process's cwd,
which differs between the CLI entry and embedded startSidecar() callers.
Files honored from the configured dir (read once at process start — restart the sidecar after changing them):
| File | Effect |
|---|---|
models.json |
Custom pi providers registered on the shared runtime — listed by GET /models and GET /providers, usable in POST /sessions; providers flagged trustKeyScopedListing also serve POST /models/for-api-key |
auth.json |
Provider credentials for the shared runtime (same format as interactive pi's auth.json) |
settings.json |
Seeded into the in-memory settings store for the internal runtime and every session (e.g. defaultProvider) |
A models.json apiKey may reference an environment variable (e.g.
"$ACME_API_KEY") — the variable must exist in the sidecar process's
environment (pass it through in Docker with -e / Env:). Worked example —
an Acme OpenAI-compatible gateway at /etc/pi-sidecar/agent/models.json:
{
"providers": {
"acme": {
"baseUrl": "https://acme.example.com/v1",
"api": "openai-completions",
"apiKey": "$ACME_API_KEY",
"models": [
{ "id": "acme/gpt-4o", "name": "GPT-4o via Acme" }
]
}
}
}
export PI_SIDECAR_AGENT_DIR=/etc/pi-sidecar/agent
export ACME_API_KEY=<your-key>
npx pi-sidecar
# GET /models now lists acme/gpt-4o (provider "acme");
# POST /sessions accepts {"provider": "acme", "model": "acme/gpt-4o", ...}
Notes:
-
The per-request
agent_dirfield onPOST /sessionsstill overrides only the session's resource loading (skills/prompts/agents) — it never reconfigures the shared runtime. -
There is no REST API change:
/health,/models,/providers, and/sessionskeep their semantics; the configured dir only changes what the runtime is seeded with at startup. -
Settings seeded from
settings.jsonnever re-enable compaction — sidecar sessions are short-lived and always run with compaction disabled.
Key-scoped listing for custom providers (trustKeyScopedListing)
By default POST /models/for-api-key refuses to send a request-scoped (user)
key to any host that is not a provider-owned canonical builtin endpoint or a
literal loopback address, so custom gateways return
{"models": [], "modelListingSupported": false}. An admin can opt a single
custom provider into key-scoped model listing by flagging it in the agent
dir's models.json (read once at startup — restart the sidecar to apply):
{
"providers": {
"acme": {
"baseUrl": "https://acme.example.com/v1",
"api": "openai-completions",
"apiKey": "$ACME_API_KEY",
"trustKeyScopedListing": true,
"models": [
{ "id": "acme/gpt-4o", "name": "GPT-4o via Acme" }
]
}
}
}
A flagged provider's listing request goes to exactly its configured baseUrl
— the same host the manifest's server credential (apiKey) already goes to.
The flag is honored only from this admin-controlled manifest; request- or
session-level input can never set it, and unflagged providers keep the default
refusal. URL hygiene is still enforced (no userinfo, query, or hash; only
http:/https:), and the provider must declare at least one model with all
models sharing the provider baseUrl (the existing uniformity check). The
extra field is ignored by pi's provider registration, so it does not affect
GET /models or POST /sessions.
For a flagged provider whose native listing succeeds, POST
/models/for-api-key returns the union of the live gateway listing and the
provider's manifest-configured models[] (snapshot at startup, same file
read as the flag). Real case: an Acme gateway lists 15 models via
/v1/models but not the manually configured rits/zai-org/glm-5-3 — the
response contains both. Manifest-only models are appended after the live
entries in the same response shape (provider/id/name/source, plus
capabilities.inputTokenLimit/capabilities.outputTokenLimit when configured —
the models.json contextWindow/maxTokens mapped onto the same schema live
entries use). On an
id collision the manifest entry wins — the admin explicitly configured it —
replacing the live entry's name and metadata at the live position. The union
applies only to that provider's own listing; one provider's manifest models
never appear in another's response. When the native listing fails or is
refused (modelListingSupported: false), no union is applied.
Every entry carries a source provenance discriminator so consumers can
distinguish verified from admin-declared models: "listing" means the
gateway listed the id for this key — the id is verified for the credential
(this includes manifest-wins collision entries: the gateway did list the id,
and the manifest only supplied the name and token limits). "manifest"
means the entry is admin-declared in models.json only and was not
verified for this key — a user with a model-restricted key may not be able to
use it, so consumers must not treat it as accessible until access is confirmed
with that key.
A key-listed model used in POST /sessions with an api_key needs token
limits: when the live listing omits them, the sidecar falls back to the
manifest-declared contextWindow/maxTokens for that id, and otherwise
rejects the session with a 400 naming the models.json remedy — limits are
never fabricated for an unknown model.
Watchdog (companion process)
export SIDECAR_WATCHDOG_URL=http://127.0.0.1:8000/health
npx pi-sidecar
When set, the sidecar polls that URL and shuts itself down after consecutive failures (default: 60s grace, 30s interval, 10s timeout, 6 failures).
Docker / process coupling
Start the sidecar, wait for /health, then start your app and trap cleanup — see the package entrypoint.example.sh pattern (SIDECAR_PORT default 9100).
Using vs extending
| Goal | What you do |
|---|---|
| Use | Run pi-sidecar, call call_ai_once / SidecarClient, pass custom_tools / tools |
| Extend | Call startSidecar({ port, host, watchdogUrl, watchdogOptions }) from Node, or override extension paths with SIDECAR_ACPX_EXTENSION_PATH, SIDECAR_CLI_PROVIDER_EXTENSION_PATH, SIDECAR_PROVIDER_EXTENSION_PATH, SIDECAR_VERTEX_EXTENSION_PATH, SIDECAR_SUBAGENT_EXTENSION_PATH |
Startup lifecycle:
- Assert Pi SDK version ≥ 1.4.0 (
MIN_PI_VERSION). - Listen on host/port.
- Optionally start watchdog.
- Async model discovery —
/healthisstartinguntil ready. - Serve session/prompt routes; idle sessions cleaned after 1 hour.
Parallel calls and usage hooks
from pi_sidecar_client import run_parallel_with_limit, set_usage_recorder
set_usage_recorder(my_recorder) # sync or async; kwargs: request_id, result, call_type, ...
results = await run_parallel_with_limit(tasks, max_concurrency=5)
ai_call_timeout on call_ai / call_ai_once is in minutes (converted to HTTP timeout seconds).
Troubleshooting
| Problem | Fix |
|---|---|
| Client connection errors | Align SIDECAR_URL with the server port (9100 vs 9201). |
/health stuck on starting / 503 |
Wait for discovery; check logs (PI_SIDECAR_LOG_LEVEL=debug). |
| Empty model list | Auth providers; set ACPX_AGENTS / CLI_AGENTS; POST /models/refresh; check GET /models/:provider/status. |
| Session create rejects model | Use an id from GET /models; interactive OAuth-only providers are blocked in this headless service. |
409 / session busy |
Wait for the current prompt or POST .../abort. |
agent_dir rejected |
Bind on loopback, or use DEV_MODE=true (value discarded). |
| Pi version error at startup | Upgrade @earendil-works/pi-coding-agent (and pi-ai) to ≥ 1.4.0. |
Warning: Default bind is loopback-only. Binding to
0.0.0.0exposes an unauthenticated AI API on the network — use only behind a trusted boundary.