Documentation source
Interactivity
Four canonical shapes — Call, Form, View, Link — that unify tool calls, structured input, surface projections, and external navigation across Amble and MCP.
# Interactivity — the Four-Shape Model
Amble speaks one interactivity vocabulary across four surfaces: internal UI, MCP tool results, MCP elicitations, and MCP Apps bundles. Every request for user input, every surface projection, and every external hand-off maps to one of four shapes.
- **Call** (`CallSpec`) — a tool invocation. Already Amble's canonical tool: `ToolDefinition` in `features/tools/registry.ts`.
- **Form** (`FormSpec`) — structured input. Collects typed values from a human or an agent. Same schema renders in-app and via MCP `elicitation/create`.
- **View** (`ViewSpec`) — a surface projection. A composition of blocks that renders a record, a tool output, or a bespoke page.
- **Link** (`LinkSpec`) — an external hand-off. OAuth authorization, destructive-action confirmation, or share-URL. Acknowledgement-only — the caller knows the click happened, not what was entered.
See [ADR-0006 — Four-shape interactivity model](/docs/adr/0006-four-shape-interactivity-model) for the rationale and registry design.
## Key Concepts
### Canonical schemas (`features/interactivity/`)
Each shape has a Zod v4 schema and a generic React renderer.
| Shape | Schema | Renderer | Data registry |
| ----- | ----------------------------------------------- | --------------------------------- | ------------------------------------------------ |
| Call | `features/tools/registry.ts` (`ToolDefinition`) | n/a — agents / chat call directly | `getToolBySlug(slug)` |
| Form | `features/interactivity/form-spec.ts` | `<FormSpecRenderer>` | `registerFormSpec(id, spec)` / `getFormSpec(id)` |
| View | `features/interactivity/view-spec.ts` | `<ViewSpecRenderer>` | `registerViewSpec(id, spec)` / `getViewSpec(id)` |
| Link | `features/interactivity/link-spec.ts` | `<LinkSpecRenderer>` | `registerLinkSpec(id, spec)` / `getLinkSpec(id)` |
### FormSpec field kinds (v1 + v2)
**v1 kinds** — `text`, `number` (with `displayHint: "slider"` option), `boolean`, `date`, `select`, `relation` (entity picker), `object` (nested FormSpec), `array` (repeated FormSpec), `custom` (escape hatch that delegates to a registered `block:{key}`).
**v2 kinds** (additive, `version` stays at `1`) —
- `entity` — combobox autocomplete for one or many entities of a given type.
- `entity-or-text` — pick an existing entity OR enter free text. Fuzzy lookups populate a dropdown; the raw string survives when the user doesn't pick.
- `file` — drag-drop + browse + URL-paste primitive with optional mime / size constraints and multi mode.
- `connection` — pick an `agent_connections` row filtered by capability(ies).
**v2 enhancements on existing kinds** — `number` slider gains `lowLabel` / `highLabel` / `suffix`; `select` adds a third option source (`{ source: "computed", computeId }`) resolved at render time via `registerComputedOptions(id, cb)`; every kind gains an optional `visibleWhen: VisibilityPredicate` that hides the field AND relaxes the submit-time Zod schema so hidden-required fields don't block submit.
### Visibility predicate
```ts
type VisibilityPredicate =
| { field: string; equals: unknown }
| { field: string; in: unknown[] }
| { field: string; notEquals: unknown }
| { allOf: VisibilityPredicate[] }
| { anyOf: VisibilityPredicate[] };
```
See `evaluateVisibilityPredicate()` in `features/interactivity/form-spec.ts`.
### Three rendering tiers
Tool inputs and outputs resolve via the same three-tier pattern as entity cards:
| Tier | Resolver | When it fires |
| --- | --- | --- |
| formspec | `getFormSpec("tool:<slug>")` / `getViewSpec("tool:<slug>")` | Author registered a FormSpec / ViewSpec via `registerFormSpec` / `registerViewSpec` |
| generic | `GenericToolForm` (derived from Zod input schema) / `GenericToolOutput` (JSON dump) | Nothing registered |
Tool input / output tier resolution happens inline at `features/tools/components/tool-page-shell.tsx` and `features/tools/server/resolve-specs.ts` — `getFormSpec("tool:<slug>")` / `getViewSpec("tool:<slug>")` are called directly, falling through to the generic renderers when nothing is registered. The dedicated `resolveToolInputTier` / `resolveToolOutputTier` helpers that previously lived in `features/tools/lib/resolve-tier.ts` were deleted on 2026-05-15 (zero callers; the historical `custom` input tier was retired by FormSpec v2 and all 15 custom tools now ship as FormSpec). Entity-card resolution still uses the sync `resolveCardTier` at `features/entities/components/entity-card/registry.ts` — see `_backlog/idea-entity-card-plugin-tier-disconnect.md` for the planned async migration.
### FormFieldKind ↔ FieldType mapping
FormSpec's `FormFieldKind` (13 kinds) stays separate from entity-schema `FieldType` (12 kinds) — they serve different axes (form-input vocabularies vs entity data storage semantics). Bridge via:
| FieldType | FormFieldKind |
| --- | --- |
| `text` | `text` |
| `number` | `number` |
| `boolean` | `boolean` |
| `date` | `date` |
| `enum` | `select` with static options |
| `url` | `text { format: "url" }` |
| `email` | `text { format: "email" }` |
| `phone` | `text { format: "phone" }` |
| `media` | `file` |
| `relation` | `relation` or `entity` |
| `object` | `object` |
| `array` | `array` |
Unifying the two vocabularies was considered and rejected — see `documents/work/2026-04-24-formspec-v2/decisions.md`. Document the mapping, don't merge.
### Custom component extension points
Three granularities of custom-code escape hatch — use the narrowest that works:
1. **Field-level** — `FormFieldCustom { kind: "custom", blockKey }`. The FormSpec stays declarative for the other fields; one block slot renders a dynamic grid / custom widget (see `quick-score`'s N×M scoring grid at `features/custom/tools/quick-score/scoring-grid-block.tsx`).
2. **Block-level** — `registerSlot(slotKey("block", "my-block"), { component })`. Any ViewSpec / tool output / entity detail that references the block type picks up the component.
3. **Entity-level** — `registerEntityCard` / `registerEntityDetailView` for a whole entity type.
### Slot registry (`lib/ui-registry.ts`)
Unified slot registry backs all component overrides. Canonical SlotKinds:
- `block` — block-type renderers (field-card, stat, list, table, etc)
- `entity-card`, `entity-detail`, `entity-detail-action` — per-entity-type overrides
- `field-display` — per-FieldType display renderer (`field-display:{type}`)
- `surface` — per-surface-type renderer (list, detail, dashboard, etc)
- `call-spec`, `form-spec`, `view-spec`, `link-spec` — optional component overrides keyed by spec `id`; data registries live in `features/interactivity/registry.ts`
Retired by FormSpec v2: the `tool-input` **registration** SlotKind and the `registerToolUI()` API — tool inputs now flow exclusively through FormSpec. Earlier cutovers retired `field-input` (Wave E5/E6 — entity-field inputs moved to `features/schemas/registry-bootstrap.ts`; later reintroduced under ADR-0015 with new `displayType`-keyed semantics) and `tool-output` (Wave E4/E6 — tool outputs moved to ViewSpec + `block:tool-output-{slug}`). The `tool-input` / `tool-output` _string tokens_ are not gone everywhere: they survive as members of the `BlockSurfaceSlot` taxonomy in `lib/ui-registry/block-contract.ts`, kept for back-compat to describe _where_ a Block is mounted (not how it registers). Same reconciliation in [Tool System](/docs/features/tool-system).
### Migrating a legacy `input-form.tsx` to FormSpec v2
The 8 complex custom tools that previously shipped hand-rolled `input-form.tsx` files all migrated as part of FormSpec v2. Recipe for any similar migration:
1. Create `features/custom/tools/<slug>/form-spec.ts` — declarative `FormSpec` with one field per input.
2. Replace `<SliderField>` with `FormFieldNumber { displayHint: "slider", lowLabel?, highLabel?, suffix? }`.
3. Replace `<EntityOrTextInput>` with `FormFieldEntityOrText`.
4. Replace `<EntityPicker>` with `FormFieldEntity` (use `multi: true` for multi-picker).
5. Replace `<ImageDropzoneUploader>` with `FormFieldFile` (plus `mimeTypes: ["image/*"]`).
6. Replace model-dependent `<Select>` pairs with one `FormFieldSelect { source: "computed", computeId }` and register the callback via `registerComputedOptions`.
7. If the UX is inherently dynamic (N×M grid, tree editor) — keep it as `FormFieldCustom { blockKey }` and register the block via `registerSlot(slotKey("block", "..."), { component })`.
8. In `features/custom/tools/ui.ts`, swap `registerToolUI(slug, { InputForm })` for `registerFormSpec("tool:<slug>", spec)`.
9. Delete the old `input-form.tsx`.
10. Add a `form-spec.test.ts` asserting shape + Zod derivation.
## How It Works
### Reference consumers (landed in Wave E1/E2)
- **`submitResponse` UI** — `features/responses/components/response-form.tsx` takes a `criteria_set` and builds a `FormSpec` via `features/responses/lib/criteria-set-to-form-spec.ts`. Numeric dimensions render as sliders (`displayHint: "slider"`); text commentary as multiline `<Textarea>`. Relation-rank dimensions use `{ kind: "custom", blockKey: "response-relation-rank" }` — FormSpec v1 does not have a native rankable-relation kind.
- **Entity-detail bento** — `features/entities/components/entity-bento.tsx` builds a single `ViewSpec` from the entity type's field layout and renders via `<ViewSpecRenderer>` with a `renderBlock` callback delegating to the existing `BlockRenderer` chrome. `view.blocks` jsonb → ViewSpec adaptation lives in `features/interactivity/view-spec-adapter.ts`.
### Tool input (Wave E3 narrowed)
6 simple custom tools have FormSpec declarations at `features/custom/tools/<slug>/form-spec.ts`, registered via `registerFormSpec("tool:<slug>", spec)` in `features/custom/tools/ui.ts`. Callers (`tool-page-shell`, `tool-block`, `session-page-client`) prefer the FormSpec path; fall back to the legacy `getToolUI(slug).InputForm` for the 8 complex tools (the set named in the `tool-input` SlotKind bullet above); final fallback is `GenericToolForm` derived from the tool's Zod `inputSchema`.
### Tool output (Wave E4)
All 11 custom tool outputs migrate to `ViewSpec` + a bespoke custom block per tool. `registerSlot` writes components directly at `block:tool-output-<slug>` — `BLOCK_TYPES` stays a closed platform union and is not polluted per-venture. `tool-call-card`, `tool-page-shell`, `tool-block` prefer `getViewSpec("tool:<slug>")` and fall back to the generic JSON renderer. The unified slot resolver (`lib/ui-registry/resolve-slot.ts`) reads spec-payloads via `findActiveSlotBinding` (`lib/ui-registry/queries.ts`) and dispatches to `<ViewSpecRenderer>` / `<FormSpecRenderer>` / `<LinkSpecRenderer>` per `payload.kind`. The legacy iframe-based plugin tier was retired in ADR-0019.
### MCP elicitation (Waves C1/C2)
- **Outbound** (`features/mcp/elicitation/outbound.ts`) — builds `elicitation/create` payloads from a `FormSpec` or `LinkSpec` when a session hits `waiting_human` state with an MCP caller. Fires at tool-call boundaries, never mid-stream.
- **Inbound** (`features/mcp/elicitation/inbound.ts` + `features/chat/components/elicitation-form.tsx`) — parses incoming `elicitation/create`, reifies it as `InboundElicitation`, renders via `<FormSpecRenderer>` (form mode) or `<LinkSpecRenderer>` (URL mode) inside the chat message list.
### MCP Apps UI (Waves D0/D1/D2)
- **Publisher** (`features/mcp/apps-ui/publisher.ts` + `app/ui/view/[id]/route.ts`) — tool results whose `output` is ViewSpec-shaped AND whose tenant opted in via `mcp_apps_publish_enabled` setting get `_meta.ui = "<base>/ui/view/<id>.html?token=<signed>"`. Token is HMAC-SHA256 signed via `MCP_APPS_SIGNING_SECRET`. Invalid/missing tokens return 404 (not 401) to avoid existence leak.
- **Consumer** (`features/mcp/apps-ui/consumer.ts` + `features/blocks/components/mcp-app-embed.tsx`) — incoming tool results with `_meta.ui` render as a sandboxed iframe (`sandbox="allow-scripts allow-same-origin"`, CSP `frame-ancestors` allows the signed host origin) wired through the typed postMessage bridge.
- **Bridge** (`features/mcp/apps-ui/bridge.ts`) — origin-pinned + source-pinned postMessage adapter with `__mcp` discriminator and UUIDv4 call IDs. Host exposes `tools/call` to the widget; widget receives `ui/notifications/tool-result`.
## API Reference
### `<FormSpecRenderer>`
```tsx
import {
FormSpecRenderer,
type FormSpec,
type FormSpecValues,
} from "@/features/interactivity";
<FormSpecRenderer
spec={formSpec}
initialValues={defaults as FormSpecValues | undefined}
onSubmit={(values: FormSpecValues) => save(values)}
disabled={isSubmitting}
/>;
```
### `<ViewSpecRenderer>`
```tsx
import { ViewSpecRenderer, type ViewSpec } from "@/features/interactivity";
<ViewSpecRenderer
spec={viewSpec}
context={{ output, input }} // arbitrary data piped into each block's context prop
renderBlock={(block) => ...} // optional — bypass the registry lookup for pre-resolved blocks
/>
```
### `<LinkSpecRenderer>`
```tsx
import { LinkSpecRenderer, type LinkSpec } from "@/features/interactivity";
<LinkSpecRenderer
spec={linkSpec}
onOpen={() => track("link.opened")}
onConfirmed={() => track("link.confirmed")}
/>;
```
### Data registries
```ts
import {
registerFormSpec,
getFormSpec,
registerViewSpec,
getViewSpec,
registerLinkSpec,
getLinkSpec,
} from "@/features/interactivity";
registerFormSpec("onboarding", onboardingFormSpec);
const spec = getFormSpec("onboarding"); // or getFormSpec("tool:<slug>")
```
## For Agents
Agents interact with this substrate through their normal tools:
- **CallSpec** — invoked directly by the model via the AI SDK tool interface.
- **FormSpec** — when a tool's `execute()` returns `status: "waiting_human"`, the session-executor transitions to `waiting_human` and emits `elicitation/create` if the caller is an MCP client; otherwise the chat UI renders the FormSpec inline.
- **ViewSpec** — a tool's `output` reaching a MCP client with tenant opt-in gets published as `ui://view/<id>` and rendered in the client's UI.
- **LinkSpec** — OAuth / destructive-confirm / share flows. The response is always `{ acknowledged: true }`; FormSpec-style value collection requires a separate Form round.
Tools declare `outputSchemaZod: z.ZodType` to power `structuredContent` validation and MCP `outputSchema` emission. See [Tool System](/docs/features/tool-system) for details.
## Design Decisions
### Why four shapes, not more?
The four shapes emerged from scanning every interactive surface in Amble — tool calls, tool inputs, tool outputs, entity detail, view editor, field inputs, OAuth flows, destructive confirms, chat elicitations. All of them projected onto one of: invoke, collect, project, hand-off. ADR-0006 rejected a 5th "notification" shape because it collapses into CallSpec (tool that records an event) or a LinkSpec (user gets a URL).
### Why the `tool-input` SlotKind was kept, then retired
FormSpec v1 could not express: dynamic arrays with add/remove UI and per-row controls, file upload fields that produce durable URLs, runtime data fetches (e.g., `/api/agent-connections`) that gate submit, model-metadata-driven conditional visibility, or derived submit labels (`"Publish"` vs `"Prioritize 3 Items"`). The legacy `registerToolUI()` registry stayed alive for the custom tools that depended on at least one of these — until **FormSpec v2** added all of them, at which point the `tool-input` registration SlotKind and the `registerToolUI()` API were retired. The `tool-input` / `tool-output` _string tokens_ remain only in the `BlockSurfaceSlot` taxonomy (`lib/ui-registry/block-contract.ts`) for back-compat.
### Why hard-cutover `field-input` and `tool-output`?
- `field-input:` had one caller subsystem (`features/schemas/`) which Wave E5 converted to a module-local `Record<FieldType, Component>`. No external consumers.
- `tool-output:` had two callers (`tool-call-card`, `tool-block`) with a simple shape — Wave E4's ViewSpec path covered 100% of existing renders.
Both retired with zero deprecation window. The 7-tool `tool-input` gap is genuinely different — it requires a schema expansion, not just a wiring change.
### Why signed URLs for `ui://` publishing?
A cookieless-subdomain origin (e.g. `ui.sprinter.ai`) is the long-term defense against session-cookie leakage when a user manually opens a published bundle URL. Short-term, HMAC-SHA256 signed tokens (15-min expiry) bound to the Amble origin protect against third-party embedding. Cookieless subdomain is captured as a P1 follow-up in `followups.md`; the first external bundle must not ship until that lands.
### Why MCP Apps publishing defaults OFF?
Tenant opt-in via `tenant_settings.mcp_apps_publish_enabled`. Default `false`. Without explicit opt-in, `_meta.ui` is never attached to tool results. Prevents tenant data from being exposed to MCP clients that render UI bundles without explicit authorization. Matches the pattern Amble already uses for Obsidian Interop and other cross-surface surfaces.
## Related Modules
- [Tool System](/docs/features/tool-system) — CallSpec canonical home; `outputSchemaZod` emission for `structuredContent`
- [View System](/docs/features/view-system) — ViewSpec is the canonical projection of a `view.blocks` jsonb row
- [Block System](/docs/features/block-system) — ViewSpec delegates each block to `getSlot("block:{type}")`
- [Chat](/docs/features/chat) — elicitation renderer inlined into the message list
- [Response System](/docs/features/response-system) — `submitResponse` is the reference FormSpec consumer
- [ADR-0006](/docs/adr/0006-four-shape-interactivity-model) — architectural rationale and rejected alternatives