- Approval: re-prompt unknown answers (max 3), shell-only affirmative guidance recovery; destructive actions stay strict - GoalVerifier subagent with strict JSON verdicts, bounded follow-up rounds, nonzero exit plus open turn on unverified one-shots - Retrieval eval (Hit@K/MRR/nDCG/Context Precision), FTS cascade with title weighting, query/read event logging, --eval-retrieval CLI - Supervisor caps (8 parallel, 64 records) and scheduler hardening (4 fires/tick, per-schedule lease, stated misfire policy) - Sandbox box resource trio: 2g/2.0/256 at create, best-effort update for existing boxes, rm -f recovery pointer - Research archive: 12 subjects, 667 sources, 27 conclusions; suites 979 + 16 green
8.2 KiB
uiflow: interactive UI flows on the user's terminal
Specification of the plexer_show agent tool and the JSON contract behind it.
The code is validate_flow_spec, PlexerFlow and PlexerController.ask in
tai.py; PlexerFlowSpecTests, PlexerFlowDriverTests, MplexFlowServerTests,
TmuxAskTests, NativeAskTests, PlexerShowToolTests and UiflowDocTests in
test_tai.py check the contract against that code, so this page cannot
silently drift.
What it is
One tool call renders a whole interactive app (dialogs, forms, branching multi-screen flows) on the terminal the user is looking at, and returns the outcome as JSON. Three layers, each dumber than the one above:
- Main agent: makes a single
plexer_showcall with the spec, gets back{status, answers, action}. Its turn stays lean no matter how complex the flow gets: it never sees intermediate screens or keypresses. - Flow driver (
PlexerFlow, the separate agent that owns the interaction): validates the spec, decomposes screens into one-screenaskcalls, evaluatesshow_ifconditions andnextbranches locally, enforces the timeout and the visit cap, and reports only the final outcome. - Backends (
PlexerController.ask): dumb renderers of one screen at a time. Native mplex shows the screen as an overlay or status prompt on the attached client (control socketshow/result/cancel, see mplex.md); tmux maps it ontodisplay-menu(choice) anddisplay-popup(text, message). Both answer the same contract:okwith a value,cancelled, ortimeout.
Spec format (v1)
{
"v": 1,
"title": "Deploy",
"timeout": 300,
"start": "main",
"screens": [
{"id": "main", "title": "Where", "body": "Pick a target.",
"fields": [
{"id": "env", "type": "choice", "label": "Environment",
"options": ["staging", "prod"], "default": 0},
{"id": "tag", "type": "text", "label": "Tag", "initial": "v1",
"show_if": {"env": "prod"}},
{"id": "note", "type": "info", "text": "Prod deploys page the team."}
],
"actions": [{"id": "go", "label": "Deploy"}, {"id": "back", "label": "Back"}],
"next": {"go": "$done", "back": "$cancel"}}
]
}
Top level: v must be 1 (missing means 1, anything else is rejected);
title is the fallback for screens without one; timeout is the whole-flow
budget in seconds (5 to 600, default 300); start is the first screen id
(default: the first screen); screens is a non-empty list (at most 32).
Screen: id (unique, no whitespace); title, body (plain text); fields
(at most 12); actions (at most 12, each with unique id and non-empty
label); next (a screen id, an {action: screen} map, $done or $cancel,
default: next screen in order, $done after the last); show_if (skip the
screen unless it holds). A screen with no body, fields or actions is rejected.
Field types:
choice: single select.optionsis 1 to 12 strings or{label, value}objects; values must be JSON scalars (string, number, boolean, null).defaultpreselects an option index. The answer recorded is the option'svalue.text: free input.initialis the starting text. The answer is the typed string (capped at 2000 chars).info: display only, never asked, never recorded. Shown with the screen's context (see below).
Conditions: show_if is {field id: expected}, where expected is a scalar or
a list of accepted scalars. Every clause must hold. A field that has not been
answered yet never holds. Referring to an unknown field id is rejected, so a
typo fails loudly instead of silently skipping.
Ids (screens, fields, actions) are unique per scope (fields: whole app), non-empty, at most 64 chars, no whitespace.
Flow semantics
Screens run in order from start. A screen whose show_if fails is skipped
(its next still applies as if it had no actions). Otherwise each visible
choice/text field is asked in turn, then the action when the screen has
actions. A screen with only body/info and no actions is shown once and waits
for acknowledgement.
next routing: a string applies to the whole screen; a map routes per taken
action, and an action missing from the map falls through to the next screen in
order ($done after the last). $done ends with status done, $cancel
ends with status cancelled. Every target must exist (unknown ids are
rejected upfront); at most 64 screen visits per run, more aborts with status
error (a loop guard, not a feature).
Outcomes, always one JSON object:
{status: done, answers: {field: value}, action}: the user finished the flow.actionis the last taken action id, or null when no screen had actions.{status: cancelled, at, answers}: the user pressed Escape (orq,C-c) on a screen, or the flow routed to$cancel.answersholds what was answered so far.{status: timeout, at, answers}: the flow budget ran out.{status: error, error, at, answers}: the visit cap tripped.
Escape anywhere aborts the whole flow; there is no per-screen back button in v1 (route an explicit Back action where you need one).
Screen decomposition
Backends render one screen at a time, so the driver asks a screen as a short
sequence: one ask per visible choice/text field, then one for the action.
The first ask carries the context (screen title, body, visible info texts,
field label); later asks show just their label. Each ask gets the remaining
flow budget as its timeout. Backend answers are indices and strings; the
driver maps them back to option values and evaluates the flow.
Backends
Native (mplex): choice and message render as a centered overlay dialog
(MplexFlowScreen: arrows/j/k, Enter, digits, Escape); text renders as a
status-row prompt with emacs editing. Wire: show renders and returns a flow
id, result polls ({done: false} until answered), cancel drops it. One
pending dialog per client; a busy client refuses loudly, as does a pane whose
session has no attached client. Server caps: MPLEX_FLOW_MAX pending flows,
MPLEX_FLOW_KEEP seconds of grace for dead drivers. Details:
mplex.md (control socket).
tmux: choice renders as display-menu (each option writes its index to a
staged answer file via run-shell), text and message as display-popup
running read (no file means dismissed, an empty file means an empty answer).
Staging files live under the system temp dir as tai-flow-*.prompt/answer
and are removed after each ask. Two known differences from native: default
does not preselect (tmux menus always start at the first item) and initial
is ignored (no portable prefill for read). After a timeout the driver closes
popups server-side (display-popup -C); a display-menu has no server-side
close and stays until the user presses Escape, which is harmless because the
flow is already over.
Hard caps
| Constant | Value | Limits |
|---|---|---|
PLEXER_FLOW_VERSION |
1 | the only accepted spec version |
PLEXER_FLOW_SCREENS_MAX |
32 | screens per spec |
PLEXER_FLOW_FIELDS_MAX |
12 | fields per screen |
PLEXER_FLOW_OPTIONS_MAX |
12 | options per choice, and per ask screen |
PLEXER_FLOW_ACTIONS_MAX |
12 | actions per screen |
PLEXER_FLOW_VISITS_MAX |
64 | screen visits per run (loop guard) |
PLEXER_FLOW_ID_MAX |
64 | chars per screen/field/action id |
PLEXER_FLOW_TITLE_MAX |
200 | chars per title |
PLEXER_FLOW_TEXT_MAX |
2000 | chars per body/info/ask text |
PLEXER_FLOW_LABEL_MAX |
200 | chars per field/action/option label |
PLEXER_FLOW_ANSWER_MAX |
2000 | chars per text answer and initial |
PLEXER_FLOW_TIMEOUT_MIN / PLEXER_FLOW_TIMEOUT_DEFAULT / PLEXER_FLOW_TIMEOUT_MAX |
5 / 300 / 600 | flow budget in seconds |
PLEXER_FLOW_POLL |
0.25 s | native result poll interval |
MPLEX_FLOW_MAX |
16 | pending flows per mplex server |
MPLEX_FLOW_KEEP |
300 s | grace before a dead driver's record is reclaimed |
Gaps
Single-screen rich forms (all fields visible at once with focus navigation), multi-select choices, progress/live screens, and per-screen back navigation do not exist in v1; the driver decomposes every screen into sequential asks, which is the documented behavior, not a fallback. The spec carries a version precisely so these can extend the contract without breaking v1 specs.