Agentic experiences
An agent that acts on a concrete domain, as the person using it, with exact control of what it sees: the domain, the context, the turn, live state and the chat.
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
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 modelYou have a working chatbot on your domain at step 6.
1. The 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).
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
descriptionand 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). The same
shipruns 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 (
messagehere), stored in your environment and readable under your permissions.
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
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:
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
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.
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.authis whoever calledorders.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.maxRoundscaps model ↔ action rounds per turn. Each round resends the selection and the action definitions, so it's also a cost limit (costs).outputmakes the answer typed (the reply, suggested next questions), and it's the action's own output.actionsis the exact list this agent can call. A change goes through a request the person confirms (step 7).- Long turns. An action call waits up to 110 s. If a turn can take longer, the action starts a workflow that runs the session, and the browser follows it live (step 5).
4. The route
The route has one job: call the business action as the signed-in person. It never opens the Context itself.
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). The browser sees the message, each action the agent runs and the answer arrive live while the call is in flight (step 5).
5. Live state: useContext
useContext reads the Context from the environment, live, and sends new messages to your route.
"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). 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. |
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
The registry's conversation component renders the state and nothing else. It doesn't fetch, stream or decide. Install it with the shadcn CLI:
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). |
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:
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.
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
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.
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).
8. 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.
maxRoundsbounds 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
- Actions: the contract every tool the agent uses is built on.
- Permissions: what the agent can read, which is exactly what the person can read.
- Modeling data: the schema the agent works on.