| name | update-system-interface |
| description | Canonical flow for changing the system interface (the web workspace UI at system/apps/system_interface) -- its frontend (the dockview shell, the sidebar, the New Tab launcher) or backend (Flask server, the inventory over the app registry, layout ops) -- and the shared frontend library at system/libs/workspace_ui. Use whenever the user wants to edit, fix, restyle, or add to the workspace UI / dockview. |
| metadata | {"author":"imbue"} |
Updating the system interface
system/apps/system_interface is the live web UI the user is looking at right now
(the dockview shell, the sidebar, the New Tab launcher). A broken build here is
served straight to the user, so you never edit the served copy directly: you
make every change in an isolated worktree clone, verify it builds and passes
there, and only merge it back into the served tree once it's known-good. This
skill is the single canonical path for that.
This is the system-interface specialization of the generic creation
lifecycle. It reuses the generic update orchestration -- the task file, the
generic harden-worker, and the report poll -- from update-creation with
type=system-interface, and adds the one thing the system interface needs
that no other creation does: a pre-merge preview, then a go-live through
the general update apply (update_self.py apply), which lands the merge
and reveals it as one atomic, rollback-on-failure motion. The
worker/orchestration core is shared; the preview is owned here, and the apply
is shared with update-self.
The hard rule
Never edit the system-interface tree that is being served to the user. Do
not run Edit/Write on files under system/apps/system_interface/ in this (the
served) checkout, and do not rebuild or restart the live UI from uncommitted
edits here. Every change is made in a separate, isolated clone of the source,
built and tested there, and merged back only after it passes. The only things
you do to the served tree are running this skill's preview / unpreview
commands and the general apply (which lands the merge itself) -- and
preview/unpreview never modify the served tree at all (they only boot
throwaway servers against the worker's separate, already-built work_dir, so
even the pre-merge preview can't reach what the user is looking at).
That isolated clone is the generic harden worker -- its own git worktree and
copy of the source, so a half-broken build can never reach the user. The worker
is just the mechanism for that safe, separate place to work.
Flow overview
- Delegate the change to the generic worker via the
update-creation
orchestration core (type=system-interface). The worker follows
harden-creation.md + op-update.md + type-system-interface.md, which
own how to build, test, and verify the change in isolation.
- The worker implements + builds + tests it on its own branch, then reports
done (the system interface emits no gate -- approval is your preview).
- You preview the change before merging: the worker is a local
worktree-agent in this container that already built its own work_dir, so one
command boots that folder and serves it -- wrapped in a labeled "preview"
frame -- for the user to click around. The user approves or rejects.
- On approval, you run the general apply: one command that lands the
merge and reveals it (refresh dependencies, install the worker's built
bundle, restart the services agent, verify the live UI is healthy, auto-rollback on
failure -- with an interruption marker so even a hard kill is recoverable),
then tear down the preview. On rejection, you tear down the preview and
hand back -- nothing is merged.
1-2. Delegate via the generic update orchestration
Check for a concurrent system-interface pass first. Passes here are named
per-slug, so two can coexist without colliding on names -- but both edit
system/apps/system_interface, so whichever merges second is guaranteed stale (see
the freshness check in Step 4). Look for another pass in flight:
grep -l 'type: system-interface' data/.tasks/harden/update-*/task.md
For any hit, check whether its update <slug> tracking ticket is still
open/in-progress in tk ready (and its worker alive, per lead-proxy.md's
liveness probe). If a live one exists, tell the user and let them decide --
dispatching in parallel is allowed but means the second merge will have to
rebase and re-verify. This is a warning, not a hard block.
Then follow update-creation Steps 1-3 (open a ticket, write the task file,
launch the worker, background-poll the report) with these system-interface
specifics:
- Pick a slug
$SLUG for the change. The worker agent / branch is
update-$SLUG / mngr/update-$SLUG; the runtime dir is
data/.tasks/harden/update-$SLUG/.
- Task-file frontmatter:
operation: update, type: system-interface,
plus the standard finish_report_path
(data/.tasks/harden/update-$SLUG/reports/report.md). Per the system-interface
exception in op-update.md, there is no ## Change origin marker -- the
body is a plain change brief, not an absorb/verify incident.
- Task-file body:
## What to do (the actual UI change the user asked for),
## Context (which panel, desired behavior, constraints), ## Real scenario
when a real conversation motivates the change (the motivating agent id + what
looks wrong in plain words -- see the next bullet; omit it, or write "no real
scenario", for net-new work), and ## Success criteria (what "done" looks
like, plus the standing line: follow the installed harden-worker sub-skill;
it composes harden-creation.md, op-update.md, and
type-system-interface.md for how to run, test, verify, and what not to
touch; report done only when its testing contract and the review gates all
pass).
- Judge whether a real scenario motivates the change, and if so point the
worker straight at it -- don't transcribe it. Most UI fixes come from how
something actually renders in a specific real conversation -- "this gap looks
wrong here", or "I don't like how permission requests render" said in a
conversation where a permission request happened (the discontent is with that
rendering, in that conversation). The worker is not cut off from that
conversation: the system interface discovers agents from the shared
MNGR_HOST_DIR, so an instance the worker boots in its own worktree renders
the very same real conversations the user is looking at. So do not measure
the DOM and write it into the brief -- that is a lossy game of telephone (you
look at the real thing, transcribe it, and the worker reconstructs it from your
prose, losing fidelity at each hop). Instead, just decide which case you are in
and say so:
- A real conversation motivated it (the usual case for a bug report): name
the motivating agent -- usually your own , or whichever agent
the user was looking at when they complained -- in the
section, and describe in plain words what looks wrong about it. Tell the
worker to open conversation in its own instance and look at it before
changing anything. The worker sees the real thing firsthand; it does not
reconstruct it from your description.
3. Preview the change before merging
On a terminal done, show the user the change before merging. The worker's
work_dir is a folder it has already built (its done contract runs
uv sync + npm run build). The preview just boots that folder -- no fetch, no
re-checkout, no rebuild. Do not destroy the worker yet -- the preview serves
its work_dir in place, so the worker must stay alive until the user gives a
verdict.
First resolve the worker's work_dir, then boot it:
WORK_DIR=$(mngr ls --include 'name == "update-<slug>"' --format json \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["agents"][0]["work_dir"])')
python3 .agents/skills/update-system-interface/scripts/reveal_system_interface.py preview \
--slug update-<slug> --work-dir "$WORK_DIR"
This boots the worker's already-built instance on a free port with layout
persistence neutered (it reads the same agents, so the user's real conversations
render, but it cannot clobber the live layout.json), then boots a small wrapper
page that embeds it in a labeled "preview" frame. The user-facing si-preview
service points at that wrapper (the inner instance is registered separately as
si-preview-app), so the tab reads as a clearly-marked proposed change. It does
not merge, touch the served tree, or modify the worker's folder. Exit 0
means the preview is up; a non-zero exit means it failed to boot (or the
work_dir was wrong / the worker was already destroyed) and tore itself down --
diagnose before retrying. One boot-refusal case is deliberate: the si-preview
tab can only show one pass at a time, so if another pass's preview is already
up, preview refuses rather than hijacking it -- surface that to the user and
coordinate with the other pass (its stderr says how to tear down an abandoned
one).
Open it as a tab and ask the user to explore. With no --view, the op goes
to the view the connected client is looking at:
python3 system/scripts/layout.py open si-preview
Self-verify against the real scenario before you ask the user. The preview's
inner instance opens on its server's primary agent -- the worker's own (empty)
agent, not the motivating conversation -- so the real case isn't on screen by
default. If a real conversation motivated the change:
- Open the motivating conversation in the preview's inner app with Playwright
(
--no-sandbox): use the tab bar's add-tab (+) dropdown and pick the real
agent (its .dockview-add-tab-dropdown-item), or otherwise navigate to it.
- Look at it and confirm the change actually fixed the real case, comparing
it against what looked wrong in the original complaint. A worker reporting
done with passing tests is not proof the real case is fixed -- you have the
real conversation right in front of you, so confirm it with your own eyes. If
it still looks wrong, the fix missed the real DOM: re-brief the worker rather
than merging.
- Tell the user how to see the real case themselves (the preview opens on the
worker's empty agent; they switch via the
+ dropdown to the motivating
agent).
Then confirm with the user: a binary keep/discard and
room for free-form notes (what looks off, what they'd change). Wait for their
answer before doing anything else.
4. On approval: take the lease and check freshness
If the user approves the preview:
-
Take the editing lease so no other chat's merge or apply interleaves
with yours -- the apply's auto-rollback restores a captured revision, so a
foreign merge landing mid-motion could be swept away by it. Same advisory
mechanics as update-app's "One editor at a time": first check
tk ready for another agent's editing service system_interface lease and
surface it to the user instead of proceeding if one is held; then
LEASE_ID=$(tk create "editing service system_interface" -t chore \
-d "Held by $MNGR_AGENT_NAME across the apply; released after teardown.")
and tk start "$LEASE_ID" (as its own command). Unlike a live service
edit, this lease deliberately spans the whole apply motion; it is released
at the end of Step 5, never held across the wait for the user's preview
verdict (that wait happens before this step).
-
Freshness check -- the branch is only mergeable if
system/apps/system_interface/, system/apps/chat/frontend/, the shared
system/libs/workspace_ui/, and the npm lockfile (the trees the shell's and
the chat's bundles are stamped over; the apply installs the worker's bundles
only as a pair, so a stale chat stamp costs the shell's bundle too) have not
changed since the worker branched (for example, another pass merged in the
meantime):
BASE=$(git merge-base HEAD "mngr/update-$SLUG")
git diff --name-only "$BASE" HEAD -- system/apps/system_interface/ system/apps/chat/frontend/ system/libs/workspace_ui/ system/package-lock.json
Empty output means fresh: continue. Any output means the pass is stale --
do not apply, and never hand-resolve a conflicted merge (the
principle in .agents/shared/references/harden-contention.md). Release
the lease, re-brief the worker to rebase onto the current tree and
re-verify, and come back through preview once it reports done again.
If the user rejects, do not merge anything. Tear down the preview (see the
end of the next section) and hand back with their feedback -- decide with
them whether to re-brief the worker for another pass. Re-briefing is your
judgment, not an automatic loop.
Note: the built static/ bundle is gitignored, so the merge brings only source
and dependency-manifest (pyproject.toml / package.json / lockfile) changes,
not the worker's build output. The apply installs the worker's already-built
bundle (the very build the user just previewed), falling back to a live build.
5. Apply the change, then tear down the preview
Run the general update apply -- the same script update-self lands releases
with -- pointing it at the worker's branch and its already-built bundle. Resolve
the work_dir again in the same invocation: Step 3's WORK_DIR is long gone,
because each bash call starts a fresh shell and the user's verdict sat between
them. A bundle path that does not resolve -- or one whose bundle was built from
some other frontend source than the merged tree's (the build stamps each
bundle with its source tree hash, and the apply checks it) -- is not an
error: the apply says so on stderr and falls back to a live build, losing only
the "what the user previewed is what ships" guarantee.
WORK_DIR=$(mngr ls --include 'name == "update-<slug>"' --format json \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["agents"][0]["work_dir"])')
python3 .agents/skills/update-self/scripts/update_self.py apply \
--merge-ref "mngr/update-$SLUG" \
--worker-bundle "system_interface=$WORK_DIR/system/apps/system_interface/imbue/system_interface/static" \
--worker-bundle "chat=$WORK_DIR/system/apps/chat/imbue/chat/static"
That single command owns the whole go-live as one deterministic, self-healing
motion -- you do not merge, or run npm/uv/mngr, by hand. It merges the
worker's branch (an ordinary merge; the rollback point is captured internally),
classifies what changed, snapshots the built bundle and the affected
environments, refreshes dependencies on a manifest change, installs the
worker's bundle (live build as fallback), pre-flights a backend change on a
throwaway port before touching the live service, restarts, verifies the live
UI to the frontend standard (scoped to a regression), refreshes every open
view, and auto-rolls-back the entire merge on any failure, restoring the
snapshots -- file copies that need neither npm nor a registry. A hard kill
mid-apply is recoverable too: the apply writes a marker under
data/.state/update-apply/, re-running the same apply resumes it, and the
boot/cron recover path rolls a stale one back on its own.
Interpret the exit code and report it to the user:
0 -- applied; the live UI is updated and healthy. One variant to read for:
if the workspace's frontend was already broken when the apply started and
is still broken now, the change still lands and still exits 0, but the final
line names the breakage instead of confirming health. Pass that finding on --
it is a separate problem, not something rolling this change back would have
fixed. (An apply that happened to fix it prints the ordinary healthy line.)
2 -- the change was bad and was automatically rolled back; the live UI is
healthy on the previous revision, but the requested change did not land.
Report this and diagnose before retrying -- the worker branch and its report
are kept, so a diagnosed retry is a quick re-run of the same apply. This
carries the same variant as 0: when the frontend was already broken
beforehand the rollback is never held to that standard, so the final line
says the backend is healthy and names what could not be confirmed instead of
claiming the UI is. Pass both problems on.
3 -- emergency: even rollback could not restore a healthy UI (including
a rollback whose own git steps failed). The interface may be down; escalate
immediately. The pre-apply copies are kept under
data/.state/update-apply/snapshots/, and when the apply touched the
frontend the stderr names a copy per bundle (the shell's and the chat's) --
copying each back over its own served directory
(system/apps/system_interface/imbue/system_interface/static/ and
system/apps/chat/imbue/chat/static/) needs neither npm nor a registry,
so pass both paths on with the escalation. Read the stderr rather than
assuming a path is there. This exit also leaves a durable
data/.state/update-apply/emergency.json (reason, the agent that drove the
apply, where the copies are) and the system interface shows a banner off it
until it is gone, so deleting that file once the workspace is verified
healthy is part of the repair -- see the update-self skill's exit-3 guidance.
1 -- precondition error; nothing was changed. A dirty tree, a merge
conflict, another apply in flight, an interrupted apply of a different
merge (run recover first, per the stderr), or this merge having already
been landed and rolled back. A conflicted merge is aborted -- resolve it
through a fresh worker pass, never by hand; and a rolled-back merge cannot be
re-landed by re-running the apply (the rollback is a forward revert, so the
merge stays in history while its content does not), so that one also needs a
fresh worker pass off the current HEAD.
Whenever Step 5 ends -- whatever its exit code: a successful apply, a rollback
(2), an emergency (3), a precondition refusal (1), or a rejection where
nothing was merged -- tear the preview down:
python3 .agents/skills/update-system-interface/scripts/reveal_system_interface.py unpreview --slug update-<slug>
unpreview is idempotent, so it is also the way to clean up after a preview
that failed partway.
unpreview only handles the service side; it does not touch the workspace
layout. The si-preview tab you opened earlier with layout.py open is a
separate concern (a layout panel, not a service), so you must close it yourself
-- otherwise the user is left with a stale tab pointing at a now-deregistered
service:
python3 system/scripts/layout.py close si-preview
Do this on every one of those exits, not only the successful one. Once the
preview is down and its tab is closed, the worker can be destroyed per
launch-task (after a failed apply, keep it until the diagnosis is done -- its
branch and report are the retry's input). Close the update-$SLUG ticket the
orchestration opened, and release the editing lease taken in Step 4 with
tk close "$LEASE_ID" "Apply finished." -- on every exit code, since a lease
left open blocks the next pass (on a rejection no lease was taken -- Step 4
never ran).
Why this exists as a script and not a checklist: if the backend fails to start,
the user loses their entire chat UI -- there is nowhere left to surface an error
message. The recover-or-revert logic must therefore run identically every time
and can never be skipped -- even across a crash or a hard kill of the apply
itself -- which is exactly what belongs in a deterministic script rather than
agent prose.
system/scripts/layout.py refresh (the manage-layout skill) is unrelated -- it only
reloads a single inner iframe/panel for arranging the workspace, not the
top-level page, so it does not reveal a system-interface code change.
Why this shape
The UI is what the user is actively looking at, so the design goal is "never
serve a half-broken UI," not "iterate in place fast." The worker's isolated clone
(in-process testing, Playwright verification, review gates) makes it safe to
merge; the pre-merge preview lets the user actually click around the change
before anything lands -- and since the worker already built its own work_dir, the
preview just boots that folder in place rather than re-cloning or rebuilding; and
the general apply's pre-flight, health probe, autonomous rollback, and
interruption marker make it safe to go live in one motion. Preview setup and
teardown are deterministic, so they live as preview/unpreview sub-commands
of this skill's script rather than as agent prose -- the only non-deterministic
part, gating on the user's judgment, stays with you.