| name | fused-feedback |
| description | Show the human a real browser UI — to ask a question, get an approval/decision, or review a plan — built from Fused's JSON-UI primitives and opened with `fused widget open` (one-shot — inline `--config` or a `.json` file) or the parley (`widget push`/`widget watch`, standing). Use in Claude Code whenever a structured choice, form, approval, or plan review would be clearer than plain terminal text, and you want the human's answer back as JSON. If the task touches an existing project, UDF, or data-bound widget (anything with `{{ref}}`/`sql`), also load `fused-projects` and `fused-widgets` first. |
Fused feedback — ask the human through a visual UI
⛔ STOP — load companion skills before your first tool call. Fused is
workspace ⊃ project ⊃ UDF. If the task touches a project, UDF, or data-bound
widget (anything with {{ref}}/sql — e.g. opening an existing project's
widget), you MUST load fused-projects (end-to-end model + how
projects/widgets are addressed) and fused-widgets (component catalog +
{{ref}} resolution) before running any command or editing any file. Do not
reverse-engineer project paths or widget props ad hoc — that context is in those
skills (e.g. the exact sql-table prop set). Only a pure static ask (a plain
widget open with no project/{{ref}}) is safe to do from this skill alone. See
fused-guide for the full set.
Instead of asking the human a question as terminal text, render a real browser
UI and get a structured answer back as JSON. You author a small JSON-UI
config (a tree of {type, props, children} nodes — text, inputs, buttons), open
it with fused widget open, and the command blocks until the human
responds, then prints their answer on stdout.
This is Fused's local feedback loop repackaged for
Claude Code: a visual-planning surface for questions, approvals, and plan
reviews, in the user's own workspace.
CLI vs in-app. This skill is the CLI widget open/parley surface — you
author a static widget and read the human's submitted action/params. The
separate in-app ask_user(summary, widget, effect) tool (with its effect: "reply" / effect: "approval_gate" discriminator) is the flow app's agent surface.
The effect argument does not apply here.
When to use this
Reach for a widget instead of a plain text question when the answer is
structured:
- A choice — single-select (
dropdown) or multi-select (checkbox-group).
- An approval / decision — Approve / Reject / Request-changes buttons.
- A form — several fields filled at once (target, batch size, flags, notes).
- A plan review — show the proposed plan, collect a verdict + edits in one go.
Stick to plain text for a quick yes/no in the middle of a flow, or when there's
no browser at the machine. Use AskUserQuestion for a
lightweight in-terminal multiple-choice; use this skill when you want a richer,
visual surface (free-text + choices + a plan laid out together, a persistent
planning page you iterate on).
Prerequisites
- The
fused CLI on PATH. (Inside an Fused source checkout, use
uv run fused … instead of fused ….)
- Node 20+ on
PATH. The first widget open/push cold-boots two
servers — the Node/Express app and a Python dev serve daemon (first paint
always resolves through it). Measured: a few seconds, up to ~13 s on a truly
cold machine (cold OS cache + first _core venv materialization — i.e. the first
question of a work session); later calls reuse both and are near-instant (a warm
resolve is ~2 ms). Don't pay that boot on the human's first question — warm it
at the start (see Make it appear instantly).
- No project, environment, or venv config is needed for
question/approval/plan widgets — they are static (only
text/inputs/button,
no sql/{{ref}}), so there's nothing to resolve. (The Python dev serve
daemon still boots and first paint still routes through it — it just hands the
config straight back.) You only need a configured environment + venv once you add
a data-bound component (a chart/table with SQL) — see
Going further.
The one-shot loop (the default)
- Author a JSON-UI config — a
div wrapping some text, input components
(each with a param), and one or more submit buttons (each with a distinct
action).
- Open it and block for the human. Pass the config inline (one call,
no scratch file) or from a file — but not both (passing a config and a
path errors):
- Read the single JSON line on stdout — the human's answer.
Worked example — an approval
approve.json:
{
"type": "div",
"props": { "style": "display:grid; gap:16px; padding:20px; max-width:640px" },
"children": [
{ "type": "text", "props": { "value": "Deploy build #1423 to production?", "variant": "h3" } },
{ "type": "text", "props": { "value": "Promotes the current preview to the release channel.", "variant": "muted" } },
{ "type": "text-area", "props": { "param": "comment", "label": "Notes (optional)", "placeholder": "Anything to flag…", "rows": 3 } },
{ "type": "div", "props": { "style": "display:flex; gap:12px" }, "children": [
{ "type": "button", "props": { "label": "Approve", "action": "approve", "submit": true, "variant": "primary" } },
{ "type": "button", "props": { "label": "Reject", "action": "reject", "submit": true, "variant": "secondary" } }
]}
]
}
fused widget open /abs/path/approve.json --port 4477 --timeout 600
Or skip the scratch file and pipe the same config inline (one call):
printf '%s' "$APPROVE_JSON" | fused widget open --config - --port 4477 --timeout 600
The human's browser opens, they pick a button, and stdout gets one line:
{"action":"approve","params":{"comment":"ship it, watch error rate"}}
You branch on action ("approve" vs "reject") and read params.comment.
The response contract (exact)
stderr carries logs + the page URL. stdout is the answer channel — exactly one
JSON line in default mode:
| Outcome | stdout | Exit |
|---|
The human pressed a submit button | {"action":"<name>","params":{…}[,"actions":[…]]} | 0 |
| The human closed the tab (or refreshed) | {"action":"closed","params":{…}} | 0 |
--timeout elapsed with no answer | {"action":"timeout"} | 3 |
| Ctrl-C while waiting | {"action":"interrupted"} | 130 |
| App wouldn't start / died / unknown widget | (no stdout) — message on stderr | 1 |
action is the pressed button's action name, or "closed"/"timeout"/
"interrupted".
params is the full param snapshot — every input's current value, keyed by
its param. (actions only appears if you used non-submit buttons; for
simple asks it's absent.)
"closed" is not consent. Treat closed/timeout/interrupted as no
decision — re-ask or fall back; never read a tab-close as approval.
checkbox-group writes an array ("steps":["lint","test"]); every other
input writes a scalar.
Components you'll use
Author each node as { "type": …, "props": { … }, "children"?: [ … ] }. Inputs
carry a param (the answer key) and usually a defaultValue. Full prop
tables: references/components.md.
| Need | Component | Writes to param |
|---|
| Heading / body / hint text | text (variant: h1–h4, default, muted, small, large) | — |
| Static rich text (lists, bold) | html (value is verbatim HTML — no substitution) | — |
| Single choice | dropdown (options: [{value,label?}]) | scalar string |
| Multi choice | checkbox-group (options, defaultSelected) | string[] |
| Short free text | text-input | scalar string |
| Long free text | text-area (rows) | scalar string |
| A number | number-input (min/max/step) or slider | scalar number |
| A date/time | datetime-input | scalar string |
| Layout container | div (CSS via style) | — |
| The decision | button (action, submit:true, variant) | — (reports the action) |
Button rules (the part that makes it work):
- A
submit: true button is what unblocks widget open. A button without
it is an intermediate signal (the page stays open) — don't use it for the final
answer.
- Give each submit button a distinct
action name so you can tell which was
pressed.
variant: "primary" for the main action, "secondary" for alternatives.
Make it appear instantly
The human's first question is slow only because the runtime cold-boots two
servers lazily, while they wait — the widget-host and the Python dev serve
daemon (a few seconds, up to ~13 s cold). Once both are up, the resolve is
~2 ms and a warm widget open is ~0.4 s + the browser. So the whole game
is: pay the boot before there's a question, and reuse one warm widget-host. Three
moves, in order:
1 — Pin a port the skill owns. The default 4410 is reused if anything
already answers there — a foreign/stale widget-host makes your widget 404 (the slow,
confusing failure). Claim a dedicated port up front and pass it on every command:
export OPENFUSED_WIDGET_HOST_PORT=4477
2 — Warm it in the background, immediately. The moment a widget looks likely,
fire a throwaway push in the background (Bash run_in_background: true).
push does a server-side resolve, so it boots both the app and the daemon —
unlike open --no-open, which boots only the app and leaves the ~13 s daemon
spawn for the human's first paint. Pipe the placeholder inline with --config -
so there's no temp file to manage:
printf '{"type":"text","props":{"value":"warming up…"}}' | fused widget push --config - --no-open --port 4477
By the time you author the real question, the visible call is the ~0.4 s warm
path, not ~13 s.
3 — For more than one ask, use the parley — not repeated open. Each
one-shot open opens a new browser tab and reloads a ~2 MB SPA. The parley
keeps one tab and re-renders in place over SSE on each push — no new
tab, no re-handshake, no bundle re-parse — so the 2nd…Nth questions are
effectively instant. Open the human's tab early with a placeholder (loads the
bundle once while you think), then push the real question into it. See
Iterative planning — the parley, below.
Running it cleanly in Claude Code (blocking & timeouts)
widget open blocks until the human acts (up to --timeout, default 600s),
and the Bash tool has its own timeout, so:
Iterative planning — the parley
For a standing back-and-forth (push plan v1 → human reacts → push v2 → …) use
the parley instead of one-shot open: fused widget push <file> updates
one persistent page, and fused widget watch streams the human's events back
as NDJSON. It's also the fast path for any multi-ask flow: one tab, the ~2 MB
SPA parsed once, and each push re-renders in place over SSE — so repeated
questions skip the new-tab + bundle reload that one-shot open pays every time.
Push a named file for the revise loop — not --config. Inline pushes
(push --config -) are fire-and-forget: the config lives only in memory, so
there's nothing to hot-read and the parley status.source is null. For the
revise loop (edit → re-push as the human reacts/comments), push a named
.json and keep editing that file — or pass push --config - --source /abs/plan.json to point the edit anchor at a file you maintain, so the
comment-agent can patch the source.
React live with a Monitor — don't poll. watch streams forever, so a plain
background task only notifies you when it exits (it never does). Run watch as a
Monitor (persistent: true) so each event line wakes you the instant the
human acts; then parse it, author the next view, and push. Arm the Monitor
before the first push:
Monitor(description: "parley human events", persistent: true,
command: "fused widget watch --port 4477 --from latest")
--from selects the replay start: latest (default — only events after the
current lastSeq, the usual choice when arming before the first push), all
(replay every event from seq 0, e.g. reattaching to an in-progress session), or a
numeric SEQ (resume strictly after that sequence number).
fused widget push /abs/path/plan-v1.json --port 4477
Each notification is an event, not a user reply. A terminal action
("terminal":true) is a submit and carries the full params snapshot (the
human's whole state — no --verbose); a non-terminal action is an intermediate
signal; close is "stepped away," not "done." The Monitor stays armed across
pushes — TaskStop it when the collaboration ends. Full loop:
references/parley.md.
CLI-native comment feedback (prefer widget watch)
The parley also carries a comment loop with no flow app — two terminals and a
browser. The human pins comments directly onto the widget on the parley page, and
each comment becomes a file edit that re-pushes the updated view. Use it when
the human's feedback is best expressed on the widget ("this axis should be log",
"drop this column", "make this the headline number") rather than as a form submit.
There are two ways to action those comments, and in Claude Code you should
default to driving them yourself with widget watch — see
Driving comments yourself, which is the preferred
path. Reserve widget agent (below) for when the human explicitly wants
comments actioned by an autonomous worker outside this session.
⛔ Why watch, not agent, is the default for feedback mode. widget agent
actions each comment by spawning a fresh claude -p worker. Those workers
are new sessions — they do not inherit this session's loaded skills,
conversation context, or the decisions made so far, and we cannot guarantee
they'll load the right companion skills (fused-projects, fused-widgets) or
understand the widget the way you do. That makes their edits lower-fidelity and
easy to get subtly wrong. Driving the comments yourself with widget watch
keeps you — the session that authored the widget and holds the full context
— as the single writer. So for feedback mode, always prefer widget watch
with a Monitor; only fall back to widget agent on an explicit request.
widget agent — autonomous workers (opt-in only)
fused widget push /abs/path/dashboard.json --port 4477
fused widget push /abs/path/dashboard.json --project-dir /abs/path/to/project --port 4477
fused widget agent --port 4477
On the page the human presses C (or clicks the bottom-right comment FAB),
pins a comment to a node, and widget agent spawns a claude -p worker per open
comment, edits the backing .json, re-pushes so the widget updates in place, and
marks the comment resolved. Workers run in parallel across disjoint nodes and
serialize on the same/nested node; the agent is the single writer of the file.
Remember these workers are fresh sessions without this session's context — prefer
widget watch unless the human explicitly asked for autonomous workers.
Start agent before the human comments — ordering matters. widget agent
reacts to the parley's live SSE stream ("the instant a comment appears"); it
does not replay comments pinned while it wasn't running. So the sequence is
push the view → start widget agent → then tell the human to comment. A
comment dropped before the agent is up is not actioned — nudge the human to re-pin
it (or restart the loop) rather than waiting. A pre-existing comment also stays
open forever in the live parley (it never gets picked up), which looks like a
bug but isn't — re-pin it after the agent is up.
Never manually push while a comment session is live — you will destroy the
human's comments. The pushed file and the live parley comment state are
separate, and parley state lives in the widget-host's memory only — it is not
persisted to disk or to the replayable event log, and there is no snapshot/recovery
endpoint (every /api/parley/{comments,params,snapshot,history} route 404s). Once a
comment session is live, widget agent is the only thing that should re-push:
it merges edits into the live state. A manual fused widget push of the on-disk
.json overwrites the in-memory comments that haven't been persisted yet — they are
gone, unrecoverable. If the agent got stopped and comments look stuck, restart
widget agent (it re-attaches as the single writer) — do not re-push the file
to "refresh" it. If you must edit the file by hand, stop the human from commenting
first and expect to lose any un-actioned live comments.
Reading current comments — never plain-curl the events endpoint.
GET /api/parley/events is an infinite SSE stream; a bare curl against it hangs
until it times out (exit 143). To inspect the current comment state on demand, capture
the stream briefly with watch instead:
fused widget watch --from all --port 4477 > /tmp/parley.jsonl 2>/dev/null &
WPID=$!; sleep 4; kill $WPID 2>/dev/null
Note the file on disk and the live parley can diverge — a comment marked resolved
in the .json may still show open in the live view (and vice-versa). Trust the live
stream for "what is the human asking now," and widget verify for "what does the
widget currently render."
Lifecycle — it's a foreground long-runner, not a one-shot. Unlike widget open (blocks, prints one JSON line, exits) or widget verify (one-shot data
envelope), widget agent boots/reuses the widget-host and runs in the
foreground until Ctrl-C. It has no stdout answer contract — its "output" is
the side effects you watch in the browser: comments flip to in-progress, the file
is edited, the view re-pushes (~1 s) and re-resolves, the comment resolves
(progress logs go to stderr). Run it in its own terminal (or a background task)
for the life of the comment session and stop it with Ctrl-C when done.
The editability gate — the view must be file-backed. Comment authoring is
enabled only when the pushed view has a source (mirrors status.source):
| Push form | source | Comment authoring |
|---|
A .json file path (push /abs/plan.json) | the file | on — agent edits that file |
--config --source /abs/plan.json | the declared file | on — agent edits that file |
--project-dir on a .json path | the file | on, and project UDFs resolve (?projectDir= mode) |
Plain inline --config (no --source) | null | off — page shows a "not file-backed, comments won't be actioned" note |
A {project, stem} push | null | off — resolves but is not editable |
So --project-dir on a .json path is the only push form that is both
project-addressed (resolves scripts/ UDFs + {{endpoint}} refs under the project
.venv) and editable — the entry point to feedback mode for a real project
widget. The agent reads status.projectDir and echoes it on every re-push, so a
--project-dir view keeps resolving in ?projectDir= mode across comment edits
(without it the re-push would fall back to flat ?dir= and break the project's UDF
refs). The widget agent verb is separate from widget watch/manual planning —
in Claude Code, default to driving the parley by hand with watch (§ below) so
the context-holding session stays the single writer; reach for agent only when
the human explicitly wants comments actioned by an autonomous worker.
Driving comments yourself with widget watch — the preferred path
This is the default for feedback mode in Claude Code. You — this session —
handle each comment, so edits carry the full conversation context and the skills
you've already loaded (fused-projects, fused-widgets), instead of a fresh
widget agent worker that has none of that. Run widget watch instead of
widget agent and be the single writer yourself. The "never manually push while a
session is live" caution above still bites — mind the ordering:
- Do not run
widget agent at all for this widget. If one is already running,
TaskStop it first — otherwise two writers race on the file and the live state.
- Arm a persistent Monitor on
watch so events reach you live:
Monitor(command: "fused widget watch --port 4477 --from latest", persistent: true).
Use --from latest for new events; snapshot the current backlog once with a
brief --from all capture (the sleep 4; kill recipe above) if you need history.
- On each comment event: edit the backing
.json, run fused widget verify … --project-dir … (confirm warnings: []), then fused widget push … --project-dir …
to update the view. Because you are now the sole writer, your push is the
merge — it will not clobber another writer's un-persisted comments (there is no
widget agent holding live state). Preserve the props.comments/__comments
array when you edit so pins survive the round-trip, and set each actioned
comment's status to resolved.
- If a comment never arrives, the Monitor missed it (or
--from latest started
after the pin) — do a one-shot --from all capture to reconcile, then re-push. Do
not assume the live view matches the file; trust the watch stream for "what
is the human asking," widget verify for "what renders."
The divergence risk in this mode comes from an earlier stray widget agent or a
push that reset rev while comments were live — start clean (no agent, one
writer) and it stays consistent. Since this is the preferred feedback-mode path,
the clean starting state is the normal one: no widget agent in the picture, this
session as the sole writer.
Recipes
Copy-paste templates for the common asks — single/multi choice, free text,
approval-with-comment, and a multi-field plan review — are in
references/recipes.md.
Going further: show data
Everything above is static (no environment needed). To put live data in
front of the human (a chart of affected rows, a table of files a migration
touches), add a data-bound component (sql-table, bar-chart, a map) whose
sql reads a UDF via {{ref}}. That needs a resolved Fused environment and a
project venv — out of scope here; see the fused-widgets skill.
vs. AskUserQuestion
Claude Code's built-in AskUserQuestion is great for a fast, in-terminal
multiple-choice. Use this skill when you want a visual surface: free-text
alongside choices, a plan laid out for review, several fields at once, or a
persistent page you iterate on with the parley.
Troubleshooting
If widget open/push errors out, it's almost always environment, not the
config. Check in this order:
- Is
fused healthy? Run fused --version. A traceback / No module named … means the install is broken — reinstall it (e.g. uv tool install --reinstall --editable . from the source checkout, or pip install -U fused). Inside a source checkout, prefer uv run fused ….
… did not hand-shake within 30s / No such command 'data-serve' / Cannot GET /widget-file/… — a stale widget-host build is being booted (its bundled
viewer code predates the current data daemon). Fix the bundle, not the config: from a
source checkout rebuild it (cd widget-host && pnpm build) or reinstall fused
from a current source so it ships a fresh widget-host/dist.
- A foreign/stale widget-host is squatting the port → "Cannot GET /widget-file/…" in
the browser. The widget-host binds one loopback port (default
4410) and reuses
whatever already answers there. If another process (e.g. a stale dev server
or a stale build) holds the port, a widget command
reuses it and the browser 404s. The tell: it answers /health with 200
but GET /widget-file/<id> returns Cannot GET (no viewer bundle).
Preflight the port before opening:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:4410/health
lsof -nP -iTCP:4410 -sTCP:LISTEN
Fix: free the port (kill <pid>) so a current widget-host boots, or pin a
dedicated port the skill owns (below). Note: a fresh --port forces a new
boot, which also surfaces a stale bundle (item 2) that port-reuse was hiding.
- Verify headlessly before involving a human — the answer should be a clean
timeout, not an error:
fused widget open /abs/path/ask.json --no-open --timeout 8
Gotchas
closed ≠ approved. A closed tab / timeout means no answer — handle it.
checkbox-group is an array, scalars otherwise — branch accordingly.
- Only
submit: true returns control. A plain button keeps the page open.
html does not substitute $param/{{ref}} — it renders value verbatim.
For static plan text that's exactly right; for live data use a data-bound node.
- The widget-host is loopback-bound (
127.0.0.1:4410) and single-user — the human
must be at (or tunneled to) the same machine.
- First call cold-boots two servers (a few seconds, up to ~13 s cold — the
Node app + the Python
dev serve daemon); reuse is near-instant. Warm them at
the start — see Make it appear instantly.
- Unknown
type is a hard error — only use components from the catalog
(references/components.md).
- Prefer
widget watch over widget agent for feedback mode — widget agent
spawns fresh claude -p workers that don't inherit this session's skills or
context; drive comments yourself with watch so the context-holding session
stays the single writer. Only use agent on an explicit request.
- Parley comments live in memory only — no disk/event-log persistence, no recovery
endpoint. A manual
push over a live comment session destroys un-actioned
comments; whoever is the single writer (you, via watch, or widget agent) must
be the sole re-pusher.
/api/parley/events is an infinite SSE stream — a bare curl hangs (exit 143);
snapshot with fused widget watch --from all instead.