Specification · v1 Working Draft
anvil·md

Appendix  ·  Examples

The whole language, drawn.

Source on the left, what the reference renderer makes of it on the right. Every block below was produced at build time by renderAnvilFence, so this page cannot show you something the packages do not actually do.

EXAMPLES
24, every one real ANVIL a test lints on each run.
DISPLAY
Controls are inert by design - this draws a block, it does not run one.
TRY IT
Every source here pastes straight into the playground.

Asking

The original half of the language. Each of these stamps once and then freezes, and the rejected options stay on screen because they show what the human was choosing between.

@choice

§4.1 #

The hint column carries the consequence, not a restatement of the label. live traffic, no undo earns its pixels; "the production environment" would not. A ! prefix marks one row destructive.

Source

@choice id=deploy-target
? Where should I ship this?
: Staging is wiped nightly. Nothing there is permanent.
- prod   | Production  | live traffic, no undo
- stage  | Staging     | safe, wiped nightly
- !scrap | Start over  | we bin the existing build
- hold   | Nowhere yet | keep it on the shelf

Rendered

Where should I ship this?
Staging is wiped nightly. Nothing there is permanent.
preview

@choice select=many

§4.1 #

Multi-select is the same block with one attribute. It grows checkboxes and a submit, because a set is not answered until the human says it is finished.

Source

@choice id=stack select=many min=1 max=3 submit="That's the stack"
? What is already in the building?
: Up to three. Pick what you would not remove.
- pg     | Postgres    | the source of truth
- redis  | Redis       | queues and sessions
- s3     | Object store
- k8s    | Kubernetes  | somebody has to run it

Rendered

What is already in the building?
Up to three. Pick what you would not remove.
preview

@input

§4.3 #

Eight field types, each with its own control. secret is the one that matters: it is masked here and the value never reaches the transcript, because a stamp is text a model reads.

Source

@input id=company submit="That's us"
? Who are you?
: Anything marked with a star is required before the block will submit.
_ legal*  | text     | Legal name      | Acme Ltd
_ site    | url      | Current website | https://...
_ seats   | number   | How many seats  | 25
_ token   | secret   | API key
_ brief   | longtext | Anything else we should know
_ nda     | bool     | We need an NDA first

Rendered

Who are you?
Anything marked with a star is required before the block will submit.
preview

@scale

§4.8 #

For when the answer is a dial rather than a pick. "How formal" is never a multiple-choice question. The poles are what the human reads; the name is the machine key.

Source

@scale id=voice steps=7
? How should the copy read?
: Seven notches. The middle one is the default when you omit the number.
% formal | Formal   | Playful  | 3
% terse  | Terse    | Detailed | 5
% warm   | Clinical | Warm     | 6

Rendered

How should the copy read?
Seven notches. The middle one is the default when you omit the number.
Formal Playful
Terse Detailed
Clinical Warm
preview

@note

§4.5 #

Three tones and no interaction. A full border and a tint, never a left accent stripe -- the stripe is the tell of every default markdown callout and reads as boilerplate.

Source

@note tone=warn
> Staging shares the production database.
> Migrations you run there are real, and there is no snapshot.

@note tone=danger
> The loudest tone in the language. Spend it carefully.

@note tone=info
> And the quiet one, for the things that are not questions at all.

Rendered

Staging shares the production database.

Migrations you run there are real, and there is no snapshot.

The loudest tone in the language. Spend it carefully.

And the quiet one, for the things that are not questions at all.

Records

Blocks that show rather than ask. Every number they draw is counted from the rows above it: there is no progress attribute, and that absence is the reason these are blocks instead of markdown.

@card

§4.12 #

Delete a subtask row and the bar moves. There is nowhere to type 3/7, so a card cannot claim a number its own list disagrees with. Hover or tab to a row with a > line under it.

Source

@card id=ANV-114 type=story status=flight as=14:02 href=https://x.dev/ANV-114
? Payment retry ladder
: Three attempts, 1m / 10m / 2h, then dead-letter.
+ !high | 5 pts | 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 - two days in flight, three of five checks green.
> Needs the SQS policy from infra before this can merge.
- [!] ANV-118 | Alerting hook        | blocked on ANV-117
- [ ] ANV-119 | Metrics
- [ ] ANV-120 | Runbook entry
- [ ] ANV-121 | Load test at 10x

Rendered

ANV-114 story In flight
Payment retry ladder
Three attempts, 1m / 10m / 2h, then dead-letter.
2/7 · 29%
  • ANV-115Retry schedulerAna
  • ANV-116Backoff policy tableAna
  • ANV-117Dead-letter queueKitKit - two days in flight, three of five checks green.Needs the SQS policy from infra before this can merge.
  • ANV-118Alerting hookblocked on ANV-117
  • ANV-119Metrics
  • ANV-120Runbook entry
  • ANV-121Load test at 10x
  • high
  • 5 pts
  • Sprint 24
  • epic ANV-100
snapshot · as of 14:02

@card type=epic

§4.12.1 #

An epic is the same block whose rows carry their own counts. The parent sums them exactly -- 3/7 + 6/6 + 0/9 -- rather than averaging three percentages into a number that means nothing.

Source

@card id=ANV-100 type=epic as=14:02
? Billing that survives a bad night
: Every child carries its own count, and the parent sums them.
- [~] ANV-114 | Payment retry ladder | 3/7
- [x] ANV-130 | Idempotency keys     | 6/6
- [ ] ANV-141 | Dunning emails       | 0/9

Rendered

ANV-100 epic In flight
Billing that survives a bad night
Every child carries its own count, and the parent sums them.
9/22 · 41%
todo1in flight1done1
  • ANV-114Payment retry ladder3/7
  • ANV-130Idempotency keys6/6
  • ANV-141Dunning emails0/9
snapshot · as of 14:02

@board

§4.13 #

The same task rows, grouped by the state they already carry. Lanes are derived, so a board cannot claim a count that disagrees with the cards in it, and max never truncates silently.

Source

@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

Rendered

Sprint 24 2/8 · 25% done
Todo3
  • ANV-119Metrics
  • ANV-120Runbook entry

+1 more

In flight3
  • ANV-117Dead-letter queue
  • ANV-142Webhook replay

+1 more

Done2
  • ANV-115Retry scheduler
  • ANV-116Backoff policy table
snapshot · as of 14:02

@card ask=

§4.12.4 #

One attribute turns a record into a question. Only the open rows become pickable -- offering a done or blocked subtask as "what next" would be a lie about what the human can choose.

Source

@card id=ANV-114 type=story ask="Which one do you want me to pick up next?"
? Payment retry ladder
- [x] ANV-115 | Retry scheduler
- [!] ANV-118 | Alerting hook | blocked on ANV-117
- [ ] ANV-119 | Metrics
- [ ] ANV-120 | Runbook entry
- [ ] ANV-121 | Load test at 10x

Rendered

ANV-114 story In flight
Payment retry ladder
1/5 · 20%
Which one do you want me to pick up next?
  • ANV-115Retry scheduler
  • ANV-118Alerting hookblocked on ANV-117
snapshot

Messages

A message an agent is proposing to send, drawn with the chrome of the thing it will become. It reads as a draft until something asserts otherwise, and that default is the safety property.

@message channel=email

§4.15 #

Addresses are text, never links. A mailto: inside a draft is one mis-click from a composer pre-filled with agent-authored text, and this block is the place a human reads what is about to go out.

Source

@message channel=email to="jonas@duplo.org" cc="ana@x.dev, kit@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. The
> alerting hook is still blocked on the SQS policy from infra.
+ patch.diff | 4 KB

Rendered

Email draft · not sent
Re: the retry ladder
Frombot@frst.dev
Tojonas@duplo.org
Ccana@x.devkit@x.dev

Hey Jonas,

The retry ladder is in. Three attempts, then dead-letter. The alerting hook is still blocked on the SQS policy from infra.

  • patch.diff4 KB
Send it?
draft

Channels

§4.15.4 #

Three chromes cover every channel worth drawing: an envelope, a bubble, a channel post. Anything unrecognised renders as a plain memo with a warning rather than borrowing somebody else's bubble.

Source

@message channel=whatsapp to="+66945556292" sent=14:07
> retry ladder is live. 2 of 7 subtasks done.

@message channel=slack to="#eng-billing" ask="Post it?"
? Deploy notice
> Shipping the retry ladder to prod in ten minutes.

Rendered

WhatsApp +66945556292 sent

retry ladder is live. 2 of 7 subtasks done.

sent · at 14:07
Slack #eng-billing draft · not sent
Deploy notice

Shipping the retry ladder to prod in ten minutes.

Post it?
draft

draft → approved → sent

§4.15.5 #

The click is approval, not delivery. A stamp records that a human said yes at 14:04; the send happens afterwards and can fail. Note that the gate never disappears -- it becomes the receipt, at the same height, so nothing below it jumps.

Source

@grid cols=2 min=19rem
@message channel=email to="j@duplo.org" ask="Send it?"
? Re: the retry ladder
> The ladder is in. Three attempts, then dead-letter.
@message channel=email to="j@duplo.org" ask="Send it?" state=approved at=14:04 by=Ana
? Re: the retry ladder
> The ladder is in. Three attempts, then dead-letter.
@message channel=email to="j@duplo.org" ask="Send it?" state=sent at=14:04:23 by=Ana
? Re: the retry ladder
> The ladder is in. Three attempts, then dead-letter.
@message channel=email to="j@duplo.org" ask="Send it?" at=14:04:26 by=Ana error="550 mailbox unavailable"
? Re: the retry ladder
> The ladder is in. Three attempts, then dead-letter.
@end

Rendered

Email draft · not sent
Re: the retry ladder
Toj@duplo.org

The ladder is in. Three attempts, then dead-letter.

Send it?
draft
Email approved · sending
Re: the retry ladder
Toj@duplo.org

The ladder is in. Three attempts, then dead-letter.

Send it? approved · sending by Ana · at 14:04
approved · at 14:04
Email sent
Re: the retry ladder
Toj@duplo.org

The ladder is in. Three attempts, then dead-letter.

Send it? sent by Ana · at 14:04:23
sent · at 14:04:23
Email not sent
Re: the retry ladder
Toj@duplo.org

The ladder is in. Three attempts, then dead-letter.

Send it? not sent by Ana · at 14:04:26 550 mailbox unavailable
send failed · at 14:04:26

Pictures

Numbers as a shape, and a process as a diagram. Both are records: neither one ever stamps, and neither one refreshes itself. Every value a chart draws is also printed, because a shape you cannot read a number off is a picture of data rather than data.

@chart

§4.16 #

The range starts at zero, always. A chart floored at its smallest value makes 98 look twice 96, which is the oldest trick in the book -- and the one thing an agent that cannot see the screen should never be trusted to decide.

Source

@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

Rendered

Signups by week signups 4 values
Week 22 is the launch.
  • W21 3.2k
  • W22 4.8klaunch
  • W23 4.1k
  • W24 4.4k
snapshot · as of 14:02

A truncated axis, announced

§4.16.2 #

Four uptimes between 99.2 and 99.99 are four identical full-height bars against a zero floor -- a chart that has told you nothing. min= lifts the floor, and the block then says scale from 99%, not zero in warning ink and notches every bar at the origin. Truncating is fine. Truncating silently is the oldest deception in the subject.

Source

@chart id=uptime render=column unit=% min=99 max=100 goal=99.9 as=14:02
? Uptime by service
- api      | 99.98
- workers  | 99.94
- webhooks | 99.21 | two incidents
- search   | 99.99

Rendered

Uptime by service uptime 4 values
  1. 99.98% api
  2. 99.94% workers
  3. 99.21% webhooks
  4. 99.99% search

scale from 99%, not zero · to 100% · goal 99.9%

snapshot · as of 14:02

render=spark

§4.16.4 #

The one-line case. values= is shorthand for a series with no labels, which is what a trend actually is -- and the full series still goes into the page as text, so the block is not empty to a screen reader.

Source

@chart id=p95 render=spark values=12,14,11,19,24,22,31 unit=ms goal=20
? p95 latency, 7d
: The dashed rule is the SLO.

Rendered

p95 latency, 7d p95 7 values
The dashed rule is the SLO.
31 ms
  • Point 1: 12 ms
  • Point 2: 14 ms
  • Point 3: 11 ms
  • Point 4: 19 ms
  • Point 5: 24 ms
  • Point 6: 22 ms
  • Point 7: 31 ms

goal 20ms

snapshot

Negative values

§4.16.2 #

A negative bar grows the other way from the same baseline, so its length is still its magnitude and only its direction changed. Zero moves to wherever zero actually is.

Source

@chart id=delta render=bar unit=% as=14:02
? Week on week
- signups   | 12.4
- activated | 4.1
- churn     | -2.8 | good
- revenue   | -0.4

Rendered

Week on week delta 4 values
  • signups 12.4%
  • activated 4.1%
  • churn -2.8%good
  • revenue -0.4%
snapshot · as of 14:02

@flow

§4.17 #

One sigil, and the arrow decides. A row with an arrow is an edge, a row without one declares a node -- the same trick a @card plays with the checkbox. The four states are the four @card states, wearing the same accents.

Source

@flow id=retry dir=right as=14:02
? Payment retry ladder
- [x] charge | Charge      | 1st attempt
- [x] retry1 | Retry 1     | 1m backoff
- [~] retry2 | Retry 2     | 10m backoff
- [!] dlq    | Dead letter | shape=round
- charge -> retry1 | fails
- retry1 -> retry2 | still failing
- retry2 -> dlq    | after 2h
- retry2 -> charge | recovered

Rendered

Payment retry ladder retry 4 steps
failsstill failingafter 2hrecoveredCharge — 1st attemptCharge1st attemptRetry 1 — 1m backoffRetry 11m backoffRetry 2 — 10m backoffRetry 210m backoffDead letter
  • Step 1: Charge — 1st attempt (done)
  • Step 2: Retry 1 — 1m backoff (done)
  • Step 3: Retry 2 — 10m backoff (in flight)
  • Step 4: Dead letter (blocked)
  • charge leads to retry1, fails
  • retry1 leads to retry2, still failing
  • retry2 leads to dlq, after 2h
  • retry2 leads to charge, recovered
snapshot · as of 14:02

Shapes, and a cycle

§4.17.3 #

A cycle is legitimate -- a retry ladder is a cycle, and so is every state machine worth drawing. The back edge is lifted out of the ranking and drawn dashed in its own lane, because dropping it would hide the loop that is the whole point of the diagram.

Source

@flow id=review dir=down as=14:02
? Pull request review
- [x] open   | PR opened  | shape=round
- [~] review | Approved?  | shape=diamond
- [ ] merge  | Merge      | squash
- [ ] fix    | Push fixes
- open -> review
- review -> merge | yes
- review -> fix   | changes requested
- fix -> review   | re-request

Rendered

Pull request review review 4 steps
yeschanges requestedre-requestPR openedApproved?Merge — squashMergesquashPush fixes
  • Step 1: PR opened (done)
  • Step 2: Approved? (in flight)
  • Step 3: Merge — squash (todo)
  • Step 3: Push fixes (todo)
  • open leads to review
  • review leads to merge, yes
  • review leads to fix, changes requested
  • fix leads to review, re-request
snapshot · as of 14:02

A picture next to the thing it is about

§4.14 #

This is what the two blocks are for. The card is the work, the chart is the reason -- side by side in the sentence where the question was asked, rather than in a dashboard somebody has to go and find.

Source

@grid cols=2 min=18rem
@card id=ANV-140 type=bug status=flight as=14:02
? Webhook retries are hammering the origin
+ !P1 | Sprint 24
- [x] ANV-141 | Reproduce in staging | Ana
- [~] ANV-142 | Add a retry budget   | Kit
- [ ] ANV-143 | Backfill the alert
@chart id=webhook-rate render=spark values=210,240,260,890,1240,1180,1330 unit=/min goal=300
? Origin requests, 7d
: The step is when the retry loop went out.
@end

Rendered

ANV-140 bug In flight
Webhook retries are hammering the origin
1/3 · 33%
  • ANV-141Reproduce in stagingAna
  • ANV-142Add a retry budgetKit
  • ANV-143Backfill the alert
  • P1
  • Sprint 24
snapshot · as of 14:02
Origin requests, 7d webhook-rate 7 values
The step is when the retry loop went out.
1330/min
  • Point 1: 210/min
  • Point 2: 240/min
  • Point 3: 260/min
  • Point 4: 890/min
  • Point 5: 1240/min
  • Point 6: 1180/min
  • Point 7: 1330/min

goal 300/min

snapshot

Layout

Two containers, and no breakpoints anywhere. The agent writing the fence cannot see the screen, so cols is a maximum rather than a count and the collapse is computed against the container. Drag this window narrower.

@grid

§4.14 #

Three cards at most, one card at least, and every step in between decided by how much room this column actually has. No media query is involved, which is why it still works inside a 380px chat panel on a 5K display.

Source

@grid cols=3 min=15rem gap=normal
@card id=ANV-114 type=story status=flight
? Payment retry ladder
- [x] ANV-115 | Retry scheduler
- [~] ANV-117 | Dead-letter queue
- [ ] ANV-119 | Metrics
@card id=ANV-130 type=story status=done
? Idempotency keys
- [x] ANV-131 | Key derivation
- [x] ANV-132 | Replay guard
@card id=ANV-141 type=story
? Dunning emails
- [ ] ANV-142 | Template set
- [ ] ANV-143 | Cadence rules
@end

Rendered

ANV-114 story In flight
Payment retry ladder
1/3 · 33%
  • ANV-115Retry scheduler
  • ANV-117Dead-letter queue
  • ANV-119Metrics
snapshot
ANV-130 story Done
Idempotency keys
2/2 · 100%
  • ANV-131Key derivation
  • ANV-132Replay guard
snapshot
ANV-141 story Todo
Dunning emails
0/2 · 0%
  • ANV-142Template set
  • ANV-143Cadence rules
snapshot

@stack inside @grid

§4.14 #

Two containers deep, and that is the ceiling. A layout that needs a third level is a document rather than a sentence in a conversation, and that boundary is what stops ANVIL growing into a worse HTML.

Source

@grid cols=2 min=17rem
@stack gap=tight frame
? This sprint
@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
@note tone=warn
> Staging shares the production database. Migrations you run there
> are real, and there is no snapshot.
@end

Rendered

This sprint
ANV-114 story In flight
Payment retry ladder
1/2 · 50%
  • ANV-115Retry scheduler
  • ANV-117Dead-letter queue
snapshot
ANV-130 story Done
Idempotency keys
1/1 · 100%
  • ANV-131Key derivation
snapshot

Staging shares the production database. Migrations you run there

are real, and there is no snapshot.

In practice

What the language actually looks like in a conversation, and what it does when the agent gets it wrong.

One turn of an interview

§10 #

ANVIL is at its best as an interview, not a form: a record to react to, one question, and the warning that makes the question worth asking. Never more than one turn ahead -- an agent that emits all eight blocks at once has written a form with extra steps.

Source

@card id=ANV-114 type=story status=flight as=14:02
? Payment retry ladder
- [x] ANV-115 | Retry scheduler
- [~] ANV-117 | Dead-letter queue | Kit
- [ ] ANV-119 | Metrics

@note tone=warn
> ANV-118 is blocked on an infra ticket nobody has picked up.

@choice id=next-move
? So what do you want me to do with the alerting hook?
: It is the last thing between this and done.
- chase | Chase infra      | I will open a ticket and link it here
- stub  | Stub it for now  | ships today, real alerting next sprint
- wait  | Leave it blocked | the sprint slips

Rendered

ANV-114 story In flight
Payment retry ladder
1/3 · 33%
  • ANV-115Retry scheduler
  • ANV-117Dead-letter queueKit
  • ANV-119Metrics
snapshot · as of 14:02

ANV-118 is blocked on an infra ticket nobody has picked up.

So what do you want me to do with the alerting hook?
It is the last thing between this and done.
preview

Every line here is wrong

§11 #

The parser is total: it has no throw path, because a model emits a fence token by token and a thrown parse error inside a page renderer takes down far more than one block. Each mistake below degrades to a warning and the block still renders.

Source

@choice id=broken steps=99
? Which environment?
-       | Production | the value before the first pipe is missing
- stage | Staging
_       | text | a field row with no name either
% | Left | Right

@chioce id=nope
? a block kind that does not exist at all

@card id=lying progress=99
- [ ] ANV-1 | Ship it | due 24/12
- [z] ANV-2 | An unknown checkbox state
- [~] ANV-3 | More done than there is | 9/4

@input id=typed
_ email | emial | a field type nobody has heard of

Rendered

Which environment?
steps="99" out of range, using 5 · option row with no value · field row with no name · scale row with no name
preview

@chioce id=nope

unknown block "@chioce"
lying Todo
0/3 · 0%
  • ANV-1Ship itdue 24/12
  • ANV-2An unknown checkbox state
  • ANV-3More done than there is9/4
"due 24/12" looks like a count but is not a rollup; write "· n/m" to roll it up · unknown task state "[z]", using todo · "9/4" claims more done than total; not rolled up
snapshot
unknown field type "emial", using text
preview

Next

Nothing above is a special case.

Two functions produced every block on this page, from the strings printed beside them. Both packages are zero-dependency and DOM-free, so the same call runs in a browser tab, in a Worker, or in whatever renders your transcript. Edit any of it in the playground, or read the specification for the grammar and the rules each of these is obeying.