Skip to content
Talk to an engineer

Headless

Headless mode

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.

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.

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.

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().

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

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.

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.

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.

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

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 waits for input cancels that component instead: the agent receives the cancellation, replies, and the promise resolves.

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

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.

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.

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.

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.

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 userThe 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 waitsA cancellation; the agent replies and the promise resolves
Sends a new message while a component waitsA cancellation first, then the new message
Clicks or dismisses a display-only component laterA 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.

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

navigation.ts
agent.setNavigationHandler((url) => router.navigate(url));
HandlerBehavior
(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 handlerHistory API navigation, falling back to a full page load

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

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.

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.

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>:

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:

title.ts
await agent.sendMessage(text, {
streamOptions: {
onTitle: ({ conversationId, title }) => renameInSidebar(conversationId, 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.

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

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.

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

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.

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

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.

Everything below is sent with each message.

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");

runTask works as in the other modes. See the Task Agent API.

task.ts
const summary = await agent.runTask({ taskKey: "summarize_ticket", data: ticket });
  • Stopped messages reject with name === "AbortError".
  • Everything else rejects with a FoldspaceError that has a code:
CodeWhen
INIT_FAILEDRemote config could not be loaded (for example, the agent is unavailable)
AGENT_REMOVEDThe agent was removed with remove()
INVALID_ARGUMENTA required argument is missing or invalid
CONVERSATION_BUSYA message is already running in this conversation (and isn’t waiting on a UI component)
HTTP_ERRORA request failed; error.status holds the HTTP status (401 also when onTokenExpired fails)
ACTION_LOOP_DETECTEDThe agent requested the same action with the same parameters twice in a row
MAX_TURNS_EXCEEDEDThe agent didn’t answer within 10 turns

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

useAgentChat.tsx
import { useRef, useState } from "react";
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() };
}

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.

  • 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.
  • Actions: give the agent actions that run in your app.
  • Context: decide what context to share with the agent.
  • Task Agent API: run focused AI tasks from your code.