AGENTS.md · K-Dense-AI/k-dense-byok
AGENTS.md from K-Dense-AI/k-dense-byok: 142 lines covering Project overview, Commands, Architecture: how a turn flows, Project / sandbox layout and more.
Template by K-Dense-AI
Chalk Labs
Need help deploying AGENTS.md · K-Dense-AI/k-dense-byok?
Chalk Labs sets up agents like this inside real teams: connected to your tools, tested on your data, with a human in the loop where it matters.
Install
Works with Codex, Cursor, GitHub Copilot, Gemini CLI, OpenCode, Claude Code. Read the repository before you give any tool access to your data.
curl -fsSL https://raw.githubusercontent.com/K-Dense-AI/k-dense-byok/main/AGENTS.md -o AGENTS.md
Read it and adapt it to your project before use.
Get the weekly diff
The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.
About
Use cases: deep-research, code-generation
Reviews
Write a reviewNo reviews yet. Installed it? Reviews come from verified installers only. How reviews work
What's in it
An AGENTS.md file: instructions coding agents (Codex, Cursor, Copilot, Gemini CLI and others) read before working in a repo.
- For
- Codex, Cursor, GitHub Copilot, Gemini CLI, OpenCode, Claude Code
- Length
- 142 lines
- File
AGENTS.md
Sections
- Project overview
- Commands
- Architecture: how a turn flows
- Project / sandbox layout
- Configuration
- Releases
- Testing notes
- Caveats worth knowing
- Office preview and editing
First lines (shown under the repo's MIT license)
# AGENTS.md
Guidance for coding agents (Claude Code loads it via `CLAUDE.md`) working in this repository.
## Project overview
K-Dense BYOK is a local AI research-assistant app ("Kady") that brings the user's own model credentials (API keys or supported subscriptions). It runs natively on macOS, Linux, and Windows. It is one repo with **two** runtime services started together by the cross-platform launcher `start.mjs` (wrapped by `./start.sh` on macOS/Linux and `start.cmd` on Windows):
| Service | Port | Code |
|---|---|---|
| Frontend (Next.js 16 / React 19) | 3000 | `web/` |
| Backend (TypeScript + Pi coding-agent SDK) | 8000 | `server/` |
The backend embeds the **Pi SDK** (`@earendil-works/pi-coding-agent`) and runs a **single flat agent** with built-in tools (`read`/`bash`/`edit`/`write`/`grep`/`find`/`ls`), a `subagent`/`bg_wait` delegation pair (the [pi-subagents](https://github.com/nicobailon/pi-subagents) extension package), an `interview` clarifying-questions tool (a native re-implementation of [pi-interview](https://pi.dev/packages/pi-interview) — see `server/src/agent/interview.ts`; the form renders inline in the chat UI instead of the package's own browser window), the [pi-web-access](https://pi.dev/packages/pi-web-access) web tools (`web_search`/`fetch_content`/`get_search_content`/`source_check`), live PDF annotation tools (`add_pdf_annotation`/`list_pdf_annotations`/`remove_pdf_annotation`), a hybrid durable Modal tool family (`modal_run`/`modal_submit`/`modal_status`/`modal_wait`/`modal_cancel`/`modal_results`/`modal_submit_batch`; `server/src/agent/modal-tool.ts`), a `generate_image` tool (Pi 1.0 image models, saved into the sandbox; `server/src/agent/image-tool.ts`), and MCP tools through **Pi's built-in MCP support** (project `.pi/mcp.json` + global `<agentDir>/mcp.json`; see the MCP caveat below). PDF annotations and Modal jobs are also available to child agents through the vendored `kady-pdf-annotations` and `kady-modal` Pi packages; annotation sidecars use a shared cross-process lock and atomic replacement. Modal jobs are server-owned and restart-recoverable. There is no orchestrator/expert split and no model proxy: models go directly to **OpenRouter**, connected Pi OAuth providers (`openai`, `openai-codex`, `anthropic`, `github-copilot`, `xai`, `kimi-coding`, `meta`), **NVIDIA NIM** (API key), or a local **Ollama** / OpenAI-compatible server. Everything runs locally; user data lives in `projects/`.
## Commands
Backend (`cd server` first; Node ≥ 22.19 recommended):
```bash
npm install # install deps
npm run dev # tsx watch on port 8000
npm run start # run backend (tsx)
npm run prep # ensure default project + seed scientific skills
npm run typecheck # tsc --noEmit
npm test # vitest
```
Frontend (`cd web` first):
```bash
npm install
npm run dev # Next.js dev server (port 3000)
npm run build # production build
npm run test # vitest
```
Full app (both services):
```bash
./start.sh # installs deps, seeds skills, starts backend + frontend
start.cmd # same, on Windows (both wrap `node start.mjs`)
```
## Architecture: how a turn flows
1. **UI → backend.** A chat tab posts to the TS backend. Each tab carries its own `sessionId` (a Pi JSONL session); requests are scoped to a project via the `X-Project-Id` header (→ `?project` → `kady-project` cookie → `default`), resolved in an `onRequest` hook using `AsyncLocalStorage` (`server/src/scope.ts`).
2. **Sessions.** `server/src/agent/session-registry.ts` holds live Pi `AgentSession` objects (one per tab, ≤10 per project) and persists each as a JSONL file under `projects/<id>/sandbox/.pi/sessions/`. `ModelRuntime` + `ModelRegistry` are process singletons; the runtime uses Kady's shared Pi auth store.
3. **Models.** `server/src/agent/models.ts` resolves canonical `provider/model` refs (`openrouter/<vendor>/<model>`, `ollama/<name>`, `openai-compatible/<id>`, or `<pi-provider>/<model-id>` for every other built-in Pi provider) to a Pi `Model`, synthesizing OpenRouter models from `web/src/data/models.json` pricing when not built in. **Every Pi provider is supported** (https://pi.dev/docs/latest/providers): `server/src/agent/provider-catalog.ts` is the single table of direct API-key / cloud-credential providers (Anthropic, OpenAI, Google, Vertex, Azure, Bedrock, Cloudflare ×2, NVIDIA NIM, Groq, Cerebras, Mistral, DeepSeek, Hugging Face, Fireworks, Together, Baseten, Vercel AI Gateway, OpenCode ×2, Kimi, Moonshot ×2, MiniMax ×2, Z.AI ×2, Qwen ×3, Xiaomi ×4, Ant Ling, Meta) with the env var(s) Pi reads, a billing class, and Settings field metadata; `provider-auth.ts` lists the OAuth logins (OpenAI / Sign in with ChatGPT, OpenAI Codex (legacy), Anthropic, GitHub Copilot, xAI, Kimi For Coding, Meta Muse, OpenRouter, Radius). `openai`/`anthropic`/`xai`/`kimi-coding`/`meta` appear in both — one ref, and billing follows whichever credential Pi resolves; `openai-codex`/`github-copilot`/`radius` stay OAuth-only (`isOAuthOnlyProvider`). Direct refs are never split after the prefix (NIM, Hugging Face, Fireworks, Cloudflare `@cf/…` ids contain slashes) and resolve only through Pi's registry — an unknown id is an error, except NVIDIA NIM, whose ids are synthesized at $0 like Pi's own entries (`synthesizeUnknownIds`; a payg model synthesized at $0 would bypass the spend cap). Pi renames a provider id now and then (1.0.3: `azure-openai-responses` → `azure`); saved refs and session files keep the old id, so `canonicalProviderId` (`RENAMED_PROVIDERS` in `provider-catalog.ts`) maps it before resolution. `provider-catalog.test.ts` checks the catalogue against the live runtime: every Pi provider covered, names match, and setting only the declared env vars flips `checkAuth` (providers with no chat models, e.g. TypeSafe's classifier-only catalogue, are exempt) — so a Pi upgrade that renames a variable fails a test instead of leaving a dead Settings field.
4. **Streaming.** `POST /sessions/:id/run` calls `session.prompt()` and streams an SSE schema mapped from Pi's `AgentSessionEvent` (`server/src/agent/events.ts`): `text_delta`, `thinking_delta`, `tool_start/update/end`, `turn_start/end`, `error`, a terminal `cost` frame, and `done`. The run body may carry inline **image attachments** (`images: [{data, mimeType}]`, base64; validated by `server/src/agent/prompt-images.ts` — ≤12 × 5MB, png/jpeg/webp/gif) that ride the user message as Pi image blocks so the model sees them directly; the composer collects them via paste/drop/file-picker (`web/src/lib/image-attachments.ts` downscales >3MB client-side), other file types keep the sandbox-upload path, steering stays text-only (image sends mid-run queue instead), and `GET /sessions/:id/history` replays them for reopened tabs. The run route is a thin wrapper around `server/src/agent/run-pipeline.ts` (`claimRun` → `openRun` → `executeRun`: claim before the first await, `run_start` before model setup, ledger before the in-flight budget entry is dropped). A permanent per-session observer (`server/src/agent/session-observer.ts`, hooked in via `setSessionObserver`) reuses that pipeline to adopt turns Pi extensions start on an idle session as **system runs**: scheduled-run notices arrive as `pi.sendMessage` custom messages with `triggerTurn: true`, while since pi-subagents 0.77 completions and supervisor requests reaching an idle lead are appended without a turn and the lead is woken by a fixed user prompt (`PARENT_WAKE_TEXT`, `sendUserMessage(…, { deliverAs: "steer" })`). That prompt is plumbing addressed to the model, so `subagent-wake.ts` (parity-tested against the installed package) blanks its `message_start` frame — the client still takes it as the run's prompt echo — and history, notebook/repro exports and chat snapshots skip it. System runs are `RunMetadata.origin: "system"`, ended on `agent_settled`, aborted when over the cap; a custom message with no turn becomes a `kind: "notice"` run. Custom messages (`role: "custom"`) reach the client as `message_start` frames carrying `customType`, text and a per-type whitelist of scalar `details` (`CUSTOM_MESSAGE_DETAIL_KEYS` in `events.ts`), appear in history as `role: "system"`, and render as `SystemCard`s; idle tabs poll `run/state?frames=0` to attach to runs they did not start.
5. **Cost ledger + budgets.** Pi reports `usage.cost` inline (no async backfill). `server/src/cost/ledger.ts` snapshots `getSessionStats()` before/after each run and appends a row to `projects/<id>/sandbox/.kady/runs/<sessionId>/costs.jsonl` (role `agent`|`subagent`|`compute`). Pi adds tool-result `usage` to those stats, so `server/src/cost/tool-usage.ts` splits it out of the turn's row on `turn_end`: models a tool ran itself (codemode `models.generateImages()`/`models.classify()` call rows, `generate_image`'s `details.model`) get their own rows billed by **their** provider and credential — a ChatGPT-subscription turn that generates an OpenRouter image must not ledger that spend as $0 — and child usage pi-subagents aggregates onto `subagent` results is dropped because the meter/bridge already ledgered it. `server/src/cost/billing.ts` classifies OpenRouter/API-key and Anthropic OAuth usage as cap-counted, while ChatGPT/Copilot/xAI/Kimi Code/Meta subscription runs retain tokens plus `listPriceUsd` with `costUsd: 0`; provider quotas and overages remain external. Durable Modal jobs reserve a full-lifetime sandbox estimate (GPU plus CPU/RAM, with CPU/RAM limits matching the preset) before admission; project summaries expose spent/reserved/committed USD, and every terminal path reconciles the hold to estimated sandbox wall-time, or the full reservation when cleanup is unconfirmed. Builds, cache storage and egress are outside this estimate. A project `spendLimitUsd` blocks only cap-counted work when the new commitment would exceed the cap.
6. **Provenance.** The run loop attaches a `ProvenanceRecorder` (`server/src/provenance/recorder.ts`) to the same `session.subscribe` stream the SSE mapper reads, and appends one observed step per tool call to `sandbox/.kady/provenance/<sessionId>/steps.jsonl`. Rows are **derived from observation, never model declaration** — there is deliberately no agent-facing tool that writes here, because provenance is what you check the model against. `write`/`edit`/`read` name their file (edges `observed`); `bash`/`subagent`/unknown tools are opaque and get a bounded stat-only sandbox scan-diff (`server/src/provenance/scanner.ts`), whose edges downgrade to `inferred` when a neighbouring step finished before the scan ran. Scans are async-drained so the event handler never blocks SSE for other tabs; `flush()` in the run's `finally` drains before the terminal frames. Budgets (20k files, 512MB/file hash, 200 edges/step) degrade *visibly* — `sandbox-too-large`/`scan-failed`/`unhashed`/`truncatedEdges` — since silent truncation reads as a verified absence of outputs. `server/src/provenance/lookup.ts` inverts the log by path for `GET /sandbox/provenance`, computing staleness (current/stale/unknown) and flagging notebook citations written before an artifact's latest version. Subagent work is harvested from each child's session JSONL on completion (`server/src/provenance/harvest.ts` + `bridge.ts`, same `tool_result`/`subagent:async-complete` hooks as the notebook harvest and cost ledger) and appended to the parent's log with `role: "subagent"` and the child's agent name/model — no tool is installed in the child, so it stays unauthorable. Because that work is inspected *after* it finished, harvested refs are marked `identityAt: "harvest"` (a matching hash then proves only "unchanged since we looked", so `lookup.ts` reports `unknown`, never `current`), `change` is `wrote` rather than a created/modified guess, and a child's opaque `bash` gets `degraded: "no-scan-baseline"` plus optional mtime-window `inferred` edges filtered by already-claimed paths. Three further step sources join the same log: **user steps** (`server/src/provenance/user-steps.ts`, `role: "user"`, pseudo-session `user-actions`) are recorded by the sandbox routes for upload/save/move/delete so lineage chains have a root and an editor save is a recorded producing step rather than an unexplained "stale"; **compute steps** (`modal-steps.ts`, `role: "compute"`, id `modal:<jobId>`) are written at the Modal manager's single terminal transition from the transfer layer's own staging/collection hashes (observed, write-time — no harvest caveat); and every lead step carries an **`environmentId`** pointing at a content-addressed snapshot in `.kady/environments/` (`environment.ts`: venv `pyvenv.cfg` + `dist-info` names with no subprocess, one bounded `Rscript` probe when present, lockfile hashes, git HEAD), captured at run start and re-captured when an install-shaped command ran or the stat fingerprint of lockfiles + venv changed (`uv.lock` and `.venv` are invisible to the scan, so the diff alone misses `uv run` syncing a venv); harvested steps get a harvest-time snapshot marked `environmentAt: "harvest"`. Opaque calls also get `inferred` **input** edges from files their command line names that existed beforehand and were not written (`mentions.ts`), which is what lets `lookup.ts`'s `walkLineage` walk figure → script + table → upload; the walk is version-aware (producer at-or-before the consuming step) and reports `changedSinceUse` per upstream node. Tests that build a `ProvenanceRecorder` should pass `captureEnvironment: false` (or a stub) so they do not spawn probes. Not yet covered: nested (depth > 1) subagents, remote image package enumeration — see `docs/provenance.md`.
7. **Skills.** Two scopes: per-project `sandbox/.pi/skills/` and user-level `<agentDir>/skills/` (`~/.kady/pi-agent/skills`), both loaded by Pi — the latter also reaches subagent child processes. Pi resolves project entries first and skill-name collisions are first-wins, so **a project skill shadows a global one**; the Settings list labels that. Every origin is fetched through the bundled **`skills` CLI** (vercel-labs/skills) by `server/src/agent/skills-fetch.ts`, which downloads into a disposable staging cache (`KADY_SKILLS_CACHE_DIR`, default `~/.kady/skills-cache`) shaped as `.pi/skills/<name>/` because `-a pi --copy` targets exactly our layout. The CLI is a **fetcher only** and its stdout is never parsed (truth = the staged tree + `skills-lock.json`); `skills-sync.ts` remains the sole writer of live skill dirs, so tree hashing, the default-disabled policy, atomic per-skill replacement, local-edit preservation and archive-on-removal all survive unchanged. Catalogue seeding falls back to a shallow `git clone` if the CLI fails, since it is on the first-run path. The catalogue (`KADY_SKILLS_REPO`, default `K-Dense-AI/scientific-agent-skills`, merged with the named skills of `CATALOGUE_EXTRA_SOURCES` in `config.ts` — currently `docx`/`pdf`/`pptx`/`xlsx` from `anthropics/skills`; a later source wins a name clash, the fetch is all-or-nothing so a failed source cannot read as upstream removals, and such skills record `source` on their catalogue manifest entry) syncs at launch and daily (`KADY_SKILLS_SYNC_INTERVAL_MS`, min 60s) and is **project-scoped only**. Manifest v2 (`.kady/skills-sync.json`, migrated from v1 rather than discarded) adds a per-skill `origin` (`catalogue`/`registry`/`local`) plus recorded source/ref, a content `catalogueDigest` replacing the commit id (staging is not a checkout), and `removed` tombstones so deleting a catalogue skill isn't undone by the next sync. `skills-install.ts` owns preview→install (the confirmed preview returns a `stagingToken` so the user installs the bytes they reviewed; the acknowledgement is enforced server-side), authoring from a template with Pi's stricter name rule, in-place `SKILL.md` editing, origin-aware removal, and on-demand update checks — user-installed skills are **never** auto-updated. Routes live in `server/src/api/skills.ts` (`?scope=project|global`); `GET /skills` merges both scopes for the composer picker so it agrees with what the agent loads. UI: `web/src/components/skills-panel.tsx`. See `docs/skill-management.md`. Live sessions keep their already-loaded skill set.
**Prompt templates + slash commands.** `/skill:<name> args` and `/<template> args` on the first line of a chat message are expanded by Kady, not Pi (`server/src/agent/prompt-expansion.ts`, ports of Pi's `parseCommandArgs`/`substituteArgs` plus "append unused args"), read fresh from disk (project entries win), and `session.prompt()` is called with `expandPromptTemplates: false`; `/steer` and `/follow-up` expand too. The skill block is byte-identical to Pi's so `parseSkillBlock` works; templates expand to `<prompt-template name=…>`, and the web `command-blocks.ts` renders both as chips. Templates live in `sandbox/.pi/prompts/*.md` and `<KADY_PI_AGENT_DIR>/prompts/*.md` (`agent/prompts.ts`, routes `api/prompts.ts`, Settings → Prompt templates; six scientific templates seeded per project, including `/prove-verify`; exact unchanged older shipped versions upgrade, while edits and deletions are preserved; a template added later is offered once to existing projects, tracked by name in `.kady/prompts-seeded-names.json` with `LEGACY_SEEDED_TEMPLATES` standing in for markers written before names were tracked). `disable-model-invocation` is exposed on `GET /skills` and toggled line-wise via `POST /skills/:name/model-invocation` (`setSkillModelInvocation`); the composer's pinned-skill picker hides such skills and the `/` menu offers them.
8. **Sandbox API + scientific previews.** `server/src/api/sandbox.ts` ports all file ops (tree/read/write/move/upload/zip/raw/download), annotation sidecars, LaTeX compile (async latexmk/multi-pass with SyncTeX), `/sandbox/synctex` (source<->PDF mapping), and the budget-gated `/sandbox/latex-assist` one-shot AI endpoint (ledgered under session id `latex-assist`). LaTeX assist returns a discriminated `status: replacement|needs_context`; only replacements enter diff review, and incomplete/malformed model responses are rejected after recording incurred usage. It also serves rich previews for many scientific formats. The frontend has an extensible viewer registry: `web/src/lib/viewers/registry.ts` maps a `FileCategory` (`web/src/lib/use-sandbox.ts`) to a lazy-loaded viewer in `web/src/components/viewers/*`; `FileViewer` in `file-preview-panel.tsx` checks the registry first and falls back to its built-in chain for the original categories (image/pdf/markdown/csv/notebook/fasta/biotable/latex/text). Binary/parse-heavy formats decode in **standalone Python helpers** under `server/src/helpers/` (`anndata_helper.py`, `chem_helper.py`, `structure_helper.py`, `massspec_helper.py`, `arrays_helper.py`, `imaging_helper.py`). They run in a dedicated **uv-managed helper venv** (`server/src/helpers/pyproject.toml`, `.venv`), resolved via `helperPython()` and pre-warmed by `syncHelperVenv()` (`server/src/helpers-env.ts`) at `prep`/boot. A generic dispatcher (`server/src/api/sci-helpers.ts`, `sciHelperFor`/`runSciHelper`) routes a `kind` param to a helper's `summarize`/`render` subcommands over two endpoints: `GET /sandbox/sci-summary?path=&kind=` (JSON) and `GET /sandbox/sci-render.png?path=&kind=&index=&axis=` (image); all helpers share the exit-code contract `0` ok / `3` deps-missing / `4` not-found / `5` bad-value / `1` other, mapped to HTTP by the routes. 3D structures also use a client-side WebGL viewer (3Dmol.js) and spectra use chart.js. New scientific viewers are view-only; user-facing coverage is in `docs/file-previews.md`.
## Project / sandbox layout
```
projects/
├── index.json # registry
└── <projectId>/
├── project.json # metadata (ProjectMeta)
└── sandbox/ # Pi agent cwd; files visible to all tabs
├── user_data/ # uploads
├── .pi/skills/ # per-project skills (Pi-discovered)
├── .pi/skills-archived/ # unchanged skills retired upstream
├── .pi/sessions/ # Pi JSONL session files (one per tab)
├── .kady/skills-sync.json # upstream hashes + pending local conflicts
├── .kady/runs/<sessionId>/costs.jsonl # cost ledger
├── .kady/provenance/<sessionId>/steps.jsonl # observed step provenance
└── .kady/modal/jobs/<jobId>/ # durable Modal state, events, and logs
```
## Configuration
- API keys come from `process.env`, auto-loaded by `server/src/env.ts` from (in order) repo-root `.env`, the legacy `kady_agent/.env` if present, and `server/.env`. `OPENROUTER_API_KEY` is optional when model access comes entirely from OAuth, a direct provider key (any env var in `provider-catalog.ts`, e.g. `ANTHROPIC_API_KEY`, `GROQ_API_KEY`, `AZURE_OPENAI_API_KEY` + endpoint, AWS credentials for Bedrock), or Ollama; Fusion and server-side speech remain OpenRouter-only. `server/src/api/credentials.ts` generates one managed `.env` entry per distinct catalogue env var (several providers share one: `MOONSHOT_API_KEY`, `OPENCODE_API_KEY`, `CLOUDFLARE_*`; `GEMINI_API_KEY` is both the web-search key and Pi's `google` provider key), so Settings → Providers (`GET /providers`, fields from the catalogue; one row per provider merging its sign-in and API-key methods, rows built by the pure `web/src/lib/provider-rows.ts`) can set every one live; non-secret configuration values (regions, account ids) skip the 8-char minimum and are echoed unmasked. Other knobs include `OLLAMA_BASE_URL`, `OPENAI_COMPATIBLE_BASE_URL`, `DEFAULT_MODEL_PROVIDER`, `DEFAULT_MODEL_ID`, `KADY_PORT`, `KADY_PROJECTS_ROOT`, and `NVIDIA_EXTRA_MODELS` (comma/whitespace-separated NIM model ids absent from Pi's catalogue — e.g. private/EA endpoints — pinned into the picker's NVIDIA NIM section; ids verbatim, no `nvidia/` ref prefix). `start.mjs` keeps its own copy of the key variable names for the "no model access" launch warning.
- **App defaults.** Settings → Defaults (`web/src/components/settings/defaults-panel.tsx`) stores the model, thinking level and compute a project's *first* chat tab starts on, the `verifierModel` reviewer specialists run on (see the verification caveat), plus `generate_image`'s `imageModel` (read on every call, so it applies to open chats; only per-token output-priced models are accepted or listed by `GET /settings/image-models`; an unavailable saved model falls back to `DEFAULT_IMAGE_MODELS` with a note in the tool result), in `<KADY_PI_AGENT_DIR>/kady-settings.json` (`server/src/app-settings.ts`, `GET/PUT /settings/defaults` in `api/app-settings.ts`; malformed file → 409, never rewritten; unknown top-level keys survive). A tab opened next to an existing one still copies that tab's choices; only the model is consumed server-side. Precedence in `configuredDefaultRef()` / `resolveModel(undefined)` (hence system runs with no project model to reuse): saved default → env `DEFAULT_MODEL_PROVIDER`/`DEFAULT_MODEL_ID` → built-in; a saved default that no longer resolves falls back silently so sessions stay openable. `fusion/…` refs are refused (they only resolve with the browser-sent panel config).
- **Fusion pricing.** `buildFusionModel` prices a turn as each panel model once **plus the judge twice** — OpenRouter bills "N panel calls + 1 judge call in addition to your normal request", and under the `openrouter/fusion` alias that fusion-bridge.ts always sends, the judge also writes the final answer. The multiplier (`JUDGE_CALLS_PER_TURN`) and the judge accessor exist twice, in `server/src/agent/models.ts` and `web/src/lib/fusion-presets.ts`, because the picker quote and the ledgered price must agree; `server/test/fusion-pricing.test.ts` has a parity check over the shipped presets. Partly-priceable configs still run (under-counting, flagged in the picker); only a wholly unpriceable one throws.
- **Local model servers.** Two parallel discovery paths, deliberately not factored together: Ollama (`/ollama/models` → `OLLAMA_BASE_URL/api/tags`) and any OpenAI-compatible server (`/openai-compatible/models` → `OPENAI_COMPATIBLE_BASE_URL/v1/models`, default LM Studio's `:1234`). The latter's picker section stays hidden unless the env var was set (`configured`) or a server answers. Both URLs are live: `config.ts` exposes getters (`ollamaBaseUrl()`, `openaiCompatibleBaseUrl()`, `openaiCompatibleConfigured()`) read per call rather than import-time constants, and `api/credentials.ts` manages them as non-secret fields (`ollamaBaseUrl`, `openaiCompatibleBaseUrl`, echoed unmasked, http(s) `validate` hook) whose `onChange` calls `registerLocalProviders()` (`agent/models.ts`), so Settings → Providers → Local model servers applies without a restart and clearing one falls back to the default. Only `id` is read from `/v1/models`; refs are `openai-compatible/<id>` and may contain slashes, so only the prefix is stripped. Both providers are billed `local`/$0 in `cost/billing.ts`, `cost/ledger.ts`, and `agent/subagent-bridge.ts` — that trio must stay in sync or a local run defaults to `payg` and counts against the spend cap. Local-only by design: a hosted gateway would need real pricing, so route those through `OPENROUTER_BASE_URL` instead.
- **Outbound proxy.** `server/src/http-proxy.ts` installs undici's `EnvHttpProxyAgent` as the global dispatcher at boot (`index.ts`, `isMain` block) when `HTTP_PROXY`/`HTTPS_PROXY` is set, and is a no-op otherwise. Node's fetch ignores those variables on its own (`NODE_USE_ENV_PROXY` is Node ≥ 24 and we support ≥ 22.19), and Pi's `configureHttpDispatcher` isn't in the package's public exports — without this the lead agent would dial providers directly while the subagent runner honoured the proxy. Note undici tunnels with `CONNECT` even for `http://` targets.
- Pi OAuth tokens default to `~/.kady/pi-agent/auth.json`. `KADY_PI_AGENT_DIR` relocates that app-scoped directory; an explicitly supplied `PI_CODING_AGENT_DIR` takes precedence and can intentionally share a standalone Pi directory. The value is inherited by child `pi` processes, so lead and subagents share one auth store.
- **Provider authentication contract.** `ProviderAuthManager` passes Pi's `LoginOptions.getDeviceId`, backed by `SettingsManager.getOrCreateDeviceId()` in the global agent directory, for current ChatGPT sign-in. Do not promote `.env` keys to `setRuntimeApiKey`: that overlay masks stored OAuth and is invisible to child runtimes. Pi's stored credential wins over environment keys; an explicit Settings key save removes the previous stored credential to select environment-key auth. Clearing an environment key preserves an independent OAuth login and writes empty `.env` assignments (including aliases) so stale shell/legacy keys do not reappear at restart. `setupModelRuntime` normalizes `OR_API_KEY` into the inherited `OPENROUTER_API_KEY`. `/model-providers.configured` describes effective key-or-OAuth availability; `connected` describes OAuth specifically. Real-Pi callback/manual-login regressions with mocked token exchanges live in `server/test/provider-auth-integration.test.ts`. Pi 1.0's Anthropic login opens with a `select` prompt (browser vs. copy-code, for a browser on another machine than the backend), which the generic prompt bridge and the OAuth dialog already carry. Anthropic also authenticates by workload identity federation (`ANTHROPIC_FEDERATION_RULE_ID` + `ANTHROPIC_ORGANIZATION_ID` + `ANTHROPIC_IDENTITY_TOKEN_FILE`, optional service-account/workspace ids) when no key is set; they are non-secret `extraEnv` fields of the catalogue entry.
- Settings exposes API keys (`/credentials`; model keys under Providers, web-search keys — Exa, Perplexity, Gemini, and Ceramic, which pi-web-access only uses when a search names it — and the Modal pair under Services) plus Pi subscription authentication (`/model-providers`, `/model-auth/flows`; the row's **Sign in** button) for OpenAI (Sign in with ChatGPT; Pi 0.99 relabelled the older `openai-codex` login "OpenAI Codex (legacy)", kept for existing tokens), Anthropic (Claude Pro/Max), GitHub Copilot, xAI, Kimi Code, and Meta (Muse). OAuth browser/device/manual prompts are mediated by `server/src/agent/provider-auth.ts` and `server/src/api/model-providers.ts`. MCP servers, native web search (pi-web-access), and **durable Modal remote compute** (hybrid tools + per-chat selector + project Compute tab) are also implemented. **Not built in:** automated citation verification, first-party literature search, and document conversion (MCP connectors are the extension point; Paperclip is offered as a key-managed one, see the MCP caveat).
**Capability hub.** The **Settings** dialog (`web/src/components/settings-dialog.tsx`, opened from the header gear) is the only customization surface, a grouped sidebar: **Models** (Providers · Defaults · Fusion), **Project · <name>** (General · Skills · Prompt templates · Specialists · Connectors) and **Workspace** (Services · Appearance). Panels live under `web/src/components/settings/` (`primitives.tsx`, `providers-panel.tsx`, `defaults-panel.tsx`, `project-settings-panel.tsx`, `services-panel.tsx`, `fusion-panel.tsx`, `key-row.tsx`, and `model-field.tsx` — the model picker with a "type a model id" escape hatch used by Defaults, Specialists and Watchdog). Providers merges OAuth sign-in and API-key entry into one row per provider (connected first; "Add a provider" has search, an All / Sign in / API key filter and "Show all"), plus Local model servers and Custom model servers cards; Services holds the web-search keys, the Paperclip literature-search key, the Modal token pair and a Remote cache card (`GET/DELETE /modal/cache`). Project → General replaces the old Edit project dialog (new projects still use the New project dialog) with four independently saved cards: General & budget, Agent instructions (AGENTS.md), Raw-data guard, Context compaction. Deep links go through `web/src/lib/settings-nav.ts` (`openSettings({tab, projectId?, section?})` dispatches `kady:open-settings`; legacy ids `model-providers`→`providers`, `api-keys`→`services`; last tab in localStorage `kady:settings:lastTab`) and `web/src/components/settings-link.tsx`; `app/page.tsx` listens gated on the active workspace and `project-view.tsx` gated on the Projects screen. Project settings are reached from the gear on a project row in the header switcher, "Project settings…" in a project card's ⋯ menu, spend-limit banner links, and the sliders button beside the context gauge (section `context`). With no usable model source (`useModels().hasAnyModelAccess === false`) an empty chat renders `connect-model-card.tsx` ("Connect a model to get started" → Providers), and the picker offers "Manage providers" and "Connect…" for a disconnected selection. Destructive actions in the Project tabs (delete specialist, restore default specialists/templates, clear memory, remove connector) confirm first. Enable/disable is non-destructive: skills move between `sandbox/.pi/skills/` and `sandbox/.pi/skills-disabled/`, project specialists between `sandbox/.pi/agents/` and `sandbox/.pi/agents-disabled/`, and MCP servers keep their entry with Pi's `enabled: false` (a legacy `sandbox/.pi/mcp-disabled.json` is folded in by `migrateDisabledMcpServers`). Builtin (pi-subagents package) specialists are disabled via `subagents.agentOverrides.<name>.disabled` in `sandbox/.pi/settings.json`. Sessions read only the canonical locations, so toggles apply to new chat tabs/subagent runs (live sessions keep their set).
## Releases
- `server/package.json` `version` is the single source of truth for the app version. The web build reads it at build time (`web/next.config.ts` injects `NEXT_PUBLIC_APP_VERSION`); `web/package.json` deliberately has no `version` field.
- Releasing = bump `server/package.json` version and push/merge to `main`. The `Release` workflow (`.github/workflows/release.yml`) runs on every push to `main`, and if the tag `v<version>` doesn't exist yet it creates it plus a GitHub release with auto-generated notes. No manual tagging.
## Testing notes
- Backend tests: `cd server && npm test` (vitest, in `server/test/`). `KADY_PROJECTS_ROOT` is pointed at a temp dir via `vitest.config.ts`.
- Frontend tests: `cd web && npm test` (vitest); `npx tsc --noEmit` for the frontend typecheck.
## Caveats worth knowing
- **`session.bindExtensions()` is what fires `session_start`.** Pi's SDK emits `session_start` (and runs `resources_discover`) only from `AgentSession.bindExtensions()`, never from `createAgentSession()`. `session-registry.ts` calls it with `mode: "print"` after every build and emits `session_shutdown` through `session.extensionRunner` on release. Skipping it looks harmless — `subagent`/`bg_wait` are registered at extension load — but pi-subagents starts its supervisor channel (and registers the lead's `subagent_supervisor` tool) only in its `session_start` handler, so the allowlist entry is dead without the call. Cold-opened sessions start on the model they last ran with (`restoredSessionModel`, resolved through Kady so synthesized local/catalogue models survive reopening; authentication is still required and a bare Fusion wire id is not restored), brand-new ones on the project's latest chat model (`latestProjectModel`; the resident scheduler session always uses that policy), because extension-initiated system runs use the session's current model and the global default is the most expensive one.
- **One flat agent.** Delegation uses pi-subagents native Pi sessions, usually in a detached runner. `subagent-runtime-settings.ts` seeds `forceTopLevelAsync: true` in `<agentDir>/extensions/subagent/config.json` (write-if-missing) and removes the old ineffective project key. `subagent-control.ts` registers public preflight, capability ceilings, required child extensions and session-scoped Modal background work. Required packages provide guard, notebook, Modal, PDF, web and MCP support even with ambient discovery disabled; `kady-child-runtime` also registers tools in foreground children where `PI_SUBAGENT_CHILD` is absent. `scripts/patch-subagents.mjs` applies exact-version, anchor-checked host seams at install/startup: child streams, watchdog reviews/permission arbiters, stable fleet control targets, and `KADY_HOST_NO_DRAIN_V1` — Kady sessions are headless to Pi (`bindExtensions({mode:"print"})`, no UI context), so pi-subagents' `agent_end` handler would `drainOutstandingWork` (wait up to 30 min for every async run and background-work provider) and, since Pi awaits extension `agent_end` handlers before a run settles, hold the user's chat turn open; the seam skips it for ids in `globalThis.__kadyInteractiveSessions` (filled by `subagent-control.ts`), and completions still arrive as system runs through the always-registered notifier. pi-subagents identifies a session by its **file path** (`getSessionFile() ?? getSessionId()`, shared/session-identity.js), so that set and Kady's Modal background-work provider use that identity; under the bare id the provider never matched and `bg_wait` could not see Modal jobs. `subagent-host.mjs` gates actual resolved model requests before calling the provider and sends idempotent usage receipts to `subagent-meter.ts` before releasing the terminal event. Detached runners use the authenticated loopback API and persist pending receipts under `.kady/subagent-usage-pending`; admission/completion drains receipts after outages. The legacy completion bridge prefers run-specific usage, never inherited fork totals, and skips already-metered sessions. Schedule completion adds origin metadata without a second charge. Model request costs are not reserved, so in-flight/concurrent requests can overshoot. External CLIs remain opt-in and unmetered.
- **Specialist fleet.** Automation contains `subagent-fleet-panel.tsx`: choose a chat, view models/tokens/activity, poll live transcripts, steer/stop/resume. `api/subagents.ts` invokes the plugin's versioned public RPC and verifies project/session/run/child membership before controls. Public snapshot rows receive canonical `{runId,index,childId}` through the pinned adapter; never derive control targets from display order or opaque `fleet-*` keys. Prompts and full skill trees from pi-subagents are seeded by `subagent-resources.ts`; its marker records the plugin version and each file's hash, so a plugin upgrade replaces copies still matching what Kady wrote (markers from before hashes use `LEGACY_SHIPPED_HASHES`, 0.73.1/0.74.0 — never extend it), adds entries the new version introduces once, upgrades disabled skills in `skills-disabled/`, and leaves edits and deletions alone. Agent frontmatter uses full YAML; unknown nested/typed fields round-trip via `extra`.
- **Workflow scripts are the delegation surface, and since pi-subagents 0.74 they live in the reply.** The model writes the script as one ```` ```js workflow ```` fenced block in its assistant message and calls `subagent({ workflow: true })` (since 0.76 a reply with no tagged block runs its only plain ```` ```js ```` block, and `"true"` is accepted for `true`); a `workflow` string containing `/` is a script file read from the request cwd, any other string a named workflow resource. The package's own named workflows take their children as data — `review` (one `reviewer`), `run-ci` (a host command, no model) and since 0.77 `parallel` (`args.tasks: [{ agent, task }]`, which the tool description now promotes for batches) — so `PACKAGE_WORKFLOWS` in `subagent-bridge.ts` reads their targets from `args`; a workflow another extension registered stays unreadable. Since 0.77 the model-facing schema keeps `agent`/`task`/`workflow`/`args`/`async`/`model`/`cwd`/`worktree`/`output`/`action`/`id`/`message` at the top level and moves every other field into an `options` object (top-level management fields still work); the guards walk the whole input, so both shapes are covered. `workflowScript`/`workflowScriptPath` were removed from the model-facing schema (model calls passing them are refused) and survive only as the plugin's internal carrier (schedules store `target.workflowScript`). Children are declared as `runs.run(key, { agent, task })`; a structured `{ agent, task }` call still launches one child, and top-level `model` is a per-run override forwarded to every child. Walking the tool input structurally sees nothing under that surface, so the guards in `subagent-bridge.ts` call `workflowCallTargets(input, { toolCallId, sessionManager, cwd })`, which reads the same script pi-subagents will run (`replyWorkflowScript` ports the plugin's non-exported `readReplyWorkflowScript`: Pi persists the assistant message before its tools run, so the block is on the branch when `tool_call` fires) and parses it with Acorn via `workflowScriptTargets()`, extracting literal agent/model properties without matching comments. A script it cannot read here (another extension's named workflow, missing file, zero or several blocks) and a computed name both report `dynamic`: gating then bills "unknown" (fail closed at the cap), and `pinWorkflowScriptModel` refuses to pin at all, because a per-run `model` outranks agent frontmatter, `agentOverrides.<name>.model`, and `subagents.defaultModel` (all checked first — see `settingsPinnedModels`). The chat renders the block collapsed as **Workflow script** (`web/src/lib/workflow-script.ts`, same fence rule). Launches default to **async**; the lead blocks on its children with **`bg_wait`**, which the lead's tool denylist (`LEAD_EXCLUDED_TOOLS`) must never name. Since 0.78 a session with `subagent` also declares `contact_supervisor` (so a fork inherits its parent's exact tool list and prompt cache; a lead call returns an error). pi-subagents also ships six **external-CLI builtins** (`claude-code`, `codex-exec`, `cursor-agent` and their `-writer` variants) that shell out to a locally installed and authenticated Claude Code / Codex / Cursor CLI, outside Kady's model runtime, ledger and spend cap; `subagent-runtime-settings.ts` seeds `agentOverrides.<name>.disabled: true` for them (write-if-missing, detected via `isExternalCliAgent`), so they show up off in Settings → Specialists and stay however the user toggles them. That tab's "Default model for specialists" writes project `subagents.defaultModel` in `sandbox/.pi/settings.json` (`GET/PUT /agents/defaults`, `setSubagentDefaultModel` in `agent-files.ts`; clearing deletes the key), so `defaults` is a reserved agent name (`RESERVED_AGENT_NAMES`) — the static route shadows `/agents/:name`. `pinInheritedChildModels` pins the parent model onto a structured (non-script) child only when neither `subagents.defaultModel` nor `agentOverrides.<name>.model` (nor frontmatter) names one, since the pin would outrank them.
- **Builtin tool overrides are reconciled, not just extended.** Builtin specialists pin a `tools:` allowlist that Pi also applies to package tools, so three bridges (notebook → modal → pdf-annotation, in that order) copy the declared list into `subagents.agentOverrides.<name>.tools` with their own group appended. That copy goes **stale** when an upstream release narrows a builtin (e.g. dropping `bash`/`edit`/`write` from `reviewer`): a settings file seeded earlier would hand the removed tools back. `server/src/agent/builtin-tool-overrides.ts` decides ownership by shape (drop tools the builtin no longer declares and that are not ours; does the remainder match something we generate?) and rewrites from the *current* frontmatter. A hand-written allowlist does not match and is left alone; a user addition to a Kady-shaped list is indistinguishable from an upstream removal and is dropped, which is the safe direction.
- **Verification gates, the verifier model and `/prove-verify`.** A brief that opens with "Verification gate:" switches any reviewer to an adversarial standard with an accept/repair/reject verdict; the definition lives in `CHILD_OPERATING_GUIDANCE` (so it reaches existing and custom personas) and the lead is told how to request it in `LEAD_DELEGATION_GUIDANCE`. `comparative-reviewer` (cross-attempt review) and `investigator` (one assigned direction → a checkable draft) joined the roster; `seedAgentFiles` offers roster additions once to existing projects, tracked in `.pi/agents/.seeded-names.json` (`LEGACY_SEEDED_ROSTER` stands in for older markers — never extend it). `SubagentType.verifier` plus `BUILTIN_VERIFIER_AGENTS` (`reviewer`, `evidence-auditor`) define the verifiers. Settings → Defaults → Verifier model (`defaults.verifierModel` in `kady-settings.json`) is projected by `agent/verifier-models.ts` into each project's `subagents.agentOverrides.<verifier>.model` — the only lever pi-subagents resolves per child for structured calls, workflow scripts (detached runner) and schedules alike. Kady owns only entries recorded in `.kady/verifier-models.json` (an edited value is released), skips verifiers with a frontmatter model (pi-subagents applies an override *over* frontmatter, so `requestedBillings` now checks `byAgent` first), and drops its entries while the model is unavailable (`modelRefAvailable` in session-registry). Reconciled at session build, in the ledger extension's `tool_call` hook before launches/schedule fires, and for every project on `PUT /settings/defaults`. `subagent-control.ts` appends `verifierModelGuidance` to the lead's delegation section while routing is active; `GET /agents` marks `verifier`/`verifierModel` rows. `/prove-verify` is prompt-only orchestration (no server loop): the lead keeps `derived/prove-verify/<run>/ledger.md`, runs one workflow per round, and checks a dollar limit with a `node -e` sum over `.kady/runs/*/costs.jsonl` since its start time. See `docs/sub-agents.md#verification`.
- **Specialist prompting.** `agent/subagent-prompts.ts` holds shared runtime guidance: `subagent-control.ts` adds the lead's task-brief/synthesis contract and the required `kady-child-runtime` extension adds child scope, clarification and handoff instructions. These are separate from editable personas in `subagents.ts`; use structured prompt sections and preserve Pi's forced prompt when its child context filter has already rewritten it. Role defaults remain seed-once (new projects or explicit Restore defaults); shared runtime guidance also reaches existing/custom personas. See `docs/sub-agents.md`.
- **Interview tool (clarifying questions).** The `interview` custom tool (`server/src/agent/interview.ts`) blocks the run on a pending-answer promise; the questions ride the normal `tool_start` SSE frame and the chat UI renders them as an inline form (`web/src/components/interview-form.tsx`), POSTing answers to `/sessions/:id/interview/:toolCallId`. Tool `promptGuidelines` + the seeded `AGENTS.md` ask for blocking ambiguity or missing authorization while allowing routine reversible choices within scope. Question schema mirrors pi-interview (single/multi/text/image/info, recommended/conviction/weight, code `content`, image/table/mermaid/chart/html `media`); user-uploaded images return to the model as image blocks. Deliberately NOT exposed to sub-agent child processes — they route blocking questions through `contact_supervisor`; the lead resolves them from existing instructions or interviews the user before replying.
- **MCP is Pi's built-in support, and it forces a tool denylist.** `session-registry.ts` adds Pi's exported `createCodemodeExtension()`, `createToolSearchExtension()` and `createMcpExtension()` to the lead's extension factories (the CLI loads them as built-ins; SDK sessions do not). The MCP extension reads the global `<agentDir>/mcp.json` and, because Kady trusts its sandboxes, the project `.pi/mcp.json` (same-name project entry wins) on `session_start`, connects in the background (since Pi 0.99.2 the first prompt waits only for servers with `direct` tools; codemode servers are awaited when a script names or searches them), and registers `mcp__<server>__<tool>` tools with `-` replaced by `_` (`mcpNamespace()`; Pi skips the later of two servers whose names fold together, so `PUT /mcp` refuses such pairs within and across scopes). Pi's default `exposure` is `codemode`: tools are callable only from `codemode` scripts (QuickJS), which the extension activates and which find them with `searchTools()`/`describeNamespace()` (`codemode-deferred` is an alias; the UI no longer offers it); `deferred` activates `tool_search`; `direct` declares them. Servers are listed in an `mcp_servers` system-prompt section with their optional `description`, which also ranks tool search. Pi 1.0 config fields Kady validates and edits: `description`, `oauth.clientName`, `oauth.authServerMetadataUrl` (https or loopback), `oauth.callbackUrl` (loopback), and `auth: { provider }`, which sends a Pi `/login` token instead of MCP OAuth and is **global-only** (Pi's loader rejects it in a project file; `validateMcpServer(name, config, scope)` mirrors that). `GET /mcp/auth-providers` lists the sign-ins; `GET/POST /mcp/radius` is the one-step Radius connector (`addRadiusConnector`, a port of Pi's `/login` offer: `https://radius.pi.dev/mcp` with `auth.provider: "radius"`, reusing an entry at that URL or falling back to `radius-mcp`). **Paperclip** (`agent/paperclip.ts`) is the key-managed counterpart: saving `PAPERCLIP_API_KEY` in Services (checked first against `GET /api/v1/documents/<missing id>`, 401/403 = rejected; injectable via `setPaperclipKeyValidatorForTests`) runs a credentials `onChange` hook that writes a global `paperclip` (or `paperclip-mcp`) entry at `https://paperclip.gxl.ai/mcp` with `headers: { "X-API-Key": "${PAPERCLIP_API_KEY}" }` — a reference Pi resolves from `process.env` at connect time, never the key; `X-API-Key` because Paperclip documents Bearer only for its REST API. An existing entry at that URL is taken over (keeps exposure/description/other headers, drops `oauth` and any `X-API-Key`/`Authorization`); clearing the key sets `enabled: false` (an unset reference makes Pi refuse the connection) and leaves OAuth-only entries alone. `GET/POST /mcp/paperclip` reports and repairs the connector (e.g. a key set in `.env` by hand); the hook never fails the key save. MCP OAuth tokens are now keyed by server name and URL in `mcp-auth.json`. Because those names are unknown at build time and an allowlist drops unlisted tools from the registry entirely (codemode cannot reach them either), the lead passes **`excludeTools`** (`LEAD_EXCLUDED_TOOLS` in `agent/tools.ts`) instead of `tools`, plus a `defaultTools: ["+grep","+find","+ls"]` override applied **after** `resourceLoader.reload()` (reload re-reads settings and drops overrides); a probe must show the same lead tool set as before. Nested calls from codemode go through the same `tool_call`/`tool_result` hooks (data guard, provenance) and reach SSE with `parentToolCallId`. Kady no longer dials servers: `agent/mcp.ts` edits both files (preserving Pi fields and other top-level keys; a malformed file is never rewritten, 409), and shells out to the bundled CLI (`node <pi>/dist/cli.js mcp …`, `piCliPath()`) for `GET /mcp/status` (`list --json`, plus a Kady-added `signedIn` read from `mcp-auth.json` with Pi's store keys, so Settings offers Sign out only when there are tokens to remove — a key-header connector like Paperclip has none), `POST /mcp/test` (`list --json` against a throwaway agent dir holding only the entry plus a copy of `mcp-auth.json`, and of `auth.json` for an `auth.provider` entry), and OAuth (`login`/`logout`; tokens in `<agentDir>/mcp-auth.json`, running sessions reconnect on their next turn). Those CLI calls `trustSandbox()` first — the CLI ignores an untrusted project's mcp.json, and a project may not have opened a chat yet. Each live session holds its own connections (closed on `session_shutdown`), so a stdio server runs once per open tab. `test/fixtures/echo-mcp-server.mjs` is a dependency-free stdio server for tests.
- **Thinking level.** Each chat tab has a per-run thinking-level selector (`web/src/components/thinking-selector.tsx`, default `high`); the run body's `thinkingLevel` is validated by `server/src/agent/thinking.ts` and applied via Pi's `session.setThinkingLevel()` after `setModel`. Ollama and Fusion runs send no level (chip disabled). Caveat: like `setModel`, Pi's `setThinkingLevel` also persists the value as the **global default** in the configured Pi agent directory (`~/.kady/pi-agent/settings.json` by default), so the last level picked in any tab becomes the starting level for subagent child `pi` processes. It also affects the user's standalone Pi CLI only when `PI_CODING_AGENT_DIR` was explicitly shared. Pin a `thinking` level in a specialist's frontmatter if a subagent must not inherit it.
- **Durable Modal remote compute.** The lead always registers the hybrid tools in `server/src/agent/modal-tool.ts`; submission reports `NOT_CONFIGURED` until a token pair is validated in Settings → Services. `server/src/modal/manager.ts` owns persistent jobs under `.kady/modal/`, budget reservations, abort-safe sandbox creation, bounded logs, fallback resources, recursive/checksummed staging, atomic output installation, retry, batches, and restart recovery through saved sandbox ids. `server/src/modal/catalog.ts` is the sole CPU/GPU and estimated-price catalogue (including multi-GPU strings through B200); the UI reads it from `/modal/instances`. Things that are easy to get wrong here: the sandbox lifetime is `sandboxLifetimeSec` (command timeout **plus** a bounded transfer headroom) and the reservation, the reconcile cap and the recovery deadline all use it while the wrapped command gets exactly `timeoutSec`; input hashing happens at execute time (`planInputs` is stat-only, `hashInputPlan` streams), so `ModalTransferFile.sha256` is optional and a job that never ran has no input digest; uploaded inputs and produced outputs are re-hashed inside the sandbox (`verifyStagedInputs`, `collectOutputs`) with manifests passed as files, and the test fakes in `server/test/helpers/` emulate those `python3 -I -c` calls; `.kady`/`.pi`/`.kady-job` are skipped in the remote output walk *and* rejected through resolved local symlink aliases; all output installs enforce the current raw-data guard; Python 3.8+ is checked before upload and remote hashes are required for every job; every SDK error passes through `classifyModalError` and `NON_FALLBACK_ERROR_CODES` decides whether the fallback chain continues; the worker chain in `schedule()` always reconciles after a failure and ends in a logging `.catch`; recovery terminates tagged orphans (`findByTags`) and any terminal job's surviving sandbox; failed termination never stamps `sandboxTerminatedAt`, and uncertain cleanup counts the full reservation conservatively; logs sync by the wrapper's `.meta` offsets with persisted remote cursors separate from local byte counts. `staging/outputs` under a job directory is the retained, verified copy that evidence packages and robustness workflows read — never prune it. A project Modal Volume is cache-only and optional named environments publish reusable images; the local sandbox remains canonical. Child `pi` processes receive the same tools from `server/pi-packages/kady-modal`, which calls the localhost project API so credentials and accounting stay centralized. Costs are estimates, not Modal invoice reconciliation; see `docs/modal-compute.md`.
- **Living Lab Notebook (`notebook`).** The lead agent uses an in-process `notebook` custom tool (like `interview` and `modal_run`) to write entries live to the notebook. Subagents get the tool via the vendored `kady-notebook` Pi package (referenced from `sandbox/.pi/settings.json`, like pi-web-access); the package registers the tool only in child processes (gated on `PI_SUBAGENT_CHILD` env var) so it never collides with the lead's tool. When a subagent finishes, its notebook entries are harvested from its session JSONL file, role-stamped with the agent name, and appended to the parent notebook (the parent is the single writer). Subagent entries appear batch-on-completion, not live token-by-token; the Lab Notebook view also polls `GET /sessions/:id/notebook` (~5s, backing off) while async subagent work is outstanding, so late entries surface without a reload. Nested subagents (depth > 1) are not harvested. Entries support threading (`relatesTo` + `stance` supports/refutes/neutral, `supersedes` for append-only amendments; both schemas — `server/src/agent/notebook.ts` and `server/pi-packages/kady-notebook/index.ts` — are deliberate duplicates guarded by a parity test, and `notebook-harvest.ts` namespaces child link ids like entry ids). Each `/run` mints a `runId` (`server/src/agent/run-ids.ts`, set/cleared in the run route, emitted as a synthetic `run_start` SSE frame) that stamps entries server-side for run dividers; async completions remain unstamped rather than borrowing a later run's id (durable launch correlation is still deferred). User pins/comments/notes live in a sidecar `sandbox/.kady/notebook/<sessionId>.annotations.json` (`GET/PUT /sessions/:id/notebook/annotations`, If-Unmodified-Since/412 like the sandbox file annotations). Export supports `format=md|json|zip` (zip bundles artifacts via adm-zip); `GET /projects/:projectId/notebook` merges all sessions for the project-scope view; `POST /sessions/:id/notebook/methods-draft` is a budget-gated one-shot AI draft (clone of latex-assist, ledgered under session id `methods-draft`, writes `methods_draft_<sessionId>.md` into the sandbox).
- **Evidence-aware notebook.** `evidence: [{entryId, sessionId?, relation, rationale?}]` adds multiple supports/challenges/inconclusive/context links, plus `outcome` and `limitations`; legacy relatesTo/stance still works. Shared pure semantics live in `web/src/lib/notebook-evidence-core.ts` (the server imports it too; keep it IO/framework-free and inside web's Turbopack root). All active evidence determines labels, never latest-entry-wins; superseded observations are excluded without inheriting links. `server/src/agent/notebook-artifacts.ts` captures citation-time hashes for new lead entries and derives bounded direct-artifact checks at GET/export time (25 citations/entry, 100 files/read, 8 MiB/file, 32 MiB total). Hashes are server-only fields and are not historical file copies; legacy/child citations remain unverified without write-time evidence. Project entries are keyed by session+id, the default project view merges active-chat entries live and polls other chats, and refresh failures are visible. `docs/README.md` indexes the notebook feature guides.
- **Frozen plans and structured results.** User-reviewed frozen plans and deviations (`server/src/agent/notebook-plans.ts`, exclusively published event files under `.kady/notebook/plans/<source-hash>/`; preview token + dataset recheck + head preconditions, no agent-facing approval tool). `analysisPlan` is only a model-authored draft. `results` references canonical persisted `scientific_result` envelopes; `notebook-results.ts` performs bounded lookup and pins server-derived content digests, refusing changed replacements and child-local/parent-id collisions. Shared plan protocol lives in `web/src/lib/notebook-plans.ts`; nested lead/child input schemas live in the self-contained vendored `plan-schema.ts`. Routes are in `api/notebook-research.ts`; explicit missing project headers are rejected rather than falling back to default for approval/result operations. GET/export enrichments never trust tool-authored approval metadata. These local records are not external preregistration, an execution guard, or a same-user shell security boundary.
- **Robustness workflows.** `notebook.robustness` proposals and the user-only **Stress-test finding** approval UI (`notebook-robustness-dialog.tsx`). `agent/notebook-robustness.ts` owns immutable project-bound previews/private input snapshots and all specification records; `modal/manager.ts.submitApprovedBatch` reserves/persists the entire stable-id batch before a shared admission gate permits remote work. Server-only `ModalJob.approval` binds requests and input hashes; generic retry is refused for these jobs. Workers verify snapshots and actual uploaded bytes, retain every failed/missing/QC-excluded attempt, and read structured effects from checksummed transfer staging rather than mutable current outputs. Unknown launch/cleanup is conservatively counted at the full reservation; `modal/approval-reservations.ts` protects holds for missing committed job records. Startup and Modal credential restoration call robustness recovery after normal Modal recovery. Single backend remains the compute owner; this is not distributed exactly-once, an invoice cap, external preregistration or a same-user shell security boundary. `docs/notebook-robustness.md` covers the Python contract and limits.
- **Research memory.** Local read-only research recall (`agent/notebook-memory-loader.ts` + `notebook-memory.ts`, shared IO-free protocol/ranking in `web/src/lib/notebook-memory.ts`, UI `notebook-memory-dialog.tsx`). It reads bounded notebook windows, user notes and validated plan events fresh per query; source ids/digests, supersession, applicability (`scope`), `revisitWhen`, outcomes, incomplete coverage and direct artifact checks remain explicit. `notebook_search` is registered for lead and child sessions with visible search/read calls, no hidden prompt injection or new fact writes; the child's loopback `memory-client.ts` validates project context and does not follow redirects/proxies. All three builtin allowlist bridges recognize legacy and new notebook/search shapes; read-only provenance classification includes the tool. Tool text is capped at 24 KiB, clipboard recall at 16 KiB; full limits/privacy are in `docs/notebook-memory.md`. Hosted models still receive requested recall as normal context.
- **Evidence packages.** Human-reviewed evidence ZIPs (`server/src/evidence/*`, `api/evidence-packages.ts`, UI `evidence-package-dialog.tsx`). Preparation captures a bounded evidence/amendment graph, original source rows, selected annotations, plans/results/workflows, version-aware provenance and stored environments without probes. Versioned artifacts are matched against `.kady/evidence/blobs/<sha>` / retained compute copies / current bytes; current unverified copies require an explicit option. Immutable project-bound package previews include gaps, source-linked Methods scaffolding, base RO-Crate 1.1, checksums and a static `python3 -I verify.py`; packaging never runs analyses/models/compute or claims reproduction. ZIP streams verify payload hashes, download checks SHA on server and browser, and `X-Content-SHA256` is CORS-exposed. Package removal retains the shared vault; explicit pruning removes only versions unreferenced by readable retained packages. Quotas: 128 MiB/artifact, 256 MiB/package, 1 GiB vault, 20 packages / 2 GiB package storage; see `docs/evidence-packages.md`. `loadMemoryCorpus(..., {retainOriginal:true})` is opt-in for package/planning-history capture; raw source fields never enter memory-tool responses or planning-model prompts.
- **What next?** Planning proposals surface in `web/src/components/next-experiments-dialog.tsx` and are authored as structured `notebook.nextExperiments` note entries (shared IO-free `web/src/lib/next-experiments.ts`, vendored `next-experiments-schema.ts`). `agent/next-experiment-context.ts` selects bounded scientific context excluding proposals/preferences; `next-experiments.ts` generates only after explicit one-call approval and context-digest review, using no tools, with normal model billing under `next-experiments`. Local `next-experiment-receipts.ts` intent/outcome files prevent repeating confirmed or uncertain requests; invalid returned outputs are ledgered and never auto-retried. User prioritize/defer/reject preferences append exact proposal/context-bound decision records, not execution approvals. Proposal links are context only and cannot retire actual evidence; Methods drafts/scaffolds exclude them. Child refs are namespaced and harvest context stays unverified. Exports/memory/package graphs preserve qualified planning metadata and source gaps. See `docs/next-experiments.md` and `docs/lab-notebook.md`; no numerical information-gain optimizer, automatic experimentation, strict model-call reservation or provider invoice cap is claimed.
- **Model billing.** OpenRouter cost is read from Pi's `usage.cost` (computed from `model.cost`); synthesized model pricing comes from `web/src/data/models.json`. Pi-documented Anthropic OAuth metered extra usage also counts toward caps. OpenAI Codex, GitHub Copilot, and xAI OAuth usage records tokens/reference price without counting it as project spend; this is not a claim that provider usage is free, because quotas and overages are managed externally. NVIDIA NIM and the prepaid token plans (`qwen-token-plan*`, `xiaomi-token-plan-*`; `isPlanBilledProvider` in `provider-catalog.ts`) are classified the same way (`billingMode: "subscription"` despite being API-key-based): they bill provider-managed credits or a plan quota rather than per-token USD, Pi prices every such model at $0, and treating them as payg would let an exceeded spend cap block runs that ledger $0 (`provider-catalog.test.ts` asserts an all-$0 Pi catalogue is plan-billed and a mostly-priced one is not). Every other direct provider is payg with Pi's list price. OpenRouter/Radius OAuth logins are `payg` (they stand in for a key); the Kimi Code OAuth login is `subscription` while `KIMI_API_KEY` is payg. Picker rows carry the same `billingMode` from `directModelForClient`, and `/providers/models` skips a dual provider Pi resolved via OAuth so each model is listed once, under the billing its credential implies.
- **One-shot model calls must name a reasoning level.** Methods draft (`agent/methods-draft.ts`), What next? proposals (`agent/next-experiments.ts`) and editor LaTeX assist (`latex/assist.ts`) call `ModelRuntime.completeSimple(model, context, { maxTokens, reasoning: ONE_SHOT_REASONING })` (`agent/one-shot-reasoning.ts`), not `complete`. `complete` is the raw provider stream: with no effort given, Pi's OpenAI-compatible adapter sends OpenRouter `reasoning: { effort: "none" }` for every reasoning-capable model, and models whose reasoning cannot be disabled (GPT-6 Astra, the default) answer `400 Reasoning is mandatory for this endpoint and cannot be disabled` — chats were unaffected because a session always carries the tab's thinking level. `completeSimple` clamps the level to what the model supports, so the one constant is safe for non-reasoning models too. The three tests assert the option is passed.
- **Schedules + missions (server-owned).** pi-subagents arms schedule timers per live session and a fire produces no `tool_call`, so `server/src/agent/scheduler.ts` keeps one **resident session** per project with active schedules (id in `.kady/scheduler.json`, `markSystemSession` = pinned, excluded from `MAX_LIVE_PER_PROJECT`, hidden from `GET /sessions`), opened at boot and refreshed after successful schedule mutations via the `tool_result`-side `setScheduleActivityListener` (a hook, because bridge → scheduler → registry would cycle). The pinned adapter restricts project-wide timer arming, recovery and firing to the recorded resident session; a session-scoped `kady:schedules:refresh` event rereads the store after creation/resume so later schedules survive chat eviction without duplicate chat timers. Explicit `sessionOnly` schedules retain their original chat ownership. Panel actions and the budget hold call the `subagent` tool's `execute` directly on that session (`invokeSubagentAction`, no model), which skips the bridge's `tool_call` gate, so `api/automation.ts` re-applies the cap itself (402 for Run now and for resuming a `heldByBudget` schedule) and fires Run now with `quiet: true` (a noisy manual fire wakes the hidden resident session for a billed turn nobody sees); `mission.close` takes `missionStatus`, not `status` (an unknown key silently closes as `completed`). Fire results: pi-subagents drops success output, so the bridge's `subagent:async-complete` handler keeps a bounded copy in `.kady/schedule-outcomes.json` keyed by the async run id (`runId ?? id`, pi-subagents' own precedence), preferring each child's `artifactPaths.outputPath` inside the sandbox over the serialized workflow return; `listSchedules` attaches it as `runs[].summary`. `GET /subagents/sessions` lists the resident session once as "Scheduled runs" (filtered out of the chat rows first). The bridge gates `schedule.create` like a launch and refuses `schedule.run`/`run-due` over the cap; `reconcileBudgetHolds` (60s tick, `startSchedulerTick` in the `isMain` block only — never in `buildApp`) pauses active schedules while over the cap (`heldByBudget`) and resumes them after. `subagent:async-complete` carries `scheduleOrigin`, ledgered as `CostEntry.origin.schedule` (`scheduleSpend`). `api/automation.ts` reads pi-subagents' store (`sandbox/.pi/subagents/schedules/<id>/{schedule,history}.json`, owner paths stripped) and missions (`<agentDir>/missions/projects/<sha256(realpath(sandbox))>/*.json`, keyed by the real path since 0.77); UI is the project view's Automation tab (`automation-panel.tsx`). `AGENTS.md` v4 tells the lead to confirm interval/cost before scheduling.
- **Custom model servers (`models.json`).** `server/src/agent/custom-models.ts` manages Pi's `<KADY_PI_AGENT_DIR>/models.json` (`GET/PUT /custom-models`, Settings → Providers → Custom model servers): validation (`CUSTOM_PROVIDER_ID_RE`, no collision with catalogue/OAuth/reserved ids, http(s) base URL, api ∈ openai-completions|openai-responses|anthropic-messages, models with optional USD-per-million cost), atomic write, then `ModelRuntime.refresh()` so the new provider is live without a restart. Kady rewrites only providers it created (sidecar `kady-custom-models.json`); hand-written providers are listed read-only and a same-id save is refused. `directProviderRef` in `models.ts` recognizes custom ids via `isCustomProvider` (mtime-cached) so `<id>/<model>` resolves through `registry.find` and an unknown id errors — the legacy "unknown prefix = OpenRouter vendor id" fallback is kept for stored refs. `/providers/models` appends custom rows (`sourceLabel: "Custom servers"`, `billingMode: "payg"`, declared cost) and lists the provider as configured, so the picker and `billing.ts` (payg default) agree. Keyless servers get `apiKey: "none"`, like Kady's own Ollama registration. `modelThinkingLevels` is deliberately not exposed (the tab's thinking level is applied after `setModel`).
- **Per-agent memory.** pi-subagents' `memory:` frontmatter is modelled in `agent-files.ts` as `AgentFile.memory` (`{scope: project|user, path}`), parsed and serialized as full YAML (both block and inline input forms work); `restoreDefaultAgents` carries an existing memory setting onto the rewritten roster file. `GET/PUT/DELETE /agents/:name/memory` read/edit/clear `<sandbox>/.pi/agent-memory/<path>/MEMORY.md` (or the user-scope file under `KADY_PI_AGENT_DIR`); the child creates it itself with `write`. Harvested child writes under dot-directories are already dropped by `resolveInSandbox` (`isUserVisible`), so memory writes never become provenance artifacts.
- **Supervisor channel + watchdog.** pi-subagents' `contact_supervisor` (child) / `subagent_supervisor` (parent, now in the lead allowlist and in both provenance read-only sets) delivers a child's question as a `subagent_supervisor_request` custom message with `triggerTurn: true`; the session observer adopts that turn as a system run, the SystemCard shows it, and the seeded `AGENTS.md` (v3) plus the `interview` tool's guidelines tell the lead to relay via `interview` and answer with `subagent_supervisor` reply (child timeout ≈10 min). The watchdog is configured in `sandbox/.pi/settings.json` → `subagents.watchdog` through `server/src/agent/watchdog-settings.ts` (`GET/PUT /watchdog`; strict upstream key set incl. `stalemateRepeats` 1–20, Kady always writes `lsp.enabled: false`, `WATCHDOG.md` seeded with scientific rules; only exact unchanged older shipped versions are upgraded; the card's number fields save on blur/Enter); findings arrive as `subagent_watchdog_warning` custom messages. Kady meters watchdog review and permission-review streams with the same admission and billing policy as children; default off. `GET /projects/:id/instructions` reports `agentsMdStatus` and `POST …/restore` rewrites the shipped text.
- **Raw-data guard.** `server/src/agent/data-guard.ts` is a `tool_call` hook (registered after the subagent bridge so budget gates run first) that blocks `write`/`edit`/`bash` mutations of the project's protected globs (`sandbox/.kady/policy.json`, `guard-policy.ts`, default `user_data/**`) and, when `destructiveConfirm` is on, pauses destructive shell commands elsewhere via `permissions.ts`: a `permission_request` frame rides the run's `RunHandle`, the UI answers through `POST /sessions/:id/permissions/:requestId`, and abort/run completion/10-min timeout resolve it as a block. The classifier (`bash-classifier.ts`: segment split, `cd` tracking, redirects, mutating verbs, destructive patterns, benign `rm -rf` targets) is pure and copied byte-for-byte into `server/pi-packages/kady-guard/classifier.ts` for child processes (`bash-classifier.test.ts` runs the same table against both and asserts parity); the child package blocks destructive commands outright since it has no UI. `sandbox-seed.ts` now versions `AGENTS.md` (`AGENTS_MD_HISTORY`; v7 scopes uv to the local sandbox and resolves distributions before import repairs): a sandbox whose file equals an older shipped version verbatim is upgraded on the next seed pass, an edited file is left alone and reported by `agentsMdStatus`, and `restoreAgentsMd` is the explicit restore. Heuristic, not a security boundary — see `docs/data-guard.md`.
- **Compaction is science-aware.** `server/src/agent/compaction-bridge.ts` hooks Pi's `session_before_compact` and returns a full `CompactionResult`: a deterministic preamble built from Kady's stores (`buildCompactionPreamble`: notebook entries, latest frozen plan, `scientific_result` step ids, latest `environmentId`, authored execution states/corrections and pending Modal jobs) plus a bounded public specialist-status snapshot, followed by a narrative generated with Pi's exported `generateSummaryWithUsage` under `SCIENCE_COMPACTION_INSTRUCTIONS` (Pi appends them as "Additional focus" to its coding checkpoint format, so they map those sections onto research work and add a `## Scientific Record` section; the history call also sees the records block, the split-turn prefix call gets `TURN_PREFIX_FOCUS` instead); split turns get a second call like Pi's own. Only the narrative of an earlier Kady summary is reused (`previousNarrative`): its records would hand the model stale job/specialist states to preserve and nest one preamble in the next. Pi ignores the `details` of hook-made compactions (`fromHook`), so the `<read-files>`/`<modified-files>` lists are carried by parsing the previous summary (`compactionFileLists`). Pi's serializer keeps only the first 2,000 chars of a tool result, so longer ones are clipped to head + tail first (`clipToolResultsForSummary`). Returned `usage` lands on the compaction entry and in `getSessionStats()`, so it is ledgered. Any failure returns `undefined` (Pi's default runs); the hook never cancels. `POST /sessions/:id/compact` is the manual path (409 while streaming/claimed, 402 over cap, ledgered from the stats delta); `GET/PUT /projects/:id/compaction` edits `compaction.*` in `sandbox/.pi/settings.json` via `compaction-settings.ts` (malformed file → 409, never rewritten). `compaction_end` reaches the client as a `customType: "compaction"` frame.
- **Provider refusals are explained, not swallowed.** Anthropic's Mythos-class models classify the *whole* request before generating; Claude Fable 5 refuses outright when the skills index in the system prompt carries certain seeded scientific skills (`adaptyv`, `diffdock`, `ginkgo-cloud-lab`, `glycoengineering`, `pathogen-variant-surveillance`, `phylogenetics`, `tamarind` — each verified to refuse on its own description, 2026-08-27). The wire signal is `stop_reason: refusal` / OpenRouter `finish_reason: content_filter` with **no `usage` object at all**, which is why such a run reports zero input tokens and bills nothing. It is a model-level behaviour reproducible with a bare chat-completions call, not a Kady bug, and it hits the lead agent as well as subagents (children inherit the lead's model). `server/src/agent/model-refusal.ts` recognizes the signal and builds the guidance; `events.ts` forwards the provider's own `errorMessage` rather than a generic `Model error`, `api/sessions.ts` appends the guidance to both provider-error publish points, and `makeSubagentRefusalExtension` appends it to a refused child's `subagent`/`bg_wait` result — a child dies in its own session inside pi-subagents' runner process, so runner text is the only trace that reaches the parent. `KNOWN_REFUSAL_TRIGGER_SKILLS` is used **only** to make an error message specific: never to block, disable, or filter, because it is empirical and providers retune classifiers. See `docs/limitations.md`.
- **The HTTP boundary is `request-guard.ts` + `auth.ts`, not CORS.** The backend is an unauthenticated-by-default API that can run a shell, so the first `onRequest` hook refuses a disallowed `Origin` (preflights included — CORS alone never stops a cross-site `<form>` POST), a `Host` that could be DNS-rebound (only loopback names, IP literals, the machine hostname, `KADY_ALLOWED_HOSTS`), and a no-cors subresource load (`Sec-Fetch-*` with no Origin) whose `Referer` is not the UI. Trusted UI origins are the UI port only (`KADY_FRONTEND_PORT`, default 3000) on loopback/own-interface hosts plus `KADY_ALLOWED_ORIGINS` — never "any localhost port", because the agent starts local servers that serve untrusted HTML. An `onSend` hook gives every response `Content-Security-Policy: sandbox; default-src 'none' …` (PDFs exempt) + `nosniff` + `Referrer-Policy: no-referrer`, because `/sandbox/raw` serves untrusted HTML/SVG from the API origin. `auth.ts` enforces an access token (`X-Kady-Token`, bearer, or `kady_token` query — redacted from logs) when the backend binds beyond loopback or `KADY_REQUIRE_AUTH=1`; the web side is `web/src/lib/api-auth.ts` (`apiFetch` header, `withApiToken()` for every URL the browser loads itself — new `<img>`/download/pdf.js URL builders must use it) and `ApiTokenGate`; child packages send `process.env.KADY_AUTH_TOKEN`. Side-effecting routes must not be GETs (`/mcp/status` is POST for this reason). Values persisted to `.env` must pass `invalidEnvValue` (a newline would add a `NODE_OPTIONS=` line); custom-model keys go through `encodeApiKeyForPi` (Pi runs `!cmd` and expands `$VAR`); LaTeX compiles with `latexmk -norc`. See `docs/security.md`.
- **Local shell trust boundary.** Agent tools run as the current OS user and are not an OS-level secret boundary. Kady-scoped `auth.json` is protected from other users, not from same-user shell processes. New sandbox instructions forbid reading credentials, but use a container/VM/separate account for adversarial content; see `docs/limitations.md`.
- **The harness is pinned to exact versions.** `server/package.json` pins Pi (`@earendil-works/pi-{agent-core,ai,coding-agent}`, which share a version line) and the extension packages `pi-subagents`, `pi-web-access`, and `skills` to exact versions (no caret). `installBackendPackages()` in `start.mjs` reads those pins and installs them as explicit `name@version` specs on every full start, so a checkout with a stale or hand-edited lockfile still converges on the pinned set. Upgrading the harness is a deliberate act: bump the pins in `server/package.json` (Pi's three packages must stay on one version), run `npm install` in `server/` to move the lockfile, and re-run typecheck + tests before shipping — the `workflowScript` and builtin-tool-override caveats above are the class of upstream change that breaks silently if versions float. Bump `pi-subagents`/`pi-web-access` with Pi: both track Pi's extension APIs closely (pi-subagents < 0.68 bundled a Pi-0.85-only `pi-server`). Since Pi 0.86 both packages start fresh sessions with a lazy loader tool (`subagents_enable`, `web_enable`) instead of their full tools; the lead's `tools` allowlist omits the loaders, and when a loader is filtered out each package keeps its tools eager (verify with `session.getActiveToolNames()` after an upgrade). Children carry no allowlist, so `ensureWebAccess` seeds `toolActivation: "eager"` into `<agentDir>/web-search.json` (write-if-missing; both packages default to `"auto"` since pi-web-access 0.34 / pi-subagents 0.74), together with scientific `summaryInstructions` (pi-web-access 0.35, used by `auto-summary`; `SCIENTIFIC_SUMMARY_INSTRUCTIONS`; a blank string keeps the package default). `seedSubagentRuntimeSettings` also seeds pi-subagents' `disabledFeatures: ["panes", "external-machines"]` (write-if-missing) — TUI panes and Herdr machines have no Kady surface, and the tool declaration rides every lead request. It also seeds `activeRunsPrompt: true` (0.78, write-if-missing; an explicit `false` sticks): launches default to async and chats are long-lived, so the lead's system prompt lists its queued/running background runs until they finish instead of relying on completion notices to remember them. The lead excludes both loaders by name (`LEAD_EXCLUDED_TOOLS`, see the MCP caveat for why it is a denylist). Current pins: Pi 1.3.0, pi-subagents 0.78.0, pi-web-access 0.41.0, skills 1.7.0. Since Pi 1.2, `before_agent_start` also fires for runs an idle `triggerTurn` custom message starts (`event.messageType: "custom"`, no `prompt`), so Kady's prompt-section handlers now reach system runs too; a handler meant only for user prompts must check `messageType === "user"`. Pi 1.2 also made OpenRouter's saved list in `<agentDir>/models-store.json` replace the built-in catalog regardless of age (1.0 preferred whichever was newer); Kady never refreshes catalogs at boot, so `pruneStaleOpenRouterCatalog` (`models.ts`, called before `ModelRuntime.create`) drops a list saved before the installed catalog — otherwise a pre-upgrade cache keeps pricing the lead and every child from stale data.
- **Node ≥ 22.19** is what Pi targets; lower 22.x usually works but emits an `EBADENGINE` warning. Node < 22 (e.g. v20) fails to build/install the packages, so the launcher (`start.mjs`) refuses to run on it, on every platform.
- **Windows portability.** Sandbox-relative paths are canonicalized to forward slashes at the API boundary (`apiRelative`/`toApiPath` in `server/src/sandbox-fs.ts`, `stripSandboxRoot` in `server/src/agent/events.ts`) — the frontend always sees `/`-separated paths. External binaries are located via `server/src/binaries.ts` (`hasBinary`/`findUv`/`firstRunnable`), never `which`. On Windows the agent's `bash` tool uses Git Bash (Git for Windows is a hard prerequisite there), the helper venv interpreter lives at `.venv/Scripts/python.exe`, and synctex is invoked from the PDF's directory with relative paths so drive-letter colons stay out of its colon-delimited args in the primary invocation (`forwardArgs`/`inverseArgs` in `server/src/latex/synctex.ts`; a forward-slash absolute-path retry exists as a fallback for absolute `Input:` records).
- **Don't run our source through `tsc` for emit** — both dev and prod run via `tsx`; `tsconfig.json` is `noEmit` for typechecking only.
## Office preview and editing
`server/src/api/office.ts` serves project-scoped `GET /sandbox/office` previews and `GET/PUT /sandbox/office/content` binary snapshots/saves. The existing helper venv validates bounded OOXML packages with DTD/entity rejection. Saves require an SHA-256 `If-Match` revision, recheck before atomic replacement, and record user provenance. Signed/enforced-protected documents cannot be overwritten.
`office-viewer.tsx` offers lightweight DOCX (sanitized docx-preview iframe), PPTX (Office Kit passive SVGs), and XLSX (existing arraydata viewer) previews. **Edit in Office** opens `/office?path=…&project=…` in a separate tab with `office-workspace.tsx`. The full Writer/Calc/Impress engine runs in browser WASM, without Docker. `office-toolbar.tsx` supplies Kady-themed controls with live UNO command status; native chrome is hidden by the frame layout manager and restored by All tools. Theme changes affect application colors, never document formatting; do not apply CSS filters to the canvas. Calc formula-bar drafts must commit through `.uno:EnterString` (`StringName`) before export; export-started messages establish the saved modification generation. `/office` alone uses COOP/COEP. `public/office/runtime.js` and `thread.js` bridge the virtual filesystem and UNO to revision-checked Kady saves; window messages must match source and origin. Keep macros and external update execution disabled. Do not replace native editing with text-run forms. Native Office export reserializes OOXML, so never promise byte-preservation of advanced objects.
`/office-assets/[build]/[file]` caches an allowlisted, SHA-pinned official ZetaOffice runtime snapshot in `~/.kady/office-assets/<build>/` (`KADY_OFFICE_ASSETS_DIR` overrides). Manifest updates require real browser open/edit/save checks for all three formats. Never float executable runtime assets without integrity checks. Fixture generation instructions live in `server/test/fixtures/office/generate.py`; scope and compatibility limits are in `docs/file-previews.md`.Signals
- Stars
- 1,400
- Forks
- 184
- Open issues
- 8
- Last commit (this folder)
- Oct 16, 2026 (14 days ago)
- Last release
- Oct 1, 2026 (29 days ago)
From GitHub (fetched Oct 24, 2026).
Details
- License
- MIT
- Repository
- github.com/K-Dense-AI/k-dense-byok
- First seen
- Oct 24, 2026
- Last checked
- Oct 24, 2026
- Source
- GitHubgithub-graphql
More from K-Dense-AI
- 0.699score
exploratory-data-analysis
K-Dense-AI
Performs bounded, local exploratory analysis of explicitly supported scientific files.
SkillAFreshness: A★ 54.4KClaude Code +9 - 0.699score
citation-management
K-Dense-AI
Comprehensive citation management for academic research.
SkillAFreshness: A★ 54.4KClaude Code - 0.699score
glycoengineering
K-Dense-AI
Analyzes and engineers protein glycosylation by scanning canonical N-glycosylation sequons, describing S/T-rich regions, checking curated glycan evidence, and…
SkillAFreshness: A★ 54.4KClaude Code +9 - 0.699score
openpiv
K-Dense-AI
Performs Particle Image Velocimetry (PIV) analysis with OpenPIV.
SkillAFreshness: A★ 54.4KClaude Code +9 - 0.699score
ginkgo-cloud-lab
K-Dense-AI
Guides protocol selection, input preparation, pricing checks, and browser ordering on Ginkgo Bioworks Cloud Lab (cloud.ginkgo.bio).
SkillAFreshness: A★ 54.4KClaude Code +9 - 0.699score
parallel-web
K-Dense-AI
Uses Parallel CLI for web search, URL extraction, deep research, structured data enrichment, entity discovery, and recurring web monitoring.
SkillAFreshness: A★ 54.4KClaude Code +9
Done for you
Rather not set it up yourself?
Chalk Labs scopes, builds and runs agents for your team: the task audit, the eval harness, the integrations and the weekly ops report. Fixed scope, priced before we start.
See how we build AI agents →Read the case studies
Chalk Labs is a two-founder AI and growth studio. We build AI agents, CRMs and MVPs for Web3 and AI companies, and have shipped 50+ product and token launches.
- launches shipped
- 50+
- launches shipped
- media placements
- 500+
- media placements
- founders on every account
- 2
- founders on every account
Press we've placed: Forbes · CoinDesk · Decrypt · Cointelegraph · The Block
Rahil Jain, Co-founder, Chalk Labs. Ex-Goldman Sachs engineer. You talk to the person who builds it.
Get AGENTS.md · K-Dense-AI/k-dense-byok running in your business
Tell us what you want to automate. A founder replies within one working day with a plan, not a pitch deck.
Cite this page
AGENTS.md · K-Dense-AI/k-dense-byok. Agent Mart. https://agentmart.dev/templates/k-dense-ai-k-dense-byok-agents-md (last updated 2026-10-24)