API Examples
End-to-end scripting recipes against the REST + SSE surface.
End-to-end recipes for the most common things you'll do programmatically: list projects, create a session, send a prompt, stream the response, abort, fetch the turn diff, run git commands, upload files. Examples in curl, Python, and Node — all three follow the same shape.
For the full route reference, open /api/docs in your deploy (Swagger UI
auto-generated from the route schemas). For the SSE side, see
docs/sse-events.md.
Setup
BASE=http://localhost:3000
KEY=<your API_KEY>
All requests use Authorization: Bearer <key>. If your deploy uses
UI_PASSWORD instead, get a JWT first:
TOKEN=$(curl -s -X POST $BASE/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"password":"your-ui-password"}' | jq -r '.token')
KEY=$TOKEN
JWTs expire (default 7 days, configurable via JWT_EXPIRES_IN_SECONDS);
API keys don't.
Health probe (no auth)
curl -s $BASE/api/v1/health
# {"status":"ok","activeSessions":0,"activePtys":0}
import httpx
print(httpx.get(f"{BASE}/api/v1/health").json())
const res = await fetch(`${BASE}/api/v1/health`);
console.log(await res.json());
End-to-end: create session, send prompt, stream response
curl
# 1. List projects
curl -s -H "Authorization: Bearer $KEY" $BASE/api/v1/projects
# 2. Create a session
SESSION=$(curl -s -X POST $BASE/api/v1/sessions \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"projectId":"<projectId>"}' | jq -r '.sessionId')
echo "Session: $SESSION"
# 3. Send a prompt (fire-and-forget — response comes via SSE)
curl -s -X POST $BASE/api/v1/sessions/$SESSION/prompt \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"text":"Refactor packages/server/src/auth.ts to extract the JWT verifier into its own function"}'
# {"accepted":true}
# 4. Stream the response (Ctrl+C to stop)
curl -N -H "Authorization: Bearer $KEY" \
$BASE/api/v1/sessions/$SESSION/stream
# 5. Abort if needed
curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/v1/sessions/$SESSION/abort
Python
import json
import httpx
BASE = "http://localhost:3000"
KEY = "<your API_KEY>"
H = {"Authorization": f"Bearer {KEY}"}
# 1. List projects
projects = httpx.get(f"{BASE}/api/v1/projects", headers=H).json()["projects"]
print(f"Found {len(projects)} projects")
# 2. Create a session
session = httpx.post(
f"{BASE}/api/v1/sessions",
headers=H,
json={"projectId": projects[0]["id"]},
).json()
session_id = session["sessionId"]
print(f"Created session {session_id}")
# 3. Send a prompt
httpx.post(
f"{BASE}/api/v1/sessions/{session_id}/prompt",
headers=H,
json={"text": "Run the test suite and fix any failures."},
)
# 4. Stream the response
streaming_text = ""
with httpx.stream(
"GET",
f"{BASE}/api/v1/sessions/{session_id}/stream",
headers={**H, "Accept": "text/event-stream"},
timeout=None,
) as r:
buffer = ""
for chunk in r.iter_text():
buffer += chunk
while "\n\n" in buffer:
event, buffer = buffer.split("\n\n", 1)
for line in event.splitlines():
if not line.startswith("data: "):
continue
payload = json.loads(line[6:])
if payload["type"] == "message_update":
e = payload.get("assistantMessageEvent") or {}
if e.get("type") == "text_delta":
streaming_text += e["delta"]
print(e["delta"], end="", flush=True)
elif payload["type"] == "agent_end":
print("\n\n[turn complete]")
raise SystemExit(0)
Node
const BASE = "http://localhost:3000";
const KEY = "<your API_KEY>";
const H = { Authorization: `Bearer ${KEY}` };
// 1. List projects
const projects = (await (await fetch(`${BASE}/api/v1/projects`, { headers: H })).json())
.projects;
console.log(`Found ${projects.length} projects`);
// 2. Create a session
const session = await (
await fetch(`${BASE}/api/v1/sessions`, {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({ projectId: projects[0].id }),
})
).json();
const sessionId = session.sessionId;
console.log(`Created session ${sessionId}`);
// 3. Send a prompt
await fetch(`${BASE}/api/v1/sessions/${sessionId}/prompt`, {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({ text: "Run npm test and fix the failures." }),
});
// 4. Stream the response
const streamRes = await fetch(`${BASE}/api/v1/sessions/${sessionId}/stream`, {
headers: { ...H, Accept: "text/event-stream" },
});
const reader = streamRes.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let idx;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data: ")) continue;
const payload = JSON.parse(line.slice(6));
if (payload.type === "message_update") {
const e = payload.assistantMessageEvent ?? {};
if (e.type === "text_delta") process.stdout.write(e.delta);
} else if (payload.type === "agent_end") {
console.log("\n\n[turn complete]");
process.exit(0);
}
}
}
}
Project CRUD
List
curl -s -H "Authorization: Bearer $KEY" $BASE/api/v1/projects
# { "projects": [{ "id": "...", "name": "...", "path": "...", "createdAt": "..." }] }
Create
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/projects \
-d '{"name":"my-app","path":"/workspace/my-app"}'
The path must be an existing directory inside WORKSPACE_PATH. Server
returns 403 if outside, 409 if a project with that path already exists.
Browse for a folder (folder picker)
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/projects/browse?path=/workspace"
# { "path":"/workspace", "parentPath":null, "entries":[{ "name","path","isGitRepo" }] }
Omit path to start at WORKSPACE_PATH.
Rename / delete
curl -s -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/projects/$PROJECT_ID \
-d '{"name":"new-name"}'
# Plain delete: removes pi-forge's record; session JSONLs stay on disk
curl -s -X DELETE -H "Authorization: Bearer $KEY" $BASE/api/v1/projects/$PROJECT_ID
# Cascade delete: also rm -rf the project's session directory
curl -s -X DELETE -H "Authorization: Bearer $KEY" "$BASE/api/v1/projects/$PROJECT_ID?cascade=1"
Session lifecycle
Cold sessions and lazy resume. A session created earlier and not currently active in the registry is "cold" — it lives only as a
.jsonlon disk./sessions/:id/treeand/sessions/:id/contextwill lazy-resume a cold session into memory automatically./sessions/:id/messages,/sessions/:id/turn-diff, and/sessions/:id/namedo not auto-resume and return404 session_not_foundon a cold id. The reliable workaround is to open the SSE stream first (GET /sessions/:id/stream), which always auto-resumes; subsequent calls then succeed.
List sessions for a project
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/sessions?projectId=$PROJECT_ID"
# { "sessions": [{ sessionId, projectId, workspacePath, isLive, name?, createdAt, lastActivityAt, messageCount, firstMessage }] }
Get full message history
curl -s -H "Authorization: Bearer $KEY" \
$BASE/api/v1/sessions/$SESSION/messages
# { "messages": [...] }
Get token + cost telemetry
curl -s -H "Authorization: Bearer $KEY" \
$BASE/api/v1/sessions/$SESSION/context
# { messages, totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheWriteTokens, totalTokens, totalCost, turns[], contextUsage }
turns[] is the per-turn breakdown derived from each
AssistantMessage.usage. contextUsage.tokens may be omitted when the
SDK doesn't have a fresh count (right after compaction). The messages
array mirrors the SSE snapshot.messages payload — it can be large
(tens of KB) for a long session, so consider polling sparingly.
Get session tree (branching history)
curl -s -H "Authorization: Bearer $KEY" \
$BASE/api/v1/sessions/$SESSION/tree
# { leafId, branchIds[], entries: [{ id, parentId, type, timestamp, role?, preview?, label? }] }
leafId is the current branch tip; branchIds is the full path from
root to leaf (= the active branch). Off-path entries are alternate
branches.
Navigate to a different leaf
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/sessions/$SESSION/navigate \
-d '{"entryId":"<some-entry-id>"}'
# Optional: { entryId, summarize: true, customInstructions: "...", label: "..." }
summarize: true writes a branch_summary entry capturing what the
abandoned branch did. label bookmarks the abandoned tip.
Fork from an entry into a new session
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/sessions/$SESSION/fork \
-d '{"entryId":"<some-entry-id>"}'
# Returns the new session's summary
The new session's path-to-leaf includes everything from the root through
entryId. The source session is preserved (in-memory restoration of the
source session manager happens server-side after the SDK's destructive
fork operation).
Set the model for THIS session only
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/sessions/$SESSION/model \
-d '{"provider":"anthropic","modelId":"claude-sonnet-4-5-20250929"}'
The route snapshots settings.json before calling the SDK and restores
it after, so per-session model picks don't mutate the global default.
Steer or follow up
# Steer (interrupt at next tool boundary)
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/sessions/$SESSION/steer \
-d '{"text":"Actually, use TypeScript not JavaScript","mode":"steer"}'
# Follow up (queue for after the current run)
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/sessions/$SESSION/steer \
-d '{"text":"And then run the tests","mode":"followUp"}'
Abort the current run
curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/v1/sessions/$SESSION/abort
Dispose / hard-delete
# Dispose only — kills the live session, preserves the JSONL
curl -X DELETE -H "Authorization: Bearer $KEY" $BASE/api/v1/sessions/$SESSION
# Hard delete — disposes AND removes the JSONL
curl -X DELETE -H "Authorization: Bearer $KEY" "$BASE/api/v1/sessions/$SESSION?hard=1"
Multipart prompt with attachments
curl -s -X POST -H "Authorization: Bearer $KEY" \
$BASE/api/v1/sessions/$SESSION/prompt \
-F "text=Look at this screenshot and tell me what's wrong" \
-F "attachments=@./screenshot.png" \
-F "attachments=@./logs.txt"
Image attachments are base64-encoded and forwarded as images to the
SDK. Text attachments are decoded as UTF-8 and prepended to the prompt
as fenced code blocks (with backtick-fence-break-safe fence selection).
Caps: 10 MB / file, 8 files, 4 images max.
File operations
List the project tree
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/files/tree?projectId=$PROJECT_ID&maxDepth=4"
Read a file
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/files/read?projectId=$PROJECT_ID&path=/workspace/my-app/src/index.ts"
# { path, content, size, language, binary }
Write / create a file (atomic tmp + rename)
curl -s -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/files/write \
-d '{"projectId":"...","path":"/workspace/my-app/notes.md","content":"# Notes\n\n..."}'
Search
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/files/search?projectId=$PROJECT_ID&q=TODO&caseSensitive=1"
# { engine: "ripgrep" | "node", matches: [...], truncated: bool }
Upload (multipart, with SHA-256 verification)
# Compute SHA-256 yourself, send as `sha256:<filename>` field BEFORE the file part
SHA=$(sha256sum ./report.pdf | cut -d' ' -f1)
curl -s -X POST -H "Authorization: Bearer $KEY" \
$BASE/api/v1/files/upload \
-F "projectId=$PROJECT_ID" \
-F "parentPath=/workspace/my-app/uploads" \
-F "sha256:report.pdf=$SHA" \
-F "files=@./report.pdf"
# { files: [{ path, size, sha256 }] }
Server hashes the received bytes and rejects with 422
checksum_mismatch if your hash doesn't match. Per-file cap 500 MB,
aggregate 2 GB, max 16 files.
Download (file or folder-as-tar.gz)
# File: streams the bytes
curl -s -OJ -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/files/download?projectId=$PROJECT_ID&path=/workspace/my-app/src/index.ts"
# Folder: gzipped tar (Content-Disposition includes the .tar.gz filename)
curl -s -OJ -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/files/download?projectId=$PROJECT_ID&path=/workspace/my-app/src"
# Whole project (omit path)
curl -s -OJ -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/files/download?projectId=$PROJECT_ID"
The folder/project tar.gz omits the same noise dirs as the file tree
(node_modules, .git, dist, build, etc.).
Git operations
# Status
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/git/status?projectId=$PROJECT_ID"
# Diff (unstaged)
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/git/diff?projectId=$PROJECT_ID"
# Diff a single file
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/git/diff/file?projectId=$PROJECT_ID&path=/workspace/my-app/src/index.ts&staged=1"
# Log
curl -s -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/git/log?projectId=$PROJECT_ID&limit=50"
# Stage / unstage / revert
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/git/stage \
-d '{"projectId":"...","paths":["/workspace/my-app/src/index.ts"]}'
# Commit
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/git/commit \
-d '{"projectId":"...","message":"feat: ship the thing"}'
# Branch management
curl -s -H "Authorization: Bearer $KEY" "$BASE/api/v1/git/branches?projectId=$PROJECT_ID"
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/git/branch/create \
-d '{"projectId":"...","name":"feat/x","checkout":true}'
# Remotes
curl -s -H "Authorization: Bearer $KEY" "$BASE/api/v1/git/remotes?projectId=$PROJECT_ID"
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/git/remote/add \
-d '{"projectId":"...","name":"origin","url":"git@github.com:you/repo.git"}'
# Push / pull / fetch
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/git/push \
-d '{"projectId":"...","remote":"origin","branch":"main","setUpstream":true}'
Configuration
List providers (presence-only auth)
curl -s -H "Authorization: Bearer $KEY" $BASE/api/v1/config/providers
curl -s -H "Authorization: Bearer $KEY" $BASE/api/v1/config/auth
# Returns presence: { providers: { anthropic: { configured: true, source: "auth.json" }, ... } }
# Key VALUES are never returned.
Set / remove an API key
curl -s -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/config/auth/anthropic \
-d '{"apiKey":"sk-ant-..."}'
curl -s -X DELETE -H "Authorization: Bearer $KEY" \
$BASE/api/v1/config/auth/anthropic
Read / write agent settings
curl -s -H "Authorization: Bearer $KEY" $BASE/api/v1/config/settings
curl -s -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/config/settings \
-d '{"defaultThinkingLevel":"high"}'
# Patch is shallow-merged; pass null to delete a key.
Read / write models.json (custom providers)
# GET returns the file with `apiKey` / `apiKeyCommand` REPLACED by
# "***REDACTED***" so the raw secret never leaves the server. The
# persisted file is unchanged; PUT takes the actual values.
curl -s -H "Authorization: Bearer $KEY" $BASE/api/v1/config/models
curl -s -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
$BASE/api/v1/config/models \
-d '{"providers":{"vllm-local":{"api":"openai-completions","url":"http://localhost:8000/v1","models":[...]}}}'
List / toggle skills
# Merged skills (global from ~/.pi/agent/skills/ + project-local from
# <project>/.pi/skills/) with per-skill enabled state for the workspace.
curl -s -G -H "Authorization: Bearer $KEY" \
--data-urlencode "workspacePath=/workspace/my-project" \
$BASE/api/v1/config/skills
# Enable / disable for the current workspace. Toggles persist as
# pattern entries in ${FORGE_DATA_DIR}/skills-overrides.json keyed by
# project — the skill .md files themselves are untouched.
curl -s -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
"$BASE/api/v1/config/skills/<skill-name>/enabled" \
-d '{"enabled":true,"workspacePath":"/workspace/my-project"}'
Equivalent endpoints exist for tools (/api/v1/config/tools/... →
tool-overrides.json) and pi prompts (/api/v1/config/prompts/... →
prompts-overrides.json).
UI config (public, no auth)
curl -s $BASE/api/v1/ui-config
# { minimal: false, workspaceRoot: "/workspace" }
Used by the React client at boot to know which surfaces to render.
Auth
# Whether auth is enabled at all
curl -s $BASE/api/v1/auth/status
# { authEnabled: true | false }
# Login (when UI_PASSWORD is set)
curl -s -X POST -H "Content-Type: application/json" \
$BASE/api/v1/auth/login \
-d '{"password":"your-ui-password"}'
# { token, expiresAt }
# Logout (client-side; server stateless — just discard the token)
JWT tokens default to 7-day expiry; the response's expiresAt is an ISO
timestamp. Refresh by logging in again before expiry.
See also
/api/docs(live in your deploy) — Swagger UI with every route's full schema, try-it-out includeddocs/sse-events.md— full SSE event catalogue + reconnect patternsdocs/architecture.md— request lifecycles + module mappackages/client/src/lib/api-client.ts— reference TypeScript client (the React UI uses it; same API surface)