Build apps on Vidlet
Describe what you want in plain English to the Rubicon agent — every action an app takes is governed, attributed, and audited. Raw markdown: guide.md · typed surface: API reference.
Everything your team uses here can be built by describing it in plain English to the Rubicon agent. Every action an app takes is governed, logged, and attributed.
Identity: this deployment's operating company is Vidlet. Projects belong to Vidlet's clients — the user names them. Never invent a client or company name.
Served here: /guide (this, HTML) · /guide.md (markdown) · /api (the browsable op reference) · /api.d.ts (the raw typed op surface, a .d.ts) · /v1/config (host/issuer/SDK resolution — never hardcode a host).
The methods (memorize this — do not invent helpers)
There are NO per-op helper methods. rubicon.getSurvey(...), rubicon.listProjectSurveys(...), rubicon.createX(...) do not exist — the op id is always a string argument. But the SERVER and BROWSER surfaces differ — don't confuse them:
SERVER (server/index.ts) — c.env.rubicon, typed RubiconServerClient from "rubicon:server". The full surface:
await c.env.rubicon.read("get_survey", { survey_id }) // governed read
await c.env.rubicon.write("op_create_survey", { project_id, name }) // governed write — COMMITS
await c.env.rubicon.call("op_or_read_id", { ...args }) // verb inferred from the op name
const v = await c.env.rubicon.state.get("key") // scratch state — returns the VALUE (or null)
await c.env.rubicon.state.set("key", value) // set / list(prefix?) / delete
await c.env.rubicon.fetch(url, init) // governed egress (declare hosts)State is SCRATCH, not truth.
rubicon.state.*is a small per-app key-value store for UI state, drafts and prefs — bound to THIS app (an app can't touch another's), JSON values (≤256 KiB, ≤1000 keys/namespace).getreturns the value directly (ornull). It is NOT a business record: real data (a survey, its answers) goes through the governed ops so it's audited and lives in the customer's DB — never keep the source of truth only instate.
BROWSER (src/*) — RubiconClient from "/sdk/rubicon.mjs". A smaller surface: read(op,args), call(op,args), whoami(), login()/ensureSignedIn(). It has NO write / state / fetch — a frontend that needs to write calls a server route (fetch("api/…", {method:"POST"})) which does the governed write. rubicon.write(...) in the browser is a TypeError.
- Discover ops with
list_operations; calldescribe_handoff(op_id)before wiring any op — it returns the contract AND a runnableinput_sample. - Writes: pass the business fields FLAT (
write("op_create_survey", {project_id, name})). The platform stamps identity, idempotency, audit, and commit — never sendactor_id, never mint your ownidempotency_key, never wrap args in{inputs: …}yourself. - Refusals differ by verb: a governed READ returns its refusal as data —
{ ok:false, message, fix_hint }, soif (!r.ok) …. A governed WRITE that is refused THROWS (RubiconWriteRefused) — an uncaught refusal 500s the route, so wrap a write intry/catchand return the error yourself. (This is why an app "silently did nothing": the write threw and the route died — catch it.) - Op names are strings — the deploy
tscgate checks the method/arg *shapes*, but a mistyped op NAME is a runtimeunknown_operationteaching error (withdid_you_mean), not a compile error. Copy names fromlist_operations.
The capsule contract (what a deploy MUST contain)
rubicon.app.json REQUIRED — {"schema_version": "1", "name": "...", "description": "...",
"permissions": {"rubicon_ops": ["op_create_survey", "get_survey", …]}}
src/App.tsx REQUIRED — export function App() … AND export default App (named export
is what the injected entrypoint imports)
server/index.ts REQUIRED — a Hono app, default export. Type the binding from the platform:
import type { RubiconServerClient } from "rubicon:server";
type Env = { Bindings: { rubicon: RubiconServerClient } };Rules the build gate enforces (violations are deploy errors, not runtime surprises):
- Server routes are defined WITHOUT
/api(app.post('/build', …)); the platform mounts them under/api/*. The FRONTEND calls them WITH the prefix, relative:fetch("api/build", {method:"POST"})— relative, so it works under/u/<app_id>/. - GET routes must be side-effect-free — the boot smoke executes every GET at deploy time; a GET that writes will 500 the smoke and fail the build. All writes go in POST routes.
- Declare every op the app calls in
permissions.rubicon_ops— undeclared ops are denied at the bridge. - Deploy with
deploy_app_source(dry_run:true first for guidance); iterate withupdate_app_source(small edits, atomic promote).exercise_appsmoke-tests the deployed GET routes server-side.get_app_activityreads the app's recent governed calls (op, outcome, error) — your first debugging stop when a button "does nothing".
Failure modes seen in real sessions → the fix
| Symptom | Cause | Fix |
|---|---|---|
c.env.rubicon has no getSurvey/listProjectSurveys | invented per-op helpers | use read/write/call(opId, args) — nothing else exists |
Additional properties are not allowed ('actor_id' …) | hand-stamped identity | remove it — the host stamps identity |
boot_smoke_failed: GET /… HTTP 500 | a GET route performs a write | move writes to POST routes |
manifest_not_json / missing_schema_version | malformed rubicon.app.json | copy the manifest block above |
route_double_api_prefix | server route defined WITH /api | define without; frontend calls with |
| write "succeeds" but nothing persists | (historical — fixed) | writes commit by default from a capsule |
| app "silently did nothing" on a write | a refused WRITE threw (RubiconWriteRefused), route died | wrap writes in try/catch; check get_app_activity for the rejection |
rubicon.write is not a function (browser) | write/state/fetch are SERVER-only | call a server route that does the write |
| op denied at the bridge | op missing from permissions.rubicon_ops | declare it in the manifest |
Governance, by default
Apps touch data ONLY through governed operations. Access is open-by-absence; restrictions are opt-in from the admin surface. Real mail is identity-bound (a signed-in human sends as their own mailbox) and recipient-allowlisted. The subjectivity boundary holds: judgments are attested by a named actor, never originated by code.
Capsule hosting
Capsule serving requires the Deno-equipped production image and RUBICON_V2_MCP_ENABLED. The SDK, /v1/config, and /api are served regardless, so an app can always be authored and typechecked against this connector.