An agent scaffold on the pi-agent SDK — clone it and it runs; add tools, extensions, or a new persona, and it becomes a vertical agent.
中文 · English
Repository: https://github.com/LPK3215/pi-starter · Upstream SDK: earendil-works/pi
Layered view of the scaffold. Top to bottom: entry points (CLI, HTTP + SSE, library, official RPC) → assembly in src/agent.ts → business resources (tools / skills / knowledge+retrieval / extensions) → HTTP contract and SSE translator → runtime deps and configuration surface. Counts, module names, versions and event names are parsed from source at generation time by the scripts under scripts/visualization/.
Every POST /chat follows the same lifecycle: HTTP body → busy guard → session.prompt → tool_call hook (extension chain, e.g. guard) → tool execution → tool_result hook (e.g. audit) → SDK events → translateEvent() → SSE frames back to the client.
Sequence diagram generated by scripts/visualization/generate_request_flow.mjs. Event vocabulary (text, thinking, tool_start, tool_end, done, error) and the tool-result preview limit are read from src/sse.ts and src/app.ts, never hard-coded.
| Metric | Value |
|---|---|
Backend source (src/, tests excluded) |
84 .ts files · 19983 lines |
| Backend tests | 53 files · 505 cases · 12152 lines |
Frontend hand-written (web/src) |
36 files · 8751 lines |
| Frontend cases | 19 |
HTTP route handlers (app. / router. methods) |
47 |
Hand-written docs (docs/*.md) |
10 |
| Largest single file | src/conversation/conversation.ts (1137 lines) |
Generated from source by
node scripts/visualization/generate_readme_numbers.mjs— do not edit by hand;npm run docs:numbers:checkguards against drift in CI.
| Layer | Library / runtime | Version | Notes |
|---|---|---|---|
| Language | TypeScript | ^5.6.0 |
strict: true, module: NodeNext, target: ES2022 |
| Runtime | Node.js | >=22.19 |
requires built-in node:sqlite |
| Module system | ESM | "type": "module" |
built output under dist/, imported as pi-starter |
| Agent SDK | @earendil-works/pi-agent-core |
0.83.0 |
pinned |
| AI adapter | @earendil-works/pi-ai |
0.83.0 |
pinned |
| Coding agent | @earendil-works/pi-coding-agent |
0.83.0 |
tools / extensions contract |
| HTTP | Express | ^5.2.1 |
single process; REST shares one session, WS runs several conversations per client (cap + LRU) |
| Schema | TypeBox | ^1.1.39 |
tool parameters definitions |
| WebSocket | ws | ^8.18.0 |
snapshot-driven bidirectional transport (transport/ws.ts) |
| Test runner | Node built-in test runner via tsx --test |
^4.22.4 |
no model calls, no network; counts live in Numbers |
| Build | tsc -p tsconfig.build.json + scripts/dist-assets.cjs |
^5.6.0 |
copies prompts/ skills/ prompt-templates/ knowledge/ into dist/ |
Truth source for the table above: package.json. When versions change, update the code and this table together (the Architecture SVG refreshes automatically via node scripts/visualization/generate_architecture.mjs).
- Three entry points: CLI (
npm run dev) + HTTP SSE (npm run web) + official RPC stdio JSONL (npm run dev -- --mode rpc, for cross-language / subprocess integration). The backend API is the product; a React frontend lives inweb/and speaks the same WS protocol from the browser - Layered prompts:
src/prompts/holdspersona.md(who the agent is) +rules.md(working constraints) — edit the files to change the personality - Pluggable tools: define them under
src/tools/, register intools/index.ts, and they are auto-registered into the agent - Optional web access (
PI_WEB=on):web_fetchreads an http(s) page as text;web_searchappears only when a search backend is injected. Both are off by default (outbound network is an exfiltration channel), size-capped, and refuse loopback/private/link-local targets — including via DNS resolution and after redirects. Swap in your own backend withbuildAgent({ webClient }); the tool contract and the model-facing surface do not change - Skill management:
src/skills/<name>/SKILL.md, loaded via the SDK'sDefaultResourceLoader.additionalSkillPaths; the catalog is injected byformatSkillsForPromptand full text is read by the built-inreadtool through<location> - Knowledge base:
src/knowledge/*.md— the system prompt carries only the catalog; bodies are fetched on demand viasearch_knowledge/read_knowledge(the SDK has no native knowledge base). Retrieval is pluggable behind theRetrieverinterface: keyword by default (zero-dep, current behavior), or vector RAG viaPI_KNOWLEDGE_RETRIEVAL=vector+ an OpenAI-compatible / Ollama embeddings endpoint — swap in aVectorStore(Qdrant/pgvector) later without touching the tool. - Cross-session memory (on by default,
PI_MEMORY=offto disable):remember/recallpersist what should survive a conversation — user preferences, durable project facts, the last unfinished conclusion — to a JSONL file (default~/.pi/agent/pi-starter-memory.jsonl, atomic write, 4 KB per entry / 2000 entries capped). It is deliberately not part of the system prompt: the model retrieves it on demand. Unlike the knowledge base it is writable, sorules.mdtells the model what belongs there. Swap the store withbuildAgent({ memory });GET/POST/DELETE /memoryexposes the same store over REST - Prompt templates:
src/prompt-templates/<name>.mdare the SDK's slash-command templates —session.prompt("/name")expands them (positional$1,$@, defaults${1:-x}); loaded viaadditionalPromptTemplatePaths, with~/.piscanning off - Database: Node's built-in
node:sqlite, in-memory by default with samplenotes;GET /dbfor liveness,db_queryfor read-only queries - Extensions: hooks via
pi.on()undersrc/extensions/. Ships withguard(pre-execution interception) andaudit(timing logs) - One-command write into Pi native config:
npm run setupmerges into~/.pi/agent/models.json+auth.json; no compatibility layer at runtime - Configurable, switchable model catalog:
PI_MODELSin.envdeclares multiple providers and models,PI_MODELpicks the default; switch mid-session with/modelin the CLI orPOST /modelover HTTP — no session rebuild. An officialscopedModelscycle list backs/cycle(CLI),POST /model/cycle(REST) andcycle_model(WS);GET /providersreports per-provider auth viaModelRuntime.getProviders/checkAuth. - Built-in coding tools off by default:
offenables only custom tools +read(skills need it); bash/edit/write must be turned on explicitly - Hardened by default: binds loopback only, strips the framework fingerprint, ships security headers, caps JSON bodies at 1 MB, sets explicit server timeouts, and refuses read-only SQL that hides writes inside a CTE
- Observable: structured logs with automatic secret redaction (
log.ts), 14 runtime metrics in JSON or Prometheus format (/metrics), and separate liveness / readiness probes - Bounded resources: at most 8 open conversations per client (LRU-recycled), 200 rows per SQL query, 500 messages per snapshot, 512 KB per knowledge document — each reports truncation instead of failing silently
- Failsafe execution: a hung tool is aborted by a watchdog (default 20 min,
settings.toolTimeoutSeconds); approvals time out as deny;denyrules can never be suppressed - Library export: after
npm run build,import { buildAgent, createApp } from "pi-starter"— inject business logic through parameters instead of editing scaffold sources
The scaffold is a vertical-agent starting point, not another coding-assistant wrapper. Right after cloning:
| Included | Excluded (unless you turn it on) |
|---|---|
Custom tools registered in src/tools/ (ready example: current_time) |
SDK built-in bash / edit / write (off still enables read, for skills) |
src/skills/ via SDK additionalSkillPaths, full text loaded by the built-in read |
Local ~/.pi/agent/skills, Claude Code / Codex skill directories |
src/knowledge/ base + search_knowledge / read_knowledge |
Vector stores / external RAG |
src/prompt-templates/<name>.md via SDK additionalPromptTemplatePaths (/name expands) |
Global ~/.pi/agent/prompts and project .pi/prompts scanning |
In-memory SQLite + GET /db / db_query |
Remote Postgres / connection pools (inject your own database) |
persona.md + rules.md system prompt |
File extensions from ~/.pi/agent/extensions and <cwd>/.pi/extensions |
guard intercepting dangerous bash, paths outside cwd (read SKILL.md excepted), and the sensitive-file deny list |
Full sandboxing / container isolation |
audit printing tool durations |
Login, multi-user sessions, public-internet exposure |
| Snapshot-driven WebSocket (restart/reconnect self-healing, backpressure drops, slow-client disconnect) | Cross-client conversation takeover |
Rename, roll back, edit a user message, fork a path, and delete a conversation for real (delete_conversation: index entry + session file, path re-checked against the same allowlist as open) |
Goal-review loop. The SDK does not provide that workflow |
| Multi-conversation concurrency (cap 8 + LRU); sessions / settings / rules persisted | Goal-review loop, delegation, SCM, background-task tracking |
| Tool watchdog (a hung tool can't block forever), approval rule engine (six matchers + editing API) | Plugin marketplace |
| Plan mode (plan-only per conversation), subagents, MCP bridge (stdio + hot reload) | Interactive PTY (vim / top), attachments and vision bridge |
Process execution in coding only: exec / exec_jobs / exec_stop (timeout, process tree, workspace realpath) |
Shell on by default. off and readonly do not get exec |
| Multiple API keys (raw values never leave the server), typed errors, rate limits, metrics, redacted logs | OAuth, multi-user, payments |
No built-in authentication — this is deliberate. The scaffold binds
127.0.0.1and warns loudly ifPI_HOSTis set to anything else, but nothing authenticates callers. Exposing it beyond loopback means exposing the agent and its tools to the network: put it behind your own auth proxy. Settings are persisted to~/.pi/agent/pi-starter-settings.json(atomic write, corrupt file falls back to defaults), sopromptTemplate/disabledToolssurvive a restart — only a library consumer that constructsSettingsServicewithout a port gets the in-memory default.builtinKnowledge/builtinSkillsare read once at assembly time, so changing those two needs a restart.
Enable built-in tools (priority: CLI flags > .env > default off):
# .env
PI_BUILTIN_TOOLS=off # default: custom tools + read (for skills)
# PI_BUILTIN_TOOLS=readonly # plus grep / find / ls
# PI_BUILTIN_TOOLS=coding # plus bash / edit / write and exec / exec_jobs / exec_stop
PI_WEB=off # default: no outbound network. "on" registers web_fetch (+ web_search
# PI_WEB=on # when a search backend is injected). Private/loopback targets are
# # refused; see SECURITY.md for what is NOT mitigated.
# PI_WEB_MAX_BYTES=262144
# PI_WEB_TIMEOUT_MS=15000
# or a temporary override
npm run dev -- --builtin-tools codingreadonly / coding go through the SDK allowlist, and custom tool names are merged into it automatically. Tools must appear in src/tools/index.ts or buildAgent({ extraTools }); names registered only inside extensions are not allowlisted automatically.
With coding on, guard intercepts a fixed list of dangerous shell patterns (e.g. rm -rf, mkfs, dd of=) and blocks read / write / edit / ls / grep / find targets whose realpath falls outside the working directory (so symlink escapes are caught too), returning { block: true }. The shell patterns are a single source of truth shared with the approval rules (src/extensions/shell-rules.ts).
guard also refuses the sensitive file names listed in src/secret-files.ts (.env, *.pem, id_rsa, auth.json, …) in every tier — the path check alone is not enough, because those files normally live inside the working directory. The same list backs the HTTP file service's denyNames (including the directory-listing preview), so the agent cannot read .env through either channel.
This is not a sandbox. bash itself has no path or command constraint beyond that pattern list, and the patterns can be evaded (command substitution, encoding, shell builtins). For real isolation use the bundled Dockerfile (non-root, publishes only on 127.0.0.1) or the tool-routing seam in src/extensions/sandbox.example.ts. exec is process execution (capture output, background jobs), not an interactive terminal. Edit rules in src/extensions/guard.ts.
- Node.js ≥ 22.19
- A ModelScope token (or an API key from any OpenAI-compatible provider you switch to)
npm install
cp .env.example .env # Windows: copy .env.example .env
# edit .env, fill in PI_API_KEY
npm run setupsetup merges (never clobbers other providers) into the Pi native paths:
~/.pi/agent/models.json— ModelScopebaseUrl+ model ids~/.pi/agent/auth.json—{ "modelscope": { "type": "api_key", "key": "..." } }(file mode 0o600)
Existing keys for a provider are kept by default. To overwrite, add --force:
npm run setup -- --forceThe SDK reads these two files at runtime, and the server also loads .env into its own process.env (loadEnvFile), so PI_API_KEY does live in the server process — that is how the model call is authenticated. It is deliberately not inherited by child processes: exec and MCP spawns receive a filtered environment (src/child-env.ts), so a bash command can no longer read the key.
The default model in .env can be written two ways:
PI_MODEL=modelscope/deepseek-ai/DeepSeek-V4-Flash-0731
# or split (when the model id itself contains a slash, it is kept as-is and not parsed as a provider)
PI_PROVIDER=modelscope
PI_MODEL=deepseek-ai/DeepSeek-V4-Flash-0731If missing, startup throws — it does not fall back to the SDK's built-in huggingface.
For multiple models, write PI_MODELS: one provider per entry, models comma-separated, display names colon-separated:
PI_MODELS=modelscope|https://api-inference.modelscope.cn/v1|openai-completions|deepseek-ai/DeepSeek-V4-Flash-0731:DeepSeek-V4-Flash,Qwen/Qwen2.5-72B-Instruct;zhipu|https://open.bigmodel.cn/api/paas/v4|openai-completions|glm-4.5-air:GLM-4.5-AirWithout PI_MODELS, setup uses only the single default entry, with the base URL from PI_BASE_URL.
Priority: CLI --model / --provider > .env. There is no third "SDK picks for you" tier.
Keys are per provider: the default provider uses PI_API_KEY, the rest use PI_API_KEY_<PROVIDER> (uppercase, e.g. PI_API_KEY_ZHIPU). A missing key for the default model's provider fails immediately; missing keys for other providers are skipped with a notice.
Hand-writing ~/.pi/agent/ also works, format:
{
"providers": {
"modelscope": {
"baseUrl": "https://api-inference.modelscope.cn/v1",
"api": "openai-completions",
"models": [
{ "id": "deepseek-ai/DeepSeek-V4-Flash-0731", "name": "DeepSeek-V4-Flash" }
]
}
}
}{
"modelscope": { "type": "api_key", "key": "ms-your-key" }
}api: useopenai-completionsfor domestic / OpenAI-compatible vendors,anthropic-messagesfor AnthropicbaseUrlgoes only up to/v1; do not append/chat/completions
npm run dev
# temporarily switch the default model:
npm run dev -- --model zhipu/glm-4.5-air
# switch inside a session (not sent to the model):
# /models
# /model modelscope/Qwen/Qwen2.5-72B-Instruct
# /cycle # next model in the scopedModels cycle list
# official RPC mode (stdio JSONL) for cross-language / subprocess integration:
npm run dev -- --mode rpcnpm run web
# defaults to http://localhost:3000The repository also ships a product frontend under web/ — an independent npm project (Vite + React + assistant-ui) whose only hand-written layer is an ExternalStoreRuntime adapter over /ws. It reuses src/protocol.ts directly, so the wire types are one source for both sides.
npm run ui:dev # Vite dev server on :5173, proxies /ws to the backend (:3000)
npm run ui:build # emit web/dist — `npm run web` then serves the app at / (one process)Dev mode needs the Vite proxy to pass the original Host through: the backend enforces a same-authority check (originAllowed in src/transport/ws.ts), so setting changeOrigin on the proxy gets the browser handshake rejected with 403 while non-browser clients (which send no Origin) keep working.
When embedding into an existing service use createApp({ staticDir: false }) and mount your own frontend.
The full surface — all 34 WS client commands, 21 server frames, the complete REST route table, the tool inventory with capability/risk tags, every environment variable and every npm script — is generated from source into docs/参考手册.md. npm run docs:reference:check gates it in CI, so it cannot drift.
Probes are split by intent, so a dependency outage does not look like a dead process:
| Endpoint | Meaning | Touches dependencies |
|---|---|---|
GET /health |
Liveness — process is up and serving | No. Stays green during a model/provider outage, so orchestrators do not restart-loop a healthy process |
GET /health/ready |
Readiness — model selected, database reachable, knowledge base parsed. Returns 503 when any check fails |
Yes |
GET /metrics |
Runtime metrics. ?format=prometheus for the text exposition format |
No |
GET /info |
Full inventory: current model, available list, skill / knowledge catalogs, DB liveness, busy flag | Model catalog only |
All responses carry hardening headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, a strict CSP, COOP/CORP), and X-Powered-By is removed. JSON bodies are capped at 1 MB by default (413 when exceeded).
curl http://localhost:3000/health/ready
curl "http://localhost:3000/metrics?format=prometheus"You can test resources without calling any model (virtual / sample data is enough):
curl http://localhost:3000/skills
curl http://localhost:3000/skills/summarize
curl http://localhost:3000/knowledge/search?q=model%20switching
curl http://localhost:3000/knowledge/about
curl http://localhost:3000/db
curl http://localhost:3000/db/notes
curl -X POST http://localhost:3000/db/query -H "content-type: application/json" -d "{\"sql\":\"SELECT title FROM notes\"}"POST /model switches the model of the current session without rebuilding it:
{ "model": "zhipu/glm-4.5-air" }POST /chat is the exposed SSE protocol over HTTP (callable from any language):
| Event type | data | Meaning |
|---|---|---|
text |
{delta} |
a chunk of the answer |
thinking |
{delta} |
a chunk of the thinking |
tool_start |
{id,name,args} |
tool execution started |
tool_end |
{id,name,result,isError} |
tool execution finished |
done |
{} |
stream fully ended |
error |
{message} |
failure |
pi-starter/
├── src/
│ ├── index.ts # CLI entry (interactive chat)
│ ├── server.ts # Web entry: parse args, listen
│ ├── app.ts # ★ HTTP app: /health /skills /knowledge /db /model /chat
│ ├── rpc.ts # official RPC entry (`npm run dev -- --mode rpc`)
│ ├── lib.ts # library export (buildAgent / createApp / setupPiAgentDir …)
│ ├── setup.ts # npm run setup: merge-write into ~/.pi/agent/
│ ├── agent.ts # ★ assembly: model + persona + tools + extensions → session
│ ├── config.ts # config layer: CLI / .env / built-in-tools tier / SDK settings passthrough
│ ├── session-hub.ts # hub: conversations per client (cap + LRU), restart recovery
│ ├── client-session.ts # one WS connection's conversations + slot admission (in-flight counts)
│ ├── conversation/ # Conversation: SDK events → snapshot state, tree ops, compaction
│ ├── protocol.ts # ★ single source of the WS/REST message types (compile-time completeness)
│ ├── sse.ts # agent events → SSE protocol / raw NDJSON (jsonl)
│ ├── snapshot.ts # snapshot emitter: delta vs full, throttled, rev chain
│ ├── errors.ts # AppError primitive (transport-agnostic)
│ ├── http/ # routes / error handler / hardening / rate limit / log & file routes
│ ├── transport/ # WebSocket: hello→ready, backpressure drops, origin check
│ ├── sessions/ # session index (JSONL catalog) + rollback / edit / fork / import
│ ├── approval/ # rules → policy → gate (deny can't be disabled) + tool watchdog
│ ├── modes/ # plan mode: per-conversation "plan only" hard gate
│ ├── extensions/ # extension layer: hooks on agent lifecycle
│ │ ├── index.ts # ★ registry
│ │ ├── guard.ts # tool_call interception (dangerous bash / path escape / secret names)
│ │ ├── audit.ts # tool-call audit log
│ │ ├── shell-rules.ts # single source for dangerous shell patterns (guard + approval share it)
│ │ └── *.example.ts # provider / command / sandbox / hooks / result-redaction seams
│ ├── tools/ # tool layer: give the LLM "hands"
│ │ ├── index.ts # ★ static tool registry
│ │ ├── registry.ts # capability tags, runtime enable/disable
│ │ ├── current-time.ts · knowledge.ts · database.ts · exec.ts
│ │ ├── memory.ts # remember / recall
│ │ ├── web.ts # web_fetch / web_search (SSRF-guarded, off by default)
│ │ └── ask-user-question.ts # HITL ask through the official ctx.ui
│ ├── skills/ # skills: <name>/SKILL.md, SDK additionalSkillPaths
│ │ └── summarize/SKILL.md
│ ├── knowledge/ # knowledge base: *.md + pluggable retrieval (keyword | vector)
│ │ ├── index.ts · about.md
│ │ ├── retrieval.ts # Retriever / EmbeddingProvider / VectorStore interfaces
│ │ ├── embeddings.ts · embeddings-transformers.ts
│ │ └── vector-store-sqlite.ts # persisted VectorStore (node:sqlite)
│ ├── memory/ # cross-session memory store (JSONL, 0600, bounded)
│ ├── prompt-templates/ # slash-command templates: <name>.md → /<name>, SDK additionalPromptTemplatePaths
│ │ └── review.md
│ ├── prompts/ # layered prompts (edit here = change the persona)
│ │ ├── persona.md # who the agent is, how it answers
│ │ └── rules.md # working constraints
│ ├── db/ # database: node:sqlite, in-memory default + read-only SQL scan
│ ├── files/ # FileService: literal path + realpath check, denyNames
│ ├── exec/ # process execution (tier `coding`): timeout, process tree, workspace
│ ├── subagents/ # delegate_task: own budget, never occupies the main conversation cap
│ ├── mcp/ # MCP stdio client + bridge (settings change applies without restart)
│ ├── context/ # context budget: what to trim when the window is full
│ └── runtime plumbing # settings.ts · provider-keys.ts · secret-files.ts · models.ts ·
│ # capabilities.ts · log.ts · log-sink-file.ts · metrics.ts ·
│ # snapshot helpers · graceful.ts · child-env.ts · sdk-adapter.ts ·
│ # cli-args.ts · prompt-images.ts · test-server.ts · test-tmp.ts
├── scripts/
│ ├── dist-assets.cjs # clean / copy prompt+skill+knowledge+prompt-templates assets into dist/
│ ├── smoke-ws.mjs # real WebSocket smoke (part of `npm run smoke`)
│ ├── verify-embed.mjs # embedding self-check (part of `npm run verify`)
│ ├── rag-smoke.mjs # optional live local-RAG check (`npm run rag:smoke`)
│ └── visualization/ # README diagram generators (see below)
│ ├── generate_architecture.mjs
│ ├── generate_request_flow.mjs
│ ├── generate_retrieval.mjs
│ └── README.md
├── docs/ # generated SVGs + guides + the generated reference manual
│ ├── architecture.svg
│ ├── sse-protocol.svg
│ ├── knowledge-retrieval.svg # pluggable RAG retrieval pipeline
│ ├── project_overview/ # static HTML overview page (index.html + script.js + style.css + project_card.html, the exportable one-image card); docs/index.html redirects here — published by GitHub Pages classic mode from `docs/`, live at https://lpk3215.github.io/pi-starter/
│ ├── 使用指南.md # getting started: three shapes, adding business logic, troubleshooting (Chinese)
│ ├── 参考手册.md # GENERATED: WS protocol / REST / tools / env vars / npm scripts
│ ├── 能力与边界.md # capability matrix & boundaries vs the SDK
│ ├── 嵌入指南.md # embedding into an existing Express service
│ ├── 智能体视角评估.md # first-person self-assessment (current state)
│ ├── engineering-review.md # independent code review: findings + measured evidence
│ ├── 官方SDK接口文档.md # SDK interface notes
│ ├── 前端调研.md # frontend research notes
│ ├── assistant-ui.md # assistant-ui integration notes
│ └── 项目分析报告.md # ARCHIVED pre-0.3.0 snapshot, kept for traceability only
├── .github/workflows/
│ ├── ci.yml # typecheck + unused + coverage + test + test:web + docs:check + smoke + e2e + audit + build (ubuntu/win/mac) — Actions cannot run for this account, so .cnb.yml is the gate that actually fires
│ └── publish.yml # tag-triggered release build (Actions unavailable in this repo — see pipeline.config.json)
├── web/ # product frontend (own package.json, Vite + React + assistant-ui)
│ └── src/pi/ # the only hand-written glue: WS client + ExternalStore adapter
├── Dockerfile # ready-to-run sandbox image (see SECURITY.md / README advanced)
├── LICENSE README.md README.zh-CN.md CONTRIBUTING.md CODE_OF_CONDUCT.md SECURITY.md
├── CHANGELOG.md FAQ.md AUTHORS .gitignore .gitattributes
└── package.json tsconfig.json tsconfig.build.json .env.example .dockerignore .cnb.yml
Contract smoke tests (no model calls, never touch the real ~/.pi/agent):
npm test # backend unit + integration (counts: see Numbers)
npm run test:web # frontend (WS client) tests — reuses tsx, adds no dependency
npm run smoke # 23 real WebSocket end-to-end checks
npm run typecheck # types + protocol completeness
npm run lint:unused # dead code gate (the "declared but never wired" class of bug)
npm run build
npm run verify # the chain above, in order: typecheck → lint:unused → test → test:web → smoke → build → verify:embed
npm run e2e # real process: restart/resume/settings round-trip (no API key) — CI runs it, verify does not
npm run rag:smoke # optional: live check of local in-process vector RAG (SKIPs if offline)The integration suite drives the real orchestration stack (SessionHub → ClientSession → Conversation → SnapshotEmitter → WebSocket) against a contract-faithful session double, so
wiring regressions surface without needing network access or an API key.
The scaffold handles assembly, models, the guardrail, and HTTP. Business logic (tools, persona, pages, login) is injected from outside — do not modify node_modules/@earendil-works.
Two ways in:
- Edit this repo: persona in
src/prompts/, tools registered insrc/tools/index.ts, skills dropped intosrc/skills/, knowledge intosrc/knowledge/, extensions registered insrc/extensions/index.ts. - Use it as a library: after
npm run build,import { buildAgent, createApp } from "pi-starter"and inject via parameters; the scaffold stays untouched.
import { buildAgent, createApp } from "pi-starter";
const agent = await buildAgent({
systemPrompt: "You are a customer-service assistant...",
extraTools: [myTool],
extraExtensions: [myExtension],
extraSkillPaths: ["./skills"],
extraKnowledgeDirs: ["./docs"],
databasePath: ":memory:",
inMemory: true,
});
const { app, dispose } = createApp({ agent, staticDir: false });
// mount app onto your existing Express; login, multi-user, frontend are yoursextraExtensions run after the built-in guard / audit. createApp({ staticDir: false }) exposes only the API. On name collisions, repo-bundled skills/knowledge take precedence over injected ones.
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
export const myTool = defineTool({
name: "my_tool",
label: "My Tool",
description: "One sentence on what this tool does (the LLM decides when to call it based on this)",
parameters: Type.Object({
query: Type.String({ description: "parameter description" }),
}),
async execute(_id, params: { query: string }) {
return { content: [{ type: "text", text: `Result: ${params.query}` }], details: {} };
},
});In-repo: add it to allTools in src/tools/index.ts. As a library: pass it to buildAgent({ extraTools: [myTool] }). The readonly / coding tiers merge these names into the SDK allowlist.
A runnable interception example already exists: src/extensions/guard.ts. Copy its structure for new extensions.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export function myExtension(pi: ExtensionAPI) {
pi.on("tool_call", (event) => {
if (event.toolName === "dangerous_tool") {
return { block: true, reason: "this tool is not allowed" };
}
return undefined;
});
}In-repo: add it to allExtensions in src/extensions/index.ts. As a library: pass it to buildAgent({ extraExtensions: [myExtension] }).
A skill is a SKILL.md with YAML frontmatter (Agent Skills). Loading goes through the SDK: noSkills: true disables scanning your local ~/.pi, and additionalSkillPaths loads only repo / injected directories. The SDK writes <available_skills> (with <location>) into the system prompt, and the model reads the full text with the built-in read tool.
src/skills/refund/SKILL.md
---
name: refund
description: Handles refund requests. Use when the user mentions refunds, returns, or order cancellation.
---
# Refund flow
1. Ask for the order number first
2. Call the business tool to check status
3. Decide refundability by policyAfter restart it shows up in GET /info / GET /skills. When the model matches the description it reads the SKILL.md at <location>. As a library: buildAgent({ extraSkillPaths: ["/path/to/skills"] }).
By default ~/.pi/agent/skills is not scanned. The off tier enables read (skills need it) but not bash/edit/write. guard blocks paths escaping cwd by default but allows read on SKILL.md.
src/knowledge/pricing.md
---
title: Pricing
description: Plans, unit prices, billing cycles
---
Basic 99/month, Pro 299/month.Restart and it is live. The model calls search_knowledge({ query: "how much is Pro" }) first, then read_knowledge({ name: "pricing" }). As a library: buildAgent({ extraKnowledgeDirs: ["/path/to/docs"] }).
This is in-process Markdown search by default (keyword). RAG is opt-in and pluggable: set PI_KNOWLEDGE_RETRIEVAL=vector plus an embeddings source — PI_EMBEDDINGS_PROVIDER=openai|ollama|transformers. transformers runs the model in-process via @huggingface/transformers (auto-downloads ONNX weights to PI_EMBEDDINGS_CACHE_DIR on first use; no Ollama/server — but it's a lazy-loaded opt-in dep, so the default stays zero-dependency). search_knowledge transparently switches to embedding + cosine over a VectorStore (default in-memory; PI_KNOWLEDGE_VECTOR_STORE=sqlite persists to node:sqlite so restarts skip re-embedding). To use a real vector DB (Qdrant/pgvector), implement VectorStore and pass buildAgent({ vectorStore }); the tool contract and model side stay unchanged. This is the SDK's prescribed pattern — it ships no RAG; you register a searchable tool (we do) and pick the backend. @huggingface/transformers is an optionalDependency (installs by default; a failed native build is non-fatal; the code lazy-imports it). npm run rag:smoke verifies the local in-process path end-to-end and prints SKIP (exit 0, no false green) when the model host / native runtime isn't reachable.
Pluggable retrieval pipeline: search_knowledge and GET /knowledge/search share one Retriever. Default KeywordRetriever (zero-dep); VectorRetriever (opt-in) composes an EmbeddingProvider (OpenAI-compatible / Ollama / in-process transformers) and a VectorStore (in-memory / sqlite). Backend class names are read from source at generation time by scripts/visualization/generate_retrieval.mjs.
A prompt template is the SDK's slash-command mechanism: the filename becomes /<name>, and session.prompt("/name") expands it into the full body before sending (supporting positional $1, $@ / $ARGUMENTS, defaults ${1:-x}, and slicing ${@:N:L}).
src/prompt-templates/refund.md
---
description: Handle a refund for order $1
---
Check the refund policy for order $1, then draft the reply. Extra notes: ${2:-none}Restart and it is live: GET /prompt-templates lists it, and sending /refund ORD-42 expands the body. Loading goes through the SDK's additionalPromptTemplatePaths; noPromptTemplates: true keeps the loader from scanning global ~/.pi/agent/prompts (same isolation rule as skills / extensions). As a library: buildAgent({ extraPromptTemplatePaths: ["./prompts"] }); drop the bundled review example with buildAgent({ builtinPromptTemplates: false }).
The SDK has no native database. The scaffold uses Node 22's node:sqlite, defaulting to :memory: with two sample notes rows written at startup. GET /db for liveness, POST /db/query runs SELECT only. Agent-side tools: db_status / db_query.
const agent = await buildAgent({
databasePath: "./data/app.db", // or PI_DATABASE_PATH
});To swap implementations: implement DatabaseStore and pass buildAgent({ database: myStore }). HTTP and tools depend only on this interface.
Testing without calling the model:
curl http://localhost:3000/db
curl http://localhost:3000/db/notes
curl -X POST http://localhost:3000/db/query \
-H "content-type: application/json" \
-d '{"sql":"SELECT id, title FROM notes"}'| Event | When | Capability |
|---|---|---|
tool_call |
before tool execution | intercept / rewrite args |
tool_result |
after tool execution | rewrite result |
context |
before sending to the LLM | inject messages (e.g. user preferences) |
input |
after receiving user input | rewrite / block input |
before_agent_start |
before a run starts | modify the system prompt |
agent_settled |
one prompt fully done | reliable completion signal |
See the pi-agent SDK upstream repository for the full event menu.
Backend endpoints are listed below. Build your own page later; do not edit the sample HTML in the scaffold.
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
liveness probe (no dependency calls) |
| GET | /health/ready |
readiness probe — model + DB + knowledge, 503 on failure |
| GET | /metrics |
runtime metrics; ?format=prometheus for text format |
| GET | /info |
full inventory: current model, available list, catalogs, provider auth status, DB liveness, busy flag |
| GET | /providers |
per-provider auth status (official ModelRuntime.getProviders/checkAuth; id/name/authorized/source, never the raw key) |
| GET | /skills |
skill catalog (no model call) |
| GET | /skills/:name |
full SKILL.md |
| GET | /knowledge |
knowledge catalog |
| GET | /knowledge/search?q= |
keyword search |
| GET | /knowledge/:name |
full document |
| GET | /prompt-templates |
prompt-template catalog (no model call) |
| GET | /prompt-templates/:name |
full template body |
| GET | /db |
sqlite liveness |
| GET | /db/notes |
sample table |
| POST | /db/query |
{ "sql": "SELECT …" }, read-only, row-capped (200) |
| POST | /model |
{ "model": "provider/modelId" }, switch in current session |
| POST | /model/cycle |
cycle to the next model in scopedModels (no body); 400 when no cycle list |
| POST | /chat |
{ "message": "..."}, response is an SSE stream. Add ?format=jsonl for the raw official JSON event stream (one event per line, json.md vocabulary) |
/db/query accepts a single read-only statement. Validation strips comments and string literals first, then scans the whole statement for write/DDL keywords — so WITH x AS (DELETE …) SELECT … is rejected even though it starts with WITH. Rejections state the specific reason (e.g. 检测到非只读关键字:DELETE) so a model can self-correct. Results are capped at 200 rows and report truncated / totalRows.
When embedding into an existing Express app use createApp({ agent, staticDir: false }); do not open a second port. Full assembly, auth placement and measured results: docs/嵌入指南.md.
app.use(auth) after createApp() — or inside its configure hook — has no effect: the kernel registers its routes first, so your middleware is ordered after /chat and /model and never runs (measured: an unauthenticated request to /skills still returns 200, which leaves POST /chat and POST /model exposed). Mount the kernel app as a sub-app instead:
const { app: agentApp } = createApp({ agent, staticDir: false });
const server = express();
server.use("/agent", requireAuth, agentApp); // ✅ measured 401 / 200
server.listen(3000);The scaffold does the following; everything else is left to business code:
| Done on purpose | Deliberately not done |
|---|---|
| Model catalog, pick at startup, switch at runtime | Login / user system. A local tool needs none; when attaching to an existing backend, wrap with your existing auth |
CLI + HTTP share one buildAgent; the web tier runs several conversations per connection (cap 8 + LRU) |
Multi-user. The busy gate only guards the shared-session path |
| Repo skills via the SDK ResourceLoader; Markdown knowledge search; sqlite liveness + read-only query | Vector stores, external RAG, scanning local ~/.pi/agent/skills |
guard blocks dangerous bash and paths escaping cwd |
Sandboxing. Regexes cannot stop command substitution, encoded bypasses, symlinks. Use containers for isolation |
noExtensions / noSkills / noPromptTemplates: local extensions, skills and prompt templates are not scanned |
Public-internet exposure. Binds 127.0.0.1 by default, with no auth; only a non-loopback PI_HOST actually exposes it, and startup warns loudly when you do |
Default PI_BUILTIN_TOOLS=off |
Turning on coding hands disk edits and shell execution to the model |
Why coding tools are off by default while read stays on: wealways pass tools to createAgentSession(), and it becomes the SDK's allowedToolNames allowlist — passing nothing is what enables the full read / bash / edit / write set. The list comes from sessionToolPolicy(tier) driven by PI_BUILTIN_TOOLS (off yields just read), so bash/edit/write must be opened explicitly.
The cost: the allowlist cannot be extended after construction (the SDK exposes no way to change it — only setActiveToolsByName to toggle enablement). Tools added at runtime (MCP) must therefore berecomputed per session: resolveToolList() is deliberately evaluated inside createSession, so reopening a conversation picks them up. Computing it once at build time would make MCP tools unreachable forever.
Also: the SDK only writes the skill catalog into the system prompt when the tool set contains read, and the model uses read to load SKILL.md — this is the official path; there is no separate read_skill wrapper. The scaffold relies on that SDK-injected <available_skills> and no longer composes its own second catalog (the {{skills}} template layer was removed; the token still renders empty for backward compatibility with custom templates). The /skills and /prompt-templates inventories come straight from the loader (getSkills() / getPrompts()), so the catalog and what the system prompt / slash-expansion actually carries can never drift.
Why local extensions and skills are not loaded: pi extensions/skills on your machine may re-register bash/write, or push unrelated workflows into this vertical agent.
guard is not a sandbox. Rules live in src/extensions/guard.ts; adjust DANGEROUS_BASH_RULES for your business.
- Login: local desktop / CLI usage can live without it. When attaching to an existing backend, mount auth on the parent app (see
server.use("/agent", auth, agentApp)above) — adding middleware aftercreateApp(), or inconfigure, does not cover the kernel routes. - Multi-user: one
buildAgent()+ independent session per user; do not reuse the current singlebusyflag. - Enable coding tools:
PI_BUILTIN_TOOLS=codingor--builtin-tools coding. That also turns onexec/exec_jobs/exec_stop. Even then,guardstill blocks dangerous bash / exec and paths escaping cwd. It is not an interactive PTY. - Model switching: at startup
--model provider/modelId; in CLI/model; over HTTPPOST /model. Only models with configured keys are accepted, viasession.setModel, no session rebuild. The list / switch / key-swap all run on the SDK's officialModelRuntime(getAvailable()/setModel()/setRuntimeApiKey()); the only custom layer is a friendlier reference parser (resolveModelRef) that keeps model ids containing slashes, acceptsprovider+modelsplit, bare-unique ids, and authed-only matching — semantics the SDK's CLI-onlyresolveCliModeldoes not cover, so this piece is deliberately kept. - Custom providers:
setup.tswrites~/.pi/agent/models.json(the official custom-models path, enough for OpenAI/Anthropic-compatible vendors). For a proxy gateway, private endpoint, or custom auth resolution, register a provider through the SDK'spi.registerProvider(name, config)— either first-class viabuildAgent({ providers }), or via a hand-written extension likesrc/extensions/custom-provider.example.tspassed throughextraExtensions. Interactive OAuth (/login, device code) is TUI-only and deliberately not implemented in this headless backend. - Model cycling:
PI_SCOPED_MODELS(orbuildAgent({ scopedModels })) feeds the officialsession.cycleModel/cycleThinkingLevel; triggered by CLI/cycle,POST /model/cycle, or WScycle_model. Default derives the list from every authed model, so cycling works out of the box without changing the initial model. - AGENTS.md / project context (opt-in):
buildAgent({ includeAgentsFiles: true })stops thenoContextFilesisolation and lets the SDK append discoveredAGENTS.mdfiles as<project_context>. Defaultfalsekeeps the system prompt fully self-authored. - Code-defined commands:
buildAgent({ commands: { deploy: { description, handler } } })registers slash commands through the officialpi.registerCommand(the code-side twin of.mdprompt templates); a handler'sctxcansendUserMessage/waitForIdle. Seesrc/extensions/example-command.tsfor a stand-alone extension form. Default empty. - Tool exclusions & session helpers:
buildAgent({ excludeTools })applies the officialexcludeToolsdenylist after the allowlist;agent.waitForIdle()/agent.getThinkingLevel()exposesession.agent.waitForIdleandsession.thinkingLevel, andcycleModel("backward")uses the official direction argument. - Entry labels:
SessionManager.appendLabelChange/getLabelare surfaced via WSset_labeland the snapshot'slabels— user bookmarks on transcript entries. This is separate from thepi-starter.treerollback marker (which prevents leaf drift across restart); labels are for navigation only. - Consuming pi packages: the backend keeps
noExtensions/noSkills/noPromptTemplatesisolation and does not runpi install/pi update(those belong to thepiCLI). To use a package's resources, point itsskills/,prompts/,extensions/directories at the existing inject params —extraSkillPaths/extraPromptTemplatePaths/extraExtensions— which is the headless equivalent of the loader'sextendResources. - JSON event stream:
POST /chat?format=jsonlemits the raw SDK events as NDJSON (first line{"type":"session",...}, then one event per line) instead of the translated SSE frames — a language-agnostic exit for custom UIs, off by default so the existing SSE contract is unchanged. - Isolation (official pattern):
guardstays the default soft gate (regex + path, zero-dep, not a sandbox). The SDK's prescribed hard isolation is deployment-level (docs/containerization.md: Docker / Gondolin micro-VM / OpenShell) or api.registerTooltool-routing extension; a readyDockerfileruns the whole service in a container (docker build -t pi-starter . && docker run -p 127.0.0.1:3000:3000 -v "$HOME/.pi/agent:/home/pi/.pi/agent:ro" pi-starter), andsrc/extensions/sandbox.example.tsshows the override mechanism (routesbashoff-host, default off). Keep the-p 127.0.0.1:prefix — the service has no auth, so don't publish it to a public interface. - Skills / knowledge / database: skills into
src/skills/; knowledge intosrc/knowledge/; the DB is in-memory by default, or setPI_DATABASE_PATH/ passbuildAgent({ database }). For vector stores or remote SQL, write a tool and pass it throughextraTools. - Turning off the built-in example content:
buildAgent({ builtinKnowledge: false, builtinSkills: false, builtinPromptTemplates: false }). The bundledabout.md(a document describing the scaffold itself), thesummarizeskill and thereviewprompt template land in the system prompt / slash-command menu, andextraKnowledgeDirs/extraSkillPaths/extraPromptTemplatePathsare additive, not replacing — so this is the only way to exclude them. Worth doing when embedding into someone else's service. - Custom WS commands:
attachWebSocket(server, { commands: { my_cmd: defineCommand<{ a: number }>({ handler }) } }). Clients invoke it with{type:"my_cmd"}; unregistered commands get an explicit error frame rather than silence. Built-ins cannot be shadowed by a same-named registration. - Custom HTTP routes:
createApp({ configure: (app) => app.get("/biz", ...) }). Use this hook — do not add routes after grabbingapp: the error handler is mounted insidecreateApp, so later routes are registered after it and their thrown errors escape translation (measured: internal details reaching the client). If you already added routes, call the returnedseal(). - Resource cleanup: the values returned by
createApp()/attachWebSocket()exposeaddDisposer(fn), invoked ondispose()/close(). Register timers, child processes and temp files opened by your extension. - File service:
createApp({ files: new FileService({ root }) })exposes/files/*(browse / read / write / rename / copy / delete / Range raw content / base64 upload), all confined toroot; traversal and symlink escapes are rejected. - Approval rule editing:
createApp({ approvalRules: store })exposes/approval/rules. Changes hit disk immediately. - Surviving restarts:
buildAgent({ inMemory: false, sessionDir })+createSessionHub(..., allowedSessionRoots)lets this workspace's conversations be listed after a restart and opened withopen_conversation(clients send only the conversation id; the path is looked up in the index, never taken from the client).
Grouped by the question you are trying to answer. Read this index first, then pick.
Getting started: how to run it and use it
| File | What it covers |
|---|---|
docs/使用指南.md |
The three shapes (CLI / HTTP+SSE / web front end): how to run them, how to send a first message, how to add your own business logic, how to diagnose failures. One-page entry point. (Chinese) |
FAQ.md |
Setup, runtime, model switching, deployment, development Q&A |
README.zh-CN.md |
Chinese companion, mirrored section by section; Quick Start and secondary development live there too |
docs/project_overview/index.html |
Interactive one-page panorama of the whole project (architecture, stack, capabilities, routes, quality). Its numbers, file lists, route table and gate list are generated from source by npm run docs:overview. Published live on GitHub Pages: https://lpk3215.github.io/pi-starter/ |
docs/project_overview/project_card.html |
The same story as one shareable image: an 800 px card that exports itself to a 2x PNG. Its numbers come from the same generator, so the two pages cannot disagree |
docs/archify/ |
Interactive, self-contained architecture views (click to zoom / search / focus): core-architecture, full architecture-panorama, workflow-panorama, sequence-panorama (POST /chat), dataflow-panorama. Live: https://lpk3215.github.io/pi-starter/archify/core-architecture.html — reachable from the overview's Interactive section and linking back to it. Regenerable from the typed JSON under scripts/visualization/archify/; the gated docs/*.svg remain the source of truth |
The interface: what it actually exposes
| File | What it covers |
|---|---|
docs/参考手册.md |
Single source of truth for the external surface: WS protocol (client commands / server frames), the full REST route table, tools with capability/risk tags, every environment variable and npm script. Generated from source; npm run docs:reference:check blocks drift. (Chinese) |
docs/嵌入指南.md |
Embedding the agent into an existing Express service: two routes, auth placement, measured checklist. (Chinese) |
docs/官方SDK接口文档.md |
Interface notes for the upstream pi-agent SDK this scaffold wraps. (Chinese) |
Boundaries: what it does, what it deliberately does not, and why
| File | What it covers |
|---|---|
docs/能力与边界.md |
Capability list, functional boundaries, why we do/don't mirror the reference project, facts pinned during review. (Chinese) |
| This README's "Scope boundaries" section | One table of "does / deliberately does not" |
SECURITY.md |
Vulnerability reporting and known protection boundaries (guard is not a sandbox; no auth by default) |
docs/智能体视角评估.md |
First-person self-assessment: what holds up, what is genuinely still broken, order of improvement. (Chinese) |
Process and history (nothing here you must read first)
| File | What it covers |
|---|---|
CONTRIBUTING.md |
Dev setup, project map, self-check commands, commit conventions |
CODE_OF_CONDUCT.md |
Community conduct (Contributor Covenant 2.1) |
CHANGELOG.md |
Version history (Keep a Changelog + SemVer) |
docs/engineering-review.md |
Independent code review: conclusions plus measured evidence. (Chinese) |
docs/项目分析报告.md |
Archived snapshot (pre-0.3.0), for traceability only. (Chinese) |
docs/前端调研.md · docs/assistant-ui.md |
Front-end research and assistant-ui integration notes. (Chinese) |
AUTHORS |
Maintainers |
scripts/visualization/README.md |
How the diagrams above are regenerated |
Feature branches, PRs welcome. See CONTRIBUTING.md for the full flow and the self-check commands, and CODE_OF_CONDUCT.md for community conduct. Whenever counts, tools, endpoints, SSE events, or routes change, regenerate what is derived from source:
npm run docs:svg # three diagrams (architecture / request flow / retrieval)
npm run docs:numbers # the generated tables in both READMEs
npm run docs:reference # docs/参考手册.md (protocol / REST / tools / env / scripts)
npm run docs:overview # docs/project_overview/ (numbers, file lists, route table, gate list)
npm run docs:check # exits 1 if any generated surface is stale — this is what CI enforcespi-starter is a local-first scaffold. The HTTP server binds 127.0.0.1 by default and has no auth; read SECURITY.md before widening PI_HOST to a non-loopback address (startup warns when you do). guard is regex-based interception, not a sandbox.
- LPK3215 · GitHub @LPK3215 · ✉️ 17538703215@163.com
See also AUTHORS and the contributors graph.
- pi-agent SDK (
@earendil-works/pi-agent-core,pi-ai,pi-coding-agent) — the agent runtime this scaffold packages - Agent Skills specification — the
SKILL.mdformat - Node.js team for the built-in
node:sqlite(>=22.5) that removes the need for a native driver
Released under the MIT License.