m4Mindset docs

Docs / SDK / Reference / The UI-less client

View as Markdown

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 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();
  },
});
OptionTypeWhat it does
agentstringRequired. The agent's handle or ID
getSessionfunctionRequired. Returns the JSON body your backend got from the session create call, or a promise of it
conversationIdstringResume this conversation. Leave it out for a fresh one
initialMessagesarrayEarlier 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

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

CommandWhat 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

MemberWhat 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
conversationIdThe 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.

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