Visitor assistant
A chat bubble for your customers' own websites, answered by Connect, for people who have no account anywhere.
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.
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.
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 });startSessiongenerates 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. Passresumewith asessionIdyou kept to reopen a conversation with its transcript; aresumethat 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.chatreturns the rawResponse, not a parsed body — it answers server-sent events, and buffering them would throw away the only reason to stream. The events aredelta({text}),doneanderror({message}).bookrefuses a time nobody offered.startAtmust be one of the slots the turn's own lookup returned, andnameandphoneare collected in real fields rather than parsed out of prose. A conversation books at most one appointment; a second call answers409.
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.
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.
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.
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
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.chatanswers 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. savealways writes a draft.publishis the only thing that makes text readable by strangers, so no edit can do it by accident. AnitemIdthat does not exist is refused, never created under.unpublishcan 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 — the signed-in counterpart: assistant turns and extraction for a member of a workspace.
- API reference — the endpoints, their schemas and their error codes.