SuperAPI AI Directory

create-railcode-app

Coding & development

View repository

Updated 10-16 04:58

About this skill

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.9 and @railcode/sdk 0.5.0 (both confirmed published 2026-10-11).

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.

Per-user connectors need CLI 0.3.9 and @railcode/sdk 0.5.0. On an older SDK ApiError.connectionRequired is undefined and connector().connection() does not exist; on an older CLI railcode connector connect / status / link --per-user are unknown commands. The platform side is live regardless, so an app on SDK 0.4.0 still receives the 409 — it just has to parse detail.code by hand. Prefer upgrading.

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. 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.

Per-user connectors — CLI 0.3.9, @railcode/sdk 0.5.0 (not breaking, but reverses two rules)

A connector can now be per-user: one name, no credential of its own, and each member connects their own account under it. connector("granola").call(…) is unchanged, but it can now refuse with 409 connection_required when the caller has not connected, or has not approved this app. Two statements became conditional the day this shipped:

  • "Do not build a connect flow." (the migration table below). Still true for shared and owned connectors. For a per-user one the app does forward the browser — to the platform's connect page at err.connectionRequired.connectUrl (plus &return_to=), never to an OAuth URL of its own.
  • "Crons now work." (worker-sdk.md → Connectors someone owns personally). For shared and owned connectors. A per-user connector has no caller under cron and refuses with connector_needs_user.

Nothing existing breaks: a shared or owned row behaves exactly as before. See worker-sdk.md → Per-user connectors and cli-workflow.md → Per-user connectors.

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 deploy on a manifest still carrying personal_connectors: fails with "personal_connectors was removed — link the account as a connector … and name it under connectors:". 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

  1. railcode connector list — the account is already there. Credentials were migrated; nobody re-authorizes anything.

  2. Check the name. If two people in one org held the same provider, or the plain name was already taken, the row was suffixed — gmail may now be gmail-jp. Use the name from connector list, not the old toolkit id.

  3. 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 for a shared or owned row. Linking left the app: the person runs railcode connector link gmail or links from the dashboard. Do not build a connect flow into the app. The exception is a per-user connector, where the app forwards the browser to the platform's connectUrl — see the entry above and per-user connectors.
  4. Replace personal_connectors: with connectors: 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.

Projects And Apps

The Railcode console now says project where it used to say app: My projects, New project, a project's Settings page. The word changed because what you deploy is not always something a person opens in a browser — it may be a web app, an agent, an API, or a worker with no UI at all. "Project" covers all of them.

A project and an app are the same object. Only the console's wording changed. The CLI (railcode apps, railcode init, railcode deploy), railcode.json, the API, and console URLs (/apps/<slug>) still say app, and so does the rest of this skill. Read the two words as interchangeable:

  • When the user says "my project", "the project's settings", or "share the project", they mean the app. Use the app commands and the app slug; there is no railcode project command.
  • When talking to the user, follow their word. Someone who came from the console will say project; do not correct them.
  • A project created in the console's New project dialog already exists in the org with nothing deployed. Use exactly its slug — railcode init <slug> for a new folder, or the same value as "app" in railcode.json for existing code — so the deploy lands in that project instead of creating a second one.
  • The one place the words differ: outside Railcode, "project" also means a source folder or repo (the migrate-to-railcode skill uses it that way). If it is unclear which the user means, check railcode apps list for a matching name before asking.

A project that is an agent in this sense is still an app: code you wrote, running in a worker. A managed agent — a manifest Railcode runs for you, listed under Agents in the console — is a different thing, built with the create-railcode-agent skill.

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.json key. 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, a poll loop standing in for a schedule, an approval check in a tab.
  • Say it up front, in one paragraph, then proceed: what the request needs, that v1 cannot provide it, that the app is going to v2, 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.
  • Size it honestly. Every page-side data call moves behind the worker, but that is a rewrite of the app's one SDK wrapper into fetch() calls to worker routes, not of the app: views, state, and business logic stay. apps/crm in railcode-examples is a large v1 app ported through exactly one module.
  • The one thing to hold for is the gate. On Path B, railcode migrate is 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.

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. 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, the window globals (me, roles, appUsers, designSystem, db, files, data, query, connector, serviceConnectors, llm, email, agents, and the rest of that set), page-side authorization, and run_as: user. None of it exists in apps v2.

Delete, don't append — a v1 note recalled beside a v2 note yields a hybrid that runs under railcode dev and fails on the platform. If you still maintain a v1 app, re-scope each note to open with v1 apps only (generation 1): instead of deleting it, and keep v1 legacy as the real source.

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
"Everyone who uses the app should act as themselves — their own meetings, inbox, calendar" A per-user connector (railcode connector link granola --per-user): one name, each member runs railcode connector connect granola (or the platform's connect page). Same manifest and call site; the worker handles ApiError.connectionRequired by sending the browser to connectUrl. OAuth providers only; no crons — see per-user 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 (no member owner; an admin holds the credential) via connector().fetch() or connector().call(). Whether members can reach it is the row's separate access_mode
"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 — see Worker LLM vs Managed Agents
"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 — see Worker LLM vs Managed Agents
"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

Start From An Example

railcode init is the starting point for a new app. The default hono+vite template scaffolds the onboarding welcome app; hono+static and tanstack scaffold a small platform tour (identity, a todo list on db, files, and the read-only org surfaces). Either is meant to be read and then deleted.

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 — everything in one batch, up front. 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. Offer hono+static for something small, tanstack when the user wants file-based routing and server functions, next (CLI 0.3.8 or later) when the user asks for Next.js or needs server-rendered pages (read its limits in references/cli-workflow.md first), static when 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|next|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/sdk surface: 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
AI llm.generate() / llm.stream(), or agents.start() + a managed agent — see Worker LLM vs Managed Agents
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.

Describe your routes at /api/openapi.json. Serve an OpenAPI document from the worker (keep it in server/openapi.ts) with required set on path parameters and body schemas. It is how an agent calling the app through the Railcode MCP learns the routes and their arguments instead of guessing them; without it, a wrong path and a wrong method are both a 404. See app-patterns.md.

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). The worker may put/get/list/delete files, but never feed file contents or file URLs to llm.generate() as a substitute
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, SSE subscriptions, presence) No server push surface; UIs poll. Request-scoped streaming responses are fine — toNdjson() streams any iterable (an llm.stream() or your own) to the page
Next.js outside what the preset supports Next.js itself runs: railcode init <app> --template next (App Router, pages at their normal URLs; see "Next.js and worker-first routing" in references/cli-workflow.md). Not supported: Next 16.4 (it builds, then fails on every request — keep next on ~16.3.8), the image optimizer, next/og and anything else that loads .wasm, and timed ISR (export const revalidate = N). Pages Router is untested. If the CLI has no next template or the server has no worker_routes (an old binary, a self-hosted server that is behind), the migrate-to-railcode skill's Next.js guide has a fallback that mounts the app under /api
Native mobile, push notifications Apps are responsive web apps at <app>.<parent>. A custom domain is not an app-code concern: it is an org-level add-on an admin configures in dashboard Settings → Custom domain
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 10 MB (5 MB on a server without worker_routes)
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 indefinitely for now (plan-based retention is planned); ingest is capped per invocation (200 lines / 256 KiB), excess lines dropped

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
Per-user connectors don't compose with cron A per-user connector runs on the caller's account; cron has none, so fetch/tools/call refuse with 409 connector_needs_user (also connection() / checkHealth())
For scheduled work on one person's account, use a connector they own An owned row's credential belongs to the row, so a cron route can call it (connectors: { "granola-jp": ["*"] }); only the owner (and whoever they share with) ratifies it. A managed agent on its own railcode agent schedule is the alternative — agents cannot name per-user connectors either
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, data/postgres/bigquery/turso, 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.

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.