Skip to content

feat(a2ui): Cards become files, drawn from a shared vocabulary - #117

Merged
Lancetnik merged 37 commits into
mainfrom
feat/skills-design5
Oct 2, 2026
Merged

Lancetnik merged 37 commits into
mainfrom
feat/skills-design5

Conversation

@Lancetnik

@Lancetnik Lancetnik commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Why are these changes needed?

Cards become files. A rich view — a weather panel, a market board, an inbox digest — used to be code: a literal inside one module, drawn by a hand-written Svelte component compiled into the bundle. Nothing about it was reachable from outside a release, and the whole catalog rode in the system prompt of every turn whether or not anything was ever drawn.

This branch implements all fifteen issues of .scratch/cards/ plus .scratch/a2ui-action-state/ issue 01:

  • A Card is a file. src/assistant/cards/ holds the format: one *.card.yaml per Card carrying its name, the use-when line the agent is offered, an optional topic (the case it is ready for), the field schema the model fills in, the layout, and one worked example. Identity is the name inside the file, so the filename is free.
  • The server expands. The model emits a Card's fields; the server pairs them with the layout from the file and publishes ordinary primitives plus a data model. No client learns that Cards are files.
  • A layout can repeat one template over an array, with bindings inside the template resolving relative to the item — carried through value resolution, data writes and action context alike.
  • The renderer forgets Card types. All twelve Cards are files; the bespoke components, the inline branches in the surface renderer and the duplicate branches in the generic renderer are deleted. The bundle now ships the vocabulary that draws all of them, not any one Card.
  • Cards link to the app's own things (Task, Chat, file, folder) through one shared link primitive.
  • The coding session stops being an exception — the one view the server fills rather than the model becomes an ordinary Card.
  • The layered state store is extracted from skills.py into state_store.py, so a Card catalog gets the same concurrency machinery without a copy.
  • A clicked Button keeps the Card instance's state: the browser sends the data model it holds alongside the click, so values the Button did not name survive and a Button inside a repeated row no longer replaces the whole list with its own row.
  • Global and Profile layers (12). Beside the Bundled Cards, a Card can live at the install Root (cards/) or in a profile's own Files space; Profile wins over Global over Bundled by name.
  • A Card can be turned off (13). cards.json at the Root, its own document beside skills.json: install-wide Disable and per-profile Suppression, default-on. A Card turned off is not offered, not fetchable and not valid to emit; an instance already in a Thread still draws.
  • The catalog is disclosed through a Skill (14). The Bundled rich-views Skill's description is rendered from the Cards this profile is offered and names each Card's topic ("the weather", "stock, fund or crypto prices", …); its body is the Card index and four steps for drawing one; one Card's schema and example, and the A2UI protocol at large (reference/protocol.md), are resources read on demand.
  • Off means off (15). With rich-views disabled or suppressed, the turn is built with no A2UI runtime and no middleware at all.

How it works

1. A Card is a file, resolved per profile. *.card.yaml carries name, description (the "Use when…" line), an optional topic, fields (the JSON schema of what fills it), layout (basic components bound to the fields, with repeated templates) and one example. Files come from three layers — Bundled (src/assistant/cards/bundled/), Global (<root>/cards/), Profile (workspace/cards/) — Profile over Global over Bundled by name. CardCatalog resolves them for one profile, cached on a fingerprint of the files and of cards.json, and answers two sets: cards(), what the agent is offered (minus Disabled/Suppressed), and drawable(), every Card on disk, so history keeps drawing.

2. The agent learns about Cards through the rich-views Skill, built in code (a2ui_skill.py) and filtered by the same switch as every other Skill:

Level When the model sees it What it holds
Description every turn, in <available_skills> what a rich view is + the topic of each available Card
Body (load_skill) once it decides to draw the Card index (name + "Use when…") and four steps for drawing one
Resources (read_skill_resource) on demand cards/<Name>.md — one Card's fields and a worked call of its script; reference/protocol.md — the A2UI protocol at large
Scripts (run_skill_script) to draw one per available Card, named after it (ADR 0040)

The description is part of the catalog the agent is built with (a snapshot until the next reload, #121); the body, resources and scripts are resolved per call.

3. A turn. The gateway's per-turn prompt opens with the agent's own system prompt (persona + skills catalog) and adds the turn guidance after it. If rich-views is available, the turn also gets the A2UI runtime; turned off, none of it is attached.

4. The model draws through a script (ADR 0040): load_skill("rich-views"), read_skill_resource("cards/WeatherPanel.md"), gathers the data with its tools, then run_skill_script(name="rich-views", script="WeatherPanel", args={…fields}). The script validates the fields against the Card's schema — an invalid call draws nothing and returns the schema — builds the A2UI messages and publishes them. The model never writes the A2UI envelope. A2UI JSON in the reply stays supported for composing several Cards or drawing from primitives: CardValidationMiddleware validates it against a catalog in which every Card is a component with its own schema (one retry), and tolerant_a2ui_middleware recovers an unwrapped reply under the same schema.

5. The server expands. expand_card_messages replaces a Card instance with its layout's primitives and turns its fields into updateDataModel writes (a nested Card is namespaced under /_cards/<id>). The browser only ever receives primitives and data.

6. History. Published surfaces are persisted as A2UISurface, already expanded. On the way to a client, as_drawn expands anything still holding a Card instance — the server-filled CodingSession (coding/surface.py, which sends only data updates while a run streams) and surfaces stored before this branch (old coding runs pass through adopted_surface first) — with drawable(), keeping the time each was recorded.

7. The browser folds the stream in project.ts and draws the shared primitive vocabulary (VOCABULARY.md) in A2UISurface.svelte / BasicA2UIComponent.svelte; no Svelte component knows any Card. A click sends the action together with the surface's current data model, and keeps working with rich views off.

Adding a Card is dropping a *.card.yaml into a layer: no code, no bundle rebuild. It joins the Skill's index and scripts on the next read and, if it has a topic, its description on the next agent build.

Measured effect on the prompt: the A2UI text resident on every turn drops from ~25.8k characters to the Skill's ~540-character description; the body the agent loads is ~3.8k (down from ~10.4k before the protocol moved out).

A turn now keeps the agent's own prompt — this touches every Skill, not only A2UI. AG2 seeds an agent's system prompt only when a turn passes none, and the gateway always passed its own, so the <available_skills> catalog never reached a web chat; while the A2UI catalog was pasted into the turn prompt this went unnoticed. The turn prompt now opens with the agent's system prompt (persona + plugins) and adds the per-turn guidance after it. Measured on seven prompts through a real gateway turn (six that call for a Card, one control): GPT-5.6 Luna draws unasked on 14/14 over two runs, up from 1/7. Drawing through a script (ADR 0040) keeps Luna at 14/14 with no failed call and moves GPT-5.4 Mini from 2/14 to 3–4/14 at the same context cost; Mini's remaining misses are turns where it never loads the Skill.

ADRs 0036 (Cards are layered files), 0037 (a Card's look is data, not code), 0038 (the catalog is disclosed through a Skill) and 0039 (the description names its Cards' cases; amends 0038) and 0040 (a Card is drawn by a Skill script) are added, with the matching CONTEXT.md glossary entries.

Known open: once, right after the first message of a new chat whose turn called a tool, the header showed the Active model until reload; two reproductions did not repeat it.

Follow-up: the rich-views description is a snapshot taken when the agent is built, so a Card switched or dropped in reaches it only on the next profile reload — part of a wider class (skills, MCP, tools) tracked in #121.

Deliberately left open: issue 13's Delete cascade — StateStore.purge clears a deleted Card's off-records and is pinned by a test, but nothing calls it yet; there is no Global Card install/delete until the Settings UI (#87).

Review fixes

A two-axis review (standards + spec) and a live pass in the browser ran over the whole branch; their findings:

  • A disabled Card could be drawn through the unwrapped-reply fallback. The tolerant middleware that recovers a bare A2UI array published it without schema validation, so a Card turned off — or a Card with invalid fields — was drawn anyway. It now validates exactly as the wrapped path does.
  • Coding runs stored before this branch lost their headline and file links. Those records carry task and integer counts; an inference rule in coding/surface.py re-derives the Card's fields from them on every read, recording nothing.
  • Two files in one layer claiming the same Card name no longer overwrite each other silently: the first in sort order wins and the second is skipped with a warning naming both.
  • A redrawn surface kept "now" as its time, so every coding session replayed under a "Today" divider; it now keeps the moment it was recorded.
  • A turn answered with a Card alone showed "(no reply)" above it; the placeholder is dropped once the turn draws a surface, whichever arrives first.
  • The thread header named the install-wide Active model, not the one the composer's switcher had chosen for the chat; it now names the same model the switcher does.
  • Testing a model config that uses the shared API key failed with "Missing credentials" while chat worked; the probe now gets the same saved-secret environment a turn does.
  • A script call for an in-process skill failed when a disk skill runtime was asked first: it rejected an object args before checking it owned the skill ([Bug]: LocalRuntime.execute rejects an object args before checking it owns the skill, breaking run_skill_script routing ag2#3326). The skill view now answers "not mine" for a name it does not hold.
  • Tools no longer name Cards (get_quotes, get_weather, generate_image); a test keeps every tool description free of Card names and A2UI. A drawn Card keeps working with rich views off — its clicks and save_surface are pinned by tests.
  • A2UISkillRuntime subclasses MemoryRuntime instead of forwarding through an untyped __getattr__; new comments trimmed to the two-line rule; the executor.py quote the markdown formatter had rewritten is restored.

Related issue number

Closes #85 (under the #84 umbrella).

Checks

  • I ran ruff check ., ruff format --check ., mypy, pytest -m "not integration" and npm --prefix web test locally and they pass.
  • If I changed anything under web/, I rebuilt and committed the SPA bundle (npm --prefix web run build).
  • I've included any doc changes needed for this change.
  • I've added or updated tests corresponding to the changes (if relevant).

AI assistance

This PR was prepared with Claude Code; the boxes below are the author's own attestation and are left for @Lancetnik to tick.

  • I understand the changes in this PR and can explain them in my own words.
  • I have verified that this description accurately reflects the actual diff.
  • If AI assistance was used, I reviewed, tested, and validated the generated code/text before submitting.

Lancetnik and others added 14 commits September 18, 2026 08:46
The persistence and resolution machinery welded to skills — mtime self-refresh,
the cross-process lock around every read-modify-write, atomic replace on save,
and the purge that clears only shared-layer records — becomes `StateStore` in
`assistant/state_store.py`, pointable at any document. The layer constants and
the typed off-record kinds move with it.

`SkillStateStore` is its first consumer and behaves exactly as before: it now
takes the root dir and owns the `skills.json` filename, so the literal is spelled
once rather than at every call site. Nothing changes for anyone using the app;
this is the prefactor that lets Card state be its own document beside it
(ADR 0027).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Column, Row or List binds its children to an array and draws one written
component per item, so data and layout stop growing together: `"children":
{"componentId":"run_row","path":"/runs"}` serves three items or thirty.

Inside a repeated row a path opening with `./` reads the item and an absolute
path still reads the whole data model. That scope travels through value
resolution, data writes and action context alike, so a checkbox writes to its
own item, a text field to its own item, and a button submits the row it
belongs to.

Both pointer writers (withA2UIValue, update_data_value) now clone a bound
array as an array and drop a write no row answers to, instead of flattening
the list into a dict.

Closes .scratch/cards/issues/02.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checklist stops being a Python literal plus a Svelte branch and becomes
`cards/bundled/checklist.card.yaml`, declaring its name, the use-when line the
agent is offered, the fields the model fills in, the layout, and one worked
example. A Card's identity is the name inside the file; the filename is free.

The server draws it. `expand_card_messages` replaces every Card instance with
the primitives its layout declares plus the data-model writes its fields make,
between AG2's validation and its publication — so the live A2UI messages and the
durable surfaces projected from them both carry primitives only, and a replayed
instance is the same drawing by construction. The browser is never told that
Cards exist.

A Card's fields become an ordinary custom component schema, so validation and
retry-on-invalid apply unchanged. The routing bullet that was hardcoded in the
prompt is gone: delete the file and nothing still points at it.

A nested instance is namespaced by its own id — `/_cards/<id>` for its fields,
`<id>__<layout id>` for its layout — so two Checklists in one Column read their
own rows and draw identically to a Checklist that is the whole answer.

An instance stored before Checklist was a file still says `component: Checklist`,
and with the front-end branch deleted its items would have vanished from that
chat. `expanded_card_surface` re-derives it on read in `StreamBridge._forward`,
the one path replay and live share — an inference rule, not a migration.

`.a2ui-basic-row` was a hard two-column grid, which split any icon+text row into
equal halves; it is now a wrapping flex row honouring the Basic Catalog's
`align`. That serves every Card, which is the point — a per-Card style is what
ADR 0028 rules out. A layout surface also takes its title from its data model
instead of from a Card type the renderer would have to know.

Carries the design this implements: ADR 0027/0028/0029 and the Card vocabulary
in CONTEXT.md, plus a new ADR 0028 bullet recording that `.`/`./` marks a
relative binding and that the mark is ours, as 02's handoff asked.

Closes .scratch/cards/issues/04.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ag2 1.0.6 is the first release carrying ag2#3254 — a MemorySkill body
rendered on every read, which the A2UI Skill disclosure needs, and with it a
skill read that is asynchronous and handed the live conversation context.
This app implements that contract itself in the layer that filters skills
down to the ones resolved available, so `FilteredSkillRuntime.read` becomes
`async def read(name, context)` and forwards the context inward. The context
is now typed on all three of its IO methods, as the protocol has always
declared it.

The mcp cap had to move with it: ag2's `acp` extra requires `mcp>=2.2.0,<3`,
so the lock was unsolvable against `mcp>=1.9.0,<2`. mcp 2.x renamed two
fields this app reads — `Tool.inputSchema` and `CallToolResult.isError` — and
those were the only camelCase MCP accesses in the package. The test fakes
spelled them the old way too; they passed as pydantic aliases while no longer
saying what production reads.

Nothing exercised a skill read through the filtered view, so the whole suite
stayed green against a runtime that no longer matched the protocol — this
would have failed on the first load_skill. Two tests close that: a skill read
through the view returns its own body, and a Disabled skill raises
SkillNotFoundError when its name is handed straight to the view.

The lock sheds 21 transitive packages (google-cloud-*, grpcio, tiktoken,
pillow) as ag2 slims its gemini extra; none are imported here. deployment.md
named the old >=1.0.0 floor.

Closes .scratch/cards/issues/03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The heaviest Card in the app stops being code. `marketboard.card.yaml` declares
its fields, its layout and its worked example; `MarketBoard.svelte`, its catalog
literal and its `quote_array` are deleted. This was the go/no-go on ADR 0028 —
whether a shared vocabulary of primitives and design tokens can carry the app's
richest look — and it holds. The scoped-CSS escape hatch stays shut, and ADR 0028
now records that.

The vocabulary grew what the board needed, and every Card gets it. Two primitives
— `Sparkline` and `Metric`, which signs, groups and arrows its own delta. A tone
vocabulary that can be **bound**: `tone: {path: ./changePercent}` resolves a
number's sign to a design token, which is how a rising quote goes green without a
colour anywhere in the file. `when`, so a heading never stands over an empty list
and a footer never reads "Source:  as of". `map`, so a Card prints "Market open"
for a field carrying `open` — the label belongs to the Card, not to the tool that
filled the field. `start` on a repeated template, so a layout that drew the lead
itself does not draw it again as a mover; the alternative was reshaping `quotes`
into `lead` + `movers`, which would have blanked every board already in a chat.
Plus the styling words the layout asked for: Text variants, `emphasis`, `format`,
`gap`, `justify`, `grow`, and a strong `Divider`.

`Card variant: feature` carries the editorial frame and its own surface tokens,
and is the one property `A2UISurface` reads to skip the generic chrome — a
property, not a Card name, so 11 still has its deletion to make. The board is
full-bleed as it was, and would have been visibly poorer boxed in a 620px panel.

Up and down resolve to the editorial palette's `--ed-up-d` / `--ed-accent-d`
through `--a2ui-positive` / `--a2ui-negative` at `:root`, so the tones read the
same on a feature Card and an ordinary one, in both themes.

The vocabulary is written down in `src/assistant/cards/VOCABULARY.md` — read it
before adding a primitive, and add to it when you do.

Verified live in Chrome: light and dark, 760px and 380px, a full board, a
one-quote board with no movers and no footer, a board with a timestamp and no
source, and a board carrying the old `status: open` code.

Closes .scratch/cards/issues/05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The task plan, the places finder and the plain structured brief never had a
component — their look was a branch inlined in `A2UISurface`. They are now
`taskplan.card.yaml`, `restaurantfinder.card.yaml` and `answerbrief.card.yaml`,
and the branches that drew them, their catalog literals, their routing bullets,
`string_array`, `result_array` and the `PlaceResult` type are deleted.

Identity is the name inside the file, so all three keep the names the old
literals had: a TaskPlan stored in a chat before this still redraws, through
`expanded_card_surface`, as the primitives its file declares.

Each roots its layout at a `Column`, not a `Card`. A layout surface is drawn
straight into the generic chrome, which is the frame the old branches drew in —
rooting at a `Card` would have added a second border around every one of them.

The vocabulary grew two styling words, not a primitive, and every Card gets
both. A `Text` can be a `pill` — the chip all three set a filter, a section or
a cadence in — and takes its ink from its `tone` like any other Text, so the one
rule serves the Cards and the generic fallback alike. An `Icon` takes the same
`size` word a `Metric` and a `Sparkline` take, because a 22px glyph beside
12.5px text is not the 12px mark the old branches drew. `Card` now honours
`grow`, which the vocabulary already promised for any component and nothing read
there. Both words are written down in `VOCABULARY.md`.

`when` does the work the old empty cases did by accident: a deliverables column
stands only over a list with something in it, and a finder with no results ends
after its filters rather than leaving a labelled empty box.

The chrome above a migrated Card now reads "Overview / Interactive view" rather
than "Task plan / Task setup" or "Places / Open places". The eyebrow, the icon
and the title were all cascades on the Card's type — the knowledge 11 deletes —
and closing that properly means either a new key in the Card format or renaming
the heading fields, which would blank the heading of every instance already
stored. Each Card carries its own heading, so nothing is unlabelled meanwhile.

Verified live in Chrome: light and dark, 760px and 330px, and for each of the
three a full, a one-item and an empty case, plus the unrecognised-view fallback
beside them to confirm the pills still match.

Closes .scratch/cards/issues/06.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A row in a rendered Card is clickable again, and one primitive covers every kind
of link. A `Link` names exactly one target — `task`, `chat`, `file`, `folder`, or
`url` — and the shell opens the page for it the way every other navigation does:
`go('/t/…')`, `go('/c/…')`, the preview rail, the Files tree, or a new tab for an
external page (http(s) only, through the existing `safeUrl` guard). It is open to
every Card whatever its layer, so a Card a user writes links to their tasks exactly
as a first-party one does.

A link to nothing that is there is drawn as the plain text it wraps, never a dead
click. A Task or a Chat is checked against the lists the drawer already polls, so a
board replayed from last month still reads once those tasks are gone. Those lists
start empty, which would have read as "everything is deleted" for the first second
of every profile, so the drawer's `loaded` flag becomes the `listsLoaded` store and
the ids resolve to `null` — not `[]` — until the first poll lands. A file or folder
path is taken as given; the Files rail reports a file that has gone itself.

The task board, the inbox digest and the day's agenda are files.
`TaskProgress.svelte`, `InboxBrief.svelte` and `AgendaCard.svelte`, their catalog
literals, their routing bullets, their worked examples, `thread_array`,
`event_array`, `task_array` and the `TaskRow`/`InboxThread`/`AgendaEvent` types are
deleted, along with the three dead branches left in `itemTitle`. Each keeps the name
its literal had, so a board stored before this redraws through
`expanded_card_surface`. `RestaurantFinder` picks up the `url` its results always
carried and nothing had drawn since 06.

The vocabulary grew three styling words and no new look. A `Text` can be a `badge` —
the status mark, one uppercase word outlined in its own `tone`. A layout takes a
`marker`: the rule down its leading edge, in its own tone, and **bindable**, read
like `when`, so one written row marks only the event that is up next. And `map`,
which a `Text` already had, now also reads a bound `tone` and an `Icon`'s `name` —
which is the whole of "status marks take their colour from the tone vocabulary":
`tone: {path: ./status, map: {failed: negative}}` puts no colour in the file, and
the same table picks the ✓/✕/clock beside a deliverable. A toned `Icon` is declared
before the tone classes so it beats the muted default, and `min-width: 0` on a
layout lets a nested column shrink with its row instead of wrapping it.

Two looks did not survive and are not expressible: the mastheads' count lines
("3 threads · 2 unread") need an aggregation the vocabulary has no word for, and the
agenda's "Nothing scheduled — the day is yours." needs the negation `when` is not.
Raising both rather than writing either into a Card file.

Verified live in Chrome: light and dark, a 335px and a 780px content column, each
Card full, sparse and bare, plus MarketBoard, TaskPlan and Checklist beside them for
the `min-width` change. A real task's row navigates to its page; a deleted task, a
mail with no URL and a `javascript:` URL all render as plain text; "Join meeting"
stands only over an event that has one. Console clean.

Closes .scratch/cards/issues/07.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two Cards with the most bespoke artwork are files. `WeatherCard.svelte`,
`NewsWire.svelte`, their catalog literals, their routing bullets, their worked
examples, `row_array`, `story_array` and the `WeatherRow`/`NewsStory` types are
deleted, along with the branches in `BasicA2UIComponent` that drew both a second way
when they were nested inside a layout. One layout now draws each, whether the Card is
the surface or a block inside one.

The glyph is a primitive, and the conditions are its vocabulary. A `WeatherGlyph`
draws one `condition` as a band with the `temperature` read in-scene; how richly it is
drawn is still the app-wide `animations` tier's business. The eight condition names
live with the primitive — `a2ui.py` for the tool, `web/src/lib/weather/conditions.ts`
for the three drawing tiers, and a test holds the two lists to each other. The weather
tool maps into the primitive's copy, so a profile editing its own weather Card cannot
change what the tool emits; the bundled Card mirrors the same eight as an `enum` so a
model typo is caught rather than silently drawn as cloudy, and anything unnamed that
does reach the glyph is drawn as cloudy rather than as nothing. `get_weather` also
returns `temperature` now: the old card regexed it out of the Temperature row, which
is Card knowledge the renderer should not have.

The vocabulary grew one more primitive and one styling word. A `Figure` is the lead
media block — the picture cropped to fill its box with the credit stamped in the
corner, `lg` beside the lead and `sm` as a thumbnail — and a `List` can be `ranked`,
numbering its rows from wherever its template opens, so the stories after the lead
read 02, 03, 04 rather than starting again at one. `templateStart` is that offset,
named for what it is now that two things read it.

A digest stored before `source` and `published` were split still reads: its whole
byline lived in `meta`, which the layout binds beside them.

Four looks did not survive and are not expressible. The masthead's edition date and
the "Updated just now" footer are both *now*, which no binding names; the news ticker
and the click-to-expand summary on a later story are motion and interaction the
vocabulary has no word for — the summary is simply shown. Raising all four rather than
writing any into a Card file.

Verified live in Chrome: light and dark, 780px and 335px, each Card full, sparse and
bare, a twelve-story digest for the ranking, a nested pair for the standalone/nested
equivalence, and a legacy `meta` byline. Console clean.

Closes .scratch/cards/issues/08.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decision matrix is a file, and it was the last one. `DecisionMatrix.svelte`,
its catalog literal, `option_array`, `criterion_array`, its routing bullet, its
worked example and the `DecisionOption`/`DecisionCriterion` types are deleted, and
with them the last component the model could emit that was not a Card file. The
paragraph that introduced the bullet list now points at the Cards themselves,
because the list it described is gone.

The table is the structural primitive, and it owns both axes. A `Table` draws one
column per item of `columns`, one ruled row per item of `rows`, and one cell per
item of the `cells` each row names, with a `header`, a `lead` and a `cell` drawn in
each one's own scope — three more layout ids beside `child`, namespaced by the
instance like any other. It aligns the columns itself, so a row that wrote fewer
values than there are options keeps the columns it did not fill and draws a dash in
them; it scrolls inside its own frame rather than widening the Card; and a
`columns` bound to nothing is no table at all rather than a stack.

Marking is the table's because no binding crosses two axes. A cell wins when the
row's `win` carries what the column's `key` carries, and `pick` marks a whole
column the same way — the comparison a Card file cannot express is exactly what the
primitive is for. A row with no clear winner names nothing and marks nothing.
`axisScopes` is now what `childSlots` repeats over too, so a template and a table
axis walk an array the same way.

Four looks did not survive. "The pick" was stamped on the recommended column's
header, which is Card copy a header cannot ask for — it is drawn in its column's
scope and cannot see `/recommended`; the column keeps its accent wash and the
verdict block names the pick in words. The "Criteria won — 2 / 4" tally is a count
of the rows a column won, which no binding names; the masthead's edition date is
*now*, already raised at 08; and the footer's "N criteria compared" is that same
count over the other axis. Raising all four rather than writing any into a Card
file.

The ticker chrome in `broadsheet.css` went with the component that was still using
it — `.bs-ticker`, `.bs-tag`, `.bs-viewport`, `.bs-track` and the marquee had no
consumer left once the matrix stopped being a Svelte component.

Verified live in Chrome: light and dark, 900px and 420px, two, three and four
options, a short row and a row with no winner, a criterion label too long for its
column, a Card with no options at all, and the matrix nested inside a Column.
Columns measured aligned at options+1 distinct offsets in every case, the grid
scrolls while the page does not, console clean.

Closes .scratch/cards/issues/09.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The view that showed a coding agent's progress was the odd one out — never drawn
by the model, assembled by the server, and absent from the catalog entirely. It is
a Card file now. `CodingSession.svelte` is deleted, and `broadsheet.css` with it: it
was the last consumer of the `.bs` shell, so the editorial chrome no longer ships as
a stylesheet at all. Who filled a Card in is the only difference left, and it is not
a difference in kind: a run emits a Card instance and it is drawn from the file
exactly as a stored MarketBoard is. Being in the catalog, the model can draw one too,
and Card state will reach it with no exception written for it.

The layout is sent once. A run opens its surface with the instance and then updates
only the data model, so a plan arriving mid-run redraws the panel without the layout
travelling again — one `A2UISurface` followed by `A2UISurfaceDataUpdated`s, the event
the save-action path already used. `card_fields` is the typed seam both emits go
through; `build_surface` and `surface_data` take the fields it returns.

One place now decides how a surface leaves the server. `as_drawn` sits beside
`to_wire`: what is persisted is the fields their author filled in, what is sent to a
client is the primitives the Card file draws. The chat socket had that step inline
and the voice socket did not, so a Card in a voice call reached a browser that no
longer knows any Card — a hole 05–09 had already opened and this ticket would have
widened.

The vocabulary grew one primitive and one styling word. A `Diff` draws one file's
unified hunks, the mark at the head of each line deciding whether the row is added,
removed, a hunk header or context — so no Card says which rows are which. It scrolls
inside a frame of its own height, so a run that touched thirty files is as long a
Card as a run that touched one. A `Text` can be `code`: the monospace face a path
reads in. The folder is a `folder` link and each changed file a `file` link on its
absolute path, which resolves because the run had that folder approved in the first
place.

What the model would have written, the server writes. The headline is the task's
first sentence cut at a word, the brief is the task picked back up from where the cut
fell — so a long prompt is printed once. "2 files changed", "+20", "−0", "No file
changes were made" and "Warming up the workshop" are all fields the filler sets,
because an aggregation and a negation are exactly what the vocabulary has no word
for, and the filler — model or server — is the one that knows them.

Four looks did not survive. Two are motion: the blinking caret on the live strip and
the beam sweeping under it; the `WORKING` badge carries the state instead. Two are
disclosure: the task brief and the diff each folded behind a `<details>`, and the
vocabulary has no word for a fold, as 08 already found. Raising the fold with the
ticker from 08 rather than writing any of the four into a Card file.

Verified live in Chrome against real Claude Code runs: light and dark, a 1100px and a
215px content column, a run in flight and a run done, one changed file and two, a
`+5 −0` stat line, a failed run with its error rule, and a plan with a completed, an
in-progress and a pending step. A changed file's row opens the file in the Files rail
through the granted Folder it was written in; the diff scrolls inside its frame while
the page does not; console clean on live and on replay.

Closes .scratch/cards/issues/10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Card is a file now, so the front end has no reason to know one from
another — and it no longer does. No component name outside the vocabulary is
written anywhere in `web/src`; a Python test scans for all twelve and is the
gate. From here the web bundle ships no Card, and one that arrives as a file is
drawn by exactly the path a first-party one is.

`AnswerBrief` was the last name standing, and it stood in four places: the
default a surface took when it named no component, the reason an empty data
model grew a `sections: []`, a heuristic that hid a surface it judged empty, and
a second renderer inside `A2UISurface` that drew a topic and a row of pills.
It is a file like the rest, and it draws through the same path as the rest.
The `LAYOUT` list that told a primitive from a Card goes with them.

A surface is named by its data model. `_surface_title` used to read the root
component's name and call anything it did not recognise "Structured answer" —
which, since a stored Card instance is stored by name, is what every replayed
Card was called. The name is gone; the title is `/title` or "Interactive view".
A surface the server draws carries that name on its event; one the client folds
from A2UI messages is retitled on every write. The eyebrow reads "Overview"
whether the surface is drawn, composing, or waiting.

Two holes the fallback had been papering over. A record carrying data and no
tree no longer blanks the tree an earlier record drew — it used to come back as
the generic box, so the clobber never showed. And a component the renderer has
no primitive for says so in one muted line rather than printing its own name
back at the reader: a Card whose file will not draw costs that Card and nothing
else.

What a Card file may draw and what the browser can draw are now asserted to be
one list, which is why a Card nobody has seen before renders at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a Button whose action the app has not registered published the
click's action context as the instance's entire data model, losing every
value the context did not name — a per-row Button replaced the whole bound
array with its own row.

The browser now sends the data model it holds alongside the click, as a
sibling of `message` so `parse_incoming_message` and the A2UI protocol are
untouched, and the gateway persists that. A click carrying no model
publishes nothing, so an older bundle or a channel client cannot blank an
instance by staying silent. A model naming another instance is not written,
and one over the size bound is refused out loud while the click still
reaches the agent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The data seed was written when a surface root was a Card instance, whose
props are the fields the model filled in. Server-side expansion makes the
root a layout primitive, so its own props were persisted as data — every
Card stored its `variant` and the id of its `child` alongside real fields.
A primitive root now contributes none.

Also folds three copies of "which component is the root" into one helper
and corrects wire.py on what is actually persisted for a Card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main moved 65 commits ahead: it split gateway/app.py into gateway/routes/*,
added mypy as a blocking gate (ADR 0030), and took ADR numbers 0027-0035.

Resolutions:
- Our three ADRs renumber to 0036/0037/0038; every reference inside the
  Cards work follows. main's 0027-0029 keep their numbers.
- The A2UI click handler stays in app.py and keeps the data model the client
  sends, now on main's `gateway` binding.
- The SkillStateStore root-dir signature reaches routes/skill.py, and the
  layer constants come from state_store, not skills.
- _build_schema_manager (main's typed rewrite) replaces our __new__ hack and
  keeps the Card vocabulary.
- The ag2 git pin moves to 1.1.0, which carries the async context-aware skill
  read our issue 03 absorbed; the commit main pinned was 1.0.3, without it.
- Six mypy findings in the Cards code fixed with real narrowing, no ignores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Branch coverage

Name Stmts Miss Branch BrPart Cover
src/assistant/a2ui.py 255 22 78 16 87%
src/assistant/a2ui_skill.py 121 9 34 7 90%
src/assistant/acp/approvals.py 58 1 8 2 95%
src/assistant/acp/chats.py 251 22 56 12 89%
src/assistant/acp/listeners.py 28 1 10 1 95%
src/assistant/acp/serve.py 66 16 4 2 74%
src/assistant/acp/serve_ws.py 103 0 24 3 98%
src/assistant/agent.py 224 25 70 9 87%
src/assistant/attachments.py 57 2 20 0 97%
src/assistant/cards/__init__.py 209 15 88 12 91%
src/assistant/channels/__init__.py 15 7 6 0 38%
src/assistant/channels/base.py 40 3 4 1 91%
src/assistant/channels/discord.py 136 73 44 1 42%
src/assistant/channels/formatting.py 121 2 32 3 97%
src/assistant/channels/router.py 544 15 186 14 96%
src/assistant/channels/slack.py 165 95 52 1 39%
src/assistant/channels/telegram.py 284 51 96 18 79%
src/assistant/cli.py 498 252 92 8 46%
src/assistant/codex_auth.py 241 67 54 5 71%
src/assistant/coding/bridge_client.py 44 7 4 1 83%
src/assistant/coding/bridge_server.py 115 30 22 4 74%
src/assistant/coding/diff.py 98 13 36 6 84%
src/assistant/coding/model_catalog.py 101 6 30 4 92%
src/assistant/coding/session.py 92 7 18 2 92%
src/assistant/config.py 203 22 58 7 89%
src/assistant/connections.py 264 18 78 14 91%
src/assistant/feedback.py 39 5 4 1 86%
src/assistant/filesearch.py 96 12 48 6 88%
src/assistant/folders.py 264 13 82 4 95%
src/assistant/gateway/app.py 439 175 120 17 57%
src/assistant/gateway/core.py 684 126 210 31 79%
src/assistant/gateway/openapi_schema.py 26 0 6 1 97%
src/assistant/gateway/profile_manager.py 404 43 118 23 87%
src/assistant/gateway/repair.py 66 3 26 1 96%
src/assistant/gateway/routes/chat.py 71 1 14 1 98%
src/assistant/gateway/routes/common.py 20 1 8 1 93%
src/assistant/gateway/routes/connection.py 167 2 42 2 98%
src/assistant/gateway/routes/file.py 170 4 58 6 96%
src/assistant/gateway/routes/folder.py 87 7 8 1 92%
src/assistant/gateway/routes/llm.py 245 17 54 6 92%
src/assistant/gateway/routes/permission.py 55 2 12 2 94%
src/assistant/gateway/routes/profile.py 73 4 8 0 95%
src/assistant/gateway/routes/secret.py 66 5 6 1 92%
src/assistant/gateway/routes/settings.py 190 18 26 8 88%
src/assistant/gateway/routes/skill.py 231 9 30 1 96%
src/assistant/gateway/routes/system.py 239 26 42 4 89%
src/assistant/gateway/routes/task.py 104 7 18 5 90%
src/assistant/gateway/stream_bridge.py 37 0 6 1 98%
src/assistant/gateway/tasks_service.py 406 49 130 17 86%
src/assistant/gateway/wire.py 19 3 2 0 86%
src/assistant/hitl/channel.py 21 2 4 2 84%
src/assistant/hitl/desktop.py 133 12 26 5 89%
src/assistant/hitl/gateway.py 22 4 2 1 79%
src/assistant/hitl/inquiry.py 168 6 36 5 95%
src/assistant/integrations/google_auth.py 136 67 24 0 49%
src/assistant/live_configs.py 116 9 40 13 86%
src/assistant/llm_configs.py 220 5 86 1 98%
src/assistant/memory.py 134 13 44 8 86%
src/assistant/middleware.py 66 2 8 0 97%
src/assistant/observability.py 69 5 10 0 94%
src/assistant/observers.py 97 1 24 5 95%
src/assistant/onboarding.py 64 2 26 0 98%
src/assistant/pairing.py 145 2 46 3 97%
src/assistant/peers.py 150 1 40 3 98%
src/assistant/permissions.py 300 21 106 8 93%
src/assistant/profiles.py 186 2 46 4 97%
src/assistant/provider_catalog.py 94 5 38 5 92%
src/assistant/scheduler_lock.py 33 0 4 1 97%
src/assistant/secrets.py 262 4 78 2 98%
src/assistant/self_tools.py 78 21 18 4 70%
src/assistant/settings.py 175 12 52 7 91%
src/assistant/skills.py 47 3 8 0 95%
src/assistant/skills_install.py 167 19 56 12 86%
src/assistant/state_store.py 163 12 50 6 92%
src/assistant/storage.py 36 4 0 0 89%
src/assistant/system_tools.py 152 45 50 13 66%
src/assistant/tasks/scheduling.py 99 6 30 4 92%
src/assistant/tasks/store.py 168 19 56 7 88%
src/assistant/tasks/summary.py 38 1 2 1 95%
src/assistant/tools/__init__.py 88 5 38 3 94%
src/assistant/tools/_mcp_compat.py 25 4 2 1 81%
src/assistant/tools/approval.py 12 6 4 0 38%
src/assistant/tools/ask.py 14 6 2 0 50%
src/assistant/tools/coding.py 30 7 2 1 75%
src/assistant/tools/docker_sandbox.py 68 20 10 1 68%
src/assistant/tools/files.py 70 18 24 6 74%
src/assistant/tools/finance.py 117 44 48 1 61%
src/assistant/tools/google.py 186 132 36 2 28%
src/assistant/tools/image_gen.py 92 38 28 2 55%
src/assistant/tools/mcp.py 193 24 42 9 83%
src/assistant/tools/weather.py 119 19 34 6 82%
src/assistant/tools/web_fetch.py 30 4 10 3 82%
src/assistant/usage.py 74 7 14 0 85%
src/assistant/voice.py 60 38 12 0 36%
src/assistant/voice_providers.py 72 31 4 0 57%
src/assistant/workspace.py 271 30 88 2 91%
TOTAL 14455 2051 3782 450 84%

41 files skipped due to complete coverage.

Lancetnik and others added 15 commits September 29, 2026 08:53
Cards come from three layers — the Bundled ones, `cards/` at the Root, and
`cards/` inside the profile's own Files space — stacked by name, so a profile's
Card covers a Global one and a Global one covers a Bundled one. A layer that is
not there is no Cards rather than an error, and the agent writing its first Card
with the ordinary file tools creates the directory it lands in.

A `CardCatalog` is bound to its three directories and owned by the Gateway, so
two installs in one process never share one (ADR 0019); it re-reads them only
when a fingerprint of their files — name, mtime and size — changes, which is what
carries an edited Card to the agent's next message with no restart. `as_drawn`
takes the catalog rather than the Cards and asks for them only once it has a
surface to draw, so no other event on the socket touches the directories.

The one per-file rule the layers added is a ceiling on the file itself: a Card
directory is the user's, so a renamed video costs a warning rather than a parse
of half a gigabyte.

Closes cards#12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Card state is `cards.json` at the Root — the reusable StateStore pointed at its
own document, so a Skill and a Card sharing a name have independent switches and
a purge in one cannot reach the other. A Card is Disabled install-wide or
Suppressed by one profile, and absence of a record means on, so nothing changes
for anyone on upgrade.

`CardCatalog` is the single resolution seam. A Card carries the layer it was read
from, and `cards()` drops whatever the store says is off; the catalog schema, the
prompt and the parser the middleware validates against are all built from that one
dict, so a Card turned off is neither offered nor valid to emit with no second
place to remember. `drawable()` is every Card on disk and is what `as_drawn` uses:
ADR 0036 names three paths a disabled Card must be absent from and all three are
the agent's, so a Card instance already in a Thread goes on drawing.

The freshness key is the state document's own revision rather than the names it
lists — two off-records can name the same (profile, Card) and differ only in kind,
which a set of names collapses.

Refs cards#13. Its Delete cascade stays open: `purge` is built and pinned, but no
Card delete exists to call it until #87.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
About 29k characters rode on every turn of every chat — the A2UI rules, every
component's schema and twelve worked examples — whether or not a Card was ever
drawn. A2UI is a Bundled Skill now, `rich-views`: its description is the only
resident text, about 450 characters saying rich views exist and when to reach for
one. The body it loads is 11k (rules, envelope, actions, and the index of this
profile's Cards a line each), and the twelve Card details are 32k between them —
1.2k to 4.5k each, read one at a time and only once the agent has chosen what to
draw. A conversation that never draws a Card pays the 450.

The index is the prompt section's own: AG2 already lists a custom catalog's
components with their descriptions, so no second index is written and a Card
dropped into a directory adds a line to it. The body is a callable over the
profile's `CardCatalog` and the Resources are listed from `cards()` at read time,
so a Card added, edited or switched off lands on the next load rather than the
next agent build. Only the catalog entry is a construction-time snapshot, and it
is the one thing that never changes.

`skill_origin` now reads a missing location as Bundled — a skill defined in code
ships with the app — so the Settings row, the install-wide Disable, the
per-profile Suppression and the refusal to delete are the ones every other
Bundled skill gets, with no second mechanism.

Two rules changed and only two: "do not call tools to discover A2UI components"
existed because there was nothing to call, and the sentence pointing at "the
worked examples below" now names the detail resource, because neither the schema
nor the example is below any more.

Refs cards#14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A profile that wants no rich views at all can have one. The `rich-views` Skill's
switch decided what the agent was offered; now it also decides whether the turn
carries A2UI machinery. Resolved unavailable — Disabled install-wide or Suppressed
here — a turn is built with no prompt fragment, no validation middleware, no
wrapper-recovery middleware and no subscription collecting A2UI messages, so
nothing in it parses, validates or recovers a rich view. The state document is
read per turn, so off and on both land on the next message with nothing to
restart.

The switch is the agent's, not the server's. A Card instance already in a Thread
goes on drawing, and so does the coding session, whose Card the server fills for
itself: ADR 0036 names three paths a disabled Card must be absent from and all
three are the agent's, as cards#13 already found. `as_drawn` still draws from
`drawable()`, so a run's panel does not vanish because a profile asked for prose.

The middleware is the whole observable difference: `capabilities_prompt(None)` is
the empty string, so once cards#14 retired the resident section the A2UI prompt
contribution is nothing either way. The tests read what a turn was built with; an
end-to-end "the model emits a view and nothing is drawn" needs a real model,
because a fake agent never runs the middleware it is handed.

Refs cards#15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eadline

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…o what

Also restores the exact executor.py quote the markdown formatter rewrote.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Card may declare a topic; the Skill's resident description lists the topics
of the Cards this profile is offered. The body shrinks to the Card index and how
to draw one, and the A2UI protocol at large becomes reference/protocol.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…luded

AG2 seeds the agent's system prompt only when a turn passes none, and the gateway
always passed its own, so no Skill's description ever reached a web chat. The
turn now opens with the agent's prompt; GPT-5.6 Luna draws a Card unasked on
14/14 eval turns, up from 1/7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
get_quotes, get_weather and generate_image told the model which Card or A2UI
component to draw, even with rich views turned off. What a Card is for now lives
only in the Card; a test keeps every tool description free of Card names and A2UI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
marklysze and others added 6 commits October 1, 2026 07:20
The cloudy scene pans its camera left, past the fixed 48-wide sky plane on
wide cards, leaving a pale strip on the left. The sky now follows the pan and
widens with the aspect.
…gating

Skill runtimes are tried in turn, and a disk runtime rejected an object args
before checking it owned the skill (ag2ai/ag2#3326), so a script call meant for
an in-process skill failed instead of passing on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e reply

Every available Card is an in-process script of rich-views; its fields arrive
as the call's arguments, the script validates, expands and publishes the surface.
Luna 14/14 with no failed call, Mini 3-4/14 (was 2/14), same context cost (ADR 0040).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A composed (bespoke) surface still takes its surfaceId from the model, which
reused one across turns so a new view drew over the first with that id. The
catalog rules now ask for an unused surfaceId unless replacing a surface.
The capability guidance now asks for a rich information experience where
possible: a rich view when a skill offers one that fits, otherwise
well-structured markdown.
Its description tied the Card to current conditions, so the model answered
tomorrow's weather in prose.

@marklysze marklysze left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, thanks @Lancetnik!

A question raised in a chat has no task, chat or detail of its own, so
GET /inquiries/pending answered None for string fields and failed response
validation. Those fields now fall back to an empty string.
@Lancetnik
Lancetnik merged commit d44f312 into main Oct 2, 2026
10 checks passed
@Lancetnik
Lancetnik deleted the feat/skills-design5 branch October 2, 2026 15:51
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.

feat: the A2UI card catalog lives in a directory

2 participants