29 KiB
App Builder — Platform Reference
You are building an App inside OpenSwarm. The workspace you're working
in is a React 18 + TypeScript + Vite project (with an optional FastAPI
backend you can opt into on demand). It's served live to a webview, so it
behaves like a real browser tab — cross-origin fetch, popups, mic/camera,
clipboard, anything a normal web page does.
STEP 0 — pick the right shape for the app
Before writing any code, decide whether this app should be workspace
(full React/MUI, the default) or lightweight (one self-contained
index.html). Picking wrong wastes the user's time: the workspace path
spends ~10-30 s pre-bundling MUI and React on first preview, which is
pointless when the app is a 200-line Three.js demo.
Lightweight when ALL apply:
- One page, no route navigation
- No persisted server state (no DB-shaped data the user comes back to)
- No real backend logic (just CDN libraries, in-memory state)
- The whole UI is essentially one of: canvas/WebGL scene, single-file visualization (D3/Plotly/Chart.js), single-purpose tool (formatter, calculator, color picker), tiny game or simulator
Workspace (this document's default) when ANY apply:
- Multiple pages with sidebar/route navigation
- Multiple distinct UI sections with their own state
- Real backend (FastAPI endpoints, file uploads with server processing, auth, persisted user data)
- Real-time updates (WS/SSE)
- The user is likely to ask for more features later (chat, dashboards, CRUD apps — these grow)
Examples — lightweight: "rotating Three.js cube", "Pomodoro timer", "JSON formatter", "Mandelbrot explorer", "CSV → bar chart (no save)", "first-person Minecraft-style demo", "color picker", "regex tester".
Examples — workspace: "chat app", "PDF previewer with annotations", "task manager with categories", "recipe app", "weather dashboard with saved cities", "Slack-style team chat with channels".
If you're unsure, lean workspace — it's strictly more capable and the boot cost only hits once per app, then warm cache makes subsequent boots fast.
Lightweight — how
- Delete everything under
frontend/src/(index.tsx,app/,pages/,shared/). Vite servesfrontend/index.htmldirectly when there's no module graph to crawl, so the pre-bundle step is skipped entirely. - Replace
frontend/index.htmlwith a single self-contained document. Inline<style>and<script>. Pull libraries fromesm.sh/unpkgvia<script type="importmap">or plain<script src=...>. - Leave
frontend/package.json,frontend/vite.config.ts,run.sh,.env,meta.jsonalone — vite still needs them. - Don't run
bash backend_init.sh— lightweight mode has no backend. - Agent control still works without the template:
window.OPENSWARM_APPis injected by the app shell, so even here you can make the app agent-operable by callingwindow.OPENSWARM_APP.register({ rules, controls, getState, invoke })from your inline<script>(see the bridge section below). This is optional, the agent can also play any app via native keyboard/mouse, but for a game/canvas a registeredgetState(e.g. score, alive) makes it far more reliable. Don't wire up the bridge object yourself; it already exists.
The rest of this document covers workspace mode. If you picked lightweight, only the "Debugging" section (frontend console logs in the Terminal pane) is relevant; skip everything else.
You are NOT writing a single HTML file or vanilla JS inside a workspace. If you picked workspace mode above, match the codebase's patterns described below.
Workspace layout
workspace/
├── .env # FRONTEND_PORT, BACKEND_PORT (NONE by default)
├── .env.example # Mirror of .env (LLM-consistency — edit both
│ # when you change either)
├── run.sh # OpenSwarm's runtime spawns this; you don't
├── backend_init.sh # Run this when you need a backend (see below)
├── restart.sh # Run this to restart the app runtime (see below)
├── SKILL.md # This document
└── frontend/
├── package.json # React 18, MUI v7, Redux Toolkit, Framer
│ # Motion, react-router v7
├── vite.config.ts # Vite config — DO NOT edit unless you know why
├── tsconfig.json # `@/*` → `src/*` path alias
├── index.html
└── src/
├── index.tsx # ReactDOM entry; mounts <Main />
├── app/
│ ├── Main.tsx # Redux + Theme + BrowserRouter + AppShell
│ └── components/
│ └── Layout/
│ ├── AppShell.tsx # Sidebar + scrollable content
│ └── Sidebar.tsx # Nav, theme toggle
├── pages/ # FILE-BASED ROUTING — see below
│ ├── index.tsx # /
│ └── health.tsx # /health
└── shared/
├── hooks.ts # useAppDispatch, useAppSelector
├── state/
│ ├── store.ts # Redux store config
│ ├── tempStateSlice.ts # Sample slice — replace or extend
│ └── API_ENDPOINTS.ts # ALL backend URL constants
└── styles/
└── ThemeContext.tsx # Design tokens — USE THESE
If a backend is enabled (after bash backend_init.sh), you'll also have:
└── backend/
├── pyproject.toml # FastAPI + typeguard (+ swarm_debug)
├── main.py # FastAPI app entry — registers SubApps
├── apps/ # Each feature is a SubApp
│ └── health/
│ └── health.py # GET /api/health/check
└── config/Apps.py # SubApp / MainApp plugin framework
File-based routing
vite-plugin-pages auto-registers every .tsx file under frontend/src/pages/
as a route. You don't touch any router config. Just create the file.
src/pages/index.tsx→/(ships with a "Brewing your app" placeholder — overwrite first)src/pages/about.tsx→/aboutsrc/pages/users/index.tsx→/userssrc/pages/users/[id].tsx→/users/:id(dynamic segment)src/pages/users/$id.tsx→/users/:id(alternate dynamic syntax, same plugin)
Each page is a default-exported React component:
// src/pages/about.tsx
export default function About() {
return <Box sx={{ p: 4 }}>About this app</Box>;
}
Add a sidebar link via frontend/src/app/components/Layout/Sidebar.tsx.
Styling — MUST use the design token system
The template ships a complete design system at frontend/src/shared/styles/ThemeContext.tsx.
Use tokens via the useClaudeTokens() hook (or whatever the template exposes — check the file).
Don't hand-roll hex colors or pixel values.
Patterns:
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export default function Card() {
const c = useClaudeTokens();
return (
<Box sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
p: 3,
}}>
<Typography variant="h2" sx={{ color: c.text.primary }}>
Hello
</Typography>
</Box>
);
}
- Use MUI components (
Box,Typography,Button,IconButton,Tooltip,Stack, etc.) — never write raw<div>for layout. - Use the
sxprop for styles, not separate CSS files. - Don't add Tailwind, Bootstrap, or any other CSS framework.
MUI imports — ALWAYS use path imports, NEVER barrel imports
This is non-negotiable. Vite pre-bundles every entry in a barrel import,
which means a single import { Button } from '@mui/material' forces Vite
to optimize 200+ MUI sub-modules — adding ~10–15 seconds to every cold
boot of the workspace's preview. MUI's own performance guide
(https://mui.com/material-ui/guides/minimizing-bundle-size/) recommends
path imports for exactly this reason.
// ✅ DO — path imports, one per component
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
// ❌ DON'T — barrel imports drag in all of @mui/material
import { Button, Box, Stack, Typography } from '@mui/material';
This template is MUI v7. For layout use import Grid from '@mui/material/Grid' (the v7 Grid takes
size={{ xs: 12, md: 6 }}). Do NOT import @mui/material/Grid2 or @mui/material/Unstable_Grid2 —
those are v5/v6 paths and DO NOT EXIST here, they crash the build with "Failed to resolve import". When
unsure a component or path exists, prefer plain Box with fl*/grid sx instead of guessing a package path.
Same rule for icons — even more important there because
@mui/icons-material re-exports thousands of SVG components:
// ✅ DO
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
// ❌ DON'T
import { Add, Delete } from '@mui/icons-material';
Icon discipline: keep icon imports to the minimum the UI actually uses. If a page only needs 4 icons, import 4 — don't pre-import 20 for "maybe later." Each icon import is another module Vite has to pre-bundle on first boot.
Check frontend/DESIGN.md for the complete design system spec.
State management — Redux Toolkit
Store is at frontend/src/shared/state/store.ts. Add new slices following
the tempStateSlice.ts pattern (createSlice, named action creators, register
the reducer in the store).
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
function MyComponent() {
const items = useAppSelector(s => s.myFeature.items);
const dispatch = useAppDispatch();
// ...
}
For server data, use plain async thunks (createAsyncThunk) or fetch
directly inside useEffect — no react-query in the template (yet).
Backend — opt-in, never roll your own
The workspace starts without a backend. If your app needs server-side code (API endpoints, secrets, server-managed state):
bash backend_init.sh
This script COPIES the canonical backend scaffold (FastAPI + SubApp pattern
- swarm-debug pre-installed) into your workspace, allocates a free port,
and flips
BACKEND_PORTin both.envand.env.example. Then runbash restart.shso the runtime restarts and brings the backend up.
You MUST NOT roll your own backend. Do not:
- Hand-write a
backend/main.pyfrom scratch. - Use Flask, Django, or any framework other than the FastAPI scaffold the script gives you.
- Install your own venv or
pip installmanually. - Edit
backend/run.shor the SubApp framework.
Persist anything the user comes back to. Your process is disposable.
OpenSwarm freezes this app's process when its card closes and fully kills
it after ~15 minutes idle, on quit, and on crash. A module-level list or
dict is therefore data loss on a timer. The scaffold ships a durable
store; use it (or your own files under backend/data/):
from backend.apps.store.store import load_store, save_store
data = load_store() # {} on first run, never raises
data["items"] = [*data.get("items", []), new_item]
save_store(data) # atomic write; a kill mid-write keeps the old data
Holding state only in memory is a bug, not a style choice.
Adding a new endpoint is just adding a new SubApp:
# backend/apps/jobs/jobs.py
from contextlib import asynccontextmanager
from backend.config.Apps import SubApp
from swarm_debug import debug
@asynccontextmanager
async def jobs_lifespan():
debug("jobs SubApp lifespan starting")
yield
jobs = SubApp("jobs", jobs_lifespan)
@jobs.router.get("/list")
async def list_jobs():
return {"jobs": [...]}
Then register it in backend/main.py:
from backend.apps.jobs.jobs import jobs
main_app = MainApp([health, jobs])
Routes are auto-prefixed: jobs.router.get("/list") becomes
GET /api/jobs/list — accessible from the frontend at fetch('/api/jobs/list').
Frontend ↔ Backend wiring
Vite proxies /api/* calls from the frontend to the workspace's own
backend (on BACKEND_PORT). Always call /api/... from frontend code
— never hardcode localhost:<port>. The proxy is configured in
vite.config.ts and reads BACKEND_PORT from .env automatically.
// frontend/src/pages/jobs.tsx
import { useEffect, useState } from 'react';
export default function Jobs() {
const [jobs, setJobs] = useState([]);
useEffect(() => {
fetch('/api/jobs/list')
.then(r => r.json())
.then(data => setJobs(data.jobs));
}, []);
return <>{/* render jobs */}</>;
}
Keep ALL backend URL paths in frontend/src/shared/state/API_ENDPOINTS.ts
so refactors are one-file edits:
export const JOBS_LIST = '/api/jobs/list';
The host SDK — the user's models, workflows, and agents
The workspace ships pre-wired helpers for calling the OpenSwarm HOST from inside the app:
LLM completions through the user's own subscription (any provider, model selectable),
listing + firing the user's workflows and reading run results, and spawning real agent
cards on the canvas (optionally positioned). Read SDK.md at the workspace root before
building any feature that needs intelligence, automation, or agents — the helpers are:
- Frontend:
import { llm, listWorkflows, runWorkflow, spawnAgent } from '@/openswarmHost' - Backend:
from backend.apps.openswarm_host.openswarm_host import llm, run_workflow, spawn_agent
Auth is automatic (host-injected token); never hand-roll fetches against host routes. The SDK
works in preview and installed apps; for features that must survive PUBLISHING to the public
web, use window.OUTPUT_LLM / window.OUTPUT_COMPUTE below instead.
The workspace also vendors the full tool-ui widget set at src/toolui/ (data-table, chart,
code-block, code-diff, terminal, geo-map, media, social-post cards, and 15 more): reach for
import { DataTable } from '@toolui/components/data-table' before hand-building any table,
chart, or code view. The catalog + usage patterns are in SDK.md.
Publishable AI + compute — window.OUTPUT_LLM / window.OUTPUT_COMPUTE
The FastAPI backend above runs in preview but is not hosted when an app is
published to the web. For features that should keep working on a published
{slug}.openswarm.host link, use these two runtime calls instead of a backend.
They run on the published site (same-origin, no credentials). In the App Builder
preview they throw a clear "available once published" error, preview can't run
them without embedding a credential into your app, so test these by publishing.
AI (Claude): call window.OUTPUT_LLM with an Anthropic-style messages body.
The model is chosen for you (a cheap default), so don't pass one.
const res = await window.OUTPUT_LLM({
messages: [{ role: 'user', content: prompt }],
max_tokens: 512,
});
const data = await res.json();
const text = data.content[0].text;
Data-shaping compute: put pure Python (json/math/csv/datetime only — no
network, no files) in a top-level backend.py that reads input_data and assigns
result, then call window.OUTPUT_COMPUTE(input):
# backend.py
result = {"total": sum(input_data["nums"])}
const out = await window.OUTPUT_COMPUTE({ nums: [1, 2, 3] }); // -> { total: 6 }
Rule of thumb: if the app should be publishable, reach for OUTPUT_LLM /
OUTPUT_COMPUTE first; only use the FastAPI backend for preview-only tools or
things those two can't do (it won't be there once published).
Make the app agent-operable: the OPENSWARM_APP bridge
An agent can drive this app on the user's behalf (e.g. "graph y=x^2 on my
Desmos app"). It does NOT do that by clicking pixels or scraping the DOM (slow,
and an app's DOM is often a bare <canvas>). Instead it reads and acts through
window.OPENSWARM_APP, a bridge the template already ships for you
(src/agentBridge.ts, installed before your app mounts).
You do not wire up the bridge; you register() into it. Call
window.OPENSWARM_APP.register({ rules, controls, getState, invoke }) once your
app's core object exists (e.g. in a mount useEffect). This is REQUIRED for
every app: the runtime verifies it and the agent's first action fails loudly
with BRIDGE MISSING if you forget. Pass:
rules(string) - what the app is and its objective, in plain prose. This is what the agent reads to understand the app (e.g. "Flappy Bird. Keep the bird airborne through the pipe gaps; the game ends on a collision.").controls- an array of{ name, args?, description?, keys? }, OR a function returning that array when controls are dynamic.keysis an optional human-style hint (e.g."Space = flap"). Return only what's available now.getState()- a small JSON snapshot used to verify an action landed. Keep it compact; this is the latency budget.invoke(name, args)- perform the named action and return a result (or throw a string the agent will read).
Keep args shapes simple (strings, numbers, booleans, small objects). The agent
only ever calls actions that controls listed; it never edits your code. When
dynamic controls change, call window.OPENSWARM_APP.refresh() so the agent
knows to re-read them (it bumps the __rev the agent watches).
// `calc` here is the app's own API (Desmos example); use whatever yours exposes.
function registerAgentBridge(calc: any) {
window.OPENSWARM_APP!.register({
rules: 'A graphing calculator. Plot and remove expressions like y=x^2 or y=sin(x).',
controls() {
const controls = [
{ name: 'addExpr', args: { latex: 'string' }, description: 'Add a graph expression, e.g. y=x^2' },
{ name: 'clear', description: 'Remove all expressions' },
];
// Dynamic: only offer removeExpr when something is on the graph.
if (calc.getExpressions().length > 0) {
controls.push({ name: 'removeExpr', args: { id: 'string' }, description: 'Remove one expression by id' });
}
return controls;
},
getState() {
return { expressions: calc.getExpressions().map((e: any) => ({ id: e.id, latex: e.latex })) };
},
invoke(name: string, args: any = {}) {
if (name === 'addExpr') { const id = String(Date.now()); calc.setExpression({ id, latex: args.latex }); window.OPENSWARM_APP!.refresh(); return { id }; }
if (name === 'removeExpr') { calc.removeExpression({ id: args.id }); window.OPENSWARM_APP!.refresh(); return { ok: true }; }
if (name === 'clear') { calc.setBlank(); window.OPENSWARM_APP!.refresh(); return { ok: true }; }
throw `Unknown action: ${name}`;
},
});
}
Real-time games need a high-level action, not per-frame controls
An agent acts in discrete tool calls separated by network + model latency
(hundreds of ms to seconds). It physically CANNOT hit frame-timing, so exposing
only invoke('flap') makes a reflex game like Flappy Bird understandable but
unwinnable: by the time the agent decides to flap, the bird has already fallen.
For anything real-time, expose a high-level action the app executes on its
own tick loop, e.g. invoke('autopilot', { on: true }) that runs the optimal
input internally, or invoke('setDifficulty', ...). Let the agent set intent;
let the app handle the milliseconds. Non-real-time apps (tools, forms, a
Spotify-style player) don't need this: their actions are already at agent cadence.
Debugging — use swarm_debug, not print()
The backend has swarm_debug pre-installed. It's a colored frame-aware
logger that lands in the app card's Terminal view under [BACKEND].
from swarm_debug import debug
debug(value) # [endpoint_name] : value = ...
debug(a, b, c) # logs all three with labels
debug(err) # red + ❌ if variable is an exception
See the swarm-debug Logger built-in skill (Skills page) for the full
reference. print() works too but lacks the variable-name inference and
colorization.
Frontend console.log/warn/error calls land in the Terminal pane under
[FRONTEND] via the app card's webview-preload bridge. Same chronological
stream as [BACKEND] lines, so you can correlate cause and effect across
the two halves of your stack.
Read the terminal yourself: .openswarm/terminal.log at the workspace
root is a live tee of everything the Terminal pane shows — [BACKEND] /
[BACKEND:stderr] stdout+stderr, [RUNTIME] events, and [FRONTEND] /
[FRONTEND:warn] / [FRONTEND:error] console lines from the running app.
It resets on every app (re)start. When something misbehaves, don't guess —
tail -100 .openswarm/terminal.log (or grep it for error) and look at
what actually happened.
Adding npm packages
Just npm install <package> in the workspace's frontend/ directory.
Vite picks it up on the next HMR cycle.
cd frontend && npm install lodash @types/lodash
Then import normally — Vite resolves it.
Common deps already in the template:
@mui/material,@mui/icons-material— use these for any UI primitive@reduxjs/toolkit,react-reduxframer-motion— for animationsreact-router-dom@7vite-plugin-pages— file-based routing (already configured)
⚠️ Don't
- Don't rename
index.htmlorrun.sh— the runtime needs both at fixed paths. - Don't edit
vite.config.tsunless you know exactly why. The/apiproxy andvite-plugin-pagesconfig are load-bearing. - Don't write a standalone HTML file at the workspace root. There's no longer a
serve/index.htmlendpoint for new-mode workspaces — the webview points at Vite's dev server. - Don't hand-roll a backend. Use
bash backend_init.sh. - Don't bypass MUI with raw
<div>+ custom CSS. UseBox,Stack,sx. - Don't hardcode
localhost:<port>. Use relative/api/...paths so the Vite proxy handles routing.
Workflow tips
- Edits are auto-saved. As soon as you write a file via the Edit/Write tool, it's on disk. Vite HMR re-renders the preview within ~100ms.
bash restart.shrestarts the app runtime yourself — backend + vite, no user action needed. Use it afterbash backend_init.sh, after editing.env, or whenever backend code must reload (uvicorn runs WITHOUT --reload, so backend edits do NOT hot-apply). Never ask the user to restart for you, and never try to kill/rerun run.sh — the harness owns the process. Ifrestart.shis missing (older app),mkdir -p .openswarm && touch .openswarm/restart-requesteddoes the same thing.- After a restart, wait a few seconds and check
.openswarm/terminal.logto confirm the boot looked clean. meta.jsonat workspace root drives the app's name + description in the OpenSwarm sidebar and on the app's live card on the dashboard. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts.
Verify before declaring done — runtime errors are silent in the preview
The preview iframe is wrapped in an ErrorBoundary that surfaces React
runtime errors as a visible red error card AND mirrors the error into
the Terminal pane as a [FRONTEND] line tagged [openswarm:app-error].
After substantial edits — especially anything that touches imports,
hooks, or React state — always check the most recent [FRONTEND]
lines before saying "done" (tail -50 .openswarm/terminal.log).
If you see one, fix it before claiming the app is ready.
The three most common ways agent edits crash a React preview:
-
Lost import after MultiEdit / Edit. When you delete or rename a symbol's usage inside a file but don't update the corresponding
importline, the file references an undefined name at runtime. Symptom in Terminal:[FRONTEND] ReferenceError: HomeIcon is not definedor similar. Always re-read the imports block of any file you edited and confirm every imported name is still used and every used name is still imported. -
Invalid hook callfrom a duplicate React copy. The single most common way to break a workspace. Symptom in Terminal:[FRONTEND] Cannot read properties of null (reading 'useState')ANDInvalid hook call ... You might have more than one copy of React in the same app. Happens when annpm installbrought in a package that bundles its own React (instead of declaring it as a peer), so there are now TWO React instances in the workspace'snode_modulesand the two copies' hook dispatchers can't see each other.Fix (one shot): from the workspace root run
rm -rf frontend/node_modules && rm -rf frontend/.vite-cacheThen run
bash restart.sh. The workspace'sfrontend/node_moduleswill re-symlink to the shared warm cache on next vite boot — only ONE React copy exists across all OpenSwarm apps, so the duplicate is gone. The.vite-cachewipe is important because vite caches pre-bundled deps including the duplicate React.How to avoid causing it in the first place:
- Never
npm install react/react-dom— the template already has them via the symlinked warm cache. - Before
npm install <pkg>, check the package'speerDependencieson npmjs.com. Ifreactis listed underpeerDependencies(good) install it. Ifreactis independencies(bad), find a different package or pin a version known to use peer deps. - Common offenders: older
react-pdf,react-pdf-viewer, some@react-*UI kits, anything from a tutorial published before 2020.
- Never
-
Hooks called outside a component body or after a conditional return.
useState/useEffect/useMemomust run in the same order on every render. Adding anif (...) return nullBEFORE a hook, or calling a hook inside a callback, breaks the rule. The ErrorBoundary will print the offending component name in the surfaced stack — start there.
When in doubt, read the file you just edited end-to-end one more time. Re-reading is cheap; sending a half-broken preview back to the user is not.
Quick start checklist
When making a new app from scratch:
- WRITE
meta.jsonFIRST, before any other tool call. Put a 1-3 word product name (Title Case) innameand a one-sentence description indescription. The sidebar and the app's dashboard card show this name to the user; until you write it, both surfaces sit at "Untitled App". Don't wait until the end of the turn to fill it in, pick a name from the user's prompt and ship it now. Example: prompt "make doodle jump" →{"name": "Doodle Jumper", "description": "Endless platform-hopper inspired by Doodle Jump."}. You can revise it later if the app's purpose shifts. - REPLACE
frontend/src/pages/index.tsx. The starter ships with a "Brewing your app" placeholder — this is intentional, it's what the user sees between React mounting and your first edit landing, and it must disappear the moment your real home page is ready. Rewrite the whole file with your app's actual<Home>component. (There's also an even earlier inline splash inindex.htmlthat paints before any JS bundle loads — leave that alone; React's first commit clears it automatically.) - Sidebar / shell is OPT-IN.
Main.tsxno longer wraps pages in<AppShell>. If your app needs a sidebar (SaaS-style dashboards, multi-page apps), importAppShellfrom@/app/components/Layout/AppShelland wrap your page in it yourself:Most apps DON'T want a sidebar (games, canvases, single-screen tools, previewers, full-bleed visualizations) — just render your content directly and the page will be full-bleed. Don't add a shell out of habit.import AppShell from '@/app/components/Layout/AppShell'; export default function Home() { return <AppShell><YourContent /></AppShell>; } - Add additional pages under
frontend/src/pages/. - If using a sidebar, update its nav entries in
frontend/src/app/components/Layout/Sidebar.tsx. - Style with
useClaudeTokens()and MUI'ssx. The moment you typeuseClaudeTokens()in a file, add its import to that SAME file:import { useClaudeTokens } from '@/shared/styles/ThemeContext';— you are rewritingpages/index.tsxfrom scratch (step 2), so the import the starter had is GONE. A missing import here is the #1 way a rewritten page throws "useClaudeTokens is not defined" and the whole app fails to boot. - If you need a backend:
bash backend_init.sh, then add a SubApp underbackend/apps/<name>/.