agentproto-apps · git:20260928.461df5e · 2026-09-28 · sha256 d29627d70458ac46
agentproto-apps git:20260928.461df5eC
Immutable. This exact content never changes and is served at /api/v1/blob/d29627d70458ac46.
---
name: agentproto-apps
description:
Operate and build Agentproto apps — AIP-53 app bundles (`defineApp().emit(dir)`,
APP.md + agents/ + workflows/ + ui/) and their daemon lifecycle (app_install,
app_apply, app_run, app_status, app_stop) plus the app-scoped durable data
plane (app_data_read/write/list/migrate). Covers serving one with a UI
(`agentproto app serve` → window.McpApp bridge → /mcp), app-scoped
path-traversal-safe storage under the installed app dir, sequential
scout-then-tailor-style app_run sequencing, and model/harness pass-through.
Use when asking to "build an agent app", "wire an app UI / app_run", "how do
apps persist data", "run an app's agents in order", or debugging an installed
app (job-application-kit, …).
metadata:
tags: agentproto, app, aip53, udashboard, app_run, app_data, defineApp, app serve
aip3:
uses: ["agentproto"]
---
# Agentproto apps (AIP-53)
A **packaged app** is a directory that ships one or more AIP-42 agents, the
AIP-15 workflows they run, and (optionally) a single-file web UI, emitted under
`<dir>/.agentproto/` by `defineApp().emit(dir)`. The daemon installs, applies,
runs, and persists them. This skill is the app plane on top of the base
`agentproto` skill (sessions/drivers live there).
## Anatomy — what an app dir contains
```
<appDir>/
.agentproto/
APP.md # frontmatter: id, agents[], workflows[], ui{path,tools}
agents/<id>/AGENT.md # AIP-42 agent manifests
workflows/<id>/WORKFLOW.md # AIP-15 workflow manifests
ui/index.html # single-file dashboard (CSS+JS inline)
data/ # default data dir (app_data_* writes here)
<dataDir>/ # …or wherever `app_install {dataDir}` /
# `app install --data-dir` pointed it
```
`APP.md` frontmatter (`schema: app/v1`, `id`, `agents: [{id,path}]`,
`workflows: [{id,path}]`, `ui: {path, title, tools: [...]}`, optional
`data: {dir}` — the default data dir, relative to the app dir). The `ui.tools`
array is an **allowlist** — `app_tool_call` REFUSES any tool id not in it, so
when the UI calls a new daemon tool (e.g. `app_data_read`), add it there.
> **Gotcha:** this repo's root `.gitignore` ignores `.agentproto/` by default
> (it targets agent-scoped scratch). For a first-class app bundle the
> `.agentproto/` dir IS the deliverable — `git add -f <appDir>/.agentproto` to
> commit it.
## The app lifecycle (daemon verbs)
- **`app_install {dir}`** — validates the app (`loadAppHandle`), cross-checks
every WORKFLOW.md `tool` step id against the daemon's registered tools
(reports ALL missing at once), and checks the agent adapter resolves. Upserts
by `appId`. Re-installing refreshes the record.
- **`app_apply {appId, scopeId?, dir?}`** — makes the app's capabilities
available in a scope; installs first if `dir` given and not already installed;
validates `requires` deps are applied. Idempotent.
- **`app_run {appId, agents?, sequence?, prompt?, cwd?, model?, harness?, adapter?}`** —
spawns one live session per agent under a fresh `appRunId`. `agents` runs them
CONCURRENTLY. `sequence: [id, ...]` runs them ONE-AT-A-TIME in order (each
awaited to a terminal session state before the next), for scout→tailor-style
dependency chains — both under one appRunId. Pass `model`/`harness` (or
`adapter`) to select the runner for every spawn.
- **`app_status {appRunId}`** — returns the run's session descriptors + its
workflow runs. As of the sequential-run work, `app_status` LAZILY reconciles:
if the stored run status is `running` but every session is terminal, it
reports `ended` (with a synthesized `endedAt`) — so concurrent runs are also
observed to conclude instead of hanging forever at `running`.
- **`app_stop {appRunId}`** — kills the run's sessions and marks it terminal.
- **`app_list`** — installed apps with a run-history summary (includes
`adapter`/`harness`/`model` when set on the run).
- `app_uninstall` / `app_unapply` / `app_list_applied` / `app_catalog` /
`app_skill_get` / `app_artifact_get` — installation/uninstall and catalog.
**Terminal states:** run-level status is `running | stopped | ended | failed`.
A session is terminal at its descriptor status ∈ `ended|exited|done|failed|
stopped|killed`. The UI's "is it done" check should accept the run wrapper
(`ended`/`done`/`endedAt`) OR any terminal session status — never only the
wrapper, since a completed run may still read `running` until reconciliation.
## The app-scoped data plane (app_data_*)
Generic `fs-*` tools are **workspace-rooted** — unsafe/incompatible for
installed-app storage. Apps persist through the app-scoped surface, anchored to
the installed app's **data dir** (`InstalledApp.dataDir`, default `<dir>/data`,
persisted in `~/.agentproto/apps.json`) with **path-traversal protection**:
`resolveAppDataPath` rejects absolute paths, drive-letter prefixes, and any
`..`/symlink escape; every failure returns an MCP error envelope containing
`traversal`.
**Data dir ≠ source dir.** `app_install {dir, dataDir?}` (CLI: `agentproto app
install <appDir> --data-dir <path>`) points the plane at a directory outside
the source tree — that's where multi-GB generated output belongs. Precedence:
explicit `dataDir` → the previously installed one (a bare re-install never
moves data) → APP.md `data: {dir}` (relative to the app dir) → `<dir>/data`.
`app_list` shows it. Resolution rule for an app-relative path:
1. resolves under `dataDir`;
2. under the default layout (`<dir>/data`) a leading `data/` is the legacy
spelling and is dropped — `data/trips/x.json` ≡ `trips/x.json`;
3. if the path (or its top-level folder) exists only under the source `dir`
(a pre-dataDir install), it resolves there — reads find it, writes update it
in place, `app_data_list` merges both views. New paths land under `dataDir`.
Address files at the new base (`trips/<id>/brief.json`, not
`data/trips/...`). Reserved for later: a `store.sqlite` in `dataDir` behind an
`app_data_query` tool — not there yet.
- **`app_data_read {appId, path}`** → `{appId, path, exists, content}` —
`.json` paths parse `content`; others return raw text. `exists:false` when
absent. Never leaks the host absolute path (returns the relative `path`).
- **`app_data_write {appId, path, content}`** → `{appId, path, size}` —
JSON-stringified (pretty) for `.json`; `{text}` otherwise. mkdir -p parent,
atomic tmp+rename. Best-effort, never crashes.
- **`app_data_list {appId, dir?}`** → `{appId, dir, entries:[{name,type,size}]}` —
missing dir → empty entries (not an error).
- **`app_data_migrate {appId, force?}`** → one-time import of a legacy data
shape into the durable layout; idempotent via `data/state.json` (`force`
re-runs). Reports `{migrated, jobCount, dossierCount, skippedFolders, alreadyMigrated?}`.
**Durable layout convention** (adapt to the app; the Job Application Kit uses,
all relative to the data dir):
```
<dataDir>/jobs/<jobId>.json
<dataDir>/search/<searchRunId>.json
<dataDir>/rankings/<rankingRunId>.json (+ rankings/latest.json for the list)
<dataDir>/state.json
<dataDir>/applications/<jobId>/{application.json, job.json, cv.json, cover.md, form-answers.md}
```
Normalize legacy records on migrate: ensure `id` === `jobId` (both present) and
derive `applyUrl` from `url` when missing. Durable files SURVIVE browser reload;
localStorage is for harmless preferences only, never as the source of truth.
## Serving the UI (the bridge)
`agentproto app serve <appDir>` (or the equivalent in-process host) serves
`<appDir>/.agentproto/ui/index.html` with a `window.McpApp` bridge whose
`callTool` POSTs to the daemon's `/mcp` endpoint (same-origin JSON
`/__agentproto/tool-call` route bridged by the serve host). The in-page JS
dispatches through a wrapper:
```js
window.McpApp.connect().then(bridge => {
callTool = bridge.callTool
// appData migrate → list → read, then render
})
```
App UI tool calls go through `app_tool_call {appId, tool, args}` (enforced by
the `ui.tools` allowlist), so every tool the UI uses must be allowlisted in
APP.md. If the bridge is absent the UI should fall back to a standalone/mock
mode instead of crashing.
### Display mode (the ⤢ toggle)
The injected bridge also mounts a floating fullscreen toggle, placed clear of
the host's chrome (`hostContext.safeAreaInsets`) and themed from
`hostContext.theme`. It shows **only** for modes the host advertises in
`hostContext.availableDisplayModes` — Claude Desktop advertises none and has
its own native control, so nothing is drawn there; Codex advertises
`fullscreen`. The bridge logs `[mcp-app] displayMode= … availableDisplayModes= …`
plus the host-context key names, which is the first thing to read when a
toggle doesn't appear.
The controller is `window.McpApp.displayMode` (also on the object `connect()`
resolves to): `get()`, `available()`, `request(mode)`, `onChange(cb)`,
`mountToggle(el?)`. An app with its own header opts the floating button out
and places it itself:
```html
<meta name="agentproto-display-toggle" content="none"> <!-- or "optimistic" -->
<script>
const app = await window.McpApp.connect() // connect({displayToggle:"none"}) also works
app.displayMode.mountToggle(document.querySelector("header"))
</script>
```
`optimistic` shows the button against a host that advertises nothing but may
still honour the request, and hides it permanently on the first refusal.
Restyle via `--agentproto-dm-bg/-fg/-border/-top/-right`.
## Recipe — build/wire an app UI against durable data (minimal)
1. Author the app bundle under `<appDir>/.agentproto/` (APP.md + agents +
workflows + ui/index.html). Use `defineApp().emit(dir)` if authoring in TS.
2. `app_install {dir}` then `app_apply {appId}`.
3. In the UI, on bridge connect, call `app_data_migrate {appId}` once, then
`app_data_list`/`app_data_read` to render datasets from disk (NOT
localStorage). Allowlist `app_data_*` in APP.md `ui.tools`.
4. Wire user-facing model/harness pickers into `app_run` args (`model`,
`harness`) — they're mirrored onto the run and observable via `app_list`/
`app_status`.
5. For scout→tailor: call `app_run {sequence:["scout","tailor"], model, harness}`
and poll `app_status` until `ended` (or any session terminal).
## Test an installed app (the smoke path)
1. Serve it: `agentproto app serve <appDir>` and open the printed URL.
2. Fresh load runs `app_data_migrate` once → dashboard populates from
`data/...` on disk — confirms recovery/migration (not a zero dashboard).
3. From devtools assert path-traversal is rejected:
`callTool("app_tool_call",{appId,tool:"app_data_read",args:{path:"../etc/passwd"}})` → `traversal` error.
4. Assert the model/harness controls surface and that a Full Pipeline run
reaches a terminal `ended` state (not stuck `running`).
5. Headless: `app_data_migrate`/`app_data_*` are covered by
`packages/runtime/src/__tests__/app-data.test.ts`; `app_run` sequencing +
terminal reconciliation by `app-tools.test.ts`. Run
`pnpm --filter @agentproto/runtime test`.
## Do / don't
- **Do** persist via `app_data_*`; path-traversal it.
- **Do** keep UI tool calls allowlisted in APP.md.
- **Do** treat a run done when wrapper OR any session is terminal.
- **Don't** treat `app_data_*` like generic fs-tools (they're app-scoped).
- **Don't** use localStorage as the data source of truth.
- **Don't** run `app_run` agents concurrently when the flow is dependency-ordered
— use `sequence`.