Sprinter Docs

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 for the rationale and registry design.

Key Concepts

Canonical schemas (features/interactivity/)

Each shape has a Zod v4 schema and a generic React renderer.

ShapeSchemaRendererData registry
Callfeatures/tools/registry.ts (ToolDefinition)n/a — agents / chat call directlygetToolBySlug(slug)
Formfeatures/interactivity/form-spec.ts<FormSpecRenderer>registerFormSpec(id, spec) / getFormSpec(id)
Viewfeatures/interactivity/view-spec.ts<ViewSpecRenderer>registerViewSpec(id, spec) / getViewSpec(id)
Linkfeatures/interactivity/link-spec.ts<LinkSpecRenderer>registerLinkSpec(id, spec) / getLinkSpec(id)

FormSpec field kinds (v1 + v2)

v1 kindstext, 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 kindsnumber 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:

TierResolverWhen it fires
formspecgetFormSpec("tool:<slug>") / getViewSpec("tool:<slug>")Author registered a FormSpec / ViewSpec via registerFormSpec / registerViewSpec
genericGenericToolForm (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:

FieldTypeFormFieldKind
texttext
numbernumber
booleanboolean
datedate
enumselect with static options
urltext { format: "url" }
emailtext { format: "email" }
phonetext { format: "phone" }
mediafile
relationrelation or entity
objectobject
arrayarray

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-levelFormFieldCustom { 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-levelregisterSlot(slotKey("block", "my-block"), { component }). Any ViewSpec / tool output / entity detail that references the block type picks up the component.
  3. Entity-levelregisterEntityCard / 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: 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:

  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 UIfeatures/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 bentofeatures/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>

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() 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 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-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.

  • Tool System — CallSpec canonical home; outputSchemaZod emission for structuredContent
  • View System — ViewSpec is the canonical projection of a view.blocks jsonb row
  • Block System — ViewSpec delegates each block to getSlot("block:{type}")
  • Chat — elicitation renderer inlined into the message list
  • Response SystemsubmitResponse is the reference FormSpec consumer
  • ADR-0006 — architectural rationale and rejected alternatives

On this page