# DomainRuntime URL: https://docs.domainruntime.dev/docs Status: Guide Reviewed: 2026-09-22 DomainRuntime is a hosted backend you define in TypeScript. You write your **domain** — the entities your business works with and the **actions** that change them — and DomainRuntime runs it: a database with live queries, authenticated actions, file storage and durable workflows, at a URL of its own. Three words you will see everywhere: * A **domain** is your data model and the operations on it, in one TypeScript file. * An **action** is a named, typed operation that changes data. It is the only way data changes. * An **environment** is a running copy of your domain with its own data and URL: one per developer, and one for production. ```ts title="src/domain.ts" import { domain, defineAction, i } from "@domainruntime/domain"; import { z } from "zod"; export default domain("tasks") .withSchema({ entities: { tasks_task: i.entity({ title: i.string(), done: i.boolean(), createdAt: i.date().indexed(), }), }, links: {}, rooms: {}, }) .withActions({ create: defineAction({ input: z.object({ taskId: z.string().uuid(), title: z.string().min(1) }), output: z.object({ taskId: z.string() }), async execute({ input, runtime }) { await runtime.tx((tx) => tx.tasks_task[input.taskId].update({ title: input.title, done: false, createdAt: Date.now() }), ); return { taskId: input.taskId }; }, }), }); ``` ```tsx title="src/app/page.tsx" "use client"; import { db } from "@/lib/db"; export default function Tasks() { const { data } = db.useQuery({ tasks_task: {} }); return ( <> ); } ``` The page reads live: open it in two tabs and both update. It never writes directly — every change is a call to an action you defined, with validated input and a recorded caller.
The domain above runs in production today. `runtime.tx` and the `db` browser client are **planned** ; each page shows what to write today. The [status page](/docs/reference/status) lists every piece.
## Start here [#start-here] * [Quickstart](/docs/introduction/quickstart) — from an empty folder to a live environment. * [How it works](/docs/introduction/how-it-works) — what runs where, and why writes go through actions. * [Using LLMs](/docs/introduction/using-llms) — rules and Markdown docs for your coding agent. ## What you get [#what-you-get] | | | | | --------------------- | ------------------------------------------------------------------- | ------------------------------------------------ | | **Modeling data** | Entities, links and indexes, typed end to end. | Verified | | **Reading data** | Queries that update live when the data changes. | Verified (the `db` client is planned) | | **Actions** | The only way data changes: validated, attributed, retry-safe. | Verified (`runtime.tx`, `runtime.query` planned) | | **Workflows** | Long-running actions that survive restarts. | Verified in production | | **Files and streams** | Uploads and ordered streams, stored with your data. | Verified (client helpers planned) | | **Environments** | A development environment per developer, production from a release. | CLI on npm planned | | **Platform** | A CLI and a platform SDK to manage it all from code. | Planned | --- # Agentic experiences URL: https://docs.domainruntime.dev/docs/agents/agentic-experiences Status: Guide Reviewed: 2026-09-24
**Built from what runs in ekairos.ai today, and changing with it.** Every step shows code that works now. Where the design is still moving, the step says so in a **Changing** note, with the direction. This page changes with the code.
An agentic experience starts with a **domain**: your entities, your links and the actions that change them. The agent **acts on that domain**, as the person using it. It reads their data, proposes changes and runs your actions once the person confirms. It can't do anything the domain doesn't define or the person isn't allowed to do. **Context** is the harness around it. It records the conversation as events of your domain, and it lets you decide **exactly** which of those events the model sees on each call. Many agent frameworks keep a session and compact it for you when it grows. Here nothing is summarized or dropped behind your back: you choose the events, and each model call records which events it saw. Context without a domain has nothing to act on. This guide builds one small domain, orders, and the agent that works on it. ## How the pieces fit [#how-the-pieces-fit] ``` 1 The domain domain("orders") … .includes(contextDomain) entities, events and actions: what exists and what can change 2 Exact context events: { ids } · session.from(selection) you pick what the model sees; the selection is recorded 3 The turn orders.ask → Session → agent({ actions }) a business action runs the agent over the domain 4 The route client.action("orders.ask") only calls the business action, as the person 5 Live state useContext(db, options) events, reactions, send status, append, stop 6 The chat … registry components that render that state 7 Confirmations requestShip → orders.decide the agent asks; the person decides through an action 8 Costs usage on every model call measured in Context, per person and per model ``` You have a working chatbot on your domain at step 6. ## 1. The domain [#domain] The agent works on a domain like any other, and Context is included in it. The domain declares what exists (the schema), what can happen in a conversation (events) and what can change (actions). ```ts title="src/domain.ts" import { domain, defineAction, i } from "@domainruntime/domain"; import { contextDomain, defineEvent } from "@domainruntime/context"; import { z } from "zod"; export default domain("orders") .includes(contextDomain) // the harness: contexts, events, reactions .withSchema({ entities: { orders_order: i.entity({ status: i.string().indexed(), total: i.number(), placedAt: i.date().indexed() }), orders_customer: i.entity({ name: i.string() }), }, links: { orders_orderCustomer: { forward: { on: "orders_order", has: "one", label: "customer" }, reverse: { on: "orders_customer", has: "many", label: "orders" }, }, }, rooms: {}, }) .withEvents({ /** The person wrote to the agent. */ message: defineEvent({ payload: z.object({ text: z.string().min(1).max(8000) }) }), }) .withActions({ openOrders: defineAction({ description: "List open orders with their customer. Use it when the question is about current or pending work.", input: z.object({ limit: z.number().int().max(100).default(20) }), output: z.object({ orders: z.array(z.object({ id: z.string(), total: z.number(), customer: z.string() })) }), async execute({ input, runtime }) { const db = await runtime.db(); const { orders_order } = await db.query({ orders_order: { $: { where: { status: "open" }, limit: input.limit }, customer: {} }, }); return { orders: orders_order.map((o) => ({ id: o.id, total: o.total, customer: o.customer?.name ?? "" })) }; }, }), ship: defineAction({ description: "Mark an open order as shipped.", input: z.object({ orderId: z.string().uuid() }), output: z.object({ orderId: z.string() }), async execute({ input, runtime }) { const db = await runtime.db(); await db.transact(db.tx.orders_order[input.orderId].update({ status: "shipped" })); return { orderId: input.orderId }; }, }), }); ``` * **The actions are the agent's tools.** The model sees each action's `description` and input schema, so write the description for it: what the action is for and when to use it. * **The agent adds nothing new.** Actions are the only way data changes ([actions](/docs/data/actions)). The same `ship` runs whether a screen, a script or the agent calls it, and it's recorded the same way. * **Events are part of the domain.** A conversation is made of typed events (`message` here), stored in your environment and readable under your [permissions](/docs/auth/permissions).
**Changing.** Today the app maps each action to the words the person sees in the chat ("Read the open orders"). A `present` field on the action ( `present: { step: (input) => … }` ) will make each action describe itself, so a new action needs no UI change.
## 2. Exact context [#exact-context] A **Context** is one conversation's history, keyed by a name you choose (for example `orders:thread:`). Everything that happens in it is appended as an event: the person's messages, each model call, each action the agent ran and its result, the answer. What the model sees on a call is **a selection of those events that you make**: ```ts const context = await Context(runtime).open(`orders:thread:${threadId}`); const events = await context.events; // the person's messages and the agent's answers, last 24; tool traffic of earlier turns left out const selection = events.filter(isConversational).slice(-24); ``` * **You decide what goes in.** Filter by type, keep the last N, always include a pinned event (the order being discussed, the person's preferences), or leave out an old turn's tool results. It's code, so you can test it. * **Nothing happens behind your back.** No automatic compaction, truncation or summary. If you want a summary, the agent writes it as an event through an action you define, and you choose when to select it instead of what it replaces. * **It's recorded.** Each selection is stored with a digest and linked to the model call that used it. Given an answer, you can look up the exact events its model call saw, and replay the call on them. * **It's durable.** Nothing lives in a process's memory. A turn that fails midway leaves its events where they are, and the next turn starts from them. ## 3. The turn, inside a business action [#turn] The conversation is entered **only through an action of your domain**, named for what the person is doing: `orders.ask`, not "run agent". Inside it, the action opens the Context and runs the agent in a Session. The harness is an implementation detail of the business operation. The operation is what's named, validated, permissioned and recorded. ```ts title="src/domain.ts (actions)" import { Context, Session } from "@domainruntime/context"; import { ai } from "@domainruntime/context/reactor"; ask: defineAction({ description: "The person asks the orders assistant a question in a thread.", input: z.object({ threadId: z.string().uuid(), text: z.string().min(1).max(8000) }), output: z.object({ reply: z.string(), suggestions: z.array(z.string()) }), async execute({ input, runtime }) { const context = await Context(runtime).open(`orders:thread:${input.threadId}`); const message = await context.append(orders.events.message({ text: input.text })); const selection = [...(await context.events).filter(isConversational).slice(-24)]; if (!selection.some((e) => e.id === message.id)) selection.push(message); await using session = await Session.open(runtime, context, { scope, engines: { agent: ai({ model: "anthropic/claude-sonnet-5", maxRounds: 24 }) }, events: { ids: selection.map((e) => e.id) }, // the exact context of this turn }); const answer = await session.from(selection).agent({ instruction: "You work on the person's orders. Read before you answer; ask before you change anything.", output: reply, // a Zod schema: { reply, suggestions } actions: [orders.actions.openOrders, orders.actions.requestShip], }); await session.complete(); return answer; }, }), ``` * **The caller is the person.** `runtime.auth` is whoever called `orders.ask`, so the agent's actions (`openOrders`, `requestShip`) run with their permissions and nothing more. * **`ai({ model })`** runs through the Vercel AI Gateway. The model is one string: the same code runs on Claude, GPT or Gemini. * **`maxRounds`** caps model ↔ action rounds per turn. Each round resends the selection and the action definitions, so it's also a cost limit ([costs](#costs)). * **`output`** makes the answer typed (the reply, suggested next questions), and it's the action's own output. * **`actions`** is the exact list this agent can call. A change goes through a request the person confirms ([step 7](#confirmations)). * **Long turns.** An action call waits up to 110 s. If a turn can take longer, the action starts a [workflow](/docs/data/workflows) that runs the session, and the browser follows it live ([step 5](#live-state)). ## 4. The route [#route] The route has one job: call the business action **as the signed-in person**. It never opens the Context itself. ```ts title="app/api/agent/route.ts" export async function POST(request: Request) { const client = await clientFor(request); // a domain client with the person's token const { contextId, messages } = await request.json(); // the shape useContext sends const text = messages.at(-1).parts.map((p) => p.text ?? "").join(""); const result = await client.action("orders.ask")({ threadId: contextId, text }); return Response.json({ contextId, result }); } ``` `clientFor` is `createDomainClient` from `@domainruntime/domain/client` with the person's token ([calling an action](/docs/data/actions#calling-an-action)). The browser sees the message, each action the agent runs and the answer arrive live while the call is in flight ([step 5](#live-state)). ## 5. Live state: `useContext` [#live-state] `useContext` reads the Context from the environment, live, and sends new messages to your route. ```tsx title="components/chat.tsx" "use client"; import { useContext } from "@domainruntime/context/react"; import { db } from "@/lib/db"; // the environment's browser client export function useChat(threadId: string) { return useContext(db, { apiUrl: "/api/agent", contextKey: `orders:thread:${threadId}` }); } ``` `db` is the same browser client you read data with ([reading data](/docs/data/reading-data)). The person's rules decide what they can read, so a conversation is private unless your rules share it. | Field | What it is | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `events` | Everything in the conversation, oldest first: the person's messages, the agent's answers, action results. Includes the person's message optimistically, before the server confirms it. | | `reactions` | One per agent run, with `status`, the events that caused it (`causes`), what it produced (`effects`), and `stream` / `liveEffects` while it's running. | | `sessions` | The sessions that ran, with their reactions: the detail behind `reactions`. | | `contextStatus` | `idle`, `running` or `failed`. | | `sendStatus` / `sendError` | `idle`, `submitting`, `streaming` or `error` for the last `append`. | | `append({ parts })` | Posts `{ contextId, messages: [{ role: "user", parts }] }` to `apiUrl`. | | `stop()` | Aborts the requests in flight. |
**Changing.** ekairos.ai still builds its timeline from its own queries instead of `useContext` , so it doesn't stream the answer token by token or offer stop. Moving it onto `useContext` is the next step, and it's being designed on this page.
## 6. The chat [#chat] The registry's `conversation` component renders the state and nothing else. It doesn't fetch, stream or decide. Install it with the shadcn CLI: ```sh npx shadcn@latest add @domainruntime/conversation ``` | Part | Use | | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `Conversation`, `ConversationContent`, `ConversationEmpty` | The scrolling log and what it shows before the first message. | | `Message from="person" \| "agent"` with `MessageContent`, `MessageMeta`, `MessageAttachments` | One message and its text. | | `MessageSteps` / `MessageStep state="running" \| "done" \| "failed" \| "waiting"` | What the agent did on the way. `running` is the system at work (cyan), `waiting` needs the person. | | `MessageRequest` with `…Title`, `…Description`, `…Actions` | A decision the agent asks for ([step 7](#confirmations)). | | `Suggestions` / `Suggestion` | Next questions the agent proposes. | | `Composer` | Where the person writes. Enter sends; the text stays if sending fails; `busy` + `onStop` turn send into stop. | The composition, from the state in step 5: ```tsx title="components/chat.tsx" import { Conversation, ConversationContent, ConversationEmpty, Message, MessageContent, MessageSteps, MessageStep, Suggestions, Suggestion, Composer, } from "@/components/domainruntime/conversation"; export function Chat({ threadId }: { threadId: string }) { const chat = useChat(threadId); const send = (text: string) => chat.append({ parts: [{ type: "text", text }] }); const busy = chat.contextStatus === "running" || chat.sendStatus === "submitting"; return ( {chat.events.length === 0 && Ask about your orders.} {chat.reactions.map((reaction) => ( ))} ); } function Turn({ reaction, onSuggest }) { const asked = textOf(reaction.causes); // the person's message const steps = reaction.effects.filter(isActionCall); // the actions the agent ran const answer = replyOf(reaction.effects); // { reply, suggestions } from `output` return ( <> {asked && {asked}} {steps.length > 0 && ( {steps.map((s) => {labelOf(s)})} )} {answer && {answer.reply}} {answer?.suggestions.length ? ( {answer.suggestions.map((q) => onSuggest(q)}>{q})} ) : null} ); } ``` `textOf`, `isActionCall`, `stateOf`, `labelOf` and `replyOf` read events. `@domainruntime/context/react` exports helpers for this: `normalizeContextEventParts`, `getActionPartInfo`, `getPartText`, `getReasoningText` and `getSourceParts`.
**Changing.** Two things are being designed on this page. One is a `MessageParts` renderer that maps each part type (text, reasoning, action step, request, view) to its component, so `Turn` becomes one line. The other is the registry parts the chat still lacks: streaming text, reasoning, an action's input and output, and retry.
## 7. Confirmations [#confirmations] A change doesn't run straight from the model. The agent calls a **request** action instead and says it's waiting. The person's decision is another business action, `orders.decide`, which runs the change **as them**, once, appends the decision to the Context, and lets the agent continue from the result. ```ts title="src/domain.ts (actions)" requestShip: defineAction({ description: "Ask the person to confirm shipping an order. Use it instead of shipping directly.", input: z.object({ orderId: z.string().uuid(), title: z.string().max(140) }), output: z.object({ status: z.literal("awaiting_confirmation"), approvalId: z.string() }), async execute({ input, runtime }) { const approvalId = crypto.randomUUID(); const db = await runtime.db(); await db.transact(db.tx.orders_approval[approvalId] .update({ title: input.title, action: "ship", input: { orderId: input.orderId }, status: "pending" }) .link({ owner: runtime.auth.user!.id })); return { status: "awaiting_confirmation", approvalId }; }, }), decide: defineAction({ description: "The person approves or declines a change the assistant asked for.", input: z.object({ threadId: z.string().uuid(), approvalId: z.string().uuid(), decision: z.enum(["approved", "declined"]) }), output: z.object({ outcome: z.enum(["executed", "failed", "declined"]) }), async execute({ input, runtime }) { // 1. claim the decision (status "running") so a second click can't run it twice // 2. if approved, run the requested action (orders.ship) as the person, with the approval id as its request id // 3. append { approvalId, decision, outcome } to the thread's Context and run the next turn, as in orders.ask }, }), ``` In the chat, a pending request is a `MessageRequest` with its title, what will happen and two buttons that call `orders.decide`. The approval id doubles as the change's request id, so a retry doesn't run it twice ([actions](/docs/data/actions#retries-and-idempotency)).
**Changing.** Requests are rows in their own entity today, merged into the timeline by time. They will become parts of the action's own event in the Context, so the conversation's history is the only record, and a confirmation will be declared on the action instead of written as a second one.
## 8. Costs [#costs] Every model call records its usage in the Context: model, provider, input, output, cached and reasoning tokens, and the cost the gateway reports. The platform meters it per organization, environment, person and model, and shows it in billing. Because you choose the context, you also choose most of the cost: * **The selection is the input.** Each round sends the instruction, the action definitions and the selected events. In ekairos.ai a round is about 13k input tokens. Leaving old action results out of the selection is the biggest saving. * **Rounds multiply it.** A typical turn is 3 rounds (about USD 0.07 on Sonnet 5), while a turn that hits the 24-round cap costs about USD 0.83. `maxRounds` bounds it. * **Prompt caching.** The instruction and action definitions repeat on every round. When the provider caches them, the repeated input costs a fraction of the normal price. Check the cached-token share in your usage. * **Model per task.** The model is one string per engine. A short briefing and a multi-step change don't need the same model. ## What's next [#whats-next] * [Actions](/docs/data/actions): the contract every tool the agent uses is built on. * [Permissions](/docs/auth/permissions): what the agent can read, which is exactly what the person can read. * [Modeling data](/docs/data/modeling-data): the schema the agent works on. --- # Permissions URL: https://docs.domainruntime.dev/docs/auth/permissions Status: Guide Reviewed: 2026-09-23
The rule engine on this page runs in production today. Declaring rules in the domain with `withPermissions` is **planned** ; today we set an environment's rules for you from the same document.
Actions decide **what an operation does**. Permissions decide **which data a user can touch at all**. You need both: a bug in one action, or a new action written in a hurry, cannot leak or overwrite another user's data if the rules forbid it. ```ts title="src/domain.ts" export default domain("orders") .withSchema({ entities: { orders_order: i.entity({ status: i.string().indexed(), total: i.number() }), }, links: { orders_orderOwner: { forward: { on: "orders_order", has: "one", label: "owner" }, reverse: { on: "$users", has: "many", label: "orders" }, }, }, rooms: {}, }) .withActions({ /* … */ }) .withPermissions({ orders_order: { bind: { isOwner: "auth.id in data.ref('owner.id')" }, allow: { view: "isOwner", create: "isOwner", update: "isOwner", delete: "false", }, }, $default: { allow: { $default: "false" } }, }); ``` Users see only their own orders. An action can create or change an order only for the user who called it. Nobody deletes one. Everything else is denied. ## Who is checked [#who-is-checked] Rules run against the **caller of the action**, not the action: * A signed-in user who calls `orders.place` creates the order *as that user*, so the `create` rule must allow it. * The browser cannot write at all: the environment accepts writes only from your action code. You do not need `create: "false"` to keep browsers out; write rules for the user an action acts for. * A [service key](/docs/auth/service-keys) is an administrator. Rules do not apply to it. Development environments apply the same rules, so you can test permissions while you develop. ## Operations [#operations] | | Checked when | `data` is | | ---------------- | --------------------------------------------------------------------- | ---------------------------------------------- | | `view` | the object would appear in a query result | the object | | `create` | a `tx` creates it | the object **after** the write, links included | | `update` | a `tx` changes it | the object before; `newData` is after | | `delete` | a `tx` deletes it | the object | | `link`, `unlink` | a `tx` links or unlinks, per label: `allow: { link: { owner: "…" } }` | the object | A hidden object is simply missing from query results — no error, no count, so the caller cannot tell it exists. A denied write fails the whole `tx` with `permission-denied`. ## What a rule can use [#what-a-rule-can-use] | | | | ----------------------------- | --------------------------------------------------------------------------------------------------- | | `auth.id` | the signed-in user's id; `null` when signed out | | `auth.email` | the email the token was minted with, if any | | `auth.claims` | claims your backend put in the [user token](/docs/auth/users) (up to 1 KB), e.g. `auth.claims.role` | | `data` | the object (see the table above) | | `newData` | in `update`, the object after the change | | `data.ref('path.attr')` | values reached through links, **always a list** | | `auth.ref('$user.path.attr')` | the same, starting from the signed-in user | | `ruleParams` | parameters the query sent with itself | Rules are [CEL](https://cel.dev) expressions that must evaluate to `true`. ## Following links: `data.ref` [#following-links-dataref] `data.ref` walks links and returns a **list**, even for a `has: "one"` link. Compare with `in`, never `==`, and end the path with an attribute: ```ts "auth.id in data.ref('owner.id')" // ✅ "auth.id == data.ref('owner.id')" // ❌ compares a string with a list "auth.id in data.ref('owner')" // ❌ no attribute at the end "auth.id in newData.ref('owner.id')" // ❌ newData has no ref; on create, use data ``` ## Reusing parts of a rule: `bind` [#reusing-parts-of-a-rule-bind] ```ts orders_order: { bind: { isOwner: "auth.id in data.ref('owner.id')", isManager: "auth.claims.role == 'manager'", }, allow: { view: "isOwner || isManager", update: "isManager" }, }, ``` ## Hiding fields [#hiding-fields] To let everyone see an entity but only some users see one attribute, add a `fields` rule: ```ts orders_customer: { allow: { view: "true" }, fields: { email: "auth.id in data.ref('account.id')" }, }, ``` ## Defaults [#defaults] For your entities, a missing rule **allows**. `$default` changes that for every entity and operation you did not write a rule for. Start from deny and open what you need: ```ts $default: { allow: { $default: "false" } }, ``` System entities have their own defaults: `$files` and `$streams` are denied until you add rules; each user sees only their own `$users` row and their own `$executions`. ## When a rule says no [#when-a-rule-says-no] 1. Reproduce it with a user token against a production release (development runs actions as an administrator today). 2. Split the rule. If `isOwner && isManager` fails, try each half alone. 3. Check the link. `data.ref('owner.id')` is empty if the action forgot `.link({ owner: … })` in the same `tx`. ## Next [#next] * [Actions](/docs/data/actions) — where writes come from. * [Users](/docs/auth/users) — where `auth.id` and `auth.claims` come from. --- # Service keys URL: https://docs.domainruntime.dev/docs/auth/service-keys Status: Planned Reviewed: 2026-09-23
**Today an organization has one service key** , provisioned for you on first use. Creating several keys, scoping them and rotating them yourself are **planned** , as described below.
A service key belongs to your organization and lets a backend act without a user: mint [user tokens](/docs/auth/users), run actions as an administrator, create environments, publish domains, release to production from CI. Permission rules do not apply to it. You do not need it to release: an owner or admin signed in with `druntime` can make any release, including an environment's first one. The runner that executes your actions never holds your service key; the platform gives it a credential of its own, valid for its environment only, which you never see and which is switched off when the environment is deleted. (Runners set up before environment credentials existed hold the organization key.) ## Create one [#create-one] ```bash druntime keys create --name "backend" --env prod-acme-main ``` Or in the console under **Organization → Keys**. The key is shown once. Store it as a secret (`DOMAIN_SERVICE_KEY`), never in the browser and never in git. ## Scope [#scope] A key is limited to what you choose when you create it: | Scope | Allows | | ---------------- | -------------------------------------------------------------- | | one environment | actions and user tokens in that environment | | a project | the above for every environment of the project, and publishing | | the organization | managing projects and environments | Prefer the narrowest scope. A key that mints user tokens for production does not need to publish domains. ## Use [#use] ```ts import { init } from "@domainruntime/platform"; const platform = init({ serviceKey: process.env.DOMAIN_SERVICE_KEY! }); ``` Every request is checked against the key's current state: a revoked key stops working on its next request. ## Rotate [#rotate] Create the new key, deploy it, then revoke the old one: ```bash druntime keys revoke ``` --- # Users URL: https://docs.domainruntime.dev/docs/auth/users Status: Planned Reviewed: 2026-09-22
**The mechanism runs today; the helpers are planned.** Minting user tokens works in production through the environment route shown under *Today* . `env.users.mintToken` , `db.auth.signIn` and `runtime.auth` are the planned helpers.
Your app keeps its own login — email, Google, Clerk, anything. After your backend has verified who the user is, it asks DomainRuntime for a **user token** for that environment. The browser signs in with it. ```text Browser ──login──▶ your backend ──verifies──▶ your auth provider │ └── mintToken({ id, email, claims }) ──▶ DomainRuntime Browser ◀── user token ──┘ Browser ── db.auth.signIn(token) ──▶ .domainruntime.cloud ``` ## Mint a token on your backend [#mint-a-token-on-your-backend] ```ts title="app/api/session/route.ts" import { init } from "@domainruntime/platform"; const platform = init({ serviceKey: process.env.DOMAIN_SERVICE_KEY! }); const env = platform.environments.get(process.env.DOMAIN_ENV!); // e.g. "prod-acme-main" export async function POST(request: Request) { const user = await requireSignedInUser(request); // your auth provider's session check const { token } = await env.users.mintToken({ id: user.id, email: user.email, claims: { role: user.role }, }); return Response.json({ token }); } ``` * `id` must be a UUID and stable for the person. Use the same id for the same person in every environment. If your provider's ids are not UUIDs (Clerk `user_…`, Auth0 `auth0|…`), derive a stable UUID from them, for example UUIDv5 over the provider id. * `claims` are yours: whatever your actions and rules need, such as a role or a tenant. Up to 1 KB. * A token is valid for one environment.
Today Call the environment directly from your backend with its service credential: ```bash curl -X POST "https://.domainruntime.cloud/admin/refresh_tokens" \ -H "authorization: Bearer $DOMAIN_SERVICE_KEY" -H "content-type: application/json" \ -d '{"id": "3f0c…", "email": "ada@acme.com", "claims": {"role": "manager"}}' # { "user": { "id": "…", "email": "…", "refresh_token": "dru_…" }, "expiresAt": "…" } ``` In the browser, sign the InstantDB client in with `db.auth.signInWithToken(refresh_token)`; see [Reading data](/docs/data/reading-data).
## Sign in from the browser [#sign-in-from-the-browser] ```ts const { token } = await fetch("/api/session", { method: "POST" }).then((r) => r.json()); await db.auth.signIn(token); db.auth.user; // { id, email, claims } or null await db.auth.signOut(); ``` ## Who is calling, on the server [#who-is-calling-on-the-server] In an action, `runtime.auth.user` is the signed-in user or `null`. In [permission rules](/docs/auth/permissions), the same user is `auth`. ## Revoking [#revoking] Signing out, or revoking a user from your backend, rejects that token's next request. Work the user already started — a running [workflow](/docs/data/workflows) — continues with the identity it was started with. ## The `$users` entity [#the-users-entity] Signed-in users appear in `$users`; each user can see only their own row. Link your entities to it to model ownership: ```ts orders_orderOwner: { forward: { on: "orders_order", has: "one", label: "owner" }, reverse: { on: "$users", has: "many", label: "orders" }, }, ``` --- # Actions URL: https://docs.domainruntime.dev/docs/data/actions Status: Guide Reviewed: 2026-09-23
**The shape and `runtime.auth` are verified; two verbs are planned.** Actions with Zod `input` and `output` , execution records, retry-safe request ids and `runtime.auth` run in development and production today. `runtime.tx` and `runtime.query` are **planned** ; each section shows what you write today.
An action is a named operation on your domain: what it accepts, what it returns, and the code that decides what changes. It is the **only** way data changes. The browser reads live, but it cannot write. **Why.** A generic client write ("set `status` to `paid`") lets any screen or script change any field the rules allow. An action names the business operation (`orders.pay`), validates it and records who ran it. Reading the domain tells you every way its data can change. ```ts title="src/domain.ts" import { domain, defineAction, i } from "@domainruntime/domain"; import { z } from "zod"; export default domain("orders") .withSchema({ entities: { orders_order: i.entity({ status: i.string().indexed(), total: i.number(), placedAt: i.date().indexed(), }), }, links: { orders_orderOwner: { forward: { on: "orders_order", has: "one", label: "owner" }, reverse: { on: "$users", has: "many", label: "orders" }, }, }, rooms: {}, }) .withActions({ place: defineAction({ input: z.object({ orderId: z.string().uuid(), total: z.number().positive() }), output: z.object({ orderId: z.string() }), async execute({ input, runtime }) { const user = runtime.auth.user; if (!user) throw new Error("Sign in to place an order"); await runtime.tx((tx) => tx.orders_order[input.orderId] .update({ status: "open", total: input.total, placedAt: Date.now() }) .link({ owner: user.id }), ); return { orderId: input.orderId }; }, }), }); ``` The caller picks `orderId`, a fresh UUID. [Retries](#retries-and-idempotency) explains why.
Today ```ts async execute({ input, runtime }) { const user = runtime.auth.user; if (!user) throw new Error("Sign in to place an order"); const db = await runtime.db(); // the caller-scoped database await db.transact( db.tx.orders_order[input.orderId] .update({ status: "open", total: input.total, placedAt: Date.now() }) .link({ owner: user.id }), ); return { orderId: input.orderId }; } ``` `db.transact(db.tx…)` is the transaction builder DomainRuntime shares with InstantDB; `db.query` reads once. Both run as the caller.
## Calling an action [#calling-an-action] ```ts const { orderId } = await db.actions.orders.place({ orderId: crypto.randomUUID(), total: 120 }); ``` The runtime checks the input against the schema before your code runs, and the output before it returns it. If your code throws, the execution ends `failed` with error code `action_failed` and your message, which the caller receives.
Today Call `POST /actions/orders.place/invoke` — see the [HTTP API](/docs/platform/admin-http-api#run-an-action) — or use `createDomainClient` from `@domainruntime/domain/client`: ```ts const result = await client.action("orders.place")({ orderId, total: 120 }); // { type: "result", result: { orderId } } ```
## Inside `execute` [#inside-execute] | | | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `runtime.query(q)` | read once, with the same [query language](/docs/data/reading-data) as the browser (planned; today `(await runtime.db()).query(q)`) | | `runtime.tx(build)` | write; everything `build` returns commits together, or nothing does (planned; today `db.transact`) | | `runtime.auth` | who is calling: `user` and `admin`, below | | `runtime.env` | what your deployment put in the runtime: configuration and clients | Reads and writes run **as the caller**, in development and in production: a query returns only what the caller could see, and a write fails if the caller's [permissions](/docs/auth/permissions) forbid it. The execution then ends `failed` with `action_failed`. ### `runtime.auth` [#runtimeauth] | Caller | `runtime.auth.user` | `runtime.auth.admin` | | ---------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------- | | A signed-in user of your app | `{ id, email, claims }`; `id` is the same `auth.id` your permission rules see | `false` | | A member of your organization, from the console or the CLI | `{ id, email, claims }` | `false` | | A [service key](/docs/auth/service-keys) | `null` | `true`: rules do not apply | The caller is captured when the call is admitted and cannot change while the execution runs, even across a workflow's steps. `runtime.auth` is read-only. ## Writing: `runtime.tx` [#writing-runtimetx] ```ts await runtime.tx((tx) => [ tx.orders_order[orderId].create({ status: "open", total, placedAt: Date.now() }), // new object only tx.orders_order[orderId].update({ status: "paid" }), // create or update tx.orders_order[orderId].merge({ meta: { paidBy: "card" } }), // deep-merge a json attribute tx.orders_order[orderId].link({ owner: userId }), // connect tx.orders_order[orderId].unlink({ owner: previousOwnerId }), // disconnect tx.orders_order[orderId].delete(), // remove ]); ``` **One `runtime.tx` is one transaction. Two calls are two transactions.** If the second fails, the first stays applied. An action as a whole is *not* a transaction: changes that must succeed or fail together go in the same `tx`. To address an object by a unique attribute instead of its id: ```ts import { lookup } from "@instantdb/core"; tx.orders_customer[lookup("email", input.email)].update({ name: input.name }); ``` If no object has that value yet, `update` creates one. ## Retries and idempotency [#retries-and-idempotency] Every call carries a **request id** (a UUID the client sends) and gets an execution record. | Situation | What happens | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | The client retries with the same request id and the same input | It gets the first execution back. Your code does not run again. | | Same request id, different input | Rejected with `REQUEST_CONFLICT` (409). | | New request id | A new execution. Your code runs again. | | The process running your code dies mid-`execute` | The execution is marked failed and is **not** re-run. Any `tx` that already committed stays committed. | | Your code returns something that fails `output` | The execution fails with `execution_output_invalid`. Writes already committed stay. | So the runtime never runs your code twice for one request, but a failed execution may have done part of its work, and the caller will retry with a new request id. Make that retry harmless: * Put all of an action's writes in **one** `runtime.tx` when you can. A failure then leaves everything or nothing. * Let the caller choose the id of what the action creates (`orderId` above), or address it by a `.unique()` attribute with `lookup`. A second run then updates the same object instead of creating another. Do not check with a query and then create: two concurrent calls can both see "nothing there". See [Guarantees](/docs/data/guarantees). ## Keeping implementations elsewhere [#keeping-implementations-elsewhere] When an action grows, move its body to its own file. The domain still shows the contract: ```ts title="src/domain.ts" import { placeOrder } from "./actions/place-order"; export default domain("orders") .withSchema({ /* … */ }) .withActions({ place: defineAction({ input: z.object({ orderId: z.string().uuid(), total: z.number().positive() }), output: z.object({ orderId: z.string() }), execute: placeOrder, }), }); ``` The Zod schemas are the contract; TypeScript infers the types from them. Do not declare separate input or output types. ## Who may call an action [#who-may-call-an-action] **Every action in the published domain can be called by any identity of the environment**: any signed-in user, or a service key. Deciding who may run it is your code's job, in the first lines of `execute`: ```ts async execute({ input, runtime }) { const { user, admin } = runtime.auth; if (!user && !admin) throw new Error("Not signed in"); if (user && user.claims.role !== "manager") throw new Error("Managers only"); // ... } ``` Permission rules protect the **data**; the check in `execute` protects the **operation**. Write both.
**In development: scopes.** Today any signed-in user of an environment can run any of its actions. Restricting who may run an action — by permissions, roles or your own custom scopes, and actions only your backend can call — is in development. Until it ships, check the caller at the top of `execute` .
## Exposing actions [#exposing-actions] `withActions` lists a domain's own actions. Including another domain brings its entities into scope; to offer its actions through your domain's typed client, re-expose them: ```ts export default domain("shop") .includes(orders) .withSchema({ entities: {}, links: {}, rooms: {} }) .withActions({ place: orders.actions.place }); ``` The action keeps its id, `orders.place`. This shapes the typed client; it is **not** access control (see above). ## Long-running work [#long-running-work] An action should finish in seconds; a call waits up to 110 s. For work that waits on people, other systems or timers, make it a [workflow](/docs/data/workflows). ## Next [#next] * [Guarantees](/docs/data/guarantees): what is atomic, what runs once, what is live. * [Error handling](/docs/data/error-handling): failing on purpose, and what the caller sees. * [Patterns](/docs/data/patterns): ownership, idempotent creates, uniqueness, imports. --- # Error handling URL: https://docs.domainruntime.dev/docs/data/error-handling Status: Guide Reviewed: 2026-09-22
The behavior on this page runs in production today. Error codes of your own on thrown errors are **planned** .
## Four kinds of error [#four-kinds-of-error] | Kind | Example | What the caller gets | Retry? | | ------------------------ | ------------------------------------------------- | ---------------------------------------------------------- | --------------------------------- | | **Rejected input** | a field fails the action's `input` schema | `action_contract_validation_failed`; your code did not run | no — fix the input | | **Your code decided no** | `throw new Error("Managers only")` | the execution fails with `action_failed` and your message | no | | **Your code has a bug** | a `TypeError`, or an output that fails `output` | `action_failed` or `execution_output_invalid` | no — fix the action | | **The platform** | the result took longer than 110 s, a network drop | a 5xx code such as `execution_result_timeout` | yes, with the **same** request id | Only the last kind is worth retrying automatically, and retrying with the same request id never runs your code twice. ## Failing on purpose [#failing-on-purpose] Throw. The message is what the caller sees: ```ts async execute({ input, runtime }) { if (!runtime.auth.user) throw new Error("Sign in to place an order"); const { orders_order } = await runtime.query({ orders_order: { $: { where: { id: input.orderId } } }, }); if (orders_order[0]?.status !== "open") throw new Error("This order is already closed"); // ... } ``` Throw **before** your first `runtime.tx`. An error after a `tx` commits leaves that write in place. ## What reaches the caller [#what-reaches-the-caller] An action call answers with its execution. A failed one carries `status: "failed"`, the code `action_failed`, and your message, truncated to 1000 characters. There is no stack trace, in any environment. Messages reach your users, so write them for people, and never put secrets, tokens or internal ids in them. ```ts try { await db.actions.orders.place({ orderId, total }); } catch (error) { toast(error.message); // "This order is already closed" } ```
**Planned.** Throwing an error with a stable code of your own (for example `order_closed` ) so the client can branch without matching messages.
## Reads that fail [#reads-that-fail] A query that fails — too slow, malformed — returns an `error` from `useQuery` instead of data. Hidden objects are not an error: they are absent. See [Errors](/docs/reference/errors) for the codes. ## Workflows [#workflows] When a workflow run gives up, its execution fails with `workflow_failed`. Steps that finished stay finished: design each step so the run can be retried or compensated. ## Finding out what happened [#finding-out-what-happened] Every call has an execution: `GET /executions/{id}` has its status, output and error. See the [HTTP API](/docs/platform/admin-http-api#follow-an-execution). --- # Files URL: https://docs.domainruntime.dev/docs/data/files Status: Guide Reviewed: 2026-09-22
File storage runs in production today, on Amazon S3. The `db.storage` and `runtime.files` helpers are **planned** ; today upload with `PUT /storage/upload` ( [HTTP API](/docs/platform/admin-http-api#files) ).
Files are objects of the `$files` entity. You upload them, link them to your own entities, and read them with a query like any other data. ## Upload from the browser [#upload-from-the-browser] ```ts const { fileId } = await db.storage.upload(`invoices/${invoiceId}.pdf`, file); ``` ## Read [#read] ```ts const { data } = db.useQuery({ $files: { $: { where: { path: `invoices/${invoiceId}.pdf` } } }, }); // data.$files[0].url → a signed URL that expires ``` `$files` objects have `path`, `size`, `content-type`, `content-disposition` and `url`. The URL expires: store the path, or link the file, and read `url` fresh each time. ## Link files to your data [#link-files-to-your-data] ```ts title="schema" links: { billing_invoicePdf: { forward: { on: "billing_invoice", has: "one", label: "pdf" }, reverse: { on: "$files", has: "many", label: "invoices" }, }, }, ``` ```ts db.useQuery({ billing_invoice: { pdf: {} } }); ``` ## Create files inside an action [#create-files-inside-an-action] When a file is the result of business logic — a generated PDF, an export — create it in the action, so it exists only if the action succeeded: ```ts async execute({ input, runtime }) { const pdf = await renderInvoice(input); const { fileId } = await runtime.files.upload(`invoices/${input.invoiceId}.pdf`, pdf, { contentType: "application/pdf", }); await runtime.tx((tx) => tx.billing_invoice[input.invoiceId].link({ pdf: fileId })); } ``` ## Files as action input [#files-as-action-input] An action can take files as input. The client uploads them before the call, and your code receives a handle to read them: ```ts import { file } from "@domainruntime/domain"; input: z.object({ invoiceId: z.string().uuid(), scan: file() }), ``` Up to 16 files, 32 MiB each and 128 MiB per call. See [Limits](/docs/reference/limits). ## Delete [#delete] ```ts await db.storage.delete(`invoices/${invoiceId}.pdf`); ``` Deleting the `$files` object removes the file from queries immediately. ## Permissions [#permissions] `$files` is **denied by default**: add rules for `view`, `create` and `delete`, usually by `path` or by the entity the file is linked to. See [Permissions](/docs/auth/permissions). --- # Guarantees URL: https://docs.domainruntime.dev/docs/data/guarantees Status: Verified Reviewed: 2026-09-23
Every guarantee on this page holds in production today. Examples use the planned `runtime.tx` and `runtime.query` ; today the same guarantees apply to `await runtime.db()` with `db.transact` and `db.query` .
This page is the contract. If your code relies on something not listed here, it relies on luck. ## At a glance [#at-a-glance] | | Guaranteed | Not guaranteed | | ---------------- | ------------------------------------------------------------------------- | ------------------------------------------------ | | One `runtime.tx` | all its operations apply, or none do | isolation from concurrent reads | | One action | input and output validated; runs once per request id; caller recorded | that its separate `tx` calls are atomic together | | `.unique()` | enforced by the database on every write | — | | Permissions | every read and write of a production action runs under the caller's rules | — | | Live queries | every commit, by any writer, refreshes affected subscribers | ordering between two different queries | | Workflow steps | a finished step never runs again | that a running step runs only once | ## A `tx` is the transaction [#a-tx-is-the-transaction] ```ts await runtime.tx((tx) => [ tx.accounts_account[from].update({ balance: fromBalance - amount }), tx.accounts_account[to].update({ balance: toBalance + amount }), ]); ``` Both updates commit together or not at all. Two `runtime.tx` calls are two transactions: if the second fails, the first stays. Inside one `tx`, if two operations set the same attribute of the same object, the last one wins. ## Reading does not lock [#reading-does-not-lock] `runtime.query` reads committed data; it does not hold it. Between your read and your `tx`, another action can change it. A check-then-write is only correct if the database can enforce the condition itself: ```ts // ❌ two concurrent calls can both see "none" and both create const { orders_customer } = await runtime.query({ orders_customer: { $: { where: { email: input.email } } }, }); if (orders_customer.length === 0) { await runtime.tx((tx) => tx.orders_customer[id()].update({ email: input.email, name: input.name })); } // ✅ `email` is .unique(); this creates or updates the one customer await runtime.tx((tx) => tx.orders_customer[lookup("email", input.email)].update({ name: input.name }), ); ``` For counters and balances, prefer appending over overwriting: record each movement as its own object and derive the total with a query.
**Planned.** Conditional writes — a `tx` that commits only if what you read has not changed — are designed, not built.
## An action runs once per request [#an-action-runs-once-per-request] | | | | ----------------------------------- | ------------------------------------------------------------------------------ | | Same request id, same input | the first execution is returned; your code does not run again | | Same request id, different input | `REQUEST_CONFLICT` | | Your code throws | the execution fails with `action_failed`; committed `tx` calls stay | | Your code returns an invalid output | the execution fails with `execution_output_invalid`; committed `tx` calls stay | | The process running it dies | the execution is marked failed, not re-run | A failed execution may have done part of its work. Keep each action's writes in one `tx`, and make a retry with a new request id harmless with caller-chosen ids or unique attributes. ## Permissions apply inside actions [#permissions-apply-inside-actions] Reads and writes inside an action run with the caller's identity, in development and in production: a user's action cannot read or write what that user could not. A write the rules forbid fails the execution with `action_failed`; writes committed before it stay. Only a service key bypasses rules. See [Permissions](/docs/auth/permissions). ## Live queries follow commits [#live-queries-follow-commits] Subscriptions refresh from the database's change log. A commit by an action, a workflow step or any other writer refreshes every affected query, in every tab, for every user who may see the result. ## Workflows [#workflows] A finished step is saved and never runs again. A step that was running when the process stopped runs again, so steps run at least once. A run keeps the identity it was admitted with and the release it started on. See [Workflows](/docs/data/workflows). ## Stale runners cannot write [#stale-runners-cannot-write] Each execution attempt carries a fence. If an attempt is superseded — its 30-second lease expired and another took over — writes from the old one are rejected. --- # Modeling data URL: https://docs.domainruntime.dev/docs/data/modeling-data Status: Verified Reviewed: 2026-09-23 You describe your data in the domain with `withSchema`. Entities are collections of objects; links connect them. ```ts title="src/domain.ts" import { domain, i } from "@domainruntime/domain"; export default domain("orders").withSchema({ entities: { orders_customer: i.entity({ name: i.string(), email: i.string().unique().indexed(), }), orders_order: i.entity({ reference: i.string().unique().indexed(), status: i.string().indexed(), total: i.number(), placedAt: i.date().indexed(), notes: i.string().optional(), }), }, links: { orders_orderCustomer: { forward: { on: "orders_order", has: "one", label: "customer" }, reverse: { on: "orders_customer", has: "many", label: "orders" }, }, }, rooms: {}, }); ``` `entities`, `links` and `rooms` are all required today, even when empty. ## Attributes [#attributes] | Type | Stores | | ------------- | ---------------------------- | | `i.string()` | text | | `i.number()` | numbers | | `i.boolean()` | true / false | | `i.date()` | a point in time | | `i.json()` | any JSON value, stored as-is | Attributes are **required** unless you add `.optional()`. Every entity also has an `id`. | Modifier | Effect | | ------------- | ---------------------------------------------------------------------------------------------- | | `.optional()` | the value may be missing | | `.unique()` | no two objects share the value; a write that would duplicate it fails with `record-not-unique` | | `.indexed()` | needed to filter with comparisons (`$gt`, `$like`…) and to `order` by it | Index what you filter and sort on. Equality filters work without an index but get slower as the entity grows. ## Links [#links] A link has two directions, each with a label and a cardinality: ```ts orders_orderCustomer: { forward: { on: "orders_order", has: "one", label: "customer" }, reverse: { on: "orders_customer", has: "many", label: "orders" }, } ``` An order now has `customer` and a customer has `orders`, and you can query either way. Many-to-many is `has: "many"` on both sides: ```ts orders_orderTags: { forward: { on: "orders_order", has: "many", label: "tags" }, reverse: { on: "orders_tag", has: "many", label: "orders" }, }, ``` Every label must be unique on its entity: two links that both add an `orders` label to `orders_customer` collide. Name them by role (`placedOrders`, `approvedOrders`). Add `onDelete: "cascade"` to the side that should disappear with its parent: ```ts forward: { on: "orders_lineItem", has: "one", label: "order", onDelete: "cascade" }, ``` ## Composing domains [#composing-domains] Large products are several small domains. A domain can include another and link to its entities: ```ts import orders from "./orders"; export default domain("billing") .includes(orders) .withSchema({ entities: { billing_invoice: i.entity({ number: i.string().unique().indexed(), amount: i.number() }), }, links: { billing_invoiceOrder: { forward: { on: "billing_invoice", has: "one", label: "order" }, reverse: { on: "orders_order", has: "many", label: "invoices" }, }, }, rooms: {}, }); ``` Including a domain brings its entities into scope. It does **not** expose its actions: each domain decides which actions its callers can run. See [Actions](/docs/data/actions#exposing-actions). **Why the prefix.** Entities share one namespace per environment and domains are composed, so `orders_order` and `billing_order` can live side by side, and anyone reading a query knows which domain owns each entity. ## Changing the schema [#changing-the-schema] Publishing applies your schema to the environment's database, the same way in development and in production. Every change is checked against the data already stored **before** anything is written: either the whole schema applies, or nothing does and the previous release keeps serving. | Change | What happens | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | New entity, attribute or link | applied | | New index, or index removed | applied | | `.unique()` added | applied if the stored values are unique; otherwise refused, listing the duplicates | | `.unique()` removed, attribute made optional | applied | | Type changed | applied if every stored value fits the new type; otherwise refused with a count and samples | | Attribute made required | applied if every object has a value; otherwise refused | | New **required** attribute on an entity that already has objects | refused: add it with `.optional()` first, fill it, then make it required | | Link cardinality narrowed (many → one) | applied if the data fits; otherwise refused | | Entity, attribute or link removed | refused until you confirm it; see below | A refusal answers `409 schema_change_refused` with one entry per problem, so the CLI can show each one: ```json { "error": "schema_change_refused", "details": { "issues": [ { "where": "orders_order.reference", "code": "duplicates", "message": "unique has 1 duplicate value (A-1 ×2)" } ] } } ``` Each publish reports what it did, e.g. `+2 attrs, +1 index, +1 link`, or `no changes`. ### Removing and renaming [#removing-and-renaming] Removing something from the schema is destructive, so it needs a confirmation, as in InstantDB's `instant-cli push`: ```bash druntime push --yes # confirm every removal of something your domain declared druntime push --delete orders_order.notes # confirm one removal by name druntime push --rename orders_order.ref:orders_order.reference # same attribute, new name, data kept ``` Without a confirmation, the publish is refused with `409 removal_unconfirmed`, listing what would be deleted. Without `--rename`, a rename is a removal plus a new attribute. A confirmed removal is a **soft delete**: the attribute and its data are hidden, not erased, for **2 days**. Within that time you can bring them back: ```bash druntime schema deleted # what was removed, and until when it can come back druntime schema restore orders_order.notes # back, with its data (not indexed, not required) ``` After 2 days the data is purged for good. Something in the database that no release ever declared (written directly, or left from an earlier setup) is kept and reported as a warning on every publish. It is deleted only when you name it with `--delete`; `--yes` never includes it. ## System entities [#system-entities] Every environment has these. You can link to them and query them: | Entity | Holds | Default visibility | | ------------- | ----------------------------------------------------- | ------------------------------------ | | `$users` | the users of your app. See [Users](/docs/auth/users). | each user sees their own row | | `$files` | uploaded files. See [Files](/docs/data/files). | hidden until your rules allow it | | `$streams` | streams. See [Streams](/docs/data/streams). | hidden until your rules allow it | | `$executions` | action runs | each user sees the runs they started | Link your entities to `$users` to model ownership; [Permissions](/docs/auth/permissions) shows how rules use it. --- # Patterns URL: https://docs.domainruntime.dev/docs/data/patterns Status: Guide Reviewed: 2026-09-23
The recipes use the planned `runtime.tx` and `runtime.query` . The same patterns run today with `await runtime.db()` ; `runtime.auth` works as shown. See [Actions](/docs/data/actions) .
## Give every object an owner [#give-every-object-an-owner] Link the object to the caller in the same `tx` that creates it, and write the rule against the link. ```ts title="schema" links: { orders_orderOwner: { forward: { on: "orders_order", has: "one", label: "owner" }, reverse: { on: "$users", has: "many", label: "orders" }, }, }, ``` ```ts title="action" await runtime.tx((tx) => tx.orders_order[input.orderId].update({ status: "open" }).link({ owner: runtime.auth.user.id }), ); ``` ```ts title="permissions" orders_order: { allow: { view: "auth.id in data.ref('owner.id')", create: "auth.id in data.ref('owner.id')", }, }, ``` The `create` rule sees the object after the write, links included. An action that forgets `.link({ owner })` is rejected, so the bug is caught instead of shipped. ## Make creates safe to retry [#make-creates-safe-to-retry] Let the caller choose the id, and treat a repeat as a lookup: ```ts input: z.object({ itemId: z.string().uuid(), label: z.string().min(1) }), async execute({ input, runtime }) { const { items_item } = await runtime.query({ items_item: { $: { where: { id: input.itemId } } } }); if (items_item[0]) return { itemId: input.itemId }; await runtime.tx((tx) => tx.items_item[input.itemId].update({ label: input.label })); return { itemId: input.itemId }; } ``` Two concurrent repeats write the same object with the same values, so the race is harmless. ## Unique across two fields [#unique-across-two-fields] There are no composite keys. Store the combination in a unique attribute and set it in the action: ```ts title="schema" orders_lineItem: i.entity({ orderId: i.string().indexed(), sku: i.string().indexed(), orderSku: i.string().unique().indexed(), // `${orderId}:${sku}` }), ``` ```ts title="action" const key = `${input.orderId}:${input.sku}`; tx.orders_lineItem[lookup("orderSku", key)].update({ orderId: input.orderId, sku: input.sku, orderSku: key, quantity: input.quantity, }); ``` Because actions are the only writers, `orderSku` never drifts from `orderId` and `sku`. ## Do not check-then-write for uniqueness [#do-not-check-then-write-for-uniqueness] ```ts // ❌ two fast calls both see "no duplicate" and both create const { orders_customer } = await runtime.query({ orders_customer: { $: { where: { email } } } }); if (orders_customer.length) throw new Error("exists"); await runtime.tx((tx) => tx.orders_customer[id()].update({ email })); ``` Let the database enforce it: mark `email` `.unique()` and write with `lookup("email", email)`. A duplicate either updates the same object or fails the `tx` with `record-not-unique`. ## Keep a count [#keep-a-count] Do not store a counter that two actions increment at the same time. Count the linked objects: ```ts db.useQuery({ orders_customer: { $: { where: { id } }, orders: { $: { fields: ["id"] } } } }); // data.orders_customer[0].orders.length ``` When the list is too large to load, recompute the number in a [workflow](/docs/data/workflows) step instead of read-modify-write in parallel actions. ## Import thousands of rows [#import-thousands-of-rows] Keep one `tx` small — hundreds of operations, not thousands. Split large imports into a workflow with one step per batch, so a crash resumes from the last batch: ```ts export async function importOrders({ input, runtime }) { "use workflow"; for (const batch of chunk(input.rows, 200)) { await writeBatch(runtime, batch); } return { imported: input.rows.length }; } async function writeBatch(runtime, rows) { "use step"; await runtime.tx((tx) => rows.map((r) => tx.orders_order[r.id].update(r))); } ``` Use ids from the source rows so a retried batch overwrites instead of duplicating. ## Backfill an attribute [#backfill-an-attribute] To start requiring a value that old objects lack, fill it first with a one-off action meant for your backend: ```ts backfillStatus: defineAction({ input: z.object({}), output: z.object({ updated: z.number() }), async execute({ runtime }) { if (!runtime.auth.admin) throw new Error("Service key only"); const { orders_order } = await runtime.query({ orders_order: { $: { where: { status: { $isNull: true } }, fields: ["id"] } }, }); await runtime.tx((tx) => orders_order.map((o) => tx.orders_order[o.id].update({ status: "open" }))); return { updated: orders_order.length }; }, }), ``` ## Seed a development environment [#seed-a-development-environment] Nothing is copied between environments. Write a `seed` action that creates realistic data with fixed ids, so running it twice is harmless, and run it after creating the environment. Never point development code at production data. ## Find objects with no link [#find-objects-with-no-link] ```ts db.useQuery({ orders_order: { $: { where: { "owner.id": { $isNull: true } } } } }); ``` --- # Reading data URL: https://docs.domainruntime.dev/docs/data/reading-data Status: Guide Reviewed: 2026-09-22
The query language on this page runs in production today. The `db` client ( `@domainruntime/react` ) is **planned** ; the block below shows the client you use today.
You read with a query: an object that names what you want. The result has the same shape. ```tsx const { isLoading, error, data } = db.useQuery({ orders_order: {} }); // data → { orders_order: [{ id: "…", status: "open", total: 120, placedAt: … }, …] } ``` `useQuery` is live. When any matching object changes — from this tab, another user, or an action — the component re-renders with the new result. If a query returns nothing, check the entity's `view` [rule](/docs/auth/permissions): hidden objects are left out without an error.
Today Use the InstantDB client pointed at your environment: ```ts import { init } from "@instantdb/react"; import schema from "@/instant.schema"; const env = process.env.NEXT_PUBLIC_DOMAIN_RUNTIME_URL!; // https://.domainruntime.cloud export const db = init({ appId: process.env.NEXT_PUBLIC_DOMAIN_ENVIRONMENT_ID!, apiURI: env, websocketURI: `${env.replace(/^http/, "ws")}/session`, schema, }); await db.auth.signInWithToken(userToken); // see Users ``` Queries, `useQuery` and `subscribeQuery` work as on this page. Writes do not: the environment rejects client transactions.
## Filtering [#filtering] ```ts db.useQuery({ orders_order: { $: { where: { status: "open" } } } }); ``` | Filter | Matches | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `{ status: "open" }` | equal | | `{ status: { $ne: "cancelled" } }` | not equal | | `{ status: { $in: ["open", "held"] } }` | any of the values | | `{ total: { $gt: 100 } }` | `$gt`, `$gte`, `$lt`, `$lte` — the attribute must be [indexed](/docs/data/modeling-data#attributes) and typed | | `{ notes: { $isNull: true } }` | missing value | | `{ "owner.id": { $isNull: true } }` | objects with no linked owner | | `{ reference: { $like: "ORD-2026%" } }` | a pattern, case-sensitive; `$ilike` ignores case. Indexed attribute. | | `{ "customer.email": "a@acme.com" }` | a value on a linked object | | `{ id: orderId }` | one object | Several keys in one `where` must all match. For anything else, use `and` and `or`: ```ts db.useQuery({ orders_order: { $: { where: { or: [{ status: "open" }, { and: [{ status: "held" }, { total: { $gt: 1000 } }] }], }, }, }, }); ``` ## Ordering and limits [#ordering-and-limits] ```ts db.useQuery({ orders_order: { $: { order: { placedAt: "desc" }, limit: 20 } }, }); ``` `order` needs an indexed, typed attribute. ## Pagination [#pagination] Cursors stay stable while data changes underneath. Use them for lists people scroll through: ```ts const { data, pageInfo } = db.useQuery({ orders_order: { $: { order: { placedAt: "desc" }, first: 20 } }, }); // next page db.useQuery({ orders_order: { $: { order: { placedAt: "desc" }, first: 20, after: pageInfo.orders_order.endCursor } }, }); // previous page: last: 20, before: pageInfo.orders_order.startCursor ``` `pageInfo.` carries `startCursor`, `endCursor`, `hasNextPage` and `hasPreviousPage`. For a fixed page number, `limit` and `offset` also work: `{ limit: 20, offset: 40 }`. That page shifts if objects are added before it. ## Nested links [#nested-links] Follow links by nesting them: ```ts db.useQuery({ orders_customer: { $: { where: { email: "a@acme.com" } }, orders: { $: { order: { placedAt: "desc" }, limit: 5 }, invoices: {}, }, }, }); // data.orders_customer[0].orders[0].invoices ``` Each level takes its own `$` options. ## Selecting fields [#selecting-fields] ```ts db.useQuery({ orders_order: { $: { fields: ["status", "total"] } } }); ``` ## Outside React [#outside-react] ```ts const stop = db.subscribeQuery({ orders_order: {} }, (result) => { if (result.data) render(result.data.orders_order); }); const { data } = await db.queryOnce({ orders_order: { $: { limit: 1 } } }); ``` ## Inside an action [#inside-an-action] Actions read with `runtime.query`, under the caller's permissions: ```ts async execute({ input, runtime }) { const { orders_order } = await runtime.query({ orders_order: { $: { where: { id: input.orderId } }, owner: {} }, }); } ``` Today: `const db = await runtime.db(); const { orders_order } = await db.query({ … });` ## Next [#next] * [Actions](/docs/data/actions) — changing what you read. * [Permissions](/docs/auth/permissions) — deciding what each user can read. --- # Streams URL: https://docs.domainruntime.dev/docs/data/streams Status: Planned Reviewed: 2026-09-22
**Planned public API.** Streams run in production today: our own AI features use them for agent output. The `runtime.streams` and `db.streams` helpers on this page are not available to your code yet.
A stream is an append-only sequence of chunks. An action writes it; any number of clients read it from the start or from where they left off, live, while it is still being written. As it grows it is stored in S3 in parts, so it can be replayed later. Use streams for output that arrives over time: model tokens, logs, progress. ## Write, in an action [#write-in-an-action] ```ts async execute({ input, runtime }) { const stream = await runtime.streams.create({ name: `report-${input.reportId}` }); for await (const chunk of generateReport(input)) { await stream.append(chunk); } await stream.done(); return { streamId: stream.id }; } ``` Appending several chunks at once sends them in one request: ```ts await stream.append(["first line\n", "second line\n", "third line\n"]); ``` ## Read, in a client [#read-in-a-client] ```ts const reader = db.streams.read({ streamId }); for await (const chunk of reader) { output.textContent += chunk; } ``` A reader that reconnects continues from the last chunk it received. A reader that opens after the stream finished gets the whole content. ## Permissions [#permissions] `$streams` is **denied by default**: add rules for who may create and read streams. See [Permissions](/docs/auth/permissions). --- # Workflows URL: https://docs.domainruntime.dev/docs/data/workflows Status: Guide Reviewed: 2026-09-22
Workflow actions run durably in production releases today (Vercel Workflow, started and followed through the runtime). In **development** environments they currently run once, in memory, without durability: test restarts and `sleep` against a production release.
A workflow is an action whose work continues after it is called. You write it as an ordinary async function marked `"use workflow"`, split into **steps** marked `"use step"`. Each step's result is saved; if the process restarts, the workflow resumes after the last finished step instead of starting over. ```ts title="src/domain.ts" import { fulfilOrder } from "./workflows/fulfil-order"; export default domain("orders") .withSchema({ /* … */ }) .withActions({ fulfil: defineAction({ input: z.object({ orderId: z.string().uuid() }), output: z.object({ shipmentId: z.string() }), execute: fulfilOrder, }), }); ``` ```ts title="src/workflows/fulfil-order.ts" import { sleep } from "workflow"; export async function fulfilOrder({ input, runtime }) { "use workflow"; const order = await loadOrder(runtime, input.orderId); const label = await createLabel(order.id); await sleep("2h"); await markShipped(runtime, order.id, label.trackingNumber); return { shipmentId: label.shipmentId }; } async function loadOrder(runtime, orderId: string) { "use step"; const db = await runtime.db(); const { orders_order } = await db.query({ orders_order: { $: { where: { id: orderId } } } }); if (!orders_order[0]) throw new Error("order_not_found"); return orders_order[0]; } async function createLabel(orderId: string) { "use step"; return shippingProvider.createLabel({ reference: orderId }); // your carrier's SDK } async function markShipped(runtime, orderId: string, trackingNumber: string) { "use step"; const db = await runtime.db(); await db.transact(db.tx.orders_order[orderId].update({ status: "shipped", trackingNumber })); } ``` Callers see the action's `input` and `output`, not how the work is done. ## Starting and following a run [#starting-and-following-a-run] A workflow action answers as soon as the run is **admitted**, not when it finishes: ```bash curl -X POST "$ENV/actions/orders.fulfil/invoke" \ -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \ -d '{"requestId": "7c1e…", "input": {"orderId": "…"}}' # 202 { "kind": "workflow", "runId": "…", "statusUrl": "…/executions/", … } curl "$ENV/executions//result" # waits up to 110 s, then returns { "shipmentId": "…" } ``` Every run is an execution you can query live; each user sees the runs they started: ```ts db.useQuery({ $executions: { $: { where: { id: executionId } } } }); ``` A typed browser helper that returns a run you can await is **planned**. ## Steps [#steps] * A step runs **at least once**. Keep its effect idempotent, or look up its result before acting again. * A step's return value must be JSON-serializable; it is what gets saved. * Pass ids to steps, not large objects: every argument and result is stored. * `runtime` is passed between steps, so it is saved with the run and restored on resume. Keep only data in it — ids and configuration — never open connections or secrets. * Code outside steps runs again on every resume. Keep it to control flow. * `sleep` waits without holding a process, for seconds or for days. ## Identity and releases [#identity-and-releases] A run keeps the identity of whoever started it. It does not store their token, so a run that takes three days is not cut short when the token expires. Revoking a user blocks their new calls; runs already admitted continue as that user. A run finishes on the release it started on. Publishing new code affects new runs only. --- # Environments URL: https://docs.domainruntime.dev/docs/environments/environments Status: Guide Reviewed: 2026-09-22 An environment is a running copy of your domain: its own data, files, streams and URL, `https://.domainruntime.cloud`. Environments of the same project share the domain's code, never data. Inside the database each environment is isolated by its own application id: queries, permissions and live subscriptions never cross environments. | Mode | Who creates it | Runs your action code | Data | | ----------------------- | ------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------- | | **Development** (`dev`) | `druntime`, one per developer, or `druntime env create` | on an isolated container per environment, republished on every save | kept until you delete the environment | | **Production** (`prod`) | you, once per product and customer | from an immutable release | permanent, backed up | A [sandbox](/docs/environments/sandboxes) is not a mode: it is a machine bound to an existing development environment. ## Organizing them [#organizing-them] Environments belong to a **project** and can be grouped in **folders**, up to eight levels: ```text Acme (organization) └─ Orders (project) ├─ prod-acme-main ├─ Clients/ │ ├─ prod-globex-orders │ └─ prod-initech-orders └─ Development/ ├─ dev-orders-alice-7f3k9x2m └─ dev-orders-bob-2m8q4t1z ``` A folder can set the repository, branch and root directory its environments are built from; environments inherit it and can override it. Moving or renaming never changes an environment's URL, data or active release. ## Handles and URLs [#handles-and-urls] Each environment gets a handle once, at creation, and it never changes. The handle is its hostname. | Mode | Handle | | ----------- | ------------------------------------- | | Development | `dev---<8 characters>` | | Production | `prod--` | Handles are at most 63 characters. ## Create one [#create-one] ```bash druntime env create staging --project orders --mode dev # dev-orders-staging-3k9x2m7q ``` Or in the console. From code, see the [Platform SDK](/docs/platform/platform-sdk) (planned). ## Data between environments [#data-between-environments] Nothing is copied automatically. To start a development environment with realistic data, write a seed action and run it — see [Patterns](/docs/data/patterns#seed-a-development-environment). Never point development code at production. --- # Local development URL: https://docs.domainruntime.dev/docs/environments/local-development Status: Planned Reviewed: 2026-09-23
**Planned for public use.** Runs against production since 2026-09-22; public once the CLI is on npm.
Your code is local; your development environment is in the cloud. `druntime` connects the two. ```bash druntime ``` 1. Signs you in, once, and picks your organization. 2. Reuses your development environment, or creates a project named after your app and a personal environment in it the first time. 3. Starts or reattaches to the machine that runs your action code. 4. Writes `DOMAIN_RUNTIME_URL` and the environment's public configuration into `.env.local`, between `# BEGIN DomainRuntime development` markers, leaving your own variables alone. 5. Publishes your domain. 6. Starts the clients listed in `.domainruntime/dev.json`, with the environment configured. 7. Watches your domain and republishes on every save. ## While it runs [#while-it-runs] | Key | | | --- | ----------------------------------------------------------- | | `p` | publish now | | `r` | restart your clients | | `q` | stop your clients and detach; the environment keeps running | A save that fails to compile leaves the previous version serving. Fix it and save again. An unchanged build is not re-uploaded. ## Without the live session [#without-the-live-session] ```bash druntime env open --once # publish and exit, without starting your clients npm run dev druntime push # after each domain change druntime env stop # stop the environment's machine; data is kept ``` For agents and CI, add `--json` for structured output and pass `--env` so nothing is guessed: ```bash druntime --env=dev-orders-alice-7f3k9x2m --once --json ``` ## Several clients [#several-clients] ```json title=".domainruntime/dev.json" { "name": "orders", "domain": "domain/src/index.ts", "clients": [ { "name": "web", "cwd": "apps/web", "command": ["pnpm", "run", "dev"] }, { "name": "android", "cwd": "apps/mobile", "command": ["pnpm", "run", "android"] } ] } ``` `druntime` starts each command and supplies the environment's URL. Emulators and SDKs are your project's; `druntime` only supervises the process. ## One environment, one workspace [#one-environment-one-workspace] A development environment follows the folder that opened it. Running `druntime` from another checkout does not silently take it over; pick another environment or stop the first session. ## What is the same as production [#what-is-the-same-as-production] Development runs your actions the way production does: * Actions run **as the caller**: `runtime.auth` is the signed-in user (or `admin` for a service key), and permission rules apply to their reads and writes. * Publishing applies your schema with the same rules, refusals and confirmations. See [Changing the schema](/docs/data/modeling-data#changing-the-schema). * Executions are admitted, recorded and retried by request id the same way. ## What is different [#what-is-different] * Workflow actions are not available in development yet: a domain with a `"use workflow"` action is refused at push with `development_workflow_adapter_unavailable`. Test them in a production release. * Action traces and the execution timeline are production-only; in development, `console.log` output stays in the machine's logs. ## Limits [#limits] The machine stops after 30 minutes with no `druntime` attached and lives at most 7.9 hours. The next save or `druntime` starts a new one and republishes your code; your data is untouched. An organization runs at most two development machines at a time. Development environments refuse production targets, even with `--prod`. --- # Production URL: https://docs.domainruntime.dev/docs/environments/production Status: Planned Reviewed: 2026-09-23
**Releases run in production today** as immutable builds of your domain source, created through the platform. `druntime push` releases your folder's domain; building straight from a Git commit and rollback from the CLI are **planned** .
A production environment runs a **release**: your domain and action code, built once, immutable. A development session never touches production; a release is an explicit `druntime push` to the production environment. ## Create the environment [#create-the-environment] ```bash druntime env create main --project orders --mode prod --client-org acme ``` Its handle is `prod-acme-main` and its URL `https://prod-acme-main.domainruntime.cloud`. Set the project's repository, branch and root directory when you create the project (`druntime project create orders --repository … --branch main`) or in the console. ## Release [#release] ```bash druntime push --env prod-acme-main ``` 1. The CLI uploads your domain's source from your folder (see [Releasing to production](/docs/platform/cli#releasing-to-production)). 2. The platform builds your domain and action code. A failed build shows the compiler's messages at your files' paths. 3. It applies your schema, checked against the stored data first. A change the data does not allow, or an unconfirmed removal, stops the release and nothing is applied. See [Changing the schema](/docs/data/modeling-data#changing-the-schema). 4. It activates the release. New calls run the new code; calls already in flight finish on the old one. The release records the digest of the built code, the schema it applied, and who pushed it and made it live. **Who can release:** an owner or admin of the organization, signed in as themselves, or the organization's [service key](/docs/auth/service-keys) from CI. The first release also sets up the environment's runner with a credential valid for that environment only; nobody handles it, and the organization's key never leaves the platform. ## Making safe changes [#making-safe-changes] When a release activates, browsers that loaded your app before it are still open, and workflows started before it are still running. Change things so both keep working. | Change | Safe | Unsafe | | ------------- | ----------------------------------------------- | --------------------------------------------------- | | Action input | add an optional field | add a required field, narrow a type, rename a field | | Action output | add a field | remove or rename a field an old client reads | | Actions | add a new action | rename or remove one that old clients call | | Schema | add an entity, attribute or link | change or remove an attribute (refused on release) | | Workflows | change code; running runs stay on their release | — | To make an unsafe change, do it in steps: add the new shape, release, move clients to it, then remove the old one in a later release. ## Roll back [#roll-back] ```bash druntime release rollback --env prod-acme-main ``` Rollback re-activates the previous release's code. It does not revert data or schema — one more reason to only ever *add* to the schema in a release. ## Delete an environment [#delete-an-environment] ```bash druntime env delete prod-acme-main # type the handle to confirm ``` It stops serving at once and its data is kept for 2 days: `druntime env restore prod-acme-main` brings the environment back with its data and its last release. While custom domains still point at it, it is not deleted without `--force`. See [Deleting an environment](/docs/platform/cli#deleting-an-environment). ## Before you ship [#before-you-ship] * Every entity has explicit rules and the default is deny: `$default: { allow: { $default: "false" } }`. See [Permissions](/docs/auth/permissions). * Every action checks its caller at the top of `execute`. See [Actions](/docs/data/actions#who-may-call-an-action). * Your backend mints user tokens. See [Users](/docs/auth/users). * Actions that create things take a caller-chosen id or write through a `.unique()` attribute. See [Guarantees](/docs/data/guarantees). --- # Sandboxes URL: https://docs.domainruntime.dev/docs/environments/sandboxes Status: Planned Reviewed: 2026-09-22
**Planned.** Sandboxes run on our infrastructure today for internal tests (measured on 2026-09-19: ready in 22–33 s, about USD 0.10 per sandbox-hour). Self-service access comes with the platform SDK. Today's SDK exposes them as `up` , `pull` , `run` , `status` and `down` ; the shape below is the one we are converging on.
A sandbox runs the whole stack for one environment on its own isolated machine: the runtime, your action code and dataset processing, talking over localhost. It is bound to an existing development environment and uses that environment's data; its files go to the same storage as every environment. Use one to give an agent a place to run your domain, to preview a branch, or to run end-to-end tests against a dedicated test environment. ## From code [#from-code] ```ts import { init } from "@domainruntime/platform"; import domain from "../src/domain"; const platform = init({ serviceKey: process.env.DOMAIN_SERVICE_KEY! }); const sandbox = await platform.sandboxes.create({ env: "dev-orders-e2e-4k2m9x1p" }); try { await sandbox.pushDomain(domain); const { orderId } = await sandbox.actions.run("orders.place", { orderId: crypto.randomUUID(), total: 10 }); const { data } = await sandbox.query({ orders_order: { $: { where: { id: orderId } } } }); expect(data.orders_order).toHaveLength(1); } finally { await sandbox.destroy(); } ``` ## From the CLI [#from-the-cli] ```bash druntime sandbox up druntime sandbox pull dev-orders-e2e-4k2m9x1p --domain=src/domain.ts druntime sandbox run orders.place --input='{"orderId":"…","total":10}' druntime sandbox down ``` ## Limits [#limits] | | | | ------------------------------------- | ---------------------------------------------------- | | Concurrent sandboxes per organization | set by your plan | | Maximum lifetime | 7.9 hours | | Network | outbound internet is available from your action code | | Workflows | not available inside a sandbox yet | --- # Testing URL: https://docs.domainruntime.dev/docs/environments/testing Status: Guide Reviewed: 2026-09-22
Unit tests work today with any test runner. The end-to-end helpers exist in `@domainruntime/testing` and run in our CI; they are **not yet published** to npm.
Test at two levels: | | Runs | Checks | Speed | | -------------- | ----------------------------------- | ------------------------------------------------ | ------------ | | **Unit** | your `execute`, with a fake runtime | your logic: decisions, what gets written, errors | milliseconds | | **End to end** | your domain in a real environment | schema, permissions, contracts, live queries | seconds | ## Unit: call `execute` directly [#unit-call-execute-directly] Every action is reachable from the domain, so a test calls it with the input and a runtime you control: ```ts title="src/domain.test.ts" import { describe, expect, it, vi } from "vitest"; import orders from "./domain"; function fakeRuntime(actor: { subject: string } | null) { const writes: unknown[] = []; const db = { tx: new Proxy({}, { get: (_, entity) => new Proxy({}, { get: (_, id) => ({ update: (v) => ({ entity, id, v }) }) }) }), transact: vi.fn(async (ops) => { writes.push(ops); }), query: vi.fn(async () => ({ orders_order: [] })), }; return { writes, runtime: { env: { actor }, db: async () => db } }; } describe("orders.place", () => { it("rejects anonymous callers before writing", async () => { const { runtime, writes } = fakeRuntime(null); await expect( orders.actions.place.execute({ input: { orderId: crypto.randomUUID(), total: 10 }, runtime }), ).rejects.toThrow("Sign in"); expect(writes).toHaveLength(0); }); it("validates its contract", () => { expect(() => orders.actions.place.input.parse({ orderId: "x", total: -1 })).toThrow(); }); }); ``` Calling `execute` directly skips the runtime: input is not parsed and permissions are not applied. Test the contract with `action.input.parse` and `action.output.parse`, and permissions end to end. With the planned verbs the fake runtime becomes `{ auth, query, tx }`. ## End to end: a real environment [#end-to-end-a-real-environment] Permissions, uniqueness and live queries are only faithful on the real runtime. Create a disposable environment, publish your domain, act as a user, and destroy it: ```ts title="e2e/orders.test.ts" import { createEnvironment, configFromEnv } from "@domainruntime/testing/e2e"; import orders from "../src/domain"; const env = await createEnvironment(configFromEnv()); try { await env.pushDomain(orders); // call actions and read as a minted user } finally { await env.destroy(); } ``` ## What to test where [#what-to-test-where] * **Decisions** — who may, what is rejected, what gets computed: unit. * **Idempotency** — a second call with the same unique key updates instead of duplicating: end to end, because it depends on `.unique()` in the real database. * **Permissions** — end to end, as two different users, against a production-mode release (development runs actions as an administrator today). * **Workflows** — unit-test each step function; the whole run end to end. --- # How it works URL: https://docs.domainruntime.dev/docs/introduction/how-it-works Status: Guide Reviewed: 2026-09-22 ## Three things [#three-things] Everything in DomainRuntime is one of three things: 1. **The domain** — entities, links and actions, in one chain of TypeScript. Reading it tells you everything the product stores and everything it can do. 2. **Reads** — queries from the browser or your backend that stay live, under the signed-in user's [permissions](/docs/auth/permissions). 3. **Actions** — the only way data changes: validated input, your code, validated output, the caller recorded. A query re-runs when the data it depends on changes, the way a React component re-renders when state changes. You never refetch: you call an action, and every affected query updates. ## What happens on a request [#what-happens-on-a-request] ```text Browser ──query / subscribe──▶ .domainruntime.cloud ──▶ database ◀── live results ───── (permissions applied) Browser ──db.actions.x(input)─▶ runtime ── checks input, records the execution │ └─▶ your execute() ──▶ runtime.tx(...) ──▶ database │ every affected live query refreshes ◀────┘ ``` `db.actions` and `runtime.tx` are the planned client and runtime APIs. Today actions are called over [HTTP](/docs/platform/admin-http-api) and write with `runtime.db()`. ## Why writes go through actions [#why-writes-go-through-actions] Most realtime databases let the browser write and rely on rules to stop bad writes. That works until the rules must express business logic — "an order can be paid once, only by its customer, only if stock is reserved" — and become code nobody can test. DomainRuntime splits the job: * **Rules** answer *which data can this user touch?* They are small and apply to every read and write. * **Actions** answer *what does this operation do?* They are ordinary TypeScript you can test, name and review. The browser keeps live reads for a responsive UI. Every change is a named operation with an input contract, a caller and an execution record. ## Where code runs, and what it guarantees [#where-code-runs-and-what-it-guarantees] | | A client query | An action's `execute` | One `runtime.tx` | A workflow action | A `"use step"` | | -------------------- | -------------- | ---------------------------------- | ------------------------- | ------------------------------------ | -------------------- | | Reads | yes | `runtime.query` | — | through its steps | `runtime.query` | | Writes | no | through `runtime.tx` | yes | through its steps | through `runtime.tx` | | All or nothing | — | **no**, per `tx` | **yes** | no | per `tx` | | Live for subscribers | yes | — | its commit refreshes them | — | — | | Calls other services | no | yes | no | through steps | yes | | After a failure | — | not re-run; one run per request id | — | resumes after the last finished step | runs again | | Time | 30 s per query | a call waits up to 110 s | — | minutes to days | seconds | Two rules follow: * **An action is not a transaction; a `tx` is.** Put the writes that must happen together in one `runtime.tx`. * **Reading, then writing, is not a lock.** Two calls can both read "no such customer" and both create one. Let the database decide with `.unique()`. See [Guarantees](/docs/data/guarantees). ## The pieces you own [#the-pieces-you-own] ```text Organization └─ Project one product, one domain ├─ Folder (optional) groups environments, up to 8 levels └─ Environment a running copy of the domain with its own data https://.domainruntime.cloud ``` * **Organization** — your company. Signing up creates a personal one; members and keys live here. * **Project** — one product, with one domain. * **Environment** — a running copy of the domain with its own data and URL. | Kind | For | Lifetime | | --------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | **Development** | You, while you build. One per developer; republished on every save. | Data is kept. The machine running your actions stops after 30 minutes idle or 7.9 hours and restarts on the next `druntime`. | | **Production** | Your users. Runs an immutable release of your domain. | Permanent. | **Publish** sends your domain to a development environment (`druntime`, `druntime push`). A **release** is an immutable build of your domain for production. Environments share code, never data. See [Environments](/docs/environments/environments). ## Where it runs [#where-it-runs] Everything runs in São Paulo (AWS `sa-east-1`): the runtime on a host there, data in PlanetScale Postgres with a primary and two replicas, files in Amazon S3. Development environments run your action code on an isolated container per environment. See [Architecture](/docs/platform/architecture). --- # Project structure URL: https://docs.domainruntime.dev/docs/introduction/project-structure Status: Planned Reviewed: 2026-09-22
**Planned layout.** `create-app` today also generates `src/runtime.ts` , `instant.schema.ts` and an `/api/domain` route for the standalone adapter; `druntime` does not need them.
```text my-app/ ├─ src/ │ ├─ domain.ts your domain: entities, links, actions │ ├─ lib/db.ts the browser client, typed from the domain │ └─ app/ your Next.js app ├─ .domainruntime/ │ ├─ dev.json what druntime runs: domain entry and client commands │ ├─ link.json the project and environment this folder is linked to (druntime link) │ └─ dev-session.json the running development session (not in git) ├─ .env.local DOMAIN_RUNTIME_URL and public configuration, written by druntime (not in git) ├─ DOMAIN.md your business in plain words, for people and agents └─ package.json ``` ## `src/domain.ts` [#srcdomaints] The source of truth. The database schema, the typed client and the action endpoints are derived from it. Keep it free of UI code: it runs in the runtime, not in the browser. For a larger product, split it and compose: ```text src/domain/ ├─ index.ts export default the root domain ├─ orders.ts domain("orders").withSchema({…}).withActions({…}) └─ billing.ts domain("billing").includes(orders).withSchema({…}).withActions({…}) ``` ## `.domainruntime/dev.json` [#domainruntimedevjson] ```json { "name": "my-app", "domain": "src/domain.ts", "clients": [ { "name": "web", "cwd": ".", "command": ["pnpm", "run", "dev"] } ] } ``` `clients` lists the commands `druntime` starts and gives the environment's URL. Add an Expo or desktop app the same way. See [Local development](/docs/environments/local-development). ## Naming [#naming] * Domain names are camelCase: `orders`, `supplierNetwork`. * Entities are prefixed with their domain: `orders_order`, `orders_lineItem`. Entities share one namespace per environment and domains are composed, so the prefix keeps them apart and tells a reader which domain owns each one. * Action ids are `.`: `orders.place`. --- # Quickstart URL: https://docs.domainruntime.dev/docs/introduction/quickstart Status: Planned Reviewed: 2026-09-22
**Planned for public use.** This flow runs against production since 2026-09-22; it becomes public when the CLI is published to npm. Until then, write to us for access.
## Create an app [#create-an-app] ```bash npx @domainruntime/cli create-app my-app --next cd my-app npx druntime # or: npm run local ``` `druntime` opens your browser to sign in — or create an account, which gives you a personal organization — and creates a project and a personal **development environment** the first time. Then it: 1. publishes `src/domain.ts` to your environment, 2. writes the environment's URL into `.env.local` as `DOMAIN_RUNTIME_URL`, 3. starts your Next.js dev server, 4. watches your domain and republishes on every save. ```text my-app · dev-my-app-dev-1a2b3c4d-7f3k9x2m backend https://dev-my-app-dev-1a2b3c4d-7f3k9x2m.domainruntime.cloud published 0.6 s ago web http://localhost:3000 ready p publish r restart clients q quit (keeps the environment) ``` ## Add an entity and an action [#add-an-entity-and-an-action] Open `src/domain.ts`: ```ts title="src/domain.ts" import { domain, defineAction, i } from "@domainruntime/domain"; import { z } from "zod"; export default domain("app") .withSchema({ entities: { app_todo: i.entity({ text: i.string(), done: i.boolean(), createdAt: i.date(), }), }, links: {}, rooms: {}, }) .withActions({ addTodo: defineAction({ input: z.object({ todoId: z.string().uuid(), text: z.string().trim().min(1).max(200) }), output: z.object({ todoId: z.string() }), async execute({ input, runtime }) { await runtime.tx((tx) => tx.app_todo[input.todoId].update({ text: input.text, done: false, createdAt: Date.now() }), ); return { todoId: input.todoId }; }, }), }); ``` Save the file. `druntime` publishes it; the action exists in your environment.
Today `runtime.tx` is planned. Today the body is: ```ts const db = await runtime.db(); await db.transact(db.tx.app_todo[input.todoId].update({ text: input.text, done: false, createdAt: Date.now() })); return { todoId: input.todoId }; ``` The current scaffold also contains `src/runtime.ts`, `instant.schema.ts` and an `/api/domain` route from the standalone adapter; `druntime` does not need them.
## Read and write from the page [#read-and-write-from-the-page] ```tsx title="src/app/page.tsx" "use client"; import { db } from "@/lib/db"; export default function Page() { const { isLoading, error, data } = db.useQuery({ app_todo: {} }); if (isLoading) return null; if (error) return

{error.message}

; return (
{ e.preventDefault(); const form = e.currentTarget; const text = new FormData(form).get("text") as string; await db.actions.app.addTodo({ todoId: crypto.randomUUID(), text }); form.reset(); }} >
    {data.app_todo.map((todo) => (
  • {todo.text}
  • ))}
); } ``` ```ts title="src/lib/db.ts" import { init } from "@domainruntime/react"; import app from "@/domain"; export const db = init({ domain: app }); ``` Open `http://localhost:3000`, add a todo, then open a second tab: both update live. Your new entity has no [permission rules](/docs/auth/permissions) yet, so anyone with the URL can read it. Add rules before you share the URL. ## Change the domain [#change-the-domain] Add a second action that takes the todo's id and flips `done`: ```ts title="src/domain.ts" toggleTodo: defineAction({ input: z.object({ todoId: z.string().uuid() }), output: z.object({ done: z.boolean() }), async execute({ input, runtime }) { const { app_todo } = await runtime.query({ app_todo: { $: { where: { id: input.todoId } } } }); if (!app_todo[0]) throw new Error("That todo no longer exists"); const done = !app_todo[0].done; await runtime.tx((tx) => tx.app_todo[input.todoId].update({ done })); return { done }; }, }), ``` ```tsx
  • db.actions.app.toggleTodo({ todoId: todo.id })}> {todo.done ? {todo.text} : todo.text}
  • ``` Save. The next click runs the new action against the same environment and the same data. Nothing is redeployed or reprovisioned. Submit an empty todo: the runtime rejects it before your code runs, with `action_contract_validation_failed`. ## What just happened [#what-just-happened] * The page **reads** with a live query. Both tabs update because the environment pushes every change to every subscriber. * The page **writes** only by calling actions. There is no transaction in the browser; every change is a named operation with validated input. * The domain file is the backend. Saving it publishes your actions. ## Next [#next] * [How it works](/docs/introduction/how-it-works) — the model and its guarantees, in one table. * [Modeling data](/docs/data/modeling-data), [Reading data](/docs/data/reading-data), [Actions](/docs/data/actions) — the three things you just did. * [Guarantees](/docs/data/guarantees) — what is atomic, what runs once, what is live. --- # Using LLMs URL: https://docs.domainruntime.dev/docs/introduction/using-llms Status: Guide Reviewed: 2026-09-22 ## The docs, as Markdown [#the-docs-as-markdown] | URL | Contains | | ---------------------------------- | --------------------------------------- | | [`/llms.txt`](/llms.txt) | an index of every page, with its status | | [`/llms-full.txt`](/llms-full.txt) | every page, in one file | | `.md` | one page, e.g. `/docs/data/actions.md` | Start with the rules below, and add single pages when your agent works on something specific. ## Rules for your agent [#rules-for-your-agent] Save this as `AGENTS.md` (Codex, Cursor and others) or `CLAUDE.md` (Claude Code) at the root of your project: ```md title="AGENTS.md" # DomainRuntime project - The backend is src/domain.ts, one chain: domain("x").withSchema({ entities, links, rooms }).withActions({...}). - Writes happen only inside actions: defineAction({ input, output, execute({ input, runtime }) }). Never write from the browser; there is no client transaction. - Zod input/output are the contract. Do not declare duplicate TypeScript types. - One transaction per runtime.tx (today: db.transact). An action is not a transaction: put writes that must happen together in one tx. - Never check-then-create for uniqueness: declare the attribute .unique() and write with lookup(). - Let the caller choose the id of anything an action creates, so a retry is harmless. - Today every action is callable by any signed-in user (action scopes are in development): check the caller at the top of execute. - Permission rules: data.ref('link.attr') returns a list, compare with `in`. On create, use data, not newData. - Long-running work is an action whose execute is marked "use workflow"; every side effect goes in a "use step" function. - Entities are prefixed with their domain (orders_order); action ids are .. - Pages are marked Verified, Guide or Planned. Do not use a Planned API; check https://docs.domainruntime.dev/docs/reference/status.md ``` ## Check that it worked [#check-that-it-worked] Ask your agent: *"How do I change data in DomainRuntime from a React page?"* A correct answer calls an action and does not mention a client-side transaction. ## Describe your business in `DOMAIN.md` [#describe-your-business-in-domainmd] Put a `DOMAIN.md` next to your code that says, in plain words, what the entities mean, which operations exist and who performs them. An agent that reads it names actions after business operations (`orders.place`) instead of generic ones (`orders.update`). --- # HTTP API URL: https://docs.domainruntime.dev/docs/platform/admin-http-api Status: Verified Reviewed: 2026-09-22 Each environment answers at `https://.domainruntime.cloud`. Use this API from languages without an SDK, from scripts, or to debug. Authenticate every request with a bearer: a [user token](/docs/auth/users) acts as that user; your organization's [service key](/docs/auth/service-keys) acts as an administrator. ```bash export ENV=https://dev-orders-alice-7f3k9x2m.domainruntime.cloud export TOKEN=... ``` ## Run an action [#run-an-action] ```bash curl -X POST "$ENV/actions/orders.place/invoke" \ -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \ -d '{"requestId": "3f0c1b2e-8a4d-4c1e-9b2a-5d6e7f8a9b0c", "input": {"orderId": "…", "total": 10}}' ``` * An input that does not fit the action's schema answers `400 action_contract_validation_failed` with `details.issues`: `[{ "where": "input.total", "code": "type", "message": "…" }]`. * `requestId` must be a UUID. The same id returns the first execution instead of running the action again; reusing it with other input answers `REQUEST_CONFLICT`. * The call waits up to 110 s and answers `200` with `{ kind, execution, uploads, statusUrl }`. The output is `execution.output`. * A failed action is also `200`, with `execution.status: "failed"`, `execution.errorCode` (`action_failed`) and your message. * A workflow answers `202` with its `runId` as soon as the run is admitted. * The body is limited to 8 MiB; files go through file inputs. `GET /actions/{name}/contract` returns the action's input and output schemas, its contract hash and the file limits. ## Follow an execution [#follow-an-execution] | | | | -------------------------------------- | --------------------------------------------------------------------- | | `GET /executions` | your executions, paged | | `GET /executions/{id}` | status, output, error code and message, uploads | | `GET /executions/{id}/result` | waits up to 110 s for the result, then `504 execution_result_timeout` | | `GET /executions/requests/{requestId}` | find an execution by your request id | | `GET /runs/{id}` | a workflow run | ## Query [#query] ```bash curl -X POST "$ENV/query" \ -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \ -d '{"query": {"orders_order": {"$": {"limit": 5}}}}' ``` The response is the Instant triple format (`result[].data.datalog-result.join-rows` plus `attrs`), what the SDKs read. `POST /query?format=objects` answers the result as nested objects instead (`{ "orders_order": [{ "id": …, … }] }`), for the same caller and under the same rules. The body is limited to 4 MiB. A rejected query answers `{ "ok": false, "error": "data_provider_rejected" }` with the engine's status. ## Files [#files] | | | | --------------------------------------------- | ---------------------------------------------------------------- | | `PUT /storage/upload` | upload; headers `path` and `content-type`, body is the file | | `POST /storage/signed-upload-url` | a URL to upload directly from a browser (JSON body, up to 1 MiB) | | `GET /storage/signed-download-url?filename=…` | a URL to download | | `DELETE /storage/files?filename=…` | delete | ## Live queries [#live-queries] `GET /session` upgrades to a WebSocket that speaks the Instant real-time protocol for reading: `init`, `add-query`, `remove-query`, `subscribe-stream`. Writes are not accepted on this socket. Use an SDK rather than implementing it by hand. ## Errors [#errors] Runtime errors are JSON with a stable code: `{ "ok": false, "error": "execution_not_found" }`. Action invocation errors also carry `executionId` and `requestId`. See [Errors](/docs/reference/errors). --- # Architecture URL: https://docs.domainruntime.dev/docs/platform/architecture Status: Verified Reviewed: 2026-09-22 ```text auth.ekairos.ai identity: people, organizations, keys ▲ validates every request Browser / backend ──▶ .domainruntime.cloud │ runtime (Rust): routing, identity, actions + data engine: queries, subscriptions, permissions ├─ files, streams │ ├─▶ PlanetScale Postgres data, 1 primary + 2 replicas │ └─▶ Amazon S3 files and stream parts └─▶ your action code development: an isolated container per environment production: an immutable release on a managed runner ``` ## The runtime [#the-runtime] One Rust service serves every environment: it routes by hostname, checks the caller, runs queries and keeps subscriptions live, admits and records action executions, and stores files and streams. At the data layer it is Instant-compatible — the same query language and real-time protocol — and it adds environments, actions, executions and identity on top. Live queries are refreshed from the database's own change log, so a write by any process — an action, a workflow, another runtime — refreshes every affected subscriber. ## Data [#data] | What | Where | Protection | | ------------------------------------------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- | | Entities, links, `$users`, `$files` records, executions, stream metadata | PlanetScale Postgres, AWS `sa-east-1` | encrypted at rest; TLS only; a primary and two replicas; automated backups | | File contents and stream parts | Amazon S3, `sa-east-1` | private bucket, encrypted at rest (SSE-S3), versioned, TLS only; reached through signed URLs | | Identity: people, organizations, keys | DomainRuntime Auth | separate database; tokens validated on every request | Each environment is isolated inside the database by its own application id: queries, permissions and live subscriptions never cross environments. ## Your code [#your-code] * **Development:** your action code runs in an isolated container per environment, with its own copy of the runtime, as an unprivileged user. Outbound internet is available. * **Production:** your action code runs as an immutable build of your domain source on a managed runner in São Paulo. Workflows stay on the release they started on. ## Region [#region] All data is stored in São Paulo (`sa-east-1`). --- # CLI URL: https://docs.domainruntime.dev/docs/platform/cli Status: Planned Reviewed: 2026-09-23
    **Planned for public use.** The commands in the sections without *(planned)* exist on the CLI's main line and run against production; the CLI is not yet published to npm. Sections marked *(planned)* are not implemented yet.
    ```bash npm i -D @domainruntime/cli # or run any command with npx @domainruntime/cli ``` The CLI is built on the [Platform SDK](/docs/platform/platform-sdk): everything it does, you can also do from code. ## Start [#start] | Command | | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `druntime create-app --next [--json]` | a new Next.js app with an empty domain and `.domainruntime/dev.json` | | `druntime` | in a linked app folder: open your development environment and keep it in sync. Elsewhere: where you are and what you can do next | ## Account [#account] | Command | | | ---------------------------- | ------------------------------------------------------------- | | `druntime login [--no-open]` | sign in with your browser; `--no-open` prints the URL instead | | `druntime logout` | forget the login on this machine | | `druntime whoami` | your user and selected organization | | `druntime orgs` | the organizations you belong to | | `druntime org use ` | select one | ## Projects and folders [#projects-and-folders] | Command | | | ------------------------------------------------------------------------------- | ---------------------------------------------- | | `druntime projects` | list the projects of the selected organization | | `druntime project create [--repository --branch --root ]` | create a project, optionally with its source | | `druntime project rename ` · `delete ` | rename or delete a project | | `druntime folder create --project

    [--parent ]` | create a folder | | `druntime folder rename` · `move` · `delete ` | rename, move or delete a folder | | `druntime link --project

    [--env ]` | link this folder to a project and environment | | `druntime unlink` | remove this folder's link | ## Environments [#environments] | Command | | | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `druntime env create --project

    [--folder ] [--mode dev\|prod] [--client-org ]` | create an environment; prints its handle | | `druntime env open [--env ] [--once] [--no-clients]` | start or reattach the development session | | `druntime push [--env ] [--yes] [--no-site] [--delete ] [--rename ]` | put the folder's domain live on its environment: publish to a development environment, [release](#releasing-to-production) to a production one. `--yes` confirms removals of what the domain declared (and, for production, the release itself), `--delete` confirms one by name, `--rename` keeps an attribute and its data under a new name, `--no-site` releases the domain without the folder's [Site](#the-folders-site). See [Changing the schema](/docs/data/modeling-data#changing-the-schema) | | `druntime schema deleted` | what was removed from the schema in the last 2 days | | `druntime schema restore ` | bring a removed entity or attribute back, with its data | | `druntime env pull [] [--project

    ] [--no-code]` | bring the environment's code and public configuration into this folder | | `druntime env run -- ` | run a command with the environment's configuration | | `druntime env stop [--env ]` | stop the development machine; data is kept | | `druntime env delete [--yes] [--confirm=] [--force]` | [delete an environment](#deleting-an-environment); restorable for 2 days | | `druntime env restore ` | bring a deleted environment back, with its data | ## Common options [#common-options] | Option | Applies to | | ---------------- | ------------------------------------------------------------------------------------ | | `--env ` | push, run, query and environment commands; otherwise the folder's linked environment | | `--org ` | project, folder and environment commands; otherwise the selected organization | | `--json` | most commands: structured output for scripts and agents | The development session (`druntime`, `env open`) never targets production. `push` releases to production only when the target is explicit — `--env prod-…` or the folder's link — and asks first (`--yes` in CI). ## Releasing to production [#releasing-to-production] ```bash druntime push --env prod-acme-main # asks, then releases druntime push --env prod-acme-main --yes # CI ``` ```text ✔ Built locally src/domain.ts · 3 files · 2 actions ✔ Uploaded build 3f0c1b2e ⟳ Building on the platform building ✔ Built on the platform 52 s ✔ Live https://prod-acme-main.domainruntime.cloud ├─ environment prod-acme-main ├─ build 3f0c1b2e-… ├─ released by ada@acme.dev (you) └─ schema +2 attrs ``` 1. The domain is built and loaded on your machine, the same check a development push makes. 2. Its files are uploaded as the release's source: the folder that holds them keeps its layout, and the packages they import are pinned to the versions you have installed. The release is built from your folder as it is, not from the project's repository. 3. The platform builds it. If the build fails, you see the compiler's messages at your files' paths, then the last lines of the build log; the current release keeps serving. 4. Activating the release applies its schema with the same checks and confirmations as in development, then prints the live URL. ```text ✘ The build failed on the platform; the current release keeps serving. ├─ src/domain.ts:3:19 │ └─ An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled. └─ log ├─ Next.js build worker exited with code: 1 and signal: null └─ Error: Command "npx --yes pnpm@10.6.1 build" exited with 1 ``` Imports must be relative (no path aliases such as `@/lib/x`) and domain files `.ts` or `.json`; `push` names any file that is not. A production release serves actions, so the domain needs at least one. ### The folder's Site [#the-folders-site] When the folder's `package.json` depends on Next.js or Vite, a production push releases that app with the domain as the environment's **Site**, served at `https://.domainruntime.site`. Domain and Site go live, and roll back, together. The app is declared in `.domainruntime/site.json`: ```json { "access": "organization", "root": "app", "enabled": true } ``` | Key | Meaning | | --------- | ------------------------------------------------------------------------------------------------------------------------------ | | `access` | who the Site serves: `organization` (default, members of the environment's organization), `users` (anyone signed in), `public` | | `root` | the app's folder, when it is not the folder itself | | `enabled` | `false` releases the domain alone on every push from this folder (default `true`) | To release only the domain once, pass `--no-site`: ```bash druntime push --env prod-acme-main --yes --no-site ``` ```text ✔ Built locally src/domain.ts · 3 files · 2 actions ◇ Site not released --no-site · the environment's Site stays as it is ✔ Uploaded build 3f0c1b2e ``` A release without a Site does not remove one: the environment's Site keeps serving what was last released with it. Who can release: an **owner or admin** of the organization, signed in as themselves, or the organization's [service key](/docs/auth/service-keys) in CI (`DOMAIN_ORGANIZATION_KEY`, with `--yes`). A member without that role is told so. Every release records who pushed it and who made it live, and `push` prints it (`released by`). The first release of an environment also sets up its runner, the service that runs your actions. The platform gives the runner a credential of its own, valid for that environment only; you never see it, and your organization's key never leaves the platform. `push` says so once: ```text ├─ released by ada@acme.dev (you) ├─ runner provisioned · environment credential ``` ## Deleting an environment [#deleting-an-environment] ```bash druntime env delete dev-orders-main-3f0c1b2e # asks first; --yes in CI druntime env delete prod-acme-main # type the handle to confirm druntime env delete prod-acme-main --confirm=prod-acme-main # CI ``` ```text ✔ Deleted prod-acme-main ├─ mode production ├─ deleted by ada@acme.dev (you) └─ restorable until 2026-09-25 14:02 UTC 2 days, then its data is purged ❯ druntime env restore prod-acme-main brings it back with its data ``` * It stops at once: its URL, actions and queries answer *not found*, its development machine stops, and it leaves every listing. * Its data — records, files, streams and executions — is kept for **2 days**. `druntime env restore ` brings it all back, including a production environment's last release. After 2 days it is purged and cannot be restored. * Only owners and admins of the organization (or its service key) can delete or restore an environment. Who did it, and when, is recorded. * A production environment asks you to type its handle; `--yes` alone never deletes one. While custom domains still point at it, it is not deleted unless you pass `--force`. * Its name is free for a new environment at once; its handle stays reserved until it is purged, so it can be restored. If a deletion is interrupted, the environment has already stopped serving; run the same command again to finish it. ## Data and actions [#data-and-actions] | Command | | | ------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `druntime run [--input '' \| @file.json \| -] [--env ]` | run an action as you and print its output | | `druntime query '' [--env ]` | run a [query](/docs/data/reading-data) as you and print the rows | Both use `--env` or the folder's linked environment, and act as you: an action sees you as `runtime.auth.user`, a query returns what the environment's rules let you read. ```text $ druntime run orders.place --input '{"orderId":"8f1c…","total":10}' ✔ Ran orders.place dev-orders-alice-7f3k9x2m · 412 ms └─ execution 0f5a… { "orderId": "8f1c…" } $ druntime query '{"orders_order": {"$": {"limit": 5}}}' ✔ orders_order 2 rows id total status 8f1c… 10 placed 91d0… 25 paid ``` If the action does not run, `run` says why: an unknown action, the parts of the input that do not fit its schema, or the error your action threw. A lost answer is retried with the same request id, so the action never runs twice. `--json` prints the call's record for `run` and the objects for `query`. ## Releases *(planned)* [#releases-planned] | Command | | | ------------------------------------- | -------------------------------- | | `druntime release rollback --env ` | re-activate the previous release | ## Keys *(planned)* [#keys-planned] | Command | | | -------------------------------------------------------------- | ---------------------------------------- | | `druntime keys create --name [--env \| --project

    ]` | a [service key](/docs/auth/service-keys) | | `druntime keys list` · `revoke ` | list or revoke keys | --- # MCP URL: https://docs.domainruntime.dev/docs/platform/mcp Status: Guide Reviewed: 2026-09-23 Every environment is an [MCP](https://modelcontextprotocol.io) server at `https://.domainruntime.cloud/mcp`. An agent connected to it can read your data and run your domain's actions, as the person or service it signed in as, with the same permissions and the same record as any other caller. There is no adapter to write per agent. ```bash druntime mcp --env=prod-orders-main ``` prints the URL and what to paste in Claude Code, Codex, Cursor, Claude and ChatGPT (`--client=codex` for one, `--json` for scripts). ## What the agent gets [#what-the-agent-gets] | Tool | What it does | | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.`, one per action of the active release | Runs the action as the caller, exactly like [`POST /actions/{name}/invoke`](/docs/platform/admin-http-api#run-an-action): an execution is recorded, `runtime.auth` is the caller, the input is checked against the action's schema. The tool's description is the action's `description`. | | `query` | Reads with InstaQL as the caller: `{"query": {"orders_order": {"$": {"where": {"status": "open"}, "limit": 20}, "items": {}}}}`. Your [permissions](/docs/auth/permissions) decide what comes back. | | `describe` | The release's domains, entities, links and actions, so the agent can find its way before it reads or acts. Also the resource `domainruntime:///schema`. | Write each action's `description` for the agent: what it does, when to use it, what it refuses. ```ts placeOrder: defineAction({ name: "orders.placeOrder", description: "Place an order for a customer. Refuses a customer on credit hold.", input: z.object({ customerId: z.string(), items: z.array(z.object({ sku: z.string(), qty: z.number().int() })) }), output: z.object({ orderId: z.string() }), async execute({ input, runtime }) { /* … */ }, }), ``` ## What the agent sees back [#what-the-agent-sees-back] * A finished function answers its output (as `structuredContent` when the output is an object). * A workflow answers once its run is admitted, with the execution to follow. * Every call names its execution in `_meta["dev.domainruntime/execution"]` (`id`, `requestId`, `status`, `url`): the same execution you see in the platform and with `GET /executions/{id}`. * A refusal is a tool error the model can read: an input that does not fit (with the part that does not), `access_denied`, a failed action with your message. Actions carry no read-only hint yet, so clients ask the person before running one. ## Signing in [#signing-in] A person connects from the client: the client reads `/.well-known/oauth-protected-resource/mcp` from the environment, registers with the DomainRuntime authority, and opens the sign-in page. The agent then acts as that person. A server connects with a key, sent as a bearer: ```toml # ~/.codex/config.toml [mcp_servers.prod-orders-main] url = "https://prod-orders-main.domainruntime.cloud/mcp" bearer_token_env_var = "DOMAINRUNTIME_TOKEN" ``` An organization's [service key](/docs/auth/service-keys) acts as an administrator; a [user token](/docs/auth/users) acts as that user of the environment. Keep keys in the environment of the process, never in a config file. > Interactive sign-in from clients that send an OAuth resource indicator (the official MCP SDKs do) is being enabled on the authority. Until then connect with a key. ## Limits [#limits] * One JSON message per request; no server-initiated stream (`GET /mcp` answers `405`). * A function call waits up to 110 s, then the tool answers `execution_result_timeout`; the execution keeps running and can be followed by its `url`. * Inputs with files answer `execution_files_required`: send files through the [HTTP API](/docs/platform/admin-http-api#files). --- # Platform SDK URL: https://docs.domainruntime.dev/docs/platform/platform-sdk Status: Planned Reviewed: 2026-09-22

    **Planned public API.** `@domainruntime/platform` exists and powers the CLI, but it is not yet on npm and today's calls differ: `init({ auth: { token }, runtime, sandbox })` , `platform.environments.resolve / pull / pushDomain(handle, domain, options)` and `platform.sandboxes.up / pull / run / down` . Errors are `PlatformApiError` , with the code in `message` , plus `status` and `body` .
    The Platform SDK is how you manage DomainRuntime from code: build a product on top of it, give an agent its own environment, run tests in sandboxes, or automate releases in CI. The [CLI](/docs/platform/cli) is built on it. ```bash npm i @domainruntime/platform ``` ## Authenticate [#authenticate] ```ts import { init } from "@domainruntime/platform"; // on a backend or in CI const platform = init({ serviceKey: process.env.DOMAIN_SERVICE_KEY! }); // as a person, reusing the CLI's login const platform = init({ auth: "cli" }); ``` ## Projects and environments [#projects-and-environments] ```ts const project = await platform.projects.create({ name: "Orders" }); const env = await platform.environments.create({ project: project.id, name: "staging", mode: "dev", }); // env.handle, env.url const same = platform.environments.get("dev-orders-staging-3k9x2m7q"); await platform.environments.list({ project: project.id }); await env.delete(); ``` ## Publish a domain [#publish-a-domain] ```ts import domain from "../src/domain"; const result = await env.pushDomain(domain); // result.pushId, result.digest ``` `pushDomain` takes the domain itself: schema, actions and their contracts come from your code, not from a file you maintain by hand. ## Run things [#run-things] ```ts await env.query({ orders_order: { $: { limit: 10 } } }); await env.actions.run("orders.place", { orderId: crypto.randomUUID(), total: 10 }); const { token } = await env.users.mintToken({ id: userId, email }); ``` ## Sandboxes [#sandboxes] ```ts const sandbox = await platform.sandboxes.create({ env: "dev-orders-e2e-4k2m9x1p" }); await sandbox.pushDomain(domain); // … the same API as an environment … await sandbox.destroy(); ``` See [Sandboxes](/docs/environments/sandboxes). ## Releases [#releases] ```ts const release = await env.releases.create(); // build the project's source and activate it await env.releases.rollback(); ``` ## Keys [#keys] ```ts const { key } = await platform.keys.create({ name: "backend", env: env.handle }); await platform.keys.revoke(keyId); ``` ## Errors [#errors] Every method throws `PlatformApiError` with `status` and a stable `code`, such as `environment_not_found` or `catalog_migration_required`. See [Errors](/docs/reference/errors). --- # Errors URL: https://docs.domainruntime.dev/docs/reference/errors Status: Verified Reviewed: 2026-09-23 ```json { "ok": false, "error": "environment_not_found" } ``` Codes are stable; messages may change. Runtime codes use `snake_case`. Codes from the query engine use `kebab-case`. ## Calling actions [#calling-actions] | Code | Status | Meaning | Do | | ---------------------------------------------------------------------- | ------ | -------------------------------------------------------------------- | ----------------------------------- | | `execution_envelope_invalid` | 400 | the body is not `{ requestId, input }`, or `requestId` is not a UUID | fix the request | | `action_contract_validation_failed` | 400 | the input does not match the action's schema; your code did not run | fix the input | | `action_not_found` | 404 | no action with that id in the active release | check the id; publish the domain | | `access_denied` | 403 | you may not run this action or see its execution | | | `environment_release_missing` | 409 | the environment has no active release yet | publish a domain | | `REQUEST_CONFLICT` | 409 | this request id was already used for another action or input | use a new request id for a new call | | `CONTRACT_MISMATCH` | 409 | the contract hash you sent does not match the active release | reload the contract | | `execution_result_timeout` | 504 | the action did not finish within 110 s; it may still finish | follow `GET /executions/{id}` | | `execution_total_file_size_exceeded` · `execution_file_limit_exceeded` | 400 | file inputs over 128 MiB in total, or more than 16 files | | | `execution_file_digest_mismatch` | 422 | a file's content did not match its declared checksum | upload it again | | `execution_not_found` · `run_not_found` | 404 | no execution or run with that id, or not yours | | ## Inside an execution [#inside-an-execution] These do not come back as an HTTP error: the call answers `200` and the execution carries the code. | Code | Meaning | | -------------------------- | -------------------------------------------------------------------------------- | | `action_failed` | your `execute` threw; the execution holds your message, up to 1000 characters | | `execution_output_invalid` | your action returned something that does not match `output`; writes it made stay | | `workflow_failed` | a workflow run failed or was cancelled | ## Authentication [#authentication] | Code | Status | Meaning | Do | | ---------------------------- | --------------------------- | --------------------------------------------------------------- | --------------------------------------- | | `invalid_credentials` | 401 | the token or key is missing, expired or revoked | sign in again, or mint a new user token | | `organization_access_denied` | 403 | the caller is not a member of the environment's organization | check the organization and key | | `session_access_denied` | WebSocket close 4401 / 4403 | the live-query socket's token is not valid for this environment | mint a token for this environment | ## Environments [#environments] | Code | Status | Meaning | Do | | ------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `environment_not_found` | 404 | no environment with that handle, or not visible to you | check the handle and organization | | `environment_release_unavailable` | 502 | the active release could not be loaded | retry | | `catalog_migration_required` | 409 | the organization still uses the previous project model | migrate it in the console | | `managed_build_required` | 410 | direct publishing is not allowed on this environment | release it instead | | `schema_change_refused` | 409 | the stored data does not allow a schema change; `details.issues` lists each one; nothing was applied | fix the data or the schema; see [Changing the schema](/docs/data/modeling-data#changing-the-schema) | | `removal_unconfirmed` | 409 | the publish removes something the domain declared | confirm with `druntime push --yes` or `--delete ` | | `development_workflow_adapter_unavailable` | 409 | workflow actions are not available in development yet | test the workflow in a production release | ## Queries and writes [#queries-and-writes] | Type | Status | Meaning | | ------------------- | ------ | ------------------------------------------------------------------------ | | `permission-denied` | 400 | a permission rule rejected the write | | `validation-failed` | 400 | the query or write is malformed | | `record-not-unique` | 400 | a write would give two objects the same value of a `.unique()` attribute | | `rate-limited` | 429 | this environment is blocked from further requests; contact us | | `timeout` | 429 | the query ran longer than 30 s; add an index or narrow it | | `result-too-large` | — | a live query result exceeded 8 MiB; paginate it | Over HTTP (`POST /query`) these arrive as `{ "ok": false, "error": "data_provider_rejected" }` with the status above. On the live-query socket they arrive as an `error` frame with the type. ## Files [#files] | Code | Status | Meaning | | ------------------------ | ------ | -------------------------------------------------- | | `storage_body_invalid` | 400 | the signed-upload-url request is not a JSON object | | `storage_body_too_large` | 400 | the signed-upload-url request is over 1 MiB | ## From the Platform SDK [#from-the-platform-sdk] The Platform SDK throws `PlatformApiError` with `status`, and today the code in `message`. --- # Limits URL: https://docs.domainruntime.dev/docs/reference/limits Status: Verified Reviewed: 2026-09-22 ## Requests [#requests] | | Limit | | ------------------------------ | --------------------------------------------------------------------------------------------------- | | Query request body | 4 MiB | | Action call body | 8 MiB (files are uploaded separately) | | Query response over HTTP | 16 MiB | | One live query result | 8 MiB; a larger result is refused with `result-too-large` and your other subscriptions keep working | | Query time | 30 s, then `timeout` | | Waiting for an action's result | 110 s per request, then `execution_result_timeout` | ## Actions and files [#actions-and-files] | | Limit | | ------------------------------------ | ------------------------------------------------------------------------------- | | Action file inputs | 32 MiB per file, 16 files, 128 MiB per call | | Error message returned to the caller | 1000 characters | | Execution lease | 30 s; an attempt that stops renewing is superseded, and its writes are rejected | | User token claims | 1 KB | | Streams | written to storage every 1 MiB and when they finish | ## Environments [#environments] | | Limit | | ------------------------------------- | ---------------------------------------------------- | | Development machine | stops after 30 minutes idle; lives at most 7.9 hours | | Development machines per organization | 2 running at a time | | Environment handle | 63 characters | | Folder nesting | 8 levels | | Region | São Paulo, AWS `sa-east-1` | Limits on the number of environments and requests per organization depend on your plan. --- # Status URL: https://docs.domainruntime.dev/docs/reference/status Status: Verified Reviewed: 2026-09-23 These docs describe the product we are building, and every page says how much of it you can use today: * **Verified** — copy the first code block and it runs in production today. * **Guide** — the mechanism runs today; some helpers shown are planned and marked inline, with a *Today* block. * **Planned** — designed, not available yet. ## Running in production [#running-in-production] | | | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Runtime | one Rust service serving every environment: queries, live subscriptions, permissions, actions, executions, files, streams | | Data | PlanetScale Postgres in `sa-east-1`, a primary and two replicas | | Files | Amazon S3 in `sa-east-1`, private, encrypted, versioned | | Query language | `where` with equality, `$ne`, `$in`, `$gt`/`$gte`/`$lt`/`$lte`, `$isNull`, `$like`/`$ilike`, `and`/`or`, link paths; `order`; `limit`/`offset`; cursors (`first`/`after`, `last`/`before`, top level); nested links; `fields` | | Permissions | per-entity CEL rules for `view`, `create`, `update`, `delete`, `link`, `unlink`; `auth`, `data`, `newData`, `data.ref`, `auth.ref`, `ruleParams`, `bind`, `fields`, `$default` | | Actions | Zod input and output checked by the runtime; execution records; one run per request id; the caller captured at admission and read with `runtime.auth`; reads and writes as the caller, in development and production | | Schema on publish | applied the same way in development and production, checked against stored data; confirmed removals are soft-deleted and restorable for 2 days | | Workflows | durable runs in production releases, started and followed through the runtime | | Identity | people, organizations and one service key per organization; user tokens per environment, validated on every request | | Projects and folders | organizations, projects, folders and environments in the console | | Development loop | `druntime` publishing on save to a development environment (CLI not on npm yet) | | HTTP API | every route on the [HTTP API](/docs/platform/admin-http-api) page | ## Built, not yet public [#built-not-yet-public] | | Missing to be public | | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | | CLI (`druntime`): development session, projects, folders, environments, releases with `push`, `run`, `query` | npm release; `query` and input issues also need the kernel release with `/query?format=objects` | | Platform SDK (`@domainruntime/platform`) | npm release, and the public shape on its page | | Testing helpers (`@domainruntime/testing`) | npm release | | Sandboxes | self-service access and per-organization limits | | Workflows inside DomainRuntime's own runtime | production adoption | ## Designed, not built [#designed-not-built] | | Page | | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `runtime.tx`, `runtime.query` inside actions (today: `runtime.db()`) | [Actions](/docs/data/actions) | | `@domainruntime/react`: `init({ domain })`, `db.useQuery`, `db.actions..`, `db.auth` | [Reading data](/docs/data/reading-data), [Users](/docs/auth/users) | | Action scopes: who may run an action, by permissions, roles or custom scopes (today any signed-in user can run any action) | [Actions](/docs/data/actions#who-may-call-an-action) | | Workflow actions in development environments | [Local development](/docs/environments/local-development) | | `withSchema` without empty `links` and `rooms` | [Modeling data](/docs/data/modeling-data) | | `withPermissions` in the domain | [Permissions](/docs/auth/permissions) | | `db.storage`, `runtime.files`, `runtime.streams`, `db.streams` | [Files](/docs/data/files), [Streams](/docs/data/streams) | | Error codes of your own on thrown errors; conditional writes | [Error handling](/docs/data/error-handling), [Guarantees](/docs/data/guarantees) | | `env.users.mintToken`; several scoped service keys and rotation | [Users](/docs/auth/users), [Service keys](/docs/auth/service-keys) | | `druntime release rollback`, `druntime keys` | [CLI](/docs/platform/cli) | | The project layout generated by `create-app` | [Project structure](/docs/introduction/project-structure) | ## Later [#later] Presence, rooms and cursors for collaborative apps; offline support; server-side rendering helpers; scheduling an action for later.