# Update System Interface

> 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.

- Skill: `imbue-ai/update-system-interface` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add imbue-ai/update-system-interface`
- Raw SKILL.md: https://api.skillmd.com/api/skills/imbue-ai/update-system-interface/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: imbue-ai (https://skillmd.com/u/imbue-ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/imbue-ai/update-system-interface

---


# 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

1. **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.
2. The **worker** implements + builds + tests it on its own branch, then reports
   `done` (the system interface emits **no gate** -- approval is your preview).
3. 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.
4. **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:

```bash
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 `$MNGR_AGENT_ID`, or whichever agent
    the user was looking at when they complained -- in the `## Real scenario`
    section, and describe in plain words what looks wrong about it. Tell the
    worker to open *that* 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.
  - **No real scenario** (e.g. a net-new control or layout with no precedent in
    any existing conversation): say so in the brief and let the worker build a
    representative fixture as usual. Don't manufacture a fake "real scenario."
  A change can be partly both -- anchored in a real conversation but adding
  something new -- in which case name the real anchor and call out the new part.
  Use your judgment.
- **Launch** with `--template worker` (installs the generic
  `harden-worker`) per `update-creation` Step 3, then background-poll per
  `.agents/shared/references/lead-proxy.md`.
- **Terminal handling differs:** the system interface emits no gate, and on
  `done` you do **not** merge here (that is `update-creation` Step 4's behavior
  for other creations). Instead, go to the preview below. On `stuck` or a
  dead-worker timeout, surface to the user per
  `.agents/skills/launch-task/references/worker-failure.md` -- do **not**
  preview, merge, or apply, and do not retry silently.

## 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:

```bash
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:

```bash
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:

1. **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

   ```bash
   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).

2. **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):

   ```bash
   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.

```bash
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:

```bash
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:

```bash
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.

