Agent integration rules
Cursor can install Comments. Open Comments Keys at https://app.bootstrapware.co/comments/keys and click Add to Cursor (OAuth). The MCP endpoint is https://comments.bootstrapware.co/mcp.
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. `layout="threads"` (the default) keeps several discussions on one record, with flat replies. `parentCommentId` is null on the thread root and on those flat replies: they are siblings of the root, not children of it. Section and forum replies set `parentCommentId` and nest at most 5 levels (depth 0 is top-level). 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, 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 `layout`: `"threads"` (default), `"section"`, or `"forum"`. Threads is the discussion list with flat replies (`parentCommentId` stays null). Section is one chronological comment stream under the record, with nested replies and no thread list. If every section thread is resolved, a viewer with `create` can post a new top-level comment; that call starts a new implicit thread and resolved history stays visible. Forum is a topic list; each topic is a nested reply tree, and resolve still closes that topic. Nesting stops at depth 5.
Optional `presentation`: `section` (default, inline on the page), `modal`, `window`, or `launcher`. This is separate from `layout`. Modal, window, and launcher start closed unless `defaultOpen`. Escape closes them. The backdrop closes modal and launcher. Nudge the corner with `--bsw-cm-frame-launcher-right` and `--bsw-cm-frame-launcher-bottom`.
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
- Stored bodies stay plain text. The widget renders `**bold**`, `_italic_` and `*italic*` beside a letter or digit, `` `code` ``, `[label](http(s) URL)`, and bare `http`/`https` URLs. Links use `rel="nofollow noopener"` and `target="_blank"`. Raw HTML and other schemes stay text. Mentions win when markdown would cross them.
- `onUnreadChange(count)` fires when the unread total for the current view changes, after that view has loaded. Threads and forum sum `unreadCount` on threads loaded for the active server filter. Section sums the section threads. Hosted and BYO.
- 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, with `parentCommentId` and `depth` on each row. Merge pages before building a tree: a later page can hold a child whose parent was on the previous page. Load more uses the opaque cursor. Default page size 50, maximum 100.
- A reply omits `parentCommentId` for a top-level comment. Threads layout always omits it. Nesting stops at depth 5. A deleted parent, a parent on another thread, and a sixth level are rejected.
- 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. The widget snapshots `unreadCount` before `markRead` and draws one New comments divider in the loaded page (threads, forum, and section). Section places that divider before the first unread comment in the merged stream and marks each loaded thread read for the comments it rendered. It stays through poll and SSE, and is gone the next time that view is opened after it was marked read.
- 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` with optional `parentCommentId` (`null` for a top-level reply) |
| 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