@adelie/agent-development 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,536 @@
1
+ ---
2
+ name: penguin-sdk
3
+ description: Use whenever the user wants to build an agent application — their own program with an agent in it, such as an AI app, an agentic app, a RAG app or a bot — or to connect a program to an Adelie agent. A program reaches an agent in one of two ways, and the skill asks the user which before building — embedded with the Penguin Harness SDK (`@adelie/core`, no server), or over the Adelie server's Agent API (AMSP, with the `@adelie/amsp` client or curl). This is writing application code, not configuring an Agent State inside Adelie. Covers self-contained projects, the createSession/run streaming loop with thinking and image messages, wiring the user's existing tools in as CLI commands, a complete RAG recipe that ingests documents into a knowledge base and answers with citations behind a web UI, and the Agent API client. Also use it for workflows — the tabs and pages beside the chat that an Agent keeps in its own `workflows/` folder inside Adelie: building one, changing it, restoring an earlier version, and removing a tab or a whole workflow when the user wants the custom UI gone.
4
+ ---
5
+
6
+ # Penguin Harness SDK and Agent API
7
+
8
+ This skill builds programs that use an Adelie agent. A program reaches one in either of two ways, embedded with the SDK or through the Adelie server's Agent API, and the user picks which. Workflows, at the end of this file, are a third thing: pages and server code an Agent keeps inside Adelie.
9
+
10
+ To have an agent perform a task for you, use the `run_subagent` tool: both ways are for programs, not for handing off your own work.
11
+
12
+ ## Two ways to reach an agent
13
+
14
+ **The SDK: `@adelie/core`, embedded in the program.**
15
+
16
+ - *What it is.* The agent loop as a TypeScript library. The program creates its own Agent from a data directory inside the project and runs its Sessions in-process. No Adelie server is involved, at build time or at run time.
17
+ - *What you get.* The basic agent loop: the Agent State files (`AGENTS.md`, Skills, tools), the streamed OmniMessages and an `approve` callback for every tool call, in a self-contained folder that runs wherever Node 24 does, on the program's own model key.
18
+ - *What you don't get.* No server-managed Sessions (the program keeps its own), no visibility in the Web App, no approvals UI (the program answers in code), no cost center and no sidebar Background folder. The embedded agent belongs to the program: changes to an Agent inside Adelie never reach it.
19
+ - *Choose it when* the program must stand alone: shipped to other machines or people, run where no Adelie server is, or with the agent built into the product.
20
+
21
+ **The API: Adelie's Agent API, over AMSP.**
22
+
23
+ - *What it is.* The program calls an Agent that lives in Adelie, over HTTP. The Adelie server runs each turn and streams it back as AMSP events, and the program continues a conversation by its Session id. TypeScript uses the `@adelie/amsp` client (`AgentClient`); any other language, or curl, speaks HTTP and SSE directly.
24
+ - *What you get.* The Agent as Adelie runs it: its `AGENTS.md`, Skills and memory, on the Project's models and credentials, so the program holds no model key. The server keeps the conversations in the Agent's Background folder, where a person can read them and answer approvals, and their usage lands in the cost center.
25
+ - *What it requires.* A running Adelie server that the program can reach over the network; API access turned on for that Agent by the Project's owner, on the Agent's **API** tab; and a key created there, shown once. You never turn these on yourself (see *The API path*).
26
+ - *Choose it when* the Agent already exists in Adelie or should be managed there, people need to see its conversations or costs, or the program is not written in TypeScript.
27
+
28
+ ### Ask which way first
29
+
30
+ Whenever the user asks to connect a program to an agent, or to build a program with an agent in it (an AI, agentic or RAG app, a bot, a service, a script), ask which way before you write anything, even when the request is otherwise concrete. It is the only question a concrete request gets: it overrides the no-follow-up-questions default of *Before you start* for this one question.
31
+
32
+ Do not ask when the way is already chosen:
33
+
34
+ - in the user's message: "use the SDK", "standalone", "without the Adelie server" or "embed the agent" chooses the SDK; "call it over the API", "through the Agent API", "with AMSP", "with `@adelie/amsp`" or "with curl" chooses the API;
35
+ - earlier in this conversation, for the same program.
36
+
37
+ Naming this skill does not choose a way. Workflows (below) take neither way and need no question; neither does connecting a Feishu, Telegram, QQ or WeChat bot to a conversation, which is the Web App's Remote control.
38
+
39
+ Ask with one sentence and this choice block, in the user's language, as the end of your reply. Mark one option `"recommended": true` only when the request leans that way: an Agent the user already has in Adelie leans to the API, an app to ship to others leans to the SDK.
40
+
41
+ ````markdown
42
+ Your program can reach the agent in two ways: embedded with the SDK, or through the Adelie server's Agent API.
43
+
44
+ ```a2ui
45
+ {
46
+ "type": "choice",
47
+ "id": "agent-access",
48
+ "question": "How should the program reach the agent?",
49
+ "options": [
50
+ {
51
+ "label": "SDK: embed the agent",
52
+ "value": "Use the SDK: embed the agent in the program",
53
+ "description": "Runs inside the program on its own model key. No Adelie server; nothing appears in the Web App."
54
+ },
55
+ {
56
+ "label": "API: call an Adelie Agent",
57
+ "value": "Use the Agent API: call an Adelie Agent over AMSP",
58
+ "description": "Calls an Agent on your Adelie server. You turn on its API access and create a key; its conversations appear in the Web App."
59
+ }
60
+ ]
61
+ }
62
+ ```
63
+ ````
64
+
65
+ The Web App shows the block as two buttons; the CLI and the messaging channels show it as a numbered list. The answer arrives as the user's next message, in plain text: read it like any other message and build on the way it names.
66
+
67
+ ## Before you start
68
+
69
+ If the user's message only invokes this skill (e.g. "use penguin-sdk skill") without a concrete app to build, ask the user what they want to build. Once the request is concrete, settle the way first (see *Ask which way first*); a workflow needs none. Then do **not** ask follow-up questions: build it end to end with that way's defaults and list the assumptions you made in your final reply.
70
+
71
+ - SDK path: a self-contained workspace project, the project default model, BM25 retrieval, a web UI styled per the web-design skill.
72
+ - API path: a self-contained workspace project in TypeScript on `@adelie/amsp` (plain HTTP when the user's program is in another language), calling the Agent the user named ("you" means this Agent) or, with none named, a new Agent created for the purpose with the agent-initialization skill.
73
+
74
+ Each path also needs one thing only the user can provide: a model credential on the SDK path (see *Keys and the data root*), and on the API path the Agent's API access and key (see *The API path*).
75
+
76
+ ## The SDK path
77
+
78
+ `@adelie/core` is the TypeScript SDK this agent itself runs on. Use it to build your own AI apps:
79
+
80
+ - An **Agent** loads its state (prompts, tools, skills) from `<root>/<project_id>/agents/<agent_id>/`. Creating an Agent whose directory is empty initializes it with defaults.
81
+ - A **Session** is one conversation of an Agent inside a **Workspace** directory.
82
+ - `session.run()` executes one task and streams every step (thinking, text, tool calls) as OmniMessages.
83
+
84
+ ### Project location
85
+
86
+ Create the app in the current workspace directory by default (the `CWD` value from your Environment section), as a self-contained project — do not place it under `<app_data_dir>` (Adelie's app data root) or depend on any path outside the project folder. When creating the app's agent, the data root defaults **under the working directory (CWD)** too: point `createAgent({ root })` at a directory inside the project, resolved from the source file so it stays relative:
87
+
88
+ ```ts
89
+ const agent = await createAgent({ root: path.join(import.meta.dirname, "penguin_data") });
90
+ ```
91
+
92
+ With every reference relative to the project, the user can move or copy the folder anywhere and it still runs.
93
+
94
+ ### Keys and the data root — check before you build
95
+
96
+ **The app's Penguin data root must live inside the CWD workspace — never `~/.adelie`.** Point `createAgent({ root })` and every `penguin config ... --root <dir>` at a directory under the current working directory (e.g. `./penguin_data`); the global `~/.adelie` belongs to the person running Penguin and must never hold — or lend — the app's config or keys.
97
+
98
+ **Credential first, code second** — a finished app that cannot answer is a failed delivery discovered too late. Before writing any code:
99
+
100
+ ```bash
101
+ env | grep -oE "(DEEPSEEK|OPENAI|ANTHROPIC|GEMINI)_API_KEY" || echo none
102
+ ```
103
+
104
+ **Only two sources count as a usable credential**: a vault-injected environment variable (the check above; vault keys also appear in your Vault Keys section), or a key already configured in the app's own data root (`penguin config model list --root <data_dir>`). Keys in the global `~/.adelie` or any other `.adelie` directory do **not** count — a bare `penguin config model list` (no `--root`) reads the global store, because the CLI defaults to the global root unless `--root` is given, so a key showing up there proves nothing for the app and must never be used or copied.
105
+
106
+ If neither counted source yields a key, **stop immediately and ask the user to configure one — do not start building, and do not burn turns re-checking in a loop**: have them open this agent's settings via the **gear icon** on its card (left side, Agents page) and add a model API key (e.g. `DEEPSEEK_API_KEY`) in the **key vault** tab — vault values reach your shell environment on the next task. One clear check, then hand back to the user. Build only after a credential is confirmed, or after clearly agreeing with the user to build now and verify later. Model ids to offer the user come from the penguin CLI catalog (`penguin config model add --help`) and the agenthub-models skill's id table.
107
+
108
+ ### Setup
109
+
110
+ ```bash
111
+ npm install @adelie/core tsx
112
+ ```
113
+
114
+ If the package is not on your npm registry (it is developed in the Adelie monorepo and may not be published), develop inside a checkout of the Adelie repo instead: add your app as a workspace package under `packages/`, depend on `"@adelie/core": "workspace:*"`, then `pnpm install && pnpm build` at the repo root. Tell the user which route you took.
115
+
116
+ Configure a model for the app's data root, in this order — stop at the first that works:
117
+
118
+ 1. `penguin config model add --root <data_dir> --provider <group> --model-id <id> --api-key <key> [--base-url <url>] [--client-type openai-chat] --set-default` — prefer `--client-type openai-chat --base-url <endpoint>` (works with any OpenAI Chat Completions compatible endpoint; exact ids in the agenthub-models skill). `--provider` is required: a model is always the `(provider, model_id)` pair and the group is never inferred from the id (`custom` for an endpoint outside the built-in groups).
119
+ 2. Environment variables cover the **credential only** (`DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, …), and only for an entry whose requests go to the **vendor's own endpoint** (no `--base-url`, or the vendor's official one) — an entry with a `--base-url` of its own (a gateway, a self-hosted server) needs `--api-key`, or `createSession` refuses it. Model selection still comes from the project config, whose preset default is `deepseek-flash`. Env-only setup therefore works out of the box only with `DEEPSEEK_API_KEY`; for another vendor either run the CLI command above or pass a configured `{ provider, modelId }` pair to `createSession`.
120
+
121
+ Keep model API keys **project-local**: configure them with the penguin CLI into the app's own data root under the working directory, so the project stays self-contained and movable. When building an AI app, **always pass `--root <data_dir>` pointing at the app's data directory inside the current working directory** (the same path you give `createAgent({ root })`, e.g. `./penguin_data`) — never run `penguin config ...` without `--root`, or it writes to the global `~/.adelie/data` instead of the project. Never read, copy or fall back to model keys stored in the user's global `~/.adelie` directory — that config belongs to the person running Penguin, not to the app you are building.
122
+
123
+ Model config lives in one hidden file under the data root's project directory: `.project_config.toml`. It is CLI-only — never read, print or edit it.
124
+
125
+ If the user agreed to build before a credential exists, do not fake the verification: finish the build, report it as **unverified**, and point them at the key vault flow above — once a key is added, vault values reach your environment on the next task and you can run the self-test to completion.
126
+
127
+ ### Streaming loop
128
+
129
+ The raw `run()` stream mixes model, event and session-meta payloads — always narrow with the exported guards (`isModelMessage`, `isCompleteModelMessage`, `isEventMessage`) before touching `payload.type`; accessing `msg.payload.type` directly does not typecheck.
130
+
131
+ ```ts
132
+ import path from "node:path";
133
+ import readline from "node:readline/promises";
134
+ import { createAgent, isModelMessage, userText } from "@adelie/core";
135
+
136
+ const agent = await createAgent({ root: path.join(import.meta.dirname, "penguin_data") });
137
+ const session = await agent.createSession({ workspaceDir: process.cwd() });
138
+
139
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
140
+ for (;;) {
141
+ const line = await rl.question("> ");
142
+ if (!line.trim()) break;
143
+ // One run per user turn; the same Session keeps the conversation context.
144
+ for await (const msg of session.run([userText(line)], {
145
+ approve: async () => "allow", // demo only — a real app should ask its user ("deny" blocks the call)
146
+ })) {
147
+ if (isModelMessage(msg)) {
148
+ const p = msg.payload;
149
+ if (p.type === "partial_text" && p.event_type === "delta") process.stdout.write(p.text);
150
+ // CoT stream from reasoning models — show progress, but keep it out of the answer channel.
151
+ if (p.type === "partial_thinking" && p.event_type === "delta") process.stderr.write(p.thinking);
152
+ }
153
+ }
154
+ process.stdout.write("\n");
155
+ }
156
+ rl.close();
157
+ session.dispose();
158
+ ```
159
+
160
+ - `createSession({ workspaceDir, provider, modelId })` — `workspaceDir` must already exist (omit for a temporary workspace); the model reference is the `(provider, modelId)` pair, so pass both to pick a configured model or neither for the project default — passing one alone throws.
161
+ - The `approve` callback gates every tool call; **omitting it denies everything**.
162
+ - `opts.thinkingLevel` (`"none" | "low" | "medium" | "high" | "xhigh" | "max"`) overrides the agent's default (`model.thinking_level` in `system_config.yaml`) for this turn only — raise it for hard questions, drop it for latency-sensitive calls like titling or classification.
163
+ - Session lifetime is the app's memory model: reuse one Session for a stateful chat (context accumulates, as above), create one per request for stateless QA (the RAG recipe below); either way call `session.dispose()` when done to release background processes.
164
+ - An Agent's behavior is edited in its `agent_state/` files (system_config.yaml, AGENTS.md, skills/), not in code.
165
+
166
+ ### Thinking and image messages
167
+
168
+ Modern models think before answering and accept images; the stream and the input protocol carry both — use them instead of flattening everything to text.
169
+
170
+ **Thinking (CoT) out.** Reasoning models stream `partial_thinking` (field `thinking`) before any `partial_text`, and a complete `thinking` message follows. Show the stream — a silent 20-second wait reads as a hang — but keep it in its own channel: a collapsible muted block per the web-design skill, auto-collapsed once answer text starts. Never concatenate thinking into the answer, store it as the answer, or cite from it; ignore its `fidelity` field (core's replay bookkeeping). Non-reasoning models simply never emit it — don't reserve UI space.
171
+
172
+ **Images in.** Build image input with `imageUrlMessage` (a web URL or a base64 data URL) beside `userText` in the same `run` input:
173
+
174
+ ```ts
175
+ import { imageUrlMessage, userText } from "@adelie/core";
176
+ session.run([userText(question), ...images.map(imageUrlMessage)], { ... });
177
+ ```
178
+
179
+ Browser flow: `<input type="file" accept="image/*">` plus paste/drag-drop → `FileReader.readAsDataURL` → POST `{ question, images: [dataUrl] }` → the server maps each entry to `imageUrlMessage`. Reject non-image MIME types and cap size (a data URL rides the context window; a few MB is plenty). Whether the session model actually sees pixels is the model config's `vision` flag (`penguin config model list` prints `vision=Y/-`; set via `--vision/--no-vision` on `model add`, default supported): with `vision=false` the core folds the image into an `[attached image: <path>]` line and the built-in `read_file` tool reads it through the project's configured `vision_model` (`penguin config model vision --provider <group> --model-id <id> --root <data_dir>`) — the app still works, through a description instead of direct sight.
180
+
181
+ **Other payloads worth handling** (always narrow with the guards first): `partial_tool_call` / `partial_tool_call_output` — surface as an activity line ("running `search`…") in apps that grant tools; `request_end` (event) — a non-`completed` `status` is the error signal (`auth` → ask for a key; `message` carries the failure detail; `retry_in_ms` announces a planned in-run retry, renderable as a countdown); `token_usage` (event) — session-cumulative and last-request counts, if the app shows cost; `compaction_begin` / `compaction_end` (events) — long-lived chats only, show a brief "context being compacted" notice. Everything else is safe to ignore.
182
+
183
+ ### Wiring in the user's tools
184
+
185
+ When the app's agent must call the user's existing tools (scripts, internal CLIs, anything with an entry point), integrate them as **CLI commands** first: wrap each one as a small executable inside the project (a script under `tools/`, or the user's own binary), and describe it in the embedded agent's persona / `AGENTS.md` — name, what it does, one usage line. The agent invokes it through the built-in `exec_command` tool, so there is nothing to register: no schema to declare, arguments are flags, stdout is the result, the `approve` callback still gates every invocation, and the same command stays testable by hand.
186
+
187
+ Add an MCP server (`tools.mcpServers` in `system_config.yaml`) only when a CLI wrapper cannot express the integration — a long-lived authenticated connection, or tool schemas the model must see typed. Otherwise the CLI form is the cheaper default and keeps the project self-contained.
188
+
189
+ ### RAG knowledge app
190
+
191
+ The default recipe when the user wants an app that answers questions over a document set ("docs QA", "knowledge base", "chat with our docs", "become an expert on X"). The core contributes the agent loop only — retrieval is app code. Default to **lexical BM25**: no extra dependencies, no embedding credential, works offline. (Semantic upgrade: embed chunks via `@prismshadow/agenthub` — see the agenthub-models skill — and rank by cosine; only when an embedding-capable key is configured.)
192
+
193
+ ```
194
+ my-app/
195
+ package.json # "type": "module"; scripts: ingest / start
196
+ persona.md # the embedded agent's role — write it per the agent-initialization skill
197
+ ingest.ts # corpus/ → data/index.json; initializes penguin_data/, installs persona
198
+ rag.ts # BM25 retrieval over the chunk index
199
+ server.ts # POST /api/ask streams SSE; serves public/
200
+ public/index.html # chat UI — build it per the web-design skill
201
+ corpus/ # collected source documents
202
+ data/index.json # generated chunk index
203
+ penguin_data/ # agent data root (generated; model config lives here)
204
+ ```
205
+
206
+ **Collect** — clone or fetch the sources into `corpus/`, keeping only text formats:
207
+
208
+ ```bash
209
+ git clone --depth 1 <repo_url> corpus/<name> # or curl pages into corpus/
210
+ find corpus -type f ! -regex '.*\.\(md\|mdx\|txt\|html?\)$' -delete && rm -rf corpus/*/.git
211
+ ```
212
+
213
+ **Ingest** (`ingest.ts`) — split on markdown headings, cap chunk size, write one JSON index; also initialize `penguin_data/`, install the persona and strip the skills the embedded agent doesn't need:
214
+
215
+ ```ts
216
+ import fs from "node:fs";
217
+ import path from "node:path";
218
+ import { createAgent } from "@adelie/core";
219
+
220
+ const ROOT = import.meta.dirname;
221
+ const walk = (d: string): string[] =>
222
+ fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
223
+ e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
224
+
225
+ const STATE = path.join(
226
+ ROOT, "penguin_data", "default_project", "agents", "default_agent", "agent_state");
227
+ await createAgent({ root: path.join(ROOT, "penguin_data") });
228
+ fs.copyFileSync(path.join(ROOT, "persona.md"), path.join(STATE, "AGENTS.md"));
229
+ // A fresh default_agent is initialized with the whole built-in Skill library, and every installed
230
+ // Skill's metadata is injected into the system prompt of every /api/ask. This app only answers
231
+ // from retrieved context, so remove them: unrelated skill descriptions cost tokens on each
232
+ // question and pull the answer off-topic when one happens to match the wording of a question.
233
+ fs.rmSync(path.join(STATE, "skills"), { recursive: true, force: true });
234
+
235
+ const chunks: { id: number; source: string; heading: string; text: string }[] = [];
236
+ for (const f of walk(path.join(ROOT, "corpus")).filter((f) => /\.(md|mdx|txt|html?)$/i.test(f))) {
237
+ const raw = fs.readFileSync(f, "utf8");
238
+ const text = /\.html?$/i.test(f) ? raw.replace(/<[^>]+>/g, " ") : raw;
239
+ const source = path.relative(ROOT, f);
240
+ let heading = path.basename(f);
241
+ for (const block of text.split(/^(?=#{1,3} )/m)) {
242
+ heading = block.match(/^#{1,3} (.+)/)?.[1] ?? heading;
243
+ for (let i = 0; i < block.length; i += 1500) {
244
+ const piece = block.slice(i, i + 1500).trim();
245
+ if (piece.length > 40) chunks.push({ id: chunks.length, source, heading, text: piece });
246
+ }
247
+ }
248
+ }
249
+ fs.mkdirSync(path.join(ROOT, "data"), { recursive: true });
250
+ fs.writeFileSync(path.join(ROOT, "data", "index.json"), JSON.stringify(chunks));
251
+ console.log(`indexed ${chunks.length} chunks`);
252
+ ```
253
+
254
+ **Retrieve** (`rag.ts`) — standard BM25 (k1 = 1.2, b = 0.75); the tokenizer treats each CJK character as a token so Chinese queries work. The corpus-wide statistics (per-chunk term frequencies, document frequencies, average length) never change once the corpus is indexed, so build them **once** in `loadIndex` — a per-query rescan would make every question O(corpus):
255
+
256
+ ```ts
257
+ import fs from "node:fs";
258
+ import path from "node:path";
259
+
260
+ export interface Chunk { id: number; source: string; heading: string; text: string }
261
+ export interface Index {
262
+ chunks: Chunk[];
263
+ tf: Map<string, number>[]; // per-chunk term → count
264
+ len: number[]; // per-chunk token length
265
+ df: Map<string, number>; // term → number of chunks containing it
266
+ avg: number; // mean chunk length (BM25 length normalization)
267
+ }
268
+
269
+ const tokenize = (s: string): string[] => s.toLowerCase().match(/[a-z0-9]+|[一-鿿]/g) ?? [];
270
+
271
+ export function loadIndex(): Index {
272
+ const chunks: Chunk[] = JSON.parse(
273
+ fs.readFileSync(path.join(import.meta.dirname, "data", "index.json"), "utf8"));
274
+ const tf: Map<string, number>[] = [];
275
+ const len: number[] = [];
276
+ const df = new Map<string, number>();
277
+ for (const c of chunks) {
278
+ const toks = tokenize(`${c.heading} ${c.text}`);
279
+ const m = new Map<string, number>();
280
+ for (const t of toks) m.set(t, (m.get(t) ?? 0) + 1);
281
+ for (const t of m.keys()) df.set(t, (df.get(t) ?? 0) + 1);
282
+ tf.push(m);
283
+ len.push(toks.length);
284
+ }
285
+ const avg = len.reduce((n, l) => n + l, 0) / Math.max(len.length, 1);
286
+ return { chunks, tf, len, df, avg };
287
+ }
288
+
289
+ export function search(index: Index, query: string, k = 6): Chunk[] {
290
+ const { chunks, tf, len, df, avg } = index;
291
+ const q = [...new Set(tokenize(query))];
292
+ const score = (i: number): number => {
293
+ let s = 0;
294
+ for (const t of q) {
295
+ const f = tf[i]!.get(t) ?? 0;
296
+ if (f === 0) continue;
297
+ const n = df.get(t) ?? 0;
298
+ s += Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5)) *
299
+ (f * 2.2) / (f + 1.2 * (0.25 + (0.75 * len[i]!) / avg));
300
+ }
301
+ return s;
302
+ };
303
+ return chunks.map((_, i) => [score(i), i] as const)
304
+ .filter(([s]) => s > 0).sort((a, b) => b[0] - a[0]).slice(0, k)
305
+ .map(([, i]) => chunks[i]!);
306
+ }
307
+ ```
308
+
309
+ **Answer & serve** (`server.ts`) — one Session per request (stateless QA), retrieved chunks numbered into the prompt, deltas streamed over SSE, sources sent as the final event. A pure QA session needs no tool calls — deny every approval; a denied or tool-less turn terminates normally. Do **not** clear the toolset with `tools: { builtin: [] }`: an empty tools array is sent to the provider verbatim and some OpenAI-compatible endpoints reject it with a 400, which surfaces as a silent empty answer. Guard the request boundary — a malformed body must return 400, never reject the async handler (an unhandled rejection takes the whole server down) — and abort the run if the client disconnects mid-answer so you stop generating (and paying) for a page nobody is reading.
310
+
311
+ ```ts
312
+ import fs from "node:fs";
313
+ import http from "node:http";
314
+ import path from "node:path";
315
+ import { createAgent, isModelMessage, userText } from "@adelie/core";
316
+ import { loadIndex, search } from "./rag.ts";
317
+
318
+ const ROOT = import.meta.dirname;
319
+ const PUB = path.join(ROOT, "public");
320
+ const agent = await createAgent({ root: path.join(ROOT, "penguin_data") });
321
+ const index = loadIndex();
322
+ const MIME: Record<string, string> = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript" };
323
+
324
+ http.createServer(async (req, res) => {
325
+ res.on("error", () => {}); // a client that vanishes mid-write must not throw an uncaught EPIPE
326
+ if (req.method === "POST" && req.url === "/api/ask") {
327
+ let question: string;
328
+ try {
329
+ let body = "";
330
+ for await (const part of req) body += part; // a mid-body connection reset rejects here — caught below, never fatal
331
+ const parsed = JSON.parse(body) as { question?: unknown };
332
+ if (typeof parsed.question !== "string" || !parsed.question.trim()) throw new Error();
333
+ question = parsed.question;
334
+ } catch {
335
+ res.writeHead(400, { "content-type": "application/json" });
336
+ res.end(JSON.stringify({ error: "expected a JSON body { question: string }" }));
337
+ return;
338
+ }
339
+ const hits = search(index, question);
340
+ const context = hits.map((c, i) => `[${i + 1}] ${c.source} — ${c.heading}\n${c.text}`).join("\n\n");
341
+ const ac = new AbortController();
342
+ res.on("close", () => ac.abort()); // client navigated away → cancel the in-flight generation
343
+ // Create the Session BEFORE committing headers: a model-config failure then returns a real
344
+ // HTTP error instead of an unhandled rejection with a 200 already on the wire.
345
+ let session;
346
+ try {
347
+ session = await agent.createSession({ workspaceDir: ROOT });
348
+ } catch {
349
+ res.writeHead(503, { "content-type": "application/json" });
350
+ res.end(JSON.stringify({ error: "no model configured yet — see the setup steps" }));
351
+ return;
352
+ }
353
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
354
+ try {
355
+ const prompt = `Answer in plain text (no Markdown; short paragraphs) from the context below; cite blocks inline as [1][2]. If the context is not enough, say so.\n\n${context}\n\nQuestion: ${question}`;
356
+ for await (const msg of session.run([userText(prompt)], { approve: async () => "deny", signal: ac.signal })) {
357
+ if (isModelMessage(msg)) {
358
+ const p = msg.payload;
359
+ if (p.type === "partial_text" && p.event_type === "delta" && !res.writableEnded)
360
+ res.write(`data: ${JSON.stringify({ delta: p.text })}\n\n`);
361
+ // Reasoning models: forward CoT on its own SSE field so the UI can collapse it.
362
+ if (p.type === "partial_thinking" && p.event_type === "delta" && !res.writableEnded)
363
+ res.write(`data: ${JSON.stringify({ thinking: p.thinking })}\n\n`);
364
+ }
365
+ }
366
+ // Sources carry the matched chunk text verbatim: the UI must be able to show the exact
367
+ // block behind each [n], not just a file link.
368
+ if (!res.writableEnded)
369
+ res.write(`data: ${JSON.stringify({ sources: hits.map((c) => ({ source: c.source, heading: c.heading, url: `/${c.source}`, text: c.text })) })}\n\n`);
370
+ } catch {
371
+ // The run failed after headers were sent, or the client left: surface an error event (best effort), then clean up.
372
+ if (!res.writableEnded) res.write(`data: ${JSON.stringify({ error: "generation failed" })}\n\n`);
373
+ } finally {
374
+ session.dispose();
375
+ if (!res.writableEnded) res.end();
376
+ }
377
+ return;
378
+ }
379
+ const pathname = (req.url ?? "/").split("?")[0] ?? "/";
380
+ // /corpus/* serves the source documents read-only, so citation links resolve to real files.
381
+ const inCorpus = pathname.startsWith("/corpus/");
382
+ const base = inCorpus ? path.join(ROOT, "corpus") : PUB;
383
+ const rel = inCorpus
384
+ ? pathname.slice("/corpus/".length)
385
+ : pathname === "/"
386
+ ? "index.html"
387
+ : pathname.slice(1);
388
+ const file = path.normalize(path.join(base, rel));
389
+ if (file.startsWith(base + path.sep) && fs.existsSync(file) && fs.statSync(file).isFile()) {
390
+ res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "text/plain" });
391
+ res.end(fs.readFileSync(file));
392
+ } else {
393
+ res.writeHead(404);
394
+ res.end();
395
+ }
396
+ }).listen(Number(process.env.PORT ?? 4630), () => console.log("http://localhost:4630"));
397
+ ```
398
+
399
+ **UI** (`public/index.html`) — a chat interface built per the web-design skill: message list, streamed assistant text appended delta by delta (plain text under the output contract below: escape, split blank-line paragraphs, style the `[n]` markers), `thinking` events into the collapsible reasoning block (collapse it when the first answer delta arrives), the final `sources` event rendered as citations (pill chips or accordion source cards), an empty state inviting the first question with **3–4 example questions the corpus can actually answer** (clicking one submits it), and a visible error state when `/api/ask` fails. Citations must satisfy both of these, never bare text:
400
+
401
+ - **Reveal the original chunk**: clicking a citation chip (or an inline `[n]`) opens a popover/panel showing the matched chunk's `text` from the sources event **verbatim** — the numbering maps 1:1 to the context blocks in the prompt, so `[n]` always reveals exactly the block the answer drew on.
402
+ - **Link to the real document**: inside the popover, `<a href="<url>" target="_blank">` using the `url` field (`/corpus/<path>`, which this server serves) — clicking the chip itself opens the popover, the document link lives within it. When the corpus was cloned from a public repository, prefer mapping the path to the canonical upstream page instead (e.g. the GitHub blob URL derived from the clone URL).
403
+
404
+ **Output format and language** — settle both up front, in the persona and the retriever, not in the UI:
405
+
406
+ - **No Markdown pipeline — set the output format instead**: instruct the embedded agent (in `persona.md` and the per-request prompt) to answer in plain text — short paragraphs separated by blank lines, citations as bare `[n]`, no Markdown syntax. The UI then only escapes the text, splits paragraphs and styles the `[n]` markers; there is no renderer to build. When richer structure genuinely matters, have the model emit a small whitelisted HTML subset (`<p> <ul> <li> <strong> <code>`) and sanitize to exactly that whitelist before inserting — never inject unsanitized model output.
407
+ - **Cross-language retrieval**: the corpus and the user often speak different languages (English docs, Chinese questions), and BM25 is purely lexical — a Chinese question scores zero against English chunks. At ingest time derive a small bilingual keyword map for the corpus's core vocabulary (10–20 domain terms, e.g. `权限 → permissions / allow / deny`, `钩子 → hooks`) and expand query tokens through it in `search()` before scoring; keep the per-character CJK tokenizer. The persona already pins the answer language to the question's language.
408
+
409
+ **Persona** (`persona.md`) — the embedded agent's role, written per the agent-initialization skill. Shape: one role sentence ("You are an expert on X; you answer strictly from the provided context blocks"), citation and refusal rules, plain-text output (no Markdown — the output contract above), answer language follows the question.
410
+
411
+ ### Verify before you hand over
412
+
413
+ Never declare the app done without running it:
414
+
415
+ 1. `npm install` succeeds (or the workspace route builds).
416
+ 2. Model configured for `penguin_data` (CLI or env var; no usable key → see Setup: ask the user to add one to this agent's key vault, and report the app as unverified for now).
417
+ 3. `npm run ingest` prints `indexed N chunks` with N > 0.
418
+ 4. Start `npm start` in the background, then ask a real question:
419
+ `curl -N -sS -X POST localhost:4630/api/ask -H 'content-type: application/json' -d '{"question":"<something the corpus answers>"}'` — expect streamed `data:` deltas ending in a `sources` event that carries `source`, `url` **and the matched chunk `text`** per hit. If nothing streams, the model call failed: re-check step 2 and the provider endpoint before touching the code.
420
+ Then `curl` one of the returned source `url`s — it must return the document, not a 404 (citation links have to resolve).
421
+ 5. Open the UI (or screenshot it) to confirm the layout renders.
422
+
423
+ Fix any failure and re-verify; when the app accepts image input, one verification question must include a real image. Report with backtick-wrapped relative paths (`server.ts`, `public/index.html`, …), how to start the app, and the assumptions you made.
424
+
425
+ ## The API path
426
+
427
+ The program calls an Agent that lives in Adelie. You write the program; the Project's owner opens the Agent to it. Read [`reference/agent-api.md`](reference/agent-api.md) before you write any code: it has the steps to hand the owner, the client code, curl, the errors and the checks. These rules hold throughout:
428
+
429
+ - **Never open an Agent yourself.** Whether an Agent is open to programs is the owner's decision, and so is everything around it: API access, keys, keyless access, the API approval mode and the server-wide switch. An Agent must not be able to expose itself. The server refuses `penguin agent api enable`, `disable`, `set`, `keys create`, `keys rm` and `server` to the token in your shell with `403 human_required`, because they take a person's sign-in. Never run them, and never sign in or mint a session to get past that refusal. Ask the user to make the change on the Agent's **API** tab, or with those commands in their own terminal after `penguin auth login`, and give them the steps. `penguin agent api status` and `keys ls` only read; use them to check.
430
+ - **Keep the key out of the code.** The program reads it from the `ADELIE_AGENT_KEY` environment variable. Never write a key into source, a committed file, a log or a reply, and never ask the user to paste one into the chat.
431
+ - **Connection values.** The Base URL and the Agent ID come from the API tab or `penguin agent api status --agent-id <agentId>`. A local server's Base URL is `http://localhost:7364/api/amsp/v1`: `localhost`, not `127.0.0.1`, where a local server serves previews and refuses every API request with `401`. The Agent ID is `<projectId>/<agentId>`.
432
+ - **No data root, no model key.** The server runs the Agent on the Project's models. An SDK recipe keeps its shape on this path: the RAG app, for instance, replaces `createAgent`, `createSession` and `session.run` with an `AgentClient` run, and its persona goes into the Agent's `AGENTS.md`.
433
+
434
+ ## Workflows: pages and server code the Agent keeps for itself
435
+
436
+ Inside Adelie an Agent can hold *workflows*: small plugin packages in its own directory, written in TypeScript, that the server boots as module trees, shows as tabs beside the chat, reloads on every file change, and versions so any edit can be undone. This is the same module mechanism the server itself is built from — manifests as data, everything checked before any code runs — so a workflow that does not type-check, that was written against an interface version this server no longer fits, or whose manifests do not hold together fails to load with the problem named, while the previous version keeps serving.
437
+
438
+ **Start here — the whole loop is files.** You need nothing but your file tools: no HTTP API, no port, no login, no server source. Do not go looking for the running server, its bundle or a checkout of the repository, and do not probe its API — everything you need to know is written into the workflow folder by the server itself.
439
+
440
+ 1. *Where:* your Agent directory is `<App Data Dir>/agents/<Agent ID>/` — both values are in the Environment section of your system prompt. Workflows live in its `workflows/` folder (create it if it is missing); `ls` it to see what already exists before you add or change anything.
441
+ 2. *Scaffold:* write the three files below — `package.json`, a minimal `index.ts`, one page under `ui/`. The server notices the folder within a second or two.
442
+ 3. *Read what the server wrote back:* `.build/status.json` is the result of the last load — `{ ok, revision, checkedAt, error, tabs, hints }`. `ok: false` carries the compiler's or the checker's message with file, line and reason; fix that and look again. `ok: true` with an empty `tabs` means NOTHING shows in the user's chat page, however finished the page under `ui/` is: a page is only a file until `package.json` contributes a tab for it (see *Tabs are contributions* below), and `hints` spells out the entry to add. `.harness/README.md` is this same contract in the harness's own words — trust it over this skill if the two ever differ. `.harness/plugin.d.ts` is the exact `WorkflowHost` and `WorkflowMain` THIS harness offers — read it once instead of guessing at an interface.
443
+ 4. *Iterate:* edit, wait a moment, read `.build/status.json` again. `checkedAt` changing tells you the server saw your edit. When it says `ok: true`, lists your tab under `tabs` and has no `hints`, the tab is already in the user's chat page — and not before. If `status.json` never appears, ask the user to open this Agent's chat page once (that is what starts the server watching the folder) rather than hunting for another way in.
444
+
445
+ What you do not need to find out by experiment: `host.run` resolves as soon as the turn has STARTED (not when it ends) with `{ sessionId, queued }`; `host.sessionStatus(id)` returns `"idle"`, `"running"` or `"compacting"`, so a run is finished when its Session is back to `"idle"`; `host.getState()` is synchronous and returns whatever was last passed to `setState` (`null` before the first one).
446
+
447
+ Layout, under `<root>/<project_id>/agents/<agent_id>/workflows/<workflow_id>/` (beside `agent_state/`):
448
+
449
+ ```
450
+ package.json "type": "module" and "penguin": { "modules": [ …manifests ] } — no dependency to install
451
+ index.ts default export { modules: { <Name>: { create(ctx) } } } satisfies WorkflowPackage — TypeScript only
452
+ ui/ the workflow's pages and their assets; which of them are tabs is what the manifest contributes
453
+ state.json the workflow's own document, kept by the server across reloads and rollbacks
454
+ .harness/ the server's: the types this workflow was written against (plugin.d.ts) and their interface table — read plugin.d.ts, never edit it
455
+ .build/ the server's: emitted JavaScript per revision, and status.json — the result of the last load; read it, never edit it
456
+ ```
457
+
458
+ The root manifest is named `Workflow`; it requires the host, provides the handler, and contributes its tabs:
459
+
460
+ ```json
461
+ {
462
+ "name": "demo",
463
+ "version": "0.1.0",
464
+ "type": "module",
465
+ "penguin": {
466
+ "modules": [
467
+ {
468
+ "name": "Workflow",
469
+ "requires": { "host": { "iface": "@adelie/server#WorkflowHost", "from": "Host" } },
470
+ "provides": { "main": "@adelie/server#WorkflowMain" },
471
+ "contributes": {
472
+ "WebModule.sessionTabs": [
473
+ { "id": "demo.main", "key": "main", "title": "Demo", "titleZh": "演示", "renderer": { "iframe": { "src": "ui/index.html" } } }
474
+ ]
475
+ }
476
+ }
477
+ ]
478
+ }
479
+ }
480
+ ```
481
+
482
+ ```ts
483
+ import type { WorkflowPackage } from "@adelie/server/plugin";
484
+
485
+ export default {
486
+ modules: {
487
+ Workflow: {
488
+ create({ use }) {
489
+ const host = use.host;
490
+ return {
491
+ api: {
492
+ main: {
493
+ async handle(req) {
494
+ // req = { method, path, query, body }; path is below the workflow's api/ mount
495
+ if (req.path === "/ask" && req.method === "POST") {
496
+ const { question } = req.body as { question: string };
497
+ // The same two verbs as above: open a Session, run a turn in it.
498
+ const { sessionId } = await host.createSession();
499
+ await host.run(sessionId, [{ text: question }]);
500
+ return { body: { sessionId } };
501
+ }
502
+ return { status: 404, body: { error: "no such route" } };
503
+ },
504
+ },
505
+ },
506
+ };
507
+ },
508
+ },
509
+ },
510
+ } satisfies WorkflowPackage;
511
+ ```
512
+
513
+ **TypeScript, checked by the server.** There is no build step to run, no `tsconfig.json` to write and nothing to `npm install`: the server builds one program from `index.ts` (and the `.ts` files it imports, with `.js` in the import specifier as NodeNext asks) under options it fixes itself — `strict` among them — refuses the load on any diagnostic, reporting file, line and reason, and otherwise emits into `.build/<revision>/` and imports that. `satisfies WorkflowPackage` is what types `use.host` and `req`; without it `strict` refuses the untyped parameters. A folder holding `index.js` or `index.mjs` instead is refused outright.
514
+
515
+ **Types come from the harness that runs you.** One machine can run several harnesses — a release, a checkout, a platform someone pushed with interfaces of its own — and none of them is a version on npm, so `@adelie/server/plugin` is not a package here: the first time the server loads the folder it writes `.harness/plugin.d.ts`, rendered from ITS OWN interface table, and that is what the import resolves to. Create `package.json` and a minimal `index.ts`, let the server load it once, then read `.harness/plugin.d.ts` for the exact `WorkflowHost` this harness offers. The server then leaves `.harness/` alone: it is the record of what the workflow was written against, and a later generation of the platform is COMPARED with it, by the TypeScript compiler, both for what the workflow requires and what it provides — a host method you rely on that has since gone is a load error naming it, not a failure on the first call. To move a workflow onto the harness that runs it now, delete `.harness/` and fix what the compiler then reports.
516
+
517
+ **Tabs are contributions.** Each entry under `WebModule.sessionTabs` is one tab beside the chat: `key` (unique in the workflow, part of the full-page URL), `title` / `titleZh`, and a `renderer` whose `iframe.src` is a file under `ui/`. Several entries make several tabs; none makes a server-only workflow. It is the same slot, written the same way, a plugin contributes to — the host opens it to workflows and scopes the tab to this Agent. A slot the host has not opened (`WebModule.pages`, say) is refused by name.
518
+
519
+ `WorkflowHost` (published as module `Host`) speaks the SDK's verbs, scoped to the workflow's Project: `listAgents()` returns the Project's Agents as `{ agentId }[]`; `createSession({ agentId? })` opens a Session of this Agent — or of another Agent of the same Project — and returns `{ sessionId }`; `run(sessionId, [{ text: "…" }])` — the SDK's `session.run`, its input items spelled out because a workflow has no package to import `userText` from — runs one turn in a Session, new or existing, and returns `{ sessionId, queued }` once it has started (a busy Session queues it as a follow-up; the Agent hears it as a message from the server, not from a person); `sessionStatus(sessionId)` says `idle` / `running` / …; `getState()` / `setState(doc)` over `state.json` (`getState()` is `unknown`: narrow it); `log(text)`. More modules may be listed in `penguin.modules` and named as `children` of `Workflow`, with their own `requires` between them — the tree is checked as a whole.
520
+
521
+ HTTP, all under `/api/projects/:projectId/agents/:agentId/workflows` (Project members only): `GET /` lists the workflows with their `revision`, `uiRev`, `tabs` (each page's URL resolved) and current load `error`; `GET /:id/ui/*` serves a file of `ui/` (there is no default document — a tab names its page); any method on `/:id/api/*` reaches `handle` — JSON by default in both directions (`req.body` parsed, `{ status?, body? }` answered as JSON), and anything else when you say so: a non-JSON request body arrives as `req.bytes` (a `Uint8Array`; `req.headers` has its `content-type`, never the app's cookie), and a response that names a `content-type` in `headers` sends `body` as the string you wrote, or `bytes` for binary, or `stream` — an async iterable (an `async function*`) of strings or `Uint8Array`s, each chunk written to the client as you yield it, which is how server-sent events and a model's token-by-token answer get through without waiting for the end (relay an upstream stream by yielding its chunks; a `finally` in the generator runs when the client goes away — though a generator parked inside an `await` only gets there once that await settles, so give a relay its own timeout rather than trusting the disconnect) — with `location` + a 3xx status for a redirect. That is how a handler serves a page it renders, an upload, a download, or the pages of a program it runs on this machine: fetch `http://127.0.0.1:<port>/…` from the HANDLER and return what came back — never put `127.0.0.1` in a page, because the page runs in the user's browser, which may be on another machine than you are; `POST /:id/reload`; `GET /:id/history` lists recorded versions; `POST /:id/rollback { revision }` restores one (code only — `state.json` stays) and reloads. `DELETE /:id` removes the workflow together with its recorded versions. From a page directly under `ui/`, call your handler with a relative `fetch("../api/…")`; the Web App shows each page in its tab and reloads it when `uiRev` changes.
522
+
523
+ **Undoing, reverting, removing (撤销 / 还原 / 清掉界面).** When the user asks to undo the UI, revert it, clean it up or get the chat page back the way it was, they are asking you to TAKE SOMETHING AWAY — never to build something. Do not answer such a request by adding a feature (a reset button, an undo stack, a new route). Work out which of the four below they mean; if it is not obvious from what they said, ask in one line before touching anything. Everything about a workflow is files in your own Agent directory, so each of these is a file operation you make yourself — list `workflows/` first to see what exists. The server notices within a moment and the user's tabs follow without a refresh.
524
+
525
+ 1. *"Get rid of it" / "back to before there was any custom UI" / 回到没定义 UI 的状态* — remove the workflow entirely: delete the folder `workflows/<id>/` **and** its recorded versions `workflows-history/<id>/`. That is everything there is; the tabs disappear and nothing else of the Agent is touched. It cannot be undone, so say so in one line first, and never delete a workflow you were not asked to remove. (The user can do the same from the tab's bar: *Remove*, two clicks.)
526
+ 2. *"Undo your last change" / "it was better before"* — go back to an earlier version of the CODE: the recorded versions are full copies under `workflows-history/<id>/<revision>/` (`versions.json` lists them, newest first); copy that version's files over the folder and the server reloads it. The tab's bar offers the same as *History → Restore*.
527
+ 3. *"Remove that tab" / "I only want the first page"* — delete its entry under `WebModule.sessionTabs` in `package.json` (and its page under `ui/` if nothing else uses it). With `"contributes": {}` the workflow keeps its server code and has no UI at all.
528
+ 4. *"Bring back the data"* — you cannot: `state.json` is never part of a version (restoring code leaves the data as it is) and it has no history of its own. Say so plainly rather than inventing a recovery.
529
+
530
+ One constraint follows from 4, and it is a constraint on you, not a task to go and do: the workflow's data is only ever changed through `host.setState` from its handler. Never edit or delete `state.json` by hand — not to reset a board, not to fix a record — because what you overwrite is gone for good.
531
+
532
+ Your handler's responses are served from the app's own origin, so anything you echo back from a request is executed there: escape what you put into HTML, and treat a value that came in over `/api/*` as untrusted even though only Project members can reach it.
533
+
534
+ **Theme.** A workflow page is a separate document, so it inherits nothing from the app's stylesheet by itself. The Web App stamps `light`/`dark` on the page's root, copies its resolved palette (the gray scale, the accent pair, the font stack, the root font size) onto it, and injects `/workflow-ui.css` first in the head — a base stylesheet that styles plain HTML (headings, lists, forms, tables, code) to match the app and exposes `--wf-bg`, `--wf-fg`, `--wf-muted`, `--wf-border`, `--wf-surface`, `--wf-accent`, `--wf-accent-fg`, plus the classes `wf-primary` (a button), `wf-card`, `wf-rows`, `wf-row`, `wf-muted`. Write plain markup, take every colour and font from those variables, and the page follows the user through a theme or accent change; hardcode them and it clashes in one theme or the other. The page's own rules always win, and linking `/workflow-ui.css` yourself makes it look right when opened outside the app too.
535
+
536
+ **Filling the app.** A page can be shown as the whole app — no sidebar, no chat, no tab strip — at `/app/<project>/<agent>/<workflow>[/<tab key>]` (the workflow's first tab when no key is given): the tab's *Fill the app* button goes there, `penguin web --app <project>/<agent>/<workflow>[/<tab key>]` opens the browser straight onto it, and the page itself can ask with `parent.postMessage({ type: "penguin:fill-app" }, "*")`. The way back is the command palette — Ctrl+P or Ctrl+Shift+P (⌘ on macOS), both, so a page may take one of them for itself but never both — whose *Exit full page* lands on that Agent's chat.