# The UI-less client

> Reference for driving a Mindset agent from your own interface. Loading, commands, events, state, conversation lists and page tools.

Use this page to run a Mindset agent behind an interface you build yourself. The UI-less client gives you the same agent and conversation as the drop-in element, with typed events in and commands out, and no Mindset UI at all.

Choose it when the conversation has to live inside an interface you already have, or when the agent's output drives something other than a chat transcript. If the Mindset chat panel fits, use [the mindset-agent element](https://docs4.mindset.ai/docs/sdk/the-mindset-agent-element) instead. It is a thin shell over this client.

## Load it

The client is an ES module served from your Mindset host. There is nothing to install. Import it from a module script or a dynamic `import()`.

```javascript
import { createAgentConversation } from "https://YOUR-MINDSET-HOST/sdk/mindset-agent-uiless.js";
```

The module also exports `isSdkEvent`, `SDK_EVENT_TYPES`, and `version` and `commit` for the build you loaded.

## Create a conversation

```javascript
const chat = createAgentConversation({
  agent: "billing-help",
  getSession: async () => {
    const r = await fetch("/api/session"); // your backend endpoint
    if (!r.ok) throw new Error("Could not start a session");
    return r.json();
  },
});
```

| Option | Type | What it does |
|---|---|---|
| `agent` | string | Required. The agent's handle or ID |
| `getSession` | function | Required. Returns the JSON body your backend got from the [session create call](https://docs4.mindset.ai/docs/sdk/create-a-session-for-your-users), or a promise of it |
| `conversationId` | string | Resume this conversation. Leave it out for a fresh one |
| `initialMessages` | array | Earlier messages to seed the agent's memory with, as `{ role, content, silent }` objects. Most integrations use `conversationId` instead |

`createAgentConversation` calls `getSession` straight away, not on the first message. Each call creates a real session on your backend, so create the conversation when the user can actually use it, for example when they open the chat panel.

The SDK reads the session, renews it while the page is open, and calls `getSession` again when it can't renew. You write no refresh logic. The session carries your org, environment and Mindset host, so there's nothing else to configure.

If `getSession` returns something the SDK can't use, you get a `session_error` event straight away. If `getSession` throws (your backend was briefly unreachable), there's no event, and the SDK tries again the next time it needs a session.

## Drive the conversation

| Command | What it does |
|---|---|
| `send(text)` | A turn the user typed. Resolves to the agent's reply text |
| `sendMessage(text, options)` | A turn your app sends. Pass `{ silent: true }` to keep it out of the transcript while the agent still reads it. Resolves to the reply text |
| `widgetAction(action)` | Send a rendered widget's action back to the agent as a silent turn. A string goes as is. Anything else is sent as JSON with a line saying the user interacted with a widget. Resolves to the reply text |
| `stop()` | Cancel the turn in progress. The model call is cut and spend stops. Safe when idle |
| `retry()` | Run the last turn again, replacing its reply. Does nothing if there isn't one |
| `reset()` | Clear the history and start fresh with the same agent |

Turns run one at a time. Sending a new turn while one is running cancels the running one.

The promise from `send`, `sendMessage`, `widgetAction` and `retry` rejects when the turn fails or is stopped. Most interfaces render from events and only catch the rejection:

```javascript
chat.send("Why was I charged twice?").catch(() => {
  // The run_error event already told the listener what happened.
});
```

A stopped turn ends with a `run_error` event carrying `aborted: true`. Show it as "stopped", not as an error.

## Listen

```javascript
let reply = "";

const unsubscribe = chat.on((event) => {
  switch (event.type) {
    case "text_delta":
      reply += event.content;
      renderReply(reply);
      break;
    case "complete":
      reply = "";
      break;
    case "run_error":
      showNotice(event.aborted ? "Stopped" : event.message);
      reply = "";
      break;
    case "conversation_id":
      saveConversationId(event.conversationId);
      break;
    default:
      break; // ignore anything you don't recognize
  }
});
```

`renderReply`, `showNotice` and `saveConversationId` stand for your own code.

`on()` returns a function that unsubscribes. A listener added after construction still receives `conversation_id`, `history_settled` and `session_error` if they already fired.

Two kinds of event arrive here: the agent runtime's events and the SDK's own events about the conversation. `isSdkEvent(event)` tells them apart. Both are in the [events reference](https://docs4.mindset.ai/docs/sdk/events-reference). Keep a `default` branch, because new event types can appear.

## Resume a conversation

The round trip is the same as on the element.

1. After the user's first completed turn, a `conversation_id` event arrives. Store `event.conversationId` against that user in your backend.
2. On their next visit, pass it as `conversationId` to `createAgentConversation`.
3. Wait for `history_settled`, draw `event.messages` as the earlier transcript, then open your input box.

`history_settled` always fires once per conversation: with the restored messages, with an empty list, or with an empty list after a failed or stalled read (it gives up after 15 seconds). So you can safely keep your input closed until it arrives. Set your own shorter limit for how long you make the user wait.

If the ID you supplied can't be used, the SDK starts a fresh conversation and sends `conversation_id` again before any turn, with `replacedConversationId` and `replacedBecause: "discarded"`. Storing whatever arrived last keeps you correct.

## Show earlier conversations

```javascript
const { conversations, nextCursor } = await chat.listConversations({ limit: 20 });

for (const c of conversations) {
  addPickerRow(c.conversationId, c.title ?? "Untitled", c.updatedAt);
}

async function onPick(conversationId) {
  await chat.switchConversation(conversationId);
}
```

`addPickerRow` and `onPick` stand for your own picker code.

| Command | What it does |
|---|---|
| `listConversations({ limit, cursor })` | This user's conversations with this agent, newest first. Each has `conversationId`, `title` (may be `null`) and `updatedAt`. The server caps `limit`. Pass `nextCursor` back as `cursor` for the next page. A `null` cursor means there are no more |
| `switchConversation(id)` | Load that conversation's history and continue it. Fires `conversation_id` with `replacedBecause: "switched"`. If the load fails, it throws and the conversation stays where it was. Switching to the current conversation does nothing |

The list never includes another user's conversations or another agent's. Store the ID from the `conversation_id` event after a switch, or the user's next visit opens the conversation they switched away from.

## Read state

| Member | What it gives you |
|---|---|
| `transcript()` | What to display: user and agent turns, without silent turns and tool traffic. A turn that showed a widget carries it on `.widget` as `{ doc, summary, kind }` |
| `messages()` | What the model sees: silent turns, the agent turns that called tools, and the tool results |
| `conversationId` | The ID this conversation's turns are stored under |
| `enabledFeatures()` | Resolves to the release features switched on for this deployment, from your `getSession` response. Empty when there are none, or when `getSession` returned only `{ session }` |

`transcript()` re-reads every widget in the conversation on each call. Call it when state changes and keep the result, rather than calling it on every render.

`messages()` and `transcript()` are different lists. Rendering from `messages()` shows users silent turns and raw tool traffic they were never meant to see.

## Give the agent context and tools

Three commands connect your app to the agent. Each replaces its whole set, so pass the full picture each time. Passing a shorter set is how you remove an entry. Each is read fresh on every turn.

| Command | What it does |
|---|---|
| `setPageTools(tools)` | Functions in your page the agent may call |
| `setSituationalAwareness(entries)` | Facts about what the user is looking at, as `{ key: value }` strings, added to the agent's context on every turn |
| `setPassthroughParams(params)` | Values a tool needs that the model must never see |

### Page tools

Each tool is `{ name, description, jsonSchema, execute }`. The model reads `description` and `jsonSchema` to decide when to call it. `execute` runs in your page with the arguments the model chose and returns a string (or a promise of one), which goes back to the agent as the tool's result.

```javascript
chat.setPageTools([
  {
    name: "open_invoice",
    description: "Open one of the signed-in user's invoices in the app.",
    jsonSchema: {
      type: "object",
      properties: { invoiceId: { type: "string" } },
      required: ["invoiceId"],
    },
    execute: async (args) => {
      const id = typeof args.invoiceId === "string" ? args.invoiceId : "";
      if (!/^inv_[a-z0-9]+$/.test(id)) return "That is not a valid invoice ID.";
      const r = await fetch(`/api/invoices/${id}`); // your backend checks this user may see it
      if (!r.ok) return "That invoice can't be opened.";
      window.location.hash = `#/invoices/${id}`;
      return `Opened invoice ${id}.`;
    },
  },
]);
```

### Page tool safety

The model decides whether to call your tool and with what arguments, and anything in its context can influence it, including knowledge sources and other tools' output.

- **`jsonSchema` is guidance, not validation.** Nothing checks the arguments against it before `execute` runs. Validate them yourself.
- **Mindset can't re-authorize a page tool.** It runs in your page, so nothing checks that this user may do this thing when the handler runs.

Treat every argument as attacker-controlled. Put nothing behind a page tool (a change, a payment, a read of personal data) unless the handler or your backend checks the user's permission first. The org API key never reaches the browser, and tools that run on Mindset's side still authorize there.

### Situational awareness

```javascript
chat.setSituationalAwareness({
  page: "Invoice detail",
  invoiceId: "inv_4471",
  invoiceStatus: "overdue",
});
```

Call it whenever the view changes. It is context, not instruction, and it is not a security boundary. What the agent may reach is still decided by Mindset.

### Pass-through values

```javascript
chat.setPassthroughParams({
  "lookup_account.tenantId": "acme-eu",
  region: "eu-west-1",
});
```

A `"tool_name.param"` key scopes a value to one tool. A plain `param` key applies to every tool. Mindset removes those parameters from the tool schemas before the model sees them, and the SDK adds your values to the tool's arguments when the tool is called. The model can't see, choose or guess them.

They stop being hidden if a tool puts the value in its result, because tool results go back to the model. Use pass-through for values the model shouldn't choose, and don't rely on it as a secret channel unless you control what the tool returns.

## What isn't possible

- No image attachments on the published client yet.
- No renaming or deleting conversations.
- No Mindset widget renderer is served for the UI-less client. A widget arrives as data (`doc`, `kind` and a plain-text `summary`). Draw it yourself, or show the summary. See [widget-payload](https://docs4.mindset.ai/docs/sdk/events-reference#widget-payload).
