npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-slug

These 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) — poll queued, sent, delivered, failed, replied, or opted-out status.

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-only

Never 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

  • createSablePublicClient
  • SableSiteProvider
  • useSableSiteProfile
  • useSableSiteClient
  • useSableSiteConfig
  • useSableLocale
  • useTranslation
  • BookingWidgetPanel
  • BookingWidgetPlaceholder
  • BlogIndex, BlogPost, PreferredSourceLink
  • WebsiteChatWidget
  • createProtocolWebsiteChatClient
  • PROTOCOL_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/blog for SSR hosts that should not load the full widget export graph
  • getResolvedSiteProfile
  • createTranslator, 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 only

The 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.