# Actions

URL: https://docs.domainruntime.dev/docs/data/actions
Status: Guide
Reviewed: 2026-09-23



<div className="docs-note">
  **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.
</div>

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.

<details>
  <summary>
    Today
  </summary>

  ```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.
</details>

## 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.

<details>
  <summary>
    Today
  </summary>

  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 } }
  ```
</details>

## 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.

<div className="docs-note">
  **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`

  .
</div>

## 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.
