What's new
Every release of @cosmoops/connect-sdk, newest first. Current version 0.56.0.
@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)andaccountContinueUrl({ returnTo })- the account-session endpoints. The reads answer exactlygetUser'sUserResponse, through the same mapping, and are retried like it.- In account mode (
./next):getConnectSession()and/sessionresolve theCONNECT_ACCOUNT_COOKIEcookie (defaultcosmoops_session_local) throughgetAccountUser;/signinredirects to Connect's continue URL withreturnTo= this app's origin plusnext;/logoutcallslogoutAccountand clears the cookie (Path=/, plusDomain=fromCONNECT_ACCOUNT_COOKIE_DOMAIN);/workspaces/switchgoes throughswitchAccountWorkspace;/callbackand One Tap do not exist. The app never writes the account cookie.getConnectSessionToken()returns theacs_account id, andasUsercalls pass it through unchanged. - Migration: in account mode a per-application session from before the
switch is still honoured through
getUseruntil it expires. - New exports:
connectSessionMode(),connectAccountSession(),connectSessionCookieName(clientId)(./next); typesConnectAccountSessionOptions(./server,./next) andConnectSessionOptions(./next).ConnectSessionStoregains an optionalmigrate(), andcreateConnectHandleran optionalaccount.
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 relayedTelephonyCarrierEvent, or throwsConnectError(invalid_signature,malformed_event). It accepts the header when anyv1=matches, because Connect signs with every active endpoint secret the application holds.connect.telephony-dial(input),hangup(providerCallUuid, { workspaceId }),browserToken(input)andarchiveRecording(recordingId, input), behind the newtelephony:callscope.hangupandarchiveRecordingare retried on a transient failure;dialnever is.connect.channels.release(channelId, { workspaceId })- the inverse ofclaim, underchannels:claim.ConnectChannel.handlerApplicationId- the application that answers the number itself, ornullwhen 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'sfileId). Uses theai:generatescope 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 justhref,classNameandchildren- therefand 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/linkincluded; anisDisabledone now refuses the click and carriesaria-disabled. Spread the props (renderLink={(props) => <Link {...props} />}) 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/{id} 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({ workspaceId, to, subject, html, text, replyTo?, fromName?, cc?, headers? })
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({ workspaceId, phone, history? }), .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.
consumetakes anidempotencyKey. A replay returnsduplicate: 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. refundmust name an earlierconsumeand 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({ workspaceId, phone, facts, source, callId?, countCall? })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 raisescontact.created/contact.updated. Scope:contacts:write, server-to-server only.scheduling.holdSlot(...)andscheduling.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.createBookingalso acceptssource(api|agent-call) andsourceCallId. Scope:calendar:write.ai.generate(...)is raw model generation, separate fromai.chat(Connect's own assistant). One model step per request; any tools you declare are returned to you, never run by Connect. It answers503until Connect enables it for the platform. Scope:ai:generate.workspaces.get({ workspaceId })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({ workspaceId, limit? })returns the files your application uploaded to that workspace's Drive, newest first.limitdefaults to 50 and is capped at 100;truncated: truemeans more exist. Needsdrive:read.connect.drive.delete(fileId, { workspaceId })removes one of them and gives the space back to the workspace's quota. Needsdrive: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 { workspaceId } in
place of { asUser }, 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, { records: 1840 }) 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({ ..., seats: 8 }) (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({ workspaceId, bookingId, attended })
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/{bookingId}/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({ asUser }) reads the workspace's inbound call
routing flow and its published agents; connect.calls.setRouting({ routing, asUser }) 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({ workspaceId, queue, payload, name?, scheduleAt? })
puts a task on one of your application's Cloud Tasks queues, declared in
Console (scope tasks:write). Cloud Tasks delivers { workspaceId, queue, payload } to the queue's URL with a Google-signed OIDC token;
createConnectTaskHandler({ handle }) - 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({ asUser }) 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({ phone, purpose })
and connect.calls.startBrowserCall({ phone, asUser, purpose }) 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, { toolkitSlug, method, endpoint, query, body })
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.
{ extension: "in" } 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(defaulttrue). Passtransition={false}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({ workspaceId }) returns { usedBytes, limitBytes }
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-foregroundtokens, 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 arevariant="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 andtext-linknow 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.
dialledAtonConnectCall(fromcalls.get()andcalls.list()) is when Connect asked the carrier to ring the person, on amembercall. Set means a cancelled call was a missed one - file it as missed, exactly ascall.missedwould.nullmeans 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.
nullon 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({ placedByApplication: true }): 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({ asUser }) and
connect.calls.setCallRecording({ recordingEnabled, asUser }) 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 aftercall.completedfor a recorded call. It carriessummary,intent(a few words, e.g."pricing enquiry"),sentiment,language,actionItems(title,dueDateasYYYY-MM-DDornull, andowner:reporcustomer) andrecordingFileId. Subscribe to it on your webhook in Connect. Typed asCallAnalyzedEvent.calls.get(callId)returns the same reading ascall.analysis, and the transcript as the call'sturns- your user asagent, the person called asuser.analysisisnullfor a call that was not recorded.- The recording is a Drive file:
connect.drive.getFileUrl(recordingFileId, { workspaceId })with adrive:readkey 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({ workspaceId, template, to, values, consent })sends one of your application's Meta-approved templates - named by slug, resolved by Connect asapp_<yourApplicationKey>_<slug>- through Connect's WhatsApp sender. Needs thewhatsapp:sendscope.whatsapp.template(slug)answersliveand thevariablesa send must carry. Offer WhatsApp on a screen only whenliveis true.consent.collectedViarecords 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 withopted_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: truewith 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.
<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 followshandoff. 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.useConnectCheckoutno longer reportsblocked. A blocked window falls back to a same-tab trip instead of asking the customer to click a link. The value stays inConnectCheckoutStatusso code handling it still compiles.ConnectAppsGridstill 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.
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/{browser-token,start,cancel}. 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.
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:
<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({ asUser }) 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.
const token = await getConnectSessionToken();
const billing = token ? await connect.workspaces.billingProfile({ asUser: token }) : null;
// offer "Use my billing details" — then keep your own copyIt 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.
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.
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.
import { connectProviderProps } from "@cosmoops/connect-sdk/next";
import { ConnectProvider } from "@cosmoops/connect-sdk/client";
<ConnectProvider {...connectProviderProps()}>{children}</ConnectProvider>It returns { appUrl }, 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; { appUrl } 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_mismatchno longer replays the state cookie's destination. With no matching entry there is no destination that flow owns, so it falls back todefaultRedirectrather than one belonging to a different round trip. - A
prompt=noneprobe whose state no longer matches still returns quietly tonext. 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({ next })
Where Connect sends the customer once the subscription is live:
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.
"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:
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:
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.
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.
// 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",
});// 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 {title, body, url, tag}.
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.const result = await connect.enrichment.brand({ domain: "acme.com" }); if (result.status === "extracted") { const primary = result.profile.brandKit.palette.find((c) => c.role === "primary"); const body = result.profile.brandKit.typography.find((t) => t.role === "body"); }Needs an API key scoped to the new
enrichment:brand. That is separate fromenrichment:readbecause 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
undiciand mostaxiossetups — 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.sourceUrlis the stable value and points at the company's own site — though it is theirs, so it can move.A
skippedstatus is a normal 200.no_stylesheetis the one new reason worth handling specially: the site answered and said things, but shipped no CSS this reader could measure.designSystem.frameworkis 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
ConnectAskAiloadsstreamdownon 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:chatit 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({ asUser, limit?, cursor? }),connect.ai.getConversation({ conversationId, asUser })andconnect.ai.deleteConversation({ conversationId, asUser }), plus the types they answer with:AiConversation,AiConversationList,AiConversationTurnandAiConversationDetail.asUseris required on all three, unlike onai.chatwhere 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[].partscomes back as the AI SDK's ownUIMessageparts, 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 typedunknown[]rather than restated in a schema that would start rejecting transcripts the day the AI SDK adds a part kind. -
connect.ai.chat({ conversationId }), 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 withoutasUser. -
createConnectHandleranswers three more routes —GET /ai/conversations,GET /ai/conversation?id=,DELETE /ai/conversation?id=— and relaysconversationIdonPOST /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({ workspaceId, contactAddress?, status? })andconnect.conversations.get(id, { workspaceId }), plus the four types they answer with:ConnectConversation,ConnectConversationMessage,ConversationsPageandConversationDetail.These are the workspace's threads, not your application's, which is the difference from
callsand it decides how they are gated. A call arrived on a channel you hold, socalls.listis 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 theconversations:readscope and a live subscription in that workspace, and, when the call comes through the browser bridge, the signed-in member's ownagent:read.Three things are worth reading before you render one:
contactAddressis 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.canReplyFreelyis computed against Connect's clock, and re-deriving it fromlastInboundAtagainst 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.refusedis 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.createdandcontact.updated. Register handlers for them oncreateConnectWebhookHandlerexactly as you do the existing four. -
ConnectCallDirectionandConnectContactSource, 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
contactPhoneand deliberately carries nocontactId— Connect resolves a caller by the number and at the moment a call ends it often has not, so the field would benullon most deliveries. Andbooking.rescheduledcarriespreviousBookingIdas well asbookingId, 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
Bookingfixtures in the bundled tests compile again — the type gained a requiredhostMemberIdsin 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.const booking = await connect.scheduling.getBooking({ workspaceId, bookingId, // the id you kept on your own record });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.
listBookingscannot serve it. That is a window overstartAt, and thestartAtyou 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 withrescheduledToId, so an id stored months ago leads to the appointment that exists today; follow the pointer.cancelledanswers with its reason.not_foundmeans 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 reasonrescheduleandcanceltake 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.idvaluesworkspaces.listMembersreturns, so an owner column is a lookup against a roster you already hold rather than another round trip. They were withheld as "Connect internals", whichlistBookingshad already contradicted by filtering onhostMemberId- 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
Bookingliteral - a test fixture, a mock, a storybook arg - needshostMemberIds: []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 carriesid="connect-main-content"andtabIndex={-1}so focus genuinely lands there. Without thattabIndexa 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.5remwith--field-radiusderived at 1.5x - while Connect's own app has always run at0.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 { --radius: 0.5rem; --field-radius: calc(var(--radius) * 1.5); }. -
<ConnectAppShell>'s content area is full width. It was capped atmax-w-6xland 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; passcontentClassNameto restore a frame of your own.
Fixed
classNamenow actually overrides. Every component concatenated your classes onto its defaults -`flex flex-col gap-4 ${className}`- 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 throughtailwind-merge, which removes the conflicting default:gap-8beatsgap-4, andp-8beatspx-3 py-2.
Added
-
cnis exported from/ui- the same merge the components use, for composing your own on top of them. -
classNameon<ConnectPageShell>,<ConnectCursorPagination>and<ConnectFilterTabs>, which had no way to be styled at all. Components that render onlychildren(<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 atsize="sm", which in@heroui/stylesis not a smaller chip butpx-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 explicitpx-3.5 py-1.5with amin-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 setscursor: defaultonbutton, 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.cssnow 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 returnednullfor every failure, andnullis what makesrequireConnectSession()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/userin 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.nullnow 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 asconst 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.erroris still set either way.
Fixed
-
POST /sso/usernever retried a 429. The transport has honouredRetry-Afterwith exponential backoff all along, but only for requests it considered idempotent - and that test keyed on the verb, so it excluded everyPOST./sso/useris aPOSTbecause 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.RequestSpecgainsidempotentfor reads shaped this way.POST /sso/tokendeliberately 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 patcheshistory.pushState/replaceStateso a shell with noactivePathcan see a client-side navigation, and it dispatched itsconnect:locationchangeevent synchronously from inside the patched method. Next's App Router commits its history entry from auseInsertionEffect(HistoryUpdater), so the listener - and the re-renderuseSyncExternalStoreforces 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.pathnamealready 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 passesactivePathnever ran any of this and was never affected.
0.24.1
Fixed
-
<ConnectConfirmDialog />never opened. Its trigger was wrapped inModal.Trigger, which renders adiv role="button"inside React Aria'sPressableand depends on a plainonClickbubbling up to it. A React Aria button nested inside - which every real trigger is, including HeroUI's<Button>- callse.stopPropagation()in its ownusePressclick 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
Modalroot, which is React Aria'sDialogTrigger. ItsPressResponderpicks 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'suseButton. 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'sPOST /integrations/connectrequires auserId- it stores it as the connection'sconnectedByUserId- and the SDK sentworkspaceId,toolkitSlugandreturnUrland nothing else. Every call 400'd, including the onecreateConnectHandler's ownPOST /integrations/connectroute makes, so<ConnectIntegrationsModal />'s Connect button was broken too: the browser reached the bridge, the bridge reached Connect, and Connect refused withInvalid 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.tsfails to compile if the route grows a fourth required field.
Changed
-
initiateConnecttakes an input object -initiateConnect(workspaceId, { toolkitSlug, returnUrl, userId }), replacing the three positional strings. Breaking in name only: the old signature had no working call to break. The object shape matchesexecute()on the same resource, and it is what keepsreturnUrlanduserIdfrom being silently transposable.userIdis 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 auserIdin the request body is ignored, the same way a craftedworkspaceIdalways was. -
InitiateIntegrationConnectionInputis exported from@cosmoops/connect-sdk/server, besideExecuteIntegrationActionInput.
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.
nullwhen the operator has not published one, in which case a terms page should omit the venue rather than substitute anything. -
CompanyProfile.brandNameis 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-profilecan now answer503 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 rendersnullon failure, anduseConnectCompanyProfile()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.const result = await connect.enrichment.company({ email: "priya@acme.test" }); if (result.status === "enriched") { form.setValues(result.profile); // then let the person confirm it }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
citationsnaming 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 backnull. 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(agmail.comaddress names a person, not an employer) andblocked_domainwill never succeed on a retry;unreachableandno_signalmay.confidenceis derived from how many fields the pages actually supported, not from the model's opinion of itself, solowmeans "offer this" andhighmeans "safe to pre-fill".Needs an API key scoped to the new
enrichment:read. That is separate fromai:extractbecause 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 takeasUser: the workspace and the actor come from the caller's session token, never from a parameter.const { capacity, viewer } = await connect.workspaces.access({ asUser: token }); if (viewer.canInvite && capacity.canAdd) { await connect.workspaces.invite({ inviteeEmail: "priya@acme.test", roleKey: "rep" }, { asUser: token }); } await connect.workspaces.setAccess({ userId, roleKey: null }, { asUser: token }); // revokeYou can only ever move your own application's grant. The wire carries a
roleKeyfrom 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:managescope, and both are about the person rather than your application: the signed-in caller must hold Connect's ownmember:invite/member:updateon the workspace, and they cannot grant access they do not hold themselves.vieweron theaccess()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.includedis a baseline plus every active subscription's member allowance folded across applications, andusedcounts pending invitations as well as members, because an invitation is a seat already spoken for.reasonseparates "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 insideinvite(), 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-profileis 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.useConnectCompanyProfilegoes throughuseConnectResource, which waits forsession.userbefore 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.useConnectResourcenow takes{ requiresSession: false }, 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.
resolveActionneeds 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:SubscriptionResponsecarriesplan: nullbeside a livesubscriptionwhenever 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/{id}/change-plan/{planId}, 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 ofresolveAction, 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 asbuildCheckoutUrl'ssubscriptionId.
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.<ConnectProvider> <App /> <ConnectOneTap /> </ConnectProvider>That is the whole integration. It needs no
clientIdin the browser and no configuration: the bridge already knows the client, and now tells the page where to ask on the/sessionread it was making anyway, so discovering One Tap costs no extra request.What it replaces.
silentSignInanswers the same question - is this visitor already signed into Connect? - by bouncing the browser to Connect and back withprompt=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 singlefetch, names the account before anything is signed, and completes with no navigation at all.silentSignInstill 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
silentSignInnever was.Requires your application to be same-site with Connect - a
cosmoops.comsubdomain.SameSiteis judged on the registrable domain, so the visitor's Connect cookie rides along fromcrm.cosmoops.comand from nowhere else. Deployed off-site, the prompt simply never appears andConnectLoginButtonbehaves 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()andoneTapSignIn()onuseConnect()- 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_requiredandinteraction_requiredmean "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, ornullwhen this deployment cannot run One Tap (noclientId, or a session store with nowrite). 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/startmints the pair and keeps the verifier in anHttpOnly,SameSite=Laxcookie on your own origin, and/one-tap/completeis 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.channelsandconnect.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.const { id } = await connect.channels.claim({ workspaceId }); await connect.channels.setRouting(id, { agent: "sales", afterHours: "voicemail" }); const { items } = await connect.calls.list({ channelId: id }); const { call, turns } = await connect.calls.get(items[0].id);You never pick the number. Inventory is a regulated asset bought under Connect's carrier account, so
claimtakes 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. A409means either "this workspace already has one" (fix the call) or "the pool is empty right now" (retry - Connect is notified).setRouting'sdestinationis 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, andclearRoutingis the only way to say "stop answering" - overwriting a destination can only point the number somewhere else. Clearing is idempotent and, unlikesetRouting, is allowed on a suspended channel: making a live number stop answering must never be the operation that is unavailable.calls.listfilters onchannelIdand nothing else - each further filter costs a Firestore composite index - and pages by cursor: page untilhasMoreisfalserather than stopping on an emptyitems.calls.getadds the transcript, ordered bysequence, which is the sort to trust since two turns can share a millisecond.recordingFileIdandtranscriptFileIdare Drive file ids readable with adrive:readkey, and arenulluntil the carrier posts the audio - branch onrecordingStatusrather than readingnullas a failure.Scopes:
channels:read,channels:claim,channels:manage,calls:read. A channel or call belonging to another application answersnot_found, notforbidden- 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-reacticon 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/applicationson Connect, a route that has never existed, so the one discovery path the launcher offered was a 404. Discovery belongs in the grid. -
GET /appsanswers with every active application, not only the subscribed ones.WorkspaceAppgains two fields:interface WorkspaceApp { subscribed: boolean; // launchable now, vs. an invitation to subscribe category: WorkspaceAppCategory; // …unchanged }needsAttentionis alwaysfalsewhensubscribedisfalse, and an application whose subscription was cancelled now appears in the explore half rather than disappearing. Applications this workspace'stypeis 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 onsubscribedto 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.const contact = await connect.contacts.lookupByPhone({ workspaceId, phone }); if (contact) { // A caller this workspace has met before. }lookupByPhoneis one point read, cheap enough for a screen pop on every inbound call, and resolves tonullfor a first-time caller - the ordinary case, not an error.get(contactId, { workspaceId })reads the record behind an id you already hold. Needs a key scoped tocontacts:readand 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.sourcetells you which kind you are holding (callmeans an agent guessed and nobody has confirmed), andphones[].verifiedtells 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
idon 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 makesBooking.contactIdan 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, andContactsResource/LookupContactInputfrom@cosmoops/connect-sdk/server.
0.18.0
Added
-
connect.scheduling.rescheduleBooking()- move an appointment without cancelling and rebooking it.const { booking, previous } = await connect.scheduling.rescheduleBooking({ workspaceId, bookingId, startAt: newSlot.startAt, });The original is retired, not edited: it stays on the workspace's diary as
rescheduledwithrescheduledToIdpointing 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
conflictleaves the customer holding the appointment they already had. -
excludeBookingIdonfindSlots()- 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 whenstatusisrescheduled. -
<ConnectBookingCalendar>- the workspace's week, in your app.import { ConnectBookingCalendar } 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- passcanBook={false}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.timeZoneis 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. -
layoutBookingsand friends frombooking-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. -
asUseron everyscheduling.*method, and the six scheduling routes it exists for oncreateConnectHandler's bridge:GET /scheduling/{event-types,slots,bookings}andPOST /scheduling/bookings{,/reschedule,/cancel}.With
asUser, Connect derives the workspace from that member's session and ignores theworkspaceIdyou passed, then checks their owncalendar: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
asUsernothing 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.const eventTypes = await connect.scheduling.listEventTypes(workspaceId); const { slots, timezone } = await connect.scheduling.findSlots({ workspaceId, eventTypeId: eventTypes[0].id, from: new Date(), to: nextWeek, }); const booking = await connect.scheduling.createBooking({ workspaceId, eventTypeId: eventTypes[0].id, startAt: slots[0].startAt, attendee: { name: "Priya", phone: "+919000000001" }, });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 nextfindSlots. 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(andcalendar:writeto 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 answers403with the same message as an unknown workspace id.Two things to hold onto when integrating:
findSlotsis 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.createBookingre-checks inside a transaction and throws aConnectErrorwithcode: "conflict"rather than double-booking. Handle that as an ordinary outcome and offer the next time; it is not an error condition.timezoneis 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 theSchedulingResourceinput types from@cosmoops/connect-sdk/server. -
ConnectError.codecan now be"conflict"- today only fromcreateBooking. Nothing that switched on the previous codes stops compiling:codeisstring, 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
-
McpToolResultis a type alias, not an interface — socallTool()can be returned straight fromserver.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.import { createStoreToolset } from "@cosmoops/connect-sdk/mcp"; const toolset = createStoreToolset({ 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 }); server.setRequestHandler(ListToolsRequestSchema, () => toolset.listTools()); server.setRequestHandler(CallToolRequestSchema, (request) => toolset.callTool(request.params.name, request.params.arguments ?? {}), );Application.storeSchemaalready 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". AnappendOnlycollection 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/writePermissionagainst 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 fromgetConnectSession(), or pass a function forasUser.There is deliberately no scan tool.
data.scanreads across every workspace and for that reason takes noasUser- 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
/serverand/next: a browser import resolves to the guard stub and fails the build. No new dependency -listTools()andcallTool()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,describeQueryRejectionandstoreIndexCoverson the root entry. The declaration type is the shape Connect stores onApplication.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.const { values } = await connect.data.aggregate({ workspaceId, collection: "deals", filters: [{ field: "outcome", op: "==", value: "won" }], aggregations: [ { op: "count", alias: "count" }, { op: "sum", field: "value", alias: "total" }, ], });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
sumoravgover nothing answersnull, not0- "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.
import { increment, unchangedSince } from "@cosmoops/connect-sdk"; await connect.data.commit({ workspaceId, operations: [ { op: "create", collection: "ledger", id, data: entry }, { op: "merge", collection: "funds", id: entry.fundId, data: { balancePaise: increment(entry.amountPaise) }, }, ], });A stale precondition throws
ConnectErrorwith status 409 and writes nothing. -
data.transaction(build)- the read/decide/commit loop with the retry written once.buildre-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 theunchangedSince(document)anddocumentExists()precondition builders. All exported from the root entry, so a Server Component and a client component build the same write the same way.incrementis 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.
-
_updateTimeon every document that came from a read - Firestore's own write timestamp, and the value a{ updateTime }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({ filter })is nowdata.query({ filters }), a list, ANDed.data.scantakes the same. One filter becomes a one-element array:// Before await connect.data.query({ workspaceId, collection: "tickets", filter: f }); // After await connect.data.query({ workspaceId, collection: "tickets", filters: [f] });The server still accepts the singular
filterquery 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.billingIntervalandSubscribedPlan.billingIntervalare nowBillingInterval, 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,ConnectSubscriptionand the subscribe dialog now label it "once".Exhaustive
switches on a billing interval will need a third arm.
Added - operators
not-injoins 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
-
applicationRoleandplatformAccessare nowrole,permissionsandstaff, three flat fields onexchangeCode()andgetUser()(and on the/sessionbridge 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: nulland a staff session always carriedapplicationRole: { role: null, granted: [] }. 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.// Before const { applicationRole, platformAccess } = await connect.identity.getUser(t); if (platformAccess) { if (!platformAccess.roles.includes("platform_admin")) return forbidden(); return renderOperatorConsole(); } if (!applicationRole.granted.includes("invoice:write")) return forbidden(); // After const { role, permissions, staff } = await connect.identity.getUser(t); if (staff) { if (role !== "platform_admin") return forbidden(); return renderOperatorConsole(); } if (!permissions.includes("invoice:write")) return forbidden();Read
stafffirst: it decides which vocabulary the other two speak - your own Permission Catalog's ids for a member, Connect'splatform:*ids for an operator. A staff session reports onerolerather 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, andpermissionsis still the union over every accepted role.ApplicationRoleandPlatformAccessare gone as exported types, replaced bySessionAccess.useConnectPlatformAccess()keeps its shape -rolesis now a one-element array on a staff session,[]otherwise - so<ConnectPlatformGate accept={[...]}>is unchanged.The signed-out
/sessionresponse carries the same three fields (role: null, permissions: [], staff: false) rather than a bareapplicationRole: 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_adminisadmin, and the same forfinance,managerandmember. This is visible to you only throughaccess.getUserRoles()andaccess.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/{id}/subscribe/{planId}. The portal route customers can actually open is/apps/...;/applicationsis a Console-only prefix. Every first-time subscriber sent from a partner's own pricing page hit a 404. The matchingresolveUrlon thesubscription_missingreadiness blocker was wrong the same way and is fixed with it. -
staffactually reaches you. Both/sso/tokenand/sso/userresolved 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 theUser-Agent, said0.12.0on 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 newGET /api/v1/application/company-profile, which needs the newcompany:readscope 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:# 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
websiteandgstNumberare 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.import { ConnectGrievanceNotice } from "@cosmoops/connect-sdk/client"; <ConnectGrievanceNotice applicationName="CosmoOps CRM" />It renders
nullwhile loading andnullon 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 ConnectSideNavConnectSidebarNavThe rail is a ConnectSidebarnow; its parts are named after itConnectUserMenuConnectUserButtonIt is a button that opens a modal. "Menu" had people looking for a dropdown that never existed AppShellkeeps its name. It stays the one export in/clientwithout theConnectprefix, exactly as 0.8.0 decided. A host whose own layout component is also calledAppShellaliases it on import -import { AppShell as ConnectAppShell }- 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 toConnectPageShell, from@cosmoops/connect-sdk/ui, which renders it next to that page's own breadcrumbs and actions:// 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'sConnectPageShell; a control that belongs to the application goes insidebarFooter, which now accepts sidebar rows (seeConnectSidebarRow).
Below
mda slim bar remains, carrying the drawer trigger and the lockup and nothing else. A phone has no rail to hold that trigger. -
-
defaultCollapsednow defaults totrue. 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. PassdefaultCollapsed={false}to keep 0.11's behaviour. -
ConnectUserMenu'scompactprop is superseded byConnectUserButton'svariant. The alias mapscompacttovariant="compact"and the old default tovariant="inline", so existing call sites are unchanged. -
ConnectAppsGridnow renders as a sidebar row by default (variant="row"). Passvariant="icon"for the square accent button it used to be.
Added
-
secondaryNavonAppShell- a secondConnectNav, 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, samegateExempthandling asnav. -
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 byisOpen/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, plusConnectShellProvider/useConnectShell/sidebarRowClassand theRAIL_COLLAPSED/RAIL_EXPANDEDconstants.AppShellcomposes exactly these, so an application whose chrome the prop API cannot express rebuilds the same rail from the same parts rather than forking it. -
ConnectLogotakesvariant-"full"|"mark"|"wordmark".collapsedis deprecated in favour ofvariant="mark".
Fixed
-
The collapsed Ask AI panel occupied 400px at
lgand up. Its shell carriedlg:w-[400px]andlg:w-0at 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 withaskAion. Only thetranslate-xhid it belowlg, 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--mutednow (4.8:1 / 6.2:1, past AA), with--foregroundon hover. The active row wastext-accentonbg-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--accentkeeps the contrast. -
The account row and the app launcher looked permanently selected. Both are HeroUI
<Button variant="tertiary">because they trigger overlays, and.button--tertiarysets 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
.avatarisrounded-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,workspaceNameandworkspaceTypeare now nullable. They arenullfor 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.workspaceIdwhere astringis 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_idclaim 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, andapplicationRole.granted: []. Platform authority is deliberately not reported through the member-role field. What staff hold is the new block:const { platformAccess } = await client.identity.getUser(sessionToken); if (!platformAccess) return forbidden(); if (!platformAccess.roles.includes("platform_admin")) return forbidden();rolesis 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
platformAccessis alwaysnulland nothing below does anything. -
platformAccessonexchangeCode()andgetUser()-PlatformAccess | null. Live ongetUser(), likesubscriptionandapplicationRole: a platform role revoked, or staff access turned off for your application, comes backnullthere and Connect has already revoked the session. -
<ConnectPlatformGate>anduseConnectPlatformAccess()in/client. The gate renders its children only for a staff session, and takes an optionalrolesallowlist 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
platformAccessserver-side on every route it reveals. -
ConnectNavItem.audience-"staff"or"member".AppShellfilters 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.filterNavForAudienceis exported for a rail you render yourself. -
ConnectAccessGatepasses a staff session straight through. Every state it reports is a fact about a workspace, andsubscription_missingwould 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.Headerand.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
h2so 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/uihas a hook or state, so it renders in a Server Component and stays out of the browser bundle.import { ConnectPageShell, ConnectSection } from "@cosmoops/connect-sdk/ui"; -
ConnectConfirmDialog,ConnectCursorPagination,ConnectFilterTabsin@cosmoops/connect-sdk/client- the interactive counterparts, which hold state and so belong on the browser side.ConnectCursorPaginationhas 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.workspaceTypeonexchangeCode()andgetUser()-"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).
-
audienceon the same two responses - what your application is sold to, joined with the account in front of you:const { user, audience } = await connect.identity.exchangeCode({ code, redirectUri }); // user.workspaceType -> "individual" // audience.application -> "b2b" // audience.matches -> falsematches: falseis a hard block, not a hint: readiness reports anaudience_mismatchblocker and any attempt to subscribe is refused. It ships alongsideworkspaceTypeso the check stays!audience.matchesinstead of a hardcoded audience constant in your own code that goes stale the moment the catalogue entry changes.audience.enforcedAtSignIntells 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>, plususeConnectWorkspaceType()anduseConnectAudience():<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>, andaskAionAppShell- a chat panel grounded in the signed-in workspace's Context Index. On by default, likegate:<AppShell name="Dispatch" nav={nav}> {children} </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={false}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${basePath}/ai/chat, a new bridge route oncreateConnectHandlerthat resolves the workspace from the caller's own session and streams Connect's answer straight through. A browser-suppliedworkspaceIdwould 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 showTablea sortable table with a CSV download showCharta bar / line / area chart (inline SVG), or a labelled proportion list for pieshowMetricsup to six metric tiles with period-over-period deltas showTimelinedated events on a rail showDetailsone entity's fields as a fact sheet showStepsa numbered procedure showSourcesthe 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 andformatAiValueare 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-wordblurInreveal with a caret while the answer streams. Three host steps, all one-time:npm i streamdown(a new optional peer dependency - the panel is the only thing that uses it).@source "…/node_modules/streamdown/dist/*.js";in your Tailwind CSS, so its utility classes are generated.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:
import { code } from "@streamdown/code"; const PLUGINS = { code }; // module scope: Streamdown compares this by reference <AppShell name="Dispatch" nav={nav} askAi={{ markdownPlugins: PLUGINS }}> -
streamResource()onuseConnect()- POST through the bridge and get the rawResponseback, unread and uncached. The seam<ConnectAskAi>streams over, and the one to use for any future streaming route.
Changed
ConnectSession(the value behinduseConnect()) carriesaudience,nullwhen 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/{channelId}/routing,GET /calls,GET /calls/{callId}). 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'stimeoutMs(20s by default) was handed tofetchas anAbortSignal, and afetchsignal 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 araw(streaming) request means once the headers land. Pass your ownsignalto bound the stream itself.
0.8.0
Never published - 0.9.0 is the first release carrying these changes.
Changed (breaking)
-
ConnectAppShellrenamed toAppShell. No other change - same props, same behaviour. Every other component in/clientkeeps itsConnectprefix; this is the one exception, by request. Update the import:- import { ConnectAppShell, type ConnectAppShellProps } from "@cosmoops/connect-sdk/client"; + import { AppShell, type AppShellProps } from "@cosmoops/connect-sdk/client";If your own layout component is also called
AppShell(a common name for one), alias the import:import { AppShell as ConnectAppShell } 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 belowmd, and your pages in the middle. Each platform passesname,iconandnav; 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
ConnectAccessGatearoundchildrenby 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 flaggedgateExempt: trueon 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/ConnectNavSectiontypes plusnormalizeNav,flattenNav,normalizePath,resolveActivePath,isNavItemActiveandgateExemptPaths. 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 importnext/link. The default is a plain<a>. Uncontrolled,useActivePathpatcheshistory.pushState/replaceStateto dispatch an event - neither fires one, so a client-side router moves the address bar and tells nothing outside itself, and a sidebar readinglocation.pathnamewould highlight the route the page first loaded on forever. PassactivePath(Next:usePathname()) and none of that runs.
0.5.0
Breaking
reactandreact-dompeers raised to>=19(from>=18). Both stayoptional- a/server-only consumer still needs neither - but/next'sgetConnectSession()now importsreact'scache()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'scache(), so calling it from a layout and the page beneath it costs one round trip to Connect, not two. Passing an explicitrequest,clientorsessionstill bypasses the cache and always makes a live call, as before.requireConnectSession()-getConnectSession()plus theif (!session) redirect(...)every integration was writing by hand. Redirects to/api/connect/signin(orsignInPath) when signed out, optionally appendingnext. 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.photoUrlis a new required field. Additive for anything reading aConnectUser, breaking for anything constructing one - a test fixture or a hand-rolled mock will stop type-checking until it setsphotoUrl: 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/tokenand/sso/user.
Added
-
<ConnectAccessGate>anduseConnectAccessGate()- 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-assemblingConnectReadinessGate+ConnectSubscriptionGateand 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.
-
ReadinessBlockerCodegainsorganization_suspendedandpayment_retry_pending;ReadinessBlockergains an optionalgraceEndsAtcarrying the dunning-suspension deadline, so a payment-due warning can show a real countdown instead of a bare "something failed". -
identity.authorizeUrl({ prompt })accepts"none"alongside"create".
Fixed
ConnectUserMenurenders 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.cssnow defines--defaultand--surface-tertiary. Without them, everysecondary/tertiaryButton 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.