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.1 for 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:

  1. Create session (POST /sessions) with provider, model, system prompt, cwd.
  2. Prompt (POST /sessions/:id/prompt) — one in-flight prompt per session.
  3. Optional abort (POST /sessions/:id/abort).
  4. 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_dir field on POST /sessions still 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 /sessions keep their semantics; the configured dir only changes what the runtime is seeded with at startup.

  • Settings seeded from settings.json never 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:

  1. Assert Pi SDK version ≥ 1.4.0 (MIN_PI_VERSION).
  2. Listen on host/port.
  3. Optionally start watchdog.
  4. Async model discovery — /health is starting until ready.
  5. 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.0 exposes an unauthenticated AI API on the network — use only behind a trusted boundary.