Skip to main content

Build an application with Runku

This tutorial builds a small notes backend and a typed client. It demonstrates the implemented local workflow: schema, Query, Mutation, identity-aware authorization, Realtime, an Action, durable scheduling, generated contracts, restart, and diagnosis.

Prerequisites​

Complete Local development, install the CLI, and install @runku/server and @runku/client from npm in the application.

The application root must be a regular directory and contain a regular runku/ directory. Runku rejects a filesystem root, a home directory, symlinked roots, source symlinks, and paths escaping the application root.

Project layout​

notes-app/
├── package.json
├── tsconfig.json
├── runku/
│ ├── schema.ts
│ ├── notes.ts
│ ├── maintenance.ts
│ └── _generated/
│ ├── api.js browser-safe generated references
│ ├── api.d.ts
│ ├── server.js server-only generated references
│ └── server.d.ts
└── src/
└── client.ts

Runku discovers .ts, .mts, .js, and .mjs files. A Function name is its path relative to runku/ plus the export name. runku/notes.ts exporting create becomes notes.create; runku/admin/users.ts exporting disable becomes admin.users.disable.

1. Define canonical values and schema​

Create runku/schema.ts:

import { defineSchema, defineTable, v } from "@runku/server"

export const note = v.object({
ownerId: v.string({ minBytes: 1, maxBytes: 256 }),
title: v.string({ minBytes: 1, maxBytes: 200 }),
archived: v.boolean(),
})

export default defineSchema({
notes: defineTable(note)
.index("by_owner", ["ownerId"])
.index("by_owner_archived", ["ownerId", "archived"]),
})

There must be exactly one default defineSchema export in runku/. Logical names derive stable table and index identities within one Project. Application code uses schema.tables.notes and schema.indexes.notes.by_owner; it never hard-codes physical IDs.

Runku values are bounded and typed. bigint is signed int64, number is float64, Uint8Array is bytes, timestamps use microseconds, and DocumentId<"notes"> cannot be passed where another table is expected. See Schema and data types for every validator and limit, then Documents and indexes for storage behavior.

2. Add an identity-aware Mutation​

Create runku/notes.ts:

import { mutation, query, v } from "@runku/server"
import schema, { note } from "./schema.js"

const noteId = v.documentId("notes")

export const create = mutation({
auth: "user",
visibility: "public",
capabilities: ["auth:read", "db:read", "db:write"],
args: v.object({ title: v.string({ minBytes: 1, maxBytes: 200 }) }),
returns: v.object({ id: noteId, note }),
async handler(ctx, input) {
const principal = ctx.auth.principal
if (principal === null || principal.kind !== "user") throw new Error("user required")

const id = ctx.db.documentId(schema.tables.notes, ctx.invocation.invocationId)
const value = { ownerId: principal.id, title: input.title.trim(), archived: false }
await ctx.db.insert(schema.tables.notes, id, value)
return { id, note: value }
},
})

All six declaration fields are required:

FieldPurpose
authRequired functional identity mode
visibilitypublic protocol access or internal nested-call-only access
capabilitiesLeast-privilege members exposed on ctx
argsRuntime-validated input contract
returnsRuntime-validated result contract
handlerImplementation selected by the artifact/runtime

The Mutation uses db:read because documentId belongs to the read database surface and db:write for insert. The TypeScript SDK prevents using context members whose capabilities were not declared; the builder and runtime enforce the same metadata independently.

3. Add a Query for Realtime​

Append to runku/notes.ts:

export const get = query({
auth: "user",
visibility: "public",
capabilities: ["auth:read", "db:read"],
args: v.object({ id: noteId }),
returns: v.union(v.null(), note),
async handler(ctx, input) {
const principal = ctx.auth.principal
if (principal === null || principal.kind !== "user") throw new Error("user required")
const document = await ctx.db.get(schema.tables.notes, input.id)
if (document === null) return null
if (document.value.ownerId !== principal.id) throw new Error("note access denied")
return document.value
},
})

Query reads one snapshot and records document/index dependencies. It cannot write or perform direct network effects. The ownership check stays inside the Function even though auth: "user" rejects anonymous calls: authentication proves who the caller is, not which note the caller owns.

4. Add an internal Mutation and scheduled Action​

Create runku/maintenance.ts:

import { action, mutation, v } from "@runku/server"
import schema from "./schema.js"

const noteReference = v.object({ id: v.documentId("notes") })

export const archive = mutation({
auth: "user",
visibility: "internal",
capabilities: ["auth:read", "db:read", "db:write"],
args: noteReference,
returns: v.boolean(),
async handler(ctx, input) {
const principal = ctx.auth.principal
const current = await ctx.db.get(schema.tables.notes, input.id)
if (principal === null || current === null || current.value.ownerId !== principal.id) {
throw new Error("note access denied")
}
if (current.value.archived) return true
await ctx.db.replace(schema.tables.notes, input.id, current.revision, {
...current.value,
archived: true,
})
return true
},
})

export const archiveLater = action({
auth: "user",
visibility: "public",
capabilities: ["scheduler:create"],
args: noteReference,
returns: v.string(),
handler(ctx, input) {
return ctx.scheduler.runAfter(
5_000_000n,
"maintenance.archive",
input,
{ idempotencyKey: `archive:${input.id}` },
)
},
})

runAfter uses microseconds; this example schedules five seconds later. Scheduled work pins the exact Release or Dev Revision and carries the authorized identity context defined by the execution contract. Delivery is at-least-once. Idempotency protects creation/replay, and the Mutation itself is safe if the note is already archived.

5. Build and inspect generated contracts​

From the application root:

runku dev --prepare
runku build

--prepare initializes missing local state and credentials, then exits. build creates an immutable package under .runku/builds-v1/rel_*, stores Release-specific generated types, and updates the browser api and server-only serverApi pairs in runku/_generated through per-file atomic renames.

Do not edit immutable build output or generated types. A build fails closed for source syntax, unsupported imports, invalid metadata, capability/type mismatch, ambiguous schema, path escape, unstable source snapshot, or size limits.

6. Start the backend​

runku dev --origin http://localhost:3000

On a successful start:

  • the listener is loopback-only, defaulting to 127.0.0.1:3210;
  • /healthz reports process liveness;
  • /readyz reports admission readiness;
  • the local Workspace HEAD points to a valid immutable Dev Revision;
  • HTTP, WebSocket, outbox, scheduler, Cron, logs, and watch loops are active;
  • .env.local contains local URL, target, publishable key, and server-only secret as appropriate.

Each --origin is an exact browser allowlist entry. Server-to-server requests without an Origin remain possible; browser origins not listed are rejected.

7. Configure the typed client​

Create src/client.ts:

import { RunkuClient, type CodeTarget } from "@runku/client"
import { api } from "../runku/_generated/api.js"

export const runku = new RunkuClient({
baseUrl: process.env.RUNKU_URL!,
target: process.env.RUNKU_TARGET! as CodeTarget,
applicationKey: process.env.RUNKU_KEY!,
getBearer: async () => currentUserJwt(),
})

The SDK does not read environment variables or detect frameworks. Pass runtime configuration explicitly. runku dev only helps reconcile dotenv names for recognized build systems. Never send rk_sec_* or rk_dev_* to a browser.

Typed calls return an envelope. The Function result is in value:

const created = await runku.mutation(api.notes.create, { title: "First note" })
const noteId = created.value.id
const loaded = await runku.query(api.notes.get, { id: noteId })

Mutation creates and preserves one operation ID across retryable attempts. Supply an explicit operationId when an operation must be reconciled across an application-process restart. Query and Mutation retry only transport or explicitly retryable failures. Action is never retried automatically because an external effect may already have happened.

8. Subscribe with Realtime​

const realtime = runku.realtime()
const subscription = realtime.subscribe("notes.get", { id: noteId }, {
onValue: ({ value, deliveryRevision }) => render(value, deliveryRevision),
onError: (error) => report(error.code, error.requestId),
})

await subscription.ready
// Later:
await subscription.unsubscribe()
realtime.close()

The first value is an authoritative Query result. A reconnect or resync_required executes another authoritative Query; Runku does not promise replay of every intermediate WebSocket frame. Replace client state with each delivered value instead of treating frames as domain events.

9. Verify persistence and recovery​

  1. Create a note and start a Realtime subscription.
  2. Stop runku dev with Ctrl-C and wait for exit.
  3. Start runku dev again.
  4. Query the same note and reconnect Realtime.
  5. Run:
runku status
runku doctor
runku logs --level warn --limit 100

Data, Environment identity, Application Clients, Release state, Workspace HEAD, and scheduled work must survive. doctor is read-only; an inconsistency is evidence to preserve, not permission to delete .runku/.

10. Next production-oriented checks​

Before treating the tutorial as an application foundation:

  • test access with two different identities and verify ownership denial;
  • test missing/invalid Application Key and expired/incorrect JWT;
  • repeat a Mutation with the same operation ID;
  • reconnect Realtime after network loss and credential refresh;
  • make every external Action effect idempotent or reconcilable;
  • test schema/Release compatibility before Channel promotion;
  • define log retention, backup verification, and credential rotation;
  • read the self-hosting support boundary.