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
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.
| Group | Blocks |
|---|---|
| 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
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
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
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:
- 01 A click stamps the block, forever. There is no unstamp verb, in the language, in the API, or in the database.
- 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.
- 03 One stamp per block id. The second is refused, not queued and not overwritten.
- 04 A stamped block still renders in full - the answer, the rejected options, who, and when. Never collapsed to text.
- 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.
| Package | What | State |
|---|---|---|
| @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.