Skip to content

feat: industry-standard conformance — AG-UI transport + schema-verified A2UI (phase 1) - #79

Merged
adamstankiewicz merged 23 commits into
mainfrom
feat/a2ui-agui-conformance
Aug 31, 2026
Merged

feat: industry-standard conformance — AG-UI transport + schema-verified A2UI (phase 1)#79
adamstankiewicz merged 23 commits into
mainfrom
feat/a2ui-agui-conformance

Conversation

@adamstankiewicz

@adamstankiewicz adamstankiewicz commented Aug 30, 2026

Copy link
Copy Markdown
Owner

What & why

Makes the standards story checkable instead of aspirational, and hardens the a2learn layer into code. Phase 1 of an incremental, verifiable migration — each phase lands only with fixtures proving it.

  • Spec truth from the official package: the A2UI v1.0 schemas come from @a2ui/web_core (the A2UI project's own publication — google/A2UI now redirects to a2ui-project/a2ui), exact-pinned as a devDependency with the lockfile integrity hash as the checksum. Nothing of the upstream spec is committed; a spec bump is an ordinary dependency bump the conformance gate passes judgment on. Our surfaces target the released revision, because interop is with shipped renderers, not the spec repo's HEAD — concretely, the emitted action event carries the intersection every revision accepts (name + context).
  • The a2learn layer as code (src/lib/a2learn/): a versioned ActivityManifest builder and the A2UI boundary mapper. Three kinds map today (markdown-card, flashcard, step-reveal); fidelity limits are stated, not papered over — the flashcard reveal survives as Tabs and its deck as a horizontal List (renderer-local interactions the catalog does provide), while flip animation and per-card progress stay renderer-side.
  • Real AG-UI transport (/api/pathway/agui): the pathway build as a typed @ag-ui/core run — protocol drift fails tsc.
  • pnpm conformance, folded into ci.yml's verify job: zod-validated fixtures → upstream-schema validation → structural invariants (including Tabs' nested child refs) → golden diffs — with negative controls at every layer, so a vacuously-green validator can't hide: the schema validator must reject an empty message and a malformed component, the structural checker must report duplicate ids, dangling refs (tab refs included), and a missing root, and the zod gate must refuse an unknown kind. A2UI_SUPPORTED_KINDS derives from the dispatch table and must be fixture-covered — support is proven, never asserted.
  • The mapping is functional, not just proven: /api/widget accepts format: "a2ui" and returns the built widget's surface alongside the spec, refusing an unmappable kind with the mapped-kinds list before spending a model call.

Reviewer notes on history: a three-tier demo page (/demo/a2ui) was built mid-review to exercise the mapper visually and retired within this PR once it had served its purpose — it nets out of the squash and never reaches main. Its surface renderer moved up the stack to #101, where its consumer (the composed widget kind) lives. An interim fetch-and-pin scheme for the schemas likewise lived and died inside this PR once the official package proved viable.

Migration phases: 2 = manifest served by find_activity/build_pathway, more kinds one fixture-proven PR each; 3 = interactive kinds via the a2learn catalog (#98/#100 — the composed kind in #101 is the first fruit); 4 = the app renders from its own surfaces (dogfood).

Type of change

  • New/changed seam implementation (a standards-conformant transport + the a2learn boundary layer)

Breaking or contract changes

Additive: a new route, a new library layer, and an additive format option on /api/widget; the app's existing wire format is untouched. The spec schemas update only by bumping the pinned @a2ui/web_core version.

How it was verified

  • pnpm lint and rm -rf .next && npx tsc --noEmit
  • pnpm conformance — all four layers green against the package's schemas; negative controls verified at every layer
  • pnpm test (40 on this branch), pnpm build
  • Live: /api/widget a2ui format returns a 41-component surface for a generated flashcard and refuses drag-sort honestly; the retired demo verified rendering end to end (Playwright) before retiring
  • Review-pass findings fixed and dispositioned (see comment): derived support list, no false correct claims, image sides mapped, protocol-legal 400s, quiet cancel teardown

Authorship

  • I understand every line of this diff
  • Agent-assisted or agent-authored: authored by Claude working interactively with @adamstankiewicz; multi-agent review pass findings + dispositions in the PR comment
  • package.json/pnpm-lock.yaml changes are the feature's own dependencies (@ag-ui/core, ajv, ajv-formats, @a2ui/web_core exact-pinned)

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
interactive-learning-experiences Ready Ready Preview Aug 31, 2026 12:05am

Request Review

@adamstankiewicz

Copy link
Copy Markdown
Owner Author

Pre-merge review pass (multi-agent finders + verification) — findings and dispositions:

Fixed in ba8137b:

  • The dispatch table now is the support list — A2UI_SUPPORTED_KINDS derives from the MAPPERS record (typed on WidgetKind), so claiming a kind without mapping it, or vice versa, is impossible by construction.
  • Flashcard sides render text/markdown/images as separate components — an image-only side is legal per the schema and no longer degrades to a dash; successMessage is carried; no emphasis-wrapping (bold can't span joined lines, and model text may already contain Markdown).
  • completedAction no longer asserts correct: true — both mapped kinds are assesses: false, and the mapper must never report mastery nothing measured. Assessing kinds gain verdicts from real checking when they arrive, gated on the registry flag.
  • Conformance gained a first layer: fixtures must parse against the app's real zod widgetSpec, so goldens prove the mapper against specs the pipeline can produce (this immediately caught the flashcard fixture missing its required successMessage). Also: explicit catalogId assertion (the ajv alias made validation catalog-blind), template-form children resolution, one fail() helper, data-URL import instead of writing into node_modules.
  • AG-UI route: doc comment states the real scope (no profile/persistence yet); 400s emit RUN_STARTED before RUN_ERROR so standard client middleware sees a legal sequence; cancelled consumers get a quiet teardown; dead error branch removed; the a2learn. namespace is owned by the a2learn layer, not the transport.
  • Workflow: permissions, concurrency, timeout-minutes, .nvmrc; package.json em-dash churn reverted.

Deferred, deliberately:

🤖 Generated with Claude Code

@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from ba8137b to 872c436 Compare August 30, 2026 18:30
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from 872c436 to 42267eb Compare August 30, 2026 18:38
@adamstankiewicz
adamstankiewicz changed the base branch from main to feat/find-activity-mvp August 30, 2026 18:49
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from 42267eb to 497fce3 Compare August 30, 2026 18:49
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from 497fce3 to ce549b4 Compare August 30, 2026 18:55
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from ce549b4 to db57202 Compare August 30, 2026 18:56
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from db57202 to ff4e6ac Compare August 30, 2026 19:06
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from ff4e6ac to 9aa18cd Compare August 30, 2026 19:14
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from 9aa18cd to 4e886aa Compare August 30, 2026 19:28
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from 4e886aa to 9c6a033 Compare August 30, 2026 19:38
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from 9c6a033 to 24966e8 Compare August 30, 2026 19:41
@adamstankiewicz
adamstankiewicz force-pushed the feat/a2ui-agui-conformance branch from 24966e8 to 7a65b2f Compare August 30, 2026 19:45
Presentation is the renderer's half of the A2UI protocol — the surface
carries semantics, the host draws them in its own idiom. Our demo
renderer was being generic to the point of foreignness, which made the
projection look broken rather than portable. Now it draws like the app:
cards get the native card treatment, a tab switch turns the panel over
with a flip (the whole panel is tappable to advance), and a horizontal
List pages like a deck, one card in view. The flashcard mapping uses
that List for its deck — the catalog had the word for it all along.

None of this exceeds the surface's semantics: no invented state, no
smuggled progress. What still separates the two columns is what the
basic catalog cannot say — per-card progress, checked completion,
markdown — which is the versioned a2learn catalog's job (#98).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Card whose sole child is a two-tab Tabs is a thing with two faces, and
the renderer now draws it as one: a single container rotating in
perspective, two backface-hidden faces sharing a grid cell so the taller
face sets the height, tap anywhere to turn it over. The caption names
the other face from the surface's own tab titles — no flashcard
vocabulary baked into the renderer, and no semantics beyond what the
surface already modeled. The pill-tab presentation remains for Tabs in
any other composition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
adamstankiewicz and others added 2 commits August 30, 2026 17:25
Third mapped kind, and the first whose fixture exercises Tabs beyond
two faces: each step is a tab (title, body, optional why as caption)
inside a card, so the walk-through survives as navigation. The gating
does not — tabs browse freely where the native widget enforces
one-at-a-time disclosure — and the mapper says so instead of hiding it.
The a2learn-catalog column needed zero changes: parity by construction,
as claimed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renderer affordances for a horizontal List: paging buttons in the app's
own idiom, dots for position, snap-scroll kept so swiping still works,
scroll position read back so the two stay in sync. Buttons disable at
the ends. Position is presentation of where the reader is in a list the
surface already modeled — no new state invented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The native flashcard changes cards with a 320ms slide-and-fade keyed on
the card, not a scroll — the deck now does the same: one item in view,
remounted per page with the identical curve and offset, direction-aware.
The snap-scroll container goes away, which also removes the deck's only
horizontal scroll surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The native flashcard ends on its own CTA — the last card's Next becomes
the completion action. The deck now composes the same way: a horizontal
List immediately followed by an action Button folds that button into the
paging controls as the final page's primary action, label taken from the
surface's own button text. Same button, same action, presented where the
reader finishes; nothing dropped, nothing added. A deck without a
trailing button (or a button elsewhere, like step-reveal's) is left
exactly as the surface laid it out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The catalog's Text is specified to support simple Markdown (without
HTML, images, or links) — drawing literal ** marks was under-rendering
the spec, not honesty. The renderer now uses the same react-markdown
stack as the native widgets, scoped exactly to the catalog: GFM
formatting renders, raw HTML is ignored, links and images unwrap to
their text. The fidelity notes shrink accordingly — what a body actually
loses in projection is links and images, and the docs now say that
instead of overclaiming a plain-text ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
adamstankiewicz and others added 2 commits August 30, 2026 17:33
Three long tab titles crowding one row communicated nothing about
stepping through — the wrong catalog word. A walk-through is a sequence,
and the catalog's sequence primitive is the horizontal List: one Card
per step (title, divider, body, why as caption), paged by the renderer
with Back/Next, and the trailing done button folding in as the final
step's action. No new primitive needed — new primitives are the a2learn
catalog's business (#98), not the vendored basic catalog's. The gating
note stands: a deck pages freely where the native widget disciplines
one-at-a-time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the fly

Two parameterized primitives join the draft a2learn catalog, replacing
the idea of one primitive per widget kind: a2learn:Sequence (children
plus a traversal policy — linear or free, gated or all, accumulate or
replace — and a completeAction fired past the end) and a2learn:Reveal
(one thing with N faces). step-reveal composes as Sequence(linear,
gated, accumulate); a flashcard deck as Sequence(free, all, replace) of
Reveal(front, back) — and any other policy mix is pure data, no new
component code, which is what lets a planner compose novel activity
shapes at generation time. Invariant 1 holds: generated output is a
schema-shaped composition tree; every primitive's implementation is
human-written renderer code. Content inside compositions reuses the
basic catalog verbatim (draft catalog = basic ∪ primitives).

The demo shows all three tiers per composed kind: full fidelity,
behavior-as-data, universal fallback — gating visibly survives at the
primitives tier and not below.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first content primitive beside the two behavior words: a block whose
intent ('why', 'tip', 'note', open set) is data the renderer maps to its
design language — amber for the elaborative why, primary tint for the
tip, neutral otherwise. step-reveal's why and markdown-card's tip now
survive projection at the primitives tier with the emphasis the authors
gave them; markdown-card gains a composition for exactly that reason.
The basic fallback keeps its honest caption — the tiers keep stating
what they keep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three-tier comparison page did its exploration job: the mapper and
conformance stand on their own gates, and the surface renderer becomes
the composed kind's component in the next PR up the stack. The page,
the comparison component, the whole-widget draft-catalog module, and
the per-kind compositions all retire; primitives.ts stays as the
catalog identity module (catalog URN + policy vocabulary) that both the
renderer and the composed kind consume. Since this PR squash-merges,
the demo never reaches main at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A2UISurfaceView and the catalog identity module served the retired demo
here; their only consumer now is the composed kind, one PR up. This PR
stays what its title says: transports, mapper, vendored schemas, and
the conformance gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Google's schema files leave the tree. What the repo owns now is
spec/a2ui/manifest.json — upstream repo, pinned commit, SHA-256 per
file — and a fetch script that materializes the files into the
gitignored spec/a2ui/v1_0/, refusing any bytes that miss the pin
(verified: a tampered checksum aborts the fetch). pnpm conformance
fetches automatically when files are missing, so CI needs no extra
step; determinism is the same as vendoring, the copied bulk is gone.

Along the way the old README's provenance was audited: the files WERE
verbatim from a2ui-project/a2ui@abcff1515fad — its stated path just omitted
the json/ segment for the envelope schemas. The manifest now records
the exact upstream path per file, so provenance is machine-checked
instead of prose-claimed. Updating the spec version is editing the
manifest: a reviewable contract change, exactly as re-vendoring was.

Our own fixtures and goldens under spec/a2learn/ stay committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urce

Adam's call, and the right one: interop is with shipped renderers, not
with the spec repo's HEAD. The one incompatibility was ours — the
emitted action event carried userMessage, a newer-spec addition the
released schemas close out (additionalProperties: false). The event now
carries the intersection every revision accepts: name + context. Prose
for a model reader belongs to the MCP Apps channel, not this boundary.

With that fixed, the official package replaces the fetch-and-pin
machinery it briefly had: @a2ui/web_core pinned exact as a
devDependency, conformance reading its schemas from node_modules, the
lockfile integrity hash serving as the checksum. The manifest and fetch
script retire after one commit of life; a spec bump is now an ordinary
dependency bump the conformance gate passes judgment on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adamstankiewicz
adamstankiewicz merged commit 182ff2a into main Aug 31, 2026
3 checks passed
@adamstankiewicz
adamstankiewicz deleted the feat/a2ui-agui-conformance branch August 31, 2026 00:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant