Repository navigation
feat(a2ui): Cards become files, drawn from a shared vocabulary - #117
Merged
Merged
Conversation
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>
Branch coverage
41 files skipped due to complete coverage. |
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>
This was referenced Sep 30, 2026
Open
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
approved these changes
Sep 30, 2026
marklysze
left a comment
Collaborator
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:src/assistant/cards/holds the format: one*.card.yamlper Card carrying its name, the use-when line the agent is offered, an optionaltopic(the case it is ready for), the field schema the model fills in, the layout, and one worked example. Identity is thenameinside the file, so the filename is free.skills.pyintostate_store.py, so a Card catalog gets the same concurrency machinery without a copy.cards/) or in a profile's own Files space; Profile wins over Global over Bundled by name.cards.jsonat the Root, its own document besideskills.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.rich-viewsSkill's description is rendered from the Cards this profile is offered and names each Card'stopic("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.rich-viewsdisabled 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.yamlcarriesname,description(the "Use when…" line), an optionaltopic,fields(the JSON schema of what fills it),layout(basic components bound to the fields, with repeated templates) and oneexample. Files come from three layers — Bundled (src/assistant/cards/bundled/), Global (<root>/cards/), Profile (workspace/cards/) — Profile over Global over Bundled by name.CardCatalogresolves them for one profile, cached on a fingerprint of the files and ofcards.json, and answers two sets:cards(), what the agent is offered (minus Disabled/Suppressed), anddrawable(), every Card on disk, so history keeps drawing.2. The agent learns about Cards through the
rich-viewsSkill, built in code (a2ui_skill.py) and filtered by the same switch as every other Skill:<available_skills>topicof each available Cardload_skill)read_skill_resource)cards/<Name>.md— one Card's fields and a worked call of its script;reference/protocol.md— the A2UI protocol at largerun_skill_script)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-viewsis 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, thenrun_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:CardValidationMiddlewarevalidates it against a catalog in which every Card is a component with its own schema (one retry), andtolerant_a2ui_middlewarerecovers an unwrapped reply under the same schema.5. The server expands.
expand_card_messagesreplaces a Card instance with its layout's primitives and turns its fields intoupdateDataModelwrites (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_drawnexpands 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 throughadopted_surfacefirst) — withdrawable(), keeping the time each was recorded.7. The browser folds the stream in
project.tsand draws the shared primitive vocabulary (VOCABULARY.md) inA2UISurface.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.yamlinto a layer: no code, no bundle rebuild. It joins the Skill's index and scripts on the next read and, if it has atopic, 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.mdglossary 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-viewsdescription 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.purgeclears 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:
taskand integer counts; an inference rule incoding/surface.pyre-derives the Card's fields from them on every read, recording nothing.argsbefore 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.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 andsave_surfaceare pinned by tests.A2UISkillRuntimesubclassesMemoryRuntimeinstead of forwarding through an untyped__getattr__; new comments trimmed to the two-line rule; theexecutor.pyquote the markdown formatter had rewritten is restored.Related issue number
Closes #85 (under the #84 umbrella).
Checks
ruff check .,ruff format --check .,mypy,pytest -m "not integration"andnpm --prefix web testlocally and they pass.web/, I rebuilt and committed the SPA bundle (npm --prefix web run build).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.