Install
npx skillscat add agentproto/ts/agentproto-apps Install via the SkillsCat registry.
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 baseagentproto 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 itAPP.md frontmatter (schema: app/v1, id, agents: [{id,path}],workflows: [{id,path}], ui: {path, title, tools: [...]}, optionaldata: {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
.gitignoreignores.agentproto/by default
(it targets agent-scoped scratch). For a first-class app bundle the.agentproto/dir IS the deliverable —git add -f <appDir>/.agentprototo
commit it.
The app lifecycle (daemon verbs)
app_install {dir}— validates the app (loadAppHandle), cross-checks
every WORKFLOW.mdtoolstep id against the daemon's registered tools
(reports ALL missing at once), and checks the agent adapter resolves. Upserts
byappId. Re-installing refreshes the record.app_apply {appId, scopeId?, dir?}— makes the app's capabilities
available in a scope; installs first ifdirgiven and not already installed;
validatesrequiresdeps are applied. Idempotent.app_run {appId, agents?, sequence?, prompt?, cwd?, model?, harness?, adapter?}—
spawns one live session per agent under a freshappRunId.agentsruns 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. Passmodel/harness(oradapter) 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_statusLAZILY reconciles:
if the stored run status isrunningbut every session is terminal, it
reportsended(with a synthesizedendedAt) — so concurrent runs are also
observed to conclude instead of hanging forever atrunning.app_stop {appRunId}— kills the run's sessions and marks it terminal.app_list— installed apps with a run-history summary (includesadapter/harness/modelwhen 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 containingtraversal.
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:
- resolves under
dataDir; - under the default layout (
<dir>/data) a leadingdata/is the legacy
spelling and is dropped —data/trips/x.json≡trips/x.json; - 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_listmerges both views. New paths land underdataDir.
Address files at the new base (trips/<id>/brief.json, notdata/trips/...). Reserved for later: a store.sqlite in dataDir behind anapp_data_query tool — not there yet.
app_data_read {appId, path}→{appId, path, exists, content}—.jsonpaths parsecontent; others return raw text.exists:falsewhen
absent. Never leaks the host absolute path (returns the relativepath).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 viadata/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 whosecallTool 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:
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 fromhostContext.theme. It shows only for modes the host advertises inhostContext.availableDisplayModes — Claude Desktop advertises none and has
its own native control, so nothing is drawn there; Codex advertisesfullscreen. 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:
<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)
- Author the app bundle under
<appDir>/.agentproto/(APP.md + agents +
workflows + ui/index.html). UsedefineApp().emit(dir)if authoring in TS. app_install {dir}thenapp_apply {appId}.- In the UI, on bridge connect, call
app_data_migrate {appId}once, thenapp_data_list/app_data_readto render datasets from disk (NOT
localStorage). Allowlistapp_data_*in APP.mdui.tools. - Wire user-facing model/harness pickers into
app_runargs (model,harness) — they're mirrored onto the run and observable viaapp_list/app_status. - For scout→tailor: call
app_run {sequence:["scout","tailor"], model, harness}
and pollapp_statusuntilended(or any session terminal).
Test an installed app (the smoke path)
- Serve it:
agentproto app serve <appDir>and open the printed URL. - Fresh load runs
app_data_migrateonce → dashboard populates fromdata/...on disk — confirms recovery/migration (not a zero dashboard). - From devtools assert path-traversal is rejected:
callTool("app_tool_call",{appId,tool:"app_data_read",args:{path:"../etc/passwd"}})→traversalerror. - Assert the model/harness controls surface and that a Full Pipeline run
reaches a terminalendedstate (not stuckrunning). - Headless:
app_data_migrate/app_data_*are covered bypackages/runtime/src/__tests__/app-data.test.ts;app_runsequencing +
terminal reconciliation byapp-tools.test.ts. Runpnpm --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_runagents concurrently when the flow is dependency-ordered
— usesequence.