ReferenceSDKThe UI-less client
The UI-less client
Reference for driving a Mindset agent from your own interface. Loading, commands, events, state, conversation lists and page tools.
Skills for your coding agent
See it running: Halcyon
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.
Live demoAsk your data · Northwind AnalyticsAsk sales questions and a dashboard draws the answers from what the agent’s functions return.A scripted agent's greeting, cards, phases and questions appear only in the <mindset-agent> element. On this client the agent runs on its configuration and tools, without them. If the agent you're embedding has a script, use the element.
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().
import { createAgentConversation } from "https://YOUR-MINDSET-HOST/sdk/mindset-agent-uiless.js";The module also exports isSdkEvent, SDK_EVENT_TYPES, readPageMessage, and version, commit and buildTimeMs for the build you loaded.
Create a conversation#
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, 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, or { from: "page" } to report something that happened on the page. 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 |
isAgentBusy() | Whether a turn is running now |
retry() | Run the last turn again, replacing its reply. Does nothing if there isn't one |
reset() | Clear the agent's memory of earlier turns. It keeps the same conversation ID, so don't use it to start over. See start a new conversation |
Turns run one at a time. Sending a new turn while one is running cancels the running one, so check isAgentBusy() first if that would cut off an answer.
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:
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#
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.
- After the user's first completed turn, a
conversation_idevent arrives. Storeevent.conversationIdagainst that user in your backend. - On their next visit, pass it as
conversationIdtocreateAgentConversation. - Wait for
history_settled, drawevent.messagesas 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.
Start a new conversation#
There is no "new conversation" command. Stop the current conversation and create another without a conversationId:
let chat = createConversation();
function createConversation() {
const c = createAgentConversation({
agent: "billing-help",
getSession: async () => {
const r = await fetch("/api/session");
if (!r.ok) throw new Error("Could not start a session");
return r.json();
},
});
c.on(handleEvent); // your own listener, as in Listen above
return c;
}
function startNewConversation() {
chat.stop();
chat = createConversation();
}handleEvent stands for your listener. Each createAgentConversation calls getSession, so a new conversation is also a new session. After its first completed turn, conversation_id arrives with the new ID. Store that one.
Don't use reset() for this. It clears what the agent remembers in the page but keeps the conversation ID, so later turns are stored in the same conversation, and resuming that ID later doesn't give a clean transcript.
Show earlier conversations#
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 }, plus ref ({ handle, version }) when the widget was authored in the Studio, and block when a script asked a question with it |
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 } |
A message sent with { from: "page" } keeps a wrapper in content. readPageMessage(content) returns { kind, text } for it, so you can draw it as a page note rather than as the user's words.
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.
Give the agent context and tools#
Three commands connect your app to the agent. Each replaces its whole set, so pass the full set 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 } entries with any JSON values, added to the agent's context on every turn. Up to 10,000 characters of JSON |
setPassthroughParams(params) | Values a tool needs that the model must never see |
Page tools explains each one, with examples and the safety rules for page tools.
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,kindand a plain-textsummary). Show the summary, or see Use your own UI for what you can rely on.
Stale configuration#
When someone activates a new version of the agent in the Studio, an open conversation's configuration goes stale. The SDK fetches the new configuration and runs the turn again, once, without telling you, as long as the turn hasn't produced anything yet. You see a run_error with reason: "stale_configuration" only when the retry hits the same problem, or when the change lands after the turn has started producing output. Errors and resilience covers what to do.