This template is a PNPM/Turbo monorepo:
packages/api: the Hono (OpenAPI) app — the single source of truth for the APIpackages/cli: thetemplateCLI — dev server and typed API accessapps/frontend: TanStack Start React app that renders apps and serves the API
Everything is one Node server: apps/frontend serves the UI, mounts the Hono
app at /api (src/routes/api/$.ts) and its MCP server at /mcp
(src/routes/mcp.ts). It has to be Node because script execution uses the
run sandbox, which needs node:worker_threads.
pnpm dev runs pnpm cli dev, which starts it through portless with HTTPS
enabled at https://frontend.localhost (Swagger UI at /api). On non-main
branches the branch slug is prepended to the hostname, for example
https://my-branch.frontend.localhost.
The frontend is a thin renderer: it talks to the MCP server (/mcp), the same
interface agents use, to load apps and run their scripts.
The server is built with Nitro and has no host-specific
code. pnpm build produces a plain Node server:
pnpm build
pnpm start # node .output/server/index.mjs, reading the repo-root .env filesIt listens on PORT (default 3000) on all interfaces, so the same output runs
in a Docker container. On Vercel, set the project's root directory to
apps/frontend; Nitro detects Vercel at build time and emits a Node function
instead.
Configure DATABASE_URL, BETTER_AUTH_SECRET (for example
openssl rand -base64 32) and, to let users pick their gateway,
ARCADE_IDENTITY_CLIENT_ID / ARCADE_IDENTITY_CLIENT_SECRET (see
Auth) in the environment, and run pnpm db:migrate against the
database before deploying a schema change. App URLs use the request's origin; set
FRONTEND_URL when that differs from the public URL (for example behind a
proxy that terminates TLS).
Preview deployments each get their own Neon branch through the
Neon-managed Vercel integration.
On every preview deployment it branches the Neon project's default branch into
preview/<git-branch> and sets DATABASE_URL / DATABASE_URL_UNPOOLED for
that deployment. To set it up:
- In the Neon Console, open the project → Integrations → Vercel → Add, then Link Existing Neon Account and pick the Vercel project, database and role.
- Turn on Automatically delete obsolete Neon branches, so a preview branch
is removed after its Git branch is deleted. The integration also points
Production and Development's
DATABASE_URLat the default branch. - Set
BETTER_AUTH_SECRETfor the Preview environment in Vercel too. Arcade fetches each deployment's/api/arcade/client.jsonto sign users in, so Vercel's Deployment Protection has to let that path through (or be off for previews).
apps/frontend/vercel.json runs pnpm db:migrate before the build on preview
deployments only, so each branch gets the PR's migrations. Production is still
migrated by hand, and nothing migrates on startup. Don't rename a Git branch
with a preview database: the integration matches Neon branches by name.
/api/scripts is CRUD for saved scripts, and POST /api/scripts/:id/execute
runs one in the run QuickJS sandbox. Every tool on the user's MCP gateway
is exposed to scripts as tools.<functionName>(args). Names are made
into valid identifiers, so Gmail.ListEmails becomes tools.Gmail_ListEmails.
GET /api/tools lists them.
const emails = await tools.Gmail_ListEmails({ n_emails: input.count })
return emailsEach script declares an inputSchema and outputSchema (JSON Schema). The
input passed to execute is validated against inputSchema and is available to
the script as input. Script names are unique slugs; apps run scripts by name.
Tool results come back as structured content, or as parsed JSON text, or as plain
text. Script failures, including tool errors, return
{ ok: false, error: { code, message } }. Scripts run as the signed-in user:
the server calls the MCP server with that user's Arcade access token (see
Auth). The dev server and pnpm start read the repo-root .env.local
and .env.
Anyone with an Arcade account signs in with it, the way MCP clients sign in to Arcade's gateways, and their tool calls run as them, on an MCP gateway they pick. blurb.page signs users in with two of Arcade's authorization servers, linked to one user:
arcade, the OAuth server MCP clients use ($ARCADE_CLOUD_URL/oauth2). Every Arcade account can authorize clients on it, so this is the sign-in. Its access token identifies the user (itssubandemail) and calls their MCP gateway. Nothing has to be set up on Arcade's side.arcade-identity, Arcade's identity provider ($ARCADE_IDENTITY_URL), which the dashboard signs in with. It's linked right after sign-in (the same Arcade login, so it passes straight through) and reads the user's organizations, projects and gateways from Arcade's APIs, which don't take MCP tokens. It needs an OAuth2 client in Arcade's Ory project:ARCADE_IDENTITY_CLIENT_IDandARCADE_IDENTITY_CLIENT_SECRET, withclient_secret_post, JWT access tokens and these redirect URIs:<origin>/api/auth/callback/arcade-identityfor each deployed origin, andhttp://127.0.0.1:5173/api/auth/callback/arcade-identityandhttp://127.0.0.1:5173/api/arcade/identity-callbackfor the dev server. Without it, everyone uses the default gateway.
Users pick a gateway from the navbar: any gateway in their projects that signs
users in with Arcade (gateways that take an API key can't run tools as them).
Their choice is kept in user_gateways; until they pick one, tool calls go to
MCP_URL, or else Arcade's global gateway ($ARCADE_API_URL/mcp/arcade),
which runs tools in their default project.
Arcade's URLs default to production. ARCADE_CLOUD_URL, ARCADE_API_URL and
ARCADE_IDENTITY_URL point blurb.page at another stack, for example staging
(https://cloud.bosslevel.dev, https://api.bosslevel.dev and
https://auth.bosslevel.dev); set all three together, since accounts don't
carry over between stacks.
Better Auth (packages/api/src/auth, mounted at
/api/auth) keeps users, sessions and the encrypted Arcade tokens in Postgres,
and refreshes the tokens when they expire. Everything under /api, except auth
itself, needs a signed-in user, and so does /mcp:
- The frontend calls
/mcpwith its session cookie. - Other MCP clients (agents) use OAuth: blurb.page is also an authorization
server for its own
/mcp(Better Auth's MCP plugin), so a client registers itself, sends the user to/loginand/consent, and then acts as them.
Arcade's MCP OAuth server identifies blurb.page by a client ID per origin. On a
public origin it's the URL of a client metadata document blurb.page serves
(/api/arcade/client.json), which Arcade fetches. Arcade can't fetch a local
one, so the dev server registers a client instead and keeps it in the
arcade_clients table. Arcade only redirects over http to bare loopback hosts,
so signing in on the portless https://*.localhost URL goes back through
http://127.0.0.1:$PORT, which forwards to the portless URL.
An app is a json-render spec — a flat JSON tree of
shadcn/ui components with state, visibility conditions and event bindings —
served by the frontend at /apps/<name>. Specs declare named queries (scripts
that load data, refetched when their input changes) and mutations (scripts run
by buttons and forms, which refetch the queries they invalidate). Results are
rendered in tables, charts, metrics and forms; data is only read inside a
Query element, so loading and error states are always handled.
Apps are created and changed over MCP. An agent needs only the MCP URL
(http://127.0.0.1:5173/mcp in dev, or /mcp on the deployed origin) and
signs in when it connects: the server's
instructions explain the workflow, and get_app_guide documents the spec
format, every component and action, and common patterns with a full example.
create_app / update_app validate specs (components, props, actions, script
names, queries and mutations, and the element tree) and return readable errors.
The catalog lives in packages/api/src/ui and is shared with the frontend as
@template/api/ui.
Diagrams, illustrations and icons are SVGs stored in their own table and
shown in any app by name with the Svg component
({ "type": "Svg", "props": { "name": "runtime-diagram" } }), so one SVG can
be reused across apps and updated in place. /api/svgs is CRUD for them, and
the MCP server has list_svgs, get_svg, create_svg, update_svg and
delete_svg. App validation rejects an Svg whose name doesn't exist.
The frontend draws each SVG as an <img>, so its scripts, event handlers and
external resources never run or load, and its ids and styles can't clash with
the page. The theme's colors are passed in as CSS variables, so an SVG that
uses currentColor, var(--foreground) or var(--chart-1) matches the app.
packages/cli is exposed at the repo root as pnpm cli:
pnpm cli dev # the app on portless, opens the browser when ready
pnpm cli dev --no-open # …without opening the browser
pnpm cli api stats # GET /api/stats
pnpm cli api stats --json # raw JSON
pnpm cli api openapi # GET /api/openapi.json
pnpm cli api docs --open # Swagger UI
pnpm cli api tools # MCP tools available to scripts
pnpm cli api scripts list
pnpm cli api scripts create --name inbox --file inbox.js
pnpm cli api scripts get|update|delete <id>
pnpm cli api scripts execute <id> --input '{"count": 5}'
pnpm cli api svgs list
pnpm cli api svgs create --name runtime-diagram --description "…" --file diagram.svg
pnpm cli api svgs get|update|delete <id>pnpm dev is pnpm cli dev: it starts the portless HTTPS proxy, registers the
branch's alias for the app, runs its dev script behind it, waits for the URL to
answer and opens it in a browser, then removes the alias on exit. It also points
the blurb-page entry in the repo's .mcp.json at the MCP endpoint
(http://127.0.0.1:5173/mcp), so MCP clients opened in the checkout, like
Claude Code, use it. Other entries are kept, and it uses the loopback URL
because Node-based clients don't trust the portless CA. Running the dev script
directly (pnpm --filter @template/frontend dev) skips portless and serves
plain HTTP on port 5173.
The api commands call the same Hono app through the typed RPC client, so they
stay in sync with packages/api. They can't sign in yet, so everything but
openapi and docs answers 401 now that the API needs a signed-in user. They target --base-url, or $TEMPLATE_API_BASE_URL, or the
portless app URL. Node does not read the system trust
store, so the CLI adds the portless CA (~/.portless/ca.pem) to its own trust
list to reach https://*.localhost.
The package is independently publishable: pnpm --filter @template/cli build
bundles it with tsdown, inlining workspace dependencies so commander and
hono are the only runtime dependencies of the published template binary.
Drizzle + Postgres, in packages/api/src/db. Everything connects through a
plain DATABASE_URL (postgres.js); routes use the shared client from getDb().
- Without
DATABASE_URL, everything falls back to a local PGlite database. Runpnpm db:localto serve it atpostgres://postgres:postgres@127.0.0.1:5433/postgres. pnpm db:generate/pnpm db:migrate/pnpm db:studioreadDATABASE_URLfrom the environment, then from the repo-root.env.localor.env. They useDATABASE_URL_UNPOOLED(Neon's direct connection) when it's set.- The server reads it from the same files. Migrations never run on startup.
pnpm dev
pnpm cli
pnpm build
pnpm typecheck
pnpm lint
pnpm fmt
pnpm startDocs are Notion-like pages at /docs/<name> that people edit in the browser
(a Tiptap editor: type / for blocks) and agents write over MCP
(get_doc_guide, create_doc, update_doc, …). A doc is stored as Markdown;
a fenced code block with the language ui holds a json-render spec (the same
format as an app's) and renders as a live component:
Open issues by label:
```ui
{ "root": "chart", "elements": { … }, "queries": { … } }
```A ui block can also mount a saved app by name, { "app": "issue-tracker" },
which shows the app as it is now (type / and pick it under Components). Saving
validates every ui block like an app spec, including that the scripts it runs
exist, and that mounted apps exist.
Apps fill whatever they're shown in: a ScrollArea without a height and Slides
take the space their parent has left, and the containers around them stretch to
pass it down ([data-fill] in index.css). Nothing measures the window. On an
app's page that's the area under the navbar; in a doc, a block holding one is
600px tall unless it sets "height".