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:ToolDefinitioninfeatures/tools/registry.ts. - Form (
FormSpec) — structured input. Collects typed values from a human or an agent. Same schema renders in-app and via MCPelicitation/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 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 anagent_connectionsrow 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
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 |
Resolver helpers at features/tools/lib/resolve-tier.ts (resolveToolInputTier, resolveToolOutputTier) mirror resolveCardTier at features/entities/components/entity-card/registry.ts. The historical custom input tier was retired by FormSpec v2 — all 15 custom tools now ship as FormSpec.
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:
- Field-level —
FormFieldCustom { kind: "custom", blockKey }. The FormSpec stays declarative for the other fields; one block slot renders a dynamic grid / custom widget (seequick-score's N×M scoring grid atfeatures/custom/tools/quick-score/scoring-grid-block.tsx). - Block-level —
registerSlot(slotKey("block", "my-block"), { component }). Any ViewSpec / tool output / entity detail that references the block type picks up the component. - Entity-level —
registerEntityCard/registerEntityDetailViewfor 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 overridesfield-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 specid; data registries live infeatures/interactivity/registry.ts
Retired by FormSpec v2: tool-input (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) and tool-output (Wave E4/E6 — tool outputs moved to ViewSpec + block:tool-output-{slug}).
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:
- Create
features/custom/tools/<slug>/form-spec.ts— declarativeFormSpecwith one field per input. - Replace
<SliderField>withFormFieldNumber { displayHint: "slider", lowLabel?, highLabel?, suffix? }. - Replace
<EntityOrTextInput>withFormFieldEntityOrText. - Replace
<EntityPicker>withFormFieldEntity(usemulti: truefor multi-picker). - Replace
<ImageDropzoneUploader>withFormFieldFile(plusmimeTypes: ["image/*"]). - Replace model-dependent
<Select>pairs with oneFormFieldSelect { source: "computed", computeId }and register the callback viaregisterComputedOptions. - If the UX is inherently dynamic (N×M grid, tree editor) — keep it as
FormFieldCustom { blockKey }and register the block viaregisterSlot(slotKey("block", "..."), { component }). - In
features/custom/tools/ui.ts, swapregisterToolUI(slug, { InputForm })forregisterFormSpec("tool:<slug>", spec). - Delete the old
input-form.tsx. - Add a
form-spec.test.tsasserting shape + Zod derivation.
How It Works
Reference consumers (landed in Wave E1/E2)
submitResponseUI —features/responses/components/response-form.tsxtakes acriteria_setand builds aFormSpecviafeatures/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.tsxbuilds a singleViewSpecfrom the entity type's field layout and renders via<ViewSpecRenderer>with arenderBlockcallback delegating to the existingBlockRendererchrome.view.blocksjsonb → ViewSpec adaptation lives infeatures/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) — buildselicitation/createpayloads from aFormSpecorLinkSpecwhen a session hitswaiting_humanstate 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 incomingelicitation/create, reifies it asInboundElicitation, 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 whoseoutputis ViewSpec-shaped AND whose tenant opted in viamcp_apps_publish_enabledsetting get_meta.ui = "<base>/ui/view/<id>.html?token=<signed>". Token is HMAC-SHA256 signed viaMCP_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.uirender as a sandboxed iframe (sandbox="allow-scripts allow-same-origin", CSPframe-ancestorsallows the signed host origin) wired through the typed postMessage bridge. - Bridge (
features/mcp/apps-ui/bridge.ts) — origin-pinned + source-pinned postMessage adapter with__mcpdiscriminator and UUIDv4 call IDs. Host exposestools/callto the widget; widget receivesui/notifications/tool-result.
API Reference
<FormSpecRenderer>
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>
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>
import { LinkSpecRenderer, type LinkSpec } from "@/features/interactivity";
<LinkSpecRenderer
spec={linkSpec}
onOpen={() => track("link.opened")}
onConfirmed={() => track("link.confirmed")}
/>;Data registries
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()returnsstatus: "waiting_human", the session-executor transitions towaiting_humanand emitselicitation/createif the caller is an MCP client; otherwise the chat UI renders the FormSpec inline. - ViewSpec — a tool's
outputreaching a MCP client with tenant opt-in gets published asui://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 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 keep tool-input SlotKind?
FormSpec v1 cannot 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"). 7 custom tools depend on at least one of these, so we kept the legacy registry alive rather than ship an incomplete migration. FormSpec v2 (captured in followups.md) restores these and lets the SlotKind retire.
Why hard-cutover field-input and tool-output?
field-input:had one caller subsystem (features/schemas/) which Wave E5 converted to a module-localRecord<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 — CallSpec canonical home;
outputSchemaZodemission forstructuredContent - View System — ViewSpec is the canonical projection of a
view.blocksjsonb row - Block System — ViewSpec delegates each block to
getSlot("block:{type}") - Chat — elicitation renderer inlined into the message list
- Response System —
submitResponseis the reference FormSpec consumer - ADR-0006 — architectural rationale and rejected alternatives
Data Table
Generic, reusable data table component with inline editing, keyboard navigation, virtualization, and domain adapters
Field Rendering
The unified field-rendering substrate at features/schemas/ — one FieldDefinition shape, one set of inputs and displays, one slot-keyed extension seam. Used by entity edit forms, criteria responses, field cards, FormSpec runtime, and tool output displays.