> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sempera.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Website widgets

> Widgets, their web chat threads, and custom components shown mid-conversation.

A widget puts an agent on a business's own website: a public voice demo any visitor can start, and a web chat that runs through it (the chat can be switched on only while the demo is). A widget always runs its own agent's published conversation. Widgets are created and configured in the Sempera App; the API reads them, reads their chat threads, and manages the custom components a widget shows.

Every route here answers in the compatibility envelope `{"data": ..., "errors": null}`, except that an agent the credential cannot read, named by `agent_id` (or `agentId`) on `GET /v1/widgets` and `GET /v1/widget/threads`, answers `404` in the standard error envelope ([Errors and limits](https://docs.sempera.org/errors-and-limits.md)), like one that never existed.

## Widgets and threads

| Route | Scope | Notes |
| - | - | - |
| `GET /v1/widgets` | `sessions:read` | The widgets on the agents this credential can read, oldest first; `agent_id` narrows to one agent |
| `GET /v1/widget/threads` | `sessions:read` | Typed web chat conversations, newest first. `agent_id`, `limit` 1 to 100 (default 20), `sort` `desc` (default) or `asc`, and `cursor` (a numeric offset). Answers `{"threads", "next_cursor"}` |

A widget reads `id`, `agent_id`, `label`, `allowed_domains`, `config` (its brand settings and the public demo's `demo_enabled`, `demo_daily_sessions` and `demo_max_seconds`), `sessions_last_24h` and `created_at`. `pathway_id`, `agent_prompt`, `messages_per_minute` and `updated_at` are always null on this read: a widget speaks for an agent, and its per-minute message limit (10 by default, 1 to 120, set in the App) is not returned here. A visitor over it is answered `429` with `retry_after_s`.

A thread reads `id`, `widget_id`, `agent_id`, `created_at`, `visitor_id` and `origin`. This historical projection does not expose the bounded live conversation: `messages`, `ended_at` and `live_agent_handoff_at` remain null. An invalid `limit`, `cursor` or `sort` answers `400`.

## Editing together

Widgets are saved in the Sempera App, and no route in this API saves one. Two owners can open the same widget and both save. Without a guard the later save would win and the earlier one would be lost without a word, so the App's save takes an optional `expected_updated_at`, the `updated_at` the screen read. The widget is saved only if it still has that `updated_at`, and the check and the save are one step, so of two saves that carry the same value exactly one is applied.

If another owner saved first, the save is refused with `409` and `code: "stale_widget"`. Nothing is written and nothing is recorded in the audit log. The answer carries the widget now on file, with its `updated_at`, so the App can show what changed and the owner can discard the edit or save it over the newer version. A value that is not a time with a zone is `400` with `field: "expected_updated_at"`. A widget of another workspace, or one deleted meanwhile, is answered as a widget that does not exist and names no `updated_at`. A save without the field behaves as it always did: the last save wins.

`GET /v1/widgets` leaves `updated_at` null, so a script that only reads widgets here has no token to send and nothing to trip over.

## Custom components

A custom component is a frame from your own website that the web chat shows when the conversation reaches one step of the agent's published conversation: a booking calendar, a product card. The frame's URL receives values the conversation has collected, as query parameters.

| Route | Scope | Notes |
| - | - | - |
| `GET /v1/widget/custom-components` | `sessions:read` | Every component on the widgets of the agents this credential can read, oldest first |
| `POST /v1/widget/custom-components` | `agents:write` | Attach a component. `200` with the component |
| `PATCH /v1/widget/custom-components/{id}` | `agents:write` | Change `widget_id` (moves the component to another of this workspace's widgets, where it counts against that widget's 20), `pathway_id`, `pathway_node`, `width`, `height`, `variables` or `iframe_url`. The whole component is checked again, so a change to `width` alone is refused with `400` when a later publish has removed the step or a variable it names |
| `DELETE /v1/widget/custom-components/{id}` | `agents:write` | Remove it. `204` |

Writes need the `owner` role: a widget speaks in the business's name to visitors nobody has met.

```http theme={null}
POST /v1/widget/custom-components
{"widget_id": "<widget id>", "pathway_id": "northwind-barbers", "pathway_node": "<step id>",
 "width": "100%", "height": "480px", "variables": ["service"], "iframe_url": "https://example.com/booking"}
```

| Field | Rule |
| - | - |
| `widget_id` | One of this workspace's widgets |
| `pathway_id` | Must be the widget's own agent id. The field name is kept for compatibility; a widget has no other conversation to run |
| `pathway_node` | A step id in the agent's published conversation. A step a later publish removes stays stored and simply stops showing |
| `width`, `height` | Whole pixels (`1px` to `9999px`) or a percentage (`1%` to `100%`) |
| `variables` | Up to 20 names of values the agent's published conversation collects. Agent environment values (`env.*`) are refused. When a name is refused, the message lists up to ten of the names this agent collects |
| `iframe_url` | An `https` address on a public host, up to 2000 characters, with no user name or password, and not the Sempera App itself |

All seven fields are required on create. A component reads those fields plus `id`, `org_id`, `created_at` and `updated_at`. Each step shows at most one component per widget, and a widget holds at most 20.

When the chat reaches the step, the visitor's browser loads `iframe_url` with each listed variable the conversation has a value for appended as a query parameter, replacing a same-named parameter already in the URL. Text is cut at 500 characters, `true` and `false` are written as words, and a value that is missing, a list or an object is left out.

## Live chats

An owner can let a typed visitor ask for a person: the widget's "Let visitors ask for a person" setting in the App, off by default. While it is off the visitor sees no button and an ask is refused. A visitor who asks waits with the agent paused; they can go back to the agent at any time, and after 5 minutes with nobody claiming the chat it returns to the agent by itself. These two routes are the desk. They answer in the standard envelope ([Errors and limits](https://docs.sempera.org/errors-and-limits.md)), need the `work_callback` capability, and see only chats on the agent named by `agent_id`.

* `GET /v1/widget-live-chats?agent_id=...` (`sessions:write`, operator role: the desk shows live visitors' words) answers `{"chats": [...], "lifetime": "chat"}`: each waiting or person-held chat with its `id`, `widget_id`, `agent_id`, `handoff` (`waiting` or `operator`), `revision`, `held_by_you`, `messages` and `expires_at`.
* `POST /v1/widget-live-chats/{chat_id}?agent_id=...` (`sessions:write`) with `action` and `expected_revision` (the `revision` you read): `claim` a waiting chat, `reply` with `text` (up to 500 characters) to the chat you hold, `reclaim` a chat another member holds (the owner, or any member of the desk, so a chat is never stranded) or `resume` to return it to the agent. Answers the chat. A changed `revision`, or an action the chat's state does not allow, answers `409 conflict`; another agent's chat, or one that has ended, answers `404 not_found`.

Live chats keep the chat's own lifetime and message limits. Their state is stored with the admitted session, isolated by workspace, and shared across servers. A server restart does not reset the five-minute wait or lose the person holding a chat. Expiry clears the conversation content; the bounded end reason remains briefly for visitor polling. What a person said is handed to the agent as conversation, never as instructions. The widget's "Every message" webhook still fires for each visitor message while a person holds the chat, with an empty `agent_reply` and `handoff` set to `waiting` or `operator`; every other message event carries `handoff: "agent"`. The "End of conversation" webhook's `messages` list each line with `role` `visitor`, `agent` or `operator`. Webhooks remain best-effort background delivery, not a durable delivery queue.

## Refusals

A refusal these routes make is `{"data": null, "errors": [{"error", "message"}]}`: `bad_request` (400) for a field that breaks a rule above, `not_found` (404) for a widget or component that is not this workspace's, and `conflict` (409) for a second component on the same step or a 21st on one widget. A missing credential, scope or role gets the standard error envelope ([Errors and limits](https://docs.sempera.org/errors-and-limits.md)).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.