# Headless mode

Use the Foldspace agent behind your own chat UI. The SDK streams answers, runs your actions, navigates, and keeps history; you render everything.

Headless mode gives you every agent capability without the Foldspace UI: no iframe and no Foldspace DOM. You send messages and render the stream, while the SDK talks to the agent, runs your client-side actions, navigates your app, and keeps conversation history.

## When to use headless

Reach for it when the chat UI is something you need to own:

- **You already have one.** Users know it, it is part of your product, and swapping it for a widget is not on the table.
- **It has to be your design system**, not a themed widget: your components, your message bubbles, your empty states.
- **It is not a corner widget.** A full page, a docked panel, or a step inside a workflow.
- **It mounts somewhere the Foldspace UI cannot go**, such as a layout or shell you control end to end.

What you get is the expensive half: streaming, multi-turn answers with tool calls, client-side actions, navigation, shared context, and conversation history with server-side search. The alternative to headless is not the widget, it is building your own agent runtime.

What you take on is the rendering. Bubbles, markdown, action chips, components, and the history list are yours to build.

If you do not have a chat UI yet, start with `OVERLAY` (the default) or `EMBEDDED`. Those give you a working chat in one call, and you can move to headless later without changing the agent, its actions, or its settings.

:::note[Prerequisite]
This page assumes you've already installed the [SDK](/start/install/) and [identified the user](/start/user-context/). Conversation history is per user, so call `identify` before sending messages.
:::

## Create a headless agent

Pass `mode: "HEADLESS"` to `foldspace.agent()`. The settings are the same as the other modes; UI-only `configuration` fields (theme, container, Bottom Bar) are ignored.

```ts title="agent.ts"
foldspace("when", "ready", () => {
  const agent = foldspace.agent({
    apiName: "YOUR-AGENT-API-NAME",
    mode: "HEADLESS",
    aiActions: {
      get_invoices: {
        execute: async ({ status }) => api.getInvoices({ status }),
      },
    },
  });
});
```

The agent loads its remote config and remote actions in the background. Calls that need them wait on their own, so you don't need to await anything first. To know when loading is done, `await agent.ready()`.

## Send a message

`sendMessage` always streams. Progress arrives through `streamOptions`, and the promise resolves with the agent's final answer.

```ts title="chat.ts"
const response = await agent.sendMessage("Show my unpaid invoices", {
  conversationId, // omit to start a new conversation
  streamOptions: {
    onStart: ({ conversationId, messageId, isNewConversation }) => {
      createAgentBubble(messageId);
      if (isNewConversation) addToSidebar(conversationId);
    },
    onMessage: ({ messageId, fullText }) => {
      renderMarkdown(messageId, fullText);
    },
    onActionStart: ({ actionKey }) => showChip(`Running ${actionKey}`),
    onError: (error) => showError(error),
  },
});

conversationId = response.conversationId;
showSuggestions(response.suggestions);
```

While answering, the agent may call your actions and continue over several backend turns. It's still one agent message: `onStart` fires once, `fullText` keeps growing, and the promise resolves after the last turn.

:::tip
Store `response.conversationId` and pass it to the next `sendMessage` to continue the conversation.
:::

## Render the reply in order

`fullText` streams the whole message and is all a plain chat bubble needs. When the agent runs an action or shows a UI component, its reply after that is the next *turn* of the same message, with the same `messageId`, and `fullText` joins the turns with a blank line. If you show action chips or components, render each turn's `turnText` in order and put the chips and components between them, so the reply appears below the component instead of above it.

```ts title="timeline.ts"
streamOptions: {
  onMessage: ({ messageId, turn, turnText }) => timeline(messageId).setText(turn, turnText),
  onActionStart: (action) => timeline(action.messageId).addChip(action),
  onRender: (component) => timeline(component.messageId).addComponentSlot(),
}
```

If you don't show chips or components, keep rendering `fullText`.

## Stop a message

Pass an `AbortSignal`, call `abort` from `onStart`, or call `agent.abort()` to stop every in-flight message.

```ts title="stop.ts"
const controller = new AbortController();
stopButton.onclick = () => controller.abort();

try {
  await agent.sendMessage(text, { conversationId, signal: controller.signal });
} catch (error) {
  if (error.name !== "AbortError") throw error;
}
```

Stopping while text streams rejects with `AbortError`. Stopping while a [UI component](#render-ui-components) waits for input cancels that component instead: the agent receives the cancellation, replies, and the promise resolves.

## Register actions

Client-side actions run in the browser when the agent calls them. The SDK runs them, sends the results back, and continues the conversation.

```ts title="actions.ts"
agent.addActionHandlers({
  get_order: {
    execute: async ({ orderId }, signal) => api.getOrder(orderId, { signal }),
    timeout: 20000, // ms, default 5000
  },
});

agent.removeActionHandlers("get_order"); // a key or an array of keys
agent.getAvailableActions(); // your handlers and remote actions
await agent.runAction("get_order", { orderId: "A-1" }); // run one yourself, without the agent
```

The agent can only call actions that are also enabled in **Agent Studio**. Remote actions published from Agent Studio load automatically; when a key exists in both, your handler wins.

## Confirm actions

Actions marked **confirmation required** in Agent Studio ask your UI first. Return `true` to run the action or `false` to tell the agent the user declined.

```ts title="confirm.ts"
await agent.sendMessage(text, {
  streamOptions: {
    onConfirmationRequired: (request) =>
      showConfirmDialog(request.confirmationMessage ?? `Run ${request.actionKey}?`),
  },
});
```

Without `onConfirmationRequired`, these actions are declined. The order of callbacks is `onActionStart`, then `onConfirmationRequired`, then `onActionComplete`.

## Render UI components

Actions with a `render` (cards, forms, pickers) are offered to the agent only when you pass `onRender`. Your action's `render()` already decides what the component looks like; `onRender` decides where it goes in your chat. Return the element, and the SDK runs `execute`, then the action's `render()` into that element, inside a shadow root so its styles stay contained.

```ts title="components.ts"
await agent.sendMessage("Add a knowledge base source", {
  streamOptions: {
    onRender: (component) => {
      const slot = createComponentSlot(component.messageId);
      if (component.awaitUserInput) {
        addCancelButton(slot, () => component.cancel());
      }
      return slot;
    },
  },
});
```

The component reports back through the `callback` and `cancel` its `render()` receives. Use `component.submit(value)` and `component.cancel()` for controls outside the component, such as your own Cancel button. If `onRender` returns nothing, the agent is told the component couldn't be shown.

What the agent receives matches the Foldspace UI:

| The user | The agent receives |
| :-- | :-- |
| Submits or dismisses a waiting component (the `callback` / `cancel` its `render()` gets, or `submit` / `cancel`) | The result, in the same message |
| Stops the message while a component waits | A cancellation; the agent replies and the promise resolves |
| Sends a new message while a component waits | A cancellation first, then the new message |
| Clicks or dismisses a display-only component later | A new turn. `submit` and `cancel` resolve with the agent's reply, streamed through the same `streamOptions` |

Display-only components (`awaitUserInput: false`) tell the agent they were shown and the message continues. Waiting components keep the message open until `submit` or `cancel`.

## Navigate your app

The agent can move the user between pages. Hand it your router:

```ts title="navigation.ts"
agent.setNavigationHandler((url) => router.navigate(url));
```

| Handler | Behavior |
| :-- | :-- |
| `(url) => navigate(url)` | Done when it returns |
| `(url) => router.navigateByUrl(url)` | The promise is awaited; a rejection or `false` means failure |
| `(url, onSuccess, onError) => { ... }` | Call one of them when done |
| `(url) => { location.href = url }` | Full page load, see below |
| No handler | History API navigation, falling back to a full page load |

The second argument sets a timeout in milliseconds (default `10000`).

### Resume after a full page load

When a navigation reloads the page, the message the agent was answering continues on the new page. Call `resumePendingMessage` once your chat UI mounts. It resolves `null` when nothing is pending.

```ts title="app-start.ts"
const pendingConversationId = agent.getPendingConversationId();
if (pendingConversationId) {
  await openConversation(pendingConversationId); // load its history first
}

const response = await agent.resumePendingMessage({ streamOptions });
```

The hand-off lasts 60 seconds and also works across subdomains of the same site (for example `app.acme.com` to `admin.acme.com`). Across subdomains it carries only the navigation result: other actions that ran in the same turn reach the agent as "not available", so their data never sits in a shared cookie. It doesn't follow a navigation that opens a new tab.

## Build a conversation sidebar

```ts title="sidebar.ts"
const page = await agent.conversations.list({ page: 1, pageSize: 20, search: "invoice", pinned: false });
// page.conversations: [{ id, title, pinned, lastActivityAt }], page.total, page.hasMore

const { title, messages } = await agent.conversations.get(conversationId);
// messages: [{ id, role: "user" | "agent", text, createdAt, feedback?, components? }], oldest first

await agent.conversations.rename(conversationId, "Q3 planning");
await agent.conversations.pin(conversationId);
await agent.conversations.unpin(conversationId);
await agent.conversations.delete(conversationId);
```

`page` is 1-based and defaults to `1`. `pageSize` accepts 5 to 50 and defaults to `20`. Search runs on the server, across every conversation, not only the page you have loaded.

Messages that showed a UI component include a saved copy of it in `components`. Show it read-only with `showComponent`, which puts HTML in a sandboxed iframe where scripts never run, and renders saved images as an `<img>`:

```ts title="history.ts"
for (const message of messages) {
  const bubble = renderBubble(message.role, message.text);
  message.components?.forEach((component) => agent.conversations.showComponent(bubble, component));
}
```

The SDK saves the component when it reports a result, and again a few seconds later in case styles load late.

New conversations get an AI-generated title a few seconds after the first answer. Use `onTitle` to update the sidebar:

```ts title="title.ts"
await agent.sendMessage(text, {
  streamOptions: {
    onTitle: ({ conversationId, title }) => renameInSidebar(conversationId, title),
  },
});
```

## Collect feedback

```ts title="feedback.ts"
await agent.sendFeedback({
  conversationId,
  messageId, // from sendMessage, onStart, or conversations.get
  rating: "negative", // or "positive"
  reason: "ANSWER_INCORRECT", // optional
});
```

`reason` takes the same choices the Foldspace UI offers: `ANSWER_INCORRECT`, `ANSWER_NOT_CLEAR`, `ANSWER_DOES_NOT_ADDRESS_QUESTION`, `MORE_INFORMATION_REQUIRED`.

## Attach images

Pass PNG, JPEG, or GIF `File` objects (uploaded for you) or ids from `uploadFiles`:

```ts title="files.ts"
await agent.sendMessage("What's wrong in this screenshot?", { files: [fileInput.files[0]] });

const [fileId] = await agent.uploadFiles([image]); // upload once, reuse the id
await agent.sendMessage("Compare it with the previous one", { files: [fileId] });
```

Other file types reject with `INVALID_ARGUMENT`.

## Turn speech into text

Record with `MediaRecorder` (or any source of audio), then transcribe and send:

```ts title="voice.ts"
const recorder = new MediaRecorder(await navigator.mediaDevices.getUserMedia({ audio: true }));
const chunks: Blob[] = [];
recorder.ondataavailable = (event) => chunks.push(event.data);
recorder.onstop = async () => {
  const text = await agent.transcribe(new Blob(chunks, { type: recorder.mimeType }));
  await agent.sendMessage(text, { conversationId });
};
recorder.start();
// later: recorder.stop();
```

Audio is limited to 10 MB.

## Share the screen

Let the agent see the page the user is on. Turn it on from your UI, for example with a share toggle:

```ts title="share.ts"
agent.startShareScreen({ exclude: ["#my-chat-panel"] });
// ...
agent.stopShareScreen();
```

While sharing is on, every message carries a screenshot and/or the page's clean HTML. The agent can also ask to see the page during a conversation (the `share_current_tab` action), and gets the same capture. `exclude` keeps elements such as your chat panel out of both.

:::note
Screen sharing must be enabled for the agent in **Agent Studio**, or through `configuration.screenSharingSettings`. That setting also decides whether screenshots, HTML, or both are shared.
:::

## Share context

Everything below is sent with each message.

```ts title="context.ts"
// Extra knowledge. The same contextName replaces; contextPayload: null removes.
agent.setAgentContext({
  contextName: "account",
  contextInstructions: "The user's current plan and limits",
  contextPayload: { plan: "pro", seats: 12 },
});
agent.removeAgentContext("account");

// Where the user is in your app
agent.setNavigationContext({ page: "billing", invoiceId: "INV-42" });

// Live UI state the agent can read and change through your handler
agent.shareState("filters", filters, (stateKey, nextState) => applyFilters(nextState), filtersSchema); // JSON Schema, object or string
agent.clearState("filters");
```

## Run task agents

`runTask` works as in the other modes. See the [Task Agent API](/reference/task-agent-api/).

```ts title="task.ts"
const summary = await agent.runTask({ taskKey: "summarize_ticket", data: ticket });
```

## Handle errors

- Stopped messages reject with `name === "AbortError"`.
- Everything else rejects with a `FoldspaceError` that has a `code`:

| Code | When |
| :-- | :-- |
| `INIT_FAILED` | Remote config could not be loaded (for example, the agent is unavailable) |
| `AGENT_REMOVED` | The agent was removed with `remove()` |
| `INVALID_ARGUMENT` | A required argument is missing or invalid |
| `CONVERSATION_BUSY` | A message is already running in this conversation (and isn't waiting on a UI component) |
| `HTTP_ERROR` | A request failed; `error.status` holds the HTTP status (401 also when `onTokenExpired` fails) |
| `ACTION_LOOP_DETECTED` | The agent requested the same action with the same parameters twice in a row |
| `MAX_TURNS_EXCEEDED` | The agent didn't answer within 10 turns |

`response.quota?.action === "BLOCK"` means the message was not answered because of a usage limit.

## Example: React

```tsx title="useAgentChat.tsx"

type Message = { id: string; role: "user" | "agent"; text: string };

export function useAgentChat(agent: IAgentHeadless) {
  const [messages, setMessages] = useState<Message[]>([]);
  const conversationId = useRef<string | undefined>(undefined);

  async function send(text: string) {
    const userId = crypto.randomUUID();
    setMessages((m) => [...m, { id: userId, role: "user", text }]);

    const setAgentText = (id: string, value: string) =>
      setMessages((m) =>
        m.some((x) => x.id === id)
          ? m.map((x) => (x.id === id ? { ...x, text: value } : x))
          : [...m, { id, role: "agent", text: value }],
      );

    try {
      const response = await agent.sendMessage(text, {
        conversationId: conversationId.current,
        streamOptions: {
          onMessage: ({ messageId, fullText }) => setAgentText(messageId, fullText),
        },
      });
      conversationId.current = response.conversationId;
      setAgentText(response.messageId, response.text);
    } catch (error) {
      if ((error as Error).name !== "AbortError") {
        setAgentText(crypto.randomUUID(), "Something went wrong. Try again.");
      }
    }
  }

  return { messages, send, stop: () => agent.abort() };
}
```

## Reference

Every signature, including the settings, the `sendMessage` options, each `streamOptions` callback, the render event, and the full list of agent methods, is on the [Headless API reference](/reference/headless-api/).

## Limitations

- Live voice conversation (talking with the agent in real time) and the Bottom Bar are features of the Foldspace UI. Use `transcribe` for voice input.
- Resuming after a full page load doesn't follow navigations that open a new tab.

## Next steps

- [Actions](/guides/connecting-actions/): give the agent actions that run in your app.
- [Context](/guides/context/): decide what context to share with the agent.
- [Task Agent API](/reference/task-agent-api/): run focused AI tasks from your code.
