---
name: mindset-uiless
description: Use when driving a Mindset (mindset.ai) AI agent from a web app's own chat UI with the UI-less client, createAgentConversation. Covers the backend session endpoint, streaming replies from events, rendering transcript() and restored history, resume, new conversations, conversation lists and switching, quick replies, capability cards and widget summaries, messages the app sends for the page, and run errors.
metadata:
  title: Drive an agent from your own UI
  blurb: Drives an agent from your own chat UI, without the ready-made element.
  group: In your app
  order: 2
  docs: the-ui-less-client, use-your-own-ui, events-reference, put-an-agent-on-your-page, sessions-and-trust, work-from-claude-code
  checked: d671041b6
  updated: 2026-10-21
---

# Drive a Mindset agent from your own chat UI

When you finish, the app's signed-in users talk to a Mindset agent inside a chat interface the app draws itself: streamed replies, restored history, a conversation picker and tappable quick replies. Mindset draws nothing. If the Mindset chat panel would fit the page, the `mindset-embed` skill is less work.

## Mindset in brief

Mindset is a hosted platform for building, running and governing AI agents. An **agent** is configuration, not code: a prompt, a model, and the resources it may use. Those resources are **scripts** (steps that must wait or happen in order), **functions** (fixed steps with one right answer), **widgets** and **connections** (the only way data enters or leaves). People manage all of it in the Studio, a web app, or through Mindset's MCP servers.

## Where you are building: host, org, environment

Every Mindset call names three things. Get all three from the person before you write code or call a tool. Never guess or invent them.

| Value | What it is | Where the person finds it |
|---|---|---|
| **Host** | The Mindset deployment their org lives on, for example `https://eu.mindset.ai` | The address bar when they're signed in to the Studio, or an agent's **Embed** tab |
| **Org slug** | Their company's account. The org is the isolation boundary: members, model keys, settings and audit sit here, and one org can never see into another | An agent's **Embed** tab, or the address under **Settings → Systems → Connect an AI client** |
| **Environment slug** | A named partition inside the org, such as Sandbox, Test or Production | An agent's **Embed** tab, or the same address. The **Environment** switcher in the Studio shows which one is current |

How environments behave:

- **Agents, scripts, functions, connections, triggers, schedules, conversations, runs and API keys each belong to exactly one environment** and can't see anything in another.
- **Every org starts with one environment, Sandbox, whose slug is `original`.** A slug never changes, even when the environment is renamed. Use the slug, never the display name.
- **Nothing copies between environments.** To promote a change from Test to Production, make the same change again in Production.
- **Don't create an environment to hold your work.** Environments can't be deleted, and they're for stages such as Test and Production, not for projects. Work in the environment the person names. Create one only if the person asks you to.
- **Members, model keys and org settings are shared by every environment.** Changing an org setting changes it everywhere.

## Credentials for code in an app: the org API key

Your app's backend authenticates to Mindset with an **org API key**.

- **An org admin creates it in the Studio under Settings → API keys.** It's shown once, when it's created. Ask the person to put it in their secret manager or environment. Never ask them to paste it to you.
- **A key works only in the environment it was created in.** Test code needs a Test key and production code needs a Production key.
- **It's admin-grade. It stays on the server.** Read it from an environment variable. Never put it in frontend code, a response body, a log line or a committed file.
- **Send it in the `x-api-key` header**, not in `Authorization`.

Keep one set of values per deployment stage, in the app's configuration:

```sh
MINDSET_HOST=https://eu.mindset.ai   # the person's host
MINDSET_ORG_SLUG=acme
MINDSET_ENV_SLUG=test                # the environment this deployment talks to
MINDSET_ORG_API_KEY=                 # secret: a key created in that same environment
```

A request with no `x-api-key` header gets `401`. A wrong org slug, a wrong environment slug, an agent that isn't in that environment, and a key without admin scope all return `404` `not_found`. The message says whether the org, the environment or the agent didn't resolve. An org or environment the key doesn't belong to reads exactly like one that doesn't exist, so nobody holding a key can probe for others. When you get a `404` during setup, check all four against the agent's **Embed** tab before anything else.

**Read the platform's own guide first.** If you hold the `mindset-admin` MCP server, call `get_information` with no arguments. When its index lists `embedding-readiness`, read that topic before you write any code: its checklist says whether this page or app can host an agent at all and what will snag, so tell the person what it finds. Then read `embedding` (sessions, setup, loading the script), `page-channels` (page tools, situational awareness, pass-through values) or `embedding-diagnosis` (error codes and fixes) for the work in hand. Those topics come from the deployment you are connected to, so they match its release: where they differ from this skill, follow them. When the index doesn't list them, the steps below apply as written.

## Ask the person for

- The host, org slug and environment slug for each deployment stage, and which environment each one talks to. The **agent handle**, from the agent's **Embed** tab, for example `billing-help`.
- Confirmation that the agent has an active version and **Embedded in your own product** ticked under its availability. On the admin MCP server, `get_agent` shows that box as `availability.embed`; its `status` gates nothing unless it reads `archived`.
- Whether the agent has a **script**. A scripted agent's greeting, cards, phases and questions appear only in the `<mindset-agent>` element. On the UI-less client it runs without them. If it has a script, stop and suggest the `mindset-embed` skill.
- That an org API key for each environment is in the app's secret store, how the backend knows the signed-in user, and where the app can store one conversation ID per user (its own database is best).

## Step 1: a session endpoint on the backend

The browser never holds the org API key. Your backend exchanges it for a short-lived session credential that runs one agent for one user, and the page fetches that from your backend.

Add one authenticated route to the app's backend, for example `GET /api/mindset-session`. It must know who the signed-in user is. It calls:

```
POST <host>/api/v1/orgs/<org-slug>/envs/<environment-slug>/agent-sessions
x-api-key: <org API key>
content-type: application/json
```

The body is strict JSON. Any field not listed here is a `400`.

| Field | Required | Notes |
|---|---|---|
| `user.email` or `user.externalId` | exactly one | Use `email` if the person might also use Mindset directly. Use `externalId` (letters, numbers, `_`, `-`, up to 100 characters) if they exist only in the app. Pick one form per user and keep it, or they become two users |
| `agent` | yes | The agent's handle or ID, from its **Embed** tab |
| `createUserIfNeeded` | in practice | Send `true`. Without it every first-time user gets a `404` |
| `attribution` | no | Your own string labels for the session: up to 16 keys |

An Express 5 route (Node 18 or later; any server stack works the same way). `requireSignedInUser` stands for the app's own sign-in middleware. On Express 4, wrap the body in `try`/`catch` and answer `502`, because Express 4 doesn't catch an `async` handler's throw:

```javascript
app.get("/api/mindset-session", requireSignedInUser, async (req, res) => {
  const r = await fetch(
    `${process.env.MINDSET_HOST}/api/v1/orgs/${process.env.MINDSET_ORG_SLUG}/envs/${process.env.MINDSET_ENV_SLUG}/agent-sessions`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.MINDSET_ORG_API_KEY,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        user: { email: req.user.email },
        agent: "billing-help",
        createUserIfNeeded: true,
      }),
    },
  );
  res.set("cache-control", "no-store");
  res.status(r.status).type("application/json").send(await r.text());
});
```

Rules for this route:

- **Return Mindset's response body unchanged.** It holds `session` and `enabledFeatures`. Passing only `session` drops `enabledFeatures`, and the agent silently loses release features.
- **Send `cache-control: no-store`.** Never cache, log, decode or store the session. Keep it in memory in the page.
- **The Mindset route has no CORS headers.** Only a server can call it.
- **One session per agent.** Two agents on one page need two calls.
- **No renewal code.** When the SDK needs a new session it calls the page's `getSession` again, which calls this route again. An unused session expires after about 20 minutes.

Errors from the session call. Every error body is JSON with a `code` and a `message`.

| Status | `code` | Meaning |
|---|---|---|
| `400` | `validation_error` | A field is extra or malformed, both or neither of `email` and `externalId` were sent, or the agent has no published version to embed |
| `403` | `agent_not_available_here` | **Embedded in your own product** isn't ticked for the agent in the Studio |
| `404` | `not_found` | Wrong org slug, wrong environment slug, an agent not in that environment (or archived, or personal), a key without admin scope, or an unknown user without `createUserIfNeeded: true` |
| `409` | `no_version_in_force` | The agent has configuration versions but none is active |
| `409` | `user_not_active` | The user was invited but hasn't finished signing up, or was removed |

## Step 2: load the client and create the conversation

The client is an ES module served from the person's Mindset host. There is no npm package. In a bundled app, import it at runtime and tell the bundler not to resolve it: `await import(/* @vite-ignore */ UILESS_URL)`.

```javascript
import { createAgentConversation } from "https://eu.mindset.ai/sdk/mindset-agent-uiless.js";

let chat;
let off = () => {};

function createConversation(conversationId) {
  const c = createAgentConversation({
    agent: "billing-help",
    getSession: async () => {
      const r = await fetch("/api/mindset-session", { credentials: "include" });
      if (!r.ok) throw new Error("session " + r.status);
      return r.json();
    },
    ...(conversationId ? { conversationId } : {}),
  });
  off = c.on(handleEvent); // step 3
  return c;
}

chat = createConversation(await loadConversationId()); // when the user opens the chat; the ID your backend stored for them, if any
```

- **Options:** `agent` (required, handle or ID), `getSession` (required), `conversationId` (resume). For `extendedSituationalAwareness`, see the `mindset-page-tools` skill.
- **`createAgentConversation` calls `getSession` at once**, and each call creates a real session. Create the conversation when the user opens the chat, which on a page that is only the chat means when it loads. In React, create it once in a ref guarded against StrictMode's double effect, or you create two sessions.
- **`getSession` returns `r.json()`**, the whole body. Throw on a non-2xx status. A throw is retried later. A bad return value is a `session_error` event.
- **Commands:** `send(text)` for a turn the user typed; `sendMessage(text, { silent: true })` for an instruction from the app (the agent acts on it, the user sees only the reply, even after a reload); `sendMessage(text, { from: "page" })` to tell the agent something that happened on the page, such as "The user opened invoice 4471" (it stays in the transcript, step 3). Each returns a promise of the reply text, settled when the turn ends.
- **Also:** `stop()`, `isAgentBusy()`, `retry()`, `reset()`, `widgetAction(action)`, `listConversations`, `switchConversation`, `setPageTools`, `setSituationalAwareness`, `setPassthroughParams`. **State:** `transcript()`, `messages()`, `conversationId`, `enabledFeatures()`. Subscribe with `on(listener)`, which returns an unsubscribe function.
- **Turns run one at a time.** Sending while a turn runs cancels the running one. Disable the composer while a turn runs, or show a **Stop** button that calls `stop()`. Before the app sends anything on its own, check `isAgentBusy()` so it doesn't cut off an answer.
- **Don't use members that aren't listed here**, even if your editor offers them. They're internal and change without notice. The one other export you may need is `readPageMessage` (step 3).

## Step 3: render from events

```javascript
let reply = null;
let runs = 0;

function handleEvent(event) {
  switch (event.type) {
    case "history_settled":
      clearLog(); // also fires after switchConversation, with that conversation's history
      for (const m of event.messages) drawMessage(m.role, m.content, m.widget);
      openComposer();
      break;
    case "run_started":
      runs += 1;
      break;
    case "text_delta":
      reply ??= startAgentBubble();
      reply.append(event.content);
      break;
    case "widget-payload": // spelled with a hyphen, unlike the others
      drawWidget(event.kind, event.doc, event.summary);
      reply = null; // text after a widget starts a new bubble below it
      break;
    case "complete":
      reply = null;
      break;
    case "run_finished":
      setComposerBusy(false);
      break;
    case "run_error":
      showNotice(event.aborted ? "Stopped" : "Something went wrong");
      reply = null;
      setComposerBusy(false);
      break;
    case "conversation_id":
      saveConversationId(event.conversationId); // PUT to your backend, per user
      break;
    case "session_error":
      console.error("Mindset session", event.code, event.message);
      break;
    default:
      break; // new event types appear; ignore them
  }
}

function sendText(text) {
  drawMessage("user", text);
  setComposerBusy(true);
  const before = runs;
  chat.send(text).catch(() => {
    // A turn that fails before its run starts (no session, no network) sends no run_error.
    if (runs === before) { showNotice("Couldn't reach the assistant. Try again."); setComposerBusy(false); }
  });
}
```

The named functions are the app's own UI code. Rendering rules:

- **Join `text_delta.content`** for the streaming reply; `complete.response` holds the final text. `run_started` opens a turn and `run_finished` or `run_error` closes it. Don't rely on any other order.
- **Render from `transcript()` or `history_settled`, never from `messages()`.** `messages()` is what the model sees: silent turns and raw tool traffic. `transcript()` re-reads widgets on each call, so call it when state changes and keep the result.
- **A message's `role` is `user` or `assistant`, and its `content` is the text to show.** A turn that showed a widget carries it on `widget` (`{ doc, summary, kind }`, sometimes `ref` and `block`), and its `content` is the widget's summary. A message sent with `{ from: "page" }` arrives wrapped in a tag: `readPageMessage(m.content)`, exported by the same module, returns `{ kind: "event", text }` for it (and `undefined` for an ordinary message), so draw `text` as a page note, not as the user's words.
- **A stopped turn is `run_error` with `aborted: true`.** Show it as stopped, not as an error.
- **Tool rows are optional.** `tool_start` and `tool_end` pair by `toolCallId`. When a tool has labels, both carry `label: { doing, done }`. Otherwise show `toolName`.

## Step 4: draw widgets, quick replies and cards

Every agent can offer quick replies on its own; nothing needs setting up. Branch on `kind`. Only two kinds have a shape you can build on:

| `kind` | `doc` | Draw |
|---|---|---|
| `quick_replies` | `{ replies: [{ label, value? }] }` | A button per reply showing `label`. A tap sends `value`, or `label` when there's no `value`, with `sendText()` |
| `capability_cards` | `{ cards: [{ title, description, prompt }] }` | A card per entry. A tap sends `prompt` with `sendText()` |
| anything else (`chart`, `callout`, `table`, `card`, `badge`, `stat`, `widget`) | A widget tree that isn't a public contract | `summary` as plain text |

```javascript
function drawWidget(kind, doc, summary) {
  if (kind === "quick_replies" && Array.isArray(doc?.replies)) {
    drawButtons(doc.replies.map((r) => ({ label: r.label, text: (r.value ?? r.label).trim() })));
  } else if (kind === "capability_cards" && Array.isArray(doc?.cards)) {
    drawButtons(doc.cards.map((c) => ({ label: c.title, detail: c.description, text: c.prompt })));
  } else if (summary) {
    drawSummary(summary);
  }
}
```

- **Never parse `doc` for the other kinds.** Mindset serves no renderer for it, the SDK types it `unknown`, and its format can change on any release. If the app must show charts or tables, use the element, or ask the agent's author to have the agent answer in text.
- **A tap is an ordinary user message.** Send it with `sendText()`, which draws it as the user's turn, and disable the row after a tap or once the user sends anything else. Draw restored widgets the same way, `drawWidget(m.widget.kind, m.widget.doc, m.widget.summary)`, with only the last message's row tappable.

## Step 5: keep, start and switch conversations

**Resume.** Store the ID from every `conversation_id` event against the user, and pass it as `conversationId` next visit. Keep the composer closed until `history_settled` arrives. It always fires once per conversation, with an empty list if there was no history or the read failed (after at most 15 seconds). If the stored ID can't be used, the SDK starts fresh and sends `conversation_id` again with `replacedBecause: "discarded"`. Always store the latest ID.

**New conversation.** There's no command for it. Stop the current one and create another without an ID:

```javascript
function startNewConversation() {
  off(); // otherwise the old conversation's last events land in the new one's view
  chat.stop();
  saveConversationId(null); // otherwise a reload before the first turn reopens the old one
  chat = createConversation();
}
```

Never use `reset()` for this. It clears the agent's memory in the page but keeps the conversation ID, so later turns land in the old conversation.

**List and switch.**

```javascript
const { conversations, nextCursor } = await chat.listConversations({ limit: 20 });
// each row: conversationId, title (may be null), updatedAt; newest first
await chat.switchConversation(picked.conversationId);
```

- Pass `nextCursor` back as `cursor` for the next page; `null` means no more. The list holds only this user's conversations with this agent.
- `switchConversation` stops any running turn, fires `conversation_id` with `replacedBecause: "switched"` (store that ID), then fires `history_settled` with the new conversation's messages. The handler in step 3 clears and redraws on it. If the load fails, it throws and stays on the current conversation. A running turn is still stopped.

## Rules

- Never put the org API key in frontend code, a response, a log or a committed file. Never cache, log or store the session body: keep it in memory.
- Switch on `event.type` and ignore unknown types. Branch on `reason` and `code`, never on message text.
- Before offering a retry after `run_error`, check `event.effects`. Entries mean tool calls already changed something, and a retry could repeat a payment or a booking.
- If the site sends a Content-Security-Policy, allow the Mindset host in `script-src` and `connect-src`. To let the agent act on the page or know what the user is looking at, use the `mindset-page-tools` skill.

## Check it works

1. Call the session route while signed in: expect `200` with a JSON body containing `session`. Open the chat: no `session_error` arrives, and `history_settled` arrives with an empty list.
2. Send a message. Text streams in and the turn ends with `complete`. A `conversation_id` event arrives after that first turn. Check the ID was stored.
3. Reload. `history_settled` arrives with the earlier turns. Then start a new conversation, send a turn, and see a different ID stored; `listConversations` shows both.

## When it fails

| What you see | Cause and fix |
|---|---|
| The import fails | Wrong host, the bundler tried to resolve the URL (use `/* @vite-ignore */` or the equivalent), or the CSP blocks the host |
| `session_error` `invalid-session-credential` | `getSession` returned something other than the response body: a `Response`, nothing (a missing `return`), or a wrapper around it. Return `r.json()` unchanged |
| `session_error` `expired-session-credential` | A cached session was reused. Fetch a new one on every `getSession` call |
| A failed turn shows nothing | It failed before its run started, so no `run_error` came. Handle the `send()` rejection as `sendText` does |
| An old transcript comes back after "new conversation" | The code called `reset()`, or kept the old stored ID. Follow step 5 |
| Every visit starts fresh | The `conversation_id` event isn't stored, or the stored ID isn't passed back |
| `run_error` with `reason` `tool_schema` or `unavailable` | Don't retry. The agent needs fixing in the Studio |
| `complete` carries `bound` | The turn stopped at a limit. Show the reply and let the user continue |

## Read more

Fetch these as Markdown when you need more than this skill gives you. The `get_information` topics come first, then this skill: if a page disagrees with either, follow them, not the page.

- https://docs.mindset.ai/docs/the-ui-less-client.md: read for every command, option and state member.
- https://docs.mindset.ai/docs/events-reference.md: read for every event and field.
- https://docs.mindset.ai/docs/use-your-own-ui.md: read for a complete page that draws quick replies and cards.
- https://docs.mindset.ai/docs/errors-and-resilience.md: read for every failure and what the code should do, with a full `run_error` handler.
- https://docs.mindset.ai/docs/put-an-agent-on-your-page.md: read for loading the module in a bundled React or Vue app.
