A chat bubble on your public marketing site, answered by Connect, for people who have no account yet.

### What it is for

Three things a visitor does, in one conversation: ask what the product is and what it costs, book a call with your team, or sign up and land inside the application. Nothing in it requires an account, a session, or any AI infrastructure of your own.

### What you configure, and what you cannot

Everything the assistant is allowed to say and do is configuration on your application record, set by a platform admin in Connect's console — not something you pass per request. It answers from your application's own catalogue entry: the name, the description, the feature list and the **live** plan prices, so a price change is reflected the next turn with nothing to re-paste. On top of that an operator can add positioning and the questions people always ask.

It cannot be widened from your side at call time, and that is the point rather than a limitation. There is no request field that names a workspace, a calendar or a collection, so nothing a visitor types — and nothing a prompt-injection attempt in your own chat window achieves — reaches a different tenant's data. The assistant has no read access to anything tenant-scoped at all.

### Three calls, in order

| Call | When | Needs |
|---|---|---|
| `POST /visitor/session` | The bubble opens. Answers the greeting and the suggested questions with no model call, so the first paint is instant. | `visitor:chat` |
| `POST /visitor/chat` | Every message. Streams the reply as it is generated. | `visitor:chat` |
| `POST /visitor/book` | The visitor taps a time. | `visitor:chat` |

The session answers a `sessionId`. **Keep it and send it back**, including after a reload: on a phone, following a link out of the chat and coming back is the most likely thing a visitor does, and a bubble that has forgotten them is where the conversation ends. Pass the same id to `/visitor/session` as `resume` and you get the transcript back.

### Never call these from a browser directly

They take your API key, so they belong behind your own backend — exactly like `/ai/chat`. If you are using `@cosmoops/connect-sdk`, `createConnectHandler` already exposes them on your own origin and `<ConnectVisitorChat>` is the widget; neither puts the credential anywhere near a page.

### The assistant does not decide anything

It writes sentences. It does not decide that a time is free, that an email address is verified, or that an account may be created — every one of those is a deterministic check on Connect's side, and the flows are built so that a model cannot skip one.

Booking is the clearest case: the assistant looks up real times and offers them, but the appointment is placed by `POST /visitor/book` from a time the lookup returned, with a name and phone number typed into real fields. So there is no path by which a confident model books an appointment at a time nobody offered.

### Budgets, because the callers are strangers

This is the only part of the API whose request volume is set by the public internet rather than by people holding accounts, so it is bounded in four places: per API key (your burst), per visitor address, per conversation, and per application per day. The daily ceiling is yours to set. A refusal is a sentence you can show — the assistant is busy, the conversation has reached its limit — never a stack trace, and never a disclosure of which ceiling was hit.

### Signing someone up

`visitor:signup` is a separate scope and off unless an operator turns it on, because it creates a real Connect account and workspace. The flow verifies an email address with a code before anything is created, and never asks for a password — there is none in it. See its own operations for the sequence.


## Endpoints

### POST `/api/v1/application/visitor/session`

Open (or reopen) a visitor conversation

**Scope** `visitor:chat` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Mints the conversation the other two calls act on, for one of your customers' websites.

**It returns no copy.** The greeting and the suggested questions are *your* widget settings, rendered from your own store - so your bubble paints its first frame with no call to Connect at all, and this endpoint exists only to mint or recover the conversation Connect holds.

#### Implementation

**1. Name the workspace.** `workspaceId` is the customer whose website this is, and it is checked against your application's own subscriptions - a key can only ever reach a tenant that installed you.

**2. Keep the `sessionId`** somewhere that survives a reload - `sessionStorage` is the right shape, not a cookie. Pass it back as `resume` next time and the transcript comes with it. On a phone, following a link out of the chat and coming back is the most likely thing a visitor does.

**3. Render `field`, not your own guess.** `message` is the ordinary composer; anything else means render that input type instead.

A `resume` naming another workspace's conversation, an expired one or a swept one silently starts a fresh session rather than failing - the token is the only thing being offered and it is being rejected, so there is nothing for the visitor to do about an error.

#### Errors
`validation_error` (400 - missing `workspaceId`), `forbidden` (403 - key missing `visitor:chat`, or no live subscription for that workspace), `not_found` (404 - the application is inactive), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `page` | `any` | no |  |
| `resume` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The conversation and its transcript |
| `400` | validation_error - workspaceId is required |
| `403` | forbidden - missing scope, or no subscription |
| `404` | not_found - application inactive |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `sessionId` | `string` | yes |  |
| `messages` | `array` | yes |  |
| `field` | `message` \| `email` \| `code` \| `name` | yes |  |
| `turnsRemaining` | `integer` | yes |  |
| `booking` | `any` | yes |  |

### POST `/api/v1/application/visitor/chat`

Send one visitor message and stream the reply

**Scope** `visitor:chat` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

One turn. The reply streams as it is generated, as `text/event-stream`.

**The transcript is Connect's, not yours.** Unlike `/ai/chat`, you do not send the conversation - only the `sessionId`, the workspace and the new message. That is not a convenience: on a public surface the client is the adversary, and a browser that can supply its own transcript can supply one in which anything has already been established.

**The answer comes from that workspace's *published* knowledge and nothing else** - see the `/visitor/knowledge` endpoints. Their Drive-fed Context Index is not reachable from this endpoint at all, which is what makes it safe to put a widget on a public page: everything the assistant can read was already meant for strangers.

#### The stream

Two event types, and nothing else:

| Event | Data | What to do |
|---|---|---|
| `delta` | `{"text":"..."}` | Append it to the bubble being drawn. |
| `done` | `{"toolCalls":[...],"field":"...","turnsRemaining":n,"grounded":bool}` | Render any structured result, and switch the composer to `field`. |

`grounded: false` means nothing published covered the question, so the assistant said so and offered a call. Worth recording: a run of them is a knowledge base with a gap in it, which is the most useful signal this endpoint produces.

A `done` carrying a `find_slots` result is what you draw the times from. **Draw the buttons from that result, not from the sentence** - the labels are already formatted in the workspace's own timezone, and a time the visitor taps is then one the server offered, which is what makes `/visitor/book` unable to book a time nobody was given.

#### Errors

An error before the stream opens is the ordinary envelope. Once the stream is open it cannot be, so a failure mid-turn arrives as an `error` event carrying a sentence you can show verbatim - refusals here are written as sentences for exactly that reason.

`validation_error` (400), `forbidden` (403 - missing scope, or no subscription for that workspace), `not_found` (404 - the conversation has ended; start a new one), `rate_limited` (429 - a burst, this conversation's own limit, or that customer's daily ceiling).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `sessionId` | `string` | yes |  |
| `workspaceId` | `string` | yes |  |
| `message` | `string` | yes |  |
| `assistantName` | `string` | no |  |
| `bookingEventTypeSlug` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | text/event-stream - delta, done and error events |
| `400` | validation_error - missing sessionId/message |
| `403` | forbidden - key missing visitor:chat |
| `404` | not_found - conversation ended, or no assistant configured |
| `429` | rate_limited - a burst, or this conversation's own ceiling |

### POST `/api/v1/application/visitor/book`

Book the call a visitor chose

**Scope** `visitor:chat` &nbsp;·&nbsp; **Rate limit** 300 requests / min per key

Places the appointment, against the workspace and event type the assistant is configured with. No account, no session - the same thing Connect's own public booking page does, reached from a conversation instead.

**This is a typed action, not something the assistant does.** The instant must be one the slot lookup returned, and the name and phone come from real form fields - so collect them with `inputmode="tel"` and friends rather than asking for them in prose. Two failures are impossible as a result: an appointment at a time nobody offered, and a phone number parsed out of a sentence.

Send the same `eventTypeSlug` the turn used. It must be **published for public booking** in that workspace; one that is not books nothing rather than reaching another tenant.

#### Implementation

**1. Draw the times from the `find_slots` result** in the previous turn's `done` event.

**2. Collect a name and a phone number.** Email is optional but worth asking for - it is where the confirmation goes.

**3. POST once.** A conversation books at most one appointment; a second call answers `conflict`.

#### Errors
`validation_error` (400 - check the details), `forbidden` (403), `not_found` (404 - conversation ended, or this assistant cannot book), `conflict` (409 - the slot was taken while they were deciding, or this conversation already booked. Both are sentences you can show), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `sessionId` | `string` | yes |  |
| `workspaceId` | `string` | yes |  |
| `eventTypeSlug` | `string` | yes |  |
| `startAt` | `any` | yes |  |
| `name` | `string` | yes |  |
| `phone` | `string` | yes |  |
| `email` | `any` | no |  |
| `notes` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The appointment |
| `400` | validation_error |
| `403` | forbidden - key missing visitor:chat |
| `404` | not_found - conversation ended, or booking not configured |
| `409` | conflict - slot taken, or already booked |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `startAt` | `string` | yes |  |
| `endAt` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `label` | `string` | yes |  |

### GET `/api/v1/application/visitor/knowledge`

A workspace's public knowledge base, drafts included

**Scope** `visitor:knowledge` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

What a subscribing workspace's website chat is allowed to tell strangers - and what it is not yet, because drafts are here too.

**This is the screen "what can this thing say about my business?" is answered from.** Show the whole list; the assistant only ever reads the published half, through a different code path that has no access to the drafts.

#### It is not the workspace's Context Index

Deliberately a separate store. A workspace's `ai:chat` corpus is fed by every Drive upload - contracts, payroll, customer lists - and pointing a public widget at it would let a visitor extract an internal document, helpfully summarised. Nothing arrives here except by an explicit publish, and nothing reachable from the widget's own credential can read anything else.

#### `mode` tells you how it reaches the model

| Value | Meaning |
|---|---|
| `none` | Nothing published. The assistant says it does not know and offers a call. |
| `inline` | The whole published set is carried in the prompt. The **normal and better** state for a small knowledge base - the model sees every item rather than a top-k slice. |
| `retrieval` | Too large for the prompt, so it is retrieved against. |

`indexState` on each item matters only in `retrieval` mode: `indexed` is answerable, `indexing` is on its way, and `failed` carries `indexError` - an item that silently never became answerable is what that field exists to surface.

#### Errors
`validation_error` (400 - missing `workspaceId`), `forbidden` (403 - key missing `visitor:knowledge`, or this workspace has no live subscription to your application), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes | The subscribing workspace. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Every item, plus how the published set reaches the model |
| `400` | validation_error - workspaceId is required |
| `403` | forbidden - missing scope, or no subscription |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `items` | `array` | yes |  |
| `mode` | `none` \| `inline` \| `retrieval` | yes |  |

### POST `/api/v1/application/visitor/knowledge`

Create or edit a knowledge item

**Scope** `visitor:knowledge` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

Writes a draft. **It cannot publish**, and that is the point: publishing is `POST /visitor/knowledge/publish`, a separate act with its own audit entry, so no edit can make text public as a side effect.

#### Implementation

**1. Omit `itemId` to create**, pass it to edit. An `itemId` that does not exist is refused rather than created under - a caller passing one has a stale reference, and silently creating would hide that behind a working save.

**2. Split long pages.** One item is capped, and a body over the cap is **refused rather than truncated**: truncation on a knowledge base is the worst kind of silent failure, because the assistant then answers confidently from the half that survived. Sections are also what makes retrieval work.

**3. Set `source` honestly.** `website` for text crawled from the customer's own public site - the safest input, since it is already public - `authored` for text typed into your screen, `upload` for a file they chose to publish. `sourceRef` carries the URL or the file name.

Editing an already-published item leaves it published and marks it for re-indexing. The write does no provider work, which is what keeps a save fast and keeps a save failure irrelevant to what strangers can read.

#### Errors
`validation_error` (400), `not_found` (404 - unknown `itemId`), `conflict` (409 - the knowledge base is full, or the title is empty), `forbidden` (403), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `itemId` | `any` | no |  |
| `title` | `string` | yes |  |
| `body` | `string` | yes |  |
| `source` | `website` \| `authored` \| `upload` | yes |  |
| `sourceRef` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The saved draft |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found - unknown itemId |
| `409` | conflict - knowledge base full, or invalid content |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `item` | `object` | yes |  |

### POST `/api/v1/application/visitor/knowledge/publish`

Make one item readable by strangers

**Scope** `visitor:knowledge` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

The act that changes what the public can be told, which is why it is its own endpoint and its own audit entry.

**It may do real work.** While the published set fits in the prompt this is one write. The publish that outgrows the prompt provisions a retrieval corpus and indexes everything published so far, so allow it a slower response than the save.

**An indexing failure still publishes.** The item comes back with `indexState: "failed"` and `indexError` set - a provider hiccup must not make your publish button look broken. Show the error and retry; the item is live from the prompt's point of view either way once the set is small enough, and reachable once indexing succeeds otherwise.

#### Errors
`validation_error` (400), `not_found` (404), `conflict` (409 - the item has no text), `forbidden` (403), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `itemId` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The published item |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `409` | conflict - nothing to publish |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `item` | `object` | yes |  |

### POST `/api/v1/application/visitor/knowledge/unpublish`

Take one item out of what strangers can read

**Scope** `visitor:knowledge` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

**A non-2xx here means the item is still live.** That is correct rather than unfortunate: the retrievable copy is removed before the record is flipped, so a provider that refuses the delete aborts the whole thing instead of leaving an item your screen shows as unpublished that the widget goes on quoting.

So treat a failure as "still public, try again" and say so - do not optimistically grey the row out. For a retracted price or a changed refund policy, an item that looks withdrawn and is not is the most expensive bug this feature has.

#### Errors
`validation_error` (400), `not_found` (404), `forbidden` (403), `internal_error` (500 - the index could not be updated; the item is still published), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `itemId` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The item, now a draft |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `429` | rate_limited |
| `500` | internal_error - still published |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `item` | `object` | yes |  |

### POST `/api/v1/application/visitor/knowledge/delete`

Remove a knowledge item entirely

**Scope** `visitor:knowledge` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

A published item is unpublished first, through the same path above - so its retrievable copy is gone before the record is. The reverse order would orphan a chunk with nothing pointing at it: the widget quoting text that no longer exists on any screen, unfixable from anywhere.

A `POST` rather than a `DELETE`, matching `sessions/revoke`: the id travels in the body.

#### Errors
`validation_error` (400), `not_found` (404), `forbidden` (403), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `itemId` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Removed |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `deleted` | `boolean` | yes |  |