Skip to content

About

Agent scaffold based on pi-agent SDK: tools, extensions, knowledge, skills, SSE server

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

122 Commits

Folders and files

Repository files navigation

π-starter

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

release license node gates issues last commit PRs welcome Contributor Covenant

Live project overview Interactive architecture diagrams

Repository: https://github.com/LPK3215/pi-starter · Upstream SDK: earendil-works/pi

Architecture

pi-starter architecture

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.

POST /chat SSE lifecycle

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.

Numbers

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:check guards against drift in CI.

Tech stack

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).

Features

  • 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 in web/ and speaks the same WS protocol from the browser
  • Layered prompts: src/prompts/ holds persona.md (who the agent is) + rules.md (working constraints) — edit the files to change the personality
  • Pluggable tools: define them under src/tools/, register in tools/index.ts, and they are auto-registered into the agent
  • Optional web access (PI_WEB=on): web_fetch reads an http(s) page as text; web_search appears 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 with buildAgent({ webClient }); the tool contract and the model-facing surface do not change
  • Skill management: src/skills/<name>/SKILL.md, loaded via the SDK's DefaultResourceLoader.additionalSkillPaths; the catalog is injected by formatSkillsForPrompt and full text is read by the built-in read tool through <location>
  • Knowledge base: src/knowledge/*.md — the system prompt carries only the catalog; bodies are fetched on demand via search_knowledge / read_knowledge (the SDK has no native knowledge base). Retrieval is pluggable behind the Retriever interface: keyword by default (zero-dep, current behavior), or vector RAG via PI_KNOWLEDGE_RETRIEVAL=vector + an OpenAI-compatible / Ollama embeddings endpoint — swap in a VectorStore (Qdrant/pgvector) later without touching the tool.
  • Cross-session memory (on by default, PI_MEMORY=off to disable): remember / recall persist 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, so rules.md tells the model what belongs there. Swap the store with buildAgent({ memory }); GET/POST/DELETE /memory exposes the same store over REST
  • Prompt templates: src/prompt-templates/<name>.md are the SDK's slash-command templates — session.prompt("/name") expands them (positional $1, $@, defaults ${1:-x}); loaded via additionalPromptTemplatePaths, with ~/.pi scanning off
  • Database: Node's built-in node:sqlite, in-memory by default with sample notes; GET /db for liveness, db_query for read-only queries
  • Extensions: hooks via pi.on() under src/extensions/. Ships with guard (pre-execution interception) and audit (timing logs)
  • One-command write into Pi native config: npm run setup merges into ~/.pi/agent/models.json + auth.json; no compatibility layer at runtime
  • Configurable, switchable model catalog: PI_MODELS in .env declares multiple providers and models, PI_MODEL picks the default; switch mid-session with /model in the CLI or POST /model over HTTP — no session rebuild. An official scopedModels cycle list backs /cycle (CLI), POST /model/cycle (REST) and cycle_model (WS); GET /providers reports per-provider auth via ModelRuntime.getProviders/checkAuth.
  • Built-in coding tools off by default: off enables 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; deny rules 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

Defaults

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.1 and warns loudly if PI_HOST is 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), so promptTemplate / disabledTools survive a restart — only a library consumer that constructs SettingsService without a port gets the in-memory default. builtinKnowledge / builtinSkills are 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 coding

readonly / 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.

Quick Start

0. Prerequisites

  • Node.js ≥ 22.19
  • A ModelScope token (or an API key from any OpenAI-compatible provider you switch to)

1. Install and write Pi config

npm install
cp .env.example .env   # Windows: copy .env.example .env
# edit .env, fill in PI_API_KEY
npm run setup

setup merges (never clobbers other providers) into the Pi native paths:

  • ~/.pi/agent/models.json — ModelScope baseUrl + 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 -- --force

The 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-0731

If 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-Air

Without 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: use openai-completions for domestic / OpenAI-compatible vendors, anthropic-messages for Anthropic
  • baseUrl goes only up to /v1; do not append /chat/completions

2. CLI chat

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 rpc

3. HTTP API (backend)

npm run web
# defaults to http://localhost:3000

The 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

Project Structure

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.

Secondary development: business comes in through the interface

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:

  1. Edit this repo: persona in src/prompts/, tools registered in src/tools/index.ts, skills dropped into src/skills/, knowledge into src/knowledge/, extensions registered in src/extensions/index.ts.
  2. 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 yours

extraExtensions 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.

Add a tool

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.

Add an extension

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] }).

Add a skill

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 policy

After 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.

Add a knowledge document

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.

knowledge retrieval pipeline

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.

Add a prompt template

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 }).

Attach a database

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"}'

Common pi.on events

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.

Embed into existing modules / build your own frontend

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.

⚠️ Auth must sit on the parent app. 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);

Scope boundaries

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.

Advanced (business decides)

  • 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 after createApp(), or in configure, does not cover the kernel routes.
  • Multi-user: one buildAgent() + independent session per user; do not reuse the current single busy flag.
  • Enable coding tools: PI_BUILTIN_TOOLS=coding or --builtin-tools coding. That also turns on exec / exec_jobs / exec_stop. Even then, guard still 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 HTTP POST /model. Only models with configured keys are accepted, via session.setModel, no session rebuild. The list / switch / key-swap all run on the SDK's official ModelRuntime (getAvailable() / setModel() / setRuntimeApiKey()); the only custom layer is a friendlier reference parser (resolveModelRef) that keeps model ids containing slashes, accepts provider + model split, bare-unique ids, and authed-only matching — semantics the SDK's CLI-only resolveCliModel does not cover, so this piece is deliberately kept.
  • Custom providers: setup.ts writes ~/.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's pi.registerProvider(name, config) — either first-class via buildAgent({ providers }), or via a hand-written extension like src/extensions/custom-provider.example.ts passed through extraExtensions. Interactive OAuth (/login, device code) is TUI-only and deliberately not implemented in this headless backend.
  • Model cycling: PI_SCOPED_MODELS (or buildAgent({ scopedModels })) feeds the official session.cycleModel/cycleThinkingLevel; triggered by CLI /cycle, POST /model/cycle, or WS cycle_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 the noContextFiles isolation and lets the SDK append discovered AGENTS.md files as <project_context>. Default false keeps the system prompt fully self-authored.
  • Code-defined commands: buildAgent({ commands: { deploy: { description, handler } } }) registers slash commands through the official pi.registerCommand (the code-side twin of .md prompt templates); a handler's ctx can sendUserMessage / waitForIdle. See src/extensions/example-command.ts for a stand-alone extension form. Default empty.
  • Tool exclusions & session helpers: buildAgent({ excludeTools }) applies the official excludeTools denylist after the allowlist; agent.waitForIdle() / agent.getThinkingLevel() expose session.agent.waitForIdle and session.thinkingLevel, and cycleModel("backward") uses the official direction argument.
  • Entry labels: SessionManager.appendLabelChange/getLabel are surfaced via WS set_label and the snapshot's labels — user bookmarks on transcript entries. This is separate from the pi-starter.tree rollback marker (which prevents leaf drift across restart); labels are for navigation only.
  • Consuming pi packages: the backend keeps noExtensions / noSkills / noPromptTemplates isolation and does not run pi install / pi update (those belong to the pi CLI). To use a package's resources, point its skills/, prompts/, extensions/ directories at the existing inject params — extraSkillPaths / extraPromptTemplatePaths / extraExtensions — which is the headless equivalent of the loader's extendResources.
  • JSON event stream: POST /chat?format=jsonl emits 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): guard stays 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 a pi.registerTool tool-routing extension; a ready Dockerfile runs 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), and src/extensions/sandbox.example.ts shows the override mechanism (routes bash off-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 into src/knowledge/; the DB is in-memory by default, or set PI_DATABASE_PATH / pass buildAgent({ database }). For vector stores or remote SQL, write a tool and pass it through extraTools.
  • Turning off the built-in example content: buildAgent({ builtinKnowledge: false, builtinSkills: false, builtinPromptTemplates: false }). The bundled about.md (a document describing the scaffold itself), the summarize skill and the review prompt template land in the system prompt / slash-command menu, and extraKnowledgeDirs / extraSkillPaths / extraPromptTemplatePaths are 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 grabbing app: the error handler is mounted inside createApp, 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 returned seal().
  • Resource cleanup: the values returned by createApp() / attachWebSocket() expose addDisposer(fn), invoked on dispose() / 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 to root; 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 with open_conversation (clients send only the conversation id; the path is looked up in the index, never taken from the client).

Documentation

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

Contributing

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 enforces

Security

pi-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.

Authors

See also AUTHORS and the contributors graph.

Acknowledgements

  • pi-agent SDK (@earendil-works/pi-agent-core, pi-ai, pi-coding-agent) — the agent runtime this scaffold packages
  • Agent Skills specification — the SKILL.md format
  • Node.js team for the built-in node:sqlite (>=22.5) that removes the need for a native driver

License

Released under the MIT License.

About

Agent scaffold based on pi-agent SDK: tools, extensions, knowledge, skills, SSE server

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages