# The `user` tool — wire contract

> Cross-repo handoff doc. The canonical schema lives in
> [`browser_tools_canonical.json`](docs/browser_tools_canonical.json)
> (entry: `"name": "user"`). This file explains the semantics, the
> result envelopes per type, and what the matrx-extend Chrome
> extension needs to implement / change.

## Why this exists

`ask_user` (canonical, aligned) and `notify_user` (canonical said
`{message, level}`, extension Zod required `{title, message}`) were
two separate tools doing related-but-different things, with the
`notify_user` cross-repo schema permanently out of sync. Consolidated
into one tool with a `type` discriminator — same pattern as `computer`
and `tabs`. Decision history in this turn's chat; design choices:

- **Tool name:** `user` (replaces both `ask_user` and `notify_user`).
- **Old tools:** hard-removed. No alias.
- **Timeout:** when an optional timer expires, the call resolves with
  `{ timed_out: true, ... }` and the agent continues.

## Input schema (authoritative)

```jsonc
{
  "type": "user(type='confirm'|'choice'|'choice_many'|'text'|'secret'|'notify')",
  "type":            "string  (required, enum above)",
  "question":        "string  (required for ASK types: confirm/choice/choice_many/text/secret)",
  "options":         "string[]  (required for choice / choice_many — >=2 items)",
  "context":         "string  (optional for ASK types — one-line why)",
  "message":         "string  (required for type='notify')",
  "actions":         "string[]  (optional for notify — action button labels; UI always appends 'Other')",
  "level":           "'info'|'success'|'warning'|'error'  (optional for notify, default 'info')",
  "timeout_seconds": "integer 1..900  (optional, all types — on expiry resolve with timed_out:true)"
}
```

## Result envelope (per type)

The result is always the same dict shape — fields populated per type.
Unused fields are `null` / `false`. **One shape always — the model
parses one schema regardless of which type was called.**

```jsonc
{
  "answer":     "string | null",      // text / secret types
  "selected":   "string[] | null",    // choice / choice_many — array (single item for choice); includes 'Other' when the user took the freeform escape
  "confirmed":  "boolean | null",     // confirm — true/false; null when the user took the 'Other' escape (text is in freeform)
  "action":     "string | null",      // notify — which action button the user clicked (or 'Other' freeform value)
  "freeform":   "string | null",      // the user's freeform text — set for ANY 'Other' escape (choice/choice_many/confirm/notify) AND whenever wrote_instead is true
  "additional_instructions": "string | null",  // optional note the user attached ALONGSIDE their structured answer — always read + honor it
  "wrote_instead": "boolean",         // user declined the structured question(s) and typed a freeform reply (in freeform); re-ask later only if still genuinely needed
  "cancelled":  "boolean",            // user explicitly dismissed
  "timed_out":  "boolean"             // timeout_seconds expired before any response
}
```

Examples:

```jsonc
// user(type='confirm', question='Submit the form?')
{ "answer": null, "selected": null, "confirmed": true,  "action": null, "freeform": null, "cancelled": false, "timed_out": false }

// user(type='choice', question='Pick one', options=['A','B','C'])
{ "answer": null, "selected": ["B"], "confirmed": null, "action": null, "freeform": null, "cancelled": false, "timed_out": false }

// user(type='choice_many', question='Pick any', options=['A','B','C'])
{ "answer": null, "selected": ["A","C"], "confirmed": null, "action": null, "freeform": null, "cancelled": false, "timed_out": false }

// user(type='text', question='Your name?')
{ "answer": "Arman", "selected": null, "confirmed": null, "action": null, "freeform": null, "cancelled": false, "timed_out": false }

// user(type='secret', question='Paste your MFA code')
{ "answer": "<sensitive>", "selected": null, "confirmed": null, "action": null, "freeform": null, "cancelled": false, "timed_out": false }
// (the extension MUST tag the message envelope so the value is masked in UI + storage)

// user(type='notify', message='Click submit then I'll continue', actions=['Done — I clicked it'])
//   → user clicks 'Done — I clicked it'
{ "answer": null, "selected": null, "confirmed": null, "action": "Done — I clicked it", "freeform": null, "cancelled": false, "timed_out": false }
//   → user clicks 'Other' and types 'I gave up, please try again'
{ "answer": null, "selected": null, "confirmed": null, "action": "Other", "freeform": "I gave up, please try again", "cancelled": false, "timed_out": false }

// any type with timeout_seconds set, no response in time
{ "answer": null, "selected": null, "confirmed": null, "action": null, "freeform": null, "cancelled": false, "timed_out": true }

// NOTE: every envelope ALSO carries "additional_instructions" (null unless the user added a note)
//       and "wrote_instead" (false unless the user took the write-instead escape). Omitted above for brevity.

// user(type='confirm', question='Are you free today?')  → user takes 'Other', types 'only after 3pm'
{ "answer": null, "selected": null, "confirmed": null, "action": null, "freeform": "only after 3pm", "additional_instructions": null, "wrote_instead": false, "cancelled": false, "timed_out": false }

// user(type='choice', ...) → user picks B and attaches a note in the additional-instructions field
{ "answer": null, "selected": ["B"], "confirmed": null, "action": null, "freeform": null, "additional_instructions": "prefer the cheaper one if they're close", "wrote_instead": false, "cancelled": false, "timed_out": false }

// ANY ask → user clicked 'Write message instead' and typed a freeform reply
{ "answer": null, "selected": null, "confirmed": null, "action": null, "freeform": "Actually let's do a 3-day trip, not 5 — rethink it.", "additional_instructions": null, "wrote_instead": true, "cancelled": false, "timed_out": false }
```

## What the matrx-extend repo needs to do

1. **Delete** the standalone `notify_user` handler — its semantics are
   now `user(type='notify', ...)`.
2. **Replace** the `ask_user` handler with a `user` handler. Suggested
   layout: `src/lib/tools/handlers/user.ts` (rename from the existing
   `user.ts` which holds `ask_user`).
3. **Zod schema** for the new tool should mirror the canonical input
   schema above. Discriminate validation by `type`:
   - confirm / choice / choice_many / text / secret → require `question`
   - choice / choice_many → require `options` (≥ 2 items)
   - notify → require `message`; `actions` and `level` optional
   - all types → `timeout_seconds` optional
4. **Sidepanel UI cards:**
   - confirm → Yes/No buttons
   - choice → single-select radio list
   - choice_many → checklist with multi-select + Submit button (NEW)
   - text → text area (existing)
   - secret → password input + masked echo (existing)
   - notify → `message` rendered with `level` icon/colour + `actions`
     buttons + always an 'Other' button that opens a freeform input (NEW)
5. **Result envelope:** populate the unified shape above. Existing
   `ask_user` payloads that returned a bare string need to be migrated
   to the new shape.
6. **Timeout:** if `timeout_seconds` is set and no user response arrives,
   resolve the tool call with `{ ...nulls, cancelled: false, timed_out: true }`
   and dismiss the card. Surface a small ephemeral hint in the panel
   ("agent resumed after timeout") — no separate stream event required.

## What `aidream` has already done

- Updated `browser_tools_canonical.json` — new `user` entry; old entries
  removed; `groups.core` updated; references in other tools' description
  strings updated.
- Created `db/migrations/_apply_unified_user_tool.py` — inserts
  `matrx-extend:user`, copies tool_handlers from the existing
  `matrx-extend:ask_user` row, deletes both old rows. Idempotent.
  **NOT yet run** — the user kicks it off.

## 2026-05-23 — always-Other + additional-instructions + write-instead

Three new escape hatches. Both UI repos (matrx-extend + matrx-frontend) implement them
identically. **The model input schema is unchanged** — these are CLIENT guarantees, so
the model never asks for them and must never add its own 'Other' option.

1. **Always-Other.** The client appends a freeform 'Other' escape to EVERY
   choice / choice_many / confirm card, regardless of any `allow_other` flag (the flag
   is now ignored — kept only for back-compat). text/secret are already freeform; notify
   already had it.
   - choice / choice_many Other → `{ selected: [...labels, 'Other'], freeform }`
   - confirm Other → `{ confirmed: null, freeform }`
2. **Additional-instructions.** One optional textarea on the final card (single ask, or
   the last question of a batch). Whatever the user types rides back as
   `additional_instructions` on that answer (and, for batches, on the top-level result).
3. **Write message instead.** A button on every ASK card. The user abandons the
   structured Q&A and types a freeform reply → `{ wrote_instead: true, freeform }`. In a
   batch it short-circuits the remaining questions (like cancel/timeout) and sets
   `wrote_instead: true` on the batched result. The agent treats the freeform text as the
   user's response and re-asks later only if it still genuinely needs the answers.
4. **Batch button label.** The primary advance button reads **"Next"** on every question
   except the last, and **"Send"** on the final / only card.

Batched result gains the two top-level fields too:
`{ answers: Envelope[], cancelled, timed_out, additional_instructions, wrote_instead }`.

## Validation checklist (after both repos ship)

- [ ] `cx_tool_trace` shows new `tool_name = 'user'` rows; no new
      rows for `ask_user` / `notify_user`.
- [ ] Each of the six `type` values produces a successful round trip.
- [ ] `timeout_seconds` resolves with `timed_out: true` after expiry.
- [ ] `cancelled` resolves true when the user dismisses the card.
- [ ] `secret` type still masks input in UI and storage.
- [ ] The agent-builder UI lists `user` in the core group; no stale
      references to `ask_user` / `notify_user`.
