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.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
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.
metadata
{"author":"imbue","crystallized":true}
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.
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.
--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.
--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>/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/supervisord.conf -- appends a program block:
[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=trueautorestart=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:
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 " 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 -- 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
-- 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.).
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:
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):
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):
[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=trueautorestart=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.
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
< = = =
= = =>
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.
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.
svg
viewBox
"0 0 24 24"
fill
"none"
stroke
"currentColor"
stroke-width
"2"
stroke-linecap
"round"
stroke-linejoin
"round"
<pathd="..."/>
</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.