Agent integration rules

Paste this into AGENTS.md, Cursor rules, or your agent system prompt when adding Bootstrapware Comments. The same file ships inside the npm package.

# Integrating @bootstrapware/comments

Use this file as agent instructions. The React widget and the Hosted Comments service are separate packages. This file is the install contract: config, keys, MCP, the widget embed, and a server-only Next.js author-token route.

## What this package does

Embeddable discussions on a host-owned resource: threads and flat replies, open or resolved. Mentions are host-granted user ids. Unread is a per-viewer activity sequence. Chat stays person-to-person messaging. Feedback stays feature requests and voting.

**Two paid modes, plus free local/test**

- **Free:** local adapter and test keys. No card. Pass `createLocalAdapter({ storageKey })`. Saved JSON that cannot be read shows: `Saved discussions could not be read. Showing an empty in-memory discussion.`
- **BYO ($9.99):** live app config. Discussion text stays on the customer backend. Pass a `CommentsAdapter`, or `createByoAdapter(impl)`.
- **Hosted ($19.99):** live app config, and Bootstrapware stores threads and comments. Pass `appId` + `publishableKey` and omit `adapter`. The customer moderates. Live Hosted writes need the Hosted plan. Live config needs BYO or Hosted. Test keys work unpaid.

Stack discount is company-wide: 25% off the second paid product’s list, 35% off the third and later. There is no Comments storage add-on, seat charge, or per-comment price.

Customer discussion text stays on the customer machine (local) or the customer backend (BYO). Hosted is the explicit exception: Bootstrapware stores comment bodies and mention ids. Never send a comment body, mention directory, resource title, or file bytes through MCP. MCP configures apps only.

Out of this package: file attachments, anonymous comments, votes, nested reply trees, dashboard billing, and MCP registration. The Hosted HTTP API lives in `apps/comments-service`.

## Install algorithm (do this in order)

1. Read this file. Call `list_comment_capabilities` on Comments MCP before inventing tools.
2. If the user only needs a working UI in this app: Path A. Do not invent keys.
3. If they need live Hosted or BYO config: Path B or C.
4. Never invent `user.id`. Wire the **real** session id from the host app. The widget prop is `user`, not `viewerId`.
5. Never mint live or secret API keys via MCP or in the browser. Never mint author tokens in the browser. For a test publishable key, call `ensure_comment_test_publishable`.

### Path A — local, zero keys

```bash
pnpm add @bootstrapware/comments
```

```tsx
import { Comments, createLocalAdapter } from "@bootstrapware/comments";
import "@bootstrapware/comments/styles.css";

<Comments
  user={{ id: session.user.id, name: session.user.name, avatarUrl: session.user.image }}
  scope={{ tenantKey: workspace.id, resourceType: "task", resourceId: task.id }}
  adapter={createLocalAdapter({ storageKey: "demo-comments" })}
  searchMentionCandidates={async ({ query }) =>
    members.filter((member) => member.name.toLowerCase().includes(query.toLowerCase())).slice(0, 20)
  }
  resolveUsers={async ({ ids }) => members.filter((member) => ids.includes(member.id))}
/>
```

Local mode stays in the browser. It does not sync profiles or devices.

`searchMentionCandidates` and `resolveUsers` are host callbacks in every mode. Bootstrapware does not store a user directory. Cap the page you return at 20 people this viewer may see on this resource.

Optional `theme` (`"light"` | `"dark"`). Omit it and the shell stays the light cream default: no `data-theme`, and no `prefers-color-scheme`. Hosts may set `--bsw-cm-bg`, `--bsw-cm-panel`, `--bsw-cm-fg`, `--bsw-cm-muted`, `--bsw-cm-line`, `--bsw-cm-accent`, `--bsw-cm-accent-fg`, and `--bsw-cm-radius` on `.bsw-cm` (with `theme="dark"`, `.bsw-cm[data-theme="dark"]`). Published or passed `branding.primaryColor` sets `--bsw-cm-accent` inline. `className` is layout only.

### Path B — Hosted

1. Confirm Cursor MCP `bootstrapware-comments` at `https://comments.bootstrapware.co/mcp` (OAuth URL-only). If disconnected, tell the human to click **Add to Cursor (OAuth)** on https://app.bootstrapware.co/comments/keys
2. `list_comment_capabilities` → `create_comment_app` → `update_comment_draft` (toggles + `allowedOrigins` for localhost and production; `name` is optional) → `publish_comment_app`
3. `ensure_comment_test_publishable`. Put `envLine` in `.env.local`. The full test publishable is returned every time. The env name is `NEXT_PUBLIC_BSW_COMMENTS_PUBLISHABLE_KEY`.
4. `get_comment_install_snippet`. `appId` is real. Use `process.env.NEXT_PUBLIC_BSW_COMMENTS_PUBLISHABLE_KEY`. Never invent a key and never put a secret in the snippet.
5. Add the server-only author-token route below. The browser receives `authorToken` only. Pass `renewAuthorToken` so the widget can refresh it.

`create_comment_app` returns these `nextSteps`, in order:

1. Call `update_comment_draft` with `allowedOrigins` for localhost and production. Name is optional.
2. Call `publish_comment_app`.
3. Call `ensure_comment_test_publishable` to get or create a test publishable key.
4. Call `get_comment_install_snippet`, then embed with the real session user id.

`publish_comment_app` returns:

1. Call `ensure_comment_test_publishable` to get or create a test publishable key.
2. Call `get_comment_install_snippet` and wire the real session user id.

The snippet uses the widget props `appId`, `publishableKey`, `user`, `scope`, `authorToken`, and `renewAuthorToken`. It does not take a `viewerId` prop. `viewerId` is an adapter and query field the widget fills from `user.id`. Fill `tenantKey`, `resourceType`, and `resourceId` from the record your server already authorized. Those strings are not proof of access.

```tsx
<Comments
  appId="cma_demo"
  publishableKey={process.env.NEXT_PUBLIC_BSW_COMMENTS_PUBLISHABLE_KEY}
  user={{ id: session.user.id, name: session.user.name }}
  scope={{ tenantKey: workspace.id, resourceType: "task", resourceId: task.id }}
  authorToken={authorTokenFromYourServer}
  renewAuthorToken={renewFromYourServer}
  searchMentionCandidates={searchPeople}
  resolveUsers={resolvePeople}
/>
```

`cma_demo` is the app id in `examples/comments-nextjs`. `get_comment_install_snippet` substitutes the published `cma_` id for the app you created. Optional `apiBaseUrl` defaults to `https://comments.bootstrapware.co`.

Live publishable keys require an author token. Test keys require one only when the published app sets `requireAuthorToken`. The widget calls `renewAuthorToken` when the unverified `exp` is under 60 seconds away, and again when the server reports an expired token, at most once every 30 seconds. Renewal failure keeps the composer text.

### Path C — BYO

Same MCP app config as Path B. Implement `CommentsAdapter` on the customer backend. `createByoAdapter(impl)` returns that adapter. Discussion text never goes to Bootstrapware. The widget callbacks (`searchMentionCandidates`, `resolveUsers`, `renewAuthorToken`) stay on the host. The same server-only author-token route applies when the host mints tokens; BYO checks mention ids before it stores the row. A selected suggestion in the browser is not a grant.

```tsx
<Comments
  appId="cma_demo"
  user={currentUser}
  scope={{ tenantKey: workspace.id, resourceType: "task", resourceId: task.id }}
  adapter={createByoAdapter(customerAdapter)}
  searchMentionCandidates={searchPeople}
  resolveUsers={resolvePeople}
/>
```

## Server-only Next.js author token

The host session is the authority. Ignore `authorId` and `permissions` if the browser sends them. Look up the signed-in user, confirm that user may open this resource, and copy permissions and the mention allow-list from that server record. The secret stays in server env (`BSW_COMMENTS_SECRET`), never `NEXT_PUBLIC_*`.

`BSW_COMMENTS_SECRET` is the dashboard secret. Paste the full `bsw_test_sec_…` or `bsw_live_sec_…` value into the server env. Do not commit it. `COMMENTS_APP_ID` is the published app id from `create_comment_app` (`cma_` plus 24 hex). The persisted BYO example in this repo uses app `cma_demo`, tenant `acme`, resource type `task`, and resource `task_1842`. Members of that task are `user_ada`, `user_kai`, and `user_noor`.

```ts
// app/api/comments/author-token/route.ts
import { NextResponse } from "next/server";
import { getSessionUser, resourceAccess } from "@/lib/session";

const COMMENTS_APP_ID = process.env.COMMENTS_APP_ID ?? "cma_demo";

export async function POST(request: Request) {
  const user = await getSessionUser();
  if (!user) return NextResponse.json({ error: "Sign in required." }, { status: 401 });

  const body = await request.json();
  const resource = {
    tenantKey: String(body.tenantKey ?? ""),
    resourceType: String(body.resourceType ?? ""),
    resourceId: String(body.resourceId ?? ""),
  };
  const access = await resourceAccess(user.id, resource);
  if (!access) {
    return NextResponse.json({ error: "The requested resource was not found." }, { status: 404 });
  }

  const requested = Array.isArray(body.mentionIds) ? body.mentionIds.filter((id) => typeof id === "string") : [];
  const mentions = requested.filter((id) => access.mentionIds.includes(id));

  const response = await fetch("https://comments.bootstrapware.co/api/v1/author-tokens", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BSW_COMMENTS_SECRET}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      authorId: user.id,
      appId: COMMENTS_APP_ID,
      tenantKey: resource.tenantKey,
      resourceType: resource.resourceType,
      resourceId: resource.resourceId,
      permissions: access.permissions,
      mentions,
      expiresInSec: 300,
    }),
  });
  const json = await response.json();
  if (!response.ok) return NextResponse.json(json, { status: response.status });
  return NextResponse.json({ authorToken: json.data.authorToken });
}
```

`resourceAccess` returns the permissions your database stores for that member. A typical author list is `["read","create","reply","edit_own","delete_own"]`. Add `"resolve"` only when that member may resolve and reopen. Add `"moderate"` only for a moderator. Do not default `moderate`. On the example task, Ada may moderate; Kai and Noor may not.

The widget `permissions` prop only hides buttons when no author token is present. It does not grant access. When a token is present, the token's `perms` win in the UI, and the Hosted API checks them again.

The widget calls `renewAuthorToken` on the host. It never mints with the publishable key. `mentions` on the token is the allow-list. An empty list grants zero mention ids. Mentioning a person does not grant them access to the resource.

The token is bound to workspace, environment, app, tenant, resource type, and resource id. Switching user, tenant, or resource drops in-flight results, closes the stream, and keeps a separate composer draft. A token stays valid until `exp`. The default lifetime is 300 seconds, clamped from 60 to 86400. There is no revocation list. Stop minting when access ends. Purge rejects new writes immediately.

## Hosted MCP (Cursor)

HTTP MCP endpoint: `https://comments.bootstrapware.co/mcp`

**Preferred:** OAuth Connect, URL-only. Scope `comments:mcp`. **Add to Cursor (OAuth)** on https://app.bootstrapware.co/comments/keys

**Fallback:** test secret as `Authorization: Bearer`. Live and secret keys stay on the dashboard.

Tools:

- `list_comment_apps`
- `get_comment_app`
- `create_comment_app`
- `update_comment_draft` (does not publish; `name` optional)
- `publish_comment_app`
- `get_comment_published_config`
- `get_comment_install_snippet`
- `ensure_comment_test_publishable`
- `list_comment_capabilities`

**Dashboard-only (do not invent MCP tools):** live and secret key mint, webhooks, app delete, branding, billing, Hosted discussion browser, export, purge. Call `list_comment_capabilities`.

Publishing stores hosted app config. Live publishable config fetch needs BYO ($9.99) or Hosted ($19.99). Live Hosted writes need Hosted ($19.99). Test keys work unpaid.

## Config

Draft defaults: `requireAuthorToken: false`, `allowedOrigins: ["*"]`, `emptyState` “No discussions yet. Start one.”, `bodyMax` 10000, mentions and resolve enabled. `["*"]` is the CORS default. It is not content authorization. A `bodyMax` above 10000 is rejected.

## Behavior

- Plain text only. Whitespace-delimited `http` and `https` URLs render as links with `rel="noopener noreferrer"`. Other schemes stay text. No HTML and no markdown.
- Body max is 10,000 Unicode code points, or the published `bodyMax` when that is lower. Empty text is rejected. The composer keeps its text on validation, conflict, rate limit, quota, forbidden, and network errors.
- Mentions are `{ userId, start, end }` code-point offsets into the normalized body. At most 20. Typing `@someone` without a selected person stays plain text.
- Threads page newest activity first. Comments page oldest first. Load more uses the opaque cursor. Default page size 50, maximum 100.
- Unread counts other people's comments above this viewer's read cursor. Opening a thread marks only the comments that were rendered. Polling does not mark read by itself.
- Resolve conflict and a stale version keep the draft. A stale version throws `StaleVersionError` and the UI offers Reload.
- Deleted comments render `This comment was deleted.` Tombstones are not stored as that sentence.
- Poll every 4 seconds when `subscribe` is missing or the stream errors. Backoff is 4s, 8s, 16s, then 60s. Pause while the document is hidden.

## Hosted service seam

`createHostedAdapter` calls the Comments HTTP API. Publishable key: `Authorization: Bearer`, and `key` on the SSE query (EventSource cannot set headers). Author token: `authorToken` on write JSON, `viewerToken` on read and SSE queries. Never the secret key.

| Call | Request |
| --- | --- |
| List threads | `GET /api/v1/threads` with `appId`, `tenantKey`, `resourceType`, `resourceId`, `filter`, `cursor`, `limit`, `viewerId`, `viewerToken` |
| Create thread | `POST /api/v1/threads` |
| List comments | `GET /api/v1/threads/:id/comments` |
| Reply | `POST /api/v1/threads/:id/comments` |
| Edit | `PATCH /api/v1/comments/:id` |
| Delete | `POST /api/v1/comments/:id` with `{ "action": "delete" }` |
| Resolve / reopen | `POST /api/v1/threads/:id/resolve` and `/reopen` |
| Read state / mark read | `GET` and `POST /api/v1/threads/:id/read` |
| Config | `GET /api/v1/config/:appId` |
| Stream | `GET /api/v1/stream` with the scope, `viewerId`, `viewerToken`, and `key` (the publishable key) |

Success responses use `{ data }`. Errors use `{ error: { code, message, details } }`. HTTP 409 with `details.reason` `stale_version` becomes `StaleVersionError`. SSE event names are `comments.thread_created`, `comments.comment_created`, `comments.comment_updated`, `comments.comment_deleted`, `comments.thread_resolved`, `comments.thread_reopened`, and `comments.user_mentioned`.

## Pricing and retention

- Free: local mode and test keys
- BYO $9.99: live config; you store discussions
- Hosted $19.99: we store discussions
- Cancel Hosted: writes freeze immediately. Dashboard JSON export for 30 days, then Hosted discussions are deleted
- Hosted → BYO keeps the rows and does not start the 30-day clock

## Security

- Do not put `bsw_live_sec_` or `bsw_test_sec_` in client code, `NEXT_PUBLIC_*`, logs, or snippets.
- Do not send comment bodies, mention directories, resource titles, or file bytes to MCP or webhooks.
- Publishable keys and `allowedOrigins` identify the workspace and apply CORS. They do not prove the end user may read or write.
- `user.id` is the actor. The author token is the grant. It does not replace `tenantKey`, `resourceType`, or `resourceId`.
- Your server mints and renews `authorToken`. The widget never calls the secret-key author-tokens API.

Related: Cursor guide · Quickstart · API overview