AIP-53: APP.md — app/v1 (agent app bundle + UI surface)
A markdown + frontmatter format for bundling one or more AIP-42 agents with the AIP-15 workflows they run, plus an optional static UI surface, into one shippable unit — `.agentproto/APP.md` on disk, `.agentapp` as a packaged, checksummed distributable. Defines the emitted bundle layout, the `window.McpApp` bridge contract a UI surface consumes, the tools-allowlist enforcement a host applies to UI-originated calls, and the three runtime modes (host / bridge / standalone) a UI's client library resolves between.
| Field | Value |
|---|---|
| AIP | 53 |
| Title | APP.md — app/v1 (agent app bundle + UI surface) |
| Status | Draft |
| Type | Schema |
| Domain | apps.sh |
| Requires | AIP-14 (TOOL), AIP-15 (WORKFLOW), AIP-34 (WORKSPACE), AIP-42 (AGENT) |
| Resources | ./resources/aip-53 — ADAPTER.md, APP.schema.json |
| Reference Impl | @agentproto/app-kit |
Abstract
app/v1 names and fixes the contract for an app: the smallest
shippable unit that couples one or more AGENT.md (AIP-42)
agents with the WORKFLOW.md (AIP-15) workflows they run,
optionally alongside a static UI surface, into a single bundle a host can
install, run, and expose. On disk an app is a directory carrying
.agentproto/APP.md — a root index manifest listing every agent/workflow
ref the app bundles — plus the referenced AGENT.md / WORKFLOW.md
files themselves and, when the app ships one, a ui/ static surface.
Packaged for distribution, an app becomes a single .agentapp file: a
tar.gz with a manifest.json carrying an aggregate SHA-256 over every
bundled file's contents.
This AIP also fixes the UI surface contract: the static-file layout
under .agentproto/ui/, the window.McpApp bridge object a host injects
so the UI can call tools, the tools allowlist a host enforces against
every UI-originated call, and the three runtime modes (host, bridge,
standalone) a UI's client library resolves between so the same
index.html runs unmodified whether embedded in an MCP-Apps panel,
served standalone by the reference CLI, or opened bare with no daemon at
all.
Motivation
Before this AIP, "an agent that does something end-to-end and shows you the result" had no canonical shape. Three things existed in isolation:
-
Agents and workflows composed only by convention. AGENT.md (AIP-42) declares a
workflows[]ref list and WORKFLOW.md (AIP-15) declareskind: agentsteps that ref an agent back — but nothing validated that the two sides of that reference actually resolved against each other inside one shipped bundle. A workflow could reference an agent the bundle never included; an agent could list a workflow nobody shipped. -
No shippable unit. A multi-agent capability (search jobs, tailor a CV, hand back a dossier) lived as a scatter of files a host had to assemble by hand — there was no single directory a host could point
installat and get a validated, runnable whole. -
UI surfaces had no bridge contract. Several daemon-side app UIs shipped ad hoc (
packages/runtime/src/*-app.tsin the reference implementation: session panels, a terminal panel, an agents overview) each hand-wiring its own way of calling back into the daemon. Nothing named the shape of that callback object, so every UI reinventedwindow.McpApp-equivalent plumbing, and nothing enforced which tools a given UI was allowed to call — an embedded HTML surface had the same reach as the agent that installed it. -
No portable package format. Handing an app to another host or another machine meant copying a directory by hand, with no integrity check and no metadata describing what was inside.
app/v1 fixes all four: the attachment invariant (agents and
workflows must reference each other, checked at bundle-definition time),
the bundle layout (.agentproto/agents/<id>/AGENT.md,
.agentproto/workflows/<id>/WORKFLOW.md, .agentproto/ui/, plus
optional .agentproto/artifact/ and .agentproto/skill/ surfaces), the
.agentapp package format (a checksummed, portable single file), and
the UI bridge contract (window.McpApp, the tools allowlist, the
three runtime modes). It composes AIP-42 and AIP-15 rather than
replacing either — an app is a cross-linked collection of already-valid
agent and workflow handles, not a new agent or workflow primitive.
Design principles
-
Bundle, don't redefine. An app's agents and workflows are ordinary
defineAgent/defineWorkflowhandles.defineAppadds exactly one new invariant — that the two sides reference each other — and otherwise carries the handles through unchanged. Field-level validation is AIP-42's and AIP-15's job, not this AIP's. -
The attachment invariant is bidirectional. Every agent's
workflows[]ref MUST resolve to a workflow the app bundles, and every bundled workflow MUST be referenced by at least one agent'sworkflows[]. A dangling ref and an orphaned workflow are both errors — "attached" means checkable both ways, not merely "shipped in the same folder." -
An agentproto-owned base, not a shared namespace. Emitted manifests live under
.agentproto/, never under the bare.agents/convention AGENT.md (AIP-42) uses for standalone agents. An app's agents don't squat the shared collection root a workspace's own hand-authored agents live in. -
The UI is a static surface, not a server.
ui.htmlis a single documentemitwrites verbatim to.agentproto/ui/index.html. There is no build step in the contract itself — a hand-written single-file UI is first-class. Aui/source project (Vite, TypeScript) that builds into.agentproto/ui/is a separate, optional convention (see Theui/source-project convention), not a requirement. -
Every UI call is allowlisted, never ambient. A UI surface never gets the reach of the agent that installed the app. Every tool a UI may call is named explicitly in
ui.tools[]at author time; a host MUST reject any call to a tool id absent from that list before dispatch, regardless of what the UI's JavaScript asks for. -
The bridge degrades gracefully, never silently wrong. A UI's client library resolves, in order, to
hostmode (an injectedwindow.McpApp),bridgemode (a same-origin dev-proxy route), orstandalonemode (caller-supplied mock handlers) — and never throws merely because no host is present. It only rejects when a present bridge itself fails, which is a real error rather than an absent one. -
Packaging is verifiable, not just portable. A
.agentappcarries an aggregate SHA-256 over every bundled file's contents, computed in a fixed sorted order. Unpacking recomputes and compares before trusting the contents — a corrupted or tampered bundle is refused, not silently installed.
Specification
File location
An app directory is any directory containing a valid .agentproto/APP.md:
job-application-kit/
.agentproto/
APP.md ← this AIP — root index, always present
agents/
job-scout/AGENT.md ← AIP-42, one per bundled agent
job-tailor/AGENT.md
workflows/
job-hunt/WORKFLOW.md ← AIP-15, one per bundled workflow
ui/
index.html ← optional — the UI surface (this AIP)
base-cv.json ← app-specific data, outside .agentproto/
dossiers/…When the app has a home WORKSPACE (AIP-34), its root
manifest is written as a sibling of .agentproto/:
<dir>/WORKSPACE.md ← AIP-34, only when the app declares `workspace`
<dir>/.agentproto/…Authors do not hand-author this layout directly in the common case — it
is what defineApp({...}).emit(dir) writes (see
Emitted layout). Hand-editing an emitted APP.md (or
its referenced AGENT.md / WORKFLOW.md files) is legal as long as the
result still round-trips through the frontmatter shape below and the
attachment invariant.
The defineApp standard signature
defineApp(definition: AppDefinition): AppHandle
interface AppDefinition {
agents?: (AgentEntry | AgentHandle)[] // REQUIRED (non-empty) UNLESS `ui` is present
workflows?: WorkflowHandle[]
attach?: DoctypeHandle[] // any other AIP handle to carry along
workspace?: WorkspaceHandle | WorkspaceShorthand
id?: string // machine id — absent = anonymous bundle
name?: string
version?: string // default "0.1.0" when `id` is set
description?: string // becomes the APP.md body
requires?: string[] // app ids this app depends on
ui?: AppUiDefinition
artifact?: AppArtifactSurface
skill?: AppSkillSurface
artifacts?: AppArtifactDecl[]
dev?: AppDevDefinition
data?: AppDataDefinition // default data-dir hint for the app_data_* plane
category?: string // coarse grouping surfaced in catalogs/trees
}
interface AgentEntry {
agent: AgentHandle // an AIP-42 defineAgent() handle
body?: string // the AGENT.md body / system prompt (composed if absent)
}
/**
* Where the app's durable data lives (the `app_data_*` plane — see
* [The app data plane](#the-app-data-plane-app_data_)). `dir` is a path
* RELATIVE to the app dir — `"data"` means `<appDir>/data`, which is also
* what a host uses when the hint is absent. It is a hint: an explicit
* `dataDir` passed at install time overrides it, and the resolved absolute
* path is persisted on the installed-app record.
*/
interface AppDataDefinition {
dir?: string
}
interface AppUiDefinition {
html: string // the full document — required
title?: string
description?: string
tools?: string[] // the ui_tool_call allowlist
port?: number // preferred local port hint for `app serve`
csp?: {
connectDomains?: string[]
resourceDomains?: string[]
}
}agents MUST be a non-empty array UNLESS ui is present, in which case
it MAY be empty or omitted entirely — a UI-only app ships a ui
block and no agent behaviour, and is a first-class conforming app (this
codifies packages/app-kit/src/define-app.ts:49-52's shipped UI-only
carve-out, pinned by define-app.test.ts's "UI-only apps (zero
agents)" suite, and matches the five daemon builtin panels —
sessions-panel, agents-overview, bureau-sessions, session-story,
live-session — that ship as agents: [] in
packages/apps/src/index.ts:14-20). Each entry is either a bare
AgentHandle (no body — the prompt composes from the agent's structured
fields) or an AgentEntry pairing the handle with the body string that
becomes its AGENT.md's body (its system prompt) — AIP-42
frontmatter is .strict() and carries no systemPrompt field, so the
free-text prompt only ever lives in an AGENT.md body.
workflows, attach, ui, artifact, skill, artifacts, dev,
data, category, and workspace are all optional. An app with agents
and nothing else is still a conforming app, as is a UI-only app with an
empty agents array.
data, when set, is the default data-dir hint for the app_data_*
plane (see The app data plane) — a
string path relative to the app dir; it MUST be non-empty when present.
category is a freeform, non-empty-when-present coarse grouping label
(e.g. "book") surfaced in host catalogs and trees; hosts MUST NOT
validate it against a fixed enum, so new categories can appear without a
schema release. Both are carried verbatim into APP.md frontmatter by
emit and back by loadAppHandle.
Conformance rules
-
Non-empty agents, unless
ui.agentsMUST contain at least one entry UNLESSuiis present. A UI-only app — auiblock with an empty or omittedagentsarray — is conforming. An app with zero agents AND nouiblock is not a conformingAppDefinition—defineAppMUST refuse it. This rule codifies shipped behaviour:packages/app-kit/src/define-app.ts:49-52accepts(agents empty || omitted) && ui === undefinedas the only rejection case, its test suite pins UI-only apps as expected behaviour (packages/app-kit/src/__tests__/define-app.test.ts, "UI-only apps (zero agents)"), and the five daemon builtin panels (packages/apps/src/index.ts:14-20) ship exactly that way. -
Unique agent ids, unique workflow ids. Two bundled agents MUST NOT share an
id; two bundled workflows MUST NOT share anid.defineAppMUST refuse a definition that violates either. -
The attachment invariant, both directions. For every agent in the bundle, every ref in that agent's
workflows[]MUST resolve to theidof a workflow the same app bundles. For every workflow the app bundles, at least one agent'sworkflows[]MUST reference it.defineAppMUST refuse a definition violating either direction — a dangling ref (agent → unbundled workflow) or an orphan (bundled workflow → no referencing agent) are both non-conforming. For a UI-only app (rule 1) the check is trivially satisfied: no agents reference anything and no workflows may be bundled alongside them (a workflow with no referencing agent would be an orphan), so a conforming UI-only app bundles zero workflows. -
idnon-empty when present.idMUST be a non-empty string when set. Settingidis what makes the app installable/discoverable —emitwrites it intoAPP.md's identity block, and a host's install path (app_install) refuses a handle with noid. An app MAY omitidand remain a valid, emittable, runnable-in-process bundle; it simply cannot be installed by id. -
ui.htmlnon-empty whenuiis present.defineAppMUST refuse auiblock whosehtmlis missing or empty. -
dev.launchnon-empty whendevis present. Same shape of rule for the optional dev-launch config. -
artifact.path/skill.pathnon-empty when present, and both MUST be absolute filesystem paths —emitcopies from them at write time and a relative path has no defined base to resolve against. -
Version default. When
idis set andversionis omitted,defineAppMUST defaultversionto"0.1.0". An anonymous bundle (noid) carries no default version. -
No I/O at
defineApptime. Constructing anAppHandleviadefineAppMUST NOT perform filesystem or network I/O. I/O is confined toemit(dir)(write) andloadAppHandle(dir)(read) — the mirror pair every doctype in the AIP series keeps separate from its puredefineXconstructor.
Emitted layout
emit(dir) (equivalently, a conforming host's install/write path) MUST
write:
<dir>/WORKSPACE.md (only when `workspace` is set)
<dir>/.agentproto/agents/<id>/AGENT.md (one per agent — id with any
`@owner/` prefix stripped)
<dir>/.agentproto/workflows/<wf.id>/WORKFLOW.md (one per workflow — a workflow
MAY be referenced by more
than one agent, so it is
written once and shared)
<dir>/.agentproto/ui/index.html (only when `ui` is set)
<dir>/.agentproto/artifact/index.html (only when `artifact` is set —
copied from `artifact.path`)
<dir>/.agentproto/skill/ (only when `skill` is set —
copied recursively from
`skill.path`, which MUST
contain a SKILL.md)
<dir>/.agentproto/APP.md (root index — ALWAYS written)Every AGENT.md / WORKFLOW.md is the handle's frontmatter serialized
to YAML plus the entry's body (agent) or description (workflow) as the
markdown body. Because a defineWorkflow handle is pure data, its
WORKFLOW.md needs no separate entry: module — the manifest is the
workflow.
APP.md's frontmatter is:
schema: app/v1
id: "@owner/app-id" # only when `id` is set
name: "Human name" # only when `name` is set
version: "0.1.0" # always present — defaulted per rule 8
description: "…" # only when `description` is set
agents:
- id: "@owner/agent-id"
path: .agentproto/agents/agent-id/AGENT.md
workflows:
- id: workflow-id
path: .agentproto/workflows/workflow-id/WORKFLOW.md
workspace: "@owner/workspace-id" # only when `workspace` is set
requires: ["@owner/other-app"] # only when `requires` is set
ui: # only when `ui` is set
path: .agentproto/ui/index.html
title: "…"
description: "…"
tools: [tool_id, ...]
port: 4173
csp: { connectDomains: [...], resourceDomains: [...] }
artifact: { path: .agentproto/artifact/index.html, title: "…", description: "…" }
skill: { path: .agentproto/skill, title: "…", description: "…" }
artifacts: [{ type: "…", description: "…" }]
dev: { launch: [{ name: "…", runtimeExecutable: "…", runtimeArgs: [...], port: 4173, url: "…" }] }
data: { dir: "data" } # only when `data` is set — app_data_* hint, relative to <dir>
category: "book" # only when `category` is set — freeform catalog groupingagents[] and workflows[] entries are { id, path } refs, path
relative to dir. The body of APP.md is the app's description
(empty string when absent). Every field the definition omitted MUST be
omitted from the written frontmatter — the emitter MUST NOT write
undefined/null placeholders.
Loading (loadAppHandle)
A conforming loader reading <dir>/.agentproto/APP.md back into an
AppHandle MUST:
- Reject frontmatter whose
schemais not exactly"app/v1". - Reject
agents/workflowsfrontmatter that is not an array of{ id: string, path: string }. - Resolve every
{ id, path }ref againstdir(relative paths are joined todir; absolute paths pass through) and load eachAGENT.mdvia the AIP-42 manifest reader and eachWORKFLOW.mdvia the AIP-15 loader. - When
workspacenames an id, read the sibling<dir>/WORKSPACE.mdand parse it via the AIP-34 manifest reader. - Re-run the loaded agents/workflows through the same construction path
as
defineApp— so the attachment invariant (rule 3, above) re-validates on every load exactly as it did at authoring time. A hand-editedAPP.mdwhose refs drifted from itsAGENT.md/WORKFLOW.mdfiles MUST fail to load, the same way a baddefineApp({...})call fails to construct.
A loader failure MUST name the offending path (which file, which ref) — a bare "invalid app" is not sufficient diagnostic detail.
Implementer's guide
For step-by-step guidance on building an app/v1 conformant host —
bundle validation, emit/load, install/run lifecycle, the UI bridge,
and the data plane — see
./resources/aip-53/draft/ADAPTER.md.
The AIP only defines the contract; the resource doc walks an
implementer through the projection. The APP.md frontmatter shape is
machine-checkable against
./resources/aip-53/draft/APP.schema.json.
Example — minimal, from a shipped app
The following is the reference implementation's own
job-application-kit example app (abridged), rendered per the emitted
layout above:
---
schema: app/v1
id: '@agentproto/job-application-kit'
name: Job Application Kit
version: 0.1.0
description: >-
Search job boards, rank results against a candidate profile, tailor CVs and
cover letters per top match.
agents:
- id: '@agentproto/job-scout'
path: .agentproto/agents/job-scout/AGENT.md
- id: '@agentproto/job-tailor'
path: .agentproto/agents/job-tailor/AGENT.md
workflows:
- id: job-hunt
path: .agentproto/workflows/job-hunt/WORKFLOW.md
ui:
path: .agentproto/ui/index.html
title: Job Application Kit
tools:
- app_run
- app_status
- app_stop
- agent_output
- app_list
- app_data_read
- app_data_write
- app_data_list
- app_data_migrate
---
Search job boards, rank results against a candidate profile, tailor CVs and
cover letters per top match.job-scout's AGENT.md frontmatter carries workflows: [{ ref: job-hunt }];
job-hunt's WORKFLOW.md has one kind: agent step referencing
@agentproto/job-scout and one referencing @agentproto/job-tailor — the
attachment invariant holds in both directions.
The .agentapp package format (agentapp/v1)
An emitted app directory is the installed form. .agentapp is the
distributable form: one self-contained, checksummed file — packaged and
unpacked by the reference agentproto app pack / agentproto app unpack
CLI verbs.
pack
Given an app directory <appDir> containing a valid
.agentproto/APP.md, pack MUST:
- Parse
APP.md's frontmatter into bundle metadata:id(falling back to aslugfield, then the literal string"app"),version(falling back to"0.1.0"),name,description, theagents[]/workflows[]id lists, and the basenames of anyui/artifact/skillpaths. - Walk the entire
<appDir>tree and collect every regular file as a path relative to<appDir>, skipping any directory namednode_modulesor.gitat any depth — aui/source project ships both, and neither belongs in a shipped app. - Sort the collected file list lexicographically by path.
- Compute one SHA-256 hash over the concatenated bytes of every file's contents, fed in the sorted order — a single aggregate digest, not a per-file manifest of digests.
- Write a
manifest.json(schema below) at the bundle root. - Tar (
tar -czf) the app directory's contents (not the directory itself, so extraction yieldsmanifest.json+.agentproto/+ any loose files at the top level) into the output.agentappfile. Without an explicit output path, the default is<safe-id>-<version>.agentappin the current directory, where<safe-id>strips a leading@and replaces every run of characters outside[A-Za-z0-9._-]with-.
The manifest.json schema:
interface AgentAppManifest {
format: "agentapp/v1"
id: string
name?: string
version: string
description?: string
agents: string[] // agent ids
workflows: string[] // workflow ids
ui?: string[] // basenames of ui-related files, if any
files: string[] // every bundled file's path, sorted
fileCount: number
totalSize: number // bytes, sum of every file's size
sha256: string // the aggregate digest (rule 4 above)
createdAt: string // ISO-8601 timestamp
agentprotoVersion: string // compatibility range, e.g. ">=0.1.0"
}manifest.json itself is excluded from files[] and from the SHA-256
computation — it describes the bundle, it is not part of it.
unpack
Given a .agentapp file, unpack MUST:
- Extract the archive to a fresh temporary directory.
- Require a
manifest.jsonat its root; a bundle without one is not a valid.agentappand MUST be refused. - Require
manifest.format === "agentapp/v1"; any other value MUST be refused with the actual format named in the error. - Recompute the aggregate SHA-256 over the files listed in
manifest.files, in the order listed, and compare againstmanifest.sha256. A mismatch MUST refuse the unpack — the bundle is corrupted or was tampered with, and unpack MUST NOT silently restore it anyway. - Only once the digest verifies, remove
manifest.jsonfrom the extracted contents (a bundle artifact, not part of the app) and move the remaining contents into the destination directory. Without an explicit destination, the default is<safe-id>-<version>in the current directory.
The UI surface contract
Static layout
A UI surface is a static file tree rooted at .agentproto/ui/, with
index.html as its entry point. Conformance rules:
index.htmlis the entry point. A conforming host serving a UI surface MUST treat.agentproto/ui/index.htmlas the document to return for the root path and for any directory-style request.- Asset paths MUST be relative. A UI ships as a portable static
tree — it does not know in advance whether it will be served at
http://127.0.0.1:<port>/, mounted under a host panel's own path scheme, or opened viafile://. Absolute root-relative paths (/app.js) are non-conforming;./app.jsorapp.jsare required. - No server-side routing. A UI surface is static files, not an
application server — there is no route table for a host to consult.
A UI that wants client-side navigation MUST use hash-based routing
(
#/jobs/42) rather than the History API'spushState, since a static file server has no fallback rule to reroute a deep-linked path back toindex.html. - A
ui/source directory is a distinct, optional thing from the emitted.agentproto/ui/— see Theui/source-project convention.
The window.McpApp bridge
A conforming host that renders a UI surface — embedding it in a panel,
or serving it standalone — MUST inject a global window.McpApp object
of this shape before the page's own scripts run:
interface McpAppGlobal {
connect(): Promise<McpAppBridge>
}
interface McpAppBridge {
callTool(name: string, args?: Record<string, unknown>): Promise<unknown>
updateModelContext(ctx: Record<string, unknown>): Promise<unknown>
openLink(url: string): Promise<unknown>
onTeardown(cb: () => void): void
}callTool(name, args)invokes a tool by id and resolves with the raw MCPtools/callresult envelope ({ isError?, structuredContent?, content? }) — unwrapping is the UI client library's job (see Result unwrapping), not the bridge's.updateModelContext(ctx)lets the UI push context back to whatever model/session is driving the host. A host with nothing to persist it to MUST treat this as a no-op, not an error.openLink(url)opens an external URL. A conforming implementation MUST NOT navigate the UI's own frame away from the app — it opens a new context (tab/window).onTeardown(cb)registers a callback fired when the UI's session ends (page unload, or the host tearing down the panel).
A UI author MUST NOT assume window.McpApp is present — it is absent
outside host mode. Either of two conforming access patterns satisfies
this: a client library implementing the resolution order below, or an
explicit presence check with graceful degraded rendering when the bridge
is missing (the hand-written single-file pattern).
Runtime modes
A UI surface's client library resolves a connection through exactly
three modes, in this order, and MUST NOT throw merely because a mode
isn't available — only standalone failing to find a handler for a
called tool, or a present bridge itself failing, are real errors:
| Mode | When | How callTool resolves |
|---|---|---|
host | window.McpApp is already present (an MCP-Apps panel injected it, or a conforming serve verb did) | Passed straight through to the injected bridge. Terminal — host mode MUST NOT downgrade. |
bridge | No window.McpApp, but a same-origin POST /__agentproto/tool-call route exists | The library POSTs { name, args } to the route and unwraps the JSON response as the MCP result envelope. |
standalone | Neither of the above | The library dispatches to a caller-supplied mock handler for the named tool; a tool with no registered handler rejects. |
Resolution from bridge to standalone MUST be lazy and optimistic: a
client library MUST NOT perform an upfront network probe to decide
between them. It constructs a bridge-mode connection immediately and
only discovers whether the route is real on the first callTool —
a network failure, an HTTP 404, or a non-JSON response on that first
call permanently downgrades the connection to standalone and replays
that same call against the standalone handlers. Once a bridge
connection has had one call succeed, a later failure on that same
connection MUST be treated as a real error (the route existed and then
broke) — not a fresh signal to downgrade.
host mode connections are terminal from the start: a present
window.McpApp is authoritative and a rejection from its own
connect() MUST propagate as a real error, not trigger a fallback.
Result unwrapping
A client library's callTool<T>(name, args) MUST unwrap the raw MCP
tools/call envelope by this precedence:
envelope.isError === true→ reject with an error naming the tool and mode, carrying the concatenated text of everycontentblock as detail.envelope.structuredContentpresent → return it asT.- Else, if
envelope.contentis a non-empty array, take the first entry'stext:JSON.parseit and return the parsed value if it parses, else return the raw text. - Else (no content) → return an empty object.
This precedence is a frozen wire contract: structuredContent
always wins over content when both are present, and a single text
block that happens to be JSON is transparently parsed. A caller
supplies the expected result shape via the type parameter; the library
does not runtime-verify it matches.
The dev-bridge route
The reserved route POST /__agentproto/tool-call is the same-origin
(bridge-mode) surface a UI's client library targets by default. A
conforming server exposing this route MUST:
- Accept a JSON body
{ name: string, args?: object }; reject any other shape with HTTP 400. - Forward
{ name, arguments: args ?? {} }as an MCPtools/callthrough its own connection to the daemon (or equivalent tool dispatcher). - Return the raw MCP result envelope with HTTP 200 — including when
isErroris true. AnisErrorresult is a resolved call, not a transport failure; it MUST NOT be mapped to a non-2xx status, so a UI's unwrapping code (rule 1, above) behaves identically whether the error arrived viapostMessage(a host panel) or via this HTTP route. - Return a non-2xx status only for a genuine transport/dispatch
failure (bad request shape → 400; the daemon/tool-dispatcher
unreachable → 502; the tool call itself threw → 502) — never for a
well-formed
isErrorresult.
Tools allowlist enforcement
Every UI-originated tool call MUST be checked against the allowlist the
app declared at author time (ui.tools[]) before dispatch. A
conforming gateway (the reference implementation's app_tool_call
MCP tool, and its HTTP twin) MUST:
- Resolve the installed app by id; refuse with an error if the app
isn't installed or has no
uiblock. - Reject the call — without dispatching it — if the requested tool id
is absent from that app's
ui.tools[], naming the tool id and the full allowlist in the error. - Only once allowlisted, dispatch: a tool id prefixed
imported:<alias>/<toolName>MUST route through the named imported MCP server; every other id dispatches through the host's own registered tools.
This is the enforcement boundary that makes rule 5 of
Design principles real: the UI's reach is
exactly ui.tools[], never the installing agent's full tool surface,
and the check happens on every call — there is no session-level or
one-time grant that widens it.
The ui/ source-project convention
Separate from the emitted .agentproto/ui/ static tree, an app
directory MAY additionally contain a ui/ source project — a Vite +
TypeScript (or equivalent) project that builds into
.agentproto/ui/. This is a convention the reference tooling supports,
not a requirement this AIP imposes on every app:
- A hand-written single-file
.agentproto/ui/index.htmlwith noui/source project and no build step remains fully first-class — the common case for a simple dashboard. - When a
ui/source project exists (<appDir>/ui/package.jsonwith a"scripts.build"), the referenceagentproto app buildverb runs it and the output lands in.agentproto/ui/. Aui/project with no build script, or noui/project at all, is a no-op success forbuild— not an error. - When the
ui/project additionally has a"scripts.dev", the referenceagentproto app devverb runs that dev server directly (Vite HMR, its own port) alongside a bridge-only HTTP server (no static files, just the/__agentproto/tool-callroute with real CORS headers, since the dev server and the bridge are different origins). Aui/project without a dev script has nothing to hot-reload — the static build is served withagentproto app serveinstead.
A ui/ source tree's node_modules/ and .git/ are excluded from
.agentapp packaging (see pack) regardless of whether the
project ships a build step — they are never part of the shipped app.
The app data plane (app_data_*)
An installed app owns a durable, app-scoped data directory — the
plane its UI and agents persist state through (app_data_read,
app_data_write, app_data_list), distinct from the app's source
tree, which is whatever install/unpack wrote and is never
agent-writable by this contract. This section is normative. It codifies
shipped behaviour: packages/runtime/src/app-data.ts (its header
comment states the resolution rule verbatim, and
resolveAppDataPath/locateAppDataPath/atomicWrite implement it),
wired as MCP tools by registerAppDataTools in the same file.
Addressing
Every data-plane tool addresses files with an app-relative path —
never an absolute one. How a relative path p resolves is the contract
app UIs and agents rely on, and a second host MUST reproduce all four
rules (this is app-data.ts's locateAppDataPath, :79-113):
- Primary root is the app's persisted
dataDir. The installed-app record carriesdataDir, defaulted to<dir>/datafor records that predate the field. A custom root is set explicitly at install time (app_install { dataDir },agentproto app install --data-dir) or hinted byAPP.md'sdata: { dir }(relative to the app dir). The resolved absolute path is persisted on the installed-app record. - Legacy
data/spelling collapses under the default layout. WhendataDirIS<dir>/data(the default layout), a leadingdata/segment is redundant and dropped for reads and writes alike:data/trips/x.jsonandtrips/x.jsonname the same file. A customdataDiris a fresh, intentional layout — no collapse. The traversal guard still runs on the collapsed result, sodata/../../xis rejected exactly as../xwould be. - Legacy root fallback. If
p— or its top-level folder — does not exist underdataDirbut does under the app's sourcedir, it resolves underdir: reads find files written by pre-dataDirinstalls, writes update them in place (or land next to their siblings). Move the folder intodataDirand the fallback stops applying. Brand-new paths always land underdataDir. - Listing merges both roots. When the same directory also exists
under the source dir,
app_data_listmerges the two views, with data-dir entries winning on name clashes; listing.yields the data dir only (or the source dir while no data dir exists yet).
A conforming host MUST surface which root a path resolved under in its
diagnostics (legacy: true vs the data root), so an author can tell a
pre-dataDir file from a durable one.
The traversal guard
Both roots get the same defence, and it is the only path-escape defence
that matters. A conforming host MUST reject, with a named
traversal error (reference: AppPathTraversalError, code
APP_PATH_TRAVERSAL), any app-relative path that:
- is absolute (starts with
/or a drive root); - carries a Windows drive-letter prefix (
C:…), so a drive-based host can never misread it as a relative segment; - after
resolvecollapses./..segments, does not land on the root itself or strictly below it — a relative../../secretor a nesteda/../../bclimbs out of the app dir into unrelated host files, and containment-by-prefix comparison againstroot + separatoris the reliable check.
Additionally, for an existing target the host MUST realpath the
resolved path and assert it still lands inside the root — so a symlink
planted inside the data dir cannot point the plane outside it
(assertRealInside). Without that check the string-level guard above is
defeated by a single symlink. Both checks run on every tool call —
read, write, and list alike — there is no once-per-session grant.
The reference implementation rejects the offending call with the original relative path named in the error; a bare "invalid path" is not sufficient diagnostic detail (mirroring the loader rule above).
The atomic-write guarantee
app_data_write MUST write atomically: create parent directories
(mkdir -p), write the payload to a temporary sibling of the target
(<target>.tmp.<pid> in the reference implementation), then rename
it over the target. A reader therefore observes either the previous
contents or the complete new contents — never a partial write, even
under concurrent readers. There is no non-atomic write path to the data
plane. A missing data directory is created lazily by the write path, not
an error.
.json paths are a convention, not a separate store: reads of paths
ending in .json return the parsed value (falling back to raw text when
the bytes do not parse); writes JSON-serialize pretty-printed. Every
other path carries raw string content verbatim.
The state ledger (carve-out)
The reference implementation ships three additional tools rooted in the
same data dir — app_state_append, app_state_get, app_state_list —
implementing an append-only, ULID-ordered event ledger folded to
snapshots (app-state.ts). One access rule is normative here:
app_state_append MUST NOT be granted to spawned agent sessions and
MUST NOT be added to a UI's ui.tools[] allowlist by default — a UI
that needs to append (e.g. recording a human approval) must explicitly
list it. The other two are read-only and follow the normal allowlist
rules.
app_data_migrate (importing legacy ranked-jobs.json + dossiers/*
files into the durable shape) is reference behaviour, informative here —
a conforming host MAY ship its own migration story or none.
A future app_data_query tool over a store.sqlite inside the data dir
is reserved, not specified — one reason the data dir is separable from
the source tree.
Reference tooling (informative)
The reference implementation exposes an app's lifecycle through five CLI
verbs under agentproto app <verb>, none of which is itself normative
(the wire/file contracts above are); they are named here because the
UI-mode table and the dev-bridge route above assume their existence:
| Verb | Role |
|---|---|
pack | Directory → .agentapp (see pack) |
unpack | .agentapp → directory, SHA-256 verified (see unpack) |
serve | Serves .agentproto/ui/ standalone with an injected host-mode bridge to a running daemon's MCP endpoint — port resolution: --port > APP.md's ui.port hint > OS-assigned |
build | Builds a ui/ source project into .agentproto/ui/ (no-op success when there's nothing to build) |
dev | Runs a ui/ source project's own dev server plus a bridge-only server for live window.McpApp calls |
The daemon-side MCP tool surface is likewise reference behavior built on
top of the file contract this AIP fixes, not itself part of the
normative surface — a conforming alternative host MAY expose a different
install/run API as long as it honors the allowlist enforcement and
bridge contract above for any UI it renders. For orientation, the
reference daemon currently registers 22 app_* tools (grep
packages/runtime/src/app-tools.ts, app-data.ts, app-external.ts,
app-state.ts for the authoritative set):
| Tool | Role |
|---|---|
app_install | loadAppHandle(dir) → validate → persist an installed-app record |
app_list | List installed apps, each with a summary of its app_run history |
app_list_applied | List apps applied to a scope |
app_uninstall | Remove an installed app (refuses while runs are live) |
app_run | Spawn a session per selected agent |
app_status | Fan out an app_run's sessions plus the app's workflow runs |
app_stop | Kill every session in an app_run and mark the run ended |
app_apply / app_unapply | Bind / unbind an installed app to a scope |
app_tool_call | Allowlist-checked tool dispatch for UI-originated calls |
app_catalog | List catalog apps, including the builtin panels |
app_artifact_get | Fetch an app's artifact surface |
app_skill_get | Fetch an app's skill surface |
app_data_read / app_data_write / app_data_list / app_data_migrate | The data plane (see The app data plane) |
app_state_append / app_state_get / app_state_list | The app state ledger (see the data-plane carve-out) |
app_external_list / app_external_read | Read-only access to declared externalReadRoots outside the app dir |
agent_output (agent sessions' structured output) is part of the agent
toolset, not the app_* family, though app UIs commonly allowlist it.
Rationale
Why compose AIP-42/AIP-15 rather than define a new agent-collection primitive? ASSEMBLY (AIP-24) already names "a collective of agents." An app is a narrower, different concern: not how agents coordinate at runtime, but how a shippable, installable unit packages agents with the workflows they run and (optionally) a UI. An app's agents don't need an ASSEMBLY's voting/council/hierarchy modes — they need to round-trip through disk as one bundle a host can validate and hand to a user. Reusing AIP-42/AIP-15 handles directly, rather than re-declaring agent/workflow shape inside APP.md, keeps the two concerns orthogonal: an app can bundle agents that also participate in an ASSEMBLY elsewhere.
Why the attachment invariant in both directions? A one-directional
check (agent → workflow) would allow orphaned workflows to accumulate
silently in a bundle nobody's agent actually drives — dead weight that
still ships in every .agentapp. Checking the reverse direction too
(every bundled workflow has a referencing agent) keeps a bundle's
contents legible: everything present is reachable from an agent.
Why is .agentapp's checksum an aggregate hash over sorted
concatenated bytes, not a per-file manifest of digests? A per-file
manifest ({path: sha256} for every file) is more granular — it can
name which file changed on a mismatch — but for an app-sized bundle
(dozens of files, not thousands) that granularity isn't worth the
larger manifest and the extra hashing bookkeeping. One aggregate digest
over a fixed sort order is simpler to implement correctly on both
sides and is sufficient to answer the one question unpack needs
answered: is this bundle exactly what pack produced.
Why node_modules/.git exclusion instead of a .agentappignore
file? A ui/ source project's node_modules can be gigabytes and is
always regenerable (npm install); a .git directory is never part of
a shipped app under any circumstance. Both are unconditional excludes
because there is no scenario where either belongs in a .agentapp —
adding a general ignore-file mechanism for a two-item, always-true
exclusion list is unwarranted complexity. A future revision MAY add
general ignore rules if a real second case emerges.
Why does host mode never downgrade but bridge mode does? A
present window.McpApp is an explicit signal from a host that chose to
inject it — if its connect() then fails, that is the host
malfunctioning, not "no bridge here." Silently falling back to
standalone in that case would hide a real host-side bug behind mock
data. bridge mode, by contrast, starts from no signal either way
(no injected global, just an assumption that the well-known route might
exist) — so discovering it doesn't is exactly the "no bridge, use
standalone" case the mode ladder exists to handle gracefully.
Why must isError results return HTTP 200 from the dev-bridge
route? The whole point of the runtime-mode contract is that a UI's
callTool unwrapping code behaves identically across host and
bridge mode. A host panel's postMessage-based tools/call reply
resolves (doesn't reject the transport promise) even when the tool
itself reports isError: true — only Result
unwrapping rule 1 turns that into a rejected
callTool promise, at the library layer, not the transport layer.
Mapping isError to a non-2xx HTTP status in bridge mode would make
the two mode's error-handling diverge exactly where they need to agree.
Backward compatibility
Not applicable — this AIP introduces a new spec canonizing bundle,
package, and UI-bridge conventions that already shipped as working code
(@agentproto/app-kit's defineApp/emitApp/loadAppHandle, the CLI's
app pack/unpack/serve/build/dev verbs, @agentproto/app-client's
connectMcpApp, and the daemon's app_* tool family). No prior AIP
claimed the app/v1 or agentapp/v1 schema strings or the
.agentproto/ bundle layout, so there is nothing to migrate from or
supersede.
Security considerations
-
The UI tools allowlist is the primary security boundary. A UI surface runs arbitrary, often third-party-authored, JavaScript inside a page the host renders. Without allowlist enforcement (see Tools allowlist enforcement), that JavaScript would inherit the full tool reach of whatever installed the app. Hosts MUST enforce the allowlist on every call, server-side — a client-side-only check is not a boundary, since the page's own script is exactly what a hostile or compromised UI controls.
-
imported:<alias>/<toolName>widens the surface a UI can reach into an external MCP server. A host wiringcallImportedToolMUST apply the same allowlist discipline to imported-tool ids as to native ones —ui.tools[]is the single allowlist for both namespaces, not two separate trust levels. -
A CORS-permissive dev bridge is a local-only surface.
app serve's andapp dev'sAccess-Control-Allow-Origin: *is deliberate for a bare local-loopback tool-call route with no cookies or session state — but implementers reusing this route pattern behind anything other than127.0.0.1MUST NOT keep the wildcard origin, since it would let any page on the internet POST tool calls through a reachable bridge. -
csp.connectDomains/csp.resourceDomainsare declared, not enforced by this AIP.AppUiDefinition.cspnames the domains a UI intends to reach; a conforming host SHOULD translate this into an actual Content-Security-Policy header/meta tag when serving the UI, but this AIP does not itself mandate the enforcement mechanism — only that the declaration exists and is carried throughemit/loadAppHandleunmodified. -
.agentappintegrity is checksum, not signature. The aggregate SHA-256 (seeunpack) detects corruption and accidental tampering but is not a cryptographic signature — it proves the bundle matches what its ownmanifest.jsonclaims, not that the claimedid/version/agentprotoVersioncame from a trusted publisher. A registry distributing third-party.agentappfiles SHOULD layer a signing scheme on top; this AIP does not define one. -
The data plane's traversal guard is a security boundary, not a convenience.
app_data_*paths resolve strictly inside the app's data dir (or legacy source dir); hosts MUST enforce the resolve-and-realpath containment checks of The traversal guard on every call — the string-level check alone is defeated by a single symlink. -
app_installvalidates workflow tool ids, not agent tool ids. The reference install path cross-checks everyWORKFLOW.mdtoolstep's id against the host's dispatchable tools (surfacing all missing ids at once), but explicitly does NOT validate an agent's own declaredtools[]— those are the chosen agent adapter's business (e.g. workspace tools likeread_fileamastra-agentadapter resolves independently). Implementers relying onapp_installsucceeding MUST NOT read that as a guarantee every agent-declared tool ref is resolvable;unvalidatedAgentToolson the install result names exactly which refs were skipped. -
extends-style cross-app trust is out of scope for v1. Unlike AGENT.md'sextends, an app has no inheritance mechanism in this AIP —requires[]names a dependency relationship (another app must be applied to the same scope first) but does not pull in or execute that app's code. There is therefore no parent-SHA-pinning concern analogous to AIP-42's for apps in v1.
Open questions
-
Signing and provenance.
.agentappintegrity today is a checksum, not a signature (see Security considerations). A future revision may define a signing envelope, likely composing with whatever AIP-19 SECRETS-adjacent identity primitive is chosen for publisher keys. -
UI framework conventions beyond Vite. The
ui/source-project convention (package.jsonwithscripts.build/scripts.dev) is deliberately tool-agnostic in principle but the reference tooling'sdetectPackageManagerand dev-proxy env var (AGENTPROTO_BRIDGE_URL) were designed against a Vite-shaped dev server. Whether other bundlers need first-class support, or whether the convention already generalizes, is open. -
Multi-UI apps.
AppDefinition.uiis singular — one UI surface per app. Whether an app should be able to declare multiple named UI entry points (e.g. an admin view and an end-user view) is deferred until real demand appears. -
requires[]version constraints. Todayrequiresis a bare list of app ids with no version range — an applied dependency satisfies it regardless of version. Whether this needs^/~-style constraints (asAGENT.md extendspinning is flagged as an open question in AIP-42) is open.
See also
- AIP-14 — TOOL.md — the tool contract
ui.tools[]allowlists against and agents declaretools[]from - AIP-15 — WORKFLOW.md — the workflow primitive an app bundles; the other half of the attachment invariant
- AIP-34 — WORKSPACE.md — the optional home workspace an
app MAY declare via
workspace - AIP-42 — AGENT.md — the agent primitive an app
bundles;
AgentEntry.bodyfills the AGENT.md body this AIP's frontmatter carries no equivalent field for - AIP-24 — ASSEMBLY.md — the multi-agent coordination primitive this AIP deliberately does not duplicate (see Rationale)
- AIP-19 — SECRETS — where a future
.agentappsigning scheme would anchor publisher identity (see Open questions)
Resources
Supporting artifacts for AIP-53. Links open the file on GitHub — markdown and JSON render natively in GitHub's viewer. Browse the full resource tree →
AIP-52: ADAPTER — agentadapter/v1 (outward framework bridge)
The outward mirror of AIP-30 DRIVER — the contract for projecting an agentproto handle (TOOL, AGENT, PROCESSOR) into a foreign framework runtime (Mastra, AI SDK, LangChain, a CLI) so it runs as a native citizen there. Names the pattern, fixes conformance rules (frontmatter precedence, schema fidelity, degradation reporting, no credentials), and canonizes the existing resources/ADAPTER.md implementer-guide convention.
AIP-54: REF — ref/v1 (typed cross-AIP artifact reference)
One typed, cross-AIP reference shape — `{aip, id, version?}`, serialized as an `aip://<aip>/<id>[@version]` URI — that any AIP artifact can use to point at any other AIP artifact, resolved through per-family AIP-43 registries joined by a RefCatalog, plus the `ws://<collection>/<body>` scheme for pointing at resources in the world (files, URLs, identities, transactions) — superseding AIP-27, which owned both jobs separately. Replaces every per-primitive reference mechanism (AIP-18's collection-scoped `refKind`, inline doctype handles, bare id strings) with one discriminated, resolvable, loudly-failing reference.