Create Railcode App
Update First
Before answering a Railcode question or running a railcode command, update the Railcode
skills and CLI, then verify npm's published version:
npx skills add Railcode-HQ/railcode-skills
npm install -g railcode@latest
railcode --version
npm view railcode version
If the skill changes, re-read this file from the top. If npm is unreachable, say so and do not
claim the guidance is current. This version was written against CLI 0.3.0 and
@railcode/sdk 0.4.0 (both confirmed published).
0.3.0 is the floor this document assumes. It is where personal connectors were folded into
railcode connector (see Breaking Changes). On an older binary the
connector link / add-mcp / share subcommands do not exist, and railcode personal-connectors still does — if you see that command work, you are on a CLI this guidance
does not describe. Reinstall before following anything below.
Two older floors still worth recognising: secrets and migrate exist but are never
dispatched on 0.2.2 (they die with an unknown-command error however correct the
invocation — 0.2.3 wires them up).
Since 0.1.28 the CLI self-updates within its major version — but only on an interactive
terminal, and agent-driven sessions are non-interactive, so keep running the explicit
npm install -g railcode@latest above rather than assuming you're on the latest.
Report Railcode feedback
After meaningful Railcode work, use the hidden railcode agent-feedback command
when you have concrete product feedback: a bug, friction, a suggestion, or something
that worked especially well. Submit once per distinct finding. Describe the task,
expected and actual behavior, and any workaround; keep the report concise and omit
credentials, private customer data, source files, and conversation transcripts.
railcode agent-feedback --message "Deploy failed with an unclear error; expected the missing field to be named." \
--category friction --command "deploy" --context "Static app deploy; corrected the manifest to unblock."
Use --file <path> for a prepared report or pipe text on stdin instead of
--message. The message limit is 10,000 characters; --context is optional and
limited to 5,000. Categories: bug, friction, suggestion, praise, other
(default). Optionally self-report your assistant/tool name with --agent (e.g.
Codex or Claude Code) and your model with --model, if known. These are your
own claims and may be inaccurate; omit unknown values rather than guessing. These
fields and --command are limited to 200 characters each.
The CLI attaches its version, OS platform, CPU architecture, and Node version. The
backend links the report to the logged-in user and organization in PostHog. It
requires an existing login; it works outside an app directory. Feedback accepted.
means best-effort acceptance, not confirmed storage. If reporting fails, continue
the original task without repeated retries or logging in solely to send feedback.
This command is intentionally absent from CLI help and requires CLI 0.3.3 or
later. This feedback guidance was verified against the CLI 0.3.3 source. Older
CLIs may return Unknown command; treat that as unavailable.
Breaking Changes
A running log of platform changes that break apps or agents already in the wild, newest first. When a user reports that something which used to work now fails, read this before debugging — the platform's own error messages are written to be the fix instructions, and the entry below tells you how to act on them. Add an entry here whenever a change ships that an existing app cannot survive untouched.
Personal connectors are gone — CLI 0.3.0, @railcode/sdk 0.4.0
personalConnections and the personal_connectors: manifest key were removed. There is
now one kind of connector: an org resource with an owner and an access mode, declared
under connectors: — whether it holds a shared team credential or one person's own account.
An owned row is restricted (owner, admins, and whoever it is shared with) until shared.
How it shows up
- A v1 app or v2 worker calling
personalConnections.*gets HTTP 410, not a 404. The body names the connector that replaced the toolkit, lists the connectors the caller owns, and carries a copy-pasteable replacement call. Read the body — it is the answer. railcode deployon a manifest still carryingpersonal_connectors:fails with "personal_connectorswas removed — link the account as a connector … and name it underconnectors:". The app keeps serving; only the deploy is refused.- Managed agents needed no work. The platform rewrote their manifests, prompts, and grant rows in place. If an agent still misbehaves, it is not this.
What to do
railcode connector list— the account is already there. Credentials were migrated; nobody re-authorizes anything.Check the name. If two people in one org held the same provider, or the plain name was already taken, the row was suffixed —
gmailmay now begmail-jp. Use the name fromconnector list, not the old toolkit id.Swap the calls. The surface is identical in the worker SDK and the v1 browser SDK:
Old New personalConnections.call("gmail", "send_email", args)connector("gmail-jp").call("send_email", args)personalConnections.tools("gmail")connector("gmail-jp").tools()personalConnections.list()serviceConnectors()personalConnections.connect("gmail")No app-side equivalent. Linking left the app: the person runs railcode connector link gmailor links from the dashboard. Do not build a connect flow into the app.Replace
personal_connectors:withconnectors:in the manifest, naming the row and the tools it may call — e.g.connectors: { "gmail-jp": ["send_email"] }, or["*"]for the whole row. Then redeploy.
A v1 app does not need migrating to apps v2 for this. connector() is already in the
platform SDK served from /_api/sdk.js. What it does need is a rebuild, because the call
site is compiled into its bundle — so this is only actionable by someone who still has the
app's source.
First: Which Generation?
Railcode apps come in two shapes, and almost every rule below depends on which one you are holding. Settle this before anything else.
- A new app is always generation 2 (apps v2). There is no choice, no flag, no
railcode.jsonkey. The server assigns it. - An existing app may be generation 1 (v1) — the legacy browser-SDK shape. Existing apps were backfilled to 1 and stay there until someone explicitly migrates them.
railcode apps show <app> --json | grep generation # 1 = legacy, 2 = apps v2
The plain text output does not print the generation; use --json.
| Situation | Do this |
|---|---|
| Building a new app | Apps v2. Continue with this file. |
Changing an app whose railcode.json has a "type" and a "server" |
Apps v2. Continue with this file. |
Changing an app whose index.html loads /_api/sdk.js |
Generation 1. Read v1 legacy — the rules here mostly do not apply. |
| Changing a v1 app, and the request needs something v1 cannot do — a secret, a backend, a cron, authorization that must hold | Migrate, and tell the user. Read When a v1 App Can't Do What's Asked next. Do not build a browser-side workaround. |
| User wants a v1 app rebuilt as v2 | Read Migration first. It is one-way and has a downtime window. |
If you cannot reach the server to check, decide from the source tree: /_api/sdk.js in
index.html means v1; a "server" key in railcode.json means v2.
When a v1 App Can't Do What's Asked
A v1 app is a page and a browser SDK. There is no worker, so there are no secrets, no cron, no server-side code, and no caller the app can trust. When a request needs one of those, the platform's answer is "that needs a worker" — and a v1 app cannot get a worker without migrating. "Add X to this app" hides a migration whenever X lands on a row below.
| The request needs… | Why v1 can't | On v2 |
|---|---|---|
| A credential the browser must not see — an AWS key, a signing key, a third-party API secret | No secrets. Anything the page can read — KV, a settings collection, a bundled constant — every user who can open the app can read | secrets.NAME in the worker |
| An API the connector proxy can't authenticate to — per-request signing (SigV4), mTLS, a bespoke handshake | Connectors do bearer/header/query/basic only, and there is nowhere server-side to run signing code | The worker calls it under egress: |
| Something to run on a schedule | No cron; nothing runs unless a page is open | A crons: entry hitting a worker route — or the agent's own schedule for AI work |
| A rule that must hold — "X submits, Y approves, X can't approve their own" | Page-side checks are advisory; any user can bypass them from devtools | A check in the worker against ctx.user |
| Server-side work — aggregating many records, chaining several services, anything too heavy or too sensitive for a tab | Nothing runs server-side | A worker route |
The rule: migrate, tell the user, and keep going. When the request lands on a row, the deliverable is the migration plus the feature — not a v1 approximation of the feature.
- Do not build the workaround. A key parked in KV so the page can sign requests itself, a poll loop standing in for a schedule, an approval check in a tab — each one ships, demos, and is the wrong design, and the user finds out afterward if at all. If you catch yourself writing "the key is readable by anyone who can use the app", you are building the workaround.
- Say it up front, in one paragraph, then proceed. What the request needs, that v1 has no way to provide it, that the app is going to v2 to get it, and which path: a new slug (Path A — reversible, preferred) or in place (Path B — one-way, with a downtime window). Do not stop the work to ask whether to migrate, and do not present migration as an alternative once the workaround is already built.
- Size it honestly — it is smaller than it looks. v2 has no browser SDK, so every data call
the page makes moves behind the worker. But that is a rewrite of the app's one SDK wrapper into
fetch()calls to worker routes, not a rewrite of the app: views, state, and business logic stay. The feature you were asked for becomes the first new route.apps/crminrailcode-examplesis a large v1 app ported through exactly one module. - The one thing to hold for is the gate. On Path B,
railcode migrateis irreversible and the live app is down until the next deploy, so finish and validate the v2 build first and get an explicit go before running it. Path A never touches the gate.
Read Migration for the procedure. If the user hears all of this and still wants the v1 version, build it and say plainly what it compromises. Small changes that stay inside what v1 already does — a new view, a fix, a field — need none of this; make them in place per v1 legacy.
The v2 Model In One Paragraph
A v2 app is a static frontend plus a backend worker, deployed and versioned as one unit.
There is no browser SDK. The frontend is plain static files that fetch() your own worker
routes; the worker imports @railcode/sdk and is the only thing that touches platform
capabilities. The worker is the app's principal (run_as: app is mandatory) and receives a
verified, unforgeable ctx.user. Authorization is worker code — that is the point: "X submits,
Y approves, X can't approve their own" now lives in a trusted place instead of in a tab.
Everything else follows from that. If you catch yourself reaching for a window.db or a
/_api data call from the page, stop: that is the v1 shape.
Clear Any v1 SDK Memory
If you are starting v2 work carrying memory written before apps v2 — agent memory files,
CLAUDE.md / AGENTS.md notes, saved project learnings, a rules file, a starred snippet —
assume it describes the v1 browser SDK and is now wrong. Stale memory is the most common
reason a v2 build goes sideways for reasons the code itself does not explain: a
<script src="/_api/sdk.js"> reappears in index.html, the page calls window.db directly,
run_as: user shows up in railcode.json, or authorization drifts back into a tab.
Clear it before writing any code, and say out loud that you are doing it. If the notes are not yours to edit, ask the user to run it:
Delete every stored note about the Railcode browser SDK —
/_api/sdk.js, thewindowglobals (me,roles,appUsers,designSystem,db,files,data,query,connector,serviceConnectors,llm,agents, and the rest of that set), page-side authorization, andrun_as: user. None of it exists in apps v2.
Two rules decide whether this actually works:
- Delete, don't append. A note saying "use
window.db" recalled beside one saying "use the worker SDK" does not resolve in v2's favor — it yields a hybrid that type-checks, runs underrailcode dev, and fails on the platform. - Still maintaining a v1 app? Re-scope instead of deleting. Rewrite each note to open with
v1 apps only (generation 1):so the boundary is recalled along with the content, and keep v1 legacy as the real source.
Then re-read this file from the top, so what you build from is this document rather than what you remembered.
Map The Request To Railcode
Use this table before choosing an architecture. If the request names an external product or data source, always check data connections/saved queries and connectors before deciding what is available; the discovery commands are in Build step 1.
| What the user asks for | Use this Railcode feature (all called from the worker) |
|---|---|
| "Show company metrics/orders/customers from our database" | Saved query via query() (default); data connection + data().runSQL() only when explicitly requested |
| "Let someone use their own Gmail, Slack, or other account" | Connector they link and own (railcode connector link gmail), named under connectors:. Owned rows are restricted until shared — see connectors |
| "Connect an account for a product Railcode does not bundle" | railcode connector add-mcp <name> <https-url> — a remote MCP server as a connector you own |
| "Use our team's shared Stripe, CRM, or other SaaS account" | An org-managed connector (access_mode: organization) via connector().fetch() or connector().call(); an admin owns the credential |
| "Store app settings, drafts, approvals, or lightweight records" | db — one flat store; partitioning and access policy are your worker's code |
| "Upload, store, download, or display files" | files (server-plane: your worker holds the bytes) |
| "Read, extract, summarize, transform, or generate a file with AI" | Managed agent with app_files + sandbox, started with agents.start() |
| "Summarize or classify data while the user waits" | llm.generate() / llm.stream() in the worker |
| "Run on a schedule" | A crons: entry hitting one of your worker routes — or the agent's own schedule |
| "Run in the background, from Slack, or after the tab closes" | Managed agent via agents.start(), results polled from the worker |
| "Send a system-owned transactional email" | email.send(); a Gmail connector when mail must come from a specific person's own account |
| "Call an arbitrary website/API" | Declare the host under egress:, or use a connector. The default allow-list is the data plane only |
The file boundary still holds. The worker may put/get/list/delete files, but any AI
that must read, understand, extract, summarize, transform, or generate a file must be a
managed agent with app_files and its sandbox. Never feed file contents or file URLs to
llm.generate() as a substitute. Start the agent from the worker with agents.start() and read
its result back.
Start From An Example
railcode init is the starting point for a new app: each worker template scaffolds a working
platform tour (identity, a todo list on db, files, and the read-only org surfaces) you can read
and then delete.
For anything past the tour, read railcode-examples. Every app there is generation 2 —
frontend/ + server/index.ts, the shape you are building — so it is safe to copy from, and
each was chosen to carry one lesson:
| Example | Read it for |
|---|---|
apps/kanban |
The plainest worker app. A shared store, and one asymmetric rule (delete is the author or an admin) written where a caller cannot reach it. |
apps/chat |
The agent loop running in the worker and streaming ndjson to the page; per-user isolation rebuilt as keys plus an owner check (server/keys.ts); batched file URLs with a fallback for non-S3 storage. |
apps/crm |
A large v1 app ported through ONE module, and the tool loop that had to stay in the browser because its writes wait for a human approval click. |
agents/pitch-deck |
agents.start() + poll, a run that reattaches after a refresh, and agent output landing in the app's own store with no bridge. |
agents/proposals |
Why a schedule belongs to the AGENT, not the app: cron has no caller, so agents.start() from cron is a 409. |
Copy a whole example as a starting point:
mkdir my-app && curl -fsSL \
https://github.com/Railcode-HQ/railcode-examples/archive/refs/heads/main.tar.gz \
| tar -xz --strip-components=3 -C my-app railcode-examples-main/apps/kanban
Then set app in railcode.json to your slug. To study one file without copying, fetch it raw
from https://raw.githubusercontent.com/Railcode-HQ/railcode-examples/main/<path>.
Build Process (follow in order)
Don't start writing app code until steps 1–2 are done.
1. Ask before building
Ask the user a few short questions to scope the app — all in one batch, as early as possible. This is the moment the user is still present; questions dribbled out mid-build risk landing after they've stepped away. Ask only what changes the design, then pick sensible defaults for the rest and state them.
Phrase every question for a non-technical user who knows nothing of Railcode internals: ask about intent, and let the answers determine the primitives without naming them. "Should each user see only their own records, or does everyone work on the same data?" — not "how should the worker partition the flat store?".
Before asking anything, check the request against Limitations below. If it needs something Railcode can't do, say so plainly first and propose the nearest supported shape.
External source discovery is mandatory. Whenever the user asks for an app that reads, writes, syncs, searches, or acts on data from a named product ("X"), do not assume a new integration is needed. Inspect all three planes first:
railcode db list # data connections
railcode query list # admin-published saved queries
railcode connector list # connectors you can reach: owned, shared, org-managed
railcode connector catalog # providers that could be linked, if none exists yet
If something plausible exists, inspect its real surface before designing around it
(railcode connector docs <name> for http rows, railcode connector tools <name> for mcp
rows). Never invent
connector names, endpoints, or tool slugs. If you cannot reach the instance, ask the user what is
configured and show them these commands; an empty local result is not proof that X is
unsupported.
If nothing suitable exists, explain the gap and offer the real next choices instead of silently
dropping the integration: have an admin connect the database and publish a saved query; create an
org-managed connector for a shared credential; link a bundled provider as a connector; or add X's
remote MCP server by URL (railcode connector add-mcp). If X has neither an API/database nor a
remote MCP server, say Railcode cannot reach it directly and ask which supported source to use.
Cover at least:
- What & who — what should the app do, and who uses it? (drives access policy, and how the worker partitions the flat store)
- Data — what does it store or read? Per-user or shared? External database → an admin-published saved query unless the user explicitly asks for direct SQL. Third-party SaaS → a service connector. Any LLM use? If AI is involved, establish its shape: does it process files, run code, or need to survive the request? Any yes → a managed agent.
- Stack — default to
hono+vite. Offerhono+staticfor something small,tanstackwhen the user wants file-based routing and server functions,staticwhen there is no backend at all. - Design — "Should I use the default Railcode design system, or do you have a specific design direction?"
- Browser testing — "Should I test my changes in a browser before calling it done?"
2. Fetch the design system (if the user wants it)
railcode login # once, if not already logged in
railcode design-system
Prints your org's design-system guidance (markdown). If it returns empty or there is no server, skip it and use Visual Direction below.
3. Build the app
railcode init <app> [dir] [--template hono+vite|hono+static|tanstack|static]
cd <app>
npm install
railcode dev
The CLI owns the build. The app declares no bundler, no wrangler, no Cloudflare package,
and no worker build script. Write server/index.ts and a frontend; railcode deploy produces
the one self-contained ESM module the platform needs.
Then follow Implementation Rules below, and write manifest.yaml beside railcode.json.
On v2, run_as: app is mandatory. Declare every capability the worker actually uses and
nothing more. Validate before deploying:
railcode manifest validate
4. Test before calling it done
Run the checks in Validation. Fix what you find before declaring the work done.
5. Deploy (when the user wants it live)
railcode deploy
To deploy on every push, run railcode ci github in the project: it mints an app-scoped
deploy token, sets it as the repo secret via gh, and writes the workflow. Never put a
personal token in CI.
Decide What To Load
Load only the reference the task needs:
- Worker SDK — the
@railcode/sdksurface:ctx,db,files,llm,agents,query, connectors,email,secrets. The main reference for v2 work. - App patterns — worker routes, frontend↔worker wiring, data modeling, authorization in worker code, cron, error relaying.
- CLI workflow — exact commands: init/dev/deploy/secrets/logs/ migrate, manifest authority, app access.
- Deployment — deploy resolution, access modes, verification.
- Migration — turning an existing v1 app into a v2 app, including when a v1 app is asked for something it can't do.
- v1 legacy — the browser-SDK platform, for maintaining existing generation-1 apps only. Never build anything new from it.
Implementation Rules
Split the app in two and keep the split clean.
The frontend is static. It holds no credentials, no platform calls, and no authority. It
does exactly one privileged-looking thing: fetch() your worker's own routes under /api/*.
The only platform endpoints a v2 page may call are /_api/me and /_api/logout (what the
chrome bar uses); normally get identity from your own worker instead.
The worker imports @railcode/sdk and is where everything real happens. Use the narrowest
surface that fits:
| Need | Worker SDK surface |
|---|---|
| Who is calling | ctx.user (verified; null only on cron) |
| Records, settings, drafts | db — one flat store; you own partitioning |
| Files | files.put/get/url/urls/list/delete |
| Database reads | query() / savedQueries() by default; data()/postgres()/bigquery()/turso() only when asked |
| Third-party account, shared or personally owned | connector('name').fetch() (http) or connector('name').call() (mcp) — one surface for both; ownership and access mode live on the row |
| Short, watched AI | llm.generate() / llm.stream() |
| File AI, code execution, durable AI | agents.start() + a managed agent |
| Org member directory | appUsers() |
| Per-app secrets | secrets.NAME |
| System-owned mail | email.send() |
Authorization is your code, and nothing else does it for you. App access control decides who
may open the app. Everything after that — who may edit, approve, delete, see whose records —
is a check you write in the worker against ctx.user. There is no scoped store doing it
implicitly any more: db is one flat store, so "per-user" means you key by ctx.user.id and
you check it on read.
Never trust a value from the request body for identity or ownership. Read it from
ctx.user. The one thing a v2 worker gets for free is a caller it can believe.
Give every top-level section its own path (/companies, /companies/acme) — never keep
navigation in an in-memory view variable. Deep links, hard refresh, and back/forward must work;
these apps get linked in Slack and tickets. Railcode serving falls back to index.html, so
client-side routes resolve with no config.
Relay the SDK's error status. When a worker route wraps an SDK call, catch ApiError and
respond with its .status and body — the frontend's 403/409/429 handling depends on surviving
the extra hop. Swallowing it into a 500 destroys every typed error the platform gives you.
Worker LLM vs Managed Agents
On v2 there is no in-page LLM — llm runs in the worker. The real boundary is now worker LLM
vs managed agent, and it is capability, not sophistication:
| The AI feature… | Use |
|---|---|
| Summarizes / classifies data the worker already reads, in seconds | Worker llm |
| Reads, understands, extracts, transforms, or generates any file | Managed agent (app_files + sandbox) |
| Writes and runs code | Managed agent (sandbox) |
| Must survive the request, be retried, or take minutes | Managed agent |
| Is triggered from Slack or by an agent schedule | Managed agent |
| Needs a run history someone will audit | Managed agent |
The planes compose: the worker runs the fast turns itself and delegates heavy steps with
agents.start(), then polls the run. See Worker SDK.
Limitations
When a request hits a row below, say so up front and offer the nearest supported shape. Do not quietly build an approximation that can't work.
Platform shape
| Not possible | Why, and the nearest supported path |
|---|---|
| Public or customer-facing apps | Every viewer must be a signed-in org member — no anonymous access, no self-signup. These are internal tools |
| Inbound webhooks / public API endpoints | Your worker only runs on an authenticated app request or your own cron. Poll the source on a cron instead of receiving events |
| Arbitrary outbound calls | Egress is an allow-list. Declare hosts under egress: (exact names or one wildcard level; no schemes, ports, or paths); the default is the data plane only |
| Real-time push (websockets, presence) | No push surface; UIs poll. LLM streaming is the only streaming response |
| Next.js | Needs the OpenNext adapter, and its SSR model doesn't map to the bounded single worker. Any other bundler that emits one self-contained ESM module works |
| Custom domains, native mobile, push notifications | Apps are responsive web apps at <app>.<parent> |
| Bring-your-own API keys in frontend code | The frontend holds nothing. Use secrets in the worker, or a connector |
Worker runtime
| Constraint | Value |
|---|---|
| The worker must be one self-bundled ESM module | A code-split, CJS, or dependency-referencing worker deploys and then crashes at invocation. The CLI guarantees this for its templates; bring-your-own is on you |
| Subrequest budget | ~100 per invocation. Use files.urls() for batches, not a loop of files.url() |
| Module size | 5 MB soft cap |
| Secrets | 64 per app, 5 KB per value, write-only |
| Daily caps | LLM tokens and emails per app; both return a typed 429 |
| Invocation logs | Retained ~14 days |
Data
| Constraint | Detail |
|---|---|
db is one flat store |
No joins, transactions, or aggregations. Partitioning is your key design. Keep heavy data in a warehouse and read it via saved queries |
KV list() is first-page-only |
Default 100, max 500 — paginate in the worker or you silently drop the tail |
| No embeddings or vector search | The LLM gateway is text-in/text-out |
Agents and cron
Function crons are alpha and will evolve. The rules below are real today and the refusals are deliberate, but the limits are under active review. Don't build an app whose core loop needs a cron to do something this table says it can't — see app-patterns.md for the supported alternative.
| Constraint | Detail |
|---|---|
| A cron invocation has no caller | ctx.user is null, ctx.trigger is "cron". Every limit below follows from this one fact |
| Cron cannot start an agent run | A run is owned by (app, caller), so a cron-started run would have no owner — 409. Give the agent its own schedule instead |
| Cron cannot poll an agent run either | agents.get() matches the same (app, caller) pair, so a cron can't read back a run an http invocation started — also 409 |
| Personal connectors don't compose with cron | Every op acts as ctx.user; cron has none, so all of list/connect/tools/call refuse with 409 |
| For scheduled work on a personal account, use a personal agent | Give it the connector and its own railcode agent schedule; it writes to its owner's user scope and your worker reads that with db.scoped(ownerUuid). Its identity is fixed at created_by_id, so it fails loudly if the owner leaves rather than acting as someone else |
| Your own authz code must handle a null caller | A shared route reading ctx.user.roles throws under cron — or returns everything. The flat store enforces nothing. Guard with if (!ctx.user) |
| Everything else still works under cron | db (incl. db.scoped), files, sql, query, llm, email, appUsers(), secrets, egress, and org/service connectors — a v2 worker's authority is its run_as: app manifest, not the caller |
| Cron caps | 5 schedules per app, 1-minute minimum. A schedule pauses with a visible reason if the current deploy has no worker |
| Cron is at-least-once and may overlap | ctx.invocationId is your idempotency key. Never promise "exactly once" |
| Cron dispatches POST | A route declared GET-only will 404 on every fire and look like a broken schedule |
| Agent runs are never awaited in-band | agents.start() returns a queued run; agents.get(request_id) reads it back. The worker SDK has no call that waits, and a get() loop is not one — poll from the frontend or a later invocation |
| Prefer org agents with v2 apps | An org agent writes into the app's shared scope, which is a v2 app's flat store. A personal agent writes into its owner's user scope, which on a migrated app is frozen and read-only |
| Not exposed to the worker | The org's role list, and design-system guidance. ctx.user.roles gives the caller's own roles; fetch design guidance at build time with railcode design-system |
Visual Direction
Treat the scaffold as functional scaffolding, not a style guide — the templates ship a platform tour meant to be read and deleted.
If the user opted into the Railcode design system, fetch it with railcode design-system and
follow it. Otherwise default to its spirit: quiet internal-tool UI, neutral surfaces, compact
controls, clear tables/lists, modest borders/radius, restrained accent color.
Apps must be responsive — verify the main workflows on desktop and mobile widths, with no overlapping text, clipped controls, or unusable tables.
Give every app a favicon. These tools get pinned in a row of tabs, so a blank icon is a real cost. Draw a small SVG that says what the app is — a funnel for a pipeline, a board for a kanban — in the accent color, and link it:
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
Put it in the frontend's static root (public/ for the Vite stacks, beside index.html for
hono+static). Keep it readable at 16px: one shape, no fine detail, no lettering. Set a real
<title> in the same file — it's the label next to that icon.
Local Development
railcode dev
Runs the frontend and the worker, serving exactly the paths production carves (/api/*,
/_serverFn/*). The worker calls the CLI's local data plane over HTTP with the same wire shape
as production, so "works in railcode dev" means "works deployed."
db/files hit a local scratch store (--reset clears it; dev never touches live data).
Governed capabilities — SQL, saved queries, LLM, email, connectors, agents — forward to the
real instance under a dev token carrying your identity. They hit real providers, real data, and
real spend, including sending email and starting real agent runs.
Accepted limits: a single identity, secrets from your local env, and cron triggered by hand.
Local dev storage is separate from the deployed app's: railcode app kv / railcode app files
read and write the live app, never the local emulation.
Validation
cd <app>
railcode dev # confirm the frontend loads and its /api routes answer
railcode manifest validate
Check the worker's own logs. This is the v2 debugging surface and it has no v1 equivalent:
railcode logs app --app <slug> # invocations: path, status, duration, who
railcode logs app <invocation_id> # one full trace: console lines, errors, authority ops
Every invocation is a record, and every governed call inside it appears as an op with its
verdict — so a refusal shows up as denied with the resource name rather than as a silent
failure.
Seeding data to test with. An app with an empty store only ever shows empty states. Once
deployed, seed a few realistic records with railcode app kv set <collection> <key> '<json>'
(--file for anything long) and railcode app files upload <path> — matching the shape the
worker actually writes, which you can confirm with railcode app kv get. Say what you seeded and
remove throwaway rows afterwards. Ask first if the app already holds real data. Under
railcode dev, seed through the app's own UI instead — the CLI doesn't touch local dev storage.
If the user asked for browser testing, exercise the running app end to end at desktop and mobile
widths. Treat console errors, failed /api/* calls, and broken layouts as failures to fix.
Deployment
railcode deploy
Reads railcode.json, runs the CLI-owned build, uploads the static tree and the worker as
one unit, ratifies the manifest, and prints the live URL. The static tree and the worker activate
and revert together — a revert restores the exact executable that deploy ran.
A new app defaults to organization-wide access; use --private for a private first deploy.
If you did not create this app, you may not be its only deployer. Apps carry editors — a
co-deploy tier — so check your rights with railcode apps show <app> (can edit / can manage), railcode pull before deploying, and treat a deploy 409 as a colleague who published
after your last sync rather than something to --force past.
Secrets are live app state, not part of a deploy. Set them once and every later deploy, revert, and cold revert re-applies the current set:
railcode secrets set NAME # hidden prompt, or piped on stdin — never inline
railcode secrets ls # names + set-at + digest, never values
Read Deployment for resolution, access modes, and verification.