aiui-46b42b — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited aiui-46b42b (Agent Skill) and scored it 92/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 2 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 2 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
aiui exposes MCP tools that render native dialogs on the user's machine, plus one that doesn't wait for the user at all:
confirm — irreversible yes/noask — single- or multi-choice with descriptions and an optionalfree-text fallback (allow_other, default off)
form — composite window with typed fields and multiple action buttonsgallery — batch review of images/videos, one decision per itemcompare — side-by-side A/B (or A/B/C) content compare, pick onenotify — fire-and-forget native OS notification; no dialog, noresponse, returns immediately
The user installed aiui because they want the agent to use it. If you catch yourself about to write any of these in chat, stop and use aiui instead:
confirmaskformrollback, prod deploy) → confirm with destructive: true, even if the user already gave loose approval. The dialog makes the consequence explicit and ships the structured answer back, no chat parsing.
form with a password field, never paste in chat.
("which deploy strategy?", "which migration path?") → ask with per-option description.
form with asortable list field.
in a bounded interval** → form with the matching field.
schedule in ASCII** ("Step A → Step B → ...") → form with a mermaid field. ASCII boxes-and-arrows look terrible in any proportional-font surface; the mermaid field renders to clean SVG. See the dedicated section below.
confirmwith image: {src}. Don't fall back to a form-with-image-and-two- buttons when the question is a plain yes/no.
or generated sound clip** before confirming, choosing, or triaging it → form with an audio field (native <audio controls>). Don't paste a file path in chat and ask the user to open it themselves.
→ ask with thumbnail per option. Use form + image_grid only when there are many candidates (≥ 7) or the picker needs multi-select.
before choosing one — two drafts, three headlines, before/after an edit — → compare. Don't reach for ask+thumbnail here: a thumbnail is too small to actually compare, compare renders the full pane.
are green", "deploy finished", "hit a merge conflict, need you" — where the point is exactly that they don't have to be watching this session → notify, not a chat message and not confirm. If you catch yourself about to end a turn with nothing but "done!" while the user has tabbed away, that's a notify, not silence.
Skip the dialog for content the user reads, doesn't answer:
in chat.
a dialog box anyway — just ask in chat.
(i.e. they're actually looking at this session — otherwise see notify above).
| Intent | Tool |
|---|---|
| Yes/no, especially destructive | confirm |
| Yes/no on a generated image ("is this OK?") | confirm with image: {src} |
| 2–6 options, possibly with per-option context | ask |
| Pick one of N images ("A or B or C") | ask with thumbnail per option |
| Multi-field input, multi-action footer | form |
| Listen to a TTS sample / voice memo / sound clip before deciding | form with an audio field |
| Pick one of many images (e.g. 12 logo variants) | form with image_grid |
| Per-item verdict on a batch of images/videos ("approve/revise/skip each") | gallery |
| Pick one of 2–3 full variants shown side by side (drafts, headlines, before/after) | compare |
| Mark where on an image (point / region) | form with annotated_image |
| Pick from a nested structure (file subtree, config namespaces, org chart) | form with a tree field |
| Async-completion signal, no reply needed, user may not be watching | notify |
| Single free-text answer | just ask in chat |
| More than 8 fields | split into multiple form calls; do not cram one dialog |
Every dialog must have something to answer: an ask needs at least one option, a form at least one field. Every ask option needs a non-empty label — the button shows only the label, so a value-only option is refused (value is what comes back, and falls back to the label). An empty or label-less dialog is rejected up front (invalid_spec) rather than opened as a window the user can only cancel.
cancelled and reasonEvery dialog tool returns cancelled plus that tool's own keys — on a submit and on a cancel, so you can read a key without guarding for it:
| Tool | On submit | On cancel |
|---|---|---|
confirm | {cancelled: false, confirmed} | {cancelled: true, confirmed: false} |
ask | {cancelled: false, answers, other?} | {cancelled: true, answers: []} |
form | {cancelled: false, action?, values} | {cancelled: true, values: {}} |
gallery | {cancelled: false, decisions} | {cancelled: true, decisions: {}} |
compare | {cancelled: false, selected} | {cancelled: true} — selected is absent, never a falsy stand-in |
`cancelled: true` does not mean the user said no. When the dialog ended without anyone answering it, aiui adds a reason:
reason | What happened | What to do |
|---|---|---|
| (absent) | The user actively cancelled — Escape or the Cancel button. | A real decision. Treat it as "no" and do not re-ask. |
ttl_expired | Nobody answered within the dialog's TTL (2 h). | Nobody saw the question. Safe to re-ask later, or say the request timed out. |
evicted | aiui dropped this dialog to stay under its 16-dialog cap — usually your own later dialogs pushed it out. | Do not re-ask blindly; you are probably opening too many dialogs at once. |
channel_dropped | Internal: the result channel closed (companion restart, WebView reload). | A transient failure, not an answer. Retry once. |
host_exiting | The companion was shutting down. | Transient. Retry once the user's machine is back. |
abandoned | aiui closed the dialog because nothing polled it for minutes — the agent's connection to the companion was gone (the agent quit, or the link was down too long). | Not an answer, and whatever the user entered is gone. Re-ask if you still need it. |
Never report a reason-carrying cancel as a user decision. "You declined the migration" after a ttl_expired is a decision the user never made. Treat an unknown reason as "we gave up", not as a user answer.
notifynotify is the odd one out: it does not open a window and does not wait for the user. Call it, get {ok: true} back immediately, move on. Use it for the class of thing you'd otherwise announce in chat and hope the user notices — "tests green", "deploy finished", "hit a merge conflict, need you" — when they may not be looking at this session at all. That's also the line that separates it from confirm: if the message expects a reply, it's the wrong tool — notify has no way to carry an answer back.
Spec: {title, body, subtitle?, sound?}. title and body are required.
title — short headline, notification banners truncate anything past~40 characters. State the outcome, not the process ("Deploy finished", not "Deploying...").
body — the detail: what finished, what needs attention, what broke.subtitle — optional extra context line (folded into body onplatforms/backends without a distinct subtitle slot — don't rely on it rendering as a visually separate line).
sound — optional OS sound name (e.g. "default"); omit for a silentnotification.
On macOS the first call triggers the one-time notification-permission prompt, same as any other native app — if the user has denied it, notify returns {ok: false, error} rather than erroring the tool call; that's an expected outcome, not a bug to retry around.
Anti-patterns:
notify for anything that expects an answer — that's confirm/ask/form. notify is one-way.
notify for routine intermediate progress ("step 3 of 10") —reserve it for the completion/attention-worthy moment, or every step becomes a notification and the signal drowns.
title with a full sentence — put the sentence in body.your age" / "What's your role?") reads as AI slop.
"enter value here".description/static_text only when the label alone is ambiguous —avoid redundancy.
ask: 2–6 options, ≤ 5 words per option label, a one-sentencedescription stating the trade-off. More than 8 options → form with a list field.
form: ≤ 8 fields per dialog — split logically if you need more.confirm: the title is the decision as a question, ≤ 10 words; messageis one sentence stating the concrete consequence.
"Create report" beats "OK".primary: true → blue, the main action.success: true → green, positive-outcome verbs ("Approve", "Publish").destructive: true → red, irreversible verbs ("Delete", "Rollback").Never style a save button red; never style a delete button green.
skip_validation: true) so required-field validationnever traps the user.
skip_validation: true is non-committing: pressing it writes no target field to disk, and each such field comes back as {written: false, error: "action '<value>' does not commit target writes"}. The built-in submit button always commits — and so does an action with value: "__submit__", whatever flags it carries. If an action needs both — skip validation and write — set writes_targets: true on it explicitly.
value — it is how thepressed button is identified, so a missing or repeated one is refused with invalid_spec. An action value the spec never declared is refused (fail closed).
The constraints you declare are checked in the dialog, not just decoration on the widget — an affirmative action only fires once they all hold:
required — a blank field, a whitespace-only text, an unset end of adate_range, or an empty table / image_grid / annotated_image selection blocks the submit.
min / max on number and slider — a value outside the intervalblocks the submit, including one typed straight into the box (native min/max only constrain the stepper arrows).
to the offending tab, shows a footer message and marks the fields. skip_validation: true is the documented way past all of it.
Defaults are normalised before the user ever sees them, so what comes back is what was on screen:
select without a default, or with a default that is not one ofits options, resolves to its first option — never "" or a value you never offered.
default the native control cannot represent is normalised orcleared rather than round-tripped: date must be ISO YYYY-MM-DD, datetime ISO YYYY-MM-DDTHH:MM (a datetime default carrying Z or an offset is converted to the user's local time, not stripped of its zone), color must be #rrggbb, and a number / slider default outside [min, max] is clamped into it.
slider min / max are optional and default to 0–100.default_selected keeps only values the widget actually offers, withoutrepeats, and at most one on a single-select widget.
And the values that come back have one shape each:
datetime carries the user's UTC offset — 2026-06-01T09:30+02:00 —so it is an instant, not a wall-clock time in an unknown zone.
number field returns null.list that is not selectable returns selected: [].None of this replaces validating the returned values on your side — it is a UI guard, not a security control.
list field — one widget, four modesselectable | multi_select | sortable | Mode |
|---|---|---|---|
| – | – | – | Static info list |
| ✓ | – | – | Single-choice (radio) |
| ✓ | ✓ | – | Multi-choice (checkboxes) |
| – | – | ✓ | Ordering via drag handles |
| ✓ | ✓ | ✓ | Pick-and-order |
Every mode is fully keyboard-operable: Tab to an item, Enter or Space to select, Alt+↑/↓ (Cmd+↑/↓ also works) to reorder a sortable one — so a sortable list is answerable without a mouse, and the reorder is announced.
Result is always {selected: [values], order: [values]} — order reflects drag changes, selected reflects checkbox state (always [] for a list that is not selectable). Each item's value must be a non-empty string and unique — it keys the result, and a missing or duplicate one is rejected. Items can carry a thumbnail — see Image sources below for the accepted URL formats. Perfect for shotlists, mood boards, carousel slides where the visual anchor matters more than the label.
table field — column-aware row triageWhen you'd otherwise dump 30 branches / 50 search results / 20 stale files into chat, hand it as a table instead. Columns carry the context (date, size, owner) that list can't, rows are clickable for selection, and the agent gets back the picked rows by their value. Keyboard works too: a multi_select table gives every row a real checkbox, a single-select table makes the row itself focusable (Enter/Space picks it), and sortable column headers are buttons reachable by Tab.
columns: [{key, label, align?: "left"|"right"|"center"}]
rows: [{value, values: {<key>: <string|number|null>}}]
multi_select?: true # checkbox-per-row
sortable_by_column?: true # click headers to sortResult: {selected: [values], order: [values], sort: {column, dir}}. The order field reflects user-driven sorts so you can preserve their view if you reopen the form. Each row's value must be non-empty and unique — two rows both called config.yaml (from different directories, say) are rejected; disambiguate with the full path.
Uniqueness is enforced across every collection, not just here: list items, table rows, image_grid images, tree nodes (the whole forest, not just siblings), gallery items, compare variants, plus form field names and tab labels. A repeat keys two things the same, which either blanks the dialog or silently collapses two entries into one result — so aiui refuses the spec instead of rendering it.
The shape is checked too. These are refused with invalid_spec before any window opens:
list, table, image_grid or tree entry, or a galleryactions entry, without a non-empty string value — a number or an empty string does not count. Only a list item may instead be a bare string, which then serves as both label and value;
list or tree without items, a table missing rows orcolumns, an image_grid without images;
tabs together with a non-empty top-level fields, and a tab without afields array (see Tabs below).
treeWhen the options carry real parent/child structure the user needs to see — a file subtree, config namespaces, the packages of a monorepo, an org chart — hand them a tree. Flat set where ordering matters → list. Flat one-of-N → select. Structure the user has to navigate → tree.
{
"kind": "tree",
"name": "<result key>",
"label": "<optional>",
"items": [{label, value, description?, children?: [...]}],
"multi_select": true, # checkbox per node; default is single-pick
"default_selected": [values],
"default_expanded": [values]
}Result: {selected: [values]} — that is the whole shape. Expand/collapse is the user's view, not an answer: it is stripped at submit, so never plan on reading it back.
Three behaviours to get right before you send one:
tree that hands the user a wall of rows. Pass the handful of values you want open — usually the roots — and let them drill down.
list). The user cansubmit with nothing picked, so handle an empty selected rather than assuming a selection. Seed default_selected when a sensible preselection exists.
does not pick its children. If you mean "the whole subtree", say so in the label or list the children explicitly.
{
"kind": "tree",
"name": "namespaces",
"label": "Which config namespaces should migrate?",
"multi_select": true,
"default_expanded": ["app", "app/auth"],
"default_selected": ["app/auth/tokens"],
"items": [
{
"label": "app",
"value": "app",
"children": [
{
"label": "auth",
"value": "app/auth",
"children": [
{"label": "tokens", "value": "app/auth/tokens", "description": "12 keys"},
{"label": "sessions", "value": "app/auth/sessions", "description": "3 keys"}
]
},
{"label": "cache", "value": "app/cache", "description": "7 keys"}
]
},
{"label": "legacy", "value": "legacy", "description": "deprecated, 41 keys"}
]
}Anti-pattern: faking the hierarchy with indented labels in a select or a list (" └ tokens") — the indentation is a picture the user cannot collapse, and the values come back flat with the structure lost. The mirror anti-pattern is reaching for tree on a flat set because it looks fancier; a two-level-deep tree of five leaves is a worse list.
mermaidWhen you'd otherwise reach for ASCII boxes-and-arrows, draw flowcharts in +--+-style art, or sketch a sequence diagram with --> and |, stop. Use the mermaid field in a form instead.
Spec: {kind: "mermaid", source: "<DSL>", label?: string, max_height?: number}.
The source is a Mermaid-DSL string. aiui pipes it through mermaid.render() and shows the resulting SVG as an image. Links inside a diagram are inert — click … href does nothing; put a link the user should follow into a markdown field instead. Covers flowcharts, sequence diagrams, state diagrams, class diagrams, gantt, ER, mind-maps, and pie charts — pick the one that fits the situation.
{
"kind": "mermaid",
"source": "graph TD; Start --> Probe; Probe -- ok --> Render; Probe -- fail --> Retry; Retry --> Probe"
}Read-only — like markdown and image, it sits between input fields to give context, not to ask anything. Result-handling unchanged.
Anti-patterns:
hierarchy — that's exactly the slop this field replaces.
diagrams (nodes, edges, swimlanes), not free drawing. For arbitrary images use image with a real source.
securityLevel: strictrejects it (which we want). Keep labels plain text.
layouts) — Mermaid is graph-DSL, not layout-DSL. Use wireframe for that, see below.
wireframeWhen you'd otherwise reach for ASCII boxes-and-pipes to mock a UI layout — dashboard tiles, hardware-UI panels, a login-screen sketch, a hand-drawn-feel app surface — stop. Use the wireframe field in a form instead. mermaid covers diagrams (graphs, flows, state); wireframe covers fixed-position panel grids.
Spec: {kind: "wireframe", panels: [{title?, content?, col_span?, row_span?, tone?}], columns?, gap?, label?, max_height?}.
panels is the only required field. Each panel has:
title — uppercase header, optionalcontent — multi-line monospace body, escape \n for line-breakscol_span / row_span — default 1tone — "default" (neutral), "muted" (de-emphasised),"highlight" (accent border + tinted background)
aiui renders real CSS-Grid panels with proper borders, monospace content, and theme-matched colours — the layout actually looks like the layout, instead of approximating it with +s and |s.
{
"kind": "wireframe",
"label": "U-Boot-Funkbude",
"columns": 3,
"panels": [
{"title": "EMPFANG", "col_span": 2, "content": "14:32:07 [SCHWACH] …\n14:32:11 [STARK] WX MIDWAY"},
{"title": "STATUS", "col_span": 1, "content": "Tiefe: 18 m\nKurs: 270°\nSpeed: 8 kn"},
{"title": "SIGNAL", "col_span": 1, "content": "Atmo: █▒▒\nWetter: ▓▒▒", "tone": "muted"},
{"title": "AKTION", "col_span": 2, "content": "[T]auchen [A]uftauchen [K]urs", "tone": "highlight"}
]
}Read-only — sits between input fields to give layout context, like markdown / image / mermaid. Result-handling unchanged.
Anti-patterns:
slop this field replaces.
wireframe for graphs / flows / sequences — those aremermaid-shaped, the panels here have no edges.
content — thefield is for the outer layout. If you need nested boxes, split into more panels with col_span / row_span.
content — content is plaintext, rendered monospace; everything else is intentionally ignored.
annotated_imageWhen the answer you need is spatial — "where should the logo go?", "which part do I crop?", "point at the bug in this screenshot" — words are a poor carrier. Show the image and let the user mark it directly.
Spec: {kind: "annotated_image", name, src, label?, alt?, mode?, max_height?, required?, default?}.
src — same resolution rules as image (absolute / ~/ local path,http(s):// URL, or data: URL). See Image sources.
mode — what the user can mark:"point" (default) — click to drop a single crosshair marker."region" — drag to draw a rectangle."both" — a Point/Region toggle appears; the user can set a pointand a region (both are returned).
default — optionally seed {point?: {x, y}, region?: {x, y, w, h}} innormalized units to pre-place a marker the user then nudges.
required — pressing an affirmative action before the user has markedthe annotation the mode calls for highlights the field and shows what is missing instead of submitting.
Result (under the field name):
{
"point": {"x": 0.42, "y": 0.31} | null,
"region": {"x": 0.10, "y": 0.20, "w": 0.30, "h": 0.25} | null,
"natural": {"width": 1920, "height": 1080} | null
}All coordinates are normalized 0..1 relative to the image — resolution independent, so they survive the image being displayed at any size. region is top-left x,y plus w,h. Multiply by natural (the image's intrinsic pixel size, filled in once it loads) to get pixel coordinates: px = point.x * natural.width.
{
"kind": "annotated_image",
"name": "logo_spot",
"label": "Where should the logo sit?",
"src": "~/renders/hero.png",
"mode": "point"
}Anti-patterns:
select when the honest answeris a spot on the image — that's exactly what this field is for.
image_grid (thisfield annotates a single image).
natural — theraw x/y/w/h are fractions, not pixels.
markdown, image, static_textThese don't ask anything — they sit between input fields to give context for the inputs that follow.
markdown — rendered Markdown block (lists, code, links, tables). Usefor "here's the diff I generated, now decide" patterns. Not a standalone display tool — if you'd be tempted to open a window just to show the user a markdown blob, render it in chat instead. Raw HTML is sanitised: <style>, style= attributes, <audio> / <video> / <source> / <track> and <map> / <area> are stripped — use the audio field kind for playback.
image — read-only single image preview. src accepts a data: URLor any http(s):// URL — see Image sources below. Optional label, alt, max_height. Use when the agent generated a chart, screenshot, or diagram and needs visual sign-off before the next decision.
static_text — plain styled note with tone: "info"|"warn"|"muted".Lighter weight than markdown when no formatting is needed.
audioRead-only, like image — sits between input fields for the user to listen to before deciding. Spec: {kind: "audio", src, label?}. Renders a native <audio controls> player; no value is returned in the form result.
{ "kind": "audio", "src": "~/Downloads/tts-sample.mp3", "label": "Voice: warm" }src follows the same resolution rules as `image` (see Image sources below), plus one twist: a local audio path (mp3/m4a/wav/aac/ogg/flac) is never inlined as a data: URL, no matter how small. It's pushed through the same size-unbounded /media cache the gallery tool uses for local video — the bridge uploads the bytes to the user's machine and the dialog streams them back over a loopback URL, working identically whether you run locally or on a remote SSH host. http(s):// and data: audio URLs work exactly like they do for image — no special handling needed.
Use audio for "does this TTS voice sound right?", "confirm this generated jingle", "here's the voice memo the user attached — does it transcribe correctly?". For a plain yes/no on the clip, still reach for confirm if there's nothing else to fill in; use audio inside a form when the listen-then-decide step needs other inputs alongside it (a text field for feedback, a select for which voice to use next, etc.).
image_gridFor "pick one (or more) of these N generated images" — logo variants, thumbnail candidates, asset triage. Spec: images: [{value, src, label?}], multi_select?, columns? (default 3). Result: {selected: [values]}. Each src follows the same rules as image — see below.
image_grid is a picker — one (or N) selected out of many. When you instead need a separate verdict per item — approve this, revise that, skip the third, with an optional note each — use the gallery tool below.
galleryA standalone tool (not a form field), for reviewing a batch of images and/or videos and collecting one decision per item in a single window — instead of firing confirm once per asset.
Spec: items: [{value, src?, label?, detail?, max_height?}], actions? (per-item buttons, default Approve / Revise / Skip), comment? (free-text field per item), columns? (default responsive). Each item's value must be non-empty and unique — it keys the result, and a duplicate is rejected (as in every value-keyed collection). src follows the same resolution rules as image; videos (a data:video/ URL, an http(s):// URL, or a local .mp4/.mov/.m4v/ .webm path) render with native <video controls>. Local video files of any size work: the bridge pushes them to aiui's media cache on the user's machine and the dialog streams them back (range-seekable), so a remote agent's clip plays without you hosting it anywhere. http(s):// video URLs stream directly.
Result: {cancelled, decisions: {"<item value>": {decision, comment?}}}. Only items the user actually touched appear in decisions — an untouched item means "no verdict", not a default.
Use gallery for "review these 6 hero renders", "triage this screenshot batch". Use confirm+image for a single yes/no sign-off, and ask+thumbnail / image_grid when the task is picking among candidates rather than judging each one.
upload (user's machine → your host)Every other aiui data flow goes your host → the user's machine (dialog specs down, image bytes inlined). upload is the one that reverses it: it pulls a file from the user's machine into your session, over the same authenticated :7777 channel the dialogs use (loopback locally, the SSH reverse-tunnel remotely). It's the native replacement for "please scp me that file".
Call upload whenever the user wants to hand you a local file — "take this file", "here's the screenshot/PDF/CSV", or the /aiui:upload slash-command. It opens a native file picker on the user's machine; the file the user chooses is streamed back and written to target_dir/<filename> on your host (the remote for an SSH session).
~/-rooted directory on yourhost, chosen from context (usually your cwd or the active project dir). Omit it only when you genuinely have no context (defaults to your process's cwd). Relative paths are rejected; the directory must already exist and be writable.
native dialog, and you infer the target. One tool call, no back-and-forth.
so the file lands at exactly target_dir/<filename> — no temp/staging path. Existing files are never overwritten; a name clash returns an error instead of clobbering (pick another target_dir or move the old file first).
upload while another is stillwaiting comes back with "another upload is already waiting for the user". Two stacked system panels are indistinguishable to the user, so aiui refuses the second rather than showing it. Let the first finish, then retry.
billing-migration. Ittitles the picker window, so a user running several agents can tell which one is asking for a file. Same label you pass to the dialog tools.
{status: "ok", path, filename, bytes} on success, or{status: "error", error} for a cancelled picker, an unreadable file, a file over the 512 MB cap, a missing/unwritable target dir, another upload in flight, or a picker nobody ever answered. Report it briefly; on ok, name the path the file landed at.
Blocks until the user picks or dismisses the picker, exactly like the dialog tools — progress notifications fire every ~10 s while you wait, so a slow response just means the user is browsing, not that aiui broke. The picker itself may take a moment to come forward. It does not wait forever: an unanswered picker eventually returns an error, so you are never stuck on a dialog the user never saw.
compareA standalone tool (not a form field), for an A/B or A/B/C compare: render 2 or more full-content variants next to each other and let the user click ONE to pick. Use it for "which draft is better", "which of these three headlines", "before vs. after this edit", "GPT vs. Claude's answer" — anywhere the full content, not a thumbnail, needs to be visible to decide.
Spec: variants: [{value, label?, content?, src?, alt?, detail?, max_height?}] (≥ 2 entries), sync_scroll?, columns? (default variants.length, capped at 4). Each variant needs a stable value (the key returned as selected) and at least one of:
content — Markdown text: a draft, a diff, a code snippet.src — an image or video, same resolution rules as everywhere elsein aiui (data:, http(s)://, or absolute/~/ local path). Videos render with native controls.
A variant may carry both (an image plus a caption). label defaults to A / B / C / … by position if omitted. detail is a short caption line under the pane (source, score, timestamp).
{
"title": "Which opening line?",
"variants": [
{"value": "a", "label": "Direct", "content": "Your invoice is 12 days overdue."},
{"value": "b", "label": "Soft", "content": "Just a friendly nudge about invoice #4471."}
]
}Result: {cancelled, selected} — selected is the value of the picked variant, present only when the user actually submits. Cancel/ Escape leaves it absent: compare is the one tool with no falsy stand-in on cancel, because any value there would read as a real pick.
`sync_scroll: true` locks scroll position across all panes — reach for it when comparing long text so the user can scroll once and see matching passages line up. Leave it off for short copy or images.
`max_height` is dialog-wide, not per-variant: set it on any one variant and it caps every pane's height, because unequal pane heights break the "side by side" framing. Omit it and aiui picks a sensible default that grows a little for image/video variants.
Use compare instead of ask+thumbnail (thumbnails are too small to actually compare) or gallery (per-item batch review with an independent verdict per asset, not a single either/or pick).
size / width / heightform, gallery, and compare accept an optional `size` hint — "s", "m", or "l" — and aiui picks good local defaults for each, clamped to the user's screen. (Power users can pass explicit width / height in logical px, which override size; rarely needed.)
The hint is a floor, not a cap: the window opens at max(content-estimate, hint). So a content-heavy dialog never opens smaller than it needs (you can't cram a 12-image gallery with size:"s"), but a sparse dialog you know will feel cramped at the default can be told to start roomy. Windows are always resizable regardless — but many users don't realise that, so a dialog that opens at a comfortable size is the difference between "looks polished" and "looks broken". Reach for "m" or "l" when a form carries images, tables, wireframes, or many fields, or a gallery has a large batch / tall thumbnails. Leave it unset for ordinary short forms — the auto-estimate already fits those.
src / thumbnail)aiui takes an image source in these places:
confirm → image: {src, alt?, max_height?} — visual yes/noask → options[].thumbnail — visual pick-one-of-Nform → image field → srcform → image_grid → images[].srcform → list → items[].thumbnailgallery → items[].src — batch review, images or videoscompare → variants[].src — side-by-side pick, images or videos(form → audio field → src accepts the same three formats too — see Audio playback above — with one difference: a local path never inlines as data:, it always routes through the /media cache, size-unbounded.)
In all of them the same three input formats render correctly:
/Users/me/foo.png, ~/Pictures/x.jpg)— the natural choice when the file is already on disk. The aiui bridge running on your host reads the file and inlines it as a data: URL before the dialog spec leaves your host. Important: the path must exist on the host you, the agent, are running on — for an SSH-tunneled session that's the remote, not the user's machine. Absolute or ~-rooted paths only — relative paths are not resolved (no stable cwd contract on MCP bridges). On a Windows host C:\…, \\?\C:\…, \\server\share\… and ~\… count as absolute too. ~someuser/… is not expanded. 10 MB cap.
it. 5-second timeout, 10 MB cap, parallel fetch for grids. Use when the image already lives on a reachable web server. The user's machine contacts the URL, not aiui's infrastructure (aiui itself never phones home). Public destinations only: a URL that resolves to loopback, a private/LAN range, link-local, CGNAT or an IPv6 ULA is refused and renders broken — the user's LAN is not yours to reach. Redirects are not followed, so link to the image itself, not to a redirector.
data:image/png;base64,…. The fallback whenneither path nor URL works (e.g. you generated bytes in-memory and don't want to write a tempfile). Embed the encoded bytes directly in the tool-call's src value — never roundtrip through a shell pipeline (see anti-pattern below). Watch the size — over ~2 MB it starts to feel laggy in the MCP transport.
Pick the simplest one that works: path first if the file's on disk, then URL if it's reachable, data: only as last resort.
When a clip doesn't make it: a local video or audio file that can't be pushed to the /media cache — missing, unreadable, or over the 512 MB per-file cap — does not fail the render. The dialog opens anyway, with a broken player where that clip should be, and the result carries a media_warnings: [...] list naming each path and why. Read it: without it you'd believe the user saw something they didn't.
What does not work — known footguns:
./foo.png, foo.png, ../assets/x.png).Resolved against an undefined cwd. Use absolute or ~/ paths.
on the remote where the agent runs (or vice versa) won't resolve — the bridge that does the reading is on the agent's host. If you need to render a file on the user's machine from a remote agent, use http(s):// or pass the bytes inline as data:.
follows the same CSP — the URL has to resolve to data: somehow. The resolver only walks src / thumbnail properties, not the bodies of markdown blocks.
[text](https://…) or <a href="https://…"> frommarkdown (and from a compare variant's content) — works as a click target. It opens in the user's default browser; the dialog window itself never navigates, so the dialog stays open and still returns a result. Only http(s) links open; anything else is ignored. It's not an image-rendering question.
If you tried a path or URL and the user reports a broken image, ask them once whether anything appeared at all — a missing file, a CSP block, and a 404 all look identical to the user. The companion logs the failure (imageresolve: …) but agents can't read those logs.
data: URLsDon't write the encoded bytes to a tempfile, then cat or printf them back through bash to construct the JSON tool call. Two failure modes seen in the wild:
data:image/... prefix in stdout andtries to render it inline — eats the rest of the pipeline.
word-split or quoting-mangled.
The fix is structural: the tool call is JSON, not shell. Either build the spec dict in your runtime and pass src=f"data:image/png;base64,{b64}" straight into the tool call, or hand aiui the path and let the bridge do the encoding for you.
datetime fieldFills the gap between date and date_range. Cron, scheduling, reminders — one field instead of splitting into two text fields with manual validation. Native <input type="datetime-local">; returns ISO with the user's UTC offset, YYYY-MM-DDTHH:MM±HH:MM (e.g. 2026-06-01T09:30+02:00). A default may be a wall-clock YYYY-MM-DDTHH:MM or carry a zone (Z, +02:00); a zoned default is shown converted to the user's local time.
Drop fields=… and pass tabs=[{label, fields: [...]}, ...] instead — tabs plus a non-empty top-level fields is refused (invalid_spec), because the top-level fields would never be shown, and so is a tab without a fields array. One submit covers all tabs; pressing it with something invalid switches to the first invalid tab, names it in a footer message and marks the offending fields — it never submits silently. An action that writes files first switches to any tab the user has not viewed yet that holds a target field, so every destination is seen before it is approved; the next press submits. Tabs are display structure, not a wizard — no per-tab confirmation, no per-tab actions, all values land in one response.
Use when a single dialog naturally falls into 2-4 distinct topical groups (e.g. "Identity / Permissions / Notifications" on a user-create form). Don't reach for tabs to cram a 30-field form into 5 tabs — split into multiple form calls instead.
For short-lived secrets (one-off API tokens, test passwords), prefer form with a password field over asking in chat: the value is masked on screen while the user types, so it doesn't appear in screen recordings or to a shoulder-surfer.
Be honest with the user, though — the value still returns to you as plaintext in the tool response. For long-lived or high-value secrets, use the secret field with a target instead (below) so the value never enters the conversation.
secret field + targetWhen a value must NOT pass through this conversation — a credential the user pastes that should land in a file, not your transcript — use a secret field with a target. Any input field may carry target; for a secret field the value is write-only: aiui writes it to the file and returns only {written, target, bytes, mode}, never the value.
A `secret` field must carry a `target`. The write-only promise is what a secret is, and the only place its value can legitimately go is the file — so a secret without a target is rejected with invalid_spec rather than silently handing you the plaintext. If you want the value back, that field is a password, not a secret.
{ "kind": "secret", "name": "token", "label": "Demo API token",
"target": { "mode": "create", "path": "~/.config/demo/token",
"perm": "0600", "overwrite": true } }overwrite: true toreplace an existing file (a path typo otherwise fails loudly rather than clobbering).
placeholder that occurs *exactlyonce in an existing file (format-agnostic: YAML/TOML/INI/env). 0 or >1 matches → error, never a partial or wrong write. Pick a distinctive sentinel* that cannot collide with real file content — e.g. __AIUI_SECRET_GITHUB_PAT__, never a common word like TOKEN or X. The exactly-once rule is the safety net (a colliding placeholder errors instead of being misapplied), but a distinctive sentinel makes the match unambiguous in the first place.
there (the native app for a local session, the bridge on a remote SSH session), and it performs the write as a plain local file operation. So create and substitute behave identically local and remote (the entered value reaches that module over aiui's own channel, never via the agent). You cannot target a foreign host. The user sees the destination and approves it by submitting: for a local session the resolved absolute path (~/ expanded by the app that will do the write), for a bridge-served session the path as you wrote it, qualified with the host it lands on — the companion deliberately does not expand ~ there, because that would name the wrong machine's home.
notes/key)and a ~user/ path (~alice/key) are rejected — a relative path has no stable working directory to resolve against, and ~user/ is not portable across the two aiui modules, so either would write somewhere the user never approved. Same rule upload's target_dir follows. For substitute, symlinks are followed: on a link it edits the file the link points at and leaves the link a link; the reported target is that resolved path. create never writes through a link: a symlink at the destination is refused, even with overwrite: true.
perm —it is editing a file the user already owns, so a 0644 config stays 0644 and the service reading it keeps working. create defaults to 0600 (tight by default for a fresh credential file). The outcome carries the octal mode actually applied, so a permission change is never invisible.
— {written: false, error: "refusing to write an empty value"} — so a skipped optional field can never truncate the user's file, and substitute can never erase its own sentinel. Leaving a target field blank is a safe no-op that reports itself, which is what makes the "paste a new token only if you want to rotate it" flow legitimate.
submit button or a plain named action; an action carrying skip_validation: true (your Cancel / Save-draft escape hatch) does not write. See Action buttons above.
overwrite must be the booleantrue/false, perm a string ("0600", not 600), placeholder a string. Anything else is refused with invalid_spec before the dialog opens — "overwrite": "false" (a string) once read as yes and clobbered the file.
{written:false, error} — no silent success.Why it exists: it replaces the fragile "guess a shell one-liner to stash a token" pattern with a native dialog + a correct, atomic write whose target the user sees first. It's a QoL + confused-deputy guard, not a hard guarantee the agent can't read the value some other way — for that, the user still types it themselves outside any agent path.
| Slop | Clean |
|---|---|
confirm(title="Are you sure?") | confirm(title="Drop table 'orders'?", destructive=True, message="18,432 rows will be removed.") |
ask(question="Choose one", options=[{"label": "Option 1"}, …]) | ask(question="Which migration strategy?", options=[{"label":"In-place","description":"Fast, no rollback."}, …]) |
form with 15 text fields | Split into logical steps, or push back to chat entirely |
| Button labels "OK" / "Cancel" | "Deploy" / "Discard" — name what happens |
static_text echoing the title | static_text adds context the labels can't carry alone |
image(src="./shot.png") (relative path — undefined cwd) | image(src="/Users/me/shot.png") — absolute, the bridge reads it locally |
Writing base64 to a tempfile, cat-ing it through bash to build the tool call | Pass the path as src and let the bridge encode, or build the data: URL directly in your runtime — never via shell pipes |
aiui.form(
title="New feature draft",
header="Discovery",
fields=[
{"kind": "text", "name": "job", "label": "User job",
"multiline": True, "required": True},
{"kind": "select", "name": "scope", "label": "Scope",
"options": [{"label": "Quick win", "value": "qw"},
{"label": "Feature", "value": "f"},
{"label": "Epic", "value": "e"}],
"default": "f"},
{"kind": "list", "name": "stakeholders", "label": "Stakeholders",
"items": [{"label": "Product", "value": "prod"},
{"label": "Design", "value": "design"},
{"label": "Engineering", "value": "eng"}],
"selectable": True, "multi_select": True,
"default_selected": ["prod", "eng"]},
{"kind": "date", "name": "deadline", "label": "Target date"},
],
actions=[
{"label": "Cancel", "value": "cancel", "skip_validation": True},
{"label": "Save draft", "value": "draft", "skip_validation": True},
{"label": "Create", "value": "commit", "primary": True},
],
)Note: Cancel and Save draft carry skip_validation: True, so neither commits a target file write — only Create does. That is the rule, not a property of this example.
Response: {cancelled: false, action: "commit", values: {job: "…", scope: "f", stakeholders: {selected: [...], order: [...]}, deadline: "…"}}.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.