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.