# Build App

> Use when you want to create a new app for the user -- a page, dashboard, or tool they can open as a tab. Runs an interactive flow: confirm the look and feel on a cheap throwaway mock first, then build the real app to a usable state, then harden it in the background. Covers scaffolding a new Flask app (canonical path) and the escape hatch for wrapping a pre-existing third-party server.

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

---


# How to build an app

An "app" here is something the user can click on as a tab in
the desktop client and see render at its own browser origin --
locally `http://<name>.<workspace-host>/` (e.g.
`http://news.host-ab12.localhost:8421/`). The forwarder routes that
origin straight to the port you register; nothing proxies or rewrites
your app's traffic.

There is one canonical path (scaffold a new Flask lib) and one
escape hatch (wrap a pre-existing third-party server). Modify/remove
flows go through the `update-app` skill.

## First: fire off the plan recorder

Before anything else, run this once, briefing it the way you would brief a
sub-agent picking this up: what the user wants, plus anything already settled.
Give it context, not a plan -- working out the approach is its whole job, and a
plan in the brief just anchors it to yours.

```bash
system/scripts/imbue_plan_extra/write_plan.sh build-app <<'IMBUE_PLAN_BRIEF'
<your brief, as many lines as it needs>
IMBUE_PLAN_BRIEF
```

Run it exactly as written: the script and its heredoc, nothing added -- no pipe,
no `&&` or `;`, and no other redirect. Keep the `<<'IMBUE_PLAN_BRIEF'` quoted, so
backticks and `$(...)` in the brief stay literal instead of running. A pipe to
`head` or `tail` is blocked by a pre-tool hook, and anything appended hides the
line it prints.

It returns immediately and records a plan for offline analysis that nothing here
reads back. It is not part of building the app: no `tk` step, no mention to the
user, no waiting, no acting on the line it prints, ignore any failure. Then
carry on as if you had not run it.

## This is the web specialization of the interactive-delivery shape

**Read `.agents/shared/references/interactive-delivery.md` first.** Building a web
view is not a "scaffold, implement, ship" recipe -- it is an *interactive* flow:
you confirm the look-and-feel on a cheap throwaway mock *before* building the real
thing, build to a usable state in the foreground, and defer the thorough
testing + review gates to a background worker. The phases below fill in that
shared skeleton for web work. The single biggest mistake this skill exists to
prevent is building (and testing, and hardening) a whole site before the user has
confirmed the basic shape is what they want.

Map of the flow:

- **Step 0 -- clarify and plan** (skeleton phases 1-3): blocking questions only,
  in business terms; a small plan; wait for approval.
- **Step 1 -- scaffold + throwaway mock** (skeleton phases 4-6): scaffold the
  service, put a mock UI in front of the user, loop to explicit confirmation of
  the look-and-feel. Hard gate.
- **Step 2-4 -- build to a usable site** (the existing build mechanics, run
  *after* confirmation): implement real routes, verify, surface the tab.
- **Step 5 -- finalize in the background** (skeleton phase 7): once the user
  confirms the *working* site looks right, hand thorough testing + the review
  gates to a background worker. The main agent never runs those itself.

If you were sent here by `fetch-process-show` for an app over fetched data,
the data sample is already confirmed -- but you still run your own mock
confirmation here, because the data sample confirms the data *shape*, not the UI
shape. Render the handed-off `sample.json` in the mock so the user judges the UI
against real data.

## Step 0: Clarify and plan (business terms only)

Ask only the questions that genuinely *block* -- a fork that is both genuinely
uncertain *and* expensive to reverse later. Most apps have none: default to
the simplest conventional choice and to a **single user**, state each default in
one line, and move on. Cheap-to-reverse choices (persistence, auto-reload vs.
reload-to-refresh, latest-only vs. history) are not P0 -- pick the obvious
default and let them surface during the mock loop or as a later follow-up
surface, where the user can react to something concrete rather than answer
"should this update on its own?" in the abstract.

If you *do* hit a real blocker, phrase it as the user-visible consequence that
motivates it -- never a technical term (this system serves non-technical users):
"should everyone see the same list?" not "do we need multi-tenancy?".

Record your stated defaults -- they are the architecture you build once, after
the mock converges. Do not build any of it yet. Then propose a small plan and
wait for approval.

## Decide which path applies

- **Authoring routes yourself** (the common case): use the Flask
  scaffolder in Step 1. The scaffolder picks correct defaults so most
  framework gotchas don't fire.
- **Wrapping a pre-existing third-party server** (Jupyter, Grafana,
  an `npx`-installed dashboard, anything with its own start command):
  skip the scaffolder, jump to "Escape hatch: wrap an existing server"
  below.

If you would otherwise scaffold a Flask lib whose only job is to
shell out to a third-party tool, do not do that -- the forwarder
already routes the service's origin to whatever URL you register.
Adding a Python proxy in front of the third-party server adds a hop,
costs an extra process, and complicates WebSocket and streaming
behavior. Use the escape hatch instead.

Do not extend `system/apps/system_interface/` to add a new view. That app runs
the top-level workspace UI; new apps go in their own scaffolded lib
under `system/apps/<your-package>/` so they get an isolated tab and origin.

## Pre-flight (both paths)

- **Pick a kebab-case app name.** Becomes the service's hostname
  label: the tab renders at `http://<name>.<workspace-host>/`, so the
  name must be DNS-safe -- lowercase letters/digits with single
  hyphens, and it must not start with `host-` or `agent-` (those
  prefixes are reserved for workspace hostname coordinates). Short and
  descriptive (`news`, `docs-viewer`) beats clever. Avoid names
  already used in `system/supervisord.conf` (`system_interface`,
  `browser`, etc. are reserved by the scaffolder).
- **Draw the app's icon** -- an `.svg` glyph specific to what *this*
  app does, in the house style (see the CLI reference below);
  `forward_port.py` refuses a brand-new registration without one. The
  scaffold copies it beside the app's manifest (`app.toml`), which names
  it.
- **Pick a free port.** `ss -tln` lists what's bound. The scaffolder
  picks the lowest free port at or above 8080 by parsing
  `system/supervisord.conf` and `data/.state/apps.toml`; if you're choosing
  manually, avoid `8000` (system_interface), `8010` (the chat app) and
  `8081` (the browser service).
- **Bind to `127.0.0.1`** (not `0.0.0.0`). The forwarder reaches your
  app from inside the same container; binding to all interfaces is
  noise. The scaffolder does this. For the wrap-existing path, many
  Node frameworks default to `0.0.0.0` -- pass an explicit host
  (`HOST=127.0.0.1`, `app.listen(port, "127.0.0.1")`, etc.) if your
  third-party tool's default isn't loopback. Python defaults are
  usually loopback already.

## Step 1: Run the scaffolder (canonical path)

```bash
uv run .agents/skills/build-app/scripts/scaffold_flask_lib.py \
    --name <service-name> \
    --description "<one-liner>" \
    --icon-file <path-to-svg> \
    [--display-name "<what users see>"] \
    [--port <int>] \
    [--extra-dep <pkg>] [--extra-dep <pkg>] ...
```

Required:
- `--name`: kebab-case (lowercase letters/digits with single hyphens;
  must not start with `host-` or `agent-`) -- it becomes the service's
  hostname label.
- `--description`: becomes the lib `pyproject.toml` description.
- `--icon-file`: the icon you drew in pre-flight (`.svg` only); copied
  to `system/apps/<package>/icon.svg`, named by the manifest, and
  registered on every start.

Optional:
- `--display-name`: what users see for the app (the manifest's
  `display_name`, at most 64 characters). Defaults to the description,
  so pass it when the description is long.
- `--port`: explicit port; auto-picked if omitted.
- `--extra-dep`: repeatable. Add libraries beyond `flask`/`flask-sock`
  (e.g. `--extra-dep "jinja2>=3.1" --extra-dep "anthropic>=0.40"`).
- `--skip-uv-sync`: skip the final manifest check, tool install and
  `uv sync --all-packages` (for fast iteration / dry runs).

The scaffolder fails non-zero with a clear stderr message if the lib
already exists, the name is reserved or invalid, the requested port
is taken, or the manifest check, the tool install or `uv sync` fails.

What gets generated:

- `system/apps/<package>/app.toml` -- the app's manifest: its registered
  `name`, `display_name`, `icon`, `instances = false` (one tab),
  `priority = "user"` (shed before any built-in under memory pressure),
  and `program` (its supervisord program). `forward_port.py --manifest`
  reads it on every start; the scaffold checks it with `uv run app-manifest
  validate-manifest system/apps/<package>/app.toml` (run that yourself after
  editing it).
- `system/apps/<package>/pyproject.toml` -- declares
  `[project.scripts] <name> = "<package>.runner:main"`, the entry point
  the app's own tool environment exposes.
- `system/apps/<package>/src/<package>/__init__.py` -- empty.
- `system/apps/<package>/src/<package>/runner.py` -- sync Flask starter.
  Builds a `Flask` app and serves it with
  `werkzeug.serving.run_simple(..., threaded=True)`. It serves at `/`,
  and the app owns its own browser origin, so no path prefix or
  `root_path`/`ROOT_PATH` is needed. It also defines a `DATA_DIR`
  constant (defaults to `data/.apps/<name>/`, overridable via the
  `<PACKAGE_UPPER>_DATA_DIR` env var) -- route all persistent state
  through it (see File-path conventions below) -- and a `PORT` constant
  (defaults to this service's assigned port, overridable via the
  `<PACKAGE_UPPER>_PORT` env var) bound in `run_simple`. Both overrides
  are what let a future edit boot a throwaway instance on a spare port
  against a data copy (see `update-app`). The scaffolded index page also
  carries the **location beacon** one-liner -- a script that posts
  `{type: "shell:location", path: location.pathname + location.search}`
  to `window.parent` on page load. Keep that line on every page the app
  serves: it is what lets the workspace shell reopen the app's tab at
  the place it was showing (the shell validates the sender's origin and
  relays the path to the app's own instances API, which stores it on the
  instance's record). An app that drops it simply always reopens at its
  origin.
- `system/apps/<package>/test_<package>_ratchets.py` -- standard ratchets at
  zero.
- `system/apps/<package>/README.md` -- one-line description.

What gets updated and installed:

- `system/supervisord.conf` -- appends a program block:

  ```ini
  [program:<name>]
  command=python3 system/services/oom_priority/bin/oom_tag_service.py user bash -c "python3 system/scripts/forward_port.py --manifest system/apps/<package>/app.toml --url http://localhost:<port> && <name>"
  directory=/home/user/workspace
  autostart=true
  autorestart=true
  # plus rotated stdout/stderr logfiles under /var/log/supervisor/<name>-*.log
  ```

  The Flask app serves at `/` and needs no prefix env var: your app
  owns its origin, so root-absolute URLs (`href="/api"`), WebSockets
  (`new WebSocket("/ws")`), cookies (`Set-Cookie: Path=/`), and
  service workers all work exactly as written -- nothing rewrites
  anything. The
  `bash -c "..."` wrapper is required because supervisord runs commands
  directly (no shell) and this one chains `forward_port.py` with `&&`. The
  `oom_tag_service.py user` prefix tags this user-created app so it is
  shed before any built-in service under memory pressure (see
  `system/services/oom_priority/README.md`).
- The app's own uv tool environment: the scaffold runs
  `uv tool install -e system/apps/<package>`, which is what puts the
  `<name>` entry point the program line runs on PATH. Every Python app
  runs from its own tool rather than the root venv (the root venv is for
  background services, agents, skills, and scripts), so a dependency you
  add later needs `uv tool install -e system/apps/<package> --reinstall`
  (see `update-app`). The root `pyproject.toml` is not edited: the
  `system/apps/*` member glob already covers the package, and the final
  `uv sync --all-packages` keeps the root lockfile current for it.

supervisord does not watch the config, so tell it to pick up the new
program, then confirm it is running:

```bash
supervisorctl reread && supervisorctl update
supervisorctl status <name>
```

If it isn't `RUNNING`, read its log
(`/var/log/supervisor/<name>-stderr.log`) or run
`supervisorctl tail <name> stderr`.

### Put a throwaway mock in front of the user (the confirmation gate; looped)

Scaffolding the service is fine before confirmation -- it is cheap and reversible.
**Building the real data layer or state architecture before the user confirms the
look-and-feel is the tripwire: do not.** Instead, serve a *throwaway mock* of the
proposed UI as a route inside the scaffolded service, so the user sees it as a
real tab and reacts to the actual look-and-feel.

This is skeleton phase 5 (the cheap throwaway mock). Keep it disposable:

- The mock renders **static / hard-coded content** that demonstrates the proposed
  layout and interactions -- no real fetching, no persistence, no backend logic.
  Invoke the `frontend-design` skill before writing the markup (see Step 2).
- If you were handed a confirmed `sample.json` (the `fetch-process-show` hybrid),
  render *that real data* in the mock so the user judges the UI against real
  content. Otherwise use representative placeholder data that covers the shapes
  the real view will show (including an empty state and a busy/overflow state).
- `layout.py open` to surface it (see Step 4 for the command and its `--view` flag), then loop:
  present -> take feedback -> update the mock so the change is *visible* ->
  re-present. Do not accept feedback and move on having only asserted you'll apply
  it.
- Loop until the user **explicitly confirms** the look-and-feel is right.

The user may respond to the mock with a request for functionality that requires updated backend support.
Your mocks should remain mostly frontend code but demonstrate how things would likely look and feel
once that updated backend code is implemented. Be careful to confirm that the user will be happy
with how things look and feel and approximately function prior to doing the heavy work of building out backend code.

**Hard gate (skeleton phase 6).** Do not implement real routes, data, or state
(Step 2 onward) until that confirmation. The mock is the single source of truth
for the UI shape: if later work changes the look-and-feel, re-confirm before
calling the site done.

For the **escape-hatch path** (wrapping a third-party tool) there is no markup you
author, so there is no mock to build -- the demonstration is the wrapped tool
itself. Stand it up, show it to the user, and confirm it's what they wanted before
investing in configuration or integration around it.

## Step 2: Build the real routes to a usable site (after confirmation)

Everything from here runs **only after** the user has confirmed the mock. The
goal of the foreground work is a *usable* site the user can actually try -- not a
fully hardened one. Implement the real routes (replacing the mock), wire in the
data/state architecture you recorded in Step 0, run the Step 3 smoke verify, and
surface the tab (Step 4). Then **stop and hand the running site to the user** --
the thorough testing and review gates happen in the background (Step 5), not here.

The starter `runner.py` has just `GET /` (a placeholder HTML page)
and `GET /health` (returns `{"status": "ok"}`). Replace the
placeholder with your real routes.

Use **sync handlers** (`def`, not `async def`). Flask handlers are
sync `def`, and the starter runs on the threaded Werkzeug server
(`run_simple(..., threaded=True)`), so concurrent requests are handled
by separate threads -- no asyncio needed.

### Rendering HTML for a human

If your service renders HTML that a person will look at (anything
beyond a pure JSON API, a webhook receiver, or a transparent proxy of
a third-party tool), you must invoke the `frontend-design` skill **before**
writing the markup. Always do this before working on UI, regardless of the scope of the work.

Skip this step for routes that emit only JSON, only redirects, or that
serve an existing third-party UI through the escape hatch below --
there's no markup to design.

### Calling Claude from your service

If your service needs to call Claude (classify/summarize content, run a one-shot
agentic task, or launch a full agent), follow the `use-ai-integration` skill: it
picks the path (a keyed `litellm` call or the keyless `claude_p.py` helper),
covers the `claude -p` environment fix and the cost model, and saves you from
hand-rolling the call.

### Always surface the raw data and its source

When a view renders data *derived* from underlying records (a summary,
a reformatted list, extracted fields), include -- by default, without
the user asking -- a "view raw" control showing the original record
**rendered in its native format** (an HTML email as the rendered email,
not escaped source; JSON pretty-printed; markdown rendered -- the
faithful original minus your processing) plus, for records from an
external service, an "open in <source>" link back to the origin (e.g.
open the email in Gmail). When you render untrusted third-party HTML (a
raw email body is the common case), sandbox it -- a sandboxed `iframe`
or a sanitizer -- so the view can't run scripts or phone home via
tracking pixels.

This is the surfacing half of the preserve-and-surface principle
(CLAUDE.md): the derived view inevitably leaves gaps (a field the agent
didn't extract, a rendering it didn't anticipate), and the raw/source
affordance lets the user bridge them without waiting for a rebuild.
Design it in from the first version -- it depends on the data layer
having persisted the raw payload and source reference (see the
crystallize data-capture guidance), so confirm that's available and
flag it if it isn't. Keep it unobtrusive (a small per-record control,
not clutter) and don't call it out in chat -- always present, never
announced.

### File-path conventions

Two cases, two patterns:

- **Persistent state** (caches, cursors, last-visit timestamps, JSON
  snapshots, user records -- anything written and read across runs):
  read and write it under the generated `DATA_DIR` constant, never a
  hardcoded `data/.apps/<name>/` at the call site. `DATA_DIR` defaults to
  `data/.apps/<name>/` (cwd-relative, resolved from `/home/user/workspace` where the
  supervisord-managed service runs) but honors the
  `<PACKAGE_UPPER>_DATA_DIR` env var. That override is what makes a
  future edit safe: an agent changing the service can run a throwaway
  instance against a *copy* of the data instead of the live store (see
  `update-app`), so keep every read/write going through `DATA_DIR`
  -- a hardcoded `data/.apps/<name>/` silently bypasses the override and
  re-exposes the live data. Do NOT use `Path(__file__)`-based paths for
  state.
- **Static assets shipped alongside the .py file** (templates,
  default configs, bundled JSON): `Path(__file__).parent / "assets/..."`
  is the right pattern.

## Step 3: Verify

Both paths use the same verification recipe. See
[references/verify.md](references/verify.md) -- curl against the
registered backend URL `http://127.0.0.1:<port>/` then a Playwright
assertion on a unique-to-your-app marker.

If verification surfaces something unexpected (connection refused,
a tab stuck on the loading page, broken WebSockets), see
[references/cross-flow-gotchas.md](references/cross-flow-gotchas.md)
-- it's symptom-indexed.

## Step 4: Surface the view to the user

Once verification passes, tell the workspace UI to actually open the
new tab. Without this step the user would have to discover it via the
"+" dropdown -- skip the surfacing step only for services with no UI
(pure JSON APIs, webhook receivers, etc.).

```bash
python3 system/scripts/layout.py open <name>
```

With no `--view`, the op edits the view the target client is looking
at, which is where the user expects the new tab. (Pass `--view <name>`
-- a project's name, or `Everything` -- to surface it in a different
view instead; the op edits that view's arrangement and switches the
client to it.)
`layout.py` POSTs to a loopback-only shell endpoint that applies the op
to that client's saved layout (no browser needs to be connected) and
broadcasts `layout_updated`, so every window of the client docks the
new tab beside the requesting chat, or brings the tab for `<name>` to
the front when it is already open.
The script briefly waits for the service to appear in
`data/.state/apps.toml` so it's safe to run immediately after the
`forward_port.py` call.

To force a reload of an already-open tab (e.g. after redeploying the
service) without prompting the user to click Refresh:

```bash
python3 system/scripts/layout.py refresh <name>
```

You should always `refresh` services after making changes, to make sure the user can see the updates.

For anything beyond `open` / `refresh` -- splitting, moving, focusing,
renaming, maximizing, replacing an iframe's URL, inspecting the live
tree -- see the `manage-layout` skill. `layout.py list` is also useful
when the user is asking about what tabs are available (it prints every
app with its instances: address, title, status, and which clients have
each docked).

## Step 5: Finalize in the background (after the user confirms the working site)

The foreground work stops at a usable, surfaced site. The thorough pass --
extending Playwright coverage, the full test suite and ratchets, review gates
 -- runs in a **background harden worker**, never in the
main agent. This is skeleton phase 7: the harden pass
(`.agents/shared/worker/references/harden-creation.md`), here the **crystallize**
operation with the **app** type -- the scaffolded app is already on
disk and the user confirmed it live, so nothing needs reconstructing and there
are no worker gates.

**The trigger is an explicit confirmation on the *working* site -- never your own
sense that the code looks done.** Once the usable site is in front of the user,
ask a plain "this generally looks good?" and hand off only once they confirm by
exercising the real behavior. (The mock confirmed the UX *shape*; this confirms
the real *behavior* -- the point where deep changes actually surface, so
finalizing earlier risks hardening an architecture the user is about to
invalidate.)

Reading the confirmation signal:

- If the user keeps asking for changes, each one is a **cheap foreground
  iteration that resets the clock** -- you have run no gates or thorough tests
  yet, so pivots stay cheap. Do not hand off until their response is a
  confirmation rather than a change request.
- If the user starts asking for surface-level (cosmetic) tweaks, or pivots to a
  slightly unrelated task or follow-up, treat that as a sign the core is settled:
  still ask, but ground it -- "seems like we've got the core thing settled here
  -- good to lock it in?" -- rather than leaving it open-ended.
- Wait for an explicit confirmation rather than firing on a timeout or silence.
  The user is never blocked: they already hold the usable site.

On confirmation, **hand the confirmed app to the `crystallize-creation`
skill with `type=app`.** It owns the rest -- the tracking ticket, the
task file (set `type: app`), launching the generic `harden-worker`,
polling, merging on `done`, and refreshing the tab after merge. Give it only:
the slug (the app name), and a task body naming the built lib path, the
app name, the URL segment, and what the app does. The generic worker
loads `harden-creation.md` + `op-crystallize.md` + `type-app.md` and
reports `done` once its testing contract and the review gates pass; there is no
worker gate because the user already confirmed the live site.

The confirmed mock plus the confirmed working site remain the single source of
truth: if finalization changes the look-and-feel, re-confirm with the user before
calling the work done.

## Escape hatch: wrap an existing server

For pre-existing third-party tools, do not scaffold a lib. Save your
icon as `system/apps/<name>/icon.svg` and write the app's manifest beside
it as `system/apps/<name>/app.toml` (like the `files` app):

```toml
name = "<name>"
display_name = "<What users see>"
icon = "icon.svg"
instances = false
priority = "user"
program = "<name>"
```

Then add a `[program:<name>]` block to `system/supervisord.conf` that runs
`forward_port.py --manifest` and then your existing start command.
supervisord runs commands directly (no shell), so wrap any command that
chains with `&&` in `bash -c "..."`, and prefix the whole thing with
`python3 system/services/oom_priority/bin/oom_tag_service.py user` so this user-created app is
shed before any built-in service under memory pressure (see
`system/services/oom_priority/README.md`):

```ini
[program:<name>]
command=python3 system/services/oom_priority/bin/oom_tag_service.py user bash -c "python3 system/scripts/forward_port.py --manifest system/apps/<name>/app.toml --url http://localhost:<port> && <existing_start_command>"
directory=/home/user/workspace
autostart=true
autorestart=true
```

Two valid shapes:

- **Inline** (preferred when one line fits):

  ```ini
  [program:docs-viewer]
  command=python3 system/services/oom_priority/bin/oom_tag_service.py user bash -c "python3 system/scripts/forward_port.py --manifest system/apps/docs-viewer/app.toml --url http://localhost:8090 && jupyter notebook --port 8090 --ip 127.0.0.1 --no-browser"
  directory=/home/user/workspace
  autostart=true
  autorestart=true
  ```

- **Wrapper script** (preferred for multi-step bootstrap or env exports):

  ```bash
  # system/scripts/run_<name>.sh
  #!/usr/bin/env bash
  set -euo pipefail
  python3 system/scripts/forward_port.py --manifest system/apps/<name>/app.toml --url http://localhost:<port>
  exec <existing_start_command>
  ```

  ```ini
  [program:<name>]
  command=python3 system/services/oom_priority/bin/oom_tag_service.py user bash system/scripts/run_<name>.sh
  directory=/home/user/workspace
  autostart=true
  autorestart=true
  ```

After editing `system/supervisord.conf`, run `supervisorctl reread &&
supervisorctl update` to start the new program.

The `forward_port.py` call MUST come first in the command -- the port
must be registered before the app starts listening, otherwise the
app-watcher races with the backend coming up.

For the full program schema and logging knobs, see the shared
[`.agents/shared/references/service-processes.md`](../../shared/references/service-processes.md).

Verification and gotchas references apply identically to this path.

## `forward_port.py` CLI reference

Used by both paths (the scaffolder generates the call; the escape
hatch has you write it directly).

```
python3 system/scripts/forward_port.py --manifest system/apps/<package>/app.toml --url URL
python3 system/scripts/forward_port.py --name NAME --url URL --icon-file PATH
python3 system/scripts/forward_port.py --name NAME --remove
```

The script is standard-library only and runs under a plain `python3`, so
registration never depends on the root venv.

Flags:

- `--manifest`: the app's `app.toml`. Its `name` (validated like
  `--name` below), the icon file it names (validated like `--icon-file`),
  and its static fields (`display_name`, `instances`, `instances_url`,
  `critical`, `priority`, `program`, `internal`, `default_shortcut`,
  `actions`) are copied onto the registry row on every call, so a changed
  manifest updates the row on the next start. This is the form every app
  with a directory uses. `--name` may accompany it and must then equal the
  manifest's name; `--icon-file`, `--program`, `--internal` and `--no-icon`
  are for registrations with no app directory (previews, isolated test
  servers) and cannot be combined with it.
- `--name`: app name. It becomes the service's hostname label (the
  tab renders at `http://<name>.<workspace-host>/`), so it is
  validated: lowercase letters/digits/underscores with single hyphens,
  and it must not be `localhost` or start with `host-` or `agent-`
  (reserved for workspace hostname coordinates). Registration fails
  loudly on an invalid name.
- `--url`: full URL where the app is reachable from inside the
  container (e.g. `http://localhost:8090`).
- `--icon-file`: path to the app's `.svg` icon (SVG only -- no
  rasters), drawn instead of the generic letter monogram. **Required
  when creating a new entry** (unless `--internal` or `--no-icon`);
  omitting it on re-registration keeps the stored icon. The file's
  *contents* are stored: a single safe `<svg>` element (no script,
  style, event handlers, or external references; at most 16384
  characters). A bad file fails a new registration loudly, but only
  warns on re-registration (the stored icon is kept), so a corrupted
  icon cannot crash-loop a running app.

  **Draw the icon in the workspace's house style**: monochrome line
  art on a transparent background, exactly like the built-in glyphs.
  The frame to author in is

  ```svg
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
       stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <path d="..."/>
  </svg>
  ```

  -- strokes only, no `fill` on the shapes, no hardcoded colors.
  `currentColor` is what lets the workspace ink the glyph to match the
  text beside it, and a transparent background is what keeps it from
  reading as a sticker in a row of line icons. Only use color if the
  user explicitly asks for a colored icon.
- `--no-icon`: skip the icon requirement for a brand-new entry. Uses
  the generic letter monogram. Use this only when the user explicitly
  declines an icon, or for short-lived preview tabs.
- `--program`: name of the supervisord program that runs the app --
  the program-name-equals-service-name convention both paths follow, so
  pass the app's own name. Its presence on the registry entry is what
  lets the workspace offer Stop/Start for the app (supervisord RPC);
  omitting it clears any previously-stored value, so every registration
  call is authoritative. Never pass it for unsupervised instances
  (previews, `serve_isolated_instance.py` test servers) -- those own
  their own teardown and must not offer a Stop that supervisord cannot
  honor.
- `--remove`: remove the named entry from
  `data/.state/apps.toml`. Use this when tearing down a service.

## The shared (public) URL

If the workspace is shared, every registered service is also reachable at
its own public origin -- the same prefix rule on the share hostname
(`https://<name>.<workspace-share-host>/`) -- with caveats about where that
hostname lives and why it isn't in `data/.state/apps.toml`. See
[references/public-url.md](references/public-url.md).

## Cleanup

To remove an app (drop the `apps.toml` entry, stop and unregister
the supervisord program, and revert the scaffolded lib), see
[references/cleanup.md](references/cleanup.md).

