Specification · v1 Working Draft
anvil·md

Overview  ·  Abstract

Ask inline.
Ask once.

A tiny DSL an agent writes inside a fenced code block, mid-sentence. It renders as real UI, at the point in the conversation where the question was asked. The human clicks. The answer comes back as structured text, and the block freezes.

No tool call. No widget runtime. No event bus into the agent loop.

STATUS
Specification v1, working draft. Free to implement.
TERM
Agent-Native Visual Interaction Language
LICENCE
MIT · reference implementation included
SEE ALSO
The full specification, the repository.

1  ·  The two halves

Markdown going down, structured text coming up.

The agent writes this, in the middle of an ordinary message:

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

The human clicks one row. It stamps. The model then receives an ordinary user turn:

<stamp block="deploy-target" kind="choice" value="stage" label="Staging">
I picked Staging.
</stamp>

That tag is the entire integration. The attributes are the truth; the sentence is the courtesy, so the transcript still reads like a conversation months later and a model that ignores the tag still gets the gist.

2  ·  Vocabulary

Ten blocks, closed on purpose.

Everything below is rendered on this page by the reference implementation, from source.

GroupBlocks
ASK @choice @gallery @input @upload
@link @scale @order
SHOW @note @code @example
CONTROL @void

Ask the aesthetic questions visually. Six swatches beat six adjectives, every time.

@gallery id=palette render=swatch select=one
? Which palette?
- ink  | Ink and paper | warm, printed | swatch=#111111,#f5f2ea,#c8452d
- deep | Deep water    | cold, gridded | swatch=#0b2540,#1d7a8c,#e8f1f2
- volt | Volt          | loud, black   | swatch=#0a0a0a,#e6ff00,#8a8a8a
Which palette?
preview

Some answers are a dial, not a pick. “How formal” was never multiple choice.

@scale id=tone steps=5
? Set the dials
% formal | Formal | Playful | 2
% dense  | Dense  | Airy    | 1
% quiet  | Quiet  | Loud    | 5
Set the dials
Formal Playful
Dense Airy
Quiet Loud
preview

Typed fields, when you genuinely need words. Eight types, and secret is masked everywhere - in the control, in the record, and in the tag that reaches the model.

@input id=company submit="That's us"
? Who are you?
_ legal*  | text   | Legal name      | Acme Ltd
_ site    | url    | Current website | https://...
_ token   | secret | API key
_ nda     | bool   | We need an NDA first
Who are you?
preview

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

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

Staging shares the production database.

Migrations you run there are real.

3  ·  The stamp

Answered once, then frozen.

A conversation transcript is an append-only record of things that happened.
A widget that can be re-answered turns that record into a lie.

Every rule in the specification falls out of that sentence. Five of them are normative:

  1. 01 A click stamps the block, forever. There is no unstamp verb, in the language, in the API, or in the database.
  2. 02 The stamp lives on the server, keyed by block id. What the client holds is a cache, and it is allowed to be wrong.
  3. 03 One stamp per block id. The second is refused, not queued and not overwritten.
  4. 04 A stamped block still renders in full - the answer, the rejected options, who, and when. Never collapsed to text.
  5. 05 To change an answer, the agent asks again in a new block. History is append-only.

A stamped block keeps its rejected options on screen. They are part of the record: they show what the human was choosing between.

4  ·  Constraints

What the specification is careful about.

Most of these exist because someone got them wrong first.

The parser never throws
An LLM emits a fence token by token, so every prefix of every fence is a real input a renderer will see. A thrown parse error inside a page renderer takes down far more than one block. Malformed input degrades with a visible warning instead.
Inert while streaming
A block whose last option has not arrived renders visibly provisional and cannot be answered. Otherwise someone answers a question they have not finished reading.
Ids come from content
A block with no id= gets one hashed from its body, never from its position - streaming can reorder, and a positional id lands the answer on the wrong block.
Height cannot change
A block occupies the same space stamped as it did open. One that shrinks three screens up yanks the scroll position out from under the reader.
Attributes are allowlisted
swatch, font and img land in style and src, where escaping is not sufficient. Hex only, conservative family names, http(s) only. A value that fails is dropped, not escaped.
Answers are untrusted
A stamp is free text somebody typed, sitting next to the agent’s own markdown and reading like it. It has to reach the model framed as data, not as instructions.

5  ·  Implement

Two packages, one of them with no dependencies.

PackageWhatState
@anvil-md/parser Document model and parser. Zero dependencies, no DOM. STABLE
@anvil-md/render-html Fence to HTML string, plus a themeable baseline stylesheet. DISPLAY ONLY
bun add @anvil-md/parser @anvil-md/render-html
// wherever you render an agent's markdown
renderer.code = ({ text, lang, raw }) => {
  if (lang === 'anvil') {
    return renderAnvilFence(text, fenceIsClosed(raw))
  }
}

Display only means the emitted controls carry disabled: this draws a block, it does not run one. Stamping needs server-held state, idempotency and a permission model, none of which belong in a rendering package. Sections 6 and 7 of the specification are the contract if you are building it.