From fusebase-flow
Guides adding a backend layer (REST API, WebSockets, cron jobs) to Fusebase Apps, including architecture, multi-user state management, and sidecar containers.
How this skill is triggered — by the user, by Claude, or both
Slash command
/fusebase-flow:app-backendThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Apps are always multi-user.** The backend serves requests from many users concurrently. Every design decision must account for this.
Apps are always multi-user. The backend serves requests from many users concurrently. Every design decision must account for this.
Per-user vs. shared state:
| Storage | Scope | Use for |
|---|---|---|
| httpOnly cookies | Per-user (per browser) | OAuth tokens, user preferences, session data |
| Dashboard rows (keyed by user ID) | Per-user (persistent) | User settings, saved state |
| Fusebase secrets / env vars | Shared (all users) | API keys, service-account credentials |
| In-memory variables | Shared (all users, lost on restart) | Short-lived caches only |
Common mistakes:
ft:*, JWT pieces, rotating token IDs) as persistent row partition keysFor any persisted per-user data, derive the partition key from stable identity only:
userId/orgUserId from a stable identity endpoint (getMe-style call)user:<userId>) in one helperDo not derive partition keys from runtime app/session tokens. Tokens rotate, so token-derived keys cause "missing records after relogin" while data still exists under old keys.
A backend is optional. Most apps work fine with the Dashboard SDK alone (client-side calls to the dashboard service). Only add backend/ when the app genuinely needs:
Sidecars are pre-built Docker images that run alongside the app backend in the same network namespace, sharing localhost. They are useful for auxiliary services like headless browsers (Chromium, Lightpanda), caches (Redis), or other tools the backend needs to communicate with over HTTP.
# Add a sidecar to an app backend
fusebase sidecar add --app <appId> --name chromium --image browserless/chrome:latest --port 9222
The sidecar is accessible from the backend at http://localhost:<port>. Max 3 sidecars per app.
Important: Port 3000 is reserved for the backend app. If a sidecar image defaults to port 3000, override it via env vars (e.g. --env PORT=9222 for browserless).
Since sidecars share the same network namespace, use localhost to reach them:
// In backend code — call sidecar on localhost
const response = await fetch("http://localhost:9222/json");
const data = await response.json();
Each sidecar can have its own env vars (not shared with the backend):
fusebase sidecar add --app <appId> --name redis --image redis:7 --port 6379 --env REDIS_MAXMEMORY=256mb
Use fusebase remote-logs runtime <appId> to see logs from all containers. Filter to a specific sidecar:
fusebase remote-logs runtime <appId> --container chromium
For full sidecar documentation, see the app-sidecar skill.
Azure Container Apps caps the sum of CPU and memory across all containers in one revision (backend + every sidecar) at:
Max 2.0 CPU / 4.0 Gi RAM
Configurations that exceed this cap are rejected at deploy time, even if every individual container is within an allowed tier.
These are the only allowed tiers (matches the DeploySidecarDefinition contract in the CLI):
| Tier | CPU | Memory |
|---|---|---|
| small | 0.5 | 1Gi |
| medium | 1 | 2Gi |
| large | 2 | 4Gi |
The backend container itself always runs at small (0.5 CPU / 1 Gi). This is fixed today and is not user-configurable — only sidecars accept a --tier option. When you compute the total budget, always start from 0.5 CPU / 1 Gi for the backend.
Fits — backend (small) + chromium sidecar (medium) + redis sidecar (small):
backend small 0.5 CPU / 1 Gi
chromium medium 1.0 CPU / 2 Gi
redis small 0.5 CPU / 1 Gi
-------------------------------------
TOTAL 2.0 CPU / 4 Gi ✓ at the limit
Exceeds — backend (small) + chromium (medium) + lightpanda (medium):
backend small 0.5 CPU / 1 Gi
chromium medium 1.0 CPU / 2 Gi
lightpanda medium 1.0 CPU / 2 Gi
-------------------------------------
TOTAL 2.5 CPU / 5 Gi ✗ Azure rejects, deploy will fail
If a revision would exceed the cap, downgrade one of the sidecars to a smaller tier (e.g. lightpanda is intended as a lightweight browser and runs fine at small).
Cron jobs do NOT count toward this limit. Each cron job runs as its own separate container (see Scheduled Tasks (Cron Jobs) below) with an independent resource budget — the 2.0 CPU / 4.0 Gi cap applies only to the live backend revision (backend + sidecars), not to scheduled job containers.
Do NOT add a backend just for CRUD on dashboard data — use the Dashboard SDK directly from the SPA.
apps/my-app/
package.json ← SPA deps (unchanged)
vite.config.ts
src/ ← SPA code
backend/ ← backend (only if needed)
package.json ← backend-only deps
tsconfig.json
src/
index.ts ← entrypoint
routes/ ← route handlers
ws/ ← WebSocket handlers (if needed)
Key points:
backend/ has its own package.json — keeps backend deps (Hono, ws libs) out of the SPA bundleshared/ directorybackend/ folder can access it. Each app must have its own backend if it needs one; one app cannot call another app's backend.package.json remains unchanged — no backend deps leak inUse Hono for the backend. It is TypeScript-first, lightweight, and has built‑in WebSocket support. It runs on Node.js and Bun.
{
"name": "my-app-server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup src/index.ts --format esm --out-dir dist",
"start": "node dist/index.js",
"lint": "eslint . --max-warnings 0"
},
"dependencies": {
"hono": "^4.x",
"@hono/node-server": "^1.x",
"@hono/node-ws": "^1.x"
},
"devDependencies": {
"tsx": "^4.x",
"tsup": "^8.x",
"typescript": "^5.x"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src"]
}
import { Hono } from "hono";
import { serve } from "@hono/node-server";
const app = new Hono().basePath("/api");
app.get("/health", (c) => c.json({ ok: true }));
// Add routes:
// import { itemsRoutes } from './routes/items'
// app.route('/items', itemsRoutes)
const port = Number(process.env.BACKEND_PORT) || 3000;
serve({ fetch: app.fetch, port }, () => {
console.log(`Server running on port ${port}`);
});
export default app;
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { createNodeWebSocket } from "@hono/node-ws";
const app = new Hono().basePath("/api");
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
app.get(
"/ws",
upgradeWebSocket((c) => ({
onMessage(event, ws) {
// handle incoming message
ws.send(JSON.stringify({ echo: event.data }));
},
onClose() {
console.log("Client disconnected");
},
})),
);
const port = Number(process.env.BACKEND_PORT) || 3000;
const server = serve({ fetch: app.fetch, port });
injectWebSocket(server);
openapi.json Is RequiredWhen backend scaffold is present, the app root contains openapi.json. This file is not decorative and it is not generated from Hono routes automatically.
openapi.json is the app's published API contract. Fusebase reads it during fusebase deploy and uses it for:
list/search/describe/call flowsThis means backend implementation and API contract are two separate artifacts:
backend/src/routes/*.tsapps/<app>/openapi.jsonIf you add or change a backend route and do not update openapi.json, the route may still work over HTTP, but the platform will not know about it for registry/discovery/call purposes.
openapi.json reviewAny change to the backend route surface must be treated as incomplete until you have checked whether openapi.json needs to change.
Examples:
app.get("/tasks") route → add/update the corresponding path + operation in openapi.jsoncomponents.schemasopenapi.jsonDo not treat spec updates as optional follow-up documentation. They are part of the same backend change.
Fusebase-specific metadata is expressed via OpenAPI x-* extensions.
Allowed values:
x-fusebase-visibility: org or privatex-fusebase-execution-mode: sync or asyncPractical guidance:
x-fusebase-visibility: org for business operations that other apps/agents in the org may discover and callx-fusebase-visibility: private for internal-only routes such as health, debug/admin endpoints, and routes that should not appear in org-wide discoveryx-fusebase-execution-mode: sync for normal request/response APIsx-fusebase-execution-mode: async for long-running or async-style operationsBefore deploy, run:
fusebase api validate
During deploy, read the registry line carefully:
Published OpenAPI registry: N operation(s) from openapi.json
If you added new backend operations and N did not increase as expected, assume openapi.json is stale until proven otherwise.
/api is Reserved for the BackendWhen an app has a backend, the /api path prefix is reserved for the backend. The SPA must not define client-side routes under /api.
/api/* (REST endpoints, WebSocket upgrades)/, /items/:id, /settings, etc.)Inbound integrations from external services (for example, Monday.com, GitHub, Stripe) can use regular HTTP webhooks and, when needed, WebSocket upgrades (for example, Twilio media streams). These requests typically do not carry a fbsfeaturetoken cookie or x-app-feature-token header.
The platform proxy skips app-token auth for any path under /api/webhooks/, including both HTTP routes and WebSocket upgrade routes.
backend.minReplicas: 1)The backend scales to zero when idle, so a webhook can hit a cold container that starts slower than the provider's timeout and is silently dropped.
Rule: if the app receives webhooks (or any always-on inbound integration), set
backend.minReplicas: 1 in fusebase.json to keep one replica warm — see
fusebase.json Backend Config. Cap 3; each warm replica
runs 24/7, so prefer 1. Apps without webhooks omit it (or 0) to keep scale-to-zero.
When the app needs to receive webhooks from a third-party service (Asana, GitHub, Stripe, Monday, etc.), register the subscription with that provider yourself as part of the deploy — do not hand the user a curl / admin-UI step to run.
create webhook HTTP API directly with the public webhook URL of the deployed app.fusebase secret create and redeploy if the handler needs it.curl your own /api/admin/register-... endpoint — invoke it yourself.Try to come up with random and hard to guess path for webhooks, for example:
/api/webhooks/stripe - bad
/api/webhooks/stripe-gja8dj21349asgj12n4asodgasdg - good
Public webhook URL: https://{FEATURE_DOMAIN}/api/webhooks/...
For external WebSocket integrations, use a path under /api/webhooks/... as well (example: /api/webhooks/twilio-stream-<random-secret>).
FBS_FEATURE_TOKEN)Use process.env.FBS_FEATURE_TOKEN when the backend must call Gate without the end-user's Fusebase session:
registerFusebaseOrgMember or addOrgUser on behalf of a new visitorFBS_FEATURE_TOKEN is the platform-issued service token minted at deploy (with permissions such as org.members.write). See fusebase-gate/references/fusebase-auth.md (§ Public Registration With Org Membership, § Two Names For Feature Token).
Security rules:
FBS_FEATURE_TOKEN to the browser or SPA bundles.getMyOrgAccess, role-gated UI) — those need the visitor/user app token plus EverHelper-Session-ID when applicable.POST /api/account/register, incoming header || cookie('fbsfeaturetoken') is only for app-proxy auth; the Gate SDK client inside the handler must use FBS_FEATURE_TOKEN, not the forwarded visitor cookie.fusebase dev, backend-only provisioning may use process.env.FBS_FEATURE_TOKEN ?? process.env.GATE_MCP_TOKEN.Not a service-token route: ordinary user-context Gate reads/writes where the acting user is the logged-in visitor — use the request app token and session header, not FBS_FEATURE_TOKEN.
fusebase dev start automatically proxies /api HTTP requests and WebSocket upgrades to the backend dev server.
The BACKEND_PORT env var is assigned by fusebase dev start and injected into both the SPA and backend processes, allowing multiple apps to run backends concurrently without port conflicts.
When an app has a backend, add the backend block to its entry in fusebase.json:
{
"apps": [
{
"id": "app-id",
"path": "apps/my-app",
"dev": { "command": "npm run dev" },
"build": { "command": "npm run build", "outputDir": "dist" },
"backend": {
"dev": { "command": "npm run dev" },
"build": { "command": "npm run build" },
"start": { "command": "npm run start" },
"minReplicas": 1
}
}
]
}
Backend commands (dev, build, start) run from the backend/ subdirectory of the app path.
backend.minReplicas (keep the backend warm)Optional integer. Minimum number of backend replicas to keep running. 0 by default. 0 means scale to zero (cold starts).
NEVER hardcode localhost in callback/redirect URLs (e.g. OAuth redirect URIs, webhook URLs, links sent to external services). An app's backend runs behind a proxy — localhost only works during local dev and breaks in production.
Instead, derive the public base URL from the incoming request headers:
/** Derive the public base URL from the incoming request. */
function getBaseUrl(req: Request): string {
const url = new URL(req.url);
const forwardedProto = req.headers.get("x-forwarded-proto");
const forwardedHost =
req.headers.get("x-forwarded-host") ?? req.headers.get("host");
if (forwardedHost) {
const proto = forwardedProto ?? url.protocol.replace(":", "");
return `${proto}://${forwardedHost}`;
}
return url.origin;
}
Usage example (OAuth redirect URI):
app.get("/auth/url", (c) => {
const baseUrl = getBaseUrl(c.req.raw);
const redirectUri = `${baseUrl}/api/auth/callback`;
// Use redirectUri when building the OAuth authorization URL
});
This works in both environments:
http://localhost:<port> (via Fusebase dev server proxy forwarding host)https://<subdomain>.{FUSEBASE_APP_HOST} (platform sets x-forwarded-host / x-forwarded-proto)Use standard fetch with relative URLs. Same-origin requests automatically include the fbsfeaturetoken cookie, so the backend can authenticate on behalf of the user without depending on a custom header surviving the deployed platform proxy:
// In SPA code
const res = await fetch("/api/items");
const data = await res.json();
If you still send x-app-feature-token from the SPA, treat it as a best-effort dev/proxy optimization only. Backend handlers must always support both sources:
import { getCookie } from "hono/cookie";
const appToken =
c.req.header("x-app-feature-token") || getCookie(c, "fbsfeaturetoken");
if (!appToken) {
return c.json({ error: "Missing app token" }, 401);
}
Platform activation at /_auth/magiclink/{key} sets HttpOnly cookies and redirects; that is not enough for knowing which user opened the link. Implement in your app backend:
POST /api/account/from-magic-link — same-origin call from the SPA after the activation redirect; the HttpOnly cookies ride along automatically (JS cannot read them).GET /:orgId/me/access with x-app-feature-token: <fbsfeaturetoken cookie> + EverHelper-Session-ID: <eversessionid cookie>.userId); GET /api/account/me reads only that cookie.See fusebase-gate/references/app-magic-links.md (§ App Session Exchange) and fusebase-auth.md (§ Magic-Link → App Session Exchange). Env: FUSEBASE_ORG_ID, APP_SESSION_SECRET.
When backend routes call Gate on behalf of the current user, keep auth in app-token context only.
401/403 and require re-auth/permission sync./api/account/from-magic-link)If the app uses Fusebase Gate magic links (requestAppMagicLink / activateAppMagicLink — see the fusebase-gate/references/app-magic-links.md and fusebase-gate/references/fusebase-auth.md skill references), the backend exchange after activation is mandatory for every app, but the cookie policy splits cleanly into Test and Production.
Mandatory exchange (same in Test and Production):
/_auth/magiclink/{key}; the platform activates the link, sets HttpOnly eversessionid / fbsfeaturetoken / fbsdashboardtoken cookies, and redirects to redirectPath.POST /api/account/from-magic-link) as a plain same-origin request — the HttpOnly cookies are attached automatically; JS cannot (and must not) read or forward the tokens itself.x-app-feature-token: <fbsfeaturetoken> and EverHelper-Session-ID: <eversessionid>, then calls getMyOrgAccess to resolve userId. The feature token alone does not identify the user on getMyOrgAccess.{ userId }).That is the only mandatory part of the exchange. The EverHelper-Session-ID header pattern is the rule that protects against a stale browser session masquerading as the magic-link recipient.
Pick the recipe based on what the app actually needs. Do not auto-upgrade a smoke test to the production recipe.
Test mode — smoke test of the magic-link flow, no Memberspace, no role-gated UI:
fbsfeaturetoken / eversessionid cookies set by activation; re-running the exchange on the next protected page-load is acceptable for a smoke test.APP_SESSION_SECRET (or any other HMAC secret) via fusebase secret create.fusebase secret create calls for the magic-link flow itself.Production mode — Memberspace, role-gated UI, anything that must remember which user opened the link across navigations:
userId). Verify it on every protected request; do not re-infer identity from fbsfeaturetoken after the initial redirect.fusebase secret create --app <%= it.flags?.includes("declarative-manifest") ? "<appPath>" : "<appId>" %> --secret "APP_SESSION_SECRET:HMAC signing key for app-owned session cookie". Read it from process.env.APP_SESSION_SECRET at runtime.httpOnly, secure, sameSite=Lax, path=/. Rotate by changing the secret and invalidating active cookies; do not rely on Fusebase platform cookies for revocation.fusebase secret create call (the HMAC secret).fusebase secret createfusebase secret create is reserved for credentials that must not appear in the repo or platform-readable config. The following are not secrets and must never be registered as such:
FUSEBASE_ORG_ID — lives in fusebase.json (orgId) and is readable by anyone who can clone the app. Read it from fusebase.json (or platform-injected env where available) at app start.productId and the app subdomain — same reasoning; both live in fusebase.json / fusebase app list output.FBS_*) — already public configuration.If fusebase secret list --feature <appId> shows any of the above, remove them (fusebase secret delete) and read the value from fusebase.json instead.
Before claiming the magic-link flow is done, verify:
/_auth/magiclink/{key} redirect, the SPA calls /api/account/from-magic-link (or another app-owned route) as a same-origin request; no code reads or forwards tokens via JS.x-app-feature-token (from the fbsfeaturetoken cookie) and EverHelper-Session-ID (from the eversessionid cookie) before calling getMyOrgAccess. The feature token alone is not enough.APP_SESSION_SECRET, no HMAC-signed app cookie, no fusebase secret create call for the magic-link flow.fusebase secret create … APP_SESSION_SECRET:…, HMAC-signed app-owned session cookie, verified on every protected request.fusebase secret list --feature <appId> does not include FUSEBASE_ORG_ID, productId, app subdomain, or any other value that already lives in fusebase.json.getMyOrgAccess with only the feature token to gate protected content — it always forwards the session header.For WebSockets:
const ws = new WebSocket(`wss://${window.location.host}/api/ws`);
ws.onmessage = (event) => {
const msg: WsMessage = JSON.parse(event.data);
// handle message
};
The deployed backend is stateless. The filesystem is ephemeral and in-memory state is lost on restart/redeployment. Do not rely on either for persistent data.
NEVER:
.env, JSON, or any local file to persist runtime statefs.writeFileSync / fs.writeFile for data that must survive restartsInstead, use:
Example — OAuth token flow (httpOnly cookie): When an OAuth callback returns a refresh token, store it in an httpOnly cookie:
import { setCookie, getCookie } from "hono/cookie";
// In the OAuth callback handler:
setCookie(c, "oauth_refresh_token", tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: "Lax",
path: "/",
maxAge: 60 * 60 * 24 * 365, // 1 year
});
// In API handlers — read token from cookie, fall back to env var:
const refreshToken =
getCookie(c, "oauth_refresh_token") ?? process.env.REFRESH_TOKEN ?? "";
// ❌ Wrong: writing to filesystem
writeFileSync(".env", `REFRESH_TOKEN=${tokens.refresh_token}`);
// ❌ Wrong: relying solely on in-memory state
config.refreshToken = tokens.refresh_token; // lost on restart
cd apps/my-app/backend && npm install — install backend depsfusebase secret create --app <%= it.flags?.includes("declarative-manifest") ? "<appPath>" : "<appId>" %> --secret "KEY:description" — register secrets (if needed), set values via the printed URLfusebase dev start — starts both SPA and backend; secrets are injected automatically as env varsNo .env files or dotenv needed — fusebase dev start injects secrets into the backend process.
Before adding a backend:
backend/ with its own package.json and tsconfig.json.basePath('/api')fusebase dev start proxies /api to backend (automatic when backend block exists in fusebase.json)fusebase.json with backend block/apiopenapi.json as the canonical app API contractopenapi.json in sync with every new/changed backend routex-fusebase-visibility: org | privatex-fusebase-execution-mode: sync | asyncfusebase api validatePublished OpenAPI registry: N operation(s) from openapi.json.env files or dotenv — secrets injected by fusebase dev start⚠️ Cron jobs do NOT run with
fusebase dev start. Local dev mode does not schedule or execute jobs. Runfusebase deployto deploy the app — jobs will be scheduled and executed in the cloud after deployment.
Cron jobs run on a schedule using the same Docker image as the app backend. Each job is an independent process that executes a command on a cron schedule and exits.
⚠️ Cron jobs cannot reach backend sidecars on
localhost. Cron jobs are deployed as independent Azure Container Apps Jobs, not as part of the backend container app, so they do not share the backend's network namespace. A cron container that callshttp://localhost:9222(or any other backend sidecar port) will fail withfetch failed.<% if (it.flags?.includes("job-sidecars")) { %> If a cron needs an auxiliary container, declare a per-job sidecar — see Job Sidecars below.<% } else { %> If a cron needs an auxiliary container, call back to the main backend over its public URL (/api/...), where the backend can use its own sidecars.<% } %>
fusebase job create \
--app <%= it.flags?.includes("declarative-manifest") ? "<appPath>" : "<appId>" %> \
--name <job-name> \
--cron "0 * * * *" \
--command "npm run cron:my-job"
Job name must be unique within the app. Use kebab-case (e.g. send-reports, cleanup-old-data).
Cron expression uses standard 5-field syntax: minute hour day-of-month month day-of-week. All times are UTC.
{
"scripts": {
// existing scripts...
"cron:send-reports": "node dist/jobs/send-reports.js"
}
}
Build config must include job entry points so they are compiled to dist/.
// backend/src/jobs/send-reports.ts
async function main() {
console.log("[send-reports] Starting at", new Date().toISOString());
// Use the same SDK / secrets as the main backend
// Env vars injected at runtime (same as backend)
// ... business logic ...
console.log("[send-reports] Done");
process.exit(0);
}
main().catch((err) => {
console.error("[send-reports] Failed:", err);
process.exit(1);
});
Key points:
process.exit(0) on success — the container job finishes only when the process exitsprocess.exit(1) on failure — signals the job faileddist/ bundle as the backend — they can import from ../ freelyfusebase job delete --app <%= it.flags?.includes("declarative-manifest") ? "<appPath>" : "<appId>" %> --name <job-name>
This removes the job from backend.jobs in fusebase.json. On the next fusebase deploy the job will be automatically deleted from cloud infrastructure.
<% if (it.flags?.includes("job-sidecars")) { %>
Each cron job can declare its own sidecar containers under apps[].backend.jobs[].sidecars. Sidecars share the job replica's network namespace, not the backend's, so the main job container talks to them on localhost:<port> exactly the way the backend talks to its own sidecars.
Add a sidecar to a job:
fusebase sidecar add \
--app <appId> \
--job <jobName> \
--name <name> \
--image <dockerImage> \
[--port <port>] \
[--tier small|medium|large] \
[--env KEY=VALUE ...]
Example — a screenshot cron with its own headless browser:
fusebase sidecar add \
--app my-scraper \
--job screenshots \
--name chromium \
--image browserless/chrome:latest \
--port 9222 \
--tier medium \
--env PORT=9222
Use fusebase sidecar remove --job <jobName> and fusebase sidecar list --job <jobName> to manage them. When --job is omitted, the commands target backend sidecars exactly as before.
Key constraints:
chromium) may exist on the backend and on a job; they are separate containers in separate replicas.replicaTimeout=3600s is the hard ceiling.fusebase dev start still does not run cron jobs nor any sidecars — job sidecars take effect only after fusebase deploy.For full details (config format, networking, debugging), see the app-sidecar skill.
<% } %>### Cron Jobs Checklist
backend/ folder and a backend block in fusebase.json (backend is scaffolded first)cron:<job-name> npm script to backend/package.jsonfusebase job create to register the jobfusebase deploy to deploy the app — cron jobs only run after deployment, not during fusebase dev start
<% if (it.flags?.includes("job-sidecars")) { %>- [ ] If the cron needs an auxiliary container (browser, cache, etc.), attached sidecars to the job via fusebase sidecar add --job <jobName> (not the backend)
<% } %>npx claudepluginhub fusebase-dev/fusebase-flow --plugin fusebase-flowCreates, edits, and verifies skills using a test-driven development approach with pressure scenarios and subagents.