# Building an “Open AI for Scientist” from scratch (OpenAI4S lineage) · full development tutorial

> **What this tutorial teaches**: not “read another scientific-agent codebase,” but how to **build an OpenAI4S-grade product with your own hands**—a local scientific workbench for wet-lab bioinformaticians and computational chemists: native JSON tools orchestrate and gate permission; a persistent Python/R kernel does scientific execution; a pure-stdlib daemon plus a hand-written WebSocket push results back into chat bubbles.
> **The most counter-intuitive point**: you think you are building “a smarter coding agent”—**wrong**. You are building a **scientific runtime of two action planes stacked together**. Even more counter-intuitive: **paranoia about the kernel protocol and provenance decides whether the product is trustworthy more than model choice does**. The control plane uses auditable JSON `Tool` calls; the scientific plane uses a full code Cell inside a persistent kernel. And the core engine, the LLM client (`urllib`), and the Web daemon (`http.server` + hand-written WS) are **all zero third-party dependencies**.
> **How it is taught**: like this repo’s *Open Design lineage*, *Manus lineage*, and *OpenWorker lineage*—**follow a real scene, start from a blank page, and every time you hit a wall, add one part**. The voice is **Codex product manager + a developer who writes the code**: first the trade-off, then how the files land, then how to accept.
> **Against source**: baseline `参考项目/OpenAI4S` @ commit `85e9fa0` (2026-08, Feat/retrosynthesis workflow triage). Every part cites a `file` and key symbols. Companion reading: *[OpenAI4S source analysis](../analyses/openai4s.md)*.
> **Prerequisite**: knowing “Agent loop = the model speaks → software acts for it → results are fed back” is enough. You **do not** need to know protein folding first; but you must be willing to start a local daemon and to use SQLite as the ledger.

---

## Contents

- [Look at the end first: what a “scientific dual-plane Agent” looks like](#end)
- [Build the chain of thought: eight propositions you must believe first](#think)
- [Step 0 · PRD: Open AI for Scientist](#s0)
- [Step 1 · From PRD to package layout](#s1)
- [Step 2 · Config and data-directory contract](#s2)
- [Step 3 · Multi-provider LLM: the `urllib` client](#s3)
- [Step 4 · `AgentEngine` ports and `route_action`](#s4)
- [Step 5 · `HostDispatcher`: the capability envelope](#s5)
- [Step 6 · Host SDK injected into the kernel](#s6)
- [Step 7 · Lazy persistent kernel + JSON-line protocol + sandbox](#s7)
- [Step 7.5 · Kernel-taste lab: dup2 / frame cap / false truth](#s7b)
- [Step 8 · Action Ledger + SQLite Store](#s8)
- [Step 9 · Native Tool catalog and `finalize_response`](#s9)
- [Step 10 · Skills: code recipes, not JSON Schema](#s10)
- [Step 11 · Gateway: `http.server` + hand-written WebSocket](#s11)
- [Step 12 · WebUI send path: composer → bubble](#s12)
- [Step 13 · Permission and approval cards](#s13)
- [Step 14 · Artifact versioning and provenance](#s14)
- [Step 15 · Context compaction](#s15)
- [Step 16 · Delegation](#s16)
- [Step 17 · FIFO execution coordination and session kernel ownership](#s17)
- [Step 18 · Science connectors and evidence packs](#s18)
- [Step 19 · Security layering (defense in depth)](#s19)
- [Step 20 · BYOC remote compute (optional later)](#s20)
- [Step 21 · Packaging: dmg / pip / WSL (later)](#s21)
- [🎬 Full replay: after the user types a prompt, how it is packed, uploaded, received, and run until it returns to the chat window](#replay)
- [Appendix A · Acceptance checklist](#a1)
- [Appendix B · Easiest ways to crash](#a2)
- [Appendix C · Source index](#a3)

---

<h2 id="end">Look at the end first: what a “scientific dual-plane Agent” looks like</h2>

Before the first line of code, see what you are assembling. **The boundary between a programming IDE Agent and a scientific Agent is not the model. It is these six things**:

1. **Actions are not a single tool table**—orchestration walks the JSON control plane; computation walks the persistent-kernel scientific plane;
2. **State must survive**—a 100 000-row DataFrame stays in kernel memory; context keeps only `"<DataFrame 100000×20>"`;
3. **The completion signal is a contract**—conversation/tool turns use the Engine’s own `finalize_response`; inside a scientific Cell there is only `host.submit_output(...)`;
4. **The ledger precedes the UI**—terminal states, tool groups, and usage are written into an append-only Action Ledger; chat bubbles are only a projection;
5. **The core is zero-dependency**—scientists’ machines are dirty, firewalls are many, installing deps explodes easily; a stdlib daemon is a product decision;
6. **Deliverables are evidence**—versioned Artifacts, environment provenance, retrieval SHA-256, not a chat log that will expire.

Draw those six into an assembly diagram:

```mermaid
flowchart TB
    subgraph UI["① What you can see"]
        CHAT["chat bubbles + Activity cards"]
        NB["read-only Notebook trace"]
        ART["versioned Artifact panel"]
        PERM["permission approval cards"]
    end
    subgraph GATEWAY["② Daemon (stdlib)"]
        HTTP["http.server REST"]
        WS["hand-written WebSocket"]
        RUN["SessionRunner._loop"]
        FIFO["FIFO execution coordinator"]
    end
    subgraph OUTER["③ Outer loop · AgentEngine"]
        MODEL["ModelPort · multi-provider LLM"]
        ROUTE["route_action"]
        TOOLS["NativeToolBatch execute"]
        FINAL["FinalizeAction"]
        CELL["CodeCell python/r"]
        LEDGER["Action Ledger"]
    end
    subgraph INNER["④ Inner loop · Kernel + host RPC"]
        K["Lazy persistent Kernel"]
        SDK["sdk/host.py inject"]
        DISP["HostDispatcher"]
        STORE["SQLite Store"]
    end
    subgraph CONTENT["⑤ Versionable content"]
        SK["skills/*/SKILL.md recipes"]
        ENV["envs/*.yml kernel environments"]
        WSFS["session workspace files"]
    end

    CHAT --> HTTP
    HTTP --> RUN
    RUN --> MODEL
    MODEL --> ROUTE
    ROUTE -->|native JSON| TOOLS
    ROUTE -->|sole finalize| FINAL
    ROUTE -->|one complete Cell| CELL
    TOOLS --> DISP
    CELL --> K
    K <-->|"host_call/ack/response"| SDK
    SDK --> DISP
    DISP --> STORE
    DISP --> SK
    K --> WSFS
    RUN --> LEDGER
    LEDGER --> STORE
    RUN --> WS --> CHAT
    WSFS --> ART
    DISP --> PERM
    K --> NB
    FIFO --> K
```

Remember this picture. Every step below fills one box.

**The key fork from the Open Design route**: Open Design **does not write** the Agent main loop (it treats an already-installed CLI as the engine); OpenAI4S **must write** the main loop—because scientific execution needs a persistent namespace, synchronous mid-cell Host RPC, and ledger/Artifact contracts; “borrow a coding CLI” cannot give you those.

---



---

<h2 id="think">Build the chain of thought: eight propositions you must believe first</h2>

> Product-manager view: before writing PRD clauses, decide the **worldview**. OpenAI4S source did not grow from “we need a chat box”; it was **worked backwards** from the eight propositions below. If you do not believe them first, the next 22 steps will keep reworking.

```mermaid
flowchart TB
    T1["① Scientific state must survive across turns"] --> T2["② print must never pollute the protocol"]
    T2 --> T3["③ A Cell must be able to call Host synchronously mid-cell"]
    T3 --> T4["④ Orchestration and computation cannot contest the same turn"]
    T4 --> T5["⑤ “Done” must be a structured fact"]
    T5 --> T6["⑥ UI bubbles are not the source of truth"]
    T6 --> T7["⑦ Fake provenance / fake green is unacceptable"]
    T7 --> T8["⑧ Core zero-dependency = the trust boundary"]
```

| # | Proposition | Wrong product if you do not believe it | Corresponding module |
|---|---|---|---|
| ① | DataFrames / fitted objects stay in the kernel | Every step shuttles CSV through tools; context explodes; results are not reproducible | Step 7 LazyKernel |
| ② | Protocol lines and user stdout must be physically split | Notebook “occasionally hangs,” frame misalignment, RPC reads a print | Steps 7 / 7.5 dup2 |
| ③ | A scientific loop needs mid-cell `host.*` | Fall back to ReAct; 14 round-trips to do one screen + plot | Steps 5–6 Host |
| ④ | JSON Tool and Code Cell are mutually exclusive, with priority | Same turn both rewrites metadata and runs a simulation; ledger unreadable | Step 4 route_action |
| ⑤ | finalize ≠ submit_output | “The model finished talking” but no metrics / artifact versions | Step 9 |
| ⑥ | Ledger/Store precede bubbles | Refresh loses state; Stop semantics drift; share packs are untrustworthy | Steps 8, 12 |
| ⑦ | A wrong fingerprint is worse than none | An R artifact stamped with a Python package list; paper attachments lie | Steps 7.5, 14 |
| ⑧ | Core stdlib-only | The moment you pull in a framework, sandbox / audit / packaging narratives all blur | Steps 1–3, 11 |

**Codex PM check sentence**: when reviewing any new requirement ask—“does this add a **defensible scientific fact**, or just another chat special effect?” Requirements that fail this sentence are deferred by default.


<h2 id="s0">Step 0 · PRD: Open AI for Scientist</h2>

### 0.1 One-sentence product

> **Open AI for Scientist (OpenAI4S)**: a hybrid scientific-agent workbench that runs on the scientist’s own computer—using a cheap Doubao / Ark API (even the ¥9.9/month tier works), it closes the loop from literature/database retrieval, data analysis, and visualization to a reproducible evidence pack; **not** another programming IDE Agent, and **not** multi-tenant SaaS.

### 0.2 Persona

| Persona | Daily pain | What success looks like |
|---|---|---|
| **Wet-lab bioinformatician** (MSc/postdoc, a little R/Python, mostly at the bench) | Sequencing comes back and they cannot clean it; they want a volcano plot but get stuck on deps; they fear losing raw data | Drop a count matrix into the workspace, state the goal in Chinese, get a versioned figure + a reproducible notebook trace + an environment fingerprint |
| **Computational chemist** (RDKit/docking; GPU locally or on a cluster) | Toolchains are scattered; remote GPU scripts are handwritten every time; results cannot be matched to which environment | Local orchestration + optional BYOC to GPU; Artifact records generation_id and input lineage |

### 0.3 Jobs-to-be-done

1. **Retrieve and land evidence**: query UniProt / RCSB / PubChem / arXiv; results must be hashable and archivable.
2. **Compute in a persistent environment**: multi-step pandas/scanpy/RDKit analysis must not collapse because “every step opens a new process.”
3. **Leave an auditable trace**: every tool/Cell step enters the ledger; dangerous operations pop an approval card.
4. **Deliver a shareable pack**: export a session / evidence pack to collaborators, not a screenshot of chat.
5. **Affordable**: default to Volcano Ark `ark`; the ¥9.9 Small plan can run the main path.

### 0.4 Non-goals (write them into the PRD or they will not block scope creep)

| Explicitly not doing | Why |
|---|---|
| **Not a coding IDE Agent** (not the home of repo-scale refactor, PR review, multi-file engineering navigation) | The user’s work object is data and experiments, not a git monorepo |
| **Not multi-tenant SaaS** | Data is sensitive; the trust boundary is “local single user + loopback”; multi-tenant would overturn the sandbox and key model |
| **No native Windows kernel** | The kernel depends on POSIX subprocesses, fd3/fd4, Seatbelt/bwrap; Windows uses WSL2 and the same Linux build |
| **No “unattended auto rm -rf”** | Headless defaults to deny; scientific users would rather click once more than silently destroy data |
| **MVP does not make Modal/SLURM first-class** | BYOC starts with SSH/NIM; cluster scheduling is v0.2+ |

### 0.5 Success metrics

| Metric | MVP bar | v0.1 target |
|---|---|---|
| Cold start to chat | After `./setup.sh && ./start.sh`, open `127.0.0.1:8760` within 60s | Same experience from a one-click dmg |
| No third-party core deps | `openai4s` engine import graph has no hard deps | CI gate |
| End-to-end scientific turn | `openai4s run "compute the mean and submit"` mockable offline | Browser smoke green |
| Cost reachable | Docs write the ark ¥9.9 path clearly | UI defaults to guiding ark |
| Security posture | bind `127.0.0.1`; headless deny | sandbox auto visible degrade |
| Repro | Artifact carries env provenance | Export session package |

### 0.6 Constraints

- **Models**: prefer Volcano Ark (Doubao etc.); also support OpenAI / Anthropic / Gemini lines; keys never enter the repo.
- **Platforms**: macOS (Apple Silicon first-class) + Linux x86_64; Windows = WSL2.
- **Runtime**: Python 3.10+; core pure stdlib; the scientific stack goes into the **kernel environment**, not the control-plane venv.
- **Data root**: `~/.openai4s` (overridable with `OPENAI4S_DATA_DIR`).
- **Compliance tone**: biosecurity screening on by default; telemetry off by default and requires consent.

### 0.7 MVP vs v0.1

| Capability | MVP (can sell the story) | v0.1 (can ship to a lab) |
|---|---|---|
| Dual-plane routing + finalize/submit | ✅ | ✅ |
| Persistent Python kernel + host RPC | ✅ | ✅ + R channel |
| SQLite Ledger + message projection | ✅ | ✅ + branch/resume |
| Static WebUI + WS streaming | ✅ | ✅ + Timeline/Notebook |
| Permission cards | basic ask/allow | persistent rules + restart-safe |
| Skills | 3–5 recipes | 30+ built-in |
| BYOC | ❌ | Prototype (SSH/NIM) |
| Packaging | pip-installable | dmg / linux tarball / WSL zip |

### 0.8 The walls hidden in that one sentence

Our first user story:

> **"Look up human insulin INS, pull sequence and structure from UniProt/RCSB, draw a simple figure, save the result as a reproducible artifact, and summarize in Chinese."**

| What the user said | The wall it hits | Which step fills it |
|---|---|---|
| “Look up” | Who reasons? How do multiple providers unify? | Steps 3–4 |
| (finish in conversation?) | What is the completion signal? | Steps 4, 9 |
| “Pull sequence and structure” | Science APIs + SSRF/egress | Steps 9, 18, 19 |
| “Draw / save artifacts” | Persistent kernel + Artifact versions | Steps 7, 14 |
| (don’t stuff 100k rows into the prompt) | Code-as-Action + compaction | Steps 7, 15 |
| (dangerous file writes) | Permission cards | Step 13 |
| (daemon died) | Ledger can rebuild history | Step 8 |
| “Summarize in Chinese” | WebUI streaming bubble projection | Steps 11–12 |
| (GPU later) | BYOC boundary | Step 20 |
| (install for a colleague) | Packaging | Step 21 |



### 0.9 Write the PRD as executable milestones (suggested schedule)

| Week | Deliverable | Corresponding steps |
|---|---|---|
| W1 | Package layout + Config + Engine unit tests green on a fake LLM transport | 1–4 |
| W2 | HostDispatcher + in-memory Store stub + `openai4s run` can finalize | 5–6, 9 |
| W3 | Real Kernel + host RPC + submit_output end-to-end | 6–7 |
| W4 | SQLite Ledger + migrations + crash fixture | 8 |
| W5 | gateway + static WebUI + WS streaming bubbles | 11–12 |
| W6 | Permission cards + Artifact versions + basic science_search | 13–14, 18 |
| W7 | Compaction + Delegation + FIFO hardening | 15–17 |
| W8 | Security-layer default posture + doctor | 19 |
| Later | BYOC, dmg/pip, R-channel polish | 20–21 |

### 0.10 Product principles (used to veto requirements while writing code)

1. **The ledger is truth; the UI is a projection.** Any PR that “only changes the frontend and calls it done” is sent back.
2. **Control plane and scientific plane do not race.** One step walks one channel.
3. **Wrong provenance is worse than missing.** If you do not know, write the missing reason.
4. **No subscriber must not default to allow.** A security event cannot be released because “nobody is looking at the card.”
5. **Core zero-dependency is a constraint, not a style.** Want to add `httpx`/`fastapi`? First write why urllib/`http.server` cannot do it.

> ✅ **This step’s acceptance**: you can tell an investor / lab PI in 90 seconds: two planes, stdlib daemon, not an IDE, not SaaS, ¥9.9, macOS+Linux.

---

<h2 id="s1">Step 1 · From PRD to package layout</h2>

### Purpose

Turn PRD boundaries into **directory ownership**. Later OpenAI4S crashes are 80% “new capability stuffed into the wrong package”: GPU provider written into `skills/`, SQL written into `gateway.py`, the completion contract registered as an ordinary Tool.

### Implementation shape

Target tree (against `85e9fa0`):

```text
openai4s/
  agent/          # 外环：engine、ports、actions、ledger、compaction、delegation
  kernel/         # 内环：manager、worker、lazy、r_kernel、sandbox 协作
  sdk/            # 注入到 worker 的 host 门面
  host/           # 能力实现（files/llm/delegation/…）
  host_dispatch.py
  llm/            # urllib 多供应商
  tools/          # Native JSON Tool 子类 + registry
  store.py        # SQLite 门面
  storage/        # repositories + migrations
  server/         # gateway、SessionRunner、webui/
  skills_loader/  # 配方加载
  compute/        # BYOC 宿主侧（后期）
  security/       # 分层策略
  config.py prompts.py …
skills/           # 内置配方（数据，不是引擎）
envs/             # conda/micromamba 环境定义
openai4s_compute_provider/  # 远程侧 stdlib worker SDK
```

### Stack

- Python 3.10+ package layout + `pyproject.toml` / setuptools
- **Forbid** hard third-party imports in the core package (numpy etc. only on the kernel side or behind `try/except ImportError`)

### Definition trade-offs

| Do | Do not |
|---|---|
| Split directories by **trust boundary and lifecycle** | Split dogmatically by “layer” (controller/service/repo) until nobody can read it |
| Keep compatibility façades (`gateway.py`/`store.py`/`host_dispatch.py`) thin | Pile new algorithms into the façade |
| Skills as a data tree shipped with the package | Treat Skills as a hot-load arbitrary-code plugin marketplace (MVP) |

### Strengths and costs

- **Strength**: CI and review have an answer to “where does this go”; mypy can be strict only on `agent/{engine,ports,…}` + `host_dispatch.py`.
- **Cost**: many directories for newcomers; you need bilingual READMEs as gates (OpenAI4S does exactly that).

### How it couples to previous and later modules

- Before: PRD non-goals directly decide “there is no `ide/`, no `saas/`.”
- After: step 2 config, step 4 engine, step 11 server all hang on this tree.

```mermaid
flowchart LR
    PRD["PRD boundaries"] --> PKG["package ownership table"]
    PKG --> AGENT["agent outer loop"]
    PKG --> KER["kernel inner loop"]
    PKG --> TOOLS["tools control plane"]
    PKG --> SRV["server projection"]
    PKG --> DATA["skills/envs data"]
```



### 1.4 Week-one minimum runnable skeleton (suggested files to create now)

```text
pyproject.toml
openai4s/__init__.py
openai4s/__main__.py
openai4s/config.py
openai4s/agent/{__init__,ports,models,events,actions,engine}.py
openai4s/llm/{__init__,client,transport,registry}.py
openai4s/tools/{__init__,base,registry}.py
openai4s/host_dispatch.py
openai4s/sdk/host.py
openai4s/kernel/{manager,worker,lazy}.py
openai4s/store.py
openai4s/server/{gateway.py,webui/index.html,webui/app.js,webui/style.css}
tests/test_agent_route_action.py
```

First let `python -m openai4s` print a version; then let `AgentEngine` run finalize under FakeModel. **Do not** start by beautifying the WebUI.

### 1.5 Ownership-table example (pin it on the wall)

| Change | Correct place | Wrong place |
|---|---|---|
| New control tool | `tools/foo.py` + `TOOL_TYPES` | inlined in `gateway.py` |
| New Host capability algorithm | `host/foo.py` | pile logic in `host_dispatch.py` |
| New SQL | `storage/foo.py` + migration | copy SQL in `store.py` |
| New WS event projection | `server/*_service.py` | only change `app.js` and pretend the backend exists |
| New scientific flow | `skills/my-flow/SKILL.md` | hardcode 3 000 lines into the system prompt |
| New GPU backend | `compute/` + `remote-compute-*` | shell out from some random skill |

> ✅ **This step’s acceptance**: draw a table of where “new Tool / new Host capability / new SQL / new UI route” land; pick one wrong landing and explain the consequence.

---

<h2 id="s2">Step 2 · Config and data-directory contract</h2>

### Purpose

Before writing the LLM, decide: **where keys come from, where data is written, which port the process binds**. Scientific users will configure models from the UI and also use `.env`; the two must not step on each other.

### Implementation shape

- `openai4s/config.py`: `Config` / `LLMConfig` dataclass + zero-dep `.env` loading
- Env-var layering: `OPENAI4S_<PROVIDER>_API_KEY` → `OPENAI4S_LLM_API_KEY` → provider default
- Data root: `~/.openai4s/{logs,artifacts,tool-results,compaction-history,openai4s.db,agent-workspaces,…}`
- Port: `OPENAI4S_HOST=127.0.0.1`, `OPENAI4S_PORT=8760`

### Stack

- `dataclasses` + `os.environ` + a hand-written dotenv (do not pull in `python-dotenv`)

### Definition trade-offs

| Do | Do not |
|---|---|
| Treat placeholder keys (`your-api-key-here`) as unconfigured | Treat a sample key as configured and get a “fake login” |
| Daemon can start with no key; configure later in the UI | Force interactive input at start (breaks `start.sh`) |
| Data dir `0700`, database `0600` | A world-readable “convenient share directory” |

### Strengths and costs

- **Strength**: Ark / Claude / Gemini keys can coexist; tests can point `OPENAI4S_DATA_DIR` at tmp.
- **Cost**: when debugging layered resolution you must print “which layer actually hit.”

### How it couples to previous and later modules

- After: LLM client (step 3) and Store paths (step 8); Gateway reads the same `Config`.

```mermaid
flowchart TD
    ENV["real environment variables"] --> RES["LLMConfig resolve"]
    DOT[".env does not override existing"] --> RES
    RES --> KEY["api_key / base_url / model"]
    RES --> CFG["Config.data_dir / host / port"]
    CFG --> DB["db_path"]
    CFG --> WS["agent-workspaces"]
```

> ✅ **This step’s acceptance**: with only `OPENAI4S_ARK_API_KEY` set, `provider=ark` can read it; a template placeholder is judged as no key.

---
<h2 id="s3">Step 3 · Multi-provider LLM: the `urllib` client</h2>

### Purpose

A scientist’s key may be Ark, DeepSeek, or official Claude/GPT/Gemini. The product must **switch providers in one line**, but cannot pull a dependency for every SDK—the core must stay pure stdlib.

### Implementation shape

```text
openai4s/llm/
  client.py          # chat() 编排入口
  transport.py       # urllib 的 post_json / post_sse
  registry.py        # PROVIDERS 目录（wire/model/base_url）
  providers/
    openai.py        # Chat Completions
    responses.py     # OpenAI Responses
    anthropic.py     # Messages
    gemini.py        # generateContent
  tooling.py messages.py capabilities.py catalog.py
```

Outward compatibility façade: `from openai4s.llm import chat`.

### Stack

- `urllib.request` + SSE line parsing
- Unified normalization: `ModelReply` / tool_calls (keep raw_arguments + parse_error + provider_meta)

### Definition trade-offs

| Do | Do not |
|---|---|
| Four wires + `ark` as an OpenAI-compatible entry | Install an official SDK for every model vendor |
| Capability layer (vision / tool_calling / parallel) | Assume every endpoint supports tools |
| Fall back to the Code-as-Action path when there is no tool_calling | Hard-send a schema and fail the whole turn |
| Streaming `on_delta` | Put a complex multimodal editor on the MVP critical path |

### Strengths and costs

- **Strength**: small package; offline tests can mock the whole transport; the Ark ¥9.9 path has zero friction.
- **Cost**: SSE/error codes must be aligned to each vendor’s docs yourself; strict schema differences need a capability buffer.

### How it couples to previous and later modules

- Before: Config resolves the key.
- After: `AgentEngine`’s `ModelPort` wraps a `ChatModel`; Host’s `host.llm` also walks the same `chat()`.

```mermaid
sequenceDiagram
    participant E as AgentEngine
    participant C as llm.chat
    participant T as transport.urllib
    participant P as Provider API
    E->>C: messages + tools + on_delta
    C->>C: provider_spec + capabilities
    C->>T: post_sse / post_json
    T->>P: HTTPS
    P-->>T: SSE chunks
    T-->>C: deltas + final
    C-->>E: ModelReply(tool_calls|content)
```



### 3.4 Minimal `chat()` shape (the contract you freeze first)

```python
# 教学简化版 —— 生产代码见 openai4s/llm/client.py
def chat(messages, cfg, *, on_delta=None, tools=None, post_sse, post_json):
    spec = provider_spec(cfg.provider)
    wire = spec["wire"]          # openai | responses | anthropic | gemini
    model = cfg.model or spec["model"]
    base = cfg.base_url or spec["base_url"]
    # 1) capability 门闩  2) 译 tool specs  3) 调对应 wire
    return wire_dispatch[wire](...)
```

The return value must be ingestible by `ModelReply.from_mapping`, at least including:

- `content: str`
- `tool_calls: list[{id, wire_id, name, ordinal, raw_arguments, arguments, parse_error, provider_meta}]`
- `usage: {prompt_tokens, completion_tokens}`

**Trade-off**: keep `raw_arguments` and `parse_error` together—the routing layer is a lossless boundary, not a second JSON parser. Parse failures still enter the Ledger; they must not be silently dropped.

### 3.5 Why Ark walks the OpenAI-compatible wire

Volcano Ark Agent Plan exposes an OpenAI Chat compatible endpoint. You put `provider=ark` in the registry, reuse `providers/openai.py`, and only change the default `base_url`/`model` catalog. That way the ¥9.9 path needs no third protocol implementation.

> ✅ **This step’s acceptance**: against a fake transport, assert that ark and anthropic both produce the same shape of `tool_calls`; when there is no key, the error message points at the correct env-var name.

---

<h2 id="s4">Step 4 · `AgentEngine` ports and `route_action`</h2>

### Purpose

Write an **outer-loop state machine that is unit-testable and does not depend on kernel/server**. This is the spine of the whole product: CLI `openai4s run` and Web `SessionRunner._loop` must share it, or the two loops will drift.

### Implementation shape

- `agent/ports.py`: `ModelPort` / `ActionExecutor` / `ContextPolicy` / `EventSink` / `CancellationPort` / `CompletionPort` / `ReplyInterceptor`
- `agent/engine.py`: `AgentEngine.run` turn loop
- `agent/actions.py`: `route_action`, `CodeCell`, `NativeToolBatch`, `FinalizeAction`
- `agent/models.py`: `RunState` / `ModelReply` / `ExecutionOutcome` / `EngineResult`
- `agent/events.py`: `RunStarted`…`RunFinished`

The core loop (semantically compressed):

```text
while turn < max_turns:
  prepare context
  reply = model.complete(messages, on_delta)
  action = route_action(reply.content, reply.tool_calls)
  outcome = executor.execute(action, reply, state)
  append history
  if outcome.stop_reason or completion: break
```

`route_action` iron laws:

1. The **sole** native call and it is named `finalize_response` → `FinalizeAction`
2. Otherwise, if there is any native call → `NativeToolBatch` (whole batch; control plane first)
3. Otherwise extract the **first** complete ```python / ```r Cell from the body
4. None of the above → `None` (upper layer nudges)

### Stack

- Pure typing Protocol + dataclass; mypy strict on these files

### Definition trade-offs

| Do | Do not |
|---|---|
| At most one action channel per step | Parallel “tools + Cell” in the same turn |
| finalize does not enter the Tool registry | Let a plugin replace the completion contract |
| Ordinary prose ≠ done | Treat the last assistant text as done |
| Multiple Cells: run only the first and attach `MULTI_CELL_NOTE` | Silently execute every Cell |

### Strengths and costs

- **Strength**: the engine can test routing and terminal states with no network and no subprocess; Web/CLI only swap adapters.
- **Cost**: if the model “talks and writes two Cells,” they are trimmed; you need the prompt and nudges to teach it.

### How it couples to previous and later modules

- Before: LLM `ModelReply`.
- After: the Executor is assembled by `agent/runtime.py` on CLI and by `WebActionExecutor` on Web; real work goes into Host/Kernel.

```mermaid
flowchart TD
    R["ModelReply"] --> RA{"route_action"}
    RA -->|"sole finalize_response"| F["FinalizeAction"]
    RA -->|"other native calls"| B["NativeToolBatch"]
    RA -->|"no native"| X{"extract_action"}
    X -->|python/r fence| C["CodeCell"]
    X -->|none| N["None → nudge"]
    F --> EX["ActionExecutor"]
    B --> EX
    C --> EX
    N --> EX
```



### 4.4 FakeModel-driven first tests (suggested to land as written)

```python
class FakeModel:
    def __init__(self, replies):
        self.replies = list(replies)
    def complete(self, messages, on_delta):
        reply = self.replies.pop(0)
        if on_delta and reply.get("content"):
            on_delta(reply["content"])
        return reply

class FakeExecutor:
    def execute(self, action, reply, state):
        # 断言 action 类型，然后返回 ExecutionOutcome(stop_reason=...)
        ...
```

Test first:

1. Sole `finalize_response` → stop reason is completion;
2. `web_search` + ```python in the body → walk the batch, do not run code;
3. Two python Cells in the body → extract only the first;
4. Unclosed ```python → do not execute + nudge.

Wait until these four are green before wiring real HTTP.



### 4.5 Micro-timing of one turn inside the engine

```mermaid
sequenceDiagram
    participant P as ContextPolicy
    participant M as ModelPort
    participant R as route_action
    participant X as ActionExecutor
    participant S as EventSink
    P->>P: prepare(messages)
    M->>S: TextDelta*
    M->>R: ModelReply
    R->>X: Action|None
    X->>S: OutcomeProduced
    alt stop/completion
      X-->>P: EngineResult
    else continue
      X->>P: history appended, next turn
    end
```

> ✅ **This step’s acceptance**: pure unit tests cover: finalize first, tools beat code, only the first of multiple Cells, unclosed fences do not execute.

---

<h2 id="s5">Step 5 · `HostDispatcher`: the capability envelope</h2>

### Purpose

Wherever a call comes from—a native Tool, or `host.web_search` inside a Cell—it must pass through **the same** permission / approval / audit / injection-screen / activity-event envelope. Otherwise you will own two inconsistent security stories.

### Implementation shape

- `openai4s/host_dispatch.py`: `HostDispatcher.__call__(method, args) -> data`
- `openai4s/host/*.py`: `WorkspaceFileService`, `LLMService`, `DelegationService`, `CompletionService`, `SkillService`…
- soft-fail: `{"error": msg}` → worker turns it into `RuntimeError`

### Stack

- Thread lock + optional `ThreadPoolExecutor` (read-only tool parallel waves live on the control executor side)
- Collaborates with `tools.registry.get_tool_by_host_method`

### Definition trade-offs

| Do | Do not |
|---|---|
| Dispatcher as envelope; algorithms sink into host/ | A 5 000-line God class implementing all scientific logic |
| Visible methods project as Activity steps | Flush internal `host.llm` calls into the Timeline |
| Plugin `register_tool` generic resolution | Hand-write a `_m_*` branch for every new tool (thin adapters may stay for history) |

### Strengths and costs

- **Strength**: one point for audit and permission; Web control tools and kernel RPC share policy.
- **Cost**: the façade is still large; refactors must be “surgery”; forbid whole-file rewrites.

### How it couples to previous and later modules

- After: the SDK only sends `host_call`; the Kernel manager calls the Dispatcher; the Ledger records audit.

```mermaid
flowchart LR
    A["Tool.invoke / host.*"] --> D["HostDispatcher"]
    D --> P["permission / approval"]
    D --> I["injection screen"]
    D --> S["host service"]
    D --> L["audit / step event"]
    S --> OUT["data | {error}"]
```

> ✅ **This step’s acceptance**: the same `web_fetch` entering from a Tool and from `host.web_fetch` has matching approval and audit fields.

---

<h2 id="s6">Step 6 · Host SDK injected into the kernel</h2>

### Purpose

Let scientific code call host capabilities **synchronously mid-Cell**: `host.llm`, `host.delegate`, `host.save_artifact`… This is the inner loop that a tool_use architecture does not have.

### Implementation shape

- `openai4s/sdk/host.py`: in-worker `host` singleton façade
- Inject a `host_call(method, args)` callback when starting the worker
- Codec: SDK snake_case ↔ wire camelCase; omit `None` fields (avoid JSON null hitting strict validation)
- `host.bash`: **does not execute shell on the Host**; Host only issues a one-shot token bound to command hash / cwd / generation / challenge; the worker executes locally

### Stack

- Pure Python façade; no third party
- `HOST_CAPABILITY_VERSION` written into the bootstrap manifest for restore comparison

### Definition trade-offs

| Do | Do not |
|---|---|
| Python inner-loop RPC | Give R equivalent mid-cell Host RPC (R is an independent analysis channel) |
| Strip `delegate/compute/...` symbols from the analysis kernel | Runtime if-reject (a symbol that exists but throws a business error is more misleading) |
| `host.submit_output` as the sole in-Cell completion | Register `submit_output` as a JSON Tool |

### Strengths and costs

- **Strength**: scientists compose capabilities with for/if/libraries; large objects never enter the prompt.
- **Cost**: deadlock risk—must have a single in-flight `host_call` lock + a single reader loop.

### How it couples to previous and later modules

- Before: Dispatcher.
- After: Kernel protocol frames `host_call` / `host_ack` / `host_response`.

```mermaid
sequenceDiagram
    participant Cell as Python Cell
    participant SDK as host SDK
    participant W as worker.py
    participant M as Kernel manager
    participant H as HostDispatcher
    Cell->>SDK: host.web_search(...)
    SDK->>W: host_call frame
    W->>M: stdout protocol line
    M->>H: dispatch(method,args)
    H-->>M: data
    M->>W: host_response
    W-->>SDK: return
    SDK-->>Cell: result
```

> ✅ **This step’s acceptance**: in a test kernel, execute `host.submit_output({"ok": True}, completion_bullets=["done"])`, outer-loop stop_reason=`submitted`.

---

<h2 id="s7">Step 7 · Lazy persistent kernel + JSON-line protocol + sandbox</h2>

### Purpose

Land the scientific plane: Tool/Finalize-only turns **do not** start a worker; the first Cell lazily pulls one up; the namespace persists across Cells; stdout and protocol are split; an OS sandbox constrains write paths and the naked network.

### Implementation shape

- `kernel/manager.py`: `Kernel` on the host side, spawn + single-thread frame reader
- `kernel/worker.py`: Python worker; `compile(..., "<kernel:N>")` + linecache; `getrusage` accounting
- `kernel/lazy.py`: CLI one-shot lazy ownership
- `kernel/r_kernel.py` + `r_worker.R`: same protocol; protocol on fd3/fd4, noise to stderr
- `kernel/environment.py`: subprocess env rebuilt from an allowlist (does not inherit daemon keys)
- `security/sandbox.py`: macOS Seatbelt / Linux bubblewrap; `auto|enforce|off`

Protocol (concept):

```json
{"type":"execute","id":"...","code":"..."}
{"type":"host_call","id":"...","method":"web_search","args":[...]}
{"type":"host_ack","id":"..."}
{"type":"host_response","id":"...","data":{...}}
{"type":"result","id":"...","ok":true,"stdout":"..."}
```

### Stack

- `subprocess` + JSON lines
- POSIX redirect (R); Seatbelt / bwrap

### Definition trade-offs

| Do | Do not |
|---|---|
| Lazy start | Preheat two heavy kernels at session start (waste) |
| Persistent namespace | A new process per Cell (ReAct-style; kills the scientific experience) |
| Capture stdout separately from the protocol | Let `print` break frame sync |
| Native Windows kernel | Refuse outright and guide to WSL2 |

### Strengths and costs

- **Strength**: Code-as-Action collapses 14 tool round-trips into 1 Cell; in-memory state is reusable.
- **Cost**: crash recovery, generation ABA, and watchdog all have to be done seriously (see step 17).

### How it couples to previous and later modules

- Artifact provenance binds to **kernel generation**, not the daemon process’s package list.
- The security layer (step 19) stacks on the spawn boundary.

```mermaid
flowchart TB
    T["Tool/Finalize turn"] -->|do not spawn| Z["no worker"]
    C["first Python Cell"] --> L["Lazy start python slot"]
    R["first R Cell"] --> LR["Lazy start R slot"]
    L --> SB["sandbox wrap"]
    SB --> W["worker subprocess"]
    W --> NS["persistent globals"]
    W --> RPC["mid-cell host RPC"]
```



### 7.4 Three iron laws when implementing the protocol

1. **Single reader**: only the `Kernel` manager thread reads worker stdout; supervisor/watchdog must not sneak a read.
2. **Single in-flight host_call**: the worker holds a lock through `host_call → host_response`; concurrent RPC will interleave frames.
3. **generation is monotonic**: every respawn bumps generation; tokens/leases/approvals bound to the old generation must fail.

### 7.5 Minimal worker pseudocode

```python
# worker.py 概念版
for line in sys.stdin:
    msg = json.loads(line)
    if msg["type"] == "execute":
        # 重定向 stdout 到捕获缓冲，协议帧走另一条通道
        try:
            exec(compile(msg["code"], f"<kernel:{n}>", "exec"), ns)
            send({"type": "result", "id": msg["id"], "ok": True, "stdout": buf})
        except Exception as e:
            send({"type": "result", "id": msg["id"], "ok": False, "error": ...})
```

The Host SDK’s `host_call` must walk a channel **separated from stdout capture**, or `print` and RPC smash each other.

> ✅ **This step’s acceptance**: two consecutive Cells, the second can read the first’s variables; a pure finalize turn has no worker in the process list.
>
> ⚠️ Not done yet: protocol anti-pollution and provenance taste are in **step 7.5**; that is where this tutorial parts ways with “ordinary Agent tutorials.”

---

---
<h2 id="s7b">Step 7.5 · Kernel-taste lab: dup2 / frame cap / false truth</h2>

### Purpose

Step 7 made the kernel “able to run.” This step makes you understand **why the author wrote the worker that way**—this is the physical landing of OpenAI4S taste. Against source: `kernel/worker.py` file header, `manager.py` single reader, CLAUDE.md “wrong provenance is worse than none.”

### Lab A · Protocol split (required thought experiment)

Suppose you are lazy: protocol and `print` share fd1.

1. The model Cell does `print(huge_df)`;
2. At the same time `host.web_search(...)` sends `host_call`;
3. The manager’s `readline()` reads a half table → JSON dies → the whole session hangs.

OpenAI4S’s solution:

- Python: `dup2(2,1)` + a high protocol fd published to `sys._openai4s_protocol_*` (**resolve again on every call; caching forbidden**);
- R: protocol on fd3/fd4, noise to stderr.

**Trade-off**: the protocol implementation becomes “ugly”; product behavior becomes “dull-correct.”

### Lab B · The frame cap is not a guess

Read the `_MAX_FRAME_BYTES` comment chain in `worker.py`:

- First there was a character cap `MAX_OUTPUT`;
- Then it was found that UTF-8 / `\uXXXX` / emoji surrogate pairs make “bytes” far larger than “characters”;
- The constant was revised from a wrong 6 to **12** bytes/character worst case, and tests nail it down.

**Taste lesson**: a scientific Agent’s users will type Chinese, molecular formulas, emoji comments. The cap must be derived from the **worst character**, or you will lose `error_lineno` and `usage` while “looking like you obeyed the docs”—again “wrong is worse than none.”

### Lab C · Single in-flight RPC and generation

```mermaid
flowchart TB
    A["Cell calls host.llm"] --> L["acquire _HOST_CALL_LOCK"]
    L --> S["send host_call"]
    S --> W["wait for host_response matching id"]
    W --> U["release lock"]
    U --> N["Cell continues"]
    R["worker respawn"] --> G["generation++"]
    G --> X["all old bash one-shot tokens fail"]
```

Acceptance thinking: if you allow concurrent host_call without a lock, what you save is latency; what you buy is **occasional mis-bound results**—unacceptable in a research scene.

### Lab D · provenance binds to generation

Wrong implementation: `capture_environment()` freezes the daemon’s `pip freeze`.
Correct implementation: look up “the generation_id of the language slot that wrote this Artifact” and freeze **that interpreter’s** package set; if unreadable, record the missing reason.

**Product copy**: writing “environment unknown (reason)” in the UI is far better than writing “environment = a list of packages that do not belong to it.”

### Definition trade-offs (this step’s dedicated table)

| Do | Do not |
|---|---|
| Protocol anti-pollution, anti-huge-frame, anti-fd-drift | Share stdout for “implementation simplicity” |
| Comments that write the failure history and the derivation | Silent magic constants |
| generation-bound safety tokens | Keep using old tokens after restart |
| matplotlib figures captured by the gateway | autoclose in the worker so the figure vanishes |

### How it couples to previous and later modules

- Steps 5–6 Host’s soft-fail contract depends on this step turning `{"error"}` into an in-Cell exception.
- Step 14 Artifact depends on this step’s generation fingerprint.
- Step 19 sandbox stacks outside spawn; **it cannot replace** the protocol split.

> ✅ **This step’s acceptance**: you can explain “why R uses fd3/fd4”; you can explain “why the frame cap is 12”; you can explain “why an R artifact must not be stamped with a Python freeze.” If you cannot, you have not yet understood the kernel.

---
<h2 id="s8">Step 8 · Action Ledger + SQLite Store</h2>

### Purpose

**The ledger precedes the UI**: tool declarations and results, Cell attempts, terminal facts, usage, approval resolutions must all be reconstructable. After a daemon restart, you cannot rely on “what the browser still remembers.”

### Implementation shape

- `store.py`: single connection, `RLock`, schema/migrations, read-only query guardrails, compatibility façade
- `storage/`: frame / artifact / action ledger / approvals / settings / memories… repositories
- `agent/ledger.py`: write `AgentEvent` as immutable group/event; a reducer fills in half-open tool groups after a crash

Key invariants:

- A native assistant declaration + all of its tool results is an **atomic group** (compaction cannot split it either)
- Terminal state is appended; it is not reverse-inferred from chat bubbles
- `host.query` is **read-only** and denylists settings/permissions/ledger and other sensitive tables
- `PRAGMA user_version` + `schema_migrations`; migrations in a single transaction, not WAL (an audit-store choice)

### Stack

- stdlib `sqlite3`
- Explicit migration map `Store._migrate`

### Definition trade-offs

| Do | Do not |
|---|---|
| append-only semantic groups | Impersonate history with a mutable “current-state row” |
| After restart, approval cards can be clicked, but old args are not replayed | Replay the approval payload as execution args |
| Store.close is idempotent and only evicts itself | A global singleton deadlocks the next generation of connections |

### Strengths and costs

- **Strength**: Timeline, cost, and compliance audit share one source; tests can assert the trace.
- **Cost**: you must maintain both the projection (messages) and the truth (ledger); it is mentally expensive.

### How it couples to previous and later modules

- Engine events → Ledger writer; Gateway projects assistant-visible text; restore reads the journal.

```mermaid
flowchart TB
    E["AgentEvent"] --> W["RuntimeActionLedger"]
    W --> G["action_groups"]
    W --> V["action_events"]
    G --> R["reducer → provider-safe history"]
    R --> M["next model.complete"]
    UI["chat bubbles"] -.->|projection only| G
```



### 8.4 Migration discipline (copy into team convention)

- New version = new step function + `SCHEMA_VERSION += 1`
- A step **must not** `commit` itself; the outer layer is a single transaction
- integrity_check first, then backup API, then migrate; on failure keep the backup
- Backfills must be `WHERE`-idempotent
- Do not casually change `journal_mode`/`synchronous`: this is an audit store, not a high-QPS business store

### 8.5 `host.query` denylist mental model

The Agent can “look at its own database”—that is powerful and dangerous. The denylist at least covers: `settings`, permission tables, the raw ledger, recovery journal, credentials-related. Match after stripping literals so `SELECT 'settings'` is not a false positive, but the `"settings"` identifier is still blocked.

> ✅ **This step’s acceptance**: manufacture a crash fixture of “tool group missing results”; after restart the reducer fills a canonical error, rather than sending a half-open batch back to the model.

---

<h2 id="s9">Step 9 · Native Tool catalog and `finalize_response`</h2>

### Purpose

Make deterministic orchestration a **named `Tool` subclass catalog**: schema, safety policy, and `execute()` in the same file; and close non-scientific turns with the Engine’s own `finalize_response`.

### Implementation shape

- `tools/base.py`: `Tool` / `ToolSpec` / `invoke()` (forbid calling `execute` directly and bypassing the envelope)
- `tools/registry.py`: `TOOL_TYPES` the unique built-in instantiation point; forbid registering `bash` / `submit_output`
- `agent/finalize.py`: closed schema (summary/findings/metrics/artifacts/limitations/next_steps/completion_bullets)
- Scientific-library breadth does not inflate tool count: only `science_list_dbs` + `science_search`

### Stack

- JSON Schema subset validation (in-house / built-in)
- provider adapters translate `ToolSpec` into each vendor’s wire

### Definition trade-offs

| Do | Do not |
|---|---|
| Read-only tools can run in parallel waves by resource_keys | Parallelize mutating tools (after the barrier they go serial) |
| `writes_files=True` does Artifact transactions at the Web boundary | Double-register kernel file writes in the Dispatcher |
| finalize metadata ToolSpec is only for model visibility | Put finalize into `REGISTRY` so a plugin can replace it |

### Strengths and costs

- **Strength**: classification is clear; permission metadata and behavior do not drift.
- **Cost**: the catalog will grow; you need taxonomy/`side_effect_class` to keep it.

### How it couples to previous and later modules

- `route_action` recognizes finalize; `WebActionExecutor` / CLI runtime execute the batch; results enter the Ledger.

```mermaid
flowchart LR
    SPEC["ToolSpec catalog"] --> LLM["provider tools params"]
    LLM --> CALLS["native tool_calls"]
    CALLS --> RA["route_action"]
    RA -->|batch| INV["Tool.invoke → Dispatcher"]
    RA -->|finalize| FIN["finalize.py validate → CompletionRecord"]
```



### 9.4 Control-tool parallel waves (do not copy this wrong when implementing)

A prefix that is read-only and whose `resource_keys` do not conflict can run in parallel; **the first mutating/unknown call is a barrier**, then serial; write-back to history must follow the provider’s original order. Parallel completion order must never scramble the canonical group—otherwise replay and compaction all break.

### 9.5 `finalize_response` minimal-schema mental model

The model must give:

- `summary` (grounded)
- `completion_bullets` (1–4 phrases of completed actions)
- optional findings/metrics/artifacts/limitations/next_steps

The Host then validates the same closed schema again. A scientific Cell **must not** close with it; keep using `host.submit_output`.

> ✅ **This step’s acceptance**: attempting `register_tool(bash)` fails; a sole `finalize_response` produces a structured completion; when other tools are mixed in, it does not walk FinalizeAction.

---

<h2 id="s10">Step 10 · Skills: code recipes, not JSON Schema</h2>

### Purpose

Write domain knowledge (docking, single-cell, folding flows) as **loadable recipes**: `SKILL.md` + optional `kernel.py` sidecar. The model first sees name+summary; when needed it `host.search_skills` / `load_skill`—progressive disclosure.

### Implementation shape

- Repo `skills/<name>/SKILL.md`
- `openai4s/skills_loader/loader.py`
- User skills only under `<data_dir>/user-skills`; on a bundled same-name conflict, bundled (read-only) wins
- sidecar compile-check before use
- Host authoring flow: `draft → personal`; Web Customize is `user` origin and cannot forge a trusted origin

### Stack

- Markdown frontmatter + file tree
- Generation-bound to Store capability state (the loader does not cache a closed repo)

### Definition trade-offs

| Do | Do not |
|---|---|
| Skill = code recipe / operations manual | Skill = another JSON tool schema marketplace |
| Progressive disclosure | Stuff 34 full texts into the system prompt |
| sidecar structural gates | import arbitrary user code with no check |

### Strengths and costs

- **Strength**: scientific flows are versionable and reviewable; the engine stays thin.
- **Cost**: recipe quality variance becomes product experience; you need a readiness check.

### How it couples to previous and later modules

- Tools: `search_skills` / `load_skill`; in-Cell `host.skills.*`; remote compute skills carry `provider.py` (step 20).

```mermaid
flowchart TD
    CAT["catalog: name+summary"] --> M["model decides whether to load"]
    M -->|load| MD["SKILL.md full text"]
    M -->|sidecar| PY["kernel.py compile-check"]
    MD --> CELL["Cell executes by recipe"]
    PY --> CELL
```



### 10.4 Write your first Skill (minimal recipe)

```text
skills/insulin-quicklook/
  SKILL.md
```

Suggested `SKILL.md` structure:

```markdown
---
name: insulin-quicklook
summary: 从 UniProt 拉取 INS 摘要并在内核中计算序列长度
---

# Insulin quicklook

1. 用 science_search 查 UniProt: INS human
2. 将序列写入 workspace
3. 开一个 python cell 计算 len(seq) 并作图
4. host.save_artifact(...)
5. host.submit_output(...)
```

Do not write the recipe as a “mysterious JSON tool.” The recipe’s readers are the model and a human reviewer.

> ✅ **This step’s acceptance**: before load, the system prompt does not contain the full text; after load, a Cell can import the sidecar; a user skill cannot shadow the built-in catalog.

---

<h2 id="s11">Step 11 · Gateway: `http.server` + hand-written WebSocket</h2>

### Purpose

Productize the engine into a **local workbench**: REST for resources, WS for turn streaming events. Stay zero-framework—one fewer dependency conflict in a scientist’s environment.

### Implementation shape

- `server/gateway.py`: composition adapter (large, but a façade)
- `WSHub`: subscribe by `root_frame_id`, broadcast, disconnect resume cursor
- `SessionRunner`: session state, job queue, `run_message`, `_loop`
- `server/webui/`: `index.html` / `app.js` / `style.css` **no build step**, served as static files from the worktree
- Default bind `127.0.0.1`; local auth / security headers (CSP etc.)

### Stack

- `http.server` + hand-written WS handshake/frames (see `ws_frames.py`)
- Thread model: blocking POST runs a turn + WS push

### Definition trade-offs

| Do | Do not |
|---|---|
| stdlib HTTP/WS | FastAPI/uvicorn as a core hard dep |
| Static worktree WebUI | A React build chain on the MVP critical path |
| Singleton pidfile daemon | Multiple daemons racing for the same DB |

### Strengths and costs

- **Strength**: `./start.sh` is simple; JS changes take effect on refresh.
- **Cost**: the gateway file is huge; new routes must land in a focused service, not keep swelling it.

### How it couples to previous and later modules

- The next chapter is dedicated to the send path; Artifact/permission/restore services hang on the same runner.

```mermaid
flowchart LR
    B["Browser"] -->|REST| G["gateway.py"]
    B -->|WS| H["WSHub"]
    G --> SR["SessionRunner"]
    SR --> ENG["AgentEngine"]
    SR --> H
    G --> STATIC["webui/*"]
```

> ✅ **This step’s acceptance**: after `openai4s serve`, GET `/` is 200; WS upgrade succeeds; a second instance is refused because of the pidfile.

---

<h2 id="s12">Step 12 · WebUI send path: composer → bubble</h2>

### Purpose

Open the **full loop a user can perceive**: input box → POST message → outer-loop multi-turn → WS events → assistant bubble / Activity / Artifact. This is the main path for accepting the product, and a preview of the tutorial’s most important sequence diagram.

### Implementation shape (against source)

1. **composer**: `send(text, opts)` in `server/webui/app.js`
2. If there is no session: `POST /frames` creates one, and `sub(frameId)` subscribes to WS
3. Optimistically render the user bubble; if `S.running` then mark queued (server FIFO accepts)
4. **`sub(currentId)` first, then POST**—otherwise the first `text_reset` / `text_chunk` is dropped for lack of a subscriber
5. `POST /frames/{id}/message` (singular; `wait:false`) → Gateway returns **202** + `{job_id, execution_id, request_id}`; the turn runs on a background thread and does not block HTTP
6. `SessionRunner.submit_message` → FIFO ticket + `MessageJob` → `run_message`
7. `SessionRunner._loop` assembles `AgentEngine` + `ChatModel` + `WebActionExecutor` + `CompactionPolicy` + `WebEventSink`
8. During engine turns: `TextDelta` / tool / cell events → `emit` → `WSHub.broadcast` (each stamped with a monotonic `seq`)
9. The frontend stitches chunks into the assistant bubble; tools become Activity cards; file writes become Artifacts
10. Completion: `server/completions.py` **projects** structured output / bullets / artifact delta as the final assistant message, then sends a terminal `frame_update`; `S.running=false`, composer unlocks

**WS resume contract (must be written into the product)**: `connectWS()` connects to `/api/v1/ws`; `view_session` carries `since_seq` (the largest seq this tab has applied) and `epoch` (daemon process identity). After a daemon restart epoch changes → `replay_begin.gap=true` → the UI must REST re-pull history; it must not pretend “you have caught up.” The buffer only covers the **currently in-progress turn**, not a full event log.

### Stack

- fetch REST + WebSocket client
- Server-side threading + Event cancellation
- Hand-written WS: `ws_frames.py`; resume via `seq` + `epoch`, not Socket.IO

### Definition trade-offs

| Do | Do not |
|---|---|
| `wait:false` + 202 immediate return | Synchronously block until the whole Agent round ends (will freeze the browser) |
| Input again while sending → queue | Silently drop (old bug) |
| Client generates the annotation admission id | Rely only on a server id (unrecoverable after disconnect) |
| Plan mode rewrites the payload, forbids tools | Plan mode still allows writing files at will |
| `/skillname` becomes a hard instruction | Hope the model remembers to load_skill itself |
| `since_seq` + `epoch` resume | Treat the UI transcript as the only truth |

### Strengths and costs

- **Strength**: the “chat” the user understands is decoupled from the real ledger; refresh can re-pull messages; 202 makes Stop / queue UX possible.
- **Cost**: subscribe timing, admission, queue hints, epoch gaps are all detail minefields.

### How it couples to previous and later modules

- Full module-level replay is at the 🎬 at the end of this document; permission-card and Artifact events insert on the same WS stream.

```mermaid
sequenceDiagram
    autonumber
    participant U as user
    participant UI as app.js composer
    participant REST as gateway POST
    participant SR as SessionRunner
    participant ENG as AgentEngine
    participant K as Kernel/Tools
    participant WS as WSHub
    participant B as assistant bubble

    U->>UI: type a prompt and send
    UI->>UI: optimistic user bubble; openTurnTicket
    UI->>WS: view_session(fid, since_seq, epoch)
    UI->>REST: POST /frames/{id}/message wait:false
    REST-->>UI: 202 execution_id
    REST->>SR: submit_message → FIFO → run_message
    SR->>WS: text_reset
    SR->>ENG: _loop → Engine.run
    loop each turn
      ENG->>ENG: model.complete (SSE deltas)
      ENG-->>WS: text_chunk / notebook_cell / activity seq++
      WS-->>B: stream render
      ENG->>K: Tool batch or Cell
      K-->>ENG: observation
    end
    ENG-->>SR: submitted / finalize / max_turns
    SR-->>WS: completions projection + frame_update terminal
    WS-->>B: completed state
    B-->>U: readable reply + Artifact entry
```



### 12.4 Four timing things the frontend must get right

1. **Generate and remember the admission id before POST** (comment/pin scenes); a server-issued id does not help after disconnect.
2. **`sub(frameId)` before POST**; the first-packet delta must not be dropped.
3. **Use the `S.running` snapshot at dispatch time to decide whether to openTurnTicket**—there is an await in the middle; an old snapshot expires.
4. **Do not let the header model overwrite a session-pinned revision** (the source comments record this pit).
5. **Reconnect with `since_seq` + `epoch`**; an epoch change must REST re-pull; do not blindly trust the buffer.

### 12.5 Server `run_message` responsibility list

- Validate frame/project scope
- Write the user message (including annotation consumption)
- If busy, enqueue; if idle, start a thread (`wait:false` path)
- Inside the thread: take a FIFO ticket → `_loop` → `completions` project the completion message → release the ticket → pull the next queued item
- Any exception: write a terminal failure (do not double-write for the same job)

> ✅ **This step’s acceptance**: run one sentence by hand in the browser; Network shows `POST .../message` returning 202; WS has `text_reset`/`text_chunk`; after refresh history is still there; sending again while running queues rather than disappearing; after killing the daemon and reconnecting, an epoch change triggers a history re-pull.

---

<h2 id="s13">Step 13 · Permission and approval cards</h2>

### Purpose

A scientific Agent will write files, go to the network, run a shell. The product must turn risky actions into **clickable approval cards**, and: **no browser subscriber ≠ auto-allow**; headless defaults to deny.

### Implementation shape

- `permissions.py` + permission rules/requests in the Store
- HostDispatcher `ask`s before a risky tool
- WebUI renders the approval card; the resolution is written back to SQLite
- Semantics:
  - Process still alive: wake **the same** blocked call
  - Daemon already restarted: record `permission_resolution` (the old operation was not executed), require Continue/replan
  - `once`: exact match of conversation/tool/target within 15 minutes, atomic consume
- `OPENAI4S_UNATTENDED_APPROVAL=deny|allow`

### Stack

- Persistent request rows + condition-variable/event wake
- Frontend WS event `permission_request`

### Definition trade-offs

| Do | Do not |
|---|---|
| Approvals survive across restart | Replay a redacted payload as execution args |
| Session/project/global standing rules | A complex IAM role system on MVP |
| Notebook REPL off by default | Default-open an arbitrary kernel execution entry |

### Strengths and costs

- **Strength**: a security narrative a lab can accept; audit is complete.
- **Cost**: after restart, “clicked allow but it did not execute” needs UI copy to educate, or users think it is a bug.

### How it couples to previous and later modules

- Tool classes declare a permission target; the bash capability is bound to generation at another layer.

```mermaid
sequenceDiagram
    participant T as Tool/host call
    participant D as Dispatcher
    participant DB as SQLite
    participant UI as approval card
    T->>D: risky action
    D->>DB: write permission_request
    D-->>UI: WS push card
    UI->>DB: allow once/session/...
    alt daemon not restarted
      DB-->>D: wake the original call
      D-->>T: continue execute
    else daemon already restarted
      DB-->>UI: requires_continue
      UI->>T: user explicitly Continue/replan
    end
```

> ✅ **This step’s acceptance**: under headless `deny`, a risky tool fails; with a UI the card is clickable; after restart, once does not re-ignite old args.

---

<h2 id="s14">Step 14 · Artifact versioning and provenance</h2>

### Purpose

What a scientist wants is not “the model said it drew a figure,” but a **versioned object on disk**: previewable, roll-backable, able to say “produced by which Cell / which environment / which inputs.”

### Implementation shape

- `server/artifacts.py`: `ArtifactManager` (capture, versions, CAS)
- End of a Cell transaction: savefig, workspace diff → a new version
- `host.save_artifact` / native `SaveArtifactTool`
- provenance: bind to the kernel **generation**’s runtime/interpreter/env_name/generation_id
- In-kernel `kernel/provenance.py`: object-level lineage tags (can `OPENAI4S_PROVENANCE_OFF=1`)
- Retrieval results carry a provenance envelope (DB, request, time, SHA-256)

### Stack

- Content-addressed files + SQLite metadata
- matplotlib figures: gateway is responsible for savefig (**do not** autoclose in the worker)

### Definition trade-offs

| Do | Do not |
|---|---|
| Version is an Artifact’s attribute timeline | Overwrite the same path every time with no history |
| Environment fingerprint comes from the interpreter that actually ran | Wrongly stamp the daemon’s package freeze (worse than missing) |
| When importing a session, do not remap external retrieval provenance | Pretend someone else’s machine’s hashes are yours locally |

### Strengths and costs

- **Strength**: a reproducible narrative; the UI can do “produced by cell N.”
- **Cost**: large files and version explosion need quotas and a cleanup policy (later).

### How it couples to previous and later modules

- finalize/submit can cite artifact ids; an exported package carries versions and provenance.

```mermaid
flowchart TB
    CELL["Cell writes files / savefig"] --> CAP["ArtifactManager.capture"]
    CAP --> V["artifact_version"]
    GEN["kernel generation"] --> V
    LIN["lineage_edges"] --> V
    V --> UI["Artifact panel"]
    V --> EXP["session package"]
```

> ✅ **This step’s acceptance**: an R Cell artifact’s provenance must not display as a Python freeze; two edits of the same path produce two version_ids.

---

<h2 id="s15">Step 15 · Context compaction</h2>

### Purpose

A long scientific session will blow the context. Compaction must **keep reproducible clues**, and must not split a native-tool atomic group or a code/observation pair.

### Implementation shape

- `agent/compaction.py`: `estimate_tokens` / `should_compact` / `compact` / `CompactionPolicy`
- Large outputs land as a content-addressed archive; the message keeps a preview + SHA-256
- Structured handoff fields: Objective / Constraints / Decisions / Done / In Progress / Blocked / Next Move / Key Artifacts / Active Kernel Generation
- System prompt and standing context (skills/memory) are rebuilt every turn, **not** mistakenly wounded by compaction as “the conversation is too long”

### Stack

- Pure-function module + optional LLM summary (`SUMMARY_FORK`)
- No hard Store dependency (metadata is injected by the caller)

### Definition trade-offs

| Do | Do not |
|---|---|
| Groups are unsplittable | Simply truncate messages from the front |
| Per-item budgets (text/images/tools/wire) | A single coarse character count |
| Archive JSON-compatible only | Pseudo-serialize in-kernel memory objects into the summary |

### Strengths and costs

- **Strength**: long sessions can still continue; cost is controllable.
- **Cost**: summary quality depends on the model; you must test “after compaction it still knows the current generation.”

### How it couples to previous and later modules

- `AgentEngine` every turn `context_policy.prepare`; Web passes in branch/ledger metadata.

```mermaid
flowchart LR
    M["messages"] --> E["ContextEstimate"]
    E -->|over threshold| C["compact"]
    C --> S["structured handoff"]
    C --> A["disk archive sha256"]
    S --> M2["short history prepared for the model"]
```

> ✅ **This step’s acceptance**: construct an oversized tool result; after compaction a preview+hash appears, and the tool group is not split into an illegal state.

---

<h2 id="s16">Step 16 · Delegation</h2>

### Purpose

The main Agent fans subtasks out to child Agents in parallel (literature one group, cleaning another), but there must be **budgets and a depth leaf**, or it fractally explodes the machine.

### Implementation shape

- `agent/delegation.py`: tree, budget, cancel, steer
- Caps: `FANOUT_CAP=48`, `SESSION_CAP=1000`, `MAX_DEPTH=4` (layer 4 cannot delegate further)
- `host.delegate` / `collect` / `stop_child` / `send_message`
- Cancel is precise to a subtree; a stopped child cannot publish late output
- Gateway hangs a shared `DelegationBudget` on `SessionState`

### Stack

- `ThreadPoolExecutor` + `contextvars` to pass the tree
- Child Agents reuse the same `AgentEngine` loop

### Definition trade-offs

| Do | Do not |
|---|---|
| Session-level cumulative spawn budget | Refund spawn when a child finishes (prevents farming) |
| Depth leaf | Infinite recursive delegation |
| In-memory steer queue consumed at turn boundary | Stuff a prompt only at start |

### Strengths and costs

- **Strength**: real parallel scientific split.
- **Cost**: resource and permission propagation is complex; a child Agent’s workspace/policy must be explicit.

### How it couples to previous and later modules

- Host `DelegationService`; UI composer has a delegation option; Ledger records child runs.

```mermaid
flowchart TB
    R["Root Agent"] -->|delegate| C1["Child ≤ depth 4"]
    R --> C2["Child"]
    C1 -->|depth&lt;4| G["Grandchild"]
    C1 -->|depth=4| L["Leaf cannot delegate further"]
    R -->|collect| OUT["aggregated results"]
```

> ✅ **This step’s acceptance**: delegate at depth 4 fails; after `stop_child` there is no late `submit_output`; exceeding session spawn errors.

---

<h2 id="s17">Step 17 · FIFO execution coordination and session kernel ownership</h2>

### Purpose

In the same session, Agent turns, user REPL, environment switches, and restore writers must not race for the kernel. You need a FIFO of **exact owner + lease**, not “fire SIGINT at the session PID at random.”

### Implementation shape

- `execution/`: coordinator and watchdog (CLI also uses it, so it does not live in `server/`)
- `server/execution_coordinator.py`: Web adapter
- `kernel/supervisor.py`: Python/R slot lifecycle; **never reads protocol frames**
- Interrupt must carry `execution_id` + `owner.kind` + `owner.id` + frozen lease
- Environment switch is build-first: only cut the pointer when the new worker is ready

### Stack

- Queue tickets + condition variables
- generation UUID against ABA

### Definition trade-offs

| Do | Do not |
|---|---|
| Cancel a queued writer without killing the current writer | Session-level blind interrupt |
| Supervisor and Kernel single-reader are separate | Multiple threads read the same stdout |
| Notebook REPL defaults to a read-only trace | Default-open a multi-line REPL |

### Strengths and costs

- **Strength**: restore and concurrency are testable; avoid “killing the wrong generation of process.”
- **Cost**: many concepts (ticket/lease/generation); docs must be clear.

### How it couples to previous and later modules

- `run_message`, cell_run, recovery all come to take a ticket; Artifact capture hangs inside the ticket lifecycle.

```mermaid
flowchart LR
    Q["Agent / REPL / lifecycle / recovery"] --> FIFO["ExecutionCoordinator"]
    FIFO --> OWN["exact owner + lease"]
    OWN --> K["Kernel.execute"]
    STOP["Stop"] --> OWN
```

> ✅ **This step’s acceptance**: while a turn is running, a second message queues; Stop only interrupts the current ticket; a simulated old watchdog cannot kill the new generation.

---

<h2 id="s18">Step 18 · Science connectors and evidence packs</h2>

### Purpose

Turn UniProt/RCSB/ChEMBL/PubChem/arXiv/OpenAlex etc. into **two tools + host.science.\***, and force provenance so results become evidence rather than “model memory.”

### Implementation shape

- `tools/science.py` + host science service
- Fixed HTTPS endpoints walk the network switch, SSRF/redirect guards, egress, approval, untrusted-output screening
- `evidence.py` / session package export
- workflows/ + `openai4s/benchmark/`: scientific-workflow benchmarks on a real Store/kernel (LLM/network injectable)

### Stack

- stdlib HTTP client wrapper (via Host)
- JSON canonicalization version numbers

### Definition trade-offs

| Do | Do not |
|---|---|
| One connector per library, normalized | Expose 10 native tools per library |
| Hash response bytes | Store only “the sequence the model summarized” as truth |
| Benchmarks assert failure/denied | Only assert “didn’t throw, so it passed” |

### Strengths and costs

- **Strength**: the tool table is stable; evidence is re-checkable.
- **Cost**: upstream API changes require maintaining normalization; rate limits need a degrade path.

### How it couples to previous and later modules

- The main path of the first user story; Artifact `source=provenance`; shares the security layer.

```mermaid
flowchart LR
    Q["science_search"] --> C["connector normalize"]
    C --> P["provenance envelope"]
    P --> A["save_artifact(source=P)"]
    A --> E["evidence / export"]
```

> ✅ **This step’s acceptance**: after a mock upstream return, the artifact version contains sha256 and timestamp; changing the bytes changes the hash.

---

<h2 id="s19">Step 19 · Security layering (defense in depth)</h2>

### Purpose

A single sandbox cannot save a scientific Agent. You must stack **mutually independent** layers: OS sandbox, env allowlist, pre-exec classifier, dlopen hook, biosecurity, injection annotation, egress, session keys, CSP…

### Implementation shape (layer table)

| Layer | Default | Role |
|---|---|---|
| OS kernel sandbox | `auto` | Seatbelt/bwrap; if enforce fails, refuse to start |
| Child env allowlist | on | Do not inherit daemon keys to Python/R |
| Pre-exec classifier | `heuristic` | Screen Cells the agent wrote |
| dlopen audit | on | Refuse `.so` on agent-writable paths |
| Biosecurity | on | ALLOW/ESCALATE/BLOCK |
| Injection scan | on | Web/PDF/MCP as data, not instructions |
| Egress allowlist | `off` (can turn on) | Application-layer outbound policy |
| Secret store | `auto` | keychain/env; fail closed |
| Data-dir perms | on | `0700`/`0600` |
| Browser headers | on | CSP etc. |
| Permission broker | on | One with step 13 |
| Bash capability token | on | generation-bound one-shot |

### Stack

- Platform branches (darwin/linux); self-test probes
- Static/heuristic/optional LLM classification

### Definition trade-offs

| Do | Do not |
|---|---|
| `auto` visible degrade | Silently “think the sandbox is on” |
| bind loopback | Default `0.0.0.0` exposing the LAN |
| Windows reports unsupported | Pretend ACLs are equivalent |

### Strengths and costs

- **Strength**: one layer failing still leaves others; the compliance narrative is complete.
- **Cost**: many knobs; CI must run the Linux branch.

### How it couples to previous and later modules

- Runs through Kernel spawn, Host network tools, and post-packaging smoke.

```mermaid
flowchart TB
    CELL["Agent Cell"] --> CL["pre-exec classifier"]
    CL --> SB["OS sandbox"]
    SB --> ENV["allowlisted env"]
    ENV --> RUN["worker"]
    RUN --> RPC["host RPC"]
    RPC --> EG["egress + permission"]
    RPC --> INJ["injection annotate"]
```



### 19.4 The correct way to expose locally

When a colleague needs access:

- ✅ SSH tunnel to `127.0.0.1:8760`
- ✅ An explicit local auth / token latch (if you added one)
- ❌ Bare `0.0.0.0` on an untrusted network
- ❌ Treat the daemon as a multi-tenant gateway

`docs/security.md` opens with: read first, then expose. Copy that sentence into your README.

### 19.5 Biosecurity and injection are product features, not compliance stickers

Bioinformatics users will hit dual-use content. Trace screening is on by default; web-tool results must be annotated “data is not an instruction.” Turning them off requires an explicit env, and doctor must show the posture.

> ✅ **This step’s acceptance**: when sandbox self-test fails, `auto` reports degraded; `enforce` does not start a worker; `web_fetch` by default refuses link-local.

---

<h2 id="s20">Step 20 · BYOC remote compute (optional later)</h2>

### Purpose

When a local laptop cannot run AlphaFold / large-model inference, send a **job** to the user’s own GPU (SSH or NIM), while keeping the key and sandbox boundary.

### Implementation shape

- `openai4s/compute/`: host-side manager/registry
- `openai4s_compute_provider/`: remote-side **stdlib** worker SDK
- Skills: `skills/remote-compute-<id>/` (`provider.json` + `provider.py`)
- `host.compute.*` / `host.fold` (strictly no forgery)
- Two-stage secret erase: entry `scrub_secret_env` → after loading the provider, erase again by prefix
- The remote helper wraps another layer of confinement (home directory unreadable etc.); **network is not isolated** (it has to call APIs)

### Stack

- SSH / docker CLI transport (not a generic pluggable Transport layer)
- Modal/SLURM not first-class yet (you can hand-write sbatch over SSH)

### Definition trade-offs

| Do | Do not |
|---|---|
| Job lifecycle stage→run→harvest | Mix an inference endpoint and a job into one concept |
| Provider discovery from `remote-compute-*` | Treat an arbitrary skill as a compute provider |
| fold fails when there is no result | Fabricate a structure and cheat the user |

### Strengths and costs

- **Strength**: BYOC fits lab assets; the core stays thin.
- **Cost**: Prototype experience is sharp; docs must write the trust boundary clearly.

### How it couples to previous and later modules

- Built after a stable Host/approval/Artifact; packaging can skip bundling the GPU stack at first.

```mermaid
sequenceDiagram
    participant A as Agent
    participant H as Host compute
    participant R as Remote provider SDK
    participant G as GPU node
    A->>H: compute.create/submit_job
    H->>H: approval + scrub
    H->>R: stage + confined helper
    R->>G: run
    G-->>R: out.tar.gz
    R-->>H: harvest
    H-->>A: artifact paths
```

> ✅ **This step’s acceptance**: without credentials, fold fails and does not fabricate; the helper exits on confinement self-test failure and does not read credentials.

---

<h2 id="s21">Step 21 · Packaging: dmg / pip / WSL (later)</h2>

### Purpose

Let scientists who will not git clone still use it: Apple Silicon `dmg`, Linux tarball, Windows zip→WSL2, and `pip install openai4s`.

### Implementation shape

- `pyproject.toml` publishes a wheel; `skills*`/`envs*`/`workflows*` must enter package data
- `scripts/` release pipeline, DMG build, `verify_release_artifacts.py`
- macOS app: embedded Python + default scientific stack; ad-hoc signature, needs Gatekeeper guidance
- Windows: the launcher installs into WSL2 and runs the same Linux build; **native Windows refuses to start a kernel**
- `openai4s doctor` / `diagnostics` / `verify-package`

### Stack

- uv / pip; platform scripts; optional micromamba kernel environment

### Definition trade-offs

| Do | Do not |
|---|---|
| Lightweight control-plane venv + optional kernel environment | Stuff a full conda into pip deps |
| An explicit platform support matrix | A fuzzy promise of “should run native on Windows” |
| A release verification script | Rely only on a human opening the dmg |

### Strengths and costs

- **Strength**: distribution covers mainstream lab machines.
- **Cost**: three-platform release is expensive; notarization/signing is a continuing burden.

### How it couples to previous and later modules

- All runtime invariants first; missing `workflows` in the package would make the benchmark fake-green—CI must block it.

```mermaid
flowchart TB
    SRC["source + skills/envs/workflows"] --> WHEEL["pip wheel"]
    SRC --> DMG["macOS arm64 dmg"]
    SRC --> TGZ["linux x86_64 tar.gz"]
    SRC --> ZIP["windows zip → WSL2"]
    WHEEL --> V["verify_release_artifacts"]
    DMG --> V
    TGZ --> V
```

> ✅ **This step’s acceptance**: after `uv build`, the wheel contains skills and workflows; on a clean machine after `pip install`, `openai4s serve` can start.

---

---

## Hands-on lab: three minimal commands to verify your skeleton

Before wiring the WebUI, prove the dual plane with the CLI:

```bash
# 1) 工具/对话完成路径（不起科学内核也可）
uv run openai4s run "用一句话解释什么是胰岛素，并 finalize。" -v

# 2) Code-as-Action 完成路径
uv run openai4s run "Compute the mean of [4,8,15,16,23,42] and submit it." -v

# 3) 起守护进程（需已配模型或 UI 内配置）
./start.sh
# 浏览器打开 http://127.0.0.1:8760/
```

Offline tests (default suite mocks the LLM):

```bash
uv run pytest tests/test_agent.py -q
uv run pytest tests/test_kernel.py -q
```

When reading against the source repo, remember: **pytest green ≠ release green**. There are also response schema/contract, harness, directory README, secret scan, browser smoke.

<h2 id="replay">🎬 Full replay: after the user types a prompt, how it is packed, uploaded, received, and run until it returns to the chat window</h2>

Scene prompt (the user types in the workbench):

> **「帮我查人胰岛素 INS，从 UniProt 拉序列摘要，写一段 Python 计算序列长度并画一个最简单的长度标注图，保存产物，最后用中文总结。」**

Below, every touch is listed in real module order. Numbers match the sequence diagram’s `autonumber`.

### Module-touch list (read against this)

| # | Module / file | What it does |
|---|---|---|
| 1 | `server/webui/app.js` `send()` | Read composer, optimistic user bubble, plan/skill rewrite |
| 2 | `POST /frames` (if no session) | `SessionRunner` builds frame + workspace |
| 3 | WS `sub(frameId)` | Frontend guarantees subscribe before POST |
| 4 | `POST /frames/{id}/message` | `gateway.do_POST` routes |
| 5 | `SessionRunner.run_message` | Admit, queue, start turn thread |
| 6 | `execution` FIFO | Take execution ticket / lease |
| 7 | `prompts.py` + skills catalog | Assemble system prompt (progressive disclosure) |
| 8 | `store` messages/frames | Persist the user message |
| 9 | `SessionRunner._loop` | Assemble Engine ports |
| 10 | `agent/compaction.py` | `prepare` context |
| 11 | `llm/client.py` + transport | Streaming request to Ark/other |
| 12 | `agent/engine.py` | turn loop |
| 13 | `agent/actions.py` `route_action` | Choose Tool / Cell / Finalize |
| 14 | `tools/*` + `HostDispatcher` | e.g. `science_search` |
| 15 | `permissions` | If approval needed, push a card and block |
| 16 | `agent/ledger.py` | Write action group/events |
| 17 | `kernel/*` | Lazy-start Python, execute Cell |
| 18 | `sdk/host.py` | In-Cell `save_artifact` / `submit_output` |
| 19 | `server/artifacts.py` | Version capture + provenance |
| 20 | `WebEventSink` + `WSHub` | `text_chunk` / steps / artifacts |
| 21 | `app.js` render | Bubble, Activity, Artifact panel |
| 22 | `finalize` or `submit_output` | Terminal completion projection |
| 23 | Store assistant message | Still there after refresh |

```mermaid
sequenceDiagram
    autonumber
    participant U as user
    participant UI as app.js
    participant GW as gateway.py
    participant SR as SessionRunner
    participant FIFO as ExecutionCoordinator
    participant ENG as AgentEngine
    participant LLM as llm.chat urllib
    participant RA as route_action
    participant HD as HostDispatcher
    participant PERM as permissions+SQLite
    participant K as Kernel worker
    participant SDK as sdk/host
    participant ART as ArtifactManager
    participant LED as ActionLedger
    participant WS as WSHub
    participant STORE as Store SQLite

    U->>UI: type prompt, enter
    UI->>UI: optimistic user bubble; clear composer
    alt no currentId
      UI->>GW: POST /frames
      GW->>SR: create SessionState + workspace
      GW->>STORE: persist frame
      GW-->>UI: {id}
      UI->>WS: sub(id)
    end
    UI->>WS: sub(currentId) force subscribe before sending
    UI->>GW: POST /frames/{id}/message wait:false
    GW->>STORE: append user message
    GW->>SR: run_message(...)
    SR->>FIFO: request ticket (queue if busy)
    FIFO-->>SR: obtain owner+lease
    SR->>ENG: _loop assemble ports then Engine.run

    Note over ENG,LLM: Turn 1 · control-plane retrieval
    ENG->>ENG: CompactionPolicy.prepare
    ENG->>LLM: complete(messages, tools, on_delta)
    LLM-->>WS: text deltas (via WebEventSink)
    WS-->>UI: text_chunk → assistant bubble streams in
    LLM-->>ENG: ModelReply(tool_calls=[science_search...])
    ENG->>RA: route_action → NativeToolBatch
    ENG->>LED: open tool action group
    ENG->>HD: Tool.invoke → science_search
    alt needs approval
      HD->>PERM: permission_request
      PERM-->>UI: approval card
      U->>UI: Allow once
      UI->>PERM: resolve
      PERM-->>HD: continue
    end
    HD-->>ENG: normalized result + provenance
    ENG->>LED: append tool results (atomic group)
    ENG->>STORE: usage / frames tokens
    Note over ENG: observation written back to messages, turn+=1

    Note over ENG,K: Turn 2 · scientific-plane Cell
    ENG->>LLM: complete(...)
    LLM-->>UI: optional short explanation text
    LLM-->>ENG: ModelReply(content contains ```python)
    ENG->>RA: route_action → CodeCell(python)
    ENG->>LED: cell attempt group
    ENG->>K: if no worker then lazy spawn + sandbox
    K->>K: run analysis code, matplotlib plot
    K->>SDK: host.save_artifact(...)
    SDK->>HD: host_call save_artifact
    HD->>ART: register version
    ART-->>WS: artifact event
    WS-->>UI: Artifact panel appears
    K->>SDK: host.submit_output(output, bullets)
    SDK->>HD: completion
    HD-->>ENG: CompletionPort set
    ENG->>LED: terminal submitted
    ENG-->>SR: EngineResult(stop_reason=submitted)

    SR->>STORE: project final assistant message (Chinese summary+bullets+artifact delta)
    SR-->>WS: terminal / done-class events
    WS-->>UI: bubble freezes; Stop hides; composer unlocks
    UI-->>U: readable conclusion + clickable figure and versions

    Note over FIFO: release ticket; if a queued message exists, next run_message
```

### On this chain, where is the code you write?

| Stage | Whose code |
|---|---|
| Chat UI, subscribe timing, queue hints | **Yours** (WebUI) |
| REST/WS, session, projection | **Yours** (Gateway/SessionRunner) |
| Outer-loop routing, completion contract, ledger | **Yours** (AgentEngine family) |
| Multi-provider HTTP | **Yours** (llm urllib) |
| Permission, audit, science APIs, Artifact | **Yours** (Host/Store) |
| Persistent kernel protocol and sandbox | **Yours** (kernel/security) |
| How specifically to look up INS, draw, explain biology | **The model’s** + **Skills recipes** |
| How Ark/Claude weights reason | **The provider’s** |

**Reasoning can be rented; the scientific runtime must be yours.**

### If the model chooses “tools-only then finalize”

The path at Turn 2 becomes: `FinalizeAction` → `agent/finalize.py` validate → no kernel start → still through Ledger and message projection back to the bubble. That is the meaning of the dual plane: **not everything enters the kernel**.

### If the user sends another sentence while it is running

`send()` sees `S.running`: the bubble is marked queued → the same `POST messages` is accepted by FIFO → after the current ticket ends, the next turn opens automatically. The old “return directly and drop the message” is a product-level accident; you cannot go back.

---

<h2 id="a1">Appendix A · Acceptance checklist</h2>

| Step | Assertion |
|---|---|
| 0 | 90 seconds: dual plane, stdlib, not IDE, not SaaS, ¥9.9, platform constraints |
| 1 | New Tool/Host/SQL/UI landing table is usable; name one wrong landing |
| 2 | Placeholder Key counts as unconfigured; provider-specific keys can coexist |
| 3 | Under fake transport, ark/anthropic normalized tool_calls have the same shape |
| 4 | finalize / tools-over-code / single Cell / unclosed fence unit tests green |
| 5 | Tool and host.* of the same method share approval and audit |
| 6 | `submit_output` can end a run; analysis kernel has no `delegate` attribute |
| 7 | Two Cells share variables; pure finalize does not start a worker |
| 8 | After a half-open tool group crash, the reducer fills a canonical error |
| 9 | Cannot register bash/submit_output; only sole finalize is FinalizeAction |
| 10 | Unloaded: no full text; user skills do not shadow bundled |
| 11 | serve is a singleton; `/` and WS work |
| 12 | sub then POST; sending again while running queues; refresh history still there |
| 13 | Headless deny; after restart once does not replay args |
| 14 | R artifact provenance is not a Python freeze; same path two versions |
| 15 | Large result becomes preview+sha; tool group is not split badly |
| 16 | depth=4 forbids delegate; stop has no late output |
| 17 | Stop carries an exact ticket; an old lease cannot kill a new generation |
| 18 | science results carry a hash; a benchmark failure case fails because of success |
| 19 | sandbox auto visible degrade; fetch refuses private nets |
| 20 | fold does not fabricate; confinement failure does not read credentials |
| 21 | wheel contains skills/workflows; a clean env can serve |

---

<h2 id="a2">Appendix B · Easiest ways to crash</h2>

**① Treat the completion signal as “the last piece of prose.”**
Symptom: the UI thinks it ended; the Ledger has no terminal; or the model replies empty.
Fix: only `finalize_response` / `host.submit_output` / explicit cancel and max_turns.

**② Parallel Tool and Cell in the same turn.**
Symptom: permission and scientific state interleave; cannot audit.
Fix: `route_action` control plane absolutely first; one channel per step.

**③ autoclose matplotlib in the worker.**
Symptom: the figure “was computed” but the Artifact is empty.
Fix: gateway savefig/close after the Cell.

**④ Artifact environment fingerprint taken from the daemon.**
Symptom: an R analysis displays a Python package list—**wrong provenance is worse than none**.
Fix: bind to the interpreter freeze of the kernel generation.

**⑤ POST before WS subscribe.**
Symptom: the first turn’s streaming characters are all dropped; at the end a whole paragraph suddenly appears (or nothing).
Fix: `send()` forces `sub(currentId)` before POST.

**⑥ Sending while running does a direct `return`.**
Symptom: the user thinks it was sent; the composer is already empty.
Fix: FIFO queue + queued style.

**⑦ Two Host execution paths (Tool walks A, host.\* walks B).**
Symptom: one side asks for approval while the other quietly runs.
Fix: a unique `HostDispatcher` envelope.

**⑧ Register `finalize_response` into the Tool marketplace.**
Symptom: a plugin replaces the completion contract; Ledger terminal state is out of control.
Fix: Engine’s own FinalizeAction.

**⑨ The kernel inherits daemon environment variables.**
Symptom: API Keys appear in child-process prints; a supply-chain nightmare.
Fix: rebuild the environment from an allowlist.

**⑩ sandbox `auto` fails but is treated as isolated.**
Symptom: demo is safe, reality is naked.
Fix: UI/doctor explicitly degraded; to actually force it use `enforce`.

**⑪ Native Windows hard-runs the kernel.**
Symptom: fd redirect and sandbox explode across the board.
Fix: refuse to start + WSL2 wrapping the same Linux build.

**⑫ The wheel forgets to pack `workflows*` / `skills*`.**
Symptom: `openai4s benchmark` 0 workflows 0 failures exit 0—**fake green**.
Fix: package data + verify script.

**⑬ Approval payload replayed as execution.**
Symptom: after restart, “allow” once for an expired dangerous arg.
Fix: restart only records the resolution; require Continue/replan.

**⑭ Compaction splits a tool atomic group.**
Symptom: the next request, the provider refuses a half-open tool batch.
Fix: keep declaration+results together or archive them together.

**⑮ Mid-cell RPC multi-flight.**
Symptom: frame-interleaved deadlock.
Fix: worker `_HOST_CALL_LOCK` + manager single reader.

---


### B.13 Frame cap guessed as ASCII

Symptom: with lots of Chinese/emoji, the Notebook loses the traceback.
Root: character cap × a wrong bytes-per-character assumption.
Fix: derive from worst-case UTF-8/JSON escape (see `worker.py` comments); add a regression test.

### B.14 autoclose matplotlib in the worker

Symptom: “the figure clearly showed but the Artifact does not have it.”
Root: gateway has not savefig’d yet; the worker closed the figure first.
Fix: capture responsibility is on the gateway; the worker must not close on its own.

### B.15 Use the daemon’s pip freeze as an R artifact’s environment

Symptom: the provenance panel looks complete; paper reproduction does not match.
Root: wrong provenance is worse than none.
Fix: bind to the kernel generation that wrote the file; otherwise record missing explicitly.

<h2 id="a3">Appendix C · Source index</h2>

Baseline: `参考项目/OpenAI4S` @ `85e9fa0`.

| Tutorial step | Source anchor |
|---|---|
| Dual-loop overview | `docs/architecture.md` · `CLAUDE.md` The dual loop |
| Step 1 layout | `openai4s/` package tree · `docs/backend-extension-guide.md` |
| Step 2 config | `openai4s/config.py` |
| Step 3 LLM | `openai4s/llm/client.py` · `llm/transport.py` · `llm/providers/*` |
| Step 4 engine | `openai4s/agent/engine.py` · `ports.py` · `actions.py` `route_action` |
| Step 5 envelope | `openai4s/host_dispatch.py` · `openai4s/host/*` |
| Step 6 SDK | `openai4s/sdk/host.py` · `sdk/bash.py` · `sdk/compute.py` |
| Step 7 kernel | `openai4s/kernel/manager.py` · `worker.py` · `lazy.py` · `r_kernel.py` |
| Step 7 sandbox | `openai4s/security/sandbox.py` · `kernel/environment.py` |
| Step 8 ledger | `openai4s/agent/ledger.py` · `store.py` · `storage/*` |
| Step 9 Tools | `openai4s/tools/registry.py` `TOOL_TYPES` · `agent/finalize.py` |
| Step 10 Skills | `skills/*/SKILL.md` · `openai4s/skills_loader/loader.py` |
| Step 11 Gateway | `openai4s/server/gateway.py` · `ws_frames.py` · `server/webui/*` |
| Step 12 send path | `webui/app.js` `send` · `SessionRunner.run_message` · `_loop` |
| Step 13 permission | `openai4s/permissions.py` · `docs/security.md` approval section |
| Step 14 Artifact | `openai4s/server/artifacts.py` · `kernel/provenance.py` |
| Step 15 compaction | `openai4s/agent/compaction.py` |
| Step 16 delegation | `openai4s/agent/delegation.py` · `host/delegation.py` |
| Step 17 FIFO | `openai4s/execution/*` · `kernel/supervisor.py` |
| Step 18 science | `openai4s/tools/science.py` · `docs/science-connectors.md` · `workflows/` |
| Step 19 security | `docs/security.md` · `openai4s/security/*` · `egress.py` |
| Step 20 BYOC | `openai4s/compute/*` · `openai4s_compute_provider/` · `docs/compute.md` |
| Step 21 packaging | `pyproject.toml` · `scripts/*` · `docs/platforms.md` · `docs/startup-guide.md` |
| CLI composition | `openai4s/agent/loop.py` · `runtime.py` · `openai4s/cli/*` |
| Web composition | `openai4s/server/agent_run.py` · `gateway.py` SessionRunner |

---

## Close: who this route is for

**This is for you if**:

- your users are **scientists**, and the work objects are data, structures, figures, and evidence packs
- you are willing to maintain a **real kernel + a real ledger**, not just wrap a ChatGPT UI
- you need to start a daemon with stdlib in a **dirty local environment**, and run through on a ¥9.9-class API
- you agree: **the control plane is auditable, the scientific plane is computable**, and the two must not be kneaded into one fake tool

**This is not for you if**:

- you are building a repo-scale coding IDE Agent (see this series’ Claude Code / Cline / Codex routes)
- you are building multi-tenant cloud SaaS with automatic isolation (this architecture’s root of trust is local single-user)
- you only want to wire an SDK and ship in two weeks, and cannot accept SQLite-ledger and sandbox detail

**Last sentence**: what people remember about OpenAI4S is not “another Agent loop,” but that it turned the things that are actually expensive in science—**persistent computation, evidence, permission, environment fingerprints**—into product invariants; the model can be swapped, Ark can be ¥9.9, these invariants cannot.

**Two planes, core zero-dependency; what you rent is inference, what you keep is the scientific runtime.**

---

*This tutorial is written from `参考项目/OpenAI4S` commit `85e9fa0`. Voice and structure align with *[Building an “AI design agent” from scratch (Open Design lineage)](./build-open-design.md)*. Companion reading: [OpenAI4S source analysis](../analyses/openai4s.md).*
