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