Specification · v1 Working Draft
anvil·md

Specification  ·  Section 4 of 14

Blocks

Every block in turn: attributes, the rows it accepts, and what it renders as, open and stamped. Including @card, @board, @message and the two layout containers.

Every ASK block shares these attributes:

AttributeDefaultMeaning
idderived (§8.2)stable identity, and the block= in the stamp
selectoneone stamps on click. many gives checkboxes plus a submit.
submit"Confirm"button label
iconper kindoverride the block’s icon
min max—with select=many, bounds enforced before submit arms
expiresnone30s 15m 2h. Server-enforced (§7.5).
optionaloffa Skip affordance that still emits a stamp
dangeroffdestructive framing plus a deliberate second click
phrase—with danger: type this exact string to arm the button

4.1 @choice

@choice id=project-kind
? What are we actually building?
: If it is more than one, pick the one that pays for the others.
- site    | Marketing site     | pages, no login
- product | Product UI         | accounts, state, real users
- !scrap  | Start from scratch | we bin the existing brand

Prefix a value with ! to mark that single row destructive.

   ╭─ ? What are we actually building? ─────────────────────────╮
   │  If it is more than one, pick the one that pays for the    │
   │  others.                                                   │
   │                                                            │
   │    [1]  Marketing site      pages, no login                │
   │    [2]  Product UI          accounts, state, real users    │
   │    [3]  Start from scratch  we bin the existing brand      │
   ╰────────────────────────────────────────────────────────────╯

Stamped, at exactly the same height (§9.2):

   ╭─ ? What are we actually building? ─────────────────────────╮
   │  If it is more than one, pick the one that pays for the    │
   │  others.                                                   │
   │                                                            │
   │         Marketing site      pages, no login                │
   │    ✓    Product UI          accounts, state, real users    │
   │         Start from scratch  we bin the existing brand      │
   ╰──────────────────────── stamped · Ana · 14:02:11 ──────────╯

Never collapse a stamped choice to one line. The rejected rows are part of the record: they show what the human was choosing between.

Same semantics as @choice, different renderer. render= picks the card body:

renderCell attributeCard shows
image (default)img=<url>the image, 4:3, object-fit cover
swatchswatch=#a,#b,#ca colour strip
typefont=<family> sample="Aa"the sample set in that family
card—label and hint in a grid, no media
@gallery id=palette render=swatch select=one
? Which palette?
- ink  | Ink and paper | warm, printed | swatch=#111111,#f5f2ea,#c8452d
- volt | Volt          | loud, black   | swatch=#0a0a0a,#e6ff00,#8a8a8a

img, swatch and font land in src and style attributes, so they are allowlisted, not escaped (§8.3). Fonts must be loaded before the card paints, or the human judges the fallback face three times.

4.3 @input

@input id=company submit="That's us"
? Tell me who you are
_ legal*  | text     | Legal name      | Acme Ltd
_ site    | url      | Current website | https://…
_ token   | secret   | API key
_ context | longtext | Anything I should know

Field row: _ name[*] | type | Label | placeholder. A trailing * on the name means required. Label defaults to the name, title-cased.

TypeControl
textsingle-line input
longtexttextarea
numbernumeric input
boola switch
secretmasked; the value never reaches the transcript (§7.6)
pathmonospace text input
urlurl input
datenative date picker

4.4 @code

The only way to put a verbatim literal inside a block.

@code lang=ts label="The handler"
~~~
export function handle(x: string) {
  return x.trim()
}
~~~

lang= drives highlighting; unknown or absent renders plain, never throws. No sigil parsing happens inside the literal. An unclosed ~~~ closes at end of fence, so the parser stays total.

4.5 @note

@note tone=warn
> Staging shares the production database. **Migrations you run there are real.**

tone is info (default), warn, danger. No id, no interaction, no stamp.

The body is markdown, with one carve-out: a # at the start of a line is a comment (§3.1) and never reaches the body, so an ATX heading cannot be written inside a note. Use **bold** for a lead-in, or a second note. The sigil is older than the block and stripping it is the one thing that keeps # usable as a comment anywhere in the language.

Two more constraints come with markdown: reuse a configuration that escapes raw HTML (this is agent-authored text), and add a recursion depth guard, because a note containing an ```anvil fence would otherwise re-enter the renderer forever.

A note also carries its own parse warnings inside its tinted box. It has no frame to hang them off, so a sibling warning would float naked in the page.

4.6 @upload

The one block that carries bytes.

@upload id=assets accept="pdf,png,svg,zip" max=25mb multiple
? Send me anything you already have
: Logo files, a brand guide, screenshots. Ugly is fine.

The rule that makes this useful instead of decorative: an uploaded file must be attached to the synthesised turn as a real attachment, in whatever form the model transport already accepts. The stamp names it; the attachment carries it. If a PDF arrives and the model cannot read it, the upload block is a lie.

Stamps on submit, not on drop (§7.4).

@link id=refs multiple submit="These are the ones"
? Anything out there you want this to feel like?

The host fetches a preview per URL and folds title and description into the stamp, so the model gets the metadata without spending a tool call. A failed fetch carries the bare URL and fetched="no" — never block the human on someone else’s slow server. The fetcher takes arbitrary user URLs, so it needs the same SSRF egress rules as any other server-side fetch.

4.8 @scale

@scale id=tone steps=5
? Set the dials
% formal | Formal | Playful | 2
% dense  | Dense  | Airy    | 3

% name | leftPole | rightPole | default. steps (default 5, range 2-11) is the notch count; the stamp reports both the notch and a normalised 0..1.

The poles are what the human reads; name is the machine key. Reads well when poles differ, ambiguously when they repeat.

4.9 @order

@order id=priorities
? Drag these into the order you would actually defend
- speed | Ship fast
- craft | Get it exactly right

Up/down buttons alongside the drag, always. Drag-only ranking is an accessibility failure and is unusable on touch.

4.10 @example

A loaded gun for an ASK block.

@example id=ex-typical for=company label="Roughly this much detail"
= legal=Northbound Tooling Ltd
= site=https://northbound.example
  • Use this fills the target and nothing else. No submit, no stamp, no tag. The human still presses the target’s own button.
  • Targeting a @choice/@gallery pre-selects rows without stamping them.
  • An example dies with its target. The moment the target stamps, every example pointing at it goes read-only and the button disappears. A live “Use this” next to a frozen block is a lie about what is possible.
  • A for= matching nothing renders read-only with a parse warning, never silently dropped.

4.11 @void

@void id=mood reason="you described it in words instead"

Renders the target struck-through and inert. This is not an unstamp — a voided block can never be answered, it just stops pretending it can be.

Emit it the moment the conversation overtakes an unanswered block. A stale live widget three screens up is the single most annoying failure mode of inline UI.

4.12 @card

A work item: a story, an epic, a bug, a ticket. The single most common thing an agent has to show a human while doing work, and the thing every chat surface currently fakes with a bulleted list and a hand-typed percentage.

@card id=ANV-114 type=story status=flight as=14:02
? Payment retry ladder
: Three attempts, 1m / 10m / 2h, then dead-letter.
+ 5 pts | high | Sprint 24 | epic ANV-100
- [x] ANV-115 | Retry scheduler      | Ana
- [x] ANV-116 | Backoff policy table | Ana
- [~] ANV-117 | Dead-letter queue    | Kit
> Kit · 2d in flight · 3 of 5 checks green. Needs the SQS policy
> from infra before this can merge. PR 482.
- [!] ANV-118 | Alerting hook        | blocked · ANV-117
- [ ] ANV-119 | Metrics
- [ ] ANV-120 | Runbook entry
- [ ] ANV-121 | Load test at 10x
   ╭─ ANV-114 · story ────────────────────────── ◐ in flight ──╮
   │  Payment retry ladder                                     │
   │  Three attempts, 1m / 10m / 2h, then dead-letter.         │
   │                                                           │
   │  ▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░  2/7 · 29%        │
   │                                                           │
   │  ✓  ANV-115  Retry scheduler                        Ana   │
   │  ✓  ANV-116  Backoff policy table                   Ana   │
   │  ◐  ANV-117  Dead-letter queue                      Kit   │
   │  ✕  ANV-118  Alerting hook             blocked · ANV-117  │
   │  ○  ANV-119  Metrics                                      │
   │  ○  ANV-120  Runbook entry                                │
   │  ○  ANV-121  Load test at 10x                             │
   │                                                           │
   │  5 pts · high · Sprint 24 · epic ANV-100                  │
   ╰──────────────────────────── as of 14:02 · snapshot ───────╯

Every number in that picture is produced by the rows above it, and a test in this repo re-derives them from SPEC.md on every run. The first draft of this section printed 3/7 · 43% over five rows with two done, which is exactly the failure §4.12.1 goes on to argue is worth a whole block to prevent.

AttributeMeaning
typefree text, shown as a chip. epic, bug and spike also pick the icon.
statustodo flight done blocked. Counted from the rows, and the count wins (§4.12.1).
hrefa link to the real ticket. The card’s only outbound affordance.
asthe timestamp the data was read. The renderer supplies no clock of its own.
askturns the card into a question (§4.12.4)

? is the title. : is one line of subtext. > written before the first task row is card-level prose; the same sigil after a row is that row’s detail (§4.12.3), and the ordering is the only thing that distinguishes them. + is the chip strip, one chip per cell; a leading ! marks one urgent.

href is allowlisted to http(s) like every other agent-authored URL (§8.3), and it does not make the card interactive: a link is navigation, not an answer.

4.12.1 The bar is counted, never authored.

There is no progress=. There is no done=. The bar, the 3/7 and the 43% are all computed from the rows on screen, and that is the whole reason @card is an ANVIL block rather than an HTML embed: an agent that writes 3/7 above nine subtasks is lying, and a format should not hand it the vocabulary.

A row may carry its own n/m in the meta cell — an epic listing stories, each with a count of its own. When any row does, every row contributes a pair and a bare row counts as one of one, so the sum stays exact:

@card id=ANV-100 type=epic as=14:02
? Billing that survives a bad night
- [~] ANV-114 | Payment retry ladder | 3/7
- [x] ANV-130 | Idempotency keys     | 6/6
- [ ] ANV-141 | Dunning emails       | 0/9
   ╭─ ANV-100 · epic ─────────────────────────── ◐ in flight ──╮
   │  Billing that survives a bad night                        │
   │                                                           │
   │  ▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░  9/22 · 41%        │
   │  ✓ done 1      ◐ flight 1      ○ todo 1                   │
   │                                                           │
   │  ◐  ANV-114  Payment retry ladder                         │
   │     ▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░  3/7                         │
   │  ✓  ANV-130  Idempotency keys                             │
   │     ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓  6/6                         │
   │  ○  ANV-141  Dunning emails                               │
   │     ░░░░░░░░░░░░░░░░░░░░░░░░  0/9                         │
   ╰──────────────────────────── as of 14:02 · snapshot ───────╯

A child claiming more done than total is clamped where it is parsed, so one bad row cannot poison every ancestor’s bar. A count is read from the end of the meta cell, so Kit · 6/6 is both an assignee and a rollup; an n/m sitting mid-cell is refused with a warning rather than guessed at, because PR 3/7 checks is not a progress count.

And status= loses to the rows. It exists because a card can be blocked at any progress, which is a fact the rows cannot know, and because a card may be written before it has any rows. But status=done above two open subtasks is the same hand-typed claim this section bans, so the count wins and the contradiction is a parse warning:

   status="done" contradicts 0/2; using "todo"

4.12.2 A card is a snapshot, not a view.

The footer says snapshot, always: no polling, no live subscription, no refresh. When the ticket moves, emit a new card further down the conversation, exactly as you would say the new thing out loud. as= is the agent’s own timestamp, and a renderer must not substitute its clock — it cannot know whether the data is fresh, and printing a time would claim that it does.

The rule is aimed at hosts, not at the grammar: a card carries no ticket handle and nothing refetchable, so it cannot go stale on its own. Two blocks that can, and until now did not say so:

A stamp cannot freeze bytes ANVIL does not own. A @gallery renders img= from someone else’s server and then stamps; that image can change, or 404, and the frozen record afterwards shows something other than what the human clicked. @link folds host-fetched metadata into a stamp the same way. A host that wants a record it can defend snapshots or proxies those bytes at stamp time, and stores the copy. Otherwise the stamp is honest about the click and silent about the thing clicked.

4.12.3 Subtask detail is a disclosure, never a hover.

A > line under a task row is that row’s detail. It must be reachable by pointer, by keyboard and by touch, and it must be in the document with an aria-describedby from its row whether or not it is currently painted.

Hover-only detail is the same accessibility failure as drag-only ranking in §4.9: it does not exist on a keyboard, and it does not exist on a phone.

4.12.4 ask= is what makes a card a question.

Without it, a card is a record and never stamps. With it, the card grows a prompt and its [ ] rows become selectable — and only its [ ] rows. Offering a done or blocked subtask as “what next” is a lie about what the human can choose.

@card id=ANV-114 type=story ask="Which one do you want me to pick up next?"
- [!] ANV-118 | Alerting hook  | blocked · ANV-117
- [ ] ANV-119 | Metrics
- [ ] ANV-120 | Runbook entry

Everything in §6 and §7 then applies unchanged: it stamps once, it freezes, and the rejected rows stay on screen because they are part of the record. A card inherits select, submit, min, max, optional and expires from the ASK table only while ask= is set; on a record they are meaningless and warn.

The pick marker must not be one of the four state glyphs. A tick meaning “you chose this” next to a circle meaning “this is not done” puts two different questions in one row wearing the same vocabulary, and an agent re-reading its own fence cannot tell them apart.

4.13 @board

The same task rows, grouped by the state they already carry.

@board id=sprint-24 max=2 as=14:02
? Sprint 24
- [ ] ANV-119 | Metrics
- [ ] ANV-120 | Runbook entry
- [ ] ANV-121 | Load test at 10x
- [~] ANV-117 | Dead-letter queue
- [~] ANV-142 | Webhook replay
- [~] ANV-143 | Retry budget
- [x] ANV-115 | Retry scheduler
- [x] ANV-116 | Backoff policy table
   ╭─ Sprint 24 ───────────────────────────── 2/8 · 25% done ──╮
   │                                                           │
   │  ○ TODO 3          ◐ IN FLIGHT 3       ✓ DONE 2           │
   │  ▓▓▓▓░░░░░░        ▓▓▓▓░░░░░░          ▓▓▓░░░░░░░         │
   │                                                           │
   │  ANV-119           ANV-117             ANV-115            │
   │  Metrics           Dead-letter queue   Retry scheduler    │
   │                                                           │
   │  ANV-120           ANV-142             ANV-116            │
   │  Runbook entry     Webhook replay      Backoff table      │
   │                                                           │
   │  +1 more           +1 more                                │
   ╰──────────────────────────── as of 14:02 · snapshot ───────╯

Lanes are derived, so a board cannot claim a lane count that disagrees with the cards in it, and an empty lane does not render at all. A board never stamps: it is a record of many things, and there is no single question in it.

Two rules that are easy to get wrong:

  • +N more is mandatory. max bounds a lane for readability; a board that truncates silently reads as a complete board, which is the same lie as an authored progress number.
  • A lane bar is a share of the board, not progress. Announce it as 3 of 5 cards todo. Announcing a todo lane as “3 of 5 done” is a straightforward lie to anyone who cannot see the colour.

4.14 @grid and @stack

Two containers. @grid flows its children into at most cols columns; @stack is one column with a controlled gap. Both close at @end, or at the end of the fence.

@grid cols=3 min=16rem gap=normal
@card id=ANV-114 type=story status=flight
? Payment retry ladder
- [x] ANV-115 | Retry scheduler
- [~] ANV-117 | Dead-letter queue
@card id=ANV-130 type=story status=done
? Idempotency keys
- [x] ANV-131 | Key derivation
@end
AttributeDefaultMeaning
cols2maximum columns, clamped 1-6. Not a count.
min14remnarrowest a column may get before the grid drops one
gapnormaltight normal loose. A keyword, never a length.
frameoffdraw a border around the group

Five rules, and the first is the one that matters:

L1. No breakpoints. Ever. The agent writing the fence cannot see the screen, so it must not be allowed to guess at one. The surface rendering it is as likely to be a 380px chat column on a 5K display as a full page, which means a viewport media query is measuring the wrong box regardless. Collapse is computed against the container:

.anvil-auto {
  --anvil-cols: 2;      /* from cols=  */
  --anvil-min: 14rem;   /* from min=   */
  --anvil-lgap: 0.6rem; /* from gap=   */

  display: grid;
  gap: var(--anvil-lgap);
  grid-template-columns: repeat(auto-fit, minmax(
    min(100%, max(var(--anvil-min),
      calc((100% - (var(--anvil-cols) - 1) * var(--anvil-lgap)) / var(--anvil-cols))))
  , 1fr));
}

Every track is at least --anvil-min, so a column never gets unreadable. Every track is also at least its exact share once the gutters are subtracted — (100% - (n-1)·gap) / n — so n tracks plus n-1 gaps consume exactly 100% and an n+1th cannot fit. That is what makes cols a maximum. The outer min(100%, …) caps the track when the container is narrower than the minimum, so a narrow panel collapses instead of overflowing. No media query, no container query.

The gap subtraction is not decoration. A plain 100%/n leaves n·(100%/n) + (n-1)·gap > 100%, which caps the grid at n-1 columns for any non-zero gap. Anyone simplifying this expression should read that sentence first.

Two more things the arithmetic depends on: --anvil-lgap must be the container’s actual gap, or the track width is computed against a gutter that is not there; and grid items need min-width: 0, or one long unbroken ref pushes a track past its share and quietly breaks the column count.

One case the formula does not cover: a grid whose own inline size is indefinite (inside width: fit-content, or a float). CSS Grid resolves an auto-fit track list against an indefinite size by repeating once, so it draws one column whatever cols says. Give the container a definite width.

L2. No coordinates. No span, no areas, no row-start, no order. One block per cell, in source order. A layout that needs a coordinate is a document, not a sentence in a conversation, and this is the boundary that stops ANVIL growing into a worse HTML.

L3. Two containers deep, enforced by the parser. Any container may hold any other — what is capped is the DEPTH, not the pairing, and two is the ceiling. A third level is flattened with a warning rather than dropped, and it still consumes its matching @end so everything below it stays balanced. @grid holding a @stack is the case worth having; @stack inside @stack is a no-op the parser permits rather than a shape anyone should write.

L4. An unclosed container is not an error. It closes at the end of the fence, the same way an unclosed ~~~ does (§4.4), because a fence truncated mid-grid must still render what arrived (§5).

L5. Layout has no identity. No id, no stamp, no for= target, no @void. Layout is the one thing in ANVIL that cannot be answered, so it must not carry a single pixel that suggests otherwise. An id= on a container is a warning and is ignored.

4.15 @message

A message an agent is proposing to send: an email, a WhatsApp, an iMessage, an SMS, a Slack post. Rendered with the chrome of the thing it will become, so the human reads it the way the recipient will.

@message channel=email to="j@duplo.org" cc="ana@x.dev" from="bot@frst.dev" ask="Send it?"
? Re: the retry ladder
> Hey Jonas,
>
> The retry ladder is in. Three attempts, then dead-letter.
+ patch.diff | 4 KB

A header is one line. It gets long, and wrapping it is the obvious thing to reach for — but §3.2’s grammar ends the header at the newline, so a wrapped attribute list is parsed as prompt text and the block silently loses every attribute after the break. The parser warns when a sigil-less line opens with key=, because the failure is otherwise invisible: the block renders, with a line of machine text where its title should be.

   ╭─ EMAIL ───────────────────────────────── draft · not sent ──╮
   │  Re: the retry ladder                                       │
   │  ─────────────────────────────────────────────────────────  │
   │  FROM  bot@frst.dev                                         │
   │  TO    j@duplo.org                                          │
   │  CC    ana@x.dev  kit@x.dev                                 │
   │                                                             │
   │  Hey Jonas,                                                 │
   │                                                             │
   │  The retry ladder is in. Three attempts, then dead-letter.  │
   │                                                             │
   │  ⊕ patch.diff · 4 KB                                        │
   │                                                             │
   │  › Send it?                                        [ Send ] │
   ╰──────────────────────────────────── draft ──────────────────╯
AttributeMeaning
channelemail whatsapp imessage sms signal telegram slack discord memo
to cc bcc fromthe envelope. Comma or semicolon separated.
subjectalias for the ? line. ? wins when both are given.
statedraft (default) approved sent failed declined. See §4.15.5.
atwhen it reached that state. by names who.
errorwith failed, what went wrong. Implies state=failed.
sentsugar for state=sent at=…. Must be asserted.
askthe send gate. Without it the block is a record and never stamps.
danger phraseas §4, for a send that cannot be taken back

? is the subject or headline. > is the body; a blank > is a paragraph break. Each + line is one attachment: name | size | mime.

4.15.1 Draft is the default, and that is a safety property.

sent has to be asserted. Every message renders as draft · not sent until an attribute says otherwise, and the state sits on the frame — a tinted pill and a dashed border — rather than in the footer where nobody reads it.

The failure this prevents is specific and expensive: a human scrolls back three screens, sees something that looks exactly like an email, and concludes it went out. It never did. A block that renders a proposal identically to a record is the same lie as a card that re-renders live state (§4.12.2), except the cost is a message that never arrived.

4.15.2 Nothing in a draft is a live affordance.

Addresses render as text, never as links. A mailto: inside a draft is one mis-click from a composer pre-filled with agent-authored text, and the whole point of the block is that it is the place a human reads what is about to be sent. The only thing on it that should do anything is the button that says yes.

For the same reason the button says what the click does. Send, not Confirm — the one moment a label matters is the one where the action leaves the building.

4.15.3 The send gate is an ordinary stamp.

With ask=, everything in §6 and §7 applies unchanged: one click, one stamp, frozen. That is the correct shape for an outbound message, because “did the human approve this” is exactly the question a transcript should still be able to answer six months later.

Pair it with danger phrase="SEND" when the send cannot be recalled.

4.15.4 Unknown channels do not borrow chrome.

A channel nobody has written chrome for renders as a plain memo plus a warning. Drawing an unrecognised channel as a WhatsApp bubble tells the human this is going somewhere it is not.

4.15.5 The lifecycle

   draft ──stamp──► approved ──host──► sent
     │                  │
     │                  └──host──► failed
     └──stamp──► declined

The click is approval, not delivery. This is the whole reason there are five states rather than two. A stamp records that a human said yes at 14:04; whether the SMTP handoff succeeded is a different fact, arriving three seconds later, from a different actor, and it can be no. A block that flipped straight to sent on the click would be telling exactly the lie §4.15.1 exists to prevent, just further along the wire.

So the stamp moves the block to approved, and the host writes the outcome afterwards:

StateMeansWritten by
draftnobody has approved itthe agent (default)
approvedthe human said yes; the wire has not answeredthe stamp
declinedthe human said nothe stamp
sentdelivery succeededthe host, after the fact
faileddelivery was attempted and did not succeedthe host, with error=

sent, failed and declined are absorbing, exactly like the stamp states in §7.2. Nothing leaves them.

4.15.5.1 This is the only post-stamp mutation in the language.

§1 says a stamped block is frozen, and everywhere else that is absolute. The approved → sent | failed edge is the one exception, and it is narrow enough to defend: it is written once, by the host, it is terminal, and it records an outcome of the stamp rather than editing the answer inside it. The human’s decision never changes. What changes is what the world did with it.

A host that cannot observe delivery must leave the block at approved. That is an honest state, and it is a better one than a sent nobody witnessed.

4.15.5.2 The gate becomes the receipt. It does not disappear.

§9.2 requires a stamped block to occupy exactly the height it did while open, and on a message the send gate is the tallest thing on the block — so removing it would jump every pixel below it up the page at the exact moment the human is looking for confirmation.

It transforms instead. The question stays on screen and the button is replaced by the answer:

   ╭─ EMAIL ───────────────────────────────── draft · not sent ──╮      ╭─ EMAIL ─────────────────────────────────────────── sent ──╮
   │  Re: the retry ladder                                       │      │  Re: the retry ladder                                     │
   │  ─────────────────────────────────────────────────────────  │      │  ───────────────────────────────────────────────────────  │
   │  TO    j@duplo.org                                          │  ►   │  TO    j@duplo.org                                        │
   │                                                             │      │                                                           │
   │  Hey Jonas, the retry ladder is in.                         │      │  Hey Jonas, the retry ladder is in.                       │
   │  ─────────────────────────────────────────────────────────  │      │  ───────────────────────────────────────────────────────  │
   │  › Send it?                                        [ Send ] │      │  ✓ Send it?              SENT · by Ana · at 14:07         │
   ╰──────────────────────────────────── draft ──────────────────╯      ╰────────────────────────────────── sent · at 14:07 ────────╯

Keeping the question is the §4.1 rule in a second place. A stamped choice keeps its rejected rows because they show what the human was choosing between; a sent message keeps its gate because “what exactly did the human approve” is the thing a transcript exists to answer.

4.15.5.3 The transition is one attribute.

The state lives in data-state on the frame, and every visual difference — accent, border, pill, receipt — hangs off it. A host stamps by swapping that one attribute on the live element, and the animation follows from CSS transitions with no scripting and no re-render. A host that re-renders the whole string instead gets a one-shot entry animation on the pill.

Two constraints on that animation, and they are not stylistic:

  • Opacity and transform only. Anything that animates a box model reflows the block mid-transition, and §9.2 has just finished promising it will not move.
  • prefers-reduced-motion removes the motion, never the information. The state must still be legible with every animation switched off, which it is, because the state is carried by colour, glyph and words — not by the movement between them.

4.16 @chart

Numbers, as a shape. The block an agent reaches for when it has just measured something and the interesting part is the outline rather than any one value.

@chart id=signups render=bar unit=k as=14:02
? Signups by week
: Week 22 is the launch.
- W21 | 3.2
- W22 | 4.8 | launch
- W23 | 4.1
- W24 | 4.4
   ╭─ Signups by week ───────────────────── signups · 4 values ──╮
   │  Week 22 is the launch.                                     │
   │                                                             │
   │  W21   ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░        3.2k              │
   │  W22   ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓        4.8k   launch     │
   │  W23   ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░        4.1k              │
   │  W24   ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░        4.4k              │
   ╰───────────────────────────── as of 14:02 · snapshot ────────╯

Row: - Label | value | note. The value cell must be a number; the note is free text shown beside it.

AttributeDefaultMeaning
renderbarbar column line spark dot
unit—suffix printed after every value. ms, %, GB
max minderivedmove the range. Neither may exclude a value (§4.16.2).
goal—a reference marker, and it extends the range so it is on screen
values—3,5,4,9 — a series with no labels, for the one-line case
as—the timestamp the data was read, exactly as on a @card

4.16.1 The number is never only in the pixels.

Every mode prints its values as text, the bars carry aria-hidden, and a mode that cannot print every number (a line of thirty points) carries the full series in a visually hidden list and prints the four numbers a trend is read for: first, last, lowest, highest.

A chart nobody can read the values off is a picture of data. This is the same rule §4.12.3 applies to subtask detail, and it fails the same way: on a screen reader the block is simply empty.

The printed value is what the agent typed. 4.8k renders as 4.8k, not as 4800, because the row is the record and rounding it into a canonical form edits the record.

4.16.2 The renderer never invents a scale.

One rule, applied three times: the range must contain every value it draws.

  • The floor defaults to zero, so a bar’s length is its magnitude. A chart that quietly floors at its smallest value makes 98 look twice 96. Negative data lowers the floor to reach the data, never to flatter it, and a negative bar grows the other way from the same baseline so its length is still its magnitude.

  • min= may lift the floor, and the block must then say so. Four uptimes between 99.2 and 99.99 are four identical full-height bars against a zero floor — a chart that has told the reader nothing. Truncating an axis is legitimate. Truncating it silently is the oldest deception in the subject, so the announcement is not optional and it happens twice: in words, in the scale line, which survives being read aloud — and in the drawing, as a notch at the origin end of every bar, for anyone skimming the shape.

   scale from 99%, not zero · to 100% · goal 99.9%
  • Neither bound may exclude a value. max=100 over a 99.4 is real context and nothing in the rows knows it. max=50 over a 90 could only be drawn by clipping the bar — which prints a number at the end of a bar too short to be that number. That is the §4.12.1 lie with nobody’s fingerprints on it, so the data wins and the parser says so:
   max="50" is below the largest value (90); using 90 so nothing is clipped
   min="99.5" is above the smallest value (99.21); using 99.21 so nothing is clipped
  • A row whose value cell is not a number is refused, loudly, and not drawn. Drawing it at zero would be worse: zero is a claim, and a bar of length nothing under Mon says Monday was nought rather than unreadable.

There is no sort=. The rows are already in the order the agent chose, and a chart that reorders a time series is lying about sequence.

4.16.3 No axes, no gridlines, no legend, no tooltips.

A chart in a transcript is read in one glance, next to the sentence that explains it, in a column that may be 380px wide. Axis furniture costs most of that width to restate numbers that are already printed on the rows, and a tooltip is §4.12.3’s hover failure again: it does not exist on a keyboard and it does not exist on a phone.

One series per block, for the same reason. Two series need a legend, a legend needs colour to carry meaning, and colour carrying meaning alone is where accessibility goes to die. Put two charts side by side in a @grid.

4.16.4 render=spark is the one-line case.

@chart render=spark values=12,14,11,19,24,22,31 unit=ms goal=20
? p95 latency, 7d
   ╭─ p95 latency, 7d ──────────────────────────────  7 values ──╮
   │                                        ╭╮                   │
   │  ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈╭─╮┈┈┈┈┈╭──╯╰──● 31 ms                 │
   │  ╰─╮ ╭──╯ ╰──────────╯  ╰─────╯                             │
   │                                                             │
   ╰───────────────────────────────────────────  snapshot ───────╯

values= is sugar and the rows are the grammar: a block with both keeps the rows and warns, because an agent that wrote both meant the rows and forgot to delete the shorthand. The dashed rule is goal=.

4.17 @flow

A process, drawn. Pipelines, retry ladders, state machines, approval chains — the shape an agent otherwise renders as five lines of arrows in a code fence.

@flow id=retry dir=right as=14:02
? Payment retry ladder
- [x] charge | Charge      | 1st attempt
- [~] retry1 | Retry 1     | 1m backoff
- [!] dlq    | Dead letter | shape=round
- charge -> retry1 | fails
- retry1 -> dlq    | 2h
- retry1 -> charge | recovered
   ╭─ Payment retry ladder ─────────────────── retry · 3 steps ──╮
   │                                                             │
   │   ╭────────────╮  fails   ╭────────────╮  2h  ╭───────────╮ │
   │   │ Charge     │ ───────► │ Retry 1    │ ───► │Dead letter│ │
   │   │ 1st attempt│          │ 1m backoff │      ╰───────────╯ │
   │   ╰────────────╯          ╰────────────╯                    │
   │         ▲                        ┊                          │
   │         └┈┈┈┈┈┈┈ recovered ┈┈┈┈┈┈┘                          │
   ╰───────────────────────────── as of 14:02 · snapshot ────────╯

One sigil, and the arrow decides what the row is. A row with an arrow is an edge; a row without one declares a node. This is §3.1’s - trick a third time: in a @choice a dash is an option, in a @card the checkbox makes it a task, and here the arrow makes it a connection.

   node  - [state] id | Label | note | shape=box|round|diamond
   edge  - from -> to | label            (also --> => →)
         - a -> b -> c                   a chain is one row, two edges
         - a -- b                        undirected. Spaces required, because
                                         `--` is one hyphen from `dead-letter`.
AttributeDefaultMeaning
dirrightright or down. A long chain reads better down in a chat column.
as—the timestamp, as everywhere else

4.17.1 The checkbox means what it means everywhere else.

[ ] [~] [x] [!] on a node are the four @card states, wearing the four @card accents. A deploy pipeline with two green stages and one blocked one is the single most useful diagram an agent can put in a transcript, and it costs no new vocabulary to say so.

There is no progress bar on a flow, and no n/m. A graph is not a checklist: its steps are not equal, several may be optional, and only one path through it will actually be taken. Counting them would produce exactly the number §4.12.1 exists to ban. If you want the count, the block for it is @card.

4.17.2 An edge may name a node nobody declared.

- build -> test on its own is a complete diagram, and it draws two boxes. The implied node takes its id as its label and todo as its state. Forcing two declaration rows first would make the common case the verbose one.

The cost is that a typo in an id silently grows a box instead of failing, so anvil-lint reports a declared node no arrow touches — retry-1 declared and retry1 wired up is the shape of that mistake.

4.17.3 Cycles are legitimate, and they are drawn as returns.

A retry ladder is a cycle. So is every state machine worth drawing. The layout lifts the back edges out before ranking, draws them dashed in a lane outside the body, and ranks everything else by longest path — so a step that waits on two things is drawn after both of them.

A renderer that dropped the back edge would be hiding the loop that is the whole point of the diagram, and one that fed it to the ranker would not terminate. Neither is acceptable in a total pipeline.

4.17.4 The layout is computed, never measured.

This renders to a string in a pipeline that has no DOM and no layout pass, so box widths come from a character count against a monospace advance — what an ASCII diagram has always done, and exact for the font the stylesheet pins. Labels past the cap are cut with an ellipsis and carried in full in a title. The gutter between ranks is sized from the widest edge label in the graph, or a label longer than the gap lies across the boxes it describes.

Past forty nodes a flow stops being a sentence and becomes a document, which is the line §4.14 L2 already drew for layout. Nodes past the cap are not drawn and the count is printed, exactly like a @board lane’s +N more.

4.18 @connect

An agent asking for a key to somewhere it cannot reach on its own: a Drive, a mailbox, a Stripe account, a repo. The moment where an assistant stops being a conversation and starts being something with hands, and the moment a human most deserves to know exactly what they are handing over.

@connect id=gdrive provider=google-drive submit=Connect
? Connect Google Drive
: So I can read Products and Brand
- drive.readonly | Read your files       | never write, never delete
- drive.metadata | See file names
- !drive.file    | Create and edit files | I can overwrite what I make
   ╭─ GOOGLE DRIVE ───────────────────────── not connected ─╮
   │  Connect Google Drive                                  │
   │  So I can read Products and Brand                      │
   │  ────────────────────────────────────────────────────  │
   │  ○ Read your files        never write, never delete    │
   │  ○ See file names                                      │
   │  ! Create and edit files  I can overwrite what I make  │
   │                                                        │
   │                                           [ Connect ]  │
   ╰──────────────── 3 scopes · not connected ──────────────╯
AttributeMeaning
providerwhich service. Closed per host; an unknown one draws no logo (§4.18.5).
accountwhich identity, when the human has more than one. Text, never a link.
statedisconnected (default) connected partial declined failed abandoned
grantedwith partial, the scope rows that survived. The count is read off them.
at bywhen the grant resolved, and under whose account
errorwith failed, what the provider said. Implies state=failed.
submitthe button label. Defaults to Connect, and should say the verb.
expiresas §4. A consent window left open all afternoon is the §4.11 problem with a key attached.
danger phraseas §4, for a grant that hands over destructive scopes

? is the headline, : the reason in the agent’s own voice. Each - row is one scope: scope | Label | why. A ! prefix marks a scope that can destroy something, exactly as it marks a destructive option in §4.1.

There is no ask=. @card and @message take one because a card and a memo are useful things to show with no question attached, and §2 is emphatic that those two are the only blocks that change category on an attribute. A @connect is asking by construction. The block that looks like a record is the same block in a terminal state: state=connected is absorbing, so it renders the grant and no button, and nothing had to switch category to get there.

4.18.1 The scopes are rows, because they are the thing being consented to.

The obvious header is scopes=drive.readonly,drive.file, and it is wrong twice. It introduces a second list syntax next to |, in a language that has exactly one. And it puts the only content that matters into attribute position, where a renderer either prints drive.readonly,drive.file at a human or hides it.

As rows, each scope carries the machine string, the human label, and the sentence that makes it agreeable. Those are three different readers: the API, the person, and the person’s suspicion. §4.12.1’s argument arrives here intact — the block prints 3 scopes because there are three rows, and there is no count= to disagree with them.

4.18.2 disconnected is the default, and that is a safety property.

Every state past disconnected has to be asserted, for the same reason a @message renders draft · not sent until something says otherwise (§4.15.1). A human who scrolls back two screens, sees a Connect card, and concludes their Drive is wired up will not check again.

connected=true is the shape to avoid. It is one boolean over six states, and the sent=No scar says what happens next: a model writes connected=No, one capital letter defeats the comparison, and the safe default was not a default.

4.18.3 The click is consent, and the stamp waits for the answer.

   disconnected ──click──► authorising ──stamp──► connected
                           (§7.2 pending)         │
                                                  ├─► partial
                                                  ├─► declined
                                                  └─► failed

The click opens someone else’s consent screen. Nothing is known yet, so nothing is stamped yet: the block sits in authorising, which is §7.2’s pending wearing a longer coat. Thirty seconds is a long spinner and a legitimate one, because the human is off reading Google’s checkboxes.

The stamp is written when the flow returns, and it carries the outcome. A human who closes the popup and never comes back trips expires and the block falls back to disconnected with an error strip, exactly as §7.2 already specifies for a nak.

This is deliberately not @message’s shape, and the difference is worth stating: an email’s fate arrives seconds later from a different actor, so §4.15.5.1 has to permit one post-stamp mutation. An OAuth flow answers in the same round trip the human is standing in. So @connect needs no exception, and §4.15.5.1’s claim to be the only one in the language survives.

4.18.4 A grant comes back partial, and the rows record which.

This is the reason @connect is a block rather than a @choice with a link on it. An email is sent or it is not. A grant is a negotiation: every serious provider’s consent screen has per-scope checkboxes, and people uncheck the one that scares them.

So the outcome is written per row, and the block keeps every row it asked for:

   ╭─ GOOGLE DRIVE ─────────────────────────────── partial ─╮
   │  Connect Google Drive                                  │
   │  So I can read Products and Brand                      │
   │  ────────────────────────────────────────────────────  │
   │  ✓ Read your files                      granted        │
   │  ✓ See file names                       granted        │
   │  ✗ Create and edit files                refused        │
   │                                                        │
   │                                     Jonas · 14:02      │
   ╰───────────────── 2 of 3 granted · 14:02 ───────────────╯

The refused row stays on screen for the §4.1 reason, arriving in a third place: a stamped @choice keeps its rejected options because they show what the human was choosing between, and a refused scope shows what the agent asked for and did not get. An agent that reads only state=partial will try to write to Drive and fail; an agent that reads the rows knows not to.

Which makes the delivery rule sharp: the granted scopes are the stamp’s values=, and the refused ones are named too. A partial grant the model never learns about is a block that has recorded a decision and told nobody.

<stamp block="gdrive" kind="connect" state="partial"
       values="drive.readonly,drive.metadata"
       labels="Read your files,See file names"
       refused="drive.file" at="14:02" by="Jonas">
  I connected Google Drive, but not with write access.
</stamp>

4.18.5 The provider’s logo is a phishing primitive.

An agent-supplied img= sitting beside the words “Connect Google” and a button is a credential-harvest card, rendered by a trusted surface, inside a transcript the human already trusts. §8.3 allowlists img= to http(s) because the risk there is a stylesheet escape. Here the URL can be perfectly well-formed and the card is still an attack.

So @connect takes no img= at all. The mark comes from a host allowlist keyed by provider=, and a provider the host has no entry for renders with a neutral mark plus a warning, in the shape §4.15.4 already established for an unknown channel. Drawing an unrecognised provider in a familiar logo tells the human this goes somewhere it does not.

4.18.6 A connect block is a receipt, not a status light.

The grant will be revoked, or the refresh token will die in six weeks. The block must not notice. It records that at 14:02 a human granted two scopes, and that stays true forever, which is §4.12.2’s rule about live state arriving at the block with the strongest temptation to break it.

A connection that has since lapsed is a new block, in the turn where the agent discovered it. Nothing re-renders in place.

For the same reason there is no Disconnect button. Revoking is not un-answering a question (§7.1, law I), it is a different action with a different blast radius, and it belongs in the provider’s own settings or in a @choice with danger on it. A block that could both grant and revoke would be a widget that can be re-answered, which §1 spent the whole document ruling out.