Everything else in this SDK acts for one of *your users*. This acts for a
stranger: someone on your customer's marketing site who has no account
anywhere, asking about that business — and, if they want, booking a call with
them. Your application holds no model credential and runs no AI
infrastructure.

```tsx
import { ConnectVisitorChat } from "@cosmoops/connect-sdk/client";

<ConnectVisitorChat
  workspaceId={workspaceId}
  assistantName="Ada"
  greeting="Ask us anything about our services."
  suggestions={["What do you charge?", "Where are you based?"]}
  bookingEventTypeSlug="intro-call"
/>
```

It works on a page with **no `ConnectProvider`** — a marketing site has no
session to read — which is why `basePath` is a prop here rather than only
inherited. It defaults to `/api/connect`, the bridge's own default.

Two scopes, split because they decide different things:
**`visitor:chat`** for the conversation, **`visitor:knowledge`** for managing
what the assistant may say.

## Three calls drive a conversation

The widget makes these through your bridge. Call them directly only if you are
building your own UI.

```ts
const session = await connect.visitor.startSession({ workspaceId, page: "/pricing" });
const stream  = await connect.visitor.chat({ sessionId: session.sessionId, workspaceId, message });
const booked  = await connect.visitor.book({ sessionId, workspaceId, eventTypeSlug, startAt, name, phone });
```

- **`startSession` generates nothing.** The greeting and the suggested
  questions come off your own widget settings, so it is the one call in the
  flow that is always fast — which is what lets a bubble paint real copy
  instead of a spinner. Pass `resume` with a `sessionId` you kept to reopen a
  conversation with its transcript; a `resume` that is expired, unknown or
  another application's silently starts a fresh one rather than failing,
  because the rejected thing is a token the visitor can do nothing about.
- **`chat` returns the raw `Response`**, not a parsed body — it answers
  server-sent events, and buffering them would throw away the only reason to
  stream. The events are `delta` (`{text}`), `done` and `error`
  (`{message}`).
- **`book` refuses a time nobody offered.** `startAt` must be one of the slots
  the turn's own lookup returned, and `name` and `phone` are collected in real
  fields rather than parsed out of prose. A conversation books at most one
  appointment; a second call answers `409`.

<Callout type="warn" title="The transcript is never a parameter">
Only the conversation id and the new message travel. The caller is ultimately
a browser on a public page, and a client that can supply its own transcript
can supply one in which anything has already been established.
</Callout>

## What a visitor cannot reach

None of these methods takes an id naming anything — there is no `asUser`, no
role and no session. The workspace a call books into, the event type it books,
the knowledge it answers from and the daily budget it may spend are all read
from **your application record**, configured by a platform admin. So nothing a
visitor types, and nothing an injection attempt in your own chat window
achieves, reaches another tenant.

**Never call `connect.visitor.*` from a browser.** It takes your API key like
every other server resource. `createConnectHandler` already exposes these
three routes on your own origin for the widget to use, and they are the only
routes on that bridge with no session at all — deliberately, because the
caller has no identity for a session to carry.

<Callout type="warn" title="One thing the bridge cannot decide for you">
`workspaceId` is the browser's claim. Connect checks it against *your*
subscriptions, so the worst case is bounded to your own customers rather than
the platform's — but that is still one customer's budget spent from another's
page. An application serving several customers should resolve the workspace
from its own widget record (by the embed token in the snippet, say) rather
than trusting the field. The SDK cannot do it for you: it does not know what
your widgets are keyed by.
</Callout>

### Rate limiting sees a visitor only if you tell it

The bridge forwards the visitor's address on `x-connect-visitor-ip`.
`x-forwarded-for` cannot do this job: by the time your backend calls Connect,
that header describes the hop from *your server*, and the address the
per-visitor limiter needs is the one on the hop before it — which only your
bridge has seen.

It partitions your application's own budget fairly between visitors and grants
nothing. Omitting it is stricter, not looser: every visitor then shares one
bucket. The two limits that actually cap spending — the per-key burst limit
and the per-application daily ceiling — are not client-supplied at all.

## The knowledge it answers from

```ts
const { items } = await connect.visitor.knowledge.list(workspaceId);
const draft = await connect.visitor.knowledge.save({ workspaceId, title, body, source: "manual" });
await connect.visitor.knowledge.publish({ workspaceId, itemId: draft.itemId });
```

`list`, `save`, `publish`, `unpublish`, `delete` — and four properties worth
designing around:

- **It is deliberately not the workspace's Context Index.** `connect.ai.chat`
  answers from a workspace's Drive-fed corpus for a signed-in member. This is
  a separate store holding only what somebody explicitly published, and that
  separation is the whole reason a widget credential published in page source
  is safe: everything the widget can reach was already meant for the public.
  Nothing reaches this store as a side effect of anything else — that is the
  invariant, not an inconvenience.
- **`save` always writes a draft.** `publish` is the only thing that makes
  text readable by strangers, so no edit can do it by accident. An `itemId`
  that does not exist is refused, never created under.
- **`unpublish` can fail, and a failure means the item is still live.** The
  retrievable copy is removed before the record is flipped, so a provider that
  refuses the delete aborts rather than leaving an item your screen calls
  withdrawn while the widget goes on quoting it. Surface the error and let
  them retry; do not grey the row out optimistically.
- **A body over the cap is refused, not truncated.** Split a long page into
  sections — which is also what makes retrieval work.

`publish` may be slow: the publish that outgrows the prompt provisions a
retrieval corpus and indexes everything published so far. An indexing failure
still publishes, and comes back with `indexState: "failed"` and `indexError`
set, so a provider hiccup never makes your button look broken.

## Next steps

- **[AI](/docs/ai)** — the signed-in counterpart: assistant turns and
  extraction for a member of a workspace.
- **[API reference](/docs/api/visitor)** — the endpoints, their schemas and
  their error codes.