@asksable/site-connector
v0.8.3
Published
Thin first-party package for connecting separate website repositories to Protocol through `siteSlug`.
Downloads
941
Readme
Protocol site connector
Thin first-party package for connecting separate website repositories to
Protocol through siteSlug.
The npm package and several exported Sable* symbols retain their legacy names
for compatibility. New customer-facing copy, environment variables, and
documentation use Protocol; do not rename existing imports until a dual-package
release is published.
Purpose
Use this package in public website repos that should:
- read business/site profile data from Protocol
- submit contact forms into Protocol
- render the shared booking widget against Protocol public booking APIs
- build a custom website-chat experience with optional SMS continuation
- send cookieless first-party web analytics into Protocol
Setup
Wrap the site in SableSiteProvider:
import { SableSiteProvider } from '@asksable/site-connector'
;<SableSiteProvider
config={{
apiUrl: import.meta.env.VITE_PROTOCOL_PUBLIC_API_URL,
siteSlug: import.meta.env.VITE_PROTOCOL_SITE_SLUG,
timezone: 'America/Chicago',
}}
>
<App />
</SableSiteProvider>timezone is optional, but recommended for mobile/local-service sites. The
workspace timezone returned by Protocol wins when configured; the host value is the
fallback before the widget considers browser or staff defaults.
Website analytics
SableSiteProvider sends a first-party pageview beacon to Protocol on every route
change. The customer-facing Website tab reads from Protocol's Convex rollups, so it
does not require PostHog query credentials.
The public site profile provides the analytics ingestion URL centrally. In
production this routes pageviews through Protocol's Cloudflare Worker first, then
forwards the request to Convex with edge country metadata attached. apiUrl
can continue to point at the Convex public API used for site profiles, contact
forms, and booking.
Website analytics is cookieless by default. The first-party pageview beacon does
not write an analytics visitor ID to cookies, localStorage, or
sessionStorage; Protocol derives a daily visitor key server-side from request
metadata, the site slug, the date, and a salt. The request handler uses IP,
user-agent, and accept-language only transiently for hashing and country
derivation, then discards them. Stored analytics rows contain aggregate counts
and short-lived anonymous session state, not raw IP addresses, full user-agents,
or persistent visitor IDs.
If a standalone host needs to override the server-provided config, pass:
<SableSiteProvider
config={{
apiUrl,
siteSlug,
analytics: {
captureEnabled: true,
apiUrl: 'https://app.protocolhome.com',
environment: import.meta.env.MODE,
},
}}
>
<App />
</SableSiteProvider>The connector sends each pageview with a generated event id. Protocol stores the event in a short-lived first-party ledger, dedupes retries, and processes it into Convex rollups for visitors, pageviews, sources, pages, devices, countries, bounce rate, and average visit duration.
SableSiteProvider also listens for clicks on tel: links and records a
sable_phone_link_clicked event with the clicked number, page, source, and
device. No extra host-site handler is required. If a matching inbound call
arrives within ten minutes, Protocol snapshots website-phone-click attribution
on the call with probable confidence. Dedicated campaign tracking numbers
take precedence and produce tracking-number attribution with exact
confidence. Both forms are available from the scoped /api/v1/calls resource.
Lead attribution
The connector also captures first-touch campaign metadata for lead attribution.
When a visitor lands with utm_* tags or common ad click ids (fbclid,
gclid, gbraid, wbraid, msclkid, ttclid), the package keeps the
campaign/click metadata for 30 days and automatically attaches it to
submitPublicContact and public booking submissions. It does not create or
store a visitor ID for this.
Custom form handlers can call getSableAttribution() and include the returned
object as attribution in a /public/contact payload.
Then consume the profile or render the shared booking widget:
import {
BookingWidgetPanel,
useSableSiteProfile,
} from '@asksable/site-connector'Preselecting From A Host Estimator
When the host page already knows what the customer is booking, pass an
initialSelection. The widget resolves serviceSlug after booking setup loads,
shows the selected service card, keeps the Change affordance available, and
sends intakeResponses with the final booking.
<BookingWidgetPanel
initialSelection={{
serviceSlug: 'interior-detailing',
customerNotes: 'Estimate shown: $199',
quotedTotalCents: 19900,
quotedTotalLabel: '$199',
intakeResponses: {
vehicle_size: 'large',
pet_hair: 'minimal',
estimate_cents: 19900,
},
}}
/>Flexible Arrival Windows
For mobile services where the owner optimizes the route after customer intake, enable flexible scheduling. Exact slots remain available, but the customer can request a date plus an arrival window without taking a hard hold.
<BookingWidgetPanel
allowFlexibleScheduling
defaultSchedulingPreference="flexible"
/>Website chat and SMS continuation
Website chat is available as a headless client so each website can own its design. The only browser configuration is the Protocol public API URL and the connected site's slug:
VITE_PROTOCOL_PUBLIC_API_URL=https://savory-okapi-110.convex.site
VITE_PROTOCOL_SITE_SLUG=your-connected-site-slugThese values identify the public connector and are safe to ship in the browser; they are not Twilio or OpenAI credentials. The website's exact production or preview origin must also be registered on its Protocol site connection.
Headless client
import {
createProtocolWebsiteChatClient,
type WebsiteChatMessage,
} from '@asksable/site-connector/website-chat-client'
const chat = createProtocolWebsiteChatClient({
apiUrl: import.meta.env.VITE_PROTOCOL_PUBLIC_API_URL,
siteSlug: import.meta.env.VITE_PROTOCOL_SITE_SLUG,
locale: 'en',
})
const profile = await chat.getSiteProfile()
if (profile.chat?.enabled) {
const session = await chat.startSession()
const messages: WebsiteChatMessage[] = [
{ role: 'user', content: 'Do you service my area?' },
]
const answer = await chat.reply({ token: session.token, messages })
messages.push({ role: 'assistant', content: answer.message })
}The headless API exposes:
getSiteProfile()— determine whether chat and SMS handoff are available.startSession()— create an origin-bound, short-lived browser session.reply({ token, messages, locale? })— send up to the most recent 16 chat messages; each message is limited to 1,200 characters.requestSmsHandoff({ token, name, phone, smsConsent, challengeToken, page? })— request one consented introductory text.getSmsStatus(token)— pollqueued,sent,delivered,failed,replied, oropted-outstatus.
Keep the session token and bounded message history in sessionStorage at most;
do not log or persist the transcript. Before calling requestSmsHandoff, render
Cloudflare Turnstile with session.turnstileSiteKey and
PROTOCOL_WEBSITE_CHAT_TURNSTILE_ACTION, then pass the returned token as
challengeToken. The consent checkbox must start unchecked and display
session.smsConsentDisclosure exactly, with the returned privacy and terms
links. A new challenge token is required after a failed request.
Optional packaged React widget
If the website does not need custom presentation, mount WebsiteChatWidget
anywhere inside SableSiteProvider. The component loads only when Protocol
reports that chat is enabled for the connected site.
import { WebsiteChatWidget } from '@asksable/site-connector'
import '@asksable/site-connector/styles.css'
function App() {
return (
<>
<Routes />
<WebsiteChatWidget />
</>
)
}The assistant answers from the workspace's public business profile and active
service names/descriptions. It does not receive private workspace records,
prices, availability, or browser credentials. Chat messages remain bounded in
the current tab's sessionStorage and are not copied into Protocol or into the
SMS handoff.
Continue by text appears only when the workspace has a connected, carrier-approved Protocol number. The visitor must enter their name and US or Canadian mobile number, then explicitly check the full SMS disclosure. Protocol stores the exact disclosure as consent evidence and sends one introductory text. The visitor must reply before the owner can continue the conversation. STOP is honored at the conversation level even when the visitor is not yet a saved client. Privacy and terms links are shown when configured in the workspace's Company settings.
Server setup is intentionally separate from browser config:
OPENAI_API_KEY=server-only
PROTOCOL_WEBSITE_CHAT_RUNTIME_ENABLED=true
PROTOCOL_WEBSITE_CHAT_RATE_SALT=random-server-only-value
PROTOCOL_WEBSITE_CHAT_TURNSTILE_SITE_KEY=public-widget-key
PROTOCOL_WEBSITE_CHAT_TURNSTILE_SECRET_KEY=server-only
TWILIO_ACCOUNT_SID=server-only
# Use either an API key pair or the auth token fallback.
TWILIO_API_KEY_SID=server-only
TWILIO_API_KEY_SECRET=server-only
TWILIO_AUTH_TOKEN=server-onlyNever expose those variables through VITE_*, NEXT_PUBLIC_*, or equivalent
browser-prefixed settings. Production chat fails closed unless the runtime flag
is explicitly true and a private rate salt is configured. SMS continuation also
fails closed without server-validated Cloudflare Turnstile, HTTPS privacy and
terms links, and a carrier-approved Protocol line. Requests are bound to the
exact production/preview origin saved on the connected workspaceSites row
and protected by lower site, visitor, global, daily, and phone circuit breakers.
Chat text is sent to OpenAI to generate each answer with Responses API storage
disabled (store: false); Protocol does not persist the transcript. Before
production enablement, verify the OpenAI organization data-control policy for
the deployment account and ensure the site's privacy policy covers this
processing.
Copy/paste prompt for a website agent
Give the agent the two public configuration values above, then paste this prompt:
Install or upgrade @asksable/site-connector to ^0.6.40. Import from
@asksable/site-connector/website-chat-client and add a custom website chat
experience using createProtocolWebsiteChatClient; do not use the packaged
WebsiteChatWidget unless I ask for it. Preserve this site's existing component
library, spacing, typography, colors, responsive behavior, and accessibility
patterns—the package owns the API flow, while this repo owns the design.
Configure the client with the provided public apiUrl and siteSlug. They are
browser-safe identifiers, not secrets. Never add Twilio, OpenAI, Turnstile
secret, or Protocol server credentials to this repo or to public environment
variables.
Call getSiteProfile() and only show chat when profile.chat.enabled is true.
Start a session lazily when the visitor opens chat. Keep the token and at most
16 user/assistant messages in sessionStorage only; never persist, log, or send
the transcript during SMS handoff. Label the experience as automated and keep
the site's normal phone/contact fallback visible. Handle expired and unavailable
sessions without making promises about prices, availability, or response time.
For Continue by text, only offer it when the session says SMS handoff is
available. Collect name and a US/Canadian mobile number. Render the exact
session.smsConsentDisclosure beside an unchecked checkbox and show the returned
privacyPolicyUrl and termsAndConditionsUrl. Render Cloudflare Turnstile with
session.turnstileSiteKey and PROTOCOL_WEBSITE_CHAT_TURNSTILE_ACTION, pass its
token as challengeToken, and reset it after an error. Call requestSmsHandoff,
then poll getSmsStatus with a bounded retry loop. The owner must not continue
the SMS conversation until the visitor replies.
Run this repo's formatter, typecheck, tests, and production build. Report the
files changed, public env names added, and any remaining origin/site setup.Website Posts
Posts the business publishes to its website from the Protocol dashboard are served live from the public API — mount the two blog components once and every future post appears on the site automatically, with no redeploys and no copy/paste.
Mount an index route at /blog and a detail route at /blog/:slug
(react-router shown; any router works — the components never navigate on
their own):
import { BlogIndex, BlogPost } from '@asksable/site-connector'
import '@asksable/site-connector/styles.css'
import { Link, useParams } from 'react-router-dom'
function BlogIndexPage() {
return (
<BlogIndex
heading="Latest posts"
renderLink={(post, children) => (
<Link to={`/blog/${post.slug}`}>{children}</Link>
)}
/>
)
}
function BlogPostPage() {
const { slug } = useParams()
return <BlogPost slug={slug!} backHref="/blog" />
}Both components must render inside SableSiteProvider. BlogIndex lists
published posts newest first (hero image, date, title, excerpt); pass
getPostHref for plain <a> links or renderLink for full router control.
BlogPost renders the article, injects the post's JSON-LD structured data,
renders the FAQ section (showFaqs={false} to opt out), and by default sets
document.title and the page meta description from the post's search
metadata (updateDocumentMeta={false} to opt out). It also links the current
hostname to Google's Preferred Sources flow after the article. Pass
preferredSourceDomain={false} when the domain is not eligible or the host
already renders that action in its footer.
For a shared footer, import PreferredSourceLink directly. Its domain prop
defaults to the current hostname:
import { PreferredSourceLink } from '@asksable/site-connector'
function SiteFooter() {
return (
<footer>
<PreferredSourceLink />
</footer>
)
}For public production articles, use the publication's canonical domain (or
subdomain), never a private prtc.app approval link, preview host, or /blog
subdirectory. Confirm the site appears in Google's source preferences tool
before promoting it. The existing deeplink is a supported integration; it does
not require another SDK. Custom article templates need to include this action
themselves, either after the article or in their shared footer.
Preferred Sources personalizes visibility for readers who select the site, including badges in supported AI search features. It is not a general ranking guarantee. See Google's publisher guidance.
The article HTML is authored and sanitized by the Protocol backend for the
connected site (scripts, inline event handlers, and javascript: URLs are
stripped server-side before storage), which is why the component may render it
directly. Do not point the connector at an API you do not control.
Headless option — fetch the data and render your own markup:
const client = createSablePublicClient({ apiUrl, siteSlug })
const { posts } = await client.fetchBlogIndex()
const { post } = await client.fetchBlogPost(posts[0].slug)Server-rendered articles and HTTP errors
The reusable components accept route-loaded data. This renders the article body, links, and JSON-LD in the initial HTML and avoids a second browser request:
// Fetch in the host framework's server loader, using the site's configured client.
const { post } = await client.fetchBlogPost(slug)
const { posts } = await client.fetchBlogIndex()
// Inside SableSiteProvider. The route owns <title>, description, and canonical.
<BlogPost slug={slug} post={post} updateDocumentMeta={false} />
<BlogIndex posts={posts} />Omit post/posts to retain browser fetching. Explicit post={null} renders the
not-found view; it does not set the host's HTTP response status. Import
ProtocolPublicApiError from the package or /client to inspect error.status:
only 404 means unknown/unpublished content. Map it to the framework's real 404.
Propagate 503 and other failures as server errors; never turn an outage into an
empty sitemap, an empty article index, or a permanent not-found response.
The host must still supply canonical URLs, metadata, and a sitemap, and invalidate cached article/index/sitemap data after publication changes. A package upgrade alone cannot add missing host routes or establish search indexing.
Booking behavior and compatibility
The widget uses the compact scheduled flow when it supports the business's configuration. Service/staff choice, flexible windows, configured intake forms, async requests, approval requirements, translations, and host customization use the complete booking flow. Setup loads once; choosing a layout does not change React hook order. Preselection remains changeable when there are other options.
Confirmation only exposes actions backed by the integration. The previous
compact cancellation/reschedule screens changed browser state without changing
an appointment and have been removed. The existing mode="reschedule" contract
still requires the host's onRescheduleSubmit mutation; it cannot create a new
booking when that callback is missing.
Photos use one shared preparation path, limited to five JPEG payloads of at most 1,500,000 base64 characters each. Unreadable images block submission with a visible error. Payments still use Stripe Elements, including full/deposit and manual-authorization behavior. A rejected confirmation restores retry against the same appointment/payment intent.
Branded publishing templates
The headless option is the standard choice when a customer website needs its own article typography, card design, CTAs, image treatment, or framework-native metadata. Protocol remains the publication source of truth; the website owns the presentation.
Search-visible pages should fetch posts during SSR or static generation. A client-only loading skeleton is useful for app-like hosts but is not the preferred foundation for search engines or agents that do not execute the full browser application.
Base articles on the business's real work and useful first-hand answers. Competitor research can inform topics; it cannot establish customer results, credentials, or service areas. Follow Google's AI search guidance and verify crawlability and indexation separately from publication success.
A complete custom integration must:
- render only the published records returned for the connected
siteSlug - resolve relative Protocol media paths before rendering or using them in Open Graph metadata
- use the approved meta title, meta description, canonical URL, publication date, and JSON-LD
- safely serialize JSON-LD and escape
<before script injection - keep fixed CTAs in an allow-listed website template instead of accepting executable CTA markup from generated content
- include published article URLs in a dynamic sitemap
- return a real 404 for unknown or unpublished slugs
- show honest empty and provider-unavailable states
- verify publish and unpublish behavior against the production hostname
The reusable architecture and launch checklist live in
docs/connected-website-publishing.md.
Use that document for every new website instead of treating a successful build
as proof that publishing works.
Exports
createSablePublicClientSableSiteProvideruseSableSiteProfileuseSableSiteClientuseSableSiteConfiguseSableLocaleuseTranslationBookingWidgetPanelBookingWidgetPlaceholderBlogIndex,BlogPost,PreferredSourceLinkWebsiteChatWidgetcreateProtocolWebsiteChatClientPROTOCOL_WEBSITE_CHAT_TURNSTILE_ACTION- headless subpath:
@asksable/site-connector/website-chat-client - focused subpaths:
@asksable/site-connector/client,@asksable/site-connector/provider, and@asksable/site-connector/blogfor SSR hosts that should not load the full widget export graph getResolvedSiteProfilecreateTranslator,pickLocaleField,localeToIntl,TRANSLATIONS,DEFAULT_LOCALE- types:
SableSiteConfig,BookingInitialSelection,BlogIndexProps,BlogPostProps,PublicBlogIndex,PublicBlogIndexPost,PublicBlogPost,PublicBlogPostResult,ProtocolWebsiteChatClient,ProtocolWebsiteChatConfig,ProtocolWebsiteChatReplyInput,ProtocolWebsiteChatSmsHandoffInput,WebsiteChatMessage,WebsiteChatSession,WebsiteChatReply,WebsiteChatSmsStatus,Locale,TranslationKey,TranslationOverrides, plus public site / booking payloads
Layout-stable loading
The booking panel reserves its own height while it loads setup data. If a host site lazy-loads the widget bundle or route, render the lightweight placeholder as the Suspense fallback so the footer does not jump before the widget code arrives:
import { Suspense, lazy } from 'react'
import { BookingWidgetPlaceholder } from '@asksable/site-connector/booking-widget-placeholder'
const BookingWidgetPanel = lazy(() =>
import('@asksable/site-connector').then((module) => ({
default: module.BookingWidgetPanel,
})),
)
<Suspense fallback={<BookingWidgetPlaceholder />}>
<BookingWidgetPanel />
</Suspense>Multi-language support
The booking widget translates its own UI chrome (form labels, buttons, summaries, error messages, date/time formatting) to match the host site's language. Every Protocol customer website MUST declare its current language to the widget so the customer never sees mismatched copy (e.g. an English "Confirm Booking" button on a Spanish-language site).
Supported locales: 'en' (default), 'es'. Adding a locale requires a package version bump.
Three usage modes
| Site type | Pattern |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Single-language site (English only) | Omit language entirely or pass 'en'. Widget defaults to English. |
| Single-language site (Spanish only) | Pass language: 'es' once at provider mount. |
| Multi-language site with a toggle | Pass a reactive value that updates when the toggle changes. The provider re-renders, the widget re-renders with the new locale. |
Multi-language example
import { SableSiteProvider } from '@asksable/site-connector'
import { useLanguage } from './your-i18n-context'
function App() {
const { lang } = useLanguage() // your toggle owns this state
return (
<SableSiteProvider
config={{
apiUrl: import.meta.env.VITE_PROTOCOL_PUBLIC_API_URL,
siteSlug: import.meta.env.VITE_PROTOCOL_SITE_SLUG,
language: lang,
}}
>
<Routes />
</SableSiteProvider>
)
}The widget responds instantly to language changes. Your toggle component flips both the host site's text and the widget by sharing the same language state.
Override individual strings (rare)
When a specific client needs different brand voice (e.g. "Reserva mi entrega" instead of the canonical "Confirmar reserva"), pass translationOverrides:
<SableSiteProvider
config={{
apiUrl,
siteSlug,
language: lang,
translationOverrides: {
es: { btnConfirmBooking: 'Reserva mi entrega' },
},
}}
>
<App />
</SableSiteProvider>Use overrides sparingly. If a change would benefit all customers, extend the canonical dictionary in translations.ts and bump the package version instead.
What the widget translates
Form labels, button text, mobile step labels, helper text, success/cancelled state copy, error messages, ARIA labels, date/time formatting (via Intl.DateTimeFormat(locale)), and currency formatting.
What the widget does NOT translate
- Service names, descriptions, category names: these come from the Protocol workspace. The widget reads
nameEn/nameEs(or any${field}En/${field}Es) fields when available, falling back to the base field. If the workspace only entered one locale, that text renders regardless of UI language. (Future workstream: dashboard support for entering both locales.) - Customer-typed input: names, notes, etc.
Detecting locale in custom components
If you build something inside SableSiteProvider that needs locale awareness, use the exposed hooks:
import { useTranslation, useSableLocale } from '@asksable/site-connector'
function MyComponent() {
const { t, locale } = useTranslation()
// t('contactFullName') → "Nombre completo" when locale is 'es'
}For template builders
Every Protocol website template should include the language prop wiring as part of the boilerplate. If the template supports a toggle, the toggle component must flip both the host site's text and the widget by sharing the same language state. Never let the widget and host site drift to different locales. Pass a single reactive language value into SableSiteConfig and the widget stays in sync automatically.
Public API Contract
The package expects the public connector endpoints documented in:
Storage-free analytics and first-party funnels (0.8.2)
For a site that does not need the booking UI, import from the lightweight
@asksable/site-connector/analytics entry point. It has no React runtime import:
import { captureSableSitePageview, captureSableSiteEvent } from '@asksable/site-connector/analytics'
const context = { apiUrl: 'https://app.protocolhome.com', siteSlug: 'YOUR_CONNECTED_SITE' }
captureSableSitePageview(context) // once per committed route, production only
captureSableSiteEvent('video_play', undefined, context) // actual playback onlyThe analytics API does not read or write cookies, localStorage, or sessionStorage. It respects Global Privacy Control and Do Not Track. It sends relative page paths, current URL campaign tags, device category, referrer host, random event IDs and monotonic event times. Lead first-touch attribution is a separate API and can use storage and ad cookies; do not invoke it when a site promises storage-free analytics. Do not send visitor names, emails, message bodies, URL tokens or ad click IDs.
Supported first-party events: article_view, video_play, cta_click,
contact_opened, contact_message_sent; pageviews are recorded as page_view.
These are browser observations, not authoritative payments or qualified leads.
Use completed server responses for message observations. Never infer a sale from a
click or a payment authorization. Existing booking events remain separate.
The site's Protocol owner/admin must configure the events actually instrumented
with websiteFunnels:configure. Missing instrumentation displays as unavailable,
not zero. The production URL must match the HTTPS browser origin (www/apex aliases
are accepted). Preview traffic is excluded by the site's production gate. Record
and test each route and video lifecycle cleanup; do not capture the same route twice.
Protocol stores these observations independently of PostHog for 35 days, deduplicates by event ID, and calculates ordered 30-minute funnels by event time. Equal-time steps are conservatively unordered. Queries are bounded to 10,000 observations and report partial coverage when capped. The daily site-scoped server hash uses a private salt; raw request IP/user-agent data is not stored in this ledger. Visitors are approximate, may merge on shared networks, and cannot be followed across days, devices or sites.
