Changing an existing app or service
Both apps and background services run as a [program:<name>] under
supervisord (see system/supervisord.conf). They differ only in whether
there's a tab to refresh:
- App -- the user opens it as a tab rendering at the service's own
origin,
http://<name>.<workspace-host>/(scaffolded viabuild-app). Lives undersystem/apps/<package>/. - Background service -- a supervisord program with no tab (
host-backup,share-gateway, forwarders), standalone undersystem/services/or co-owned by an app (named<app>-<role>, code in the app's folder).
Two things are easy to forget when editing either, and both leave the user looking at stale state: a code change doesn't take effect until the process is reloaded, and an open app tab keeps showing the old page until it's refreshed. The live change loop below handles both.
If you're doing something other than editing an existing app or service:
- Creating a new app ->
build-app. - Changing the workspace UI itself (
system/apps/system_interface-- the dockview shell, the sidebar, the New Tab launcher) ->update-system-interface(it never edits the served tree directly; it previews in isolation and applies only when known-good). - Rearranging tabs (split/move/focus/rename/close) ->
manage-layout.
Match the flow to the scope of the change
Not every change is a quick edit. Before you start, decide which of these the request is -- it changes what you do before touching code:
Small / contained change -- a bug fix, a copy tweak, a new field, a backend logic fix, a config or
commandchange, a restart. Go straight to the live change loop below: edit, apply, refresh, verify.Larger-scope change -- a redesign, a new page or view, a meaningful shift in look-and-feel, or a new user-facing capability. Run the same mock-confirm flow
build-appused to create the service: read.agents/shared/references/interactive-delivery.md, put a cheap, throwaway version of the proposed change in front of the user, loop until they explicitly confirm the shape, and only then build the real thing to a usable state. Never build heavy against an unconfirmed shape.When a hand mock won't convince -- a redesign, or a data-touching change -- boot the actually changed service as a labeled preview tab beside the live one via the shared
serve_isolated_instance.pyscript (invocation under "Protect the user's data while you verify"; it's the same preview mechanism the system-interface flow uses). Keep the lighter hand mock for quick look-and-feel loops. Either way, reading the live store to render a preview is fine; never let a preview or verification write to it.A new view or capability bolted onto an existing service is its own delivery with its own feedback gate (interactive-delivery phase 8): confirm and ship it on its own rather than bundling it with unrelated changes.
When in doubt about which bucket you're in, treat a change that alters what the user sees or perceives as larger-scope (confirm the shape first) and a change that only alters behavior behind an unchanged surface as contained. Either way, the mechanics of surfacing the change to the user are the same live loop:
One editor at a time: the service lease
Every chat shares this one working tree and this one live process, so two
agents editing the same service at the same time interleave destructively --
there is no merge step where that could be reconciled. Concurrent edits must
be serialized, and the serialization token is an advisory lease held as a
regular tk ticket (regular tickets are visible across agents).
Pre-flight, before touching the service's code or config:
Check whether another agent is mid-edit:
tk ready > /tmp/service-leases.txt grep "editing service <name>" /tmp/service-leases.txtIf a lease exists and
tk show <id>says it is not yours, do not silently proceed: tell the user another chat is currently modifying this service and let them decide (wait, or explicitly override). If the holder looks abandoned -- its agent is no longer running, or the lease is hours old with no notes -- say that too and offer to break it. The lease is advisory: it is broken deliberately by the user's call, never silently.Take your own lease:
LEASE_ID=$(tk create "editing service <name>" -t chore \ -d "Held by $MNGR_AGENT_NAME while editing this service; released at turn end.")then
tk start "$LEASE_ID"(as its own command).
Release the lease at the end of every editing turn with
tk close "$LEASE_ID" "Done editing for this turn." -- never hold it across
an idle wait for user feedback. On the next feedback round, take a fresh
lease before editing again. Between turns the changes are committed, so
another chat editing sequentially on top is safe; only simultaneous editing
needs the lease.
An in-flight harden pass does not block you -- the foreground wins. If
tk ready also shows an in-progress update <name> or heal <name> ticket
(a background pass hardening an earlier change to this service), proceed with
your edit; your change simply makes that pass stale. Leave a note on that
ticket (tk add-note <id> "...") so its owner coalesces at merge time. The
full contention rules live in
.agents/shared/references/harden-contention.md.
The live change loop
Make the change interactive and keep the user's view in sync as you go.
1. Make the change
Edit the service's code under system/apps/<package>/ (or wherever the program's
command points). If the change renders HTML a person looks at, invoke the
frontend-design skill before writing markup, and if it calls Claude,
follow use-ai-integration -- the same rules as when the service was
built.
2. Apply it so it actually takes effect
The scaffolded web runner runs with use_reloader=False, and daemons
don't watch their own source, so a code change is not live until the
process restarts:
Backend change (Python / server logic, for an app or a daemon): restart the program.
supervisorctl restart <name> supervisorctl status <name> # confirm it came back RUNNINGDependency or entry-point change to an app (its
pyproject.toml): an app with anapp.tomlmanifest runs from its own uv tool environment, an editable install ofsystem/apps/<package>/that picks up source edits on its own but not a new dependency or console script. Reinstall the tool, then restart (an app with noapp.tomlruns from the root venv: for it, only theuv sync --all-packagesbelow is needed):uv tool install -e system/apps/<package> --reinstall uv sync --all-packages # the app is also a workspace member; keep the lockfile current supervisorctl restart <name>(Background services under
system/services/run from the root venv instead:uv sync --all-packages, then restart.)Frontend-only change (templates, static JS/CSS served fresh on each request): no restart needed -- the next request already serves the new markup. Skip straight to the refresh.
Change to the service definition (its port, its
command, its log config, or adding/removing a program): editsystem/supervisord.conf, thensupervisorctl reread && supervisorctl update. The full program schema, the add/remove/inspect mechanics, and theforward_port.pywiring live in.agents/shared/references/service-processes.md. This surgical reload is for iterating on a single service. It is not the path for landing anupdate-selfmerge -- that restarts the whole services agent (mngr start --restart system-services) sobootstrapre-runs too, and must be followed bypython3 system/scripts/refresh_workspace_view.py(see step 3).
If it doesn't come back RUNNING, read
/var/log/supervisor/<name>-stderr.log or
supervisorctl tail <name> stderr.
3. Refresh the user's view
If the service has a user-facing tab, the open iframe is still showing the pre-change page. Refresh it so the user sees the update without being told to click Refresh:
python3 system/scripts/layout.py refresh <name>
refresh reloads every iframe for the service. If no tab is open yet and
the change is ready to show, surface it instead with
python3 system/scripts/layout.py open <name>: with no --view it lands in
the view the user is looking at, and --view <name> targets one view (it
fails fast and harmlessly when no client has that view active).
For any other tab manipulation, see manage-layout. Background daemons have
no tab -- skip the tab refresh, but not the rest of this step.
If you restarted the whole services agent rather than a single program, one tab refresh is not enough -- the workspace shell itself was bounced. Rebuild the user's whole view instead:
python3 system/scripts/refresh_workspace_view.py
Nothing else does this for you. The Minds app only intervenes when a workspace looks unreachable for a sustained stretch, and a services restart that comes back quickly never crosses that bar, so the user is left reading the page the previous build rendered. The helper is fire-and-forget and always exits 0; it names any channel that did not land on stderr and is never a reason to stop.
4. Verify
Confirm the change actually does the right thing, exercised as the user would (not just "the process is up"):
- App:
curlagainst the registered backend URLhttp://127.0.0.1:<port>/then a Playwright assertion on a marker unique to your change. The recipe is inbuild-app's verify reference; the symptom-indexed gotchas (connection refused, a tab stuck on the loading page, broken WebSockets) are in that skill'scross-flow-gotchas.md. - Daemon: watch its log (
supervisorctl tail -f <name> stderr) and confirm the new behavior actually fires.
Protect the user's data while you verify
The service's persistent store -- data/.apps/<name>/ (whatever DATA_DIR
resolves to) -- is the user's real data. The recurring, expensive
failure mode is not the code edit: it is verifying a change by writing
test data into the live store and then "cleaning up" with a delete/reset
whose predicate is too broad and takes real records with it. The delete is
where the data dies. Encode these, cheapest first:
Read-only verification needs no ceremony. Most changes (UI, copy, a backend read path) can be exercised by curl/Playwright against the live service without writing anything. Reading the live store -- including to render a preview -- is fine; the danger is only writes.
If exercising the change must write, mutate, or delete data, never point it at the live store. Copy the store to a scratch path outside
data/(so it is neither served by the live service nor backed up), boot a throwaway instance against the copy on a spare port, exercise it there, then delete the copy. The sharedserve_isolated_instance.pyscript owns the boot + teardown -- it picks a free port, injects it (via the<PACKAGE_UPPER>_PORToverride) plus your data-dir override, waits for the instance to answer, and prints its URL:cp -r data/.apps/<name> /tmp/<name>-scratch URL=$(python3 .agents/shared/scripts/serve_isolated_instance.py up \ --name <name>-test --cwd . \ --port-env <PACKAGE_UPPER>_PORT \ --env <PACKAGE_UPPER>_DATA_DIR=/tmp/<name>-scratch \ --health-path /health \ -- uv run <name>) # ...exercise the change at "$URL" (curl / Playwright); it can write freely... python3 .agents/shared/scripts/serve_isolated_instance.py down --name <name>-test rm -rf /tmp/<name>-scratch # deleting a copy can't harm real dataThis is the point of the
DATA_DIR+<PACKAGE_UPPER>_PORToverrides: the isolation you need is data isolation, not code isolation, and it's a copy-plus-one-command setup, not a worktree. The live store is only ever read (once, to make the copy); the only delete lands on a disposable path where real data never lived. If you want the user to see the throwaway instance -- a redesign, or a risky change where a hand mock won't convince -- add--service-name <name>-preview-app --preview-service-name <name>-preview --preview-title "<change>"to theupcall to surface it as a labeled "preview" tab (open it withpython3 system/scripts/layout.py open <name>-preview); that is the same machinery the system-interface flow uses. Use judgment on when that is worth it.Never "clean up" test data by deleting from the live store. If you did leave a stray test record in it, leave it -- an additive junk record is a far cheaper mistake than a broad delete. Better: don't write to the live store in the first place (use the copy above).
Snapshot before any genuinely in-place change to the real store. If a change truly must rewrite the live store (a data migration you can't run on a copy),
cp -r data/.apps/<name> /tmp/<name>-pre-<change>first, run the change, confirm the real data survived, and only then remove the snapshot. The snapshot is a recovery net -- do not turn it into a routine "wipe live and restore backup" step: overwriting a running service's store tears its state, and any real writes that landed during your test window are silently lost on restore.Retrofit older services when you touch them. A service that predates this convention hardcodes
data/.apps/<name>/and its listen port at its call sites. Add both overrides the scaffold now emits, as part of your change, so the throwaway instance above works: the data-dir overrideDATA_DIR = Path(os.environ.get("<PACKAGE_UPPER>_DATA_DIR", "data/.apps/<name>"))(route reads/writes through it), and the port overridePORT = int(os.environ.get("<PACKAGE_UPPER>_PORT", "<assigned-port>"))(bindPORTinrun_simple, never a hardcoded literal). If you genuinely can't, fall back to read-only verification plus the snapshot net.The copy isolates local state, not external effects. Pointing at a data copy does not stop a test run from really posting to Slack, calling a remote API, or sending a message. Guard those separately (a dry-run flag, test credentials) -- the data copy only protects the local store.
Removing a service
Dropping a service is the definition-level case of step 2: remove its
[program:<name>] block, supervisorctl reread && supervisorctl update,
and (for an app) python3 system/scripts/forward_port.py --name <name> --remove plus reverting the scaffolded lib. The mechanics are in
.agents/shared/references/service-processes.md; for a
scaffolded web lib, build-app's cleanup.md reference has the
full teardown.
Teardown stops at the code and the process. Leave the service's data
(data/.apps/<name>/) in place -- removing a service is not license to
delete the user's records. Delete the data dir only if the user explicitly
asks, and confirm before you do.
Turn-end: get feedback, then harden the change
The live loop above delivers the change to the user interactively. At turn-end, formalize it through the background worker pipeline -- the main agent never runs the thorough test passes or the review gates itself.
Get the user's feedback before you start any hardening pass. Delivering
the change live is not the same as the user wanting it, so never dispatch
the hardening worker in the same turn you make the change. This holds for
every change, contained or larger-scope -- even a one-line copy tweak gets
shown and confirmed first. It can take several rounds: treat each
response as another live iteration -- make the change, show it, ask again --
and hold the harden pass until you are sure the user is satisfied. A single
"looks fine" mid-thread while they're still tweaking isn't done. (For a
larger-scope change this gate is the working result, not just the mock,
exactly as build-app's Step 5 gates on the working site.)
- A change you and the user discussed and applied live, or repeatable
work you did by hand -> invoke
update-creationwithtype=app. It opens a tracking ticket, dispatches the generic harden worker to verify/test the change on its own branch, proxies the gates, merges, and refreshes the tab on go-live. - The service errored or produced a wrong result and you worked around
it -> invoke
heal-creationwithtype=app(ortype=servicefor a background service) at turn-end instead. - The workspace UI (
system/apps/system_interface) ->update-system-interfaceowns its own preview-before-merge and its go-live through the atomic update apply; use it rather than this flow.
update-creation and heal-creation also stand on their own as turn-end
skills; this skill's turn-end step is just the service-shaped entry into
them.
Both flows enforce single-flight per creation: if another chat already has a
harden pass in flight for this service, they leave a note on its ticket
instead of dispatching a sibling, and the eventual superseding pass covers
both changes. See
.agents/shared/references/harden-contention.md.