Files
tai/uiflow.md
T
retoor af080fe68e Strict goal verification, approval hardening, retrieval eval, work limits, sandbox caps (1.38.0)
- 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
2026-10-18 12:13:24 +02:00

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_show call 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-screen ask calls, evaluates show_if conditions and next branches 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 socket show/result/cancel, see mplex.md); tmux maps it onto display-menu (choice) and display-popup (text, message). Both answer the same contract: ok with a value, cancelled, or timeout.

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. options is 1 to 12 strings or {label, value} objects; values must be JSON scalars (string, number, boolean, null). default preselects an option index. The answer recorded is the option's value.
  • text: free input. initial is 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. action is the last taken action id, or null when no screen had actions.
  • {status: cancelled, at, answers}: the user pressed Escape (or q, C-c) on a screen, or the flow routed to $cancel. answers holds 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.