Skip to content

8. Handle commands

Context flows into the conversation as vars. Commands are the return path: the agent’s code sends a small JSON payload to the browser mid-turn, and your page decides what to do with it. This is what turns an agent that explains where a setting lives into one that takes the user there.

Inside any script the agent writes, or inside a library function it calls:

await meta.urai.sendCommand(meta.vars.thread_id, {
command: "navigate",
url: "/settings/billing",
});

The first argument is the thread id, which is always present in meta.vars. The second is your payload, relayed to the page verbatim.

Put this in a library function rather than leaving it to the agent to compose. An exported navigateTo(path) is called correctly every time; a free-form instruction to “send a navigate command” produces a different shape each turn:

// index.ts in your library
/** Navigate the user's browser to a path in the Acme app. */
export async function navigateTo(path: string) {
await meta.urai.sendCommand(meta.vars.thread_id, { command: "navigate", url: path });
}

Then document it in AGENT.md so the agent knows when to use it:

- `navigateTo(path)`: move the user's browser to a path in the app. Use it
after telling the user where you are sending them, never instead of telling
them.

The widget surfaces each command as an event:

<UraiChatWidget
widgetToken="wgt_..."
userId={user.id}
onCommand={(payload) => {
const cmd = payload as { command?: string; url?: string };
if (cmd.command === "navigate" && typeof cmd.url === "string") {
navigate(cmd.url);
}
}}
/>

With the script tag:

UraiChat.on("command", (e) => {
const cmd = e.command;
if (cmd?.command === "navigate" && typeof cmd.url === "string") {
router.push(cmd.url);
}
});

The payload is whatever the code sent, relayed without inspection, and the code was written by a model. Treat it as untrusted input.

Match on an allowed set rather than acting on whatever arrives:

const ACTIONS = {
navigate: (payload) => {
// Same-origin, path-only. No absolute URLs from a command.
if (typeof payload.url === "string" && payload.url.startsWith("/")) {
router.push(payload.url);
}
},
refresh: () => queryClient.invalidateQueries({ queryKey: ["invoices"] }),
highlight: (payload) => {
if (typeof payload.elementId === "string") {
document.getElementById(payload.elementId)?.scrollIntoView();
}
},
};
UraiChat.on("command", (e) => {
const handler = ACTIONS[e.command?.command];
if (handler) handler(e.command);
});

Two rules worth keeping: never pass a command value into eval, innerHTML, or a redirect that accepts absolute URLs, and never let a command perform an action the visitor could not perform themselves. The agent runs on the server, but the command arrives in a session that belongs to the user.

Navigation, refreshing a view the agent just changed, opening a modal, scrolling to and highlighting an element, and prefilling a form the user still submits.

They are a UI signal, not a data channel. Payloads are capped at 64 KB, and anything larger belongs in the reply text.

Commands are delivered only while the turn’s stream is open. A command sent long after a function returns, or after the agent has already answered, can be dropped. Fire them during the work, not as an afterthought.

Every open widget for the conversation receives its own copy, so a visitor with two tabs open gets the command in both. Make your handlers idempotent, and be careful with anything that would be jarring twice.

Ask the agent to take you somewhere, such as “show me my invoices”. Your app should navigate, and the reply should also say what happened. If nothing moves, log the raw event first; a payload shape you did not expect is the usual cause.

Next: go live.