Skip to content

appx-org/agent-server

Repository files navigation

@appx/agent-server

Pi-SDK-based agent orchestration. A standalone HTTP/SSE service that wraps the pi coding agent SDK into a stable REST + SSE contract.

One process serves one organisation and orchestrates many projects — each an isolated agent workspace (its own directory, config, and chat sessions) sharing one set of LLM credentials. Projects are explicit, persisted resources: create them via POST /v1/projects, then drive sessions under /v1/projects/{id}/sessions/*. See docs/architecture/project-lifecycle-and-workspace-layout.md.

Run it

npm install
npm run build
WORKSPACE_DIR=/abs/path/to/workspace npm start
# → listening on http://127.0.0.1:4001
# → docs at  http://127.0.0.1:4001/docs
# → spec at  http://127.0.0.1:4001/openapi.json

Dev with watch: WORKSPACE_DIR=/abs/path/to/workspace npm run dev.

Configuration

All via env vars (see .env.example):

Var Required Default Notes
WORKSPACE_DIR yes Root holding every project dir plus .pi-global/. Must exist. Mount as a Docker volume for restart-safe projects + registry.
ANTHROPIC_API_KEY no Injected into Pi's AuthStorage at boot; otherwise relies on .pi-global/auth.json.
PI_EXTENSION_PATHS no Comma-separated Pi extension/package sources (npm:, git:, or paths).
PI_SKILL_PATHS no Comma-separated Pi skill file/directory paths.
PI_PROMPT_PATHS no Comma-separated Pi prompt template paths.
PI_THEME_PATHS no Comma-separated Pi theme paths.
PI_NO_EXTENSIONS no false "true" disables extension discovery except PI_EXTENSION_PATHS.
PI_NO_SKILLS no false "true" disables skill discovery.
PI_NO_PROMPTS no false "true" disables prompt template discovery.
PI_NO_THEMES no false "true" disables theme discovery.
LITELLM_BASE_URL no When set, registers a litellm provider from LITELLM_* envs (see below).
AGENT_SERVER_HOST no 127.0.0.1 Bind host.
AGENT_SERVER_PORT no 4001 Bind port.
AGENT_SERVER_TOKEN no If set, /v1/* requires Authorization: Bearer <token>.
APPX_TEMPLATE_DIR no App template recursively copied into a project dir the first time it is created (build caches skipped). Absent ⇒ projects start empty. Must exist if set.
APP_CONTAINER_RUNTIME no podman Container runtime the deploy-app skill + injected prompt reference. Use docker for macOS Docker Desktop in local dev.

Auth is opt-in: loopback-only single-user dev can leave AGENT_SERVER_TOKEN unset. Set it for shared hosts or any deployment where another local process could reach the port.

Containerised apps (Stage 1)

New projects can be seeded from a baked-in app template and deployed as DEV + PROD containers. The builder-agent assets (the deploy skill + app template) live under builder-agent/. For local dev:

WORKSPACE_DIR=/abs/path/to/workspace \
  APPX_TEMPLATE_DIR="$PWD/builder-agent/templates/vite-spa" \
  APP_CONTAINER_RUNTIME=docker \
  PI_SKILL_PATHS="$PWD/builder-agent/skills/deploy-app" \
  npm run dev
  • APPX_TEMPLATE_DIR seeds the provisional Vite SPA template (a lean, single-runtime-target Dockerfile served by nginx) into each fresh project.
  • PI_SKILL_PATHS wires in the deploy-app skill so the builder agent knows the build/run/redeploy/promote conventions. The outer container image bakes both in at fixed paths (Stage 2).
  • Ports + public URLs come from the control plane (appx) on project create and are written to each project's .pi/deployment.json; the agent never invents a port.

The shell above exports the vars (so $PWD expands). When using a .env file, Node's --env-file does not expand $PWD — write real paths, and make PI_SKILL_PATHS absolute (it is passed through unresolved and the runtime cwd is the project dir, not the agent-server repo).

Filesystem layout

Everything lives under WORKSPACE_DIR, so a single mounted volume makes projects and the registry restart-safe:

WORKSPACE_DIR/
├── .pi-global/                 # org-global + agent-server state
│   ├── auth.json               # Pi auth (keys are injected from env at boot, in-memory-first)
│   ├── models.json             # Pi custom providers
│   ├── projects.json           # durable project registry — source of truth
│   └── sessions/{id}/          # session transcripts, namespaced by project id
└── {id}/                       # project working dir = app source + config
    └── .pi/                    # AGENTS.md, skills/, extensions/, settings.json (committable)
  • {id} is the project slug (id = slugify(name)), immutable and used as the registry key, route param, and directory name.
  • Project .pi/ holds config only and is committable. Session transcripts are centralised under .pi-global/sessions/{id}/, so they never leak into a project's git history and survive independently on the volume.
  • A project with no .pi/AGENTS.md starts with no pinned prompt (silent skip); Pi's normal context-file discovery then applies.
  • LLM credentials are injected from env into memory at startup and are not the job of the volume to persist (auth.json holds only non-secret/OAuth state).

API

REST routes are defined with Zod via @hono/zod-openapi; the OpenAPI 3.1 doc (/openapi.json) is the contract surface, and consumer types are generated from it (see "Consuming from another app").

Org-global (/v1):

Method Path Description
GET /v1/sessions/models List selectable models and auth availability
GET /v1/auth/providers List provider auth status without secrets
PUT /v1/auth/providers/{provider}/api-key Store a provider API key
DELETE /v1/auth/providers/{provider} Remove a stored provider credential
POST /v1/auth/providers/{provider}/subscription/start Start a subscription OAuth flow
GET /v1/auth/subscription/{flowId} Read subscription flow state
POST /v1/auth/subscription/{flowId}/continue Continue a prompt/code step
DELETE /v1/auth/subscription/{flowId} Cancel a pending flow
GET /v1/custom/providers List custom models.json providers without secrets
PUT /v1/custom/providers Create or update a custom provider
DELETE /v1/custom/providers/{provider} Remove a custom provider
GET /v1/healthz Liveness + per-channel SSE subscriber counts

Project lifecycle (/v1/projects):

Method Path Description
POST /v1/projects { name } — create-or-get a project (idempotent on name). Returns { id, name, projectDir, createdAt }.
GET /v1/projects List registered projects
GET /v1/projects/{id} Get one project's metadata
DELETE /v1/projects/{id} Remove the runtime, metadata, working dir, and transcripts

Sessions (under /v1/projects/{projectId}):

Method Path Description
GET …/sessions List sessions (persisted + live)
POST …/sessions Create a new session
GET …/sessions/{id} Persisted message history
GET …/sessions/{id}/settings Active model/thinking settings
PATCH …/sessions/{id}/settings Switch model and/or thinking while idle
GET …/sessions/{id}/events SSE stream of pi AgentSessionEvents
GET …/sessions/{id}/extension-ui Pending extension UI requests
POST …/sessions/{id}/extension-ui/{requestId}/response Resolve an extension UI request
POST …/sessions/{id}/prompt { text } — send a user prompt
POST …/sessions/{id}/abort Abort the in-flight run (no-op if idle)

Session routes resolve their runtime by a pure lookup on the path id; a request for a project that was never created returns 404.

Plus GET /openapi.json (OpenAPI 3.1) and GET /docs (Swagger UI).

SSE wire format

Each SSE event is data: <json> carrying a WireEvent — pi's AgentSessionEvent plus the extension_ui_request / extension_error events agent-server injects. The schema is generated from pi's TypeScript types (via typia, scripts/genEventSchema.ts) and merged into openapi.json as WireEvent, so clients codegen the event + message types (ToolCall, AssistantMessage, …) from the same contract as the REST surface — no hand-mirroring, no importing pi in clients. Regenerate after a pi upgrade with npm run gen:event-schema; the resulting eventSchema.generated.json is committed.

Non-JSON lines also occur and should be ignored: an initial connected to <id> line and periodic heartbeat keepalives (every 15s). Outgoing events are classified server-side against the contract (forward-compatible: an unmodeled type is forwarded with a soft log; the stream is never broken).

Handle message_update.assistantMessageEvent by contentIndex: text blocks use text_start/text_delta/text_end, tool-call blocks use toolcall_start/toolcall_delta/toolcall_end, and thinking blocks may be emitted without being shown in the transcript.

Extension UI requests arrive on the same stream as { "type": "extension_ui_request", ... }. Blocking requests (select, confirm, input, editor) are held until the browser answers POST …/sessions/{id}/extension-ui/{requestId}/response with one of { "value": "…" }, { "confirmed": true }, or { "cancelled": true }. After connecting/reconnecting, call GET …/sessions/{id}/extension-ui so requests created before the SSE connection aren't missed.

Models and thinking

GET /v1/sessions/models returns non-secret Pi model metadata (provider, id, display name, API family, reasoning support, auth availability, context window, max output tokens, default thinking level).

PATCH …/sessions/{id}/settings accepts:

{
  "provider": "anthropic",
  "modelId": "claude-sonnet-4-5",
  "thinkingLevel": "high"
}

thinkingLevel is one of off, minimal, low, medium, high, xhigh. Changes during streaming return 409; Pi clamps unsupported levels to the model's supported set and returns the effective level.

LiteLLM

When LITELLM_BASE_URL is set, the server registers a Pi provider named litellm. Useful envs: LITELLM_API_KEY, LITELLM_DEFAULT_MODEL, LITELLM_MODELS (comma-separated ids), LITELLM_MODELS_JSON (full per-model config: reasoning, thinkingLevelMap, defaultThinkingLevel, compat, api, token limits), LITELLM_DEFAULT_THINKING, and LITELLM_API (openai-completions | openai-responses | anthropic-messages). Presets exist for openai/gpt-5.5, deepseek/deepseek-v4-pro, and deepseek/deepseek-v4-flash.

The same shape can be managed at runtime via PUT /v1/custom/providers; records are written to .pi-global/models.json with 0600 perms and reloaded immediately. Responses only report whether a key exists, never the key.

Provider auth

GET /v1/auth/providers merges Pi model availability, stored API keys, runtime/env credentials, models.json keys, and registered OAuth providers into one non-secret status list. Use PUT /v1/auth/providers/{provider}/api-key for API keys, or POST /v1/auth/providers/{provider}/subscription/start for subscription auth (some providers, e.g. OpenAI Codex / Anthropic, require pasting the browser's final localhost redirect back through POST /v1/auth/subscription/{flowId}/continue).

Extensions

Pi packages and extensions execute code in the agent process. Keep configuration conservative, review package source before enabling, and prefer project-local .pi/settings.json or PI_EXTENSION_PATHS over global installs. For first-party app bundles, put prompt/skill/extension assets under the project's .pi/ and let Pi discover them; the PI_*_PATHS vars are for temporary overlays or package sources that shouldn't be committed to the workspace.

Regenerating openapi.json

openapi.json is the published contract — REST routes (described by @hono/zod-openapi) and the SSE WireEvent schema, which is generated from pi's TypeScript types via typia rather than hand-authored.

# only needed after a pi upgrade or a change to WireEvent — regenerates
# src/contract/eventSchema.generated.json (the committed event schema).
npm run gen:event-schema

# always: rebuild and dump the merged contract to ./openapi.json
npm run build
npm run openapi

gen:event-schema requires the typia compiler transform ( ts-patch / tspc, already wired via tsconfig.gen.json); the resulting JSON is committed so the normal build/openapi/runtime never need it. The live server serves the same merged document at /openapi.json.

Consuming from another app

Feed the generated openapi.json to openapi-typescript (or any generator) to get typed REST DTOs and the SSE event/message types (WireEvent, ToolCall, AssistantMessage, …) — so consumers never re-derive pi's shapes or import pi:

# in the consuming app
npx openapi-typescript ../../agent-server/openapi.json -o src/generated/agent-server.d.ts

Then use a typed client; SSE is consumed separately (native EventSource, or piped through the consumer backend with fetch().body streaming):

import createClient from "openapi-fetch";
import type { paths } from "./generated/agent-server.js";

const client = createClient<paths>({ baseUrl: "http://127.0.0.1:4001" });
const { data } = await client.POST("/v1/projects", {
  body: { name: "my-app" },
});

Library mode (advanced)

To embed the runtime in your own Hono app. ProjectRegistry.create is async (it sets up shared auth/model state and rehydrates the project registry from projects.json); runtimes are built lazily on first use.

import { Hono } from "hono";
import {
  ProjectRegistry,
  createCredentialsApp,
  createProjectsApp,
  createSessionsApp,
} from "@appx/agent-server";

const registry = await ProjectRegistry.create({ workspaceDir });
const app = new Hono();

app.route("/v1", createCredentialsApp(registry.credentials)); // org-global auth/custom/models
app.route("/v1", createProjectsApp(registry)); // project lifecycle
app.route(
  "/v1/projects/:projectId",
  createSessionsApp(async (c) => {
    const runtime = await registry.getRuntime(c.req.param("projectId"));
    if (!runtime) throw new Error("project not registered"); // map to 404 in onError
    return runtime;
  }),
);

Projects are created with registry.createProject({ name }); each runtime derives its working dir (WORKSPACE_DIR/{id}), centralised sessions (.pi-global/sessions/{id}), AGENTS.md, skills, and extensions automatically. The registry holds only org-shared state (auth, models, credentials, project registry). The standalone server (src/server.ts) is the primary deployment; this exists for tests and embedded hosts.

About

Agent orchestrator SDK - manages sessions, spawns agents to do tasks

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors