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). get returns the value directly (or null). 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 in state.

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; call describe_handoff(op_id) before wiring any op — it returns the contract AND a runnable input_sample.
  • Writes: pass the business fields FLAT (write("op_create_survey", {project_id, name})). The platform stamps identity, idempotency, audit, and commit — never send actor_id, never mint your own idempotency_key, never wrap args in {inputs: …} yourself.
  • Refusals differ by verb: a governed READ returns its refusal as data{ ok:false, message, fix_hint }, so if (!r.ok) …. A governed WRITE that is refused THROWS (RubiconWriteRefused) — an uncaught refusal 500s the route, so wrap a write in try/catch and 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 tsc gate checks the method/arg *shapes*, but a mistyped op NAME is a runtime unknown_operation teaching error (with did_you_mean), not a compile error. Copy names from list_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 with update_app_source (small edits, atomic promote). exercise_app smoke-tests the deployed GET routes server-side. get_app_activity reads 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

SymptomCauseFix
c.env.rubicon has no getSurvey/listProjectSurveysinvented per-op helpersuse read/write/call(opId, args) — nothing else exists
Additional properties are not allowed ('actor_id' …)hand-stamped identityremove it — the host stamps identity
boot_smoke_failed: GET /… HTTP 500a GET route performs a writemove writes to POST routes
manifest_not_json / missing_schema_versionmalformed rubicon.app.jsoncopy the manifest block above
route_double_api_prefixserver route defined WITH /apidefine without; frontend calls with
write "succeeds" but nothing persists(historical — fixed)writes commit by default from a capsule
app "silently did nothing" on a writea refused WRITE threw (RubiconWriteRefused), route diedwrap writes in try/catch; check get_app_activity for the rejection
rubicon.write is not a function (browser)write/state/fetch are SERVER-onlycall a server route that does the write
op denied at the bridgeop missing from permissions.rubicon_opsdeclare 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.