# Examples

> You're reading the Mindset v3 documentation. v3 is no longer sold; this is reference for existing customers. Mindset 4 docs are at /docs.

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.

#### Built-in chat UI

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](https://docs4.mindset.ai/v3/deploy/sdk3-client-apis/methods#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](https://docs4.mindset.ai/v3/deploy/sdk3-client-apis/mindset-agent-element#configuration-alternative-to-js-methods) 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.

#### Headless: vanilla HTML

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);
});
```

> **Note:** A dedicated programmatic-resume API on the element is on the roadmap. For now, treat interrupts as turn-ending and drive the next turn with `sendMessage()`.

**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 });
```

#### Headless: React

`<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](https://docs4.mindset.ai/v3/deploy/sdk3-client-apis/methods#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.

#### Headless: Angular

`<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()),
  ],
};
```

> **Note:** On Angular 17 and 18, use the classic token instead: `{ provide: APP_INITIALIZER, useFactory: (m: MindsetService) => () => m.init(), deps: [MindsetService], multi: true }`.

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));
}
```

> **Note:** Zone.js patches `addEventListener`, so listeners usually run inside Angular's zone already. The explicit `zone.run()` is cheap insurance for events the SDK fires from promise callbacks, and it's what you'll need if you've moved to zoneless change detection (`provideExperimentalZonelessChangeDetection`). There, signal writes inside the handler drive the view and `zone.run()` is a harmless no-op.

### 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](https://docs4.mindset.ai/v3/deploy/sdk3-client-apis/methods#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.

## Related

- [Methods reference](https://docs4.mindset.ai/v3/deploy/sdk3-client-apis/methods): Every method on the element with signatures and behaviors.
- [Events reference](https://docs4.mindset.ai/v3/deploy/sdk3-client-apis/events): Every event the element fires, with payload shapes.
- [Integration walkthrough](https://docs4.mindset.ai/v3/deploy/sdk3-client-apis/integration-guide): The end-to-end setup, from token endpoint to working agent.
- [Data channels overview](https://docs4.mindset.ai/v3/deploy/sdk3-client-apis/data-channels-overview): How your application talks to the agent.
