m4Mindset docs

Mindset v3 / Deploy in your app / Agent Chat SDK 3: Client-side Integration APIs

View as Markdown

Examples

Four complete starter integrations. Pick the tab that matches your stack: the built-in chat UI, or headless mode with vanilla HTML, React, or Angular.

Four complete starter integrations. The Built-in chat UI tab is the fastest path to a working agent. The three headless tabs show how to render your own UI on top of the <mindset-agent> element.

This is the minimum integration. The element renders Mindset AI's built-in chat UI inside a shadow DOM. Your users see a chat panel; you write no UI code.

Complete page

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Mindset AI agent</title>
  <style>
    body { margin: 0; font-family: system-ui, sans-serif; }
    main { max-width: 800px; margin: 0 auto; padding: 2rem; }
    mindset-agent { display: block; height: 600px; border: 1px solid #ddd; border-radius: 8px; }
  </style>
</head>
<body>
  <main>
    <h1>Support agent</h1>
    <p>Ask anything about your account, orders, or our products.</p>

    <mindset-agent
      agent-uid="agt-your-agent-uid"
    ></mindset-agent>
  </main>

  <script src="MINDSET-SERVER-URL/mindset-sdk3.umd.js"></script>
  <script>
    window.mindset.init({
      appUid: 'your-app-uid',
      fetchAuthentication: async () => {
        // Your application's API - validates current user and then calls Mindset AI's API to create
        const r = await fetch('/api/mindset-token', { credentials: 'include' });
        const { authToken } = await r.json();
        return authToken;
      },
    });
  </script>
</body>
</html>

Replace the agent and app UIDs with yours, set up the token endpoint, and you have a working chat panel.

What you get

  • Chat input and message history
  • Thread switcher (the user can start, switch, rename, and delete threads)
  • Streaming responses with typing indicator
  • Tool widgets rendered automatically (cards, carousels, custom UIs)
  • Quick reply chips and follow-up question buttons
  • Citation display when the agent uses retrieval
  • Theme that matches the page's color scheme (configurable)

Sizing the element

<mindset-agent> is display: block by default. Size it like any other block element:

css
mindset-agent {
  display: block;
  height: 600px;
  width: 100%;
  border: 1px solid #ddd;
  border-radius: 8px;
}

Common patterns:

  • For full-page chat, use height: 100vh; width: 100%.
  • For a sidebar, use height: 100%; width: 360px.
  • For an embedded panel, use a fixed height like the example above.

The chat UI is responsive within the element. It works at sizes from ~320px wide up to full screen.

Reacting to events

Even with the built-in UI, you can hook into events to integrate the agent with the rest of your page.

js
const agent = document.querySelector('mindset-agent');

// Sync the active thread to the URL
agent.addEventListener('mindset:thread-changed', (e) => {
  if (e.detail.threadUid) {
    history.replaceState({}, '', `?thread=${e.detail.threadUid}`);
  }
});

// Track turn completions in your analytics
agent.addEventListener('mindset:complete', (e) => {
  analytics.track('agent_turn_complete', {
    threadId: e.detail.threadId,
    responseLength: e.detail.response.length,
  });
});

// Show your own loading state outside the chat panel
agent.addEventListener('mindset:agent-busy', () => updateStatusIndicator('thinking'));
agent.addEventListener('mindset:agent-idle', () => updateStatusIndicator('ready'));

Driving the agent from outside

You can call methods on the element from anywhere on your page. Useful for:

Quick-action buttons that send a pre-written message:

html
<button id="ask-status">Where's my order?</button>

<script>
  document.getElementById('ask-status').addEventListener('click', () => {
    document.querySelector('mindset-agent').sendMessage("Where's my order?");
  });
</script>

Deep-linking to a specific thread:

js
const params = new URLSearchParams(location.search);
const threadUid = params.get('thread');

document.querySelector('mindset-agent').addEventListener('mindset:agent-idle', async function once() {
  if (threadUid) {
    await this.switchThread(threadUid);
  }
  this.removeEventListener('mindset:agent-idle', once);
}, { once: false });

Programmatic priming. Silently prime the agent with context when the user does something on the page:

js
document.querySelector('.cart-button').addEventListener('click', () => {
  const agent = document.querySelector('mindset-agent');
  agent.sendMessage('the user just opened the cart panel', { silent: true });
});

The agent receives the message and can respond, but no user-side bubble appears in the chat.

Adding page tools

Page tools let the agent call functions on your page during a turn. Configure them with agent.setPageTools():

js
const agent = document.querySelector('mindset-agent');

agent.setPageTools([
  {
    name: 'lookup_order',
    description: 'Look up the current user\'s order by ID',
    parameters: {
      type: 'object',
      properties: {
        orderId: { type: 'string', description: 'The order ID' },
      },
      required: ['orderId'],
    },
    handler: async ({ orderId }) => {
      const r = await fetch(`/api/orders/${orderId}`);
      return await r.json();
    },
  },
]);

Call setPageTools once the element has fired mindset:agent-idle. See When the element is ready to call.

Configuration via attributes

For static setup that's known at render time, you can pass values as attributes on the element instead of calling JS methods. See the <mindset-agent> reference for the full list.

html
<mindset-agent
  agent-uid="agt-..."
  initial-question="What can I help you with today?"
  situational-awareness='{"currentPage":"/products","userPlan":"enterprise"}'
></mindset-agent>

Attributes are read once at render time. Use the JS method if you need to update values dynamically.

Switching to headless later

If you outgrow the built-in UI and want to render your own, add the headless attribute and start listening for events:

html
<mindset-agent agent-uid="agt-..." headless></mindset-agent>

The runtime, state machine, auth flow, and DOM events stay the same. The only thing that changes is whether the element renders its own UI. Switch to the Headless: vanilla HTML, Headless: React, or Headless: Angular tab above for what that looks like end-to-end.

This example builds a complete chat interface in plain HTML and JavaScript. The <mindset-agent> element runs the agent and dispatches DOM events; you handle rendering. No framework, no build step.

What this example covers

  • Streaming text rendering as it arrives
  • Sending messages and managing busy state
  • Rendering tool widgets when the agent calls tools
  • Switching threads and showing thread history
  • Quick reply chips and follow-up question buttons

Complete page

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Custom agent UI</title>
  <style>
    body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-columns: 240px 1fr; height: 100vh; }
    aside { background: #f5f5f5; padding: 1rem; overflow-y: auto; }
    aside h2 { font-size: 0.9rem; text-transform: uppercase; color: #666; }
    aside ul { list-style: none; padding: 0; }
    aside button { display: block; width: 100%; text-align: left; padding: 0.5rem; border: none; background: transparent; cursor: pointer; border-radius: 4px; }
    aside button:hover { background: #e0e0e0; }
    aside button.active { background: #d0d0ff; }
    main { display: flex; flex-direction: column; padding: 1rem; }
    #messages { flex: 1; overflow-y: auto; }
    .message { margin-bottom: 1rem; padding: 0.75rem; border-radius: 8px; }
    .message.user { background: #e0e8ff; margin-left: 2rem; }
    .message.agent { background: #f0f0f0; margin-right: 2rem; }
    .quick-replies { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.5rem 0; }
    .quick-replies button { padding: 0.4rem 0.8rem; border: 1px solid #ccc; background: white; border-radius: 16px; cursor: pointer; }
    form { display: flex; gap: 0.5rem; padding-top: 1rem; border-top: 1px solid #ddd; }
    form textarea { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; resize: vertical; min-height: 2.5rem; }
    form button { padding: 0.5rem 1rem; }
    form button:disabled { opacity: 0.5; cursor: not-allowed; }
  </style>
</head>
<body>
  <aside>
    <button id="new-thread">+ New thread</button>
    <h2>History</h2>
    <ul id="thread-list"></ul>
  </aside>

  <main>
    <div id="messages"></div>
    <form id="form">
      <textarea id="input" placeholder="Ask anything…" required></textarea>
      <button type="submit" id="send">Send</button>
    </form>
  </main>

  <!-- Headless: no UI rendered by the element itself -->
  <mindset-agent
    id="agent"
    agent-uid="agt-your-agent-uid"
    headless
  ></mindset-agent>

  <script src="MINDSET-SERVER-URL/mindset-sdk3.umd.js"></script>
  <script>
    const agent = document.getElementById('agent');
    const messagesEl = document.getElementById('messages');
    const inputEl = document.getElementById('input');
    const sendBtn = document.getElementById('send');
    const formEl = document.getElementById('form');
    const threadListEl = document.getElementById('thread-list');
    const newThreadBtn = document.getElementById('new-thread');

    // Render state
    let currentReplyEl = null;
    let currentReplyText = '';

    // ---- Initialize the SDK ----
    window.mindset.init({
      appUid: 'your-app-uid',
      fetchAuthentication: async () => {
        const r = await fetch('/api/mindset-token', { credentials: 'include' });
        const { authToken } = await r.json();
        return authToken;
      },
    });

    // ---- Listen for lifecycle events ----
    agent.addEventListener('mindset:agent-idle', () => {
      sendBtn.disabled = false;
      refreshThreadList();
    });

    agent.addEventListener('mindset:agent-busy', () => {
      sendBtn.disabled = true;
      // Start a new agent message bubble
      currentReplyText = '';
      currentReplyEl = appendMessage('agent', '');
    });

    agent.addEventListener('mindset:agent-error', (e) => {
      appendMessage('agent', `[error: ${e.detail.message}]`);
      sendBtn.disabled = false;
    });

    // ---- Listen for streaming output ----
    agent.addEventListener('mindset:text-delta', (e) => {
      currentReplyText += e.detail.content;
      if (currentReplyEl) currentReplyEl.textContent = currentReplyText;
    });

    // ---- Tool widgets ----
    agent.addEventListener('mindset:tool-end', (e) => {
      if (e.detail.widget && currentReplyEl) {
        const widgetEl = renderWidget(e.detail.widget);
        currentReplyEl.appendChild(widgetEl);
      }
    });

    // ---- Quick replies ----
    agent.addEventListener('mindset:quick-replies', (e) => {
      if (!currentReplyEl) return;
      const wrapper = document.createElement('div');
      wrapper.className = 'quick-replies';
      // Optional: render the framing question above the chips
      if (e.detail.question) {
        const q = document.createElement('div');
        q.textContent = e.detail.question;
        q.style.fontSize = '0.85rem';
        q.style.color = '#666';
        currentReplyEl.appendChild(q);
      }
      e.detail.options.forEach((option) => {
        const btn = document.createElement('button');
        btn.textContent = option;
        btn.addEventListener('click', () => {
          inputEl.value = option;
          formEl.requestSubmit();
        });
        wrapper.appendChild(btn);
      });
      currentReplyEl.appendChild(wrapper);
    });

    // ---- Thread management ----
    // mindset:thread-changed fires when a thread persists server-side
    // (after the first user message of a new thread, or when switching to
    // an existing one). It does NOT fire immediately on agent.newThread().
    // A fresh thread has no uid until the user engages with it.
    agent.addEventListener('mindset:thread-changed', () => {
      refreshThreadList();
    });

    newThreadBtn.addEventListener('click', async () => {
      await agent.newThread();
      // newThread() resets the agent's local state but doesn't persist a thread
      // server-side. That happens after the user's first message. Clear the
      // message list now so the UI matches; the sidebar will pick up the new
      // thread once it persists (via mindset:thread-changed).
      messagesEl.innerHTML = '';
      refreshThreadList();  // refresh anyway in case a previous thread should now appear
    });

    async function refreshThreadList() {
      const { threads } = await agent.listThreads({ pageSize: 20 });
      threadListEl.innerHTML = '';
      threads.forEach((t) => {
        const li = document.createElement('li');
        const btn = document.createElement('button');
        btn.textContent = t.title || '(untitled)';
        if (t.uid === agent.threadUid) btn.classList.add('active');
        btn.addEventListener('click', () => agent.switchThread(t.uid));
        li.appendChild(btn);
        threadListEl.appendChild(li);
      });
    }

    // ---- Send a message ----
    // sendWhenIdle handles the case where the agent is still busy with its
    // icebreaker turn (an automatic first turn some agents are configured to
    // fire on startup). If the agent is idle right now, it sends straight
    // away; if it's busy, it waits for the next mindset:agent-idle and
    // recurses. Safe to call at any time.
    function sendWhenIdle(text) {
      if (!agent.isAgentBusy()) {
        agent.sendMessage(text);
      } else {
        agent.addEventListener('mindset:agent-idle', () => sendWhenIdle(text), { once: true });
      }
    }

    formEl.addEventListener('submit', (e) => {
      e.preventDefault();
      const text = inputEl.value.trim();
      if (!text) return;

      appendMessage('user', text);
      sendWhenIdle(text);
      inputEl.value = '';
    });

    // ---- Helpers ----
    function appendMessage(role, text) {
      const el = document.createElement('div');
      el.className = `message ${role}`;
      el.textContent = text;
      messagesEl.appendChild(el);
      el.scrollIntoView({ block: 'end' });
      return el;
    }

    function renderWidget(widget) {
      // Replace this with your own widget rendering (cards, carousels, custom components).
      const el = document.createElement('pre');
      el.style.background = '#fafafa';
      el.style.padding = '0.5rem';
      el.style.borderRadius = '4px';
      el.style.fontSize = '0.85rem';
      el.textContent = JSON.stringify(widget, null, 2);
      return el;
    }
  </script>
</body>
</html>

How it works

The element runs the agent. You write the rest. Three event handlers do most of the work:

  1. mindset:agent-busy starts a new agent message bubble in the UI
  2. mindset:text-delta appends streaming text to the current bubble
  3. mindset:agent-idle re-enables the send button. The turn is done.

Everything else (tool widgets, quick replies, thread changes) is layered on top with additional event listeners.

The sendWhenIdle helper covers a subtle case: if your agent is configured with an icebreaker (a turn that fires automatically as the agent settles after init), the agent goes idle once after init completes, then immediately busy again while the icebreaker streams, then idle a second time when the icebreaker finishes. A user clicking "Send" during that first window would otherwise hit a busy throw. sendWhenIdle re-checks busy state on each mindset:agent-idle, so it queues automatically until the agent is actually free.

Common adjustments

Show a typing indicator. Replace the empty agent message with a "..." while waiting for the first text delta:

js
agent.addEventListener('mindset:agent-busy', () => {
  currentReplyText = '';
  currentReplyEl = appendMessage('agent', '…'); // visible while streaming starts
});

agent.addEventListener('mindset:text-delta', (e) => {
  currentReplyText += e.detail.content;
  if (currentReplyEl) currentReplyEl.textContent = currentReplyText; // overwrite the …
});

Render markdown in agent replies. Pipe currentReplyText through your favorite markdown library before setting it on the element. Re-render on every delta:

js
import { marked } from 'https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js';

agent.addEventListener('mindset:text-delta', (e) => {
  currentReplyText += e.detail.content;
  if (currentReplyEl) currentReplyEl.innerHTML = marked.parse(currentReplyText);
});

Handle interrupts. Some turns pause for input. The agent calls a UI-interrupt tool (e.g. present_choices) and waits. Listen for mindset:interrupt, render UI based on e.detail.args, then drive the next turn with agent.sendMessage() once the user selects:

js
agent.addEventListener('mindset:interrupt', (e) => {
  // e.detail.interruptType identifies the tool that paused the turn.
  // e.detail.args carries the data your UI needs to render.
  const userSelection = await promptUserForChoice(e.detail.args);
  agent.sendMessage(userSelection);
});

Persist the active thread in the URL. Sync agent.threadUid to the URL so reloads land on the same thread. Because uids only surface after a thread persists server-side, the URL won't get a value until the user's first message. That's the correct behavior, since a local-only thread can't be reloaded anyway.

js
agent.addEventListener('mindset:thread-changed', (e) => {
  if (e.detail.threadUid) {
    history.replaceState({}, '', `?thread=${e.detail.threadUid}`);
  } else {
    // threadUid went null (between threads or after newThread() reset).
    history.replaceState({}, '', location.pathname);
  }
});

// On page load, after the agent reaches idle:
agent.addEventListener('mindset:agent-idle', async function once() {
  const threadUid = new URLSearchParams(location.search).get('thread');
  if (threadUid) {
    try {
      await agent.switchThread(threadUid);
    } catch (err) {
      // The uid in the URL no longer exists (deleted, expired, or stale).
      // Clear the URL and continue with a fresh thread.
      console.warn('Stored thread no longer exists, starting fresh:', err.message);
      history.replaceState({}, '', location.pathname);
    }
  }
  agent.removeEventListener('mindset:agent-idle', once);
}, { once: false });

<mindset-agent> is a custom element, which means it works directly as a JSX tag with no wrapper component. This example shows the patterns React developers want: refs, useEffect for subscription, cleanup on unmount, and TypeScript types for the events.

Initialization

Call mindset.init() once at app startup. Don't put it inside a component's render. Call it once in your entry file or a top-level effect:

ts
// src/lib/mindset-init.ts
declare global {
  interface Window {
    mindset: {
      init(config: {
        appUid: string;
        fetchAuthentication: () => Promise<string>;
      }): Promise<void>;
    };
  }
}

let initialized = false;

export function initializeMindset() {
  if (initialized) return;
  initialized = true;

  window.mindset.init({
    appUid: import.meta.env.VITE_MINDSET_APP_UID,
    fetchAuthentication: async () => {
      const r = await fetch('/api/mindset-token', { credentials: 'include' });
      const { authToken } = await r.json();
      return authToken;
    },
  });
}

Call initializeMindset() in your app's entry point (e.g. main.tsx):

tsx
import { initializeMindset } from './lib/mindset-init';

initializeMindset();

createRoot(document.getElementById('root')!).render(<App />);

Make sure the SDK script is loaded in your index.html before the app boots:

html
<script src="MINDSET-SERVER-URL/mindset-sdk3.umd.js"></script>

TypeScript types for the element and events

There are no shipped TypeScript declarations yet (planned). Declare the types you need locally:

ts
// src/types/mindset.d.ts
declare namespace JSX {
  interface IntrinsicElements {
    'mindset-agent': React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement> & {
        'agent-uid': string;
        headless?: boolean | '';
        'initial-question'?: string;
        'thread-uid'?: string;
        'situational-awareness'?: string;
        'passthrough-params'?: string;
        'theme'?: string;
        'show-thread-list'?: 'true' | 'false';
      },
      HTMLElement
    >;
  }
}

interface MindsetAgentElement extends HTMLElement {
  setPageTools(tools: PageToolDefinition[]): void;
  setSituationalAwareness(sa: Record<string, string>): void;
  setPassthroughParams(params: Record<string, string>): void;
  sendMessage(text: string, options?: { silent?: boolean }): void;
  isAgentBusy(): boolean;
  newThread(): Promise<void>;
  switchThread(threadUid: string): Promise<void>;
  deleteThread(threadUid: string): Promise<boolean>;
  renameThread(threadUid: string, title: string): Promise<boolean>;
  listThreads(options?: { pageSize?: number; pageToken?: string }): Promise<{
    threads: Array<{
      uid: string;
      title: string;
      createdAt: string;
      updatedAt: string;
      messageCount: number;
    }>;
    nextPageToken: string | null;
    hasMore: boolean;
  }>;
  readonly threadUid: string | null;
}

// Event detail shapes. See events reference for the full list.
interface MindsetEventMap {
  'mindset:agent-registered': CustomEvent<{ headless: boolean }>;
  'mindset:agent-initializing': CustomEvent<{}>;
  'mindset:agent-idle': CustomEvent<{}>;
  'mindset:agent-busy': CustomEvent<{}>;
  'mindset:agent-error': CustomEvent<{ code: string; message: string }>;
  'mindset:thread-changed': CustomEvent<{ threadUid: string | null; previous: string | null }>;
  'mindset:text-delta': CustomEvent<{ content: string }>;
  'mindset:tool-start': CustomEvent<{
    toolName: string;
    toolCallId: string;
    runId: string;
    silent: boolean;
    args?: Record<string, unknown>;
  }>;
  'mindset:tool-end': CustomEvent<{
    toolName: string;
    toolCallId: string;
    runId: string;
    durationMs: number;
    silent: boolean;
    widget?: unknown;
    canvas?: unknown;
    llmToolCallId?: string;
    output?: string;
  }>;
  'mindset:complete': CustomEvent<{ response: string; threadId: string }>;
  // …add more as you use them
}

interface PageToolDefinition {
  name: string;
  description: string;
  parameters: Record<string, unknown>;
  handler: (args: any) => Promise<unknown>;
}

A reusable hook

A small custom hook handles the common pattern: get a typed ref, subscribe to events, clean up on unmount.

tsx
// src/hooks/useMindsetAgent.ts
import { useEffect, useRef } from 'react';

export function useMindsetAgent() {
  const ref = useRef<MindsetAgentElement>(null);

  function on<K extends keyof MindsetEventMap>(
    type: K,
    handler: (e: MindsetEventMap[K]) => void,
  ) {
    useEffect(() => {
      const el = ref.current;
      if (!el) return;
      el.addEventListener(type as string, handler as EventListener);
      return () => el.removeEventListener(type as string, handler as EventListener);
    }, [handler]);
  }

  return { ref, on };
}

A chat component

Putting it together:

tsx
// src/components/AgentChat.tsx
import { useState, useCallback } from 'react';
import { useMindsetAgent } from '../hooks/useMindsetAgent';

interface Message {
  role: 'user' | 'agent';
  text: string;
}

interface Props {
  agentUid: string;
}

export function AgentChat({ agentUid }: Props) {
  const { ref, on } = useMindsetAgent();
  const [messages, setMessages] = useState<Message[]>([]);
  const [busy, setBusy] = useState(false);
  const [input, setInput] = useState('');

  const onBusy = useCallback(() => {
    setBusy(true);
    setMessages((prev) => [...prev, { role: 'agent', text: '' }]);
  }, []);

  const onIdle = useCallback(() => {
    setBusy(false);
  }, []);

  const onDelta = useCallback((e: MindsetEventMap['mindset:text-delta']) => {
    setMessages((prev) => {
      const next = [...prev];
      const last = next[next.length - 1];
      if (last && last.role === 'agent') {
        next[next.length - 1] = { ...last, text: last.text + e.detail.content };
      }
      return next;
    });
  }, []);

  const onComplete = useCallback((e: MindsetEventMap['mindset:complete']) => {
    console.log('Turn done in thread', e.detail.threadId);
  }, []);

  on('mindset:agent-busy', onBusy);
  on('mindset:agent-idle', onIdle);
  on('mindset:text-delta', onDelta);
  on('mindset:complete', onComplete);

  // sendWhenIdle handles the case where the agent is still busy with its
  // icebreaker turn (an automatic first turn some agents are configured to
  // fire on startup). If the agent is idle, it sends straight away; if it's
  // busy, it waits for the next mindset:agent-idle and recurses. Safe to call
  // at any time.
  function sendWhenIdle(agent: MindsetAgentElement, text: string) {
    if (!agent.isAgentBusy()) {
      agent.sendMessage(text);
    } else {
      agent.addEventListener(
        'mindset:agent-idle',
        () => sendWhenIdle(agent, text),
        { once: true },
      );
    }
  }

  function send(e: React.FormEvent) {
    e.preventDefault();
    const text = input.trim();
    if (!text || !ref.current) return;

    setMessages((prev) => [...prev, { role: 'user', text }]);
    sendWhenIdle(ref.current, text);
    setInput('');
  }

  return (
    <>
      <mindset-agent
        ref={ref}
        agent-uid={agentUid}
        headless
      />

      <div className="messages">
        {messages.map((m, i) => (
          <div key={i} className={`message message-${m.role}`}>
            {m.text}
          </div>
        ))}
      </div>

      <form onSubmit={send}>
        <textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask anything…"
        />
        <button type="submit" disabled={busy}>
          Send
        </button>
      </form>
    </>
  );
}

How it works

A few things make this React pattern straightforward:

  • <mindset-agent> is a real custom element, so React recognizes it as a JSX tag and forwards the ref to the underlying DOM node. No wrapper component needed.
  • Events come from the element, not from React. Subscribe in useEffect, return a cleanup function that calls removeEventListener. The custom hook does this in one line.
  • State setters and sendMessage are sync, so call them directly from event handlers and form submit handlers. No await needed.
  • Thread CRUD is async. newThread, switchThread, and the rest return Promises. Handle them like any other API call.
  • sendWhenIdle covers the icebreaker race. If your agent is configured with an icebreaker (a turn that fires automatically as the agent settles after init), the lifecycle is agent-idle (init done), then agent-busy (icebreaker streaming), then agent-idle (icebreaker done). A user clicking Send during that first window would otherwise hit a busy throw. The recursive sendWhenIdle re-checks busy state on each idle event, so it queues automatically until the agent is actually free.

Configuring page tools

Set page tools when the component mounts. Use useEffect to wait for the element ref:

tsx
useEffect(() => {
  const el = ref.current;
  if (!el) return;

  el.setPageTools([
    {
      name: 'lookup_order',
      description: 'Look up the current user\'s order by ID',
      parameters: {
        type: 'object',
        properties: { orderId: { type: 'string' } },
        required: ['orderId'],
      },
      handler: async ({ orderId }: { orderId: string }) => {
        const r = await fetch(`/api/orders/${orderId}`);
        return await r.json();
      },
    },
  ]);
}, []);

If this effect runs before mindset.init() has completed (or before the SDK script has loaded), setPageTools won't exist on the element yet. Gate the call on a mindset:agent-idle listener (or wait for it inside the effect). See When the element is ready to call for the pattern.

Updating situational awareness when state changes

Pass page state to the agent so it can personalize responses without the user having to repeat themselves:

tsx
const { user, currentPage, cart } = useAppState();

useEffect(() => {
  ref.current?.setSituationalAwareness({
    currentPage,
    userPlan: user.plan,
    cartItems: String(cart.items.length),
  });
}, [currentPage, user.plan, cart.items.length]);

The agent reads these values on the next turn.

<mindset-agent> is a custom element, so it drops straight into an Angular template. Angular has three quirks that aren't obvious if you're coming from the Headless: React tab, and getting them wrong produces confusing failures:

  1. Angular rejects unknown elements unless you tell it <mindset-agent> is a custom element (CUSTOM_ELEMENTS_SCHEMA).
  2. The mindset:* event names contain a colon, which Angular's template event binding parses as a target/event pair, so (mindset:text-delta)="…" does not work. You subscribe with addEventListener instead.
  3. Events fire from the element, not from Angular, so change detection may not run unless you re-enter the Angular zone.

This example targets Angular 17+ with standalone components and signals. The same approach works with NgModules and older versions, and the notes call out where.

Allow the custom element

Angular's template compiler errors on unknown tags (NG8001). Add CUSTOM_ELEMENTS_SCHEMA to any standalone component that renders <mindset-agent> (or to the NgModule that declares it):

ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@Component({
  selector: 'app-agent-chat',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  templateUrl: './agent-chat.component.html',
})
export class AgentChatComponent {}

CUSTOM_ELEMENTS_SCHEMA tells Angular to leave any unrecognized tag and its attributes alone instead of validating them. It's scoped to the component, so it doesn't loosen checking anywhere else.

Initialization

Call mindset.init() once, before the app renders the element. The cleanest place is an app initializer so it runs during bootstrap:

ts
// src/app/mindset.service.ts
import { Injectable } from '@angular/core';
import { environment } from '../environments/environment';

declare global {
  interface Window {
    mindset: {
      init(config: {
        appUid: string;
        fetchAuthentication: () => Promise<string>;
      }): Promise<void>;
    };
  }
}

@Injectable({ providedIn: 'root' })
export class MindsetService {
  private initialized = false;

  async init(): Promise<void> {
    if (this.initialized) return;
    this.initialized = true;

    await window.mindset.init({
      appUid: environment.mindsetAppUid,
      fetchAuthentication: async () => {
        const r = await fetch('/api/mindset-token', { credentials: 'include' });
        const { authToken } = await r.json();
        return authToken;
      },
    });
  }
}

Wire it into bootstrap with provideAppInitializer (Angular 19+), or the APP_INITIALIZER token on older versions:

ts
// src/app/app.config.ts
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { MindsetService } from './mindset.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideAppInitializer(() => inject(MindsetService).init()),
  ],
};

Make sure the SDK script is loaded in your index.html before Angular boots:

html
<script src="MINDSET-SERVER-URL/mindset-sdk3.umd.js"></script>

init() is safe to call before the <mindset-agent> element exists in the DOM. The SDK uses a MutationObserver to bind elements Angular mounts later.

TypeScript types for the element and events

The SDK doesn't ship TypeScript declarations, so you declare the types you use locally. Unlike React, Angular doesn't use JSX, so you don't declare a JSX namespace, because CUSTOM_ELEMENTS_SCHEMA already lets the template accept the tag. What you want is a type for the element instance you get from @ViewChild, plus the event payloads:

ts
// src/types/mindset.d.ts
export interface PageToolDefinition {
  name: string;
  description: string;
  parameters: Record<string, unknown>;
  handler: (args: any) => Promise<unknown>;
}

export interface MindsetAgentElement extends HTMLElement {
  setPageTools(tools: PageToolDefinition[]): void;
  setSituationalAwareness(sa: Record<string, string>): void;
  setPassthroughParams(params: Record<string, string>): void;
  sendMessage(text: string, options?: { silent?: boolean }): void;
  isAgentBusy(): boolean;
  newThread(): Promise<void>;
  switchThread(threadUid: string): Promise<void>;
  deleteThread(threadUid: string): Promise<boolean>;
  renameThread(threadUid: string, title: string): Promise<boolean>;
  listThreads(options?: { pageSize?: number; pageToken?: string }): Promise<{
    threads: Array<{
      uid: string;
      title: string;
      createdAt: string;
      updatedAt: string;
      messageCount: number;
    }>;
    nextPageToken: string | null;
    hasMore: boolean;
  }>;
  readonly threadUid: string | null;
}

// Event detail shapes. See events reference for the full list.
export interface MindsetEventMap {
  'mindset:agent-registered': CustomEvent<{ headless: boolean }>;
  'mindset:agent-initializing': CustomEvent<{}>;
  'mindset:agent-idle': CustomEvent<{}>;
  'mindset:agent-busy': CustomEvent<{}>;
  'mindset:agent-error': CustomEvent<{ code: string; message: string }>;
  'mindset:thread-changed': CustomEvent<{ threadUid: string | null; previous: string | null }>;
  'mindset:text-delta': CustomEvent<{ content: string }>;
  'mindset:complete': CustomEvent<{ response: string; threadId: string }>;
  // …add more as you use them
}

A reusable subscription helper

The recurring pattern is: get the element, add a listener, re-enter the Angular zone so change detection runs, and remove the listener on destroy. A small helper captures all of it. It uses NgZone and DestroyRef, both available from inject():

ts
// src/app/agent-events.ts
import { DestroyRef, NgZone } from '@angular/core';
import type { MindsetEventMap } from '../types/mindset';

/**
 * Subscribe to a mindset:* event on an element, with change detection and
 * automatic teardown. Call from a component's injection context (constructor
 * or a field initializer) so inject(DestroyRef) resolves.
 */
export function onAgentEvent<K extends keyof MindsetEventMap>(
  el: HTMLElement,
  type: K,
  handler: (e: MindsetEventMap[K]) => void,
  zone: NgZone,
  destroyRef: DestroyRef,
): void {
  const listener = (e: Event) =>
    // Re-enter Angular's zone: the SDK dispatches some events from async
    // callbacks that may have escaped the zone, which would skip change
    // detection. zone.run() guarantees the view updates.
    zone.run(() => handler(e as MindsetEventMap[K]));

  el.addEventListener(type, listener);
  destroyRef.onDestroy(() => el.removeEventListener(type, listener));
}

A chat component

Putting it together. The element is referenced with @ViewChild and a template reference variable; subscriptions are wired in ngAfterViewInit, once the view exists. State lives in signals so the template stays declarative:

ts
// src/app/agent-chat.component.ts
import {
  AfterViewInit,
  ChangeDetectionStrategy,
  Component,
  CUSTOM_ELEMENTS_SCHEMA,
  DestroyRef,
  ElementRef,
  NgZone,
  ViewChild,
  inject,
  input,
  signal,
} from '@angular/core';
import { onAgentEvent } from './agent-events';
import type { MindsetAgentElement } from '../types/mindset';

interface Message {
  role: 'user' | 'agent';
  text: string;
}

@Component({
  selector: 'app-agent-chat',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <mindset-agent #agent [attr.agent-uid]="agentUid()" headless></mindset-agent>

    <div class="messages">
      @for (m of messages(); track $index) {
        <div class="message message-{{ m.role }}">{{ m.text }}</div>
      }
    </div>

    <form (submit)="send($event)">
      <textarea
        [value]="input()"
        (input)="input.set($any($event.target).value)"
        placeholder="Ask anything…"
      ></textarea>
      <button type="submit" [disabled]="busy()">Send</button>
    </form>
  `,
})
export class AgentChatComponent implements AfterViewInit {
  // agentUid is a required input: <app-agent-chat [agentUid]="…" />
  readonly agentUid = input.required<string>();

  @ViewChild('agent') private agentRef!: ElementRef<MindsetAgentElement>;

  protected readonly messages = signal<Message[]>([]);
  protected readonly busy = signal(false);
  protected readonly input = signal('');

  private readonly zone = inject(NgZone);
  private readonly destroyRef = inject(DestroyRef);

  ngAfterViewInit(): void {
    const el = this.agentRef.nativeElement;

    onAgentEvent(el, 'mindset:agent-busy', () => {
      this.busy.set(true);
      this.messages.update((prev) => [...prev, { role: 'agent', text: '' }]);
    }, this.zone, this.destroyRef);

    onAgentEvent(el, 'mindset:agent-idle', () => {
      this.busy.set(false);
    }, this.zone, this.destroyRef);

    onAgentEvent(el, 'mindset:text-delta', (e) => {
      this.messages.update((prev) => {
        const next = [...prev];
        const last = next[next.length - 1];
        if (last && last.role === 'agent') {
          next[next.length - 1] = { ...last, text: last.text + e.detail.content };
        }
        return next;
      });
    }, this.zone, this.destroyRef);

    onAgentEvent(el, 'mindset:complete', (e) => {
      console.log('Turn done in thread', e.detail.threadId);
    }, this.zone, this.destroyRef);
  }

  send(e: Event): void {
    e.preventDefault();
    const text = this.input().trim();
    const el = this.agentRef?.nativeElement;
    if (!text || !el) return;

    this.messages.update((prev) => [...prev, { role: 'user', text }]);
    this.sendWhenIdle(el, text);
    this.input.set('');
  }

  // sendWhenIdle handles the case where the agent is still busy with its
  // icebreaker turn (an automatic first turn some agents are configured to
  // fire on startup). If the agent is idle, it sends straight away; if it's
  // busy, it waits for the next mindset:agent-idle and recurses. Safe to call
  // at any time.
  private sendWhenIdle(el: MindsetAgentElement, text: string): void {
    if (!el.isAgentBusy()) {
      el.sendMessage(text);
    } else {
      el.addEventListener(
        'mindset:agent-idle',
        () => this.sendWhenIdle(el, text),
        { once: true },
      );
    }
  }
}

How it works

The Angular-specific pieces, and why each one matters:

  • CUSTOM_ELEMENTS_SCHEMA is required. Without it the template compiler throws NG8001: 'mindset-agent' is not a known element. With it, Angular passes the tag and its attributes straight to the DOM.
  • You can't bind mindset:* events in the template. Angular reads the colon in (mindset:text-delta) as a target:event pair (the same syntax as (window:resize)), so the binding silently targets a non-existent mindset global. Subscribe with addEventListener via @ViewChild instead. That's what onAgentEvent does. Plain DOM events without a colon would bind fine, but every Mindset AI event uses the colon.
  • Re-enter the Angular zone for change detection. Events come from the element, not from Angular. zone.run() (or a signal write under zoneless) makes sure the view updates when a text-delta lands.
  • @ViewChild resolves in ngAfterViewInit, not the constructor. The element doesn't exist until the view renders, so wire subscriptions there. DestroyRef.onDestroy removes them when the component is torn down, with no manual OnDestroy boilerplate.
  • Attributes go through [attr.agent-uid]. Custom elements read configuration from attributes, not Angular @Input properties. Use attribute binding (or a static agent-uid="…") so the value lands in the DOM where the element reads it. headless is a static boolean attribute.
  • sendMessage and signal writes are sync, so call them directly from the submit handler and event callbacks. Thread CRUD is async (newThread, switchThread, and the rest), so handle the returned Promises like any other API call.
  • sendWhenIdle covers the icebreaker race. If your agent is configured with an icebreaker (a turn that fires automatically as the agent settles after init), the lifecycle is agent-idle (init done), then agent-busy (icebreaker streaming), then agent-idle (icebreaker done). A user clicking Send during that first window would otherwise hit a busy throw. The recursive sendWhenIdle re-checks busy state on each idle event, so it queues until the agent is actually free.

Configuring page tools

Set page tools after the view initializes, once you have the element ref:

ts
ngAfterViewInit(): void {
  const el = this.agentRef.nativeElement;

  el.setPageTools([
    {
      name: 'lookup_order',
      description: "Look up the current user's order by ID",
      parameters: {
        type: 'object',
        properties: { orderId: { type: 'string' } },
        required: ['orderId'],
      },
      handler: async ({ orderId }: { orderId: string }) => {
        const r = await fetch(`/api/orders/${orderId}`);
        return await r.json();
      },
    },
  ]);

  // …event subscriptions
}

If ngAfterViewInit runs before mindset.init() has completed (or before the SDK script has loaded), setPageTools won't exist on the element yet. Gate the call on a mindset:agent-idle listener. See When the element is ready to call for the pattern.

Updating situational awareness when state changes

Push page state to the agent so it can personalize responses without the user repeating themselves. With signals, an effect runs the update whenever the source state changes:

ts
import { effect, inject } from '@angular/core';
import { AppStateService } from './app-state.service';

private readonly appState = inject(AppStateService);

constructor() {
  // appState exposes signals: currentPage(), userPlan(), cartItemCount()
  effect(() => {
    this.agentRef?.nativeElement.setSituationalAwareness({
      currentPage: this.appState.currentPage(),
      userPlan: this.appState.userPlan(),
      cartItems: String(this.appState.cartItemCount()),
    });
  });
}

If your app state lives in RxJS observables instead, subscribe and call setSituationalAwareness in the next handler, and use takeUntilDestroyed() for cleanup. The agent reads the latest values on its next turn either way.