Every other page here documents one surface. This one builds a whole
application across all of them, in order, and never skips a step: **Pulse**, a
campaign tool sold on Connect to logistics workspaces.

By the end it signs people in, sells plans, gates features, stores its own
data, meters what it consumes, receives webhooks, exposes its tools to
Connect's assistant, and is ready to go live. Each step says what you are
buying with it, so you can stop at whichever one is the last one you need —
this is a sequence, not a checklist you must complete.

<Callout title="Runnable, not illustrative">
  Every snippet is the real API.
  [`connect-example`](https://github.com/cosmoops-com/connect-example) is this
  same integration deployed, installing the SDK from npm exactly as you will.
</Callout>

## The map

| # | Step | What it gets you |
|---|---|---|
| 1 | [Register the application](#1-register-the-application) | A client id, an API key, a place in the catalogue |
| 2 | [Install and configure](#2-install-and-configure) | The SDK, two environment variables, the theme |
| 3 | [Mount the bridge](#3-mount-the-bridge) | Sign-in, callback, and every browser-side read |
| 4 | [Sign a user in](#4-sign-a-user-in) | A session, on the server and in the browser |
| 5 | [Put the shell up](#5-put-the-shell-up) | Chrome, navigation, launcher, account row |
| 6 | [Gate the screens](#6-gate-the-screens) | Nothing renders that the workspace may not use |
| 7 | [Sell a plan](#7-sell-a-plan) | Pricing page, checkout, entitlements |
| 8 | [Store your own data](#8-store-your-own-data) | A per-application database, declared and indexed |
| 9 | [Meter what you consume](#9-meter-what-you-consume) | Usage-billed add-ons, enforced |
| 10 | [Use the workspace](#10-use-the-workspace) | Members, roles, files, contacts, appointments |
| 11 | [Receive webhooks](#11-receive-webhooks) | State changes you did not have to poll for |
| 12 | [Expose your tools](#12-expose-your-tools) | Your product, usable from Connect's assistant |
| 13 | [Go live](#13-go-live) | The checks that are only obvious in hindsight |

---

## 1. Register the application

Nothing in your codebase can do this step — an application is a record on the
platform, created by a Connect operator in the console. Ask for, or create:

<Steps>

<Step>

### The catalogue entry

Name, one-line description, icon, category, and an **audience**. Audience is
the one that bites later: `b2b` means only `business` workspaces can subscribe,
`b2c` only `individual` ones, `both` accepts either. It is enforced at
subscribe time *and* at sign-in, so choosing it wrongly looks like a broken
login rather than a pricing decision.

Pick the `key` carefully — lowercase, `_`-separated, and **immutable**. It is
the first segment of every permission id (`pulse:campaign:manage`) and every
MCP tool name your application ever exposes.

</Step>

<Step>

### The Connect client

The OAuth2 half. Register your redirect URI:

```
https://pulse.example.com/api/connect/callback
```

…and one for every environment you run, `http://localhost:3000/...` included.
An origin that is not registered cannot complete a sign-in and cannot be a
`return_to` target either.

</Step>

<Step>

### An API key

Copy it once — Connect stores only `sha256(key)` and cannot show it again.
This one string is three credentials at once: your API key, your OAuth2
`client_secret`, and the key your `id_token` is signed with. Revoking it
revokes all three.

</Step>

<Step>

### At least one plan

A plan row is a tier *and* a price point. "Growth monthly" and "Growth annual"
are two rows sharing a `planGroup`, which is what pairs them in the pricing
grid. Give each plan its **feature keys** now (`reporting`, `csv-export`) —
those are the strings `ConnectFeatureGate` matches on, and they are a contract,
not labels.

</Step>

</Steps>

---

## 2. Install and configure

```bash
npm install @cosmoops/connect-sdk
```

```bash title=".env.local"
CONNECT_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CONNECT_CLIENT_ID=cid_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Two variables and no third. There is deliberately no `NEXT_PUBLIC_*` client
id: the default sign-in flow bounces the browser to your own `/signin` route,
which already knows the client id server-side.

```css title="app/globals.css"
@import "tailwindcss";
@import "@heroui/styles";
@import "@cosmoops/connect-sdk/theme.css";
```

Order matters, and it has to be imported from *your* stylesheet rather than
from `node_modules` — otherwise Tailwind roots its content scan inside
`node_modules` and never scans your app.

<Callout type="warn" title="The key must never reach the browser">
  `@cosmoops/connect-sdk/server` and `/next` carry a `browser` export
  condition that resolves to a stub, so importing either from a Client
  Component fails the build instead of shipping your key. If you see
  `this-entry-is-server-only` in a bundler error, the path in the message is
  the whole diagnosis.
</Callout>

---

## 3. Mount the bridge

```ts title="app/api/connect/[...connect]/route.ts"
export { GET, POST } from "@cosmoops/connect-sdk/next";
```

That single route is your entire backend for Connect. It answers `/signin` and
`/callback` (the full OAuth2 round trip), plus the reads every client component
makes: `/session`, `/entitlements`, `/plans`, `/addons`, `/apps`,
`/workspaces`, `/sessions`, `/integrations`, `/logout`.

**It is not a generic proxy, and that is the point.** It exposes a fixed set of
reads and resolves the workspace from the caller's own session — never from a
query parameter. A pass-through would hand any browser your whole API under
your credentials.

Not on Next.js? `createConnectHandler` from `/server` is the same thing with an
explicit session store instead of an assumed cookie.

---

## 4. Sign a user in

Client side, one button:

```tsx
"use client";
import { ConnectLoginButton, ConnectRegisterButton } from "@cosmoops/connect-sdk/client";

<ConnectLoginButton />
<ConnectRegisterButton />   {/* same flow, prompt=create */}
```

Server side, the same session with no `headers()` dance:

```tsx title="app/dashboard/page.tsx"
import { requireConnectSession } from "@cosmoops/connect-sdk/next";

export default async function Dashboard() {
  const { user } = await requireConnectSession();
  return <h1>{user.workspaceName}</h1>;
}
```

Three properties worth knowing before you build on it:

- **`workspaceId` is your tenant key, and it is nullable.** It is `null`
  exactly when `staff` is `true` — a Connect operator signing into your
  application, which is off by default and per-application. Decide what your
  tenanting does with a session that has no tenant before you turn it on.
- **Both helpers are memoized per request**, so a layout and the page beneath
  it cost one round trip.
- **They read `cookies()` transitively**, so under `cacheComponents` they
  belong inside a `<Suspense>` boundary like any other request-scoped read.

Route protection before a page renders belongs in `proxy.ts` — see
[Authentication](/docs/authentication).

---

## 5. Put the shell up

```tsx title="app/providers.tsx"
"use client";
import { AppShell, ConnectProvider } from "@cosmoops/connect-sdk/client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Send, Settings, Users } from "lucide-react";

const NAV = [
  { label: "Dashboard", href: "/", icon: <LayoutDashboard className="size-5" /> },
  { label: "Campaigns", href: "/campaigns", icon: <Send className="size-5" /> },
  { label: "Contacts", href: "/contacts", icon: <Users className="size-5" /> },
];

// Routes you go to in order to change how the job works, not to do it.
const SETTINGS_NAV = [
  { label: "Settings", href: "/settings", icon: <Settings className="size-5" /> },
  // The route that fixes a billing problem must render while blocked:
  { label: "Plans", href: "/plans", icon: <Send className="size-5" />, gateExempt: true },
];

// `connect` comes from `connectProviderProps()` in the Server Component layout -
// it reads `CONNECT_APP_URL`, which a Client Component cannot.
export function Shell({
  children,
  connect,
}: {
  children: React.ReactNode;
  connect: { appUrl: string };
}) {
  return (
    <ConnectProvider {...connect}>
      <AppShell
        name="Pulse"
        nav={NAV}
        secondaryNav={SETTINGS_NAV}
        activePath={usePathname()}
        renderLink={({ href, children, ...props }) => (
          <Link href={href} {...props}>{children}</Link>
        )}
      >
        {children}
      </AppShell>
    </ConnectProvider>
  );
}
```

<Preview name="app-shell">

```tsx
<AppShell name="Pulse" nav={NAV} secondaryNav={SETTINGS_NAV} activePath={pathname} renderLink={renderLink}>
  {children}
</AppShell>
```

</Preview>

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

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Shell connect={connectProviderProps()}>{children}</Shell>
      </body>
    </html>
  );
}
```

One `ConnectProvider` holds one session read, shared by everything under it —
a page with an account row, a launcher and a plan table makes one request.

**The `connect` prop is not optional once you point anywhere but production.**
The rail's account menu and launcher navigate to `{appUrl}/...`, and the
provider defaults that to production because `CONNECT_APP_URL` is server-side
and never reaches the browser. Omit it against a staging instance and the shell
works perfectly while every link out of it lands somewhere your workspace has
no subscription. [Environments](/docs/environments#the-browser-half-points-somewhere-too)
has the full list of what moves.

**Never wrap `AppShell` in a gate.** It gates its own children and leaves its
chrome alone, so a workspace whose payment failed still has navigation to reach
the page that fixes it. Backwards, the user loses their way out at exactly the
moment they need it.

The rail's launcher lists every application this account can reach — the ones
it holds a subscription to, then the rest of the catalogue as an *Explore*
group. See [Shell and navigation](/docs/ui/shell#the-launcher).

---

## 6. Gate the screens

Four gates, four genuinely different questions. Walk down and stop at the first
that fits:

| Gate | Ask it when |
|---|---|
| `ConnectAccessGate` | The whole application is unusable right now |
| `ConnectSubscriptionGate` | This screen needs a subscription, and is *not* the page that sells one |
| `ConnectFeatureGate` | This screen is one capability their plan may not include |
| `ConnectWorkspaceTypeGate` | This screen only means anything to a team |

<Preview name="access-gate">

```tsx
import { ConnectAccessGate } from "@cosmoops/connect-sdk/client";

<ConnectAccessGate>
  <Dashboard />
</ConnectAccessGate>
```

</Preview>

```tsx
// Per feature key, from the subscribed plan's `features`.
<ConnectFeatureGate feature="reporting">
  <DeliveryReports />
</ConnectFeatureGate>

// Inside a component that is already rendering, prefer the hook - and
// disable, do not hide: a hidden feature is indistinguishable from a
// missing one, and nobody learns an upgrade would give it to them.
const canExport = useConnectFeature("csv-export");
<Button isDisabled={!canExport}>Export CSV</Button>
```

<Callout type="warn" title="A gate is the explanation, not the enforcement">
  Every check here also belongs on your server, in the use-case that does the
  work. A gate alone is a hidden button in front of an open endpoint.
</Callout>

```ts title="lib/campaigns.ts"
import { connect } from "@/lib/connect";
import { blockingBlockers } from "@cosmoops/connect-sdk";

export async function sendCampaign(workspaceId: string, campaignId: string) {
  const readiness = await connect.entitlements.getReadiness(workspaceId);
  if (blockingBlockers(readiness).length > 0) throw new Error("Not entitled");
  // …
}
```

---

## 7. Sell a plan

```tsx title="app/plans/page.tsx"
export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
```

That one line is the readiness banner, the current subscription, the plan grid
and the whole subscribe / upgrade / downgrade / switch-cadence / cancel flow.

<Preview name="plans">

```tsx
import { ConnectPlans } from "@cosmoops/connect-sdk/client";

<ConnectPlans />   {/* the grid alone, for a page of your own */}
```

</Preview>

Three things about plans that are easy to learn the hard way:

- **A cadence change is not an upgrade.** Monthly → annual is its own kind of
  plan change, priced by closing the running cycle and re-anchoring a new one.
  If you build your own pricing page, drive the button from `resolveAction` and
  group the cards with `groupPlansByTier` rather than comparing prices
  yourself — both are exported for exactly that.
- **Drive the *URL* from the same action.** `checkoutSubscriptionId(action,
  subscription)` is what `buildCheckoutUrl` wants for `subscriptionId`. Reading
  the id straight off the entitlements response looks equivalent and is not: a
  live subscription whose plan has been deleted from your catalogue comes back
  as `plan: null` beside a non-null `subscription`, and a button correctly
  reading "Subscribe" then opens the change-plan screen, which cannot price a
  change it cannot classify either.
- **Surplus is credited, never refunded.** The customer keeps the same product;
  there is a balance to carry, not compensation to pay.
- **`planGroup` pairs the cadences, not `name`.** Names are neither unique nor
  stable across a rename or a clone.

Show what they are on, wherever it belongs in your own settings:

<Preview name="subscription">

```tsx
<ConnectSection title="Your plan">
  <ConnectTrialBanner />        {/* renders nothing outside a trial */}
  <ConnectSubscriptionCard />
</ConnectSection>
```

</Preview>

---

## 8. Store your own data

Your application gets **its own Firestore database**, provisioned by Connect
and reachable only through it. You hold no Firebase credential and write no
security rules.

<Steps>

<Step>

### Declare the collections

A collection that is not declared does not exist — an undeclared write is a
`403`, never an implicit create. `Application.storeSchema` is the allowlist for
the collection *and* for every query shape against it:

| Field | What it gates |
|---|---|
| `name` | The collection may be used at all |
| `read`/`writePermission` | The per-*user* gate, for calls the browser bridge makes |
| `queryableFields` | Nothing may be filtered, ordered or aggregated on a field not listed |
| `indexes` | The composite indexes Connect builds, and the query shapes they permit |
| `crossWorkspace` | Whether `data-scan` may read it across every tenant. Off by default |
| `appendOnly` | Create-only, refused for **every** caller including your own key |

Indexes are *declared*, not deployed: saving the schema creates what is new and
**removes what is no longer declared**, because an index nothing queries is
still written on every document write.

Transcribing each one by hand from a filter/`orderBy` pair works until a screen
grows past a handful of them. `storeIndexFor` derives the composite index a
query shape needs straight from your own query matrix instead - see
[Deriving the declaration instead of transcribing it](/docs/api/data#deriving-the-declaration-instead-of-transcribing-it)
in the Data API reference.

</Step>

<Step>

### Read and write

```ts
// One document.
const campaign = await connect.data.get({ workspaceId, collection: "campaigns", id });

// A page. Every field named here must be in `queryableFields`, and the shape
// must be covered by a declared index - otherwise you get a refusal that names
// the index to declare, rather than a runtime failure pointing at a console you
// cannot open. Page on `nextCursor`; `null` is the end.
const { items, nextCursor } = await connect.data.query({
  workspaceId,
  collection: "campaigns",
  filters: [{ field: "status", op: "==", value: "sending" }],
  orderBy: { field: "createdAt", direction: "desc" },
  limit: 25,
});

// A count is an aggregate, never `query(...).items.length` - the latter reads
// every document to produce one number.
const { values } = await connect.data.aggregate({
  workspaceId,
  collection: "campaigns",
  filters: [{ field: "status", op: "==", value: "sent" }],
  aggregations: [{ op: "count", alias: "sent" }],
});
```

Dates are **ISO-8601 strings**. UTC instants sort lexicographically in
chronological order, so `orderBy` and range filters behave exactly as they
would on a native timestamp — and a calendar day stays `YYYY-MM-DD`, because a
charter date is a day, not an instant.

</Step>

<Step>

### Write several things atomically

You never hold a transaction — handing a partner a server-side lock whose
duration their network controls is a queue waiting to happen. Instead, send the
set:

```ts
import { increment, unchangedSince } from "@cosmoops/connect-sdk";

await connect.data.transaction(
  async () => {
    const campaign = await connect.data.get({ workspaceId, collection: "campaigns", id });
    if (campaign.data.status !== "draft") throw new Error("Already sent.");

    return [
      {
        op: "merge",
        collection: "campaigns",
        id,
        data: { status: "sending", sendCount: increment(1) },
        // Refuse the whole commit if it moved since that read.
        precondition: unchangedSince(campaign),
      },
      { op: "create", collection: "campaign-events", data: { campaignId: id, kind: "started" } },
    ];
  },
  { workspaceId },
);
```

Up to 200 writes apply as one server-side transaction; a stale precondition is
a `409` that writes nothing, and `transaction()` is that retry loop written
once. **`build` may run more than once**, so keep it free of side effects — the
price of not holding a lock, and the same contract Firestore's own
`runTransaction` states. Only a `409` is retried: a `403` means the operation
was wrong rather than late.

`increment` is commutative and needs no precondition at all, which is the case
read-modify-write genuinely cannot handle.

</Step>

</Steps>

<Callout title="Append-only is the rule you cannot talk your way past">
  A ledger, a consent record, an audit trail: mark the collection `appendOnly`
  and Connect refuses every update and delete on it, including from your own
  API key. It is what a Firestore security rule used to say, in the one place
  a partner can no longer forget to write it.
</Callout>

---

## 9. Meter what you consume

If your plans sell a consumable — messages, credits, minutes — spend it on your
server and show the balance in your UI. Both halves read the same number.

```ts
// Enforced server-side. Under overagePolicy: "reject" this throws when the
// balance is spent; under "bill" it accrues to the cap.
await connect.billing.consumeAddon(
  { workspaceId, addonId: "messages", quantity: batch.length },
  { idempotencyKey: `campaign:${campaignId}:batch:${batchNumber}` },
);
```

<Preview name="usage-meter">

```tsx
import { ConnectUsageMeter } from "@cosmoops/connect-sdk/client";

<ConnectUsageMeter />
```

</Preview>

It shows `balance` against `includedPerCycle` and deliberately never a "used"
figure: with `carryForward` an unspent balance survives the cycle boundary, so
`included − balance` can go negative — a meter running backwards.

---

## 10. Use the workspace

Everything below already belongs to the workspace. You are borrowing it, not
rebuilding it — and each of these is one endpoint plus, usually, one component.

<Tabs items={["Members and roles", "Files", "Contacts", "Appointments", "OTP", "Key–value", "Analytics", "AI"]}>

<Tab value="Members and roles">

```ts
// The team, with each member's permissions already resolved for you.
const members = await connect.workspaces.members({ asUser: session.user.userId });

// One member's role and resolved permissions on your application.
const { role, granted } = await connect.access.getUserRoles(userId, { workspaceId });

// Your own permission catalogue - the ids you declared on the application.
const catalogue = await connect.access.listRoles();
```

Permission ids are namespaced to your `key` (`pulse:campaign:manage`), and
`app:pulse:admin` grants all of them. Connect models none of your internal
permissions beyond that: the catalogue is yours, the grant is Connect's.

</Tab>

<Tab value="Files">

```ts
const uploaded = await connect.drive.upload({ workspaceId, file, fileName: "export.csv" });
const url = await connect.drive.getFileUrl(uploaded.id, { workspaceId });
```

Storage is the workspace's own quota, shared with every other application it
subscribes to — an over-quota upload is refused, and nothing is ever evicted to
make room.

</Tab>

<Tab value="Contacts">

```ts
const contact = await connect.contacts.lookupByPhone({ workspaceId, phone: "+919000000000" });
```

The workspace's own contact records, shared across the applications it uses —
so a phone number your product sees is the same person Connect's agents
already know about.

</Tab>

<Tab value="Appointments">

```ts
const eventTypes = await connect.scheduling.listEventTypes(workspaceId);
const slots = await connect.scheduling.findSlots({ workspaceId, eventTypeId, from, to });

// Pass a slot's `startAt` back unchanged - re-deriving it from a local wall
// time is how an integration books an hour that was never offered.
const booking = await connect.scheduling.createBooking({
  workspaceId,
  eventTypeId,
  startAt: slots.slots[0].startAt,
  attendee: { name, phone },   // phone is required; email optional
});
```

Booking through this path sends the confirmation, the reminders and the "your
appointment moved" mail for you. `<ConnectBookingCalendar>` is the same flow
with a grid around it.

</Tab>

<Tab value="OTP">

```ts
const { verificationId } = await connect.otp.send(
  { phoneNumber: "+919876543210", channel: "whatsapp" },
  { idempotencyKey },
);
const { status } = await connect.otp.verify({ verificationId, code });
```

For verifying something in *your* product — a delivery address, a customer's
number. It is not a sign-in mechanism; Connect owns authentication.

</Tab>

<Tab value="Key–value">

```ts
await connect.kv.set({ workspaceId, namespace: "rate", key: userId, value: n, ttlSeconds: 60 });
const hit = await connect.kv.get({ workspaceId, namespace: "cache", key: "catalogue" });
```

Cache-aside, rate limits, feature flags, idempotency keys. A page can come back
shorter than its limit and still have more behind it — expired entries are
filtered after paging — so stop on `nextCursor === null`, never on an empty
page. See [Caching](/docs/guides/caching).

</Tab>

<Tab value="Analytics">

```ts
// `sessionToken` is what attributes the event to a user and their workspace.
await connect.analytics.emit([{ name: "campaign.sent", value: 1, sessionToken }]);

const series = await connect.analytics.series({
  metrics: ["api.requests"],
  from: "2026-08-01",
  to: "2026-08-31",   // the range must be shorter than 90 days
});
```

Emit what only you know; Connect already counts sign-ins, subscriptions and API
usage without being told. See [Analytics](/docs/guides/analytics).

</Tab>

<Tab value="AI">

```tsx
import { ConnectAskAi } from "@cosmoops/connect-sdk/client";

<ConnectAskAi placeholder="Ask about your campaigns…" />
```

RAG-grounded chat over the workspace's own indexed content, streamed through
your bridge — so the credential stays on your server and the workspace comes
from the session.

</Tab>

</Tabs>

---

## 11. Receive webhooks

```ts title="app/api/connect/events/route.ts"
import { createConnectWebhookHandler } from "@cosmoops/connect-sdk/server";

export const POST = createConnectWebhookHandler({
  secret: process.env.CONNECT_WEBHOOK_SECRET!,
  on: {
    "subscription.cancelled": ({ workspaceId }) => dropEntitlements(workspaceId),
    "workspace.deletion_scheduled": ({ workspaceId, purgeAfter }) => freeze(workspaceId, purgeAfter),
    "workspace.deleted": ({ workspaceId }) => purge(workspaceId),
  },
});
```

Delivery is **at-least-once and unordered**, so a handler has to be idempotent
and must not assume it has seen the previous event. And webhooks are the fast
channel, not the authoritative one: reconcile against
`connect.entitlements.getReadiness` and let events save you the wait. Full
contract, including signature verification if you write your own receiver, in
[Webhooks](/docs/webhooks).

---

## 12. Expose your tools

Two directions, and they are separate features:

**Your application serves MCP.** `@cosmoops/connect-sdk/mcp` derives a toolset
from the `storeSchema` you already declared — so the collections above become
tools with no second definition to keep in sync.

```ts
import { createStoreToolset } from "@cosmoops/connect-sdk/mcp";

// One toolset per signed-in user: Connect checks each collection's declared
// permission against that member's live roles, so `asUser` is the gate.
const toolset = createStoreToolset({
  collections: STORE_SCHEMA,
  data: connect.data,
  workspaceId: session.user.workspaceId,
  asUser: session.sessionToken,
});

server.setRequestHandler(ListToolsRequestSchema, () => toolset.listTools());
server.setRequestHandler(CallToolRequestSchema, (request) =>
  toolset.callTool(request.params.name, request.params.arguments ?? {}),
);
```

The package takes no dependency on `@modelcontextprotocol/sdk` — `listTools()`
and `callTool()` answer in the protocol's own shapes, so you wire them to
whichever server and transport you already run.

**Connect calls your MCP server.** An operator registers your endpoint,
Connect runs `tools/list` once and pins the result, and your tools appear in
the workspace assistant. Three things follow from that design:

- **Discovery is a snapshot.** Renaming a tool changes nothing until someone
  re-runs discovery, and your uptime never sits on the critical path of
  somebody's conversation.
- **Two credentials, two questions.** `Connect-Signature` says the request is
  Connect's; a short-lived bearer says which member it is for. Resolve the
  bearer against Connect and apply *your* gate — Connect models none of your
  permissions.
- **There is no MCP secret.** Connect signs with a value derived from your API
  key's hash, and sends one signature per live key. Check *every* `v1` in the
  header, not the last one, or your integration breaks the day a second key is
  issued.

See [MCP](/docs/mcp).

---

## 13. Go live

<Steps>

<Step>

### Redirect URIs for every origin you deploy

Preview deployments included. An unregistered origin cannot complete a sign-in
and cannot be a `return_to` target, which shows up as a callback that loops.

</Step>

<Step>

### Every entitlement check exists on the server

Search your codebase for each `ConnectFeatureGate` and confirm there is a
matching check in the use-case behind it. The gate is the explanation.

</Step>

<Step>

### Your webhook receiver is idempotent

Replay one delivery twice against staging. If the second one double-charges,
double-sends or double-writes, that is a production incident waiting for a
network hiccup.

</Step>

<Step>

### Every query you ship has a declared index

`assertQueryServable` refuses anything else, and it refuses at call time — so a
screen nobody opened during testing is where you find out. Re-run your schema
save after adding a filter.

</Step>

<Step>

### The audience matches what you are selling

A `b2b` application is invisible to individual workspaces in the launcher's
Explore group and refuses their sign-in. That is correct behaviour and a
confusing bug report if it was not what you meant.

</Step>

<Step>

### Your privacy page names the platform's grievance officer

Not one of your own, and not a copy of theirs in an environment variable. Drop
`<ConnectGrievanceNotice applicationName="Pulse" />` on it and leave the route
public — an officer resigns, the platform's record is corrected, and every
application reading it is correct within the hour. Every application holding a
copy is publishing an address that reaches nobody, silently. See
[Compliance](/docs/concepts/compliance); the endpoint needs `company:read` on
your API key.

</Step>

<Step>

### Nothing in the browser bundle imports `/server` or `/next`

The export condition catches it at build time, but check your build actually
fails rather than warns — a bundler configured to ignore the condition ships
your API key.

</Step>

</Steps>

## Where to go deeper

- **[Domain modelling](/docs/guides/architecture)** — the layering every
  snippet above assumes, written out once
- **[Authentication](/docs/authentication)** — the OAuth2 round trip, route
  protection, and permission resolution
- **[Building the UI](/docs/ui)** — the shell, the gates and the screens, each
  with the component running on the page
- **[API reference](/docs/api)** — every endpoint, with its inputs and its
  refusals