`@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
versions may carry breaking changes** and patch versions never do.

## 0.56.0

### One CosmoOps login: account mode (first-party apps)

`CONNECT_SESSION_MODE=account` makes the `./next` bridge use the shared
CosmoOps account session Connect sets on the parent domain, instead of a
per-application OAuth session. Sign in once at Connect and every first-party
app knows you; signing out anywhere signs out everywhere; switching workspace
in one app switches all of them. The application must be marked first-party
in Console. Partner applications are unaffected - application mode stays the
default.

- **`connect.identity.getAccountUser(accountSession)`**,
  **`logoutAccount(accountSession)`**,
  **`switchAccountWorkspace(accountSession, workspaceId)`** and
  **`accountContinueUrl(&#123; returnTo &#125;)`** - the account-session endpoints.
  The reads answer exactly `getUser`'s `UserResponse`, through the same
  mapping, and are retried like it.
- **In account mode** (`./next`): `getConnectSession()` and `/session`
  resolve the `CONNECT_ACCOUNT_COOKIE` cookie (default
  `cosmoops_session_local`) through `getAccountUser`; `/signin` redirects to
  Connect's continue URL with `returnTo` = this app's origin plus `next`;
  `/logout` calls `logoutAccount` and clears the cookie (`Path=/`, plus
  `Domain=` from `CONNECT_ACCOUNT_COOKIE_DOMAIN`); `/workspaces/switch` goes
  through `switchAccountWorkspace`; `/callback` and One Tap do not exist. The
  app never writes the account cookie. `getConnectSessionToken()` returns the
  `acs_` account id, and `asUser` calls pass it through unchanged.
- **Migration:** in account mode a per-application session from before the
  switch is still honoured through `getUser` until it expires.
- New exports: `connectSessionMode()`, `connectAccountSession()`,
  `connectSessionCookieName(clientId)` (`./next`); types
  `ConnectAccountSessionOptions` (`./server`, `./next`) and
  `ConnectSessionOptions` (`./next`). `ConnectSessionStore` gains an optional
  `migrate()`, and `createConnectHandler` an optional `account`.

### Per-application session cookie name

In application mode the `./next` cookie is now
**`connect_session_<last 12 chars of CONNECT_CLIENT_ID>`** rather than
`connect_session`, so several apps on one host (`localhost`) stop
overwriting each other's session. The legacy `connect_session` cookie is
still read as a fallback and, once Connect accepts it, rewritten under the
new name by `GET /session`; it is never cleared, because another app may own
it. `CONNECT_SESSION_COOKIE` still overrides the name exactly (with no
fallback). If you read the cookie yourself by name, use
`connectSessionCookieName(clientId)`, or `getConnectSessionToken()`.

### Telephony: answer your own calls through Connect's carrier edge

Connect stays the carrier - the carrier account, the number inventory, rental
and billing - and an application with a telephony webhook configured in
Console owns its calls. Every carrier callback for a number it handles is
relayed to that webhook, signed with the application's webhook secret, and the
XML it answers `answer`/`fallback` with is executed on the call.

- **`verifyTelephonyEvent(rawBody, signatureHeader, secret)`** (`./server`) -
  verifies and parses a relayed `TelephonyCarrierEvent`, or throws
  `ConnectError` (`invalid_signature`, `malformed_event`). It accepts the
  header when *any* `v1=` matches, because Connect signs with every active
  endpoint secret the application holds.
- **`connect.telephony`** - `dial(input)`, `hangup(providerCallUuid, &#123; workspaceId &#125;)`,
  `browserToken(input)` and `archiveRecording(recordingId, input)`, behind the
  new `telephony:call` scope. `hangup` and `archiveRecording` are retried on a
  transient failure; `dial` never is.
- **`connect.channels.release(channelId, &#123; workspaceId &#125;)`** - the inverse of
  `claim`, under `channels:claim`.
- **`ConnectChannel.handlerApplicationId`** - the application that answers the
  number itself, or `null` when Connect does.

### Call analysis

- **`connect.ai.analyzeCall(input)`** - transcript, summary, intent, sentiment
  and action items for a recording your application stored in the workspace's
  Drive (e.g. `archiveRecording`'s `fileId`). Uses the `ai:generate` scope and
  the workspace's monthly AI budget. New type: `CallAnalysis`.

New types: `TelephonyCarrierEvent`, `TelephonyEdgeEvent`, `TelephonyDialInput`,
`TelephonyDialResult`, `BrowserTokenInput`, `BrowserTokenResult`,
`ArchiveRecordingInput`, `ArchivedRecording`, `CallAnalysisInput`, `CallAnalysis`.

## 0.55.1

### Fixed

- **`<ConnectLinkButton renderLink>` passes the button's full props through**,
  not just `href`, `className` and `children` - the `ref` and React Aria's
  press, keyboard and focus handlers included, which ends the "Ref was not
  connected" and "PressResponder" warnings and restores the pressed and focus
  styling. Clicks still navigate, `next/link` included; an `isDisabled` one now
  refuses the click and carries `aria-disabled`. Spread the props
  (`renderLink=&#123;(props) => <Link &#123;...props&#125; />&#125;`) to benefit; the widened
  parameter type, `ConnectLinkButtonRenderProps`, is exported.

## 0.55.0

### Breaking: `conversations` is removed

`ConnectClient.conversations` (`list`, `get`) is gone, and so are the
`ConnectConversation`, `ConnectConversationMessage`, `ConnectMessageFailure`,
`ConversationDetail`, `ConversationsPage` and `ListConversationsInput` types.
`GET /conversations` and `GET /conversations/&#123;id&#125;` and the
`conversations:read` scope are removed from the API.

Customer WhatsApp now belongs to the CosmoOps Messaging application, which
holds the customer's own WhatsApp Business account, its templates, opt-outs
and the human inbox. Connect's conversations came from an agent replying on a
WhatsApp channel, and no workspace could ever create such a channel, so the
endpoints only ever returned an empty list. If you read them, delete the
call. Connect's own platform WhatsApp (`connect.whatsapp.*`, OTP over
WhatsApp, notifications) is unchanged.

Existing keys that still carry `conversations:read` keep working: the scope
is dropped when the key is read, not refused.

### Send email

`connect.email.send(&#123; workspaceId, to, subject, html, text, replyTo?, fromName?, cc?, headers? &#125;)`
sends your application's own email - a support reply, a portal sign-in code -
for a workspace that subscribes to you, with no email provider account of your
own. It backs the new `POST /email/send` and needs a key scoped to
`email:send`. Server-side only.

Every message leaves from Connect's address under `fromName` (the workspace's
name by default); put your inbound address in `replyTo`. Store the returned
`messageId` - it is the Message-ID the email actually left under, and the one
a reply names in `In-Reply-To` - and thread the next reply with
`headers.inReplyTo`/`headers.references`. Addresses that hard-bounced or
complained are skipped and listed in `suppressed`, not thrown. Pass an
`idempotencyKey` so a retry cannot send a second copy.

Email is additive.

### Do-not-call

`connect.doNotCall.get(&#123; workspaceId, phone, history? &#125;)`, `.set(...)` and
`.clear(...)` read and write the workspace's do-not-call list, which Connect
now enforces before **every** outbound dial: a member's call, a partner's
`POST /calls/start`, and an agent-placed call. A listed number is refused
with `409 do_not_call`. If the list cannot be read, the dial is refused with
a retryable `503 do_not_call_unverified` rather than placed. Setting a listed
number again is a no-op, and clearing it needs a note; both are kept in an
append-only history. The list needs `do-not-call:read` / `do-not-call:write`
scopes. Write through to it wherever your own app records "do not call",
because a flag only your app knows about stops nobody's dialer.

### Prepaid credit

`connect.credit.getBalance`, `.consume`, `.refund` and `.listLedger` give your
application a prepaid rupee balance per workspace. The customer tops it up on
Connect (send them to the balance's `topUpPath` with `openConnect`; it hands
them back), Connect charges GST and issues the invoice under your SAC, and
you debit it per use.

- `consume` takes an `idempotencyKey`. A replay returns `duplicate: true`;
  the same key with a different amount is refused.
- A balance that cannot cover the debit is refused with
  `409 insufficient_credit`, unless Connect has granted your application an
  overdraft.
- `refund` must name an earlier `consume` and can never exceed it.
- Scopes: `credit:read` / `credit:write`.

### WhatsApp helpers: `@cosmoops/connect-sdk/whatsapp`

A new browser-safe subpath with Meta's template component model, template
compile and validate, template-name rules, the opt-out and consent decision,
and webhook parsing. It is the same implementation Connect runs, for an
application that runs its own WhatsApp Business account. Nothing changes
for the root entry.

### Writes for applications that act for a workspace

- **`contacts.upsertFromInteraction(&#123; workspaceId, phone, facts, source, callId?, countCall? &#125;)`**
  contributes what a call or conversation learned to the workspace's contact
  book. It is find-or-create by phone, with the same merge Connect's own calls
  use: an automated source fills blanks and never overwrites. It raises
  `contact.created` / `contact.updated`. Scope: `contacts:write`,
  server-to-server only.
- **`scheduling.holdSlot(...)` and `scheduling.confirmHold(...)`** hold a slot
  while you collect details, then confirm it. Only the application that took
  a hold can confirm it, and confirming twice is harmless. `createBooking`
  also accepts `source` (`api` | `agent-call`) and `sourceCallId`. Scope:
  `calendar:write`.
- **`ai.generate(...)`** is raw model generation, separate from `ai.chat`
  (Connect's own assistant). One model step per request; any tools you
  declare are returned to you, never run by Connect. It answers `503` until
  Connect enables it for the platform. Scope: `ai:generate`.
- **`workspaces.get(&#123; workspaceId &#125;)`** returns a subscribing workspace's
  name, legal name, account type, status and the owner's name and email,
  with nobody signed in. Scope: `workspace-profile:read`, which is sensitive
  and refuses session tokens.

### New webhook: `subscription.activated`

Fires once, when a subscription to your application first becomes active:
free, trial or first payment. It does not fire on renewals, or when a
subscription resumes from pause. Use it to provision what a new subscriber
gets.

### Drive: list and delete your own files

- `connect.drive.list(&#123; workspaceId, limit? &#125;)` returns the files your
  application uploaded to that workspace's Drive, newest first. `limit`
  defaults to 50 and is capped at 100; `truncated: true` means more exist.
  Needs `drive:read`.
- `connect.drive.delete(fileId, &#123; workspaceId &#125;)` removes one of them and gives
  the space back to the workspace's quota. Needs `drive:write`.

Both reach only files your application uploaded. A member's file, another
application's file and an unknown id all answer the same 404, so a retried
delete can treat 404 as done.

### Calling readiness from your own server

`connect.calls.getCallingReadiness` now also accepts `&#123; workspaceId &#125;` in
place of `&#123; asUser &#125;`, so a dialer can check before it places a call with
nobody signed in. The answer is the same, except that `canManage` is always
`false` because there is no member.

Everything except the `conversations` removal is additive.

## 0.54.0

### Report usage against your plan limits

`connect.entitlements.reportUsage(workspaceId, &#123; records: 1840 &#125;)` tells
Connect how many of your own limited resources a workspace has, so the
customer's Billing → Usage tab shows "1,840 of 5,000" with a meter instead of
a bare ceiling. It backs the new `POST /usage` and needs a key scoped to
`usage:report`.

Send the **current count**, not a change - the number you already pass to
`canAdd`. Reporting twice is harmless and a lost report is healed by the next,
so no idempotency key is sent. Keys you leave out keep their last value; a key
outside your limit catalog is refused by name (`400`), and a workspace that is
not your subscriber answers `404`. Connect enforces nothing on these numbers -
`canAdd` is still the gate - so report after the user's action and catch.

### Subscribe with a seat count

`buildCheckoutUrl(&#123; ..., seats: 8 &#125;)` (and the checkout hooks that take
`CheckoutUrlOptions`) seeds Connect's checkout with the total seats on a
per-seat plan, so "Subscribe for my 8-person team" is priced in one payment
rather than the included count now and a prorated top-up after. Ignored on a
plan change and on plans that do not sell seats one at a time.

Additive; nothing to change for existing integrations.

## 0.53.0

### Record a no-show

`connect.scheduling.recordAttendance(&#123; workspaceId, bookingId, attended &#125;)`
marks an appointment the attendee missed as `no_show` (`attended: false`),
and takes a mistaken one back to `confirmed` (`attended: true`). It backs the
new `POST /scheduling/bookings/&#123;bookingId&#125;/attendance` and needs
`calendar:write`.

`no_show` has always been on the `Booking` wire and nothing could set it, so
any attendance figure built on it read 100%. There is no "held" to record - a
`confirmed` booking whose time has passed already is one. A booking that is
not `confirmed`/`no_show`, or has not started yet, answers `409`. The slot is
not released and the attendee is not notified.

Additive; nothing to change for existing integrations.

## 0.52.0

### Calls survive a dropped connection, or end saying why

When the browser goes offline mid-call, the in-call bar now says
"Reconnecting…" and Plivo's SDK renegotiates the audio by itself when the
network returns - a few seconds' blip costs the call nothing. If it is still
offline after 30 seconds, the call is ended in the browser instead of ticking
on a dead line (the carrier's own hang-up cannot reach an offline browser, so
the bar used to sit at "in call" indefinitely). A call that ends that way says
"Your internet connection dropped, so the call ended." and offers
**Call again**; `onEnded` / `onCallEnded` fire as for any other end.

Pressing Call while offline now answers "You're offline. Check your internet
connection and try again." instead of a phone-service sign-in error.

`ConnectActiveCall` gains `reconnecting` and `canRedial`, and
`ConnectActiveCallBar` an optional `onRedial`. Nothing to wire - the provider
does all of it.

## 0.51.0

### Inbound call routing

`connect.calls.getRouting(&#123; asUser &#125;)` reads the workspace's inbound call
routing flow and its published agents; `connect.calls.setRouting(&#123; routing,
asUser &#125;)` sets it (smart CRM owner routing, AI agent, single number, team,
fallback). Needs `CHANNEL_MANAGE` to write.

### Add-ons billed yearly

`Addon.billingCadence` is `"annual"` for an add-on billed once on the first
cycle that holds it and on each anniversary, even on a monthly plan; render
its price as "/year". `"plan"` or absent means every plan cycle, as before.
`ConnectPlans` already draws the suffix. Additive; nothing else changed.

## 0.50.0

### Background tasks

`connect.tasks.enqueue(&#123; workspaceId, queue, payload, name?, scheduleAt? &#125;)`
puts a task on one of your application's Cloud Tasks queues, declared in
Console (scope `tasks:write`). Cloud Tasks delivers `&#123; workspaceId, queue,
payload &#125;` to the queue's URL with a Google-signed OIDC token;
`createConnectTaskHandler(&#123; handle &#125;)` - or `verifyConnectTask(request)` -
from `./server` or `./next` verifies it. Set `CONNECT_TASK_INVOKER` to the
invoker account the platform gives you. `name` dedups per workspace;
`status: "unavailable"` means the Connect deployment has no queue, so keep a
fallback. Additive; nothing else changed.

## 0.49.0

### Calling readiness

`connect.calls.getCallingReadiness(&#123; asUser &#125;)` answers whether the
workspace may legally make each kind of call - `runs` covers service and
sales (`promotional`) calls, dialled by a rep (`preview`) or by software
(`automatic`), each with the first `reason` in the way - plus the steps left
on Connect's calling compliance journey and a `setupUrl` to finish them.
Member-only, on the `calls:place` scope. Read `runs` rather than deriving
anything from `steps`: Connect evaluates them with the gates its dialer
refuses on. Additive; nothing else changed.

## 0.48.0

### Calls declare a purpose

`<ConnectCallButton purpose="promotional">`, `startCall(&#123; phone, purpose &#125;)`
and `connect.calls.startBrowserCall(&#123; phone, asUser, purpose &#125;)` take an
optional `ConnectCallPurpose` - `"service"`, `"transactional"` or
`"promotional"`. Connect records it on the call and places the call from a
workspace number allowed to carry it: in India a regular number carries
service and transactional calls only, and promotional calls need a 140-series
number with an approved DLT header. Omit it and the call is placed as before.

While the workspace's Connect deployment runs `CALL_PURPOSE_ENFORCEMENT=shadow`
(the default), a call no number may carry is still placed and only logged;
under `enforce` it is refused with a `412` whose message names the number
series needed. The bridge drops a purpose that is not one of the three
rather than forwarding it to be refused.

## 0.47.0

### `connect.integrations.request()` reaches the endpoints a toolkit has no action for

A Composio toolkit's action catalogue is not the provider's API: Zoho Books
ships no action to create a sales order, an estimate or an invoice.
`integrations.request(workspaceId, &#123; toolkitSlug, method, endpoint, query, body &#125;)`
sends one request to the provider through the workspace's connected account,
under the `integrations:execute` scope - Connect still holds the credential.

`endpoint` is a path relative to the provider's API base (`/salesorders`).
Connect refuses an absolute URL, a query string in the path and dot segments,
and there is no way to set a header: whoever chooses the host chooses where
the credential goes. The call resolves whenever Connect relayed it - read the
provider's own `status` and `data` from the result.

### `initiateConnect` takes `connectionData`

Pre-fills a toolkit's own connection fields on the provider's screen.
`&#123; extension: "in" &#125;` puts a Zoho connection on the India data centre, which
otherwise defaults to `zoho.com`.

### A 503 without `Retry-After` is no longer retried

The SDK retried every 503 on an idempotent request, twice, sleeping the
server's `Retry-After` between. Connect now leaves that header off a 503 it
knows will not heal - a dependency rejecting Connect's own credential, such
as the provider behind `connect.integrations.list()` - and the SDK now takes
that at its word: no `Retry-After`, no retry, and `ConnectError.isRetryable`
is `false`. A misconfigured dependency used to cost every read three round
trips and about four seconds of sleeping to return the same error. Every 503
Connect means to be retried still carries the header, so nothing that
recovers today stops recovering.

## 0.46.0

### `secondary` buttons are soft indigo

`<Button variant="secondary">` and `<ConnectLinkButton variant="secondary">`
now paint an indigo tint with indigo text instead of the neutral grey,
so a view reads as one solid green primary action beside indigo secondary
ones. `tertiary` stays neutral, for quieter in-row controls. New tokens
`--secondary-soft`, `--secondary-soft-hover` and
`--secondary-soft-foreground`, in both themes. To keep the old look,
redefine `.button--secondary` after importing `theme.css`.

### `ConnectPageShell` plays a route transition

Every page drawn in `ConnectPageShell` now fades the outgoing page out and
the arriving one in with a slight rise, using React's `<ViewTransition>`.
The animations ship in `theme.css` (`connect-page-enter`/`connect-page-exit`)
and respect `prefers-reduced-motion`. Nothing to wire.

- **Next.js App Router only, in practice.** `<ViewTransition>` exists in
  the React canary Next bundles and in no stable React yet. The shell looks
  it up at runtime, so on any other host the page renders exactly as before,
  with no motion and no error.
- **New prop `transition`** (default `true`). Pass `transition=&#123;false&#125;` if
  your app already animates its routes, or the two transitions play over each
  other.
- Where a *layout* draws the shell, such as a record page with tabs, entering
  and leaving the record animates and switching tabs does not.

### `connect.drive.getUsage()`

`connect.drive.getUsage(&#123; workspaceId &#125;)` returns `&#123; usedBytes, limitBytes &#125;`
for the workspace's Drive - the same limit an upload is refused against with
`quota_exceeded`, so a usage meter agrees with the refusal. `limitBytes: null`
is unlimited. Needs `drive:read` and an active subscription to your
application. New type: `DriveUsage`.

### Components follow HeroUI's colour roles

No API changes; these are colour fixes inside the components.

- **Links are links.** The calling notices and the grievance contact draw in
  `--link` (the indigo) instead of the green, which is for actions.
- **State colours as text are legible.** Error messages, the usage meter's
  overdraw, banner text, the trial note and the AI widgets' tone text used the
  fill colours (`text-danger`, `text-accent`, `text-success`, `text-warning`)
  as text, which measures under 3:1 on a light surface. They now use HeroUI's
  `*-soft-foreground` tokens, which HeroUI derives for exactly that and which
  follow a host's re-themed `--accent`/`--danger`.
- **Chips and destructive buttons use HeroUI's own variants.** Status and
  saving chips are `<Chip color variant="soft">` instead of hand-painted
  classes; Cancel subscription, Sign out and Disconnect are
  `variant="danger-soft"` instead of a tertiary button painted red.
- The filter tabs' keyboard ring uses `--focus`, like every HeroUI component.

## 0.45.0

### `theme.css`: a secondary colour

The shared theme gains a second brand colour, a deep indigo, beside the
green. HeroUI v3 has a single brand role (`--accent`), so the indigo adds no
classes. It fills two roles HeroUI components already read:

- **`--link`** - `<Link>`, prose anchors and `text-link` now draw in indigo
  rather than in body-text colour.
- **`--focus`** - the keyboard focus ring on every component is indigo rather
  than green, so a focused primary button is no longer a green ring around a
  green button.

`--secondary` holds the value both read from, so a platform restyling them
redefines one token after the import. Green stays the colour of action;
indigo never paints a fill. The current breadcrumb item, which HeroUI also
draws with `--link`, is pinned to `--foreground` so the page you are on does
not look like a link.

**What moves on upgrade:** links and focus rings change colour. Nothing else
does.

## 0.44.0

### `ConnectCall.dialledAt`: was the person's phone rung?

`unansweredReason: "cancelled"` covers two calls that need opposite handling:
your user hanging up while the person's phone rang, and your user giving up
before anybody was rung. The first left a missed call on the person's phone;
the second never happened. Nothing on the call told them apart, so an app
reading a call back after hang-up had to skip both and wait for the webhook.

- **`dialledAt`** on `ConnectCall` (from `calls.get()` and `calls.list()`) is
  when Connect asked the carrier to ring the person, on a `member` call. Set
  means a cancelled call was a missed one - file it as missed, exactly as
  `call.missed` would. `null` means nobody was rung - file nothing. Connect's
  webhooks already follow the same rule.
- It is on the record before any hang-up can be, so it never needs a retry.
  `null` on agent calls and on member calls placed before this release.

Nothing to wire if you file calls from `call.completed` / `call.missed` only.

### `calls.list(&#123; placedByApplication: true &#125;)`: the calls your UI placed

A call placed with `<ConnectCallButton>` leaves from the workspace's own
number, which is assigned to no application - so `calls.list()` never
returned it, and an app whose webhook was delivered to another deployment had
no way to find out the call happened. `placedByApplication: true` (with an
optional `workspaceId`) lists exactly those calls, newest first: reconcile a
call log from it.

### Call recording, from inside your app

`connect.calls.getCallSettings(&#123; asUser &#125;)` and
`connect.calls.setCallRecording(&#123; recordingEnabled, asUser &#125;)` read and switch
the workspace's call recording - the same switch as Connect's Calling screen.
`canManage` is the member's own `channel:manage`; draw the switch disabled
when it is false, because a save is refused `403`. The change applies to calls
started afterwards and is audited against the member. Needs `calls:place`,
which a key placing calls already holds.

### The "Call ended" card shows the minutes left

After a call, `<ConnectCallProvider>` re-reads the calling balance and the
card now says how many minutes are left once that read lands, staying a few
seconds longer to show it. Nothing to wire.

## 0.43.0

### Recorded calls: transcript, summary, intent and action items

A workspace can now switch on **call recording** (Connect > Calling). Calls
placed with `<ConnectCallButton>` are then recorded, the person called hears
"This call may be recorded" as they answer, and a few minutes after the call
Connect has transcribed it and read it.

- **`call.analyzed`** - a new webhook event, sent after `call.completed` for a
  recorded call. It carries `summary`, `intent` (a few words, e.g.
  `"pricing enquiry"`), `sentiment`, `language`, `actionItems` (`title`,
  `dueDate` as `YYYY-MM-DD` or `null`, and `owner`: `rep` or `customer`) and
  `recordingFileId`. Subscribe to it on your webhook in Connect. Typed as
  `CallAnalyzedEvent`.
- **`calls.get(callId)`** returns the same reading as `call.analysis`, and
  the transcript as the call's `turns` - your user as `agent`, the person
  called as `user`. `analysis` is `null` for a call that was not recorded.
- **The recording** is a Drive file: `connect.drive.getFileUrl(recordingFileId, &#123; workspaceId &#125;)` with a
  `drive:read` key plays it.

Recording is the workspace's choice, off by default, and adds ₹0.30 + GST per
minute to the call - nothing to wire on your side to offer it.

## 0.42.0

### `connect.whatsapp` - your own approved templates, to a workspace's customers

- **`whatsapp.send(&#123; workspaceId, template, to, values, consent &#125;)`** sends one
  of your application's Meta-approved templates - named by slug, resolved by
  Connect as `app_<yourApplicationKey>_<slug>` - through Connect's WhatsApp
  sender. Needs the `whatsapp:send` scope.
- **`whatsapp.template(slug)`** answers `live` and the `variables` a send must
  carry. Offer WhatsApp on a screen only when `live` is true.
- `consent.collectedVia` records an opt-in your application collected, only
  when Connect has none for the number. A number that opted out stays opted
  out: the send fails with `opted_out`.


### Ask AI explains failures instead of printing them

Nothing to wire - update and the panel behaves better on a bad day.

- **A tool step that fails says so where its result would have been.** When
  Connect cannot reach your application's MCP server - it is down, deploying,
  or timing out - the step renders a short inline notice such as
  *"CosmoOps CRM can't be reached right now, so nothing was looked up. Try
  again in a few minutes."*, and the assistant answers in the same plain
  words. It used to answer `TypeError: fetch failed`. Your own MCP server's
  refusals (`isError: true` with a text block) are shown the same way, so
  write them for your user. Reopened conversations in History show them too.
- **Request failures are worded by cause**, never shown raw: offline, signed
  out, no access, throttled, or Connect having trouble. The raw error still
  goes to the browser console for whoever is debugging.
- **"Try again"** re-asks the failed question in place of the failed exchange.
  It is withheld where a retry cannot help - signed out, no access, or Ask AI
  switched off for the workspace.

### `streamResource` throws `ConnectError`

A failed `streamResource()` from `useConnect()` now throws a `ConnectError`
carrying `status` and `code`, rather than a bare `Error`. The message is
unchanged and `ConnectError` extends `Error`, so existing `catch` blocks keep
working; branch on `error.status` instead of the message if you do.

### Every trip to Connect comes back: `openConnect` and `handoff`

Each Connect button in this package - the user menu's Profile and Billing,
`ConnectManageSubscriptionButton`, `ConnectInvoicesButton`,
`ConnectProfileButton`, "Manage on Connect", checkout, a readiness blocker's
fix, the access gate - now ends back in your app when the task is done. Most
of them used to open a new tab that went nowhere: the customer finished on
Connect, stayed there, and your tab still showed the old state.

```tsx
<ConnectProvider handoff="popup">   {/* the default */}
```

- **`"popup"`** (default) opens Connect in a new tab that **closes itself**
  when the task is done and tells your page first. Every Connect component on
  the page then re-reads, so a new plan or a fixed payment shows up without a
  reload. A blocked popup falls back to `"redirect"`.
- **`"redirect"`** sends this tab to Connect and back. Prefer it for an
  installed PWA or an app inside an iframe.

**Register your app's origin** among your client's redirect URIs in Connect.
Connect only sends somebody back to an origin registered for your client; an
unregistered one still reaches Connect, it just gets no way back.

For your own buttons, `useConnect().openConnect(path)` does the same thing -
`openConnect("/calendar/settings")`, or a readiness blocker's `resolveUrl`.
Call it from a press handler. `onHandoffResult` on the provider tells you how
each trip ended (`completed`, `cancelled`, `closed`); a redirect return
arrives as `?connect_result=`, which the provider reads and strips.

Your bridge now reports your public `clientId` on `/session`, so you do not
need to pass `clientId` to `<ConnectProvider>` for any of this, and
`useConnect().clientId` is filled in from it.

Behaviour changes worth knowing:

- **`<ConnectAccessGate>`'s fix button follows `handoff`.** It used to
  navigate this tab with `?next=`, which only Connect's subscribe page
  honoured - a lapsed payment, a missing payment method or verification left
  the customer on Connect. It now comes back from every blocker.
- **`useConnectCheckout` no longer reports `blocked`.** A blocked window
  falls back to a same-tab trip instead of asking the customer to click a
  link. The value stays in `ConnectCheckoutStatus` so code handling it still
  compiles.
- `ConnectAppsGrid` still opens a *subscribed* application in a plain new
  tab - that is leaving for another product, not a task on Connect.

## 0.41.0

### Calling from the browser: `<ConnectCallButton>`

A signed-in member can now ring a number from your app - through the
**workspace's own number**, so the person called sees the business, never the
member's phone. Calls are **not recorded**, and every one lands in Connect's
call log with who placed it, whether it was answered and how long they talked.

```tsx
import { ConnectCallProvider, ConnectCallButton } from "@cosmoops/connect-sdk/client";

// once, inside <ConnectProvider>, beside your app shell - a call outlives the page
<ConnectCallProvider onCallEnded={({ callId }) => callId && logCall(callId)}>
  {children}
</ConnectCallProvider>

// wherever a person can be rung
<ConnectCallButton phone="+919812345678" name="Asha Rao" />
```

`<ConnectCallProvider>` holds the call and draws the in-call bar (mute, hang
up, a timer), so navigating mid-call does not hang up. `useConnectCall()` is
there for your own button. Plivo's browser SDK - now this package's one
runtime dependency - is loaded only when someone presses Call.

**Log the outcome on your server**, not from the browser: `onEnded` /
`onCallEnded` hand you the call id, and `connect.calls.get(callId)` reads
`status`, `talkSeconds`, `unansweredReason` and `placedByUserId`. A browser is
not a witness a call log should trust, and the carrier's report reaches
Connect a moment after the line drops - read again if `status` is still
`dialing` or `answered`.

**Two halves of permission, both required.** Your API key needs the new
`calls:place` scope (and `calls:read` to read outcomes); the member needs
"Make calls" - from their workspace role, or a role on your application
carrying `connect:call:place`. Nothing here works server-to-server: a call is
placed by a person. Re-run your seed with the new scopes.

New on `ConnectCall`: `handler` (`agent` | `member`), `placedByUserId`,
`talkSeconds` and `unansweredReason`. `CallStatus` gains `dialing` and
`unanswered`, which outbound calls already passed through - if you switch on
it exhaustively, add the two cases.

Server-side, `connect.calls` gains `getBrowserCallingToken`,
`startBrowserCall` and `cancelBrowserCall`; `createConnectHandler` serves
them as `POST /calls/&#123;browser-token,start,cancel&#125;`. You should not need to
call either directly.

## 0.40.0

### `workspaces.create()` exists now, and the switcher's "Create workspace" row is inline

0.37.0 added `alwaysShow` and said, deliberately, that there was no
`workspaces.create()` - creating a workspace was Connect's job, tied to
KYC/KYB and a billing profile. That reasoning held for a tenant's *first*
workspace, which still mints a tenant, an Identity Platform account and a
password reset. It never applied to a tenant's *second* workspace: opening a
branch office under a tenant the caller already owns needs none of that -
`CreateWorkspaceUseCase` just checks ownership, the tenant's plan, and the
business-email-domain rule.

So `alwaysShow`'s "Create workspace" row now opens
`<ConnectCreateWorkspaceModal>` inline - name, individual-or-business - and
switches into the result automatically when it is possible
(`workspace.inAudience`), rather than opening Connect in a new tab and
leaving the member to find their way back. Needs the new `workspaces:create`
scope on your API key.

```tsx
import { ConnectCreateWorkspaceModal, useCreateWorkspace } from "@cosmoops/connect-sdk/client";
```

Both are usable standalone, not only through the switcher. `useCreateWorkspace()`
is not optimistic, same as every other scheduling/workspace write here: the
tenant's plan is what decides whether another workspace is allowed at all,
and that is a question only the server can answer. There is still no
capacity read to show ahead of time - submit and show the server's own
refusal, the same posture Connect's own "Create workspace" button takes.

`ConnectWorkspaceSwitcherProps.alwaysShow`'s doc comment, and
`WorkspacesResource`'s, are both updated; nothing about the prop's name or
default changed, so an existing host that set `alwaysShow: true` for the old
link-out gets the inline flow with no code change on its side beyond the
version bump and the new scope.

## 0.37.0

### The workspace switcher can stay visible at one workspace, and offer a second

`ConnectWorkspaceSwitcher` has always hidden itself below two workspaces -
right, since a switcher with one option teaches nothing. But a partner with no
way to *create* a workspace from its own chrome had no way to help somebody
discover that a second one is possible, either. `alwaysShow` is the opt-in:

```tsx
<AppShell workspaceSwitcher={{ alwaysShow: true }} /* ... */ />
```

Off by default, so every existing host keeps today's behaviour. On, the
dropdown stays mounted below two workspaces and gains a "Create workspace"
row that opens Connect - there is still no `workspaces.create()` on the SDK,
deliberately: creating one is tied to KYC/KYB and a billing profile, which is
Connect's job and not something an API key should be able to trigger.

### A one-time plan no longer renews under the annual toggle

`groupPlansByTier` filed a `billingInterval: "one-time"` plan as that tier's
*annual* plan, because `PlanTier` only ever had two slots. For a tier that
also sold a recurring plan under the same `planGroup`, that meant `annualSaving`
compared a monthly charge against a one-time lump sum and produced a bogus
"Save X%" badge, and switching `ConnectPlans`'s toggle to annual showed the
one-time purchase as if it were this year's discounted rate on a plan that
renews.

`PlanTier.oneTime` is the fix - its own slot, which `annualSaving` and the
annual/monthly toggle never read from. A tier that is *only* a one-time plan
ignores the toggle entirely now, rather than happening to work by the other
slot being empty.

## 0.36.0

### A workspace's billing details can be read, so a tax id is typed once

`workspaces.billingProfile(&#123; asUser &#125;)` returns the legal name, GSTIN,
registered address and entity form the customer gave CosmoOps so we can
invoice them.

It exists for one shape of problem: an application that prints documents - a
CRM's quotation letterhead - needs the same three facts about the same legal
entity, and the only way to get them was to ask the customer again. Two copies
of a GSTIN diverge, and on an Indian quotation the first two characters of that
GSTIN are what decide CGST+SGST versus IGST.

```ts
const token = await getConnectSessionToken();
const billing = token ? await connect.workspaces.billingProfile({ asUser: token }) : null;
// offer "Use my billing details" — then keep your own copy
```

**It is a prefill source, not a shared record.** This is the *Bill To* side -
who we invoice - and your letterhead is the *Bill From* side. They are the same
facts for most customers without being the same field: a group that bills us
through a holding company while quoting under a trading subsidiary has to stay
expressible. So copy it on first setup and keep your copy authoritative. There
is deliberately **no write form** - an application must not edit the details
its own invoices are raised against.

**Nothing in it is verified.** It is self-declared in the customer's billing
settings, not the KYB record; no PAN check stands behind it. A convenience,
never evidence of identity.

**It needs `billing:read` on the signed-in member**, on top of `workspaces:read`
on your key. A tax identity belongs to whoever can already see it in Connect's
billing screens - an owner, admin or manager - and it cannot be granted through
a role on your application. So an ordinary member gets a `403` here where they
get a roster from `members()`: **treat that as a normal branch**, offer the
prefill to whoever can use it, and let everyone else type their own details.

`razorpayCustomerId` is not returned. It is a payment-provider handle no
partner has a use for.

### `getConnectSessionToken()`, so `asUser` works from a Server Component

Every `asUser` call needs the caller's opaque session token, and there was no
way to obtain one inside a Server Component: `getConnectSession()` answers
*who* the caller is and deliberately does not hand back the credential. A page
that wanted its own workspace's roster had to reimplement the cookie read or
route the call through the browser.

```tsx
const token = await getConnectSessionToken();
const members = token ? await connect.workspaces.members({ asUser: token }) : [];
```

**It is the cookie, unverified** - no round trip, and no check that the session
is still live. A non-null answer means a cookie was present, not that it works;
Connect is what answers `401` for a revoked one. Use `getConnectSession()` for
"is somebody here" and this for "hand Connect the credential they arrived
with".

### `SDK_VERSION` matched the published version again

It still read `0.35.0` after the version bump, so every request from this
release would have carried a `User-Agent` a version behind. `src/sdk.test.ts`
is what caught it, and is the reason the constant is pinned to
`package.json` rather than trusted.

### A diary and a roster could not be joined

One field, `ConnectWorkspaceMember.memberId`, without which no integration
could say who an appointment belongs to.

`Booking.hostMemberIds` holds `WorkspaceMember.id` values, and so does
`listBookings`' `hostMemberId` filter. `workspaces.members()` returned
`userId` and no member id at all - so the two sets of ids had nothing in
common, and a partner could filter a diary by a value it had no way to read
back.

That made two obvious features unreachable rather than merely awkward: a host
column on a calendar, and narrowing a shared calendar to one person. Both fail
*quietly* if you reach for `userId` instead, because a filter nobody matches
returns an empty window, which reads as a free afternoon rather than a bug.

```ts
const { members } = await connect.workspaces.members({ asUser });
const byMemberId = new Map(members.map((m) => [m.memberId, m]));

const { bookings } = await connect.scheduling.listBookings({ workspaceId, asUser });
bookings.map((b) => b.hostMemberIds.map((id) => byMemberId.get(id)?.displayName));
```

The two ids are not interchangeable and neither is derived from the other:
`userId` identifies the person everywhere else in this API, `memberId` is what
the calendar speaks in. It is workspace-scoped and opaque - do not address a
member by it anywhere else.

Additive, so nothing breaks. `Booking.hostMemberIds`' own doc comment claimed
these were already the ids `workspaces.listMembers` returns, which was wrong
twice over - that method is `workspaces.members()`, and until now its rows
carried no id these could be matched against.

## 0.35.0

One addition, `connectProviderProps()`, for a mistake both applications built
on this SDK shipped before anyone noticed.

### Your server and your browser can address different Connect instances

`CONNECT_APP_URL` points the server at a non-production Connect. It is not
`NEXT_PUBLIC_`, so it never reaches the browser, and `ConnectProvider` defaults
`appUrl` to the public instance independently. Point one and not the other and
nothing fails: your API reads go to staging while every destination the browser
builds opens **production** - the user menu's Profile and Billing rows, the app
launcher's link for an unsubscribed application, `ConnectAccessGate`'s subscribe
target, and checkout.

The docs said to pass both. That was a rule to remember rather than a shape to
follow, and the sentence next to it - "the provider takes no props, which is the
point" - was about `clientId` and read as being about all of them.

```tsx title="app/layout.tsx"
import { connectProviderProps } from "@cosmoops/connect-sdk/next";
import { ConnectProvider } from "@cosmoops/connect-sdk/client";

<ConnectProvider {...connectProviderProps()}>{children}</ConnectProvider>
```

It returns `&#123; appUrl &#125;`, resolved from `CONNECT_APP_URL` and falling back to the
production origin - so the line is correct in every environment and you write it
once. Props you pass yourself still win, as long as the spread comes first.

**Call it in the Server Component, not in your shell.** It reads a server-side
variable, so it ships from `/next`, which a Client Component cannot import -
attempting it fails the build naming `this-entry-is-server-only`. A shell that
needs `usePathname()` takes the result as a prop; `&#123; appUrl &#125;` is a plain
serializable object, so that costs nothing.

Already have a helper that owns this origin - because you also build your own
links into Connect - keep using it. One function owning the read beats this one
sitting beside it.

Nothing changes if you were already passing `appUrl`, and nothing breaks if you
were not: the default is unchanged.

**This was never a security issue**, which is worth saying because it sounds
like one. `appUrl` is a navigation target you set from your own environment: it
carries no credential, no gate or permission check reads it, it is never
reachable from a URL or a request body, and redirect URIs are still matched
exactly server-side. The worst case is a real charge in the wrong instance, not
a way in.

### The sidebar's nav rows are 36px, so a ten-route rail stops scrolling

`AppShell`'s rail is the only part of the shell that grows with *your* route
count, and `ConnectSidebarBody` is the only part that scrolls - so a rail that
overflows overflows there. Ten destinations at the old 40px came to 436px of
nav against 273px of fixed chrome (header, workspace switcher, secondary-nav
footer, account footer), which needs a 709px viewport to sit still; a 1366x768
screen has about 678px of it. The nav scrolled under its own footer on the most
common small laptop, which reads as a rail with destinations missing from it.

Nav rows - and every 20px-glyph row, so the secondary nav and the app launcher
too - are now **36px**, and the scrolling region's own vertical padding drops
from 8px to 4px a side. That is 56px back on a twelve-row integration, enough
that the same rail sits still. 36px is still past WCAG 2.5.8's 24px target
minimum, with the row's full width as the hit area.

**The two 32px-glyph rows stay at 40px** - the workspace switcher and the
account row. There are exactly two of them, neither scales with your nav, and
both need the height: a 32px avatar in a 36px row has 2px of air, and the
account row's label is two lines.

**Shrinking the icons would not have done it.** The glyph box is a flex
centring container for an icon *you* supply at your own size, so a smaller box
buys no vertical space and only pushes the icon off the 32px horizontal centre
the collapsed rail is built on - `RAIL_GUTTER + inset + glyph/2 = RAIL_COLLAPSED / 2`
is what lets the rail collapse without anything drifting sideways.

Nothing in your code changes. If your nav is long enough to still overflow, the
levers are the row count and `secondaryNav` - a rail is navigation, and past a
dozen rows a scrolling one is a sitemap.

### `doctor` now catches it

`npx @cosmoops/connect-sdk doctor` reports a **note** when `CONNECT_APP_URL` is
set and the layout holding `<ConnectProvider>` passes no `appUrl`, with the fix
in the output. A note rather than a failure, so it cannot break a CI run: the
prop may be threaded from the layout into a `"use client"` shell, which reading
the layout cannot see.

### The rail fits a ten-destination nav on a laptop

`AppShell`'s nav rows, its launcher and its secondary-nav rows are **36px**
rather than 40px, and the one region that scrolls (`ConnectSidebarBody`) carries
`py-1` rather than `py-2`. The two rows with a 32px glyph - the account row and
the workspace switcher - stay at 40px: there are exactly two of them, neither
grows with your nav, and both need the height (a 32px tile in a 36px row has 2px
of air, and the account row's name-over-email label measures ~32px on its own).

Ten destinations at 40px came to 436px of nav against 273px of fixed chrome,
which needs a 709px viewport to sit still - a 1366x768 screen has about 678px of
it, so the nav scrolled on a perfectly ordinary laptop. Four pixels a row is 48px
back across the twelve rows a real integration draws. 36px is still comfortably
past WCAG 2.5.8's 24px target minimum, with the row's full width as the hit area.

The glyph box is deliberately unchanged at 20px. It is a flex centring container
for an icon **you** supply at your own size, so shrinking it buys no vertical
space and only pushes the icon off the 32px centre the collapsed rail is built
on. If you pass an icon larger than 20px it will now sit in a slightly tighter
row; `size-5` remains the recommendation.

### Fixed: the User-Agent reported the wrong version

`SDK_VERSION` was left at `0.34.0` when 0.34.1 shipped, so every request this
package made identified itself as the previous release. It is in step again, and
the assertion that pins it to `package.json` is back to green - it had been
failing since that release, unnoticed because the publish guard runs the build
and the pack check rather than the test suite. Diagnostic only; nothing behaved
differently.

## 0.34.1

Two tabs, or one abandoned sign-in and one fresh one, could not both work -
and when the older one came back it was bounced into a loop with nothing on
screen explaining it. If a customer has told you "I signed in and it showed me
the login page again", this is it.

### One browser may have several sign-ins in flight

`createConnectHandler`'s state cookie held a single `state:next` pair, so the
second `GET /signin` overwrote the first. The older round trip then came back
to a cookie naming a state the handler had never heard of, was answered with
`connect_error=state_mismatch`, and - because that sends the visitor to a page
your own session gate guards - got silently re-signed-in by your app, landing
them back on Connect's sign-in screen.

The cookie now holds up to **five** pending round trips, newest first, and a
callback consumes only its own entry. So finishing in one tab leaves the other
able to finish too, and each returns to the destination *it* asked for rather
than whichever one was started last.

Nothing in your code changes. The cookie's internal format did change -
entries are `state:flags:next` with the destination percent-encoded, joined by
`|` - and the handler still reads the old single-pair format, so sign-ins in
flight across your deploy complete rather than failing.

### Both retry bounds work for the first time

A `state_mismatch` had no bound at all, and `login_required`'s was
unreachable: the callback looked for `connect_retry=1` on its own URL, but
that parameter is set on `/signin`, and the `redirect_uri` handed to Connect is
a registered exact-match value that cannot carry extra query parameters. It
never arrived, so `login_required` could loop indefinitely too.

The marker now travels on the flow's own cookie entry, stamped at `/signin`
where it can actually be read. Each case gets one retry - the usual cause is a
lost entry for someone Connect has already signed in, which completes in a
single hop - and then surfaces `connect_error` instead of going round again.

### Changed

- A `state_mismatch` no longer replays the state cookie's destination. With no
  matching entry there is no destination that flow owns, so it falls back to
  `defaultRedirect` rather than one belonging to a different round trip.
- A `prompt=none` probe whose state no longer matches still returns quietly to
  `next`. The silent check now happens before the state check, so the new
  retry can never escalate a silent probe into a visible sign-in.

## 0.34.0

Getting a blocked customer unblocked, and getting them back afterwards - plus
the field the deletion events were missing. No new resources: two of these are
the places the subscription journey dead-ended, and the third is a number your
webhook handler had no way to read.

### `<ConnectAccessGate>` no longer strands people in a second tab

The `subscription_missing` card's button used to `window.open(resolveUrl,
"_blank")`. The customer subscribed over there, was left on Connect's billing
page with nothing pointing home, and the tab they started in still showed
"Choose a plan to continue" - unchanged, because nothing re-reads entitlements
on its own. The usual ending was two open tabs and the belief that the payment
had not gone through.

Now it is a **same-tab navigation carrying `?next=`** back to the page they
were standing on. Connect honours it after the subscribe when the origin
matches one registered for your Connect client, so the customer lands back
exactly where they were, subscribed.

Register your app's origin in Console if you have not - an unregistered one
costs the return trip, nothing more. They still reach Connect, still resolve
the blocker, and:

**A blocked gate now re-checks when the tab regains focus.** Covers everything
the return trip cannot: a second tab, a colleague with the admin rights, a
payment settled on a phone. Only while blocked - a `clear` gate does not put a
request on the critical path of alt-tabbing back into a working app.

`useConnectAccessGate()` gained a `refresh()` for the cases your app learns of
a change some other way.

### `buildCheckoutUrl(&#123; next &#125;)`

Where Connect sends the customer once the subscription is live:

```ts
buildCheckoutUrl({
  appUrl,
  applicationId,
  planId,
  next: window.location.href,   // back to your pricing page, subscribed
});
```

Without it the default destination is the new subscription's own billing page
- right for someone who started in Connect, wrong for someone who started on
your pricing page. Connect validates the origin against your registered
redirect URIs and ignores anything else, so an unrecognised value costs the
return trip and can never redirect your customer somewhere you did not
register.

### `useConnectResource().refetch()` actually refetches

It only re-ran the effect, on the assumption the caller had just evicted the
cache entry itself through `mutateResource`. The provider's cache is a `Map`
of resolved promises with no expiry, so a `refetch()` on its own re-read the
same settled promise and reported the same stale answer forever. It now evicts
first. Still safe after a `mutateResource` against the same path.

### Note for applications that cannot be used without a plan

If your application is unusable without a subscription, the better fix is on
Connect's side and needs no SDK change: a platform admin turns on **"Require a
subscription to sign in"** on your Connect client. Plan selection then happens
*inside* the sign-in handshake - the customer picks a plan, free tier first,
and is handed back to you automatically - instead of them arriving signed-in
and hitting this gate at all. The gate stays as the backstop for a
subscription that lapses mid-use, which is what the changes above are for.


### The deletion events carry your retention policy

`workspace.deletion_scheduled` and `workspace.deleted` now include `policy`:
your application's own data policy, frozen as it stood when the customer
confirmed the deletion.

```ts
"workspace.deleted": async ({ workspaceId, policy }) => {
  for (const dataClass of policy?.dataClasses ?? []) {
    if (dataClass.disposition === "retain") {
      await archive(workspaceId, dataClass.key, dataClass.retainDays);
    }
  }
}
```

Before this, the two events carried `purgeAfter` and `graceDays` — Connect's
clock — and nothing about yours. The number a handler actually acts on is
`retainDays` per data class (and `quarantineDays` per held identifier), and
that was the one number never on the wire: every integration had to re-declare
in code what it had already declared in Console, where the two drift the first
time somebody edits the policy and nothing fails.

`policy` is per recipient — a deletion fans out to every application the
workspace subscribed to, and each receives only its own declaration. It is the
*clamped*, *versioned* snapshot: `version` names the policy the customer was
shown, and the numbers are the ones Connect promised them rather than whatever
your stored policy says today. `null` only for a deletion confirmed before
policies existed; an application that has declared nothing gets empty arrays,
never a missing key.

New types: `ConnectDeletionPolicyNotice`, `ConnectDataClassNotice`,
`ConnectResourceReleaseNotice`, `ConnectDataDisposition`,
`ConnectFiduciaryRole`.

Also corrected: `requestedBy` on `workspace.deletion_scheduled` is
`"owner" | "platform_admin"` — which path the request came in on. The payload
example in Console had been showing a user id, which is not on the wire.

## 0.33.0

Two additions: the public visitor assistant, and `connect.enrichment.presence()`.

### The visitor assistant

A chat bubble for **your customers' own websites**, answered by Connect, for
people who have no account anywhere. A visitor asks about that business, or
books a call with them; your application holds no model credential and runs no
AI infrastructure.

Three calls drive a conversation and five manage what it is allowed to say:

```ts
const session = await connect.visitor.startSession({
  workspaceId,          // which of your customers' sites this is
  page: "/pricing",
});

const stream = await connect.visitor.chat({
  sessionId: session.sessionId,
  workspaceId,
  message: "do you take walk-ins?",
});
```

Needs an API key scoped to **`visitor:chat`**, and **`visitor:knowledge`** to
manage the knowledge base. Both are workspace-scoped, so they carry the second
check every workspace-wide scope carries: a live subscription to the workspace
you name.

**The assistant answers from a knowledge base you publish, and from nothing
else.** `connect.visitor.knowledge.*` writes to a store that exists only for
this purpose:

```ts
const item = await connect.visitor.knowledge.save({
  workspaceId,
  title: "Opening hours",
  body: "Mon-Fri, 9 to 6. Closed on public holidays.",
  source: "authored",
});
await connect.visitor.knowledge.publish({ workspaceId, itemId: item.id });
```

Five things to know before wiring it in, in the order they will bite you:

**It is not the workspace's Context Index, and must never become it.** The
corpus behind `ai.chat` is fed by every Drive upload a workspace makes —
contracts, payroll, customer lists. Pointing a public widget at that would let
a stranger extract an internal document, helpfully summarised. Nothing reaches
the visitor store except through an explicit `publish`, and the assistant's
read path has no route to the other corpus at all. Do not try to fill it by
uploading to Drive; that is the invariant, not an inconvenience.

**Publishing is its own call, and unpublishing can fail.** `save` always writes
a draft, so no edit can make text public by accident. `unpublish` removes the
retrievable copy *before* it flips the record, which means a failure leaves the
item **still live** — surface the error and let them retry rather than greying
the row out optimistically. For a retracted price, an item that looks withdrawn
and is not is the most expensive bug this feature has.

**Record `grounded: false` from the `done` event.** It means nothing published
covered the question, so the assistant said so and offered a call instead of
inventing an answer. A run of them is a knowledge base with a gap in it, which
is the most useful signal this endpoint produces.

**`mode` tells you how the knowledge reaches the model**, and `inline` is the
good state. Below a size threshold the whole published set is carried in the
prompt — the model sees every item rather than a top-k slice, with no chunk
boundaries and no retrieval round trip. `retrieval` only begins once a
knowledge base has outgrown that, and only then does `indexState` on each item
matter.

**The workspace is yours to resolve, not the browser's.** Connect checks the
`workspaceId` you send against your own subscriptions, so the worst case is
bounded to your own customers rather than the platform's. That is still one
customer's budget spent from another's page, so if you serve several customers,
resolve the workspace from your own widget record — the token in the embed
snippet, say — rather than trusting a field a page supplied.

`<ConnectVisitorChat>` in `./client` is the widget itself: a bubble that
becomes a full-height sheet on a phone and an anchored panel above `sm`, with
the composer's input type decided by the server rather than guessed from the
reply — so an email field brings up a keyboard with an `@` on it and a
verification code can be offered straight from the notification. It renders on
a page with **no `ConnectProvider`**, which most marketing pages are, and it
mints nothing until somebody opens it.

Times offered for a call are drawn from the turn's tool result, never from the
assistant's sentence, and the appointment is placed by a typed action rather
than by the model — so it cannot book a time nobody offered, and a phone number
never arrives parsed out of prose.

### `connect.enrichment.presence()`

`connect.enrichment.presence()` — how visible a business is online, scored out
of 100.

The third sibling of `company()` and `brand()`: that one answers what a
business does, the other what it looks like, and this answers how findable it
is — by a search engine, by a customer, and by the assistants that
increasingly answer questions about a business before anybody visits its site.

Returns the score, a breakdown across five categories, every check with the
evidence behind it, every social profile linked from the site, and a
prioritised list of what to fix.

```ts
const result = await connect.enrichment.presence({ domain: "acme.com" });
if (result.status === "scanned") {
  for (const next of result.scan.recommendations) {
    console.log(`${next.priority}. ${next.title} — ${next.fix}`);
  }
}
```

Needs an API key scoped to **`enrichment:presence`**.

Three things to know before wiring it in:

**The score is computed, not generated.** Every point comes from a check run
against something a fetcher actually observed, and every check ships with its
weight, what was seen and the URL that settles it — so the total is
reconstructable. A model only orders the recommendations and writes the prose;
it cannot move the number. That is the whole reason this is worth showing a
customer: ask a model how good a company's web presence is and it answers 72
with total confidence for a site it never read.

**`social` is claimed, never verified.** Those are URLs the site itself
printed. Nothing proves the account belongs to that business — render it as
"linked from their site". Check `machineReadable` too: `false` means a real
profile that search engines have to infer from a footer link, which is a
specific, common and easily fixed finding.

**A low score is a success, not a skip.** Every check failing is the most
useful report this produces. Only `unreachable` means the site did not answer —
and `provider_unavailable` means *Connect* did not, which is never something
to tell your user about their own site.

## 0.32.0

Push notifications: `connect.push.send()` on the server, `useConnectPush()` in
the browser.

**The point is what your application no longer holds.** It holds no VAPID key,
stores no push endpoint, and needs no notification table of its own. It names
a workspace, some members and three strings; Connect resolves those members'
devices, applies each member's own preferences, and signs the payload.

Two things follow that are worth more than the code saved:

- **A member grants the browser prompt once for the platform**, not once per
  app. The same device receives your notifications, every other app's, and
  Connect's own — and one switch in their Connect profile silences the lot.
  That matters because a browser will not re-prompt once it has been told no,
  so every app asking separately is a queue of chances to lose the permission
  for all of them.
- **You never learn who has a device.** There is no read that answers it, by
  design. A send reports a fan-out; it does not report an audience.

```ts
// Your server — a task fell due.
await connect.push.send({
  workspaceId,
  userIds: [ownerId],
  title: "A task is due",
  body: "Follow up with Priya Sharma",
  url: "https://crm.example.com/tasks",
});
```

```tsx
// Your browser — registering this device.
const { status, enable, error } = useConnectPush("/sw.js");
```

Three things to know before wiring it up:

**`delivered: 0` is a success.** The member may have registered no device,
silenced notifications from their apps, or denied the prompt — all ordinary. A
caller that retries on zero retries forever against somebody who opted out.
Treat a notification as a nudge toward something already true in your own data,
and never let a send failure fail the thing that triggered it.

**`url` must be absolute and on an origin you registered as an SSO redirect
URI.** Connect refuses anything else: a URL it did not check is an open
redirect delivered to a lock screen, wearing the styling of an app the person
trusts.

**You supply the service worker.** The SDK does not install one — a worker is a
site-wide concern with caching implications only you can decide. It needs to
handle `push` and `notificationclick`; the payload is `&#123;title, body, url, tag&#125;`.

`status` has five values rather than being a boolean, and the one that matters
is `denied`: it cannot be undone by script, only from the browser's own site
settings. A control that silently does nothing when pressed is the worst
version of that screen, so the state is surfaced for you to explain.

Requires the `push:send` scope on your API key.

## 0.31.0

`connect.enrichment.company()` tells you what a company *does*. This adds
`connect.enrichment.brand()`, which tells you what it *looks like*: its logo,
its palette, its typefaces, and the radius, spacing and breakpoint scales its
own stylesheets declare.

The reason this needed building rather than prompting: **these values are
measured, not generated.** Ask any model for a company's brand colour and it
answers `#3B82F6` with complete confidence, because that is the median of
every site it has ever seen. Every hex in this response was parsed out of a
real stylesheet, every scale step is a count of real declarations, and
`palette[].source` tells you which parse produced it — a `css-var` entry is
the company naming its own colour in its own tokens, a `frequency` entry is a
ranked count and much weaker. A model is involved in exactly two places:
writing the prose fields, and assigning a `role` to a value it was shown.

`officialName` follows the same discipline the company lookup applies to
`legalName`. It is filled only from a legal disclosure the company published
itself — a footer copyright line, or the entity named on its Terms page —
matched against a corporate suffix by code and cited. `officialNameSource`
says which. It is never a model's recollection of a company register.

### Added

- **`connect.enrichment.brand()`** — read a company's brand kit and design
  system from its domain.

  ```ts
  const result = await connect.enrichment.brand(&#123; domain: "acme.com" &#125;);
  if (result.status === "extracted") &#123;
    const primary = result.profile.brandKit.palette.find((c) => c.role === "primary");
    const body = result.profile.brandKit.typography.find((t) => t.role === "body");
  &#125;
  ```

  Needs an API key scoped to the new **`enrichment:brand`**. That is separate
  from `enrichment:read` because the cost differs by an order of magnitude: a
  company lookup is three page fetches, a brand read crawls stylesheets, may
  drive a headless browser, and caches image bytes.

  Three sharp edges, in the order they will bite you:

  **Set your HTTP client timeout to at least 90 seconds.** A cold read runs
  the full extraction before answering and can take up to a minute. Many
  clients — including `undici` and most `axios` setups — default to 30s and
  will abandon a request that was about to succeed. Cached reads return in
  milliseconds, and cached is the normal case: profiles are platform-owned
  and held for 90 days, so you pay the cold path roughly once per domain
  across all of your users.

  **Never persist `brandKit.logos[].url`.** It is a short-lived signed URL,
  minted fresh on every read. Stored in your own database or baked into a
  cached page it becomes a broken image with a plausible-looking address.
  Render it directly, or re-read the profile. `sourceUrl` is the stable value
  and points at the company's own site — though it is theirs, so it can move.

  **A `skipped` status is a normal 200.** `no_stylesheet` is the one new
  reason worth handling specially: the site answered and said things, but
  shipped no CSS this reader could measure.

  `designSystem.framework` is worth reading first. It tells you how to
  interpret everything else — a Tailwind site's spacing scale is Tailwind's
  defaults rather than a considered choice, and a palette full of `--bs-*` is
  Bootstrap's.

## 0.30.1

`ConnectAppShell` was putting **462 KB** of markdown renderer on every page of
every application platform that mounts it — which is every page, because the
shell owns the layout. `streamdown` was a static import in `ConnectAskAi`, so
it was downloaded, parsed and evaluated on each route to render nothing until
somebody opened the assistant, which most page views never do. Measured on a
partner app: 48 of its 49 routes carried it, roughly a quarter of each route's
JavaScript.

It is now `React.lazy`, so the chunk is requested the first time there is
markdown to render and cached by React from then on. No API changed and no
host setup changed; `streamdown` stays a peer dependency of `/client` and is
still needed for formatted answers.

The Suspense fallback is the **raw text** rather than a spinner. An answer
streams in token by token, and unformatted prose is readable — so the first
words now appear *while* the renderer is being fetched instead of behind it,
and re-render as markdown when it lands. On a cold cache that makes the first
answer feel earlier than it did before, not later.

### Changed

- `ConnectAskAi` loads `streamdown` on demand instead of at module scope.
  Partner apps get the saving by upgrading; nothing in their code moves.

## 0.30.0

`<ConnectAskAi>` forgot everything on reload. Connect's own panel has had
History and a New-conversation button since it shipped; this is the same two
controls, backed by the same storage, for the panel a partner mounts.

The reason it took a server change rather than a component one: the
application chat lane deliberately stored nothing, because a conversation
belongs to a *person* and an API key names an application. That is still true
of a server-to-server call — it persists nothing, exactly as before. What
changed is that the browser path was already sending a signed-in user's
session token, so there has been a real owner to file a transcript under for
some time, and nothing was doing it.

### Added

- **History and New in `<ConnectAskAi>`.** Two buttons in the panel header. New
  clears the transcript and starts a fresh conversation without deleting the
  old one; History lists what this user has asked *in your application*, and
  pressing a row reopens the transcript and keeps writing into it.

  Nothing to wire up: if your API key carries `ai:chat` it already works. There
  is no prop to turn it on, and none to turn it off — a chat panel that cannot
  show you what you asked yesterday is the odd one out, not the default.

- `connect.ai.listConversations(&#123; asUser, limit?, cursor? &#125;)`,
  `connect.ai.getConversation(&#123; conversationId, asUser &#125;)` and
  `connect.ai.deleteConversation(&#123; conversationId, asUser &#125;)`, plus the types
  they answer with: `AiConversation`, `AiConversationList`,
  `AiConversationTurn` and `AiConversationDetail`.

  `asUser` is **required** on all three, unlike on `ai.chat` where it is
  optional. There is no application-wide view of these rows to fall back to:
  they are scoped to a person, and a version of this call that read every
  user's transcripts is not a smaller feature, it is a different and worse one.

  `turns[].parts` comes back as the AI SDK's own `UIMessage` parts, in the
  order the model produced them — prose, reasoning and tool calls interleaved.
  Hand them to your chat UI as they are. Flattening a turn to text loses the
  model's own argument and makes the conversation unresumable, which is why
  these are typed `unknown[]` rather than restated in a schema that would start
  rejecting transcripts the day the AI SDK adds a part kind.

- `connect.ai.chat(&#123; conversationId &#125;)`, optional. Generate one id per
  conversation client-side and send it with every turn of it; Connect upserts
  against it. **No id, nothing stored** — which is how a caller opts out of
  history entirely, and it stays the behaviour for any call made without
  `asUser`.

- `createConnectHandler` answers three more routes —
  `GET /ai/conversations`, `GET /ai/conversation?id=`,
  `DELETE /ai/conversation?id=` — and relays `conversationId` on
  `POST /ai/chat`. The owner comes from the caller's own session on all four;
  there is no parameter that could name somebody else.

### Changed

- `mutateResource(path, body?, method?)` takes an optional third argument,
  `"POST"` (the default, and what every existing call gets) or `"DELETE"`.
  Existing calls are unaffected.

### Notes

- **Scoped to your application, not just to the workspace.** A workspace
  subscribing to two products on Connect keeps two separate histories, and
  neither can read the other's — nor the one that user keeps in the Connect
  portal itself. An id from the wrong one answers `404`, not somebody else's
  transcript.

- **A transcript is not an audit trail.** It records what was asked and
  answered, and a user can delete their own. If you need a durable record of
  something the assistant *did*, that is your own write and your own log.

## 0.29.0

Voice was readable and writing was not. `calls.list` has always let a partner
read back what a customer said out loud; the WhatsApp threads a workspace's
agents hold had no partner surface at all, so a CRM could show every call on a
contact and nothing they had typed.

### Added

- `connect.conversations.list(&#123; workspaceId, contactAddress?, status? &#125;)` and
  `connect.conversations.get(id, &#123; workspaceId &#125;)`, plus the four types they
  answer with: `ConnectConversation`, `ConnectConversationMessage`,
  `ConversationsPage` and `ConversationDetail`.

  **These are the *workspace's* threads, not your application's**, which is the
  difference from `calls` and it decides how they are gated. A call arrived on
  a channel you hold, so `calls.list` is scoped to your application by the key
  alone. A conversation lives in the workspace's own subtree and belongs to it
  the way its contact book does — so reading one needs the `conversations:read`
  scope **and** a live subscription in that workspace, and, when the call comes
  through the browser bridge, the signed-in member's own `agent:read`.

  Three things are worth reading before you render one:

  `contactAddress` is the filter to reach for beside your own customer record.
  You hold a phone number, and you cannot derive a conversation id from one —
  the id is built from a hashed key, deliberately, so an id you could guess is
  an id you could probe. Send the number however your records spell it.

  `canReplyFreely` is computed against Connect's clock, and re-deriving it from
  `lastInboundAt` against yours is the mistake to avoid: WhatsApp allows a
  free-form reply only inside 24 hours of the customer's last message, and a
  skew between your server and Connect's shows up as a thread your UI calls
  answerable when it is not.

  Messages that sent nothing are included and carry `failure`. Render them — a
  transcript that silently drops them reads as a customer being ignored, which
  is the opposite of what happened. `refused` is not a failure at all: it is
  the agent deciding not to answer, which is what it is for.

  Read-only, and deliberately. Sending on a thread would mean saying whether it
  is a free-form reply or an approved template, and what that means for the
  agent still answering it.

## 0.28.0

Connect's webhook bus carried four events, all of them about billing and
deletion. It now carries the seven a system of record is actually built on —
calls, appointments and contacts — so the work your customers do inside
Connect can reach the product they do the rest of their work in.

### Added

- Seven webhook event types, with their payloads fully typed on
  `ConnectWebhookEventMap`: `call.completed`, `call.missed`,
  `booking.confirmed`, `booking.cancelled`, `booking.rescheduled`,
  `contact.created` and `contact.updated`. Register handlers for them on
  `createConnectWebhookHandler` exactly as you do the existing four.
- `ConnectCallDirection` and `ConnectContactSource`, the two unions those
  payloads carry.

  These are **activity** events rather than lifecycle ones, and the difference
  decides who receives them: they are delivered only to applications the
  workspace is actually subscribed to, so a lapsed customer stops emitting on
  the same boundary that stops their access.

  Three conventions are worth reading before you write a handler, each
  documented on the types themselves. Phone numbers are E.164. A call event
  identifies its party by `contactPhone` and deliberately carries no
  `contactId` — Connect resolves a caller *by* the number and at the moment a
  call ends it often has not, so the field would be `null` on most deliveries.
  And `booking.rescheduled` carries `previousBookingId` as well as `bookingId`,
  because Connect retires the old booking and writes a new one: reconcile on
  the previous id or you will end up holding two appointments.

### Fixed

- `Booking` fixtures in the bundled tests compile again — the type gained a
  required `hostMemberIds` in 0.27.0 and one fixture was missed. No runtime
  change.

### A note on exhaustiveness

`ConnectWebhookEventType` is a union that grows, and this release grows it.
`createConnectWebhookHandler` is unaffected — its `on` map is partial and it
answers 200 to anything you have not registered. A hand-written `switch` with
an exhaustive `never` check on that union will now fail to compile until you
add the new members; that is the documented contract of this union rather than
a break, and it is the reason the handler exists.

## 0.27.0

A minor rather than a patch because `Booking` gains a required field, so a
hand-written object satisfying the old interface no longer type-checks. Nothing
you *read* from the API breaks.

0.26.1 was never published; its note below ships as part of this release, so the
registry goes 0.26.0 → 0.27.0.

### Added

- **`connect.scheduling.getBooking()`** - one appointment, by the id you
  stored.

  ```ts
  const booking = await connect.scheduling.getBooking(&#123;
    workspaceId,
    bookingId, // the id you kept on your own record
  &#125;);
  ```

  This is the read to make when a record of yours - a meeting on a deal, an
  appointment on a case - holds a booking id. **`listBookings` cannot serve
  it.** That is a window over `startAt`, and the `startAt` you cached is
  exactly what is wrong once somebody has moved the appointment on the
  workspace's own calendar, so the window you build from it comes back without
  the row - in the one case worth re-reading for.

  A retired booking still resolves, deliberately. `status: "rescheduled"`
  answers with `rescheduledToId`, so an id stored months ago leads to the
  appointment that exists today; follow the pointer. `cancelled` answers with
  its reason. `not_found` means this workspace never had that booking, which is
  a different fact from "it moved".

  `GET /scheduling/booking?bookingId=` is the matching browser bridge route -
  singular, and the id in the query string, because the bridge's route table is
  matched by exact string. Same reason `reschedule` and `cancel` take theirs in
  the body.

- **`Booking.hostMemberIds`** - the workspace members committed to an
  appointment, so an integration can render who it belongs to.

  These are the `WorkspaceMember.id` values `workspaces.listMembers` returns,
  so an owner column is a lookup against a roster you already hold rather than
  another round trip. They were withheld as "Connect internals", which
  `listBookings` had already contradicted by *filtering* on `hostMemberId` - a
  partner could narrow a diary by a value it was never allowed to read back.

  Resolved when the slot was held and frozen there: a round-robin event type
  picks one host, a collective one commits the whole group, and re-pointing the
  event type afterwards does not move an appointment already made.

  **This is the breaking half.** The field is required, so code constructing a
  `Booking` literal - a test fixture, a mock, a storybook arg - needs
  `hostMemberIds: []` added. Code that only reads bookings from the API is
  unaffected.

## 0.26.1

### Added

- **`<ConnectAppShell>` renders a skip link.** The rail is the shell's first
  content, so a keyboard or switch user tabbed every navigation row, the app
  launcher and the account menu again on **every** route change before reaching
  the page they came for - WCAG 2.4.1, and the one bypass a single-rail layout
  cannot do without, because there is no second landmark to jump between. The
  link is the first focusable element in the document, visually hidden until it
  takes focus, and `<main>` now carries `id="connect-main-content"` and
  `tabIndex=&#123;-1&#125;` so focus genuinely lands there. Without that `tabIndex` a
  browser scrolls to the anchor and leaves focus where it was, so the next Tab
  returns to the top of the rail and the link appears to do nothing.

  Nothing to adopt: it is on for every platform. The only visible change is a
  "Skip to content" button that appears at the top-left on the first Tab.

## 0.26.0

A minor rather than a patch because two of these change how existing screens
*look*, without changing any API. 0.25.1 was never published; its notes below
ship as part of this release.

### Changed

- **The shared theme now sets `--radius`, and partner platforms get smaller
  corners.** It never carried the token, so every platform importing it fell
  through to HeroUI's stock `--radius: 0.5rem` with `--field-radius` derived
  at 1.5x - while Connect's own app has always run at `0.25rem`. Cards,
  inputs, chips and buttons in a partner app were drawn at twice the radius of
  the same component in Connect, which was the one place two products sharing
  this palette visibly disagreed. HeroUI's whole radius scale is *ratios* of
  that one token, so this is a single value and every corner follows it.
  To keep the old look, redefine both after the import:
  `:root &#123; --radius: 0.5rem; --field-radius: calc(var(--radius) * 1.5); &#125;`.

- **`<ConnectAppShell>`'s content area is full width.** It was capped at
  `max-w-6xl` and centred, which spent the width twice: dead margin down both
  sides, and a fitted layout - a board that folds a column away when it runs
  out of room - holding the pixels that would have paid for another column.
  Landing surfaces on an application platform are boards, tables and
  dashboards, and all three want the monitor. Forms and prose should cap
  themselves at the surface rather than have the shell cap everything; pass
  `contentClassName` to restore a frame of your own.

### Fixed

- **`className` now actually overrides.** Every component concatenated your
  classes onto its defaults - `` `flex flex-col gap-4 $&#123;className&#125;` `` - which
  emits *both* rules, leaving the winner to Tailwind's output order rather
  than to you. So an override worked on one prop and silently did nothing on
  the next, with no way to tell which you were getting. All 27 sites now merge
  through `tailwind-merge`, which *removes* the conflicting default:
  `gap-8` beats `gap-4`, and `p-8` beats `px-3 py-2`.

### Added

- **`cn` is exported from `/ui`** - the same merge the components use, for
  composing your own on top of them.

- **`className` on `<ConnectPageShell>`, `<ConnectCursorPagination>` and
  `<ConnectFilterTabs>`**, which had no way to be styled at all. Components
  that render only `children` (`<ConnectPlatformGate>`,
  `<ConnectWorkspaceTypeGate>`, `<MotionProvider>`) still take none, because
  they have no element to put it on.

## 0.25.1

### Fixed

- **`<ConnectFilterTabs>` chips had almost no padding.** They were drawn at
  `size="sm"`, which in `@heroui/styles` is not a smaller chip but
  `px-1 py-0` - four pixels of horizontal padding and none at all vertically.
  That is reasonable on a label chip and wrong on the primary control of a
  list: the filter strip read as a row of bare words, and the press target
  was barely taller than its own text on a control people hit constantly,
  often with a thumb. Now explicit `px-3.5 py-1.5` with a `min-h-8`, so a
  chip carrying a count and one without still share a shape.

- **A hand-rolled `<button>` showed an arrow cursor, not a pointer.**
  Tailwind v4's Preflight sets `cursor: default` on `button`, reversing v3,
  and HeroUI restores the pointer only on its own classes via
  `--cursor-interactive`. So every plain `<button>` an app writes - a menu
  row, a filter chip, a calendar day, a card that is really a press target -
  rendered as though it were text. `theme.css` now corrects it once, at zero
  specificity via `:where()`, so component rules including HeroUI's own
  disabled handling still win. Every application platform importing the theme
  picks it up.

## 0.25.0

### Changed

- **`getConnectSession()` no longer reports a failed read as "signed out".**
  It returned `null` for *every* failure, and `null` is what makes
  `requireConnectSession()` redirect to sign-in - so a rate-limited or
  unreachable Connect logged a signed-in user out, and the OAuth round trip
  that followed spent more calls against the same exhausted budget and failed
  again on the way back. A login loop that could only end when the rate-limit
  window rolled. Two developers hit it; production logs showed 120 429s on
  `/sso/user` in a day, arriving in bursts of fifteen inside one second,
  because a partner layout that reads the session is called once per
  prefetched route shell.

  `null` now means **there is no session** - no cookie, or one Connect reports
  as expired, revoked, or issued to another application, which are exactly the
  cases where sending someone back through sign-in is right. Everything else
  throws. This is the breaking part: a guard written as
  `const s = await getConnectSession(); if (!s) redirect(…)` will now surface
  a transient failure to the nearest error boundary rather than silently
  signing the visitor out. That is the intended behaviour - an error boundary
  is recoverable and truthful, a spurious sign-out is neither - but it is a
  behaviour change, which is why this is a minor rather than a patch.

- **`<ConnectProvider>` keeps the session it already has when a refresh
  fails.** It used to replace whatever was known with a signed-out session on
  any error. On a first load that is right - something has to render rather
  than skeletons forever - but on a refresh it meant one dropped or throttled
  request logged the user out of the UI, and the host app's own guards then
  acted on it. `error` is still set either way.

### Fixed

- **`POST /sso/user` never retried a 429.** The transport has honoured
  `Retry-After` with exponential backoff all along, but only for requests it
  considered idempotent - and that test keyed on the verb, so it excluded
  every `POST`. `/sso/user` is a `POST` because its input is a session token
  that must not sit in a URL; it writes nothing, and it is the most-called
  endpoint on the platform. It was therefore the one endpoint that could never
  ride out a throttle. `RequestSpec` gains `idempotent` for reads shaped this
  way. `POST /sso/token` deliberately does not set it: it consumes a one-time
  authorization code, and a replay is an exchange of a code that no longer
  exists.

- **"useInsertionEffect must not schedule updates." on every navigation.**
  `useActivePath`'s uncontrolled fallback patches
  `history.pushState`/`replaceState` so a shell with no `activePath` can see a
  client-side navigation, and it dispatched its `connect:locationchange`
  event synchronously from inside the patched method. Next's App Router
  commits its history entry from a `useInsertionEffect` (`HistoryUpdater`),
  so the listener - and the re-render `useSyncExternalStore` forces from it -
  landed in the middle of that phase, which React refuses: a console error on
  every navigation in any app that lets the rail resolve its own active path.

  The dispatch is now deferred to a microtask, which runs once the commit's
  stack has unwound. `location.pathname` already holds the new value by then,
  so the snapshot is unchanged and the highlight still moves on the same
  commit; only the scheduling moved. A shell that passes `activePath` never
  ran any of this and was never affected.

## 0.24.1

### Fixed

- **`<ConnectConfirmDialog />` never opened.** Its trigger was wrapped in
  `Modal.Trigger`, which renders a `div role="button"` inside React Aria's
  `Pressable` and depends on a plain `onClick` bubbling up to it. A React Aria
  button nested inside - which every real trigger is, including HeroUI's
  `<Button>` - calls `e.stopPropagation()` in its own `usePress` click
  handler, so the click never arrived. Nothing threw and nothing warned; the
  press was simply eaten, and every confirmation in every app built on this
  SDK was inert. Twelve of them across the two reference apps: each delete
  confirmation in the CRM, and each in the club app.

  The trigger is now a direct child of the `Modal` root, which is React Aria's
  `DialogTrigger`. Its `PressResponder` picks the trigger up directly, so
  there is no nested pressable and no propagation to lose.

  No call site changes. The prop's contract is now stated the right way round:
  the trigger **must** be a pressable - a HeroUI `Button`, or anything else
  built on React Aria's `useButton`. A non-pressable element (a bare `<span>`)
  used to be the only thing that worked and now does not; nothing shipped
  passes one.

## 0.24.0

### Fixed

- **`connect.integrations.initiateConnect()` could never succeed.** Connect's
  `POST /integrations/connect` requires a `userId` - it stores it as the
  connection's `connectedByUserId` - and the SDK sent `workspaceId`,
  `toolkitSlug` and `returnUrl` and nothing else. Every call 400'd, including
  the one `createConnectHandler`'s own `POST /integrations/connect` route
  makes, so **`<ConnectIntegrationsModal />`'s Connect button was broken too**:
  the browser reached the bridge, the bridge reached Connect, and Connect
  refused with `Invalid input: expected string, received undefined` - a Zod
  message that names no field, which is most of why this survived.

  The conformance suite pinned the *response* shape and not the request, so
  nothing failed the build. It now pins the request body against the
  use-case command, and `src/conformance.test.ts` fails to compile if the
  route grows a fourth required field.

### Changed

- **`initiateConnect` takes an input object** - `initiateConnect(workspaceId,
  &#123; toolkitSlug, returnUrl, userId &#125;)`, replacing the three positional
  strings. Breaking in name only: the old signature had no working call to
  break. The object shape matches `execute()` on the same resource, and it is
  what keeps `returnUrl` and `userId` from being silently transposable.

  `userId` is the signed-in member starting the connection. Take it from your
  own session, never from a form: the toolkit connection is shared by the
  whole workspace, so this attribution is the only record of who turned it on.
  The bridge already does this for you - it reads the session's user, and a
  `userId` in the request body is ignored, the same way a crafted
  `workspaceId` always was.

- **`InitiateIntegrationConnectionInput` is exported** from
  `@cosmoops/connect-sdk/server`, beside `ExecuteIntegrationActionInput`.

## 0.23.0

### Added

- **`CompanyProfile.legalJurisdiction`** - the seat a governing-law clause
  names, e.g. `"Chennai, Tamil Nadu"`.

  **This is not the registered address**, and that is why it is its own field
  rather than something to derive. An entity registered in one state routinely
  names another as the venue for disputes, and the applications that kept
  their own copies of this had reached for the registered address instead -
  publishing two different venues on two terms pages operated by the same
  company. `null` when the operator has not published one, in which case a
  terms page should omit the venue rather than substitute anything.

- **`CompanyProfile.brandName` is now editable by the platform operator.** The
  field is unchanged on the wire; what changed is that Connect served it from
  a hardcoded constant until now, which made it the one company fact nobody
  could correct without a deploy.

### Changed

- **`GET /company-profile` can now answer `503 unavailable`.** Connect used to
  resolve every field against hardcoded defaults, so this endpoint always
  answered - including with a legal name and grievance officer that nobody had
  checked against the operator's own record. Those defaults are gone, and an
  unconfigured platform refuses the read instead.

  Nothing you wrote needs changing: `<ConnectGrievanceNotice />` already
  renders `null` on failure, and `useConnectCompanyProfile()` surfaces it as
  an error like any other. **Do not add a fallback of your own** - a copy kept
  for outages is still a copy, and going stale is the failure this endpoint
  exists to prevent.

- **`connect.enrichment.company()`** - fill an account in from a work email
  address, instead of asking somebody to type it.

  ```ts
  const result = await connect.enrichment.company(&#123; email: "priya@acme.test" &#125;);
  if (result.status === "enriched") &#123;
    form.setValues(result.profile); // then let the person confirm it
  &#125;
  ```

  Connect reads the company's **own website** - the domain your input yields,
  plus About/Contact pages linked from its homepage - and returns what it found
  with `citations` naming the URL each claim came from. It does not search the
  web, and it never answers from a model's recall: a field the pages did not
  state comes back `null`. An invented industry is indistinguishable from a
  real one, and an invented address reaches an invoice.

  `status: "skipped"` is a normal 200, not an error - most records legitimately
  have nothing to enrich. `consumer_domain` (a `gmail.com` address names a
  person, not an employer) and `blocked_domain` will never succeed on a retry;
  `unreachable` and `no_signal` may. `confidence` is derived from how many
  fields the pages actually supported, not from the model's opinion of itself,
  so `low` means "offer this" and `high` means "safe to pre-fill".

  Needs an API key scoped to the new **`enrichment:read`**. That is separate
  from `ai:extract` because the two differ in what they touch rather than only
  in what they cost: extraction reads a file you already hold, this makes
  Connect fetch a third-party website on a string that reached you from a
  stranger.

- **`connect.workspaces.access()`, `.invite()`, `.setAccess()` and
  `.revokeInvitation()`** - manage the customer's roster from inside your own
  application, instead of sending them to Connect's team page.

  `members()` has always been a read, so a partner's own team screen could
  show who had access and then had to hand off to Connect for every change.
  These four close that loop, and all of them take `asUser`: the workspace and
  the actor come from the caller's session token, never from a parameter.

  ```ts
  const &#123; capacity, viewer &#125; = await connect.workspaces.access(&#123; asUser: token &#125;);
  if (viewer.canInvite && capacity.canAdd) &#123;
    await connect.workspaces.invite(&#123; inviteeEmail: "priya@acme.test", roleKey: "rep" &#125;, &#123; asUser: token &#125;);
  &#125;
  await connect.workspaces.setAccess(&#123; userId, roleKey: null &#125;, &#123; asUser: token &#125;); // revoke
  ```

  **You can only ever move your own application's grant.** The wire carries a
  `roleKey` from your catalog rather than a roles array, and Connect computes
  the member's next roles as *what they hold now, minus any grant naming your
  application, plus yours* - so nothing these methods can express reaches a
  workspace-wide Connect role, another application's access, or removes
  someone from the workspace.

  Two gates apply on top of the API key's new `members:manage` scope, and both
  are about the person rather than your application: the signed-in caller must
  hold Connect's own `member:invite` / `member:update` on the workspace, and
  they cannot grant access they do not hold themselves. `viewer` on the
  `access()` response is the first of those, resolved, so you can render your
  controls from it instead of letting a button 403.

  **Seats are the workspace's, not your plan's.** `capacity.included` is a
  baseline plus every active subscription's member allowance folded across
  applications, and `used` counts pending invitations as well as members,
  because an invitation is a seat already spoken for. `reason` separates "no
  plan grants member seats" (`no_allowance` - sell them one) from "every seat
  is taken" (`exhausted` - an upgrade, or a removal); those read alike and
  need different words on screen. The read is advisory - the authoritative
  check runs inside `invite()`, which is where two people claiming the last
  seat at once is settled.

  `revokeInvitation()` is not optional decoration: an invitation sent to a
  mistyped address holds a seat for its full seven days, and an application
  that can spend one but not release it leaves a customer paying for a seat
  nobody can use.

  **Server-side only, deliberately.** None of the four is exposed through
  `createConnectHandler`, which stays four fixed reads plus sign-out and the
  workspace switch. A bridge route is a capability handed to any script
  running in your page, and "grant this account admin" is the highest-value
  one there is; call these from your own server, where the API key already
  lives.

## 0.22.0

### Fixed

- **`<ConnectGrievanceNotice />` rendered nothing for a signed-out visitor -
  which is most of the people who read a privacy page.**

  The bridge route `GET /company-profile` is deliberately unauthenticated: the
  answer is a fact about the platform operator, identical for every caller and
  already on the public web, and requiring a session would hide the redressal
  channel at the one moment it most needs to be legible. The browser half
  never got that exemption. `useConnectCompanyProfile` goes through
  `useConnectResource`, which waits for `session.user` before it reads - right
  for every other route, and the reason this component was blank on exactly
  the public page the docs recommend putting it on.

  `useConnectResource` now takes `&#123; requiresSession: false &#125;`, and the company
  profile is its only caller. It widens no access - the bridge decides that,
  not the hook.

- **`<ConnectPlans />`: a card reading "Subscribe" opened Connect's *change
  plan* screen, which answered a 404.**

  The label and the URL were two separate decisions. `resolveAction` needs the
  **current plan** to classify a change and falls back to `"subscribe"`
  without one; the checkout URL was built from the mere presence of a
  subscription *row*. Those disagree in a state Connect reports routinely:
  `SubscriptionResponse` carries `plan: null` beside a live `subscription`
  whenever that plan has been deleted from the application's catalogue - which
  re-seeding an application does. The button then said "Subscribe" and opened
  `/billing/subscriptions/&#123;id&#125;/change-plan/&#123;planId&#125;`, where Connect could not
  read the current price either and rendered its not-found page.

  Both now come from the same `PlanAction`. When the grid cannot classify a
  change it hands off to the subscribe route, which re-reads the workspace's
  live subscriptions server-side and redirects to the change-plan screen
  itself when there is genuinely one to change - so Connect decides, and the
  grid does not have to be sure.

### Added

- **`checkoutSubscriptionId(action, current)`** - the other half of
  `resolveAction`, exported alongside it. A partner writing its own pricing
  grid has to turn that action into a checkout URL, and reading the
  subscription id straight off the entitlements response is exactly the bug
  above. Pass its result as `buildCheckoutUrl`'s `subscriptionId`.

## 0.21.0

### Added

- **`<ConnectOneTap />`** - a signed-out visitor who is already signed into
  Connect is offered their account, and one tap signs them in **without leaving
  the page**.

  ```tsx
  <ConnectProvider>
    <App />
    <ConnectOneTap />
  </ConnectProvider>
  ```

  That is the whole integration. It needs no `clientId` in the browser and no
  configuration: the bridge already knows the client, and now tells the page
  where to ask on the `/session` read it was making anyway, so discovering One
  Tap costs no extra request.

  **What it replaces.** `silentSignIn` answers the same question - *is this
  visitor already signed into Connect?* - by bouncing the browser to Connect
  and back with `prompt=none`. That costs a visible full-page navigation on the
  first load of **every** signed-out visit, shows the visitor nothing while it
  happens, and gives them no way to decline. One Tap asks with a single
  `fetch`, names the account before anything is signed, and completes with no
  navigation at all. `silentSignIn` still works and is unchanged; prefer this.

  **It only appears when it can help.** A visitor Connect does not recognise
  sees nothing, ever - no prompt, no flicker, no redirect - so this is safe to
  mount unconditionally, which `silentSignIn` never was.

  **Requires your application to be same-site with Connect** - a
  `cosmoops.com` subdomain. `SameSite` is judged on the registrable domain, so
  the visitor's Connect cookie rides along from `crm.cosmoops.com` and from
  nowhere else. Deployed off-site, the prompt simply never appears and
  `ConnectLoginButton` behaves exactly as before. No third-party cookie is
  involved at any point, so this works in every browser rather than only in
  Chromium.

  **Two prompts, because consent is not assumed.** A visitor who has authorised
  your application before gets their name, email, workspace and photo. One who
  has not gets an impersonal prompt whose tap leads to the real consent
  screen - Connect releases no identifying detail at all until that has
  happened once.

  **Dismissal is remembered.** Closing the prompt buys 2 hours of quiet, then a
  day, then a week; signing out buys a day. All in your own origin's
  `localStorage`, and none of it leaves the browser.

- **`readOneTapHint()` and `oneTapSignIn()` on `useConnect()`** - the two calls
  `<ConnectOneTap />` is built from, for applications that want to draw the
  prompt themselves. Both report expected outcomes as values rather than
  throwing: `consent_required` and `interaction_required` mean "hand off to the
  interactive screen", which is a hand-off, not an error to show anybody.

- **`ConnectSession.oneTap`** - where the browser may ask Connect about the
  visitor, or `null` when this deployment cannot run One Tap (no `clientId`, or
  a session store with no `write`). Present on the signed-in and signed-out
  branches alike, so no consumer has to test for key presence.

### Security

- The One Tap exchange is **PKCE-bound, and the verifier never reaches the
  page**. Connect hands the authorization code to the browser, so unlike the
  redirect flow the code alone must not be redeemable: `/one-tap/start` mints
  the pair and keeps the verifier in an `HttpOnly`, `SameSite=Lax` cookie on
  your own origin, and `/one-tap/complete` is the only thing that can spend it.
  A code lifted out of the browser, or replayed cross-site, is worthless.

## 0.20.0

### Added

- **`connect.channels` and `connect.calls`** - the phone numbers Connect has
  assigned to your application, and the calls that arrived on them. Seven
  endpoints that have been live on the API and reachable only by hand until
  now.

  ```ts
  const &#123; id &#125; = await connect.channels.claim(&#123; workspaceId &#125;);
  await connect.channels.setRouting(id, &#123; agent: "sales", afterHours: "voicemail" &#125;);

  const &#123; items &#125; = await connect.calls.list(&#123; channelId: id &#125;);
  const &#123; call, turns &#125; = await connect.calls.get(items[0].id);
  ```

  **You never pick the number.** Inventory is a regulated asset bought under
  Connect's carrier account, so `claim` takes a workspace and a kind and
  Connect picks from a pool you cannot see - which is also why nothing here
  exposes a carrier, a provider reference or a monthly rate. A workspace holds
  exactly one number across every application it uses, and it is fixed for the
  life of the engagement; only a Connect operator can unassign one. A `409`
  means either "this workspace already has one" (fix the call) or "the pool is
  empty right now" (retry - Connect is notified).

  `setRouting`'s `destination` is an arbitrary JSON object Connect stores
  verbatim and **never reads into**: which agent answers, business hours,
  overflow and fallback are all your application's model. It replaces rather
  than merges, and `clearRouting` is the only way to say "stop answering" -
  overwriting a destination can only point the number somewhere else. Clearing
  is idempotent and, unlike `setRouting`, is allowed on a suspended channel:
  making a live number stop answering must never be the operation that is
  unavailable.

  `calls.list` filters on `channelId` and nothing else - each further filter
  costs a Firestore composite index - and pages by cursor: **page until
  `hasMore` is `false` rather than stopping on an empty `items`.**
  `calls.get` adds the transcript, ordered by `sequence`, which is the sort to
  trust since two turns can share a millisecond. `recordingFileId` and
  `transcriptFileId` are Drive file ids readable with a `drive:read` key, and
  are `null` until the carrier posts the audio - branch on `recordingStatus`
  rather than reading `null` as a failure.

  Scopes: `channels:read`, `channels:claim`, `channels:manage`, `calls:read`.
  A channel or call belonging to another application answers `not_found`, not
  `forbidden` - confirming it exists would make these reads an oracle for
  enumerating another partner's ids.

### Changed

- **The app launcher shows the whole catalogue, in two groups.**
  `<ConnectAppsGrid>` now renders **Your apps** - what this workspace holds a
  live subscription to - and **Explore**, the rest of the platform's active
  applications. A tile in the first opens the application's own site; a tile in
  the second opens that application's page on Connect, where a plan is chosen.
  Your integration builds neither URL.

  Tiles draw the application's registered icon (its uploaded brand image, or
  its `lucide-react` icon from Connect's curated set) instead of a generic grid
  glyph, the grid animates in, and a filter field appears once there are more
  than eight tiles. The **"Browse all applications"** footer link is **gone**:
  it pointed at `/applications` on Connect, a route that has never existed, so
  the one discovery path the launcher offered was a 404. Discovery belongs in
  the grid.

- **`GET /apps` answers with every active application**, not only the
  subscribed ones. `WorkspaceApp` gains two fields:

  ```ts
  interface WorkspaceApp &#123;
    subscribed: boolean;   // launchable now, vs. an invitation to subscribe
    category: WorkspaceAppCategory;
    // …unchanged
  &#125;
  ```

  `needsAttention` is always `false` when `subscribed` is `false`, and an
  application whose subscription was **cancelled** now appears in the explore
  half rather than disappearing. Applications this workspace's `type` is
  outside the audience of are omitted entirely unless it already holds a
  subscription to one - showing a tile whose only outcome is a refusal at
  checkout is the dead end this change set out to remove.

  **If you render your own grid from `connect.apps.list()`, filter on
  `subscribed` to get the previous set back.** That is the only migration;
  nothing was removed from the row.

## 0.19.0

### Added

- **`connect.contacts`** - who a workspace already knows how to reach.

  ```ts
  const contact = await connect.contacts.lookupByPhone(&#123; workspaceId, phone &#125;);
  if (contact) &#123;
    // A caller this workspace has met before.
  &#125;
  ```

  `lookupByPhone` is one point read, cheap enough for a screen pop on every
  inbound call, and resolves to `null` for a first-time caller - the ordinary
  case, not an error. `get(contactId, &#123; workspaceId &#125;)` reads the record behind
  an id you already hold. Needs a key scoped to `contacts:read` and a live
  subscription in the workspace.

  **This is the telephony identity, not a CRM record.** A contact is keyed by
  `primaryPhone` - the one fact every inbound call carries - and it is written
  automatically: a voice agent creates one on the first call from a new number
  and fills fields in as it learns them. `source` tells you which kind you are
  holding (`call` means an agent guessed and nobody has confirmed), and
  `phones[].verified` tells you the same about a single value.

  **Reference it, do not copy it.** If your application keeps its own customer
  records, store this contact's `id` on yours. Two writable copies of a
  person's name with no single owner drift, and Connect's is the one an agent
  keeps updating from calls. Storing the id also makes `Booking.contactId` an
  **exact** join - so "which of my records is this appointment for" is a lookup
  rather than a phone-number match, including for appointments a voice agent
  booked where nobody ever chose one of your records.

  Read-only, deliberately. Writing into a store with a per-field provenance
  chain means declaring where your facts came from and how they merge with a
  human's; nothing needs that yet.

- New exported types: `ConnectContact`, `ContactChannelValue`, `ContactSource`,
  and `ContactsResource` / `LookupContactInput` from
  `@cosmoops/connect-sdk/server`.

## 0.18.0

### Added

- **`connect.scheduling.rescheduleBooking()`** - move an appointment without
  cancelling and rebooking it.

  ```ts
  const &#123; booking, previous &#125; = await connect.scheduling.rescheduleBooking(&#123;
    workspaceId,
    bookingId,
    startAt: newSlot.startAt,
  &#125;);
  ```

  The original is **retired, not edited**: it stays on the workspace's diary as
  `rescheduled` with `rescheduledToId` pointing at its replacement, and both
  come back. So a booking id you cached still resolves and still says where the
  appointment went, and a report that already counted the original hour is not
  rewritten under it.

  **A refused move changes nothing.** The new slot is claimed before the old one
  is released, so a `conflict` leaves the customer holding the appointment they
  already had.

- **`excludeBookingId` on `findSlots()`** - set it when offering times to move
  an existing appointment to. Without it the appointment counts as blocking its
  own replacement, and the adjustment people actually make - same day, half an
  hour later - comes back with nothing available.

- **`Booking.rescheduledToId`**, set when `status` is `rescheduled`.

- **`<ConnectBookingCalendar>`** - the workspace's week, in your app.

  ```tsx
  import &#123; ConnectBookingCalendar &#125; from "@cosmoops/connect-sdk/client";

  <ConnectBookingCalendar timeZone="Asia/Kolkata" />
  ```

  A week grid with booking, rescheduling and cancelling built in. Clicking an
  empty hour opens a slot picker; clicking an appointment opens it with Move
  and Cancel. Everything goes through the bridge as the signed-in member, so
  Connect checks *their* `calendar:read` / `calendar:manage` - pass
  `canBook=&#123;false&#125;` to render the read-only shape without offering a button
  that would be refused.

  **Two views, and the viewport decides.** A seven-column time grid needs about
  640px before a column can hold a name, so below `sm:` the same week renders
  as a grouped agenda - a different shape for a phone, not a squeezed grid.

  **`timeZone` is the business's, not the viewer's.** Every instant is read
  against it, so a workspace's working day does not shift because a member is
  travelling. It defaults to the host's zone, which is right when staff and
  business share one; pass it when they might not.

- **Hooks, if you would rather build your own surface**:
  `useBookableEventTypes`, `useAvailableSlots`, `useBookings`,
  `useBookingActions`. None of the actions is optimistic - whether a slot is
  still free is a question only the server can answer inside the transaction
  that decides it.

- **`layoutBookings` and friends** from `booking-grid.js`, exported so a custom
  grid can reuse the arithmetic: zone-correct day bucketing (half-hour zones
  and DST included), transitive overlap clustering into side-by-side columns,
  and a minimum height so a fifteen-minute appointment stays clickable.

- **`asUser` on every `scheduling.*` method**, and the six scheduling routes it
  exists for on `createConnectHandler`'s bridge:
  `GET /scheduling/&#123;event-types,slots,bookings&#125;` and
  `POST /scheduling/bookings&#123;,/reschedule,/cancel&#125;`.

  With `asUser`, Connect derives the workspace from **that member's session**
  and ignores the `workspaceId` you passed, then checks their own
  `calendar:read` / `calendar:manage`. The bridge always sends it, and that is
  what makes a browser-facing booking component safe: a browser must never name
  a tenant, and an end user of your app is not automatically allowed to book on
  their employer's calendar just because your API key can.

  Without `asUser` nothing changes - the call is your server acting for a
  workspace you name, gated on your application being installed there.

## 0.17.0

### Added

- **`connect.scheduling.*` - a subscribing workspace's own calendar.**

  ```ts
  const eventTypes = await connect.scheduling.listEventTypes(workspaceId);
  const &#123; slots, timezone &#125; = await connect.scheduling.findSlots(&#123;
    workspaceId,
    eventTypeId: eventTypes[0].id,
    from: new Date(),
    to: nextWeek,
  &#125;);
  const booking = await connect.scheduling.createBooking(&#123;
    workspaceId,
    eventTypeId: eventTypes[0].id,
    startAt: slots[0].startAt,
    attendee: &#123; name: "Priya", phone: "+919000000001" &#125;,
  &#125;);
  ```

  Five methods - `listEventTypes`, `findSlots`, `listBookings`,
  `createBooking`, `cancelBooking` - against the same calendar the workspace's
  own team configures and its voice agent books into. A booking you make shows
  up on their screen immediately, and a slot their receptionist just took is
  gone from your next `findSlots`. That is the reason to read it through
  Connect rather than keeping a copy: two calendars double-book, and nothing
  reconciles them afterwards.

  Needs a key scoped to `calendar:read` (and `calendar:write` to book), **and**
  a live subscription to your application in the workspace you name. The key
  says which application is calling; the subscription is what makes you welcome
  in a given tenant. A workspace that has not installed your application
  answers `403` with the same message as an unknown workspace id.

  Two things to hold onto when integrating:

  - **`findSlots` is advisory.** Between reading it and booking, anyone else on
    that calendar can take a slot - a member, a voice agent mid-call, someone on
    the workspace's public booking link. `createBooking` re-checks inside a
    transaction and throws a `ConnectError` with `code: "conflict"` rather than
    double-booking. Handle that as an ordinary outcome and offer the next time;
    it is not an error condition.
  - **`timezone` is the business's, not the viewer's.** It is what to render a
    slot's wall time in. A slot shown in the viewer's zone is an hour the
    business may not be open.

- New exported types: `BookableEventType`, `BookingSlot`, `BookingSlots`,
  `Booking`, `BookingAttendee`, `BookingStatus`, `BookingSource`,
  `ListBookingsPage`, and the `SchedulingResource` input types from
  `@cosmoops/connect-sdk/server`.

- `ConnectError.code` can now be `"conflict"` - today only from
  `createBooking`. Nothing that switched on the previous codes stops
  compiling: `code` is `string`, deliberately, so a new one reaches a partner
  as a case to handle rather than a type error on a published package they
  cannot patch.

## 0.16.1

### Fixed

- **`McpToolResult` is a type alias, not an interface** — so `callTool()` can be
  returned straight from `server.setRequestHandler(CallToolRequestSchema, ...)`.

  The MCP SDK types a request handler's return as a union whose members carry an
  index signature. TypeScript does not grant an interface the implicit index
  signature it grants a type alias, so as an interface the result was assignable
  to none of them, and the four-line wiring in 0.16.0's own documentation failed
  to compile with a misleading `Property 'task' is missing`. The only way past it
  was a cast, at exactly the boundary where a cast hides shape errors.

  Type-level only; no runtime change, and nothing that compiled against 0.16.0
  stops compiling.

## 0.16.0

### Added

- **`@cosmoops/connect-sdk/mcp` - your store declaration, as MCP tools.**

  ```ts
  import &#123; createStoreToolset &#125; from "@cosmoops/connect-sdk/mcp";

  const toolset = createStoreToolset(&#123;
    collections: STORE_SCHEMA,      // the array you already seed to Connect
    data: connect.data,
    workspaceId: session.workspaceId,
    asUser: session.sessionToken,   // every call runs as this user
  &#125;);

  server.setRequestHandler(ListToolsRequestSchema, () => toolset.listTools());
  server.setRequestHandler(CallToolRequestSchema, (request) =>
    toolset.callTool(request.params.name, request.params.arguments ?? &#123;&#125;),
  );
  ```

  `Application.storeSchema` already says what each collection holds, which
  fields may be filtered on, which query shapes the database can serve, and
  whether it refuses everything but a create. Writing tools over that by hand
  means keeping a second copy of all of it, and the copy is wrong the first time
  a filter is added to a screen. So they are derived: adding a queryable field
  and re-seeding widens the tools on the next boot, with no tool code to touch.

  Three read tools per collection (`_get`, `_list`, `_aggregate`), and writes
  only if you ask - `writes: "create" | "all"`, defaulting to `"none"`. An
  `appendOnly` collection never derives an update or a delete, because Connect
  would refuse them anyway and a tool that can only fail is worse than no tool.

  Each tool's description carries the collection's queryable fields and its
  declared composite indexes, and an unservable query is refused locally with
  the index it would need - so a model adapts instead of spending a turn
  discovering the shape.

  **Every call carries an end user's session token.** Connect resolves it to a
  member and checks the collection's declared `readPermission`/`writePermission`
  against that member's live roles, so a toolset never reaches further than the
  person it runs for, and a permission revoked in the console is gone from the
  next call. Build one per request from `getConnectSession()`, or pass a
  function for `asUser`.

  There is deliberately **no scan tool**. `data.scan` reads across every
  workspace and for that reason takes no `asUser` - deriving one would mean that
  single call escalating to your API key while every tool beside it ran as the
  user. Cross-tenant reads belong behind their own gate.

  Server-only, like `/server` and `/next`: a browser import resolves to the
  guard stub and fails the build. **No new dependency** - `listTools()` and
  `callTool()` answer in the shapes the MCP protocol defines, so the official
  MCP SDK stays yours to install and version.

  **Pass `include`.** Measured on the reference applications, deriving over
  every collection yields 45 read tools for club and 21 for CRM - whole-catalogue
  numbers, and a model offered them chooses worse than one offered a handful.
  Name the collections the surface at hand is about. Where a collection declares
  more index shapes than are worth listing (club's ledger declares around
  forty), the description states the rule and leaves the specifics to the
  refusal, which names the exact index a rejected query needed.

- **`StoreCollectionDeclaration`, `describeQueryRejection` and
  `storeIndexCovers`** on the root entry. The declaration type is the shape
  Connect stores on `Application.storeSchema`, so an app deriving its schema
  from its own query matrix can stop declaring a local copy of it.

## 0.15.0

Your application's own database stops being a toy. Real queries, real
aggregations, and real atomicity - without ever handing you a Firebase
credential.

Everything here is `data.*`. The rest of the SDK is unchanged.

### Added

- **`data.aggregate()` - count, sum and average in the database.**

  ```ts
  const &#123; values &#125; = await connect.data.aggregate(&#123;
    workspaceId,
    collection: "deals",
    filters: [&#123; field: "outcome", op: "==", value: "won" &#125;],
    aggregations: [
      &#123; op: "count", alias: "count" &#125;,
      &#123; op: "sum", field: "value", alias: "total" &#125;,
    ],
  &#125;);
  ```

  Firestore bills an aggregation by the index entries it scans rather than by
  the documents it would have returned, so a dashboard tile over ten thousand
  rows costs a handful of reads and stays flat as the collection grows. Every
  integration that had no aggregate call to reach for was paging the rows into
  its own process and adding them up, which is an unbounded read wearing a
  different name.

  A `sum` or `avg` over nothing answers `null`, not `0` - "nothing matched"
  and "the total is zero" are different facts.

- **`data.commit()` - a set of writes that all land or none do**, across as
  many of your collections as they name.

  You never hold a transaction: a handle passed across an HTTP boundary is a
  server-side lock whose duration your network controls. Instead you read,
  decide, and commit conditional on nothing having moved. Connect runs the
  whole set as one short transaction on its side.

  ```ts
  import &#123; increment, unchangedSince &#125; from "@cosmoops/connect-sdk";

  await connect.data.commit(&#123;
    workspaceId,
    operations: [
      &#123; op: "create", collection: "ledger", id, data: entry &#125;,
      &#123;
        op: "merge",
        collection: "funds",
        id: entry.fundId,
        data: &#123; balancePaise: increment(entry.amountPaise) &#125;,
      &#125;,
    ],
  &#125;);
  ```

  A stale precondition throws `ConnectError` with status 409 and writes
  nothing.

- **`data.transaction(build)`** - the read/decide/commit loop with the retry
  written once. `build` re-runs against fresh reads when a precondition went
  stale, and only a 409 is retried: a 403 means the operation was wrong rather
  than late.

- **Field transforms**: `increment`, `serverTimestamp`, `arrayUnion`,
  `arrayRemove`, `deleteField`, plus the `unchangedSince(document)` and
  `documentExists()` precondition builders. All exported from the root entry,
  so a Server Component and a client component build the same write the same
  way.

  `increment` is the one that genuinely cannot be done any other way: it is
  commutative, so two concurrent increments both land - where read-add-write
  from your own process loses one of them however carefully it retries.

- **Composite indexes.** Declare them per collection on your application's
  **Store** tab and Connect builds them in your database, reconciling on every
  save - including removing ones you no longer declare, which would otherwise
  be paid for on every document write forever.

  This is what lifts the old one-filter ceiling. A query with no matching
  index is still refused, but the refusal now names the index to declare
  rather than telling you the shape is impossible.

- **`_updateTime`** on every document that came from a read - Firestore's own
  write timestamp, and the value a `&#123; updateTime &#125;` precondition is compared
  against. Not `_updatedAt`: a transform in the same commit can move that
  field, and cannot touch the write timestamp.

### Changed - breaking

- **`data.query(&#123; filter &#125;)` is now `data.query(&#123; filters &#125;)`**, a list, ANDed.
  `data.scan` takes the same. One filter becomes a one-element array:

  ```ts
  // Before
  await connect.data.query(&#123; workspaceId, collection: "tickets", filter: f &#125;);
  // After
  await connect.data.query(&#123; workspaceId, collection: "tickets", filters: [f] &#125;);
  ```

  The server still accepts the singular `filter` query parameter, so an
  integration on 0.14 or earlier keeps working against an upgraded Connect -
  but the SDK method has moved and this one is a code change.

- **`Plan.billingInterval` and `SubscribedPlan.billingInterval` are now
  `BillingInterval`**, which adds `"one-time"`. Connect has been able to
  return it; the SDK type could not represent it, so a one-time plan was typed
  as monthly and rendered "per month" - a false price rather than a cosmetic
  slip. `ConnectPlans`, `ConnectSubscription` and the subscribe dialog now
  label it "once".

  Exhaustive `switch`es on a billing interval will need a third arm.

### Added - operators

- `not-in` joins the query operators.

## 0.14.0

One session answers "who is this?" once, and the subscribe button goes where
it says it does.

### Changed - breaking

- **`applicationRole` and `platformAccess` are now `role`, `permissions` and
  `staff`**, three flat fields on `exchangeCode()` and `getUser()` (and on the
  `/session` bridge read).

  The two blocks they replace were mutually exclusive by construction. A
  Connect platform account belongs to no workspace - Connect enforces it - so
  a member session always carried `platformAccess: null` and a staff session
  always carried `applicationRole: &#123; role: null, granted: [] &#125;`. Two shapes
  for one question meant every integration wrote the same null-check twice,
  and the block that did not apply still had to be built and sent.

  ```ts
  // Before
  const &#123; applicationRole, platformAccess &#125; = await connect.identity.getUser(t);
  if (platformAccess) &#123;
    if (!platformAccess.roles.includes("platform_admin")) return forbidden();
    return renderOperatorConsole();
  &#125;
  if (!applicationRole.granted.includes("invoice:write")) return forbidden();

  // After
  const &#123; role, permissions, staff &#125; = await connect.identity.getUser(t);
  if (staff) &#123;
    if (role !== "platform_admin") return forbidden();
    return renderOperatorConsole();
  &#125;
  if (!permissions.includes("invoice:write")) return forbidden();
  ```

  Read `staff` first: it decides which vocabulary the other two speak - your
  own Permission Catalog's ids for a member, Connect's `platform:*` ids for an
  operator. A staff session reports one `role` rather than a list, and it is
  the highest-authority role your application accepts; the platform roles nest
  (`developer` ⊆ `platform_manager` ⊆ `platform_admin`), so nothing is lost,
  and `permissions` is still the union over every accepted role.

  `ApplicationRole` and `PlatformAccess` are gone as exported types, replaced
  by `SessionAccess`. `useConnectPlatformAccess()` keeps its shape -
  `roles` is now a one-element array on a staff session, `[]` otherwise - so
  `<ConnectPlatformGate accept=&#123;[...]&#125;>` is unchanged.

  The signed-out `/session` response carries the same three fields
  (`role: null, permissions: [], staff: false`) rather than a bare
  `applicationRole: null`, so a client never tells "signed out" apart from
  "signed in with no role" by which keys happen to be present.

- **Workspace role ids lost their `org_` prefix** - `org_admin` is `admin`,
  and the same for `finance`, `manager` and `member`. This is visible to you
  only through `access.getUserRoles()` and `access.listRoles()`, which report
  a *member's* resolved role on your application rather than these; if you
  hardcoded a Connect workspace role id anywhere, it needs updating. Grants
  stored under the old ids keep resolving.

### Fixed

- **Subscribing no longer lands on a 404.** `buildCheckoutUrl` - and so
  `<ConnectPlans>`'s subscribe button - built
  `/applications/&#123;id&#125;/subscribe/&#123;planId&#125;`. The portal route customers can
  actually open is `/apps/...`; `/applications` is a Console-only prefix. Every
  first-time subscriber sent from a partner's own pricing page hit a 404. The
  matching `resolveUrl` on the `subscription_missing` readiness blocker was
  wrong the same way and is fixed with it.

- **`staff` actually reaches you.** Both `/sso/token` and `/sso/user` resolved
  the staff block, typed it and documented it, then dropped it while building
  the JSON response - the handlers are hand-built rather than schema-parsed,
  so a forgotten field was silent. Staff access has been invisible to every
  partner since it shipped in 0.11.0. If you turned it on and concluded it did
  not work, it did not.

- `SDK_VERSION`, sent in the `User-Agent`, said `0.12.0` on a 0.13.0 package.

## 0.13.0

The platform's grievance channel is now something you read rather than
something you copy.

### Added

- **`connect.platform.getCompanyProfile()`** - the registered entity operating
  Connect, and the grievance officer s.13 of India's DPDP Act 2023 requires a
  Data Fiduciary to publish. Backed by the new
  `GET /api/v1/application/company-profile`, which needs the new
  **`company:read`** scope on your API key.

  Your application is a Data Processor on that notice, not a second Data
  Fiduciary, so the channel your privacy page names is this one. The shape it
  replaces is four lines in an `.env`:

  ```bash
  # Before - a statutory contact that can go stale without anyone finding out.
  APP_DATA_FIDUCIARY_NAME="..."
  APP_GRIEVANCE_OFFICER_EMAIL="..."
  ```

  An officer resigns, and every application holding a copy is now publishing a
  redressal channel that reaches nobody. Reading it removes that failure mode
  outright, which is the entire reason it is an endpoint.

  Every field is resolved server-side against the platform's published
  defaults, so only `website` and `gstNumber` are nullable - there is nothing
  here for you to write a fallback for.

- **`useConnectCompanyProfile()`** - the same read through the browser bridge.
  The one hook here that does not depend on a session, so it resolves for a
  signed-out visitor too: the moment a grievance channel most needs to be
  readable is on a public privacy page.

- **`<ConnectGrievanceNotice />`** - the notice, rendered. The wording lives in
  the SDK rather than being retyped per application, so a user comparing two
  applications' privacy pages does not find two different accounts of who
  their Data Fiduciary is.

  ```tsx
  import &#123; ConnectGrievanceNotice &#125; from "@cosmoops/connect-sdk/client";

  <ConnectGrievanceNotice applicationName="CosmoOps CRM" />
  ```

  It renders `null` while loading and `null` on failure rather than a skeleton
  or an error box - a red error in the middle of a legal notice reads as the
  notice itself being broken. Call the hook directly if you want those states.

### Upgrading

Existing API keys do not carry `company:read`. Tick it on the key's scopes in
the Console's Integration tab, or re-run the seed script for that application,
which widens scopes in place.

## 0.12.0

The application shell is one column now. The top bar is gone, the rail holds
everything it used to, and the rail starts collapsed.

### Changed - breaking

- **Two renames, both with deprecated aliases kept**, so nothing breaks on
  upgrade:

  | Was | Now | Why |
  |---|---|---|
  | `ConnectSideNav` | `ConnectSidebarNav` | The rail is a `ConnectSidebar` now; its parts are named after it |
  | `ConnectUserMenu` | `ConnectUserButton` | It is a button that opens a modal. "Menu" had people looking for a dropdown that never existed |

  **`AppShell` keeps its name.** It stays the one export in `/client` without
  the `Connect` prefix, exactly as 0.8.0 decided. A host whose own layout
  component is also called `AppShell` aliases it on import - `import &#123;
  AppShell as ConnectAppShell &#125;` - which is the documented answer to that
  clash and what both reference integrations do.

- **The top bar is removed.** With it go two props:

  - **`title`** - a page's heading belongs to the page. Move it to
    `ConnectPageShell`, from `@cosmoops/connect-sdk/ui`, which renders it next
    to that page's own breadcrumbs and actions:

    ```tsx
    // before                     // after
    <AppShell title="Billing">    <AppShell>
      <BillingPage />               <ConnectPageShell title="Billing">…</ConnectPageShell>
    </AppShell>                   </AppShell>
    ```

  - **`headerActions`** - there is no header to put them in. A control that
    belongs to a page goes in that page's `ConnectPageShell`; a control that
    belongs to the *application* goes in `sidebarFooter`, which now accepts
    sidebar rows (see `ConnectSidebarRow`).

  Below `md` a slim bar remains, carrying the drawer trigger and the lockup and
  nothing else. A phone has no rail to hold that trigger.

- **`defaultCollapsed` now defaults to `true`.** The icon rail is the resting
  state and expanding is the deliberate act - the rail's job is to be *there*,
  not to be read. A stored preference from the user's last visit still wins.
  Pass `defaultCollapsed=&#123;false&#125;` to keep 0.11's behaviour.

- **`ConnectUserMenu`'s `compact` prop** is superseded by
  `ConnectUserButton`'s `variant`. The alias maps `compact` to
  `variant="compact"` and the old default to `variant="inline"`, so existing
  call sites are unchanged.

- **`ConnectAppsGrid` now renders as a sidebar row by default**
  (`variant="row"`). Pass `variant="icon"` for the square accent button it used
  to be.

### Added

- **`secondaryNav`** on `AppShell` - a second `ConnectNav`, pinned to
  the foot of the rail above the account row. For the routes a product has but
  does not want competing with its own: settings, help, plans, status. Same
  shape, same active resolution, same audience filtering, same `gateExempt`
  handling as `nav`.

- **The workspace switcher moved into the rail's header**, directly under the
  brand, and is now collapse-aware: a 32px initials tile when the rail is
  narrow, tile plus name plus chevron when it is open. It still hides itself
  for an account with one workspace.

- **The account row is the last thing in the rail.** Circular avatar, name over
  email, opening the same account modal as before. It collapses to the avatar
  alone, which is still a complete account control.

- **`ConnectProfileModal`** - the account modal with no trigger of its own,
  controlled by `isOpen`/`onOpenChange`. For an application that wants to keep
  its own account control and put Connect's modal behind it.

- **The rail's pieces are exported**: `ConnectSidebar`, `ConnectSidebarHeader`,
  `ConnectSidebarBody`, `ConnectSidebarFooter`, `ConnectSidebarRow`, plus
  `ConnectShellProvider` / `useConnectShell` / `sidebarRowClass` and the
  `RAIL_COLLAPSED` / `RAIL_EXPANDED` constants. `AppShell` composes
  exactly these, so an application whose chrome the prop API cannot express
  rebuilds the same rail from the same parts rather than forking it.

- **`ConnectLogo` takes `variant`** - `"full"` | `"mark"` | `"wordmark"`.
  `collapsed` is deprecated in favour of `variant="mark"`.

### Fixed

- **The collapsed Ask AI panel occupied 400px at `lg` and up.** Its shell
  carried `lg:w-[400px]` and `lg:w-0` at once; Tailwind emits arbitrary values
  after scale values, so the override never applied and the closed panel sat
  permanently in the layout, eating that much of the content pane on every page
  with `askAi` on. Only the `translate-x` hid it below `lg`, which is why it
  looked right on a phone and wrong on a laptop.

- **Sidebar contrast, in both directions.** Resting nav rows were
  `--foreground` - 17.6:1 against the rail in light, 15.8:1 in dark - so eight
  destinations shouted as loudly as body copy and the selected row had to win
  on background alone. They are `--muted` now (4.8:1 / 6.2:1, past AA), with
  `--foreground` on hover. The *active* row was `text-accent` on
  `bg-accent-soft`, which measures **2.8:1 in light and 4.0:1 in dark - both
  below AA**, on the one row a user most needs to read; it now uses HeroUI's
  derived `--accent-soft-foreground` (4.8:1 / 5.6:1), so a host that re-themes
  `--accent` keeps the contrast.

- **The account row and the app launcher looked permanently selected.** Both
  are HeroUI `<Button variant="tertiary">` because they trigger overlays, and
  `.button--tertiary` sets a real `--button-bg`. Next to the plain anchors the
  nav renders, three rows in the rail appeared highlighted at once.

- **The account avatar is a circle.** HeroUI's `.avatar` is `rounded-2xl`/
  `rounded-3xl` - right for the media component, wrong for a person, and it
  read as a rendering fault beside circular status dots.

### Motion

The rail animates its **width and nothing else**, via `motion`. Every row is
laid out so its glyph sits on the same 32px horizontal centre in both states,
and every label is clipped by the shrinking rail rather than re-measured - so
collapsing costs no reflow on any row, and no child moves sideways. Section
headings hold their box and cross-fade a rule in place of their text, so
nothing jumps vertically either. A tween rather than a spring: an overshooting
*width* drags every clip edge past its resting place and back, which reads as
the labels wobbling.

## 0.11.0

### Changed - breaking

- **`ConnectUser.workspaceId`, `workspaceName` and `workspaceType` are now
  nullable.** They are `null` for exactly one thing: a **Connect platform
  staff** session, described below. Every member session carries them as
  before.

  If you never turn staff access on for your application - it is off by
  default, and only a Connect platform admin can turn it on - this is a type
  narrow and nothing else. Reading `user.workspaceId` where a `string` is
  wanted becomes a compile error, and `?? ""` is the wrong fix: decide what
  your tenanting does with a session that has no tenant before you enable the
  feature.

  The honest alternative was a synthesized placeholder workspace, which would
  have kept the types quiet while flowing a fabricated tenant id into the ID
  token's `org_id` claim and into whatever you key your own rows on. A
  compile error is cheaper than that.

### Added

- **Connect platform staff sign-in.** Connect's own operators
  (`platform_admin`, `platform_manager`, `developer`) can now sign into your
  application, so an operator console can live inside your product and be
  authenticated by Connect rather than by a second login you maintain.

  These accounts belong to no workspace - Connect enforces that, they cannot
  own or join one - which is why the session is shaped differently: no
  `workspaceId`, `subscription: null`, and `applicationRole.granted: []`.
  Platform authority is deliberately *not* reported through the member-role
  field. What staff hold is the new block:

  ```ts
  const &#123; platformAccess &#125; = await client.identity.getUser(sessionToken);
  if (!platformAccess) return forbidden();
  if (!platformAccess.roles.includes("platform_admin")) return forbidden();
  ```

  `roles` is the intersection of what the account holds and what your
  application accepts - never Connect's full role list for that person.

  Off by default and per-application. Ask a Connect platform admin to enable it
  on your catalogue entry, naming the roles you accept; until then
  `platformAccess` is always `null` and nothing below does anything.

- **`platformAccess` on `exchangeCode()` and `getUser()`** - `PlatformAccess |
  null`. Live on `getUser()`, like `subscription` and `applicationRole`: a
  platform role revoked, or staff access turned off for your application,
  comes back `null` there and Connect has already revoked the session.

- **`<ConnectPlatformGate>`** and **`useConnectPlatformAccess()`** in
  `/client`. The gate renders its children only for a staff session, and takes
  an optional `roles` allowlist when your console has tiers of its own.

  Like every gate in this package it is a **UX gate, not a security
  boundary** - and that matters more here than anywhere else, because what
  sits behind it is usually an operator console. Re-check `platformAccess`
  server-side on every route it reveals.

- **`ConnectNavItem.audience`** - `"staff"` or `"member"`. `AppShell` filters
  the rail by it off the live session, so your operator routes appear only for
  staff and your billing/team/usage routes disappear for them. A parent whose
  children all disappear is dropped with them. `filterNavForAudience` is
  exported for a rail you render yourself.

- `ConnectAccessGate` passes a staff session straight through. Every state it
  reports is a fact about a workspace, and `subscription_missing` would
  otherwise send an operator to your plan picker to buy a plan for a workspace
  that does not exist.

### Note

Workspace-scoped bridge routes (`/entitlements`, `/addons`, `/integrations`,
`/ai/chat`, `/data/*`, `/kv/*`) answer `403 no_workspace` for a staff session.
There is no tenant to read, and the only other option would be letting the
request name one - which is precisely what `createConnectHandler` exists to
prevent. An operator console reads `platformAccess` and talks to your own
backend for anything tenant-shaped.

## 0.10.0

### Added

- **`@cosmoops/connect-sdk/ui`** - the page-composition layer, and a new entry
  point. `ConnectPageShell` (with `.Header` and `.Async`), `ConnectPageHeader`,
  `ConnectSection`, `ConnectEmptyState`, `ConnectSettingRow`,
  `ConnectStatusChip`.

  The SDK already shipped the *application* chrome - the shell, the launcher,
  the account menu, the plan grid - and nothing between that and raw HeroUI. So
  every integration re-derived the same six primitives, and they drifted the
  same way each time: the page action ending up below the description instead
  of beside the title, section headings rendered as `h2` so every block became
  a sibling of its own page, and one empty state doing duty for both "you have
  none" and "none match this filter". Those are the three bugs these components
  are shaped to make unrepresentable, not merely to style consistently.

  **A separate entry from `/client`, deliberately.** That module's barrel
  carries a `"use client"` directive, so re-exporting a presentational `<Card>`
  wrapper through it would turn every page shell into a client boundary. Nothing
  in `/ui` has a hook or state, so it renders in a Server Component and stays
  out of the browser bundle.

  ```tsx
  import &#123; ConnectPageShell, ConnectSection &#125; from "@cosmoops/connect-sdk/ui";
  ```

- **`ConnectConfirmDialog`, `ConnectCursorPagination`, `ConnectFilterTabs`** in
  `@cosmoops/connect-sdk/client` - the interactive counterparts, which hold
  state and so belong on the browser side. `ConnectCursorPagination` has no page
  numbers on purpose: a keyset-paginated list cannot know its page count without
  a second billed index scan over the same filter, for a number nobody acts on.

## 0.9.0

### Added

- **`user.workspaceType`** on `exchangeCode()` and `getUser()` - `"individual"`
  (a one-seat personal account, KYC) or `"business"` (a team, KYB). The one
  fact about the signed-in workspace a partner could not look up on its own:
  the application API has no workspace read, so an application serving both
  audiences had to render one UI for everybody. An `"individual"` workspace
  has exactly one seat and can never gain another, so a members table, an
  invite button and a seat meter are all dead controls there.

  Re-read it rather than caching it past sign-in: an individual workspace can
  convert to a business one under a live session (the reverse never happens).

- **`audience`** on the same two responses - what your application is *sold*
  to, joined with the account in front of you:

  ```ts
  const &#123; user, audience &#125; = await connect.identity.exchangeCode(&#123; code, redirectUri &#125;);
  // user.workspaceType    -> "individual"
  // audience.application  -> "b2b"
  // audience.matches      -> false
  ```

  `matches: false` is a hard block, not a hint: readiness reports an
  `audience_mismatch` blocker and any attempt to subscribe is refused. It ships
  alongside `workspaceType` so the check stays `!audience.matches` instead of a
  hardcoded audience constant in your own code that goes stale the moment the
  catalogue entry changes. `audience.enforcedAtSignIn` tells you whether
  Connect refuses the sign-in itself, in which case a mismatch can only reach
  you on a session minted before that setting was turned on.

- **`<ConnectWorkspaceTypeGate>`**, plus `useConnectWorkspaceType()` and
  `useConnectAudience()`:

  ```tsx
  <ConnectWorkspaceTypeGate type="business">
    <TeamMembersTable />
  </ConnectWorkspaceTypeGate>
  ```

  This is *not* the audience gate - it does not know what your application is
  sold to, and is for varying what each account type sees *inside* an
  application both audiences can reach. `<ConnectAccessGate>` remains the one
  that blocks a mismatched account outright, and already did.

  Like every gate in this package: a UX gate, not a security boundary.

- **`<ConnectAskAi>`**, and `askAi` on `AppShell` - a chat panel grounded in
  the signed-in workspace's Context Index. **On by default**, like `gate`:

  ```tsx
  <AppShell name="Dispatch" nav=&#123;nav&#125;>
    &#123;children&#125;
  </AppShell>
  ```

  It stays inert until it can work - nothing renders for a signed-out visitor,
  and a workspace whose Context Index is off gets a message in the panel rather
  than a broken answer. Pass `askAi=&#123;false&#125;` for an application that should not
  offer it, or an object to override the heading, copy or markdown plugins.

  It holds no credential, like everything else in `./client`: it POSTs to
  `$&#123;basePath&#125;/ai/chat`, a new bridge route on `createConnectHandler` that
  resolves the workspace from the caller's own session and streams Connect's
  answer straight through. A browser-supplied `workspaceId` would be another
  tenant's documents delivered as a chat reply, so there isn't one.

  The panel parses the server-sent event stream itself rather than pulling in
  `ai`/`@ai-sdk/react`.

- **Answers that draw, not just write.** Connect's Ask AI can call a small set
  of *presentation tools*, and this panel renders each one natively:

  | Tool | Rendered as |
  |---|---|
  | `showTable` | a sortable table with a CSV download |
  | `showChart` | a bar / line / area chart (inline SVG), or a labelled proportion list for `pie` |
  | `showMetrics` | up to six metric tiles with period-over-period deltas |
  | `showTimeline` | dated events on a rail |
  | `showDetails` | one entity's fields as a fact sheet |
  | `showSteps` | a numbered procedure |
  | `showSources` | the documents a grounded answer rested on |

  Every value arrives **raw with a format tag** - a currency is the number
  `12500`, never the string `"₹12,500"` - so it is formatted in the viewer's
  locale here, and a column still sorts numerically. The seven widgets and
  `formatAiValue` are exported for a partner rendering Connect's chat somewhere
  other than this panel.

  No charting library ships in this package: the chart is hand-drawn SVG. A
  400px side panel does not need pan, zoom or brushes, and a charting
  dependency is not a reasonable thing to make every platform install.

- **Markdown, via `streamdown`** - tables, fenced code, task lists, and a
  word-by-word `blurIn` reveal with a caret while the answer streams. Three
  host steps, all one-time:

  1. `npm i streamdown` (a new **optional** peer dependency - the panel is the
     only thing that uses it).
  2. `@source "…/node_modules/streamdown/dist/*.js";` in your Tailwind CSS, so
     its utility classes are generated.
  3. `import "streamdown/styles.css";` for the streaming animation.

  The colour tokens Streamdown expects (it paints with shadcn/ui's names) are
  mapped onto HeroUI's in this SDK's `theme.css`, which you already import - so
  answers inherit your platform's theme with no further work.

  Syntax highlighting, KaTeX, Mermaid and CJK typography are Streamdown plugin
  packages, off unless you pass them - several are large, and prose, tables and
  plain code fences render without any of them:

  ```tsx
  import &#123; code &#125; from "@streamdown/code";
  const PLUGINS = &#123; code &#125;;   // module scope: Streamdown compares this by reference

  <AppShell name="Dispatch" nav=&#123;nav&#125; askAi=&#123;&#123; markdownPlugins: PLUGINS &#125;&#125;>
  ```

- `streamResource()` on `useConnect()` - POST through the bridge and get the
  raw `Response` back, unread and uncached. The seam `<ConnectAskAi>` streams
  over, and the one to use for any future streaming route.

### Changed

- `ConnectSession` (the value behind `useConnect()`) carries `audience`,
  `null` when signed out alongside the three fields that already were.

### Known gaps

- No SDK methods for the channel and call endpoints (`GET /channels`,
  `GET`/`PUT`/`DELETE /channels/&#123;channelId&#125;/routing`, `GET /calls`,
  `GET /calls/&#123;callId&#125;`). They are documented in the partner API and callable
  over HTTP; they just have no typed wrapper here yet. The conformance suite
  names them, and stays red until they land.

### Fixed

- `ai.chat()` no longer has its stream cut off mid-answer. The transport's
  `timeoutMs` (20s by default) was handed to `fetch` as an `AbortSignal`, and a
  `fetch` signal keeps governing the *body* long after the headers arrive - so
  any conversation that took longer than the timeout to finish generating was
  torn down. The timeout is now cleared once an attempt is done reading, which
  for a `raw` (streaming) request means once the headers land. Pass your own
  `signal` to bound the stream itself.

## 0.8.0

*Never published - `0.9.0` is the first release carrying these changes.*

### Changed (breaking)

- **`ConnectAppShell` renamed to `AppShell`.** No other change - same props,
  same behaviour. Every other component in `/client` keeps its `Connect`
  prefix; this is the one exception, by request. Update the import:

  ```diff
  - import &#123; ConnectAppShell, type ConnectAppShellProps &#125; from "@cosmoops/connect-sdk/client";
  + import &#123; AppShell, type AppShellProps &#125; from "@cosmoops/connect-sdk/client";
  ```

  If your own layout component is also called `AppShell` (a common name for
  one), alias the import: `import &#123; AppShell as ConnectAppShell &#125; from "..."`.

## 0.6.0

### Added

- **`ConnectAppShell`** - the application frame every service platform was
  otherwise writing by hand: a collapsible sidebar, a top bar carrying the app
  launcher and the account menu, a mobile drawer below `md`, and your pages in
  the middle. Each platform passes `name`, `icon` and `nav`; the lockup, the
  rail geometry, the active-route treatment and the chrome come from one
  place, which is what keeps a family of platforms looking like one product.

  It mounts `ConnectAccessGate` around `children` by default. That is a
  deliberate default rather than a convenience: the shell is exactly the "wrap
  your app content once" place that gate asks for, and a platform that ships a
  shell and forgets the gate is a platform where a suspended workspace keeps
  working. The chrome is never gated, and routes that *resolve* a block -
  your pricing page - are flagged `gateExempt: true` on the nav item itself,
  so the exemption lives next to the route rather than in a second list that
  has to be kept in step.

- **`ConnectSideNav`** - the sidebar's nav column on its own, for an app that
  already has chrome it likes. Sections, one level of children, and the same
  active-route resolution the shell uses.

- **The nav model as data**: `ConnectNav` / `ConnectNavItem` /
  `ConnectNavSection` types plus `normalizeNav`, `flattenNav`, `normalizePath`,
  `resolveActivePath`, `isNavItemActive` and `gateExemptPaths`. Pure functions
  over plain data, exported because a breadcrumb or a page title derived from
  your own nav should not be able to disagree with what the rail highlights.

- **`useActivePath`** - the current path, from your router when you pass one
  and from the browser when you don't.

  Routing is a prop throughout (`renderLink`), because this package ships into
  any React app and cannot import `next/link`. The default is a plain `<a>`.
  Uncontrolled, `useActivePath` patches `history.pushState`/`replaceState` to
  dispatch an event - neither fires one, so a client-side router moves the
  address bar and tells nothing outside itself, and a sidebar reading
  `location.pathname` would highlight the route the page first loaded on
  forever. Pass `activePath` (Next: `usePathname()`) and none of that runs.

## 0.5.0

### Breaking

- **`react` and `react-dom` peers raised to `>=19`** (from `>=18`). Both stay
  `optional` - a `/server`-only consumer still needs neither - but `/next`'s
  `getConnectSession()` now imports `react`'s `cache()` unconditionally,
  which React 18 doesn't export. Every current App Router project is already
  on 19; this only bites a project still pinned to 18.

### Added

- **`getConnectSession()` is now memoized per request.** The plain,
  zero-argument call - the one every page, layout and Server Action actually
  uses - is wrapped in React's `cache()`, so calling it from a layout *and*
  the page beneath it costs one round trip to Connect, not two. Passing an
  explicit `request`, `client` or `session` still bypasses the cache and
  always makes a live call, as before.
- **`requireConnectSession()`** - `getConnectSession()` plus the
  `if (!session) redirect(...)` every integration was writing by hand.
  Redirects to `/api/connect/signin` (or `signInPath`) when signed out,
  optionally appending `next`. See "Authentication & authorization" in the
  docs for where role/entitlement checks still belong - this only covers the
  "is anyone signed in" half.

## 0.4.0

### Breaking

- **`ConnectUser.photoUrl` is a new required field.** Additive for anything
  *reading* a `ConnectUser`, breaking for anything *constructing* one - a test
  fixture or a hand-rolled mock will stop type-checking until it sets
  `photoUrl: string | null`. Nothing else about the type changed.

  Connect has always stored a profile picture; it was simply never carried on
  the wire, so `<ConnectUserMenu>` showed initials for every account no matter
  what the member had set. It now flows through `/sso/token` and `/sso/user`.

### Added

- **`<ConnectAccessGate>` and `useConnectAccessGate()`** - one gate composing
  the four states a partner actually needs, in priority order: workspace
  suspended, no subscription, onboarding approval pending, and a dismissible
  payment-due banner during a dunning retry. Replaces hand-assembling
  `ConnectReadinessGate` + `ConnectSubscriptionGate` and deciding the ordering
  yourself.

  Mount it once around your app's content, but **carve out your own plans and
  pricing routes** - its "Choose a plan" state is where blocked customers are
  sent, so gating that page too traps them. See the component's doc comment.

- **`<ConnectProvider silentSignIn>`** - OIDC silent authentication. A visitor
  already signed into Connect who has authorised your app before is signed in
  with no click and no screen. Off by default: it costs one full-page redirect
  on the first load of a signed-out visit, and is attempted at most once per
  tab.

  First contact still shows Connect's consent screen once - that grant is
  exactly what makes every later visit silent.

- `ReadinessBlockerCode` gains `organization_suspended` and
  `payment_retry_pending`; `ReadinessBlocker` gains an optional `graceEndsAt`
  carrying the dunning-suspension deadline, so a payment-due warning can show a
  real countdown instead of a bare "something failed".

- `identity.authorizeUrl(&#123; prompt &#125;)` accepts `"none"` alongside `"create"`.

### Fixed

- `ConnectUserMenu` renders the member's actual profile picture, falling back
  to initials while it loads or if they have none.
- Panels inside the account modal shared one elevation and one icon-medallion
  spec; the destination tiles and session list previously used a fill that is
  the *same lightness* as the modal itself in both light and dark, so they read
  as hairline outlines beside a solid identity card.
- `theme.css` now defines `--default` and `--surface-tertiary`. Without them,
  every `secondary`/`tertiary` Button and default Chip fell back to
  `@heroui/styles`' stock cool-grey ramp while the rest of the palette sat on a
  warm one - close enough to read as a mistake rather than a choice.