# Agentic experiences

URL: https://docs.domainruntime.dev/docs/agents/agentic-experiences
Status: Guide
Reviewed: 2026-09-24



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

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         <Conversation> … <Composer>                   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).

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

## 2. Exact context [#exact-context]

A **Context** is one conversation's history, keyed by a name you choose (for example `orders:thread:<id>`). 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.                                                                                                                                                         |

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

## 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 (
    <Conversation>
      <ConversationContent>
        {chat.events.length === 0 && <ConversationEmpty>Ask about your orders.</ConversationEmpty>}
        {chat.reactions.map((reaction) => (
          <Turn key={reaction.id} reaction={reaction} onSuggest={send} />
        ))}
      </ConversationContent>
      <Composer onSubmit={send} busy={busy} onStop={chat.stop} placeholder="Ask about your orders" />
    </Conversation>
  );
}

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 && <Message from="person"><MessageContent>{asked}</MessageContent></Message>}
      <Message from="agent">
        {steps.length > 0 && (
          <MessageSteps>
            {steps.map((s) => <MessageStep key={s.id} state={stateOf(s)}>{labelOf(s)}</MessageStep>)}
          </MessageSteps>
        )}
        {answer && <MessageContent>{answer.reply}</MessageContent>}
        {answer?.suggestions.length ? (
          <Suggestions>{answer.suggestions.map((q) => <Suggestion key={q} onClick={() => onSuggest(q)}>{q}</Suggestion>)}</Suggestions>
        ) : null}
      </Message>
    </>
  );
}
```

`textOf`, `isActionCall`, `stateOf`, `labelOf` and `replyOf` read events. `@domainruntime/context/react` exports helpers for this: `normalizeContextEventParts`, `getActionPartInfo`, `getPartText`, `getReasoningText` and `getSourceParts`.

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

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

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

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