Agent integration rules

Cursor can install Feedback. Open Feedback Keys at https://app.bootstrapware.co/feedback/keys and click Add to Cursor (OAuth). The MCP endpoint is https://feedback.bootstrapware.co/mcp.

Paste this into AGENTS.md, Cursor rules, or your agent system prompt when adding Bootstrapware Feedback. The same concepts ship inside the npm package as AGENTS.md.

What the package does

Embeddable feature-request board for SaaS: list, submit, vote, status badges.

After 4 characters in the compose title, similar requests search beyond the loaded page: Hosted and BYO debounce listPosts({ q }); local uses in-memory substring/token overlap. Up to 3 clickable matches jump to the post and focus Vote.

The board lists 50 posts per page. When listPosts returns nextCursor, the embed shows Load more. Honor cursor in BYO so later pages can load. Vote and submit refresh the first page and keep already-loaded extras.

Submit accepts an optional JPEG, PNG, or WebP screenshot (4 MB) on Local and BYO. Persist optional imageUrl on createPost. Hosted hides Attach image; image upload is not available yet.

The author can correct title and body on their own post while status is open. After staff moves the status, the text locks. Optional updatePost on the adapter. Omit it and the embed hides Edit. The edit reuses the host-asserted author.id and existing authorToken when the board requires it. It does not change status. On Local and BYO, optional imageUrl clears or replaces the screenshot (omit leaves it; null clears it; data URLs stay at 4 MB). Remove image and Replace image show in that edit form for the author of an open request. Hosted ignores imageUrl. Optional withdrawPost archives the author's own open request. Omit it and Withdraw stays hidden. Withdraw sets archivedAt and is not a hard delete.

When user.id is set, My requests filters through optional listPosts({ authorId }). Honor authorId in BYO so later pages stay scoped to that author. Voted lists requests that viewer has voted on through optional listPosts({ votedByViewer: true }) with the same viewerId. Without a viewer id, return an empty list.

The embed Sort control defaults to Votes and calls listPosts({ sort: "votes" | "recent" }). Apply sort before cursor. votes orders by vote count, then newest, then id. Omit sort and the list stays newest-first. Do not reuse a cursor from one sort on the other.

Hosts can pin a request with focusPostId or #bsw-fb-post-<id> on the current page (same as the post element id). If it is not on the first page, the embed fetches listPosts({ postId }). Copy link writes that hash on the host URL. This is not a public board page.

Two modes

  • BYO ($9.99): you own posts. Pass a FeedbackAdapter (or callbacks wrapped with createByoAdapter). Bootstrapware only hosts board config.
  • Hosted ($19.99): omit adapter; pass boardId + publishableKey. Posts/votes live on Bootstrapware. Customer moderates in the dashboard.

Never send post title/body through MCP. MCP configures boards only.

Install

pnpm add @bootstrapware/feedback
import { Feedback, createLocalAdapter } from "@bootstrapware/feedback";
import "@bootstrapware/feedback/styles.css";

Identity

Bootstrapware does not authenticate end users. Your app asserts:

user={{ id: "opaque-stable-id", name: "Ada" }}

id is opaque. Optional email / avatarUrl are display metadata only.

Optional authorToken: mint with POST /api/v1/author-tokens (secret key). Required when the board enables requireAuthorToken. See Identity.

Local / demo

<Feedback
  user={{ id: "user_1", name: "Ada" }}
  adapter={createLocalAdapter({ storageKey: "demo-feedback" })}
/>

BYO adapter

<Feedback
  boardId="brd_..."
  user={currentUser}
  adapter={{
    listPosts: async ({ boardId, status, cursor, q, includeMerged, authorId, postId, sort, votedByViewer }) => {
      const params = new URLSearchParams({ boardId });
      if (status) params.set("status", status);
      if (cursor) params.set("cursor", cursor);
      if (q) params.set("q", q);
      if (includeMerged) params.set("includeMerged", "1");
      if (authorId) params.set("authorId", authorId);
      if (sort) params.set("sort", sort);
      if (votedByViewer) params.set("votedByViewer", "1");
      if (postId) params.set("postId", postId);
      const res = await fetch(`/api/feedback?${params}`);
      const json = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(json.error ?? `Failed to load feedback (${res.status}).`);
      return json;
    },
    createPost: async (input) => {
      const res = await fetch("/api/feedback", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(input),
      });
      const json = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(json.error ?? `Could not create post (${res.status}).`);
      return json;
    },
    vote: async ({ postId, author }) => {
      const res = await fetch(`/api/feedback/${postId}/vote`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ author }),
      });
      if (!res.ok) {
        const json = await res.json().catch(() => ({}));
        throw new Error(json.error ?? `Vote failed (${res.status}).`);
      }
    },
    unvote: async ({ postId, author }) => {
      const res = await fetch(`/api/feedback/${postId}/unvote`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ author }),
      });
      if (!res.ok) {
        const json = await res.json().catch(() => ({}));
        throw new Error(json.error ?? `Vote failed (${res.status}).`);
      }
    },
    updatePost: async ({ postId, title, body, imageUrl, author, authorToken }) => {
      const res = await fetch(`/api/feedback/${postId}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title, body, imageUrl, author, authorToken }),
      });
      const json = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(json.error ?? `Could not update request (${res.status}).`);
      return json;
    },
    withdrawPost: async ({ postId, author, authorToken }) => {
      const res = await fetch(`/api/feedback/${postId}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "withdraw", author, authorToken }),
      });
      const json = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(json.error ?? `Could not withdraw request (${res.status}).`);
      return json;
    },
  }}
/>

Honor optional q on listPosts so similar-request search can look past the loaded page. Honor optional cursor so Load more can fetch the next page. Persist optional imageUrl from createPost (or implement uploadImage and return a URL). Optional updatePost lets the author correct title and body while the post is open, and optional imageUrl clears or replaces the screenshot. Omit updatePost and the embed hides Edit. Optional withdrawPost archives that author's open request. Omit it and the embed hides Withdraw. Throw when a request fails. Do not return an error JSON body: the embed treats a missing posts array, or a write result with no post id, as a failure instead of crashing or confirming a post that was not saved.

The embed passes optional includeMerged: true on the board list so a merged duplicate stays visible. When that flag is set, return archived posts that have mergedIntoPostId and keep every other archived post hidden. Omit the flag and the list stays archived-hidden. Those rows are non-votable Merged into … pointers that focus the canonical post. Do not accept votes on them, and do not clear archivedAt on the source. Similar-request search omits the flag.

Honor optional authorId so My requests can list only that author's posts. Honor optional sort (votes or recent) before cursor. Honor optional votedByViewer with viewerId so Voted lists only that viewer's votes; with no viewer id, return an empty list. Honor optional postId so a focused permalink can fetch one live (or merged-stub) post that is not on the first page. When postId is set, ignore status, q, cursor, authorId, sort, and votedByViewer. Do not return a plain archived row for postId.

Hosted

<Feedback
  boardId="brd_..."
  publishableKey="bsw_live_pub_..."
  user={currentUser}
/>

Optional apiBaseUrl defaults to https://feedback.bootstrapware.co. Hosted author edits call POST /api/v1/posts/:id with action: "update" while the post is open.

Hosted management (secret key)

Secret keys (bsw_test_sec_ / bsw_live_sec_) must stay server-side. Use them as Authorization: Bearer against https://feedback.bootstrapware.co to create, draft, and publish boards without the dashboard.

# Create
curl -s -X POST https://feedback.bootstrapware.co/api/v1/boards \
  -H "Authorization: Bearer $BSW_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"name":"Product ideas"}'

# Save draft (replace BOARD_ID)
curl -s -X PATCH https://feedback.bootstrapware.co/api/v1/boards/BOARD_ID \
  -H "Authorization: Bearer $BSW_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"name":"Product ideas","config":{"submissionsEnabled":true,"votingEnabled":true,"showVoteCounts":true,"allowAnonymous":false,"emptyState":"No requests yet. Suggest one."}}'

# Publish
curl -s -X POST https://feedback.bootstrapware.co/api/v1/boards/BOARD_ID \
  -H "Authorization: Bearer $BSW_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"action":"publish"}'

Also available: GET /api/v1/boards, GET /api/v1/boards/:id, GET /api/v1/usage.

Hosted MCP (Cursor)

HTTP MCP endpoint: https://feedback.bootstrapware.co/mcp. Prefer OAuth Connect (no secret in mcp.json). Secret Bearer keys remain a fallback. Never send post title/body through MCP tools.

Cursor plugin: github.com/BootstrapWare/bootstrapware-cursor. Use Keys → Add to Cursor (OAuth), then Connect and sign in.

Tools:

  • list_boards
  • get_board
  • create_board
  • update_draft (does not publish; name optional)
  • publish_board
  • get_published_config
  • get_install_snippet
  • ensure_test_publishable
  • list_capabilities

Dashboard-only: live/secret API key mint/revoke, webhooks, board delete, branding, billing, Hosted inbox. Test publishable keys: ensure_test_publishable. Prefer list_capabilities before inventing endpoints.

Publishing stores hosted board config. Live bsw_live_pub_ config fetch needs BYO ($9.99) or Hosted ($19.99). Live Hosted post storage needs Hosted ($19.99). Test publishable keys work without that.

Revoke Cursor OAuth grants under Keys → Active Cursor connections. Marketplace listing may still be pending.

First-run setup

  1. Open Feedback → Keys and click Add to Cursor (OAuth) (or install the plugin).
  2. In Cursor, click Connect, sign in on Bootstrapware, and Allow.
  3. Secret fallback: mint a test secret and paste the Keys-page mcp.json snippet if OAuth is unavailable.

Open Keys (Connect Cursor)

{
  "mcpServers": {
    "bootstrapware-feedback": {
      "type": "http",
      "url": "https://feedback.bootstrapware.co/mcp"
    }
  }
}

Statuses and toggles

Fixed statuses: open | planned | in_progress | shipped | declined.

Published config toggles: submissionsEnabled, votingEnabled, showVoteCounts, allowAnonymous, requireAuthorToken, emptyState.

Appearance

Optional presentation: section (default, inline), modal, window, or launcher. Modal, window, and launcher start closed unless defaultOpen. Escape closes them. The backdrop closes modal and launcher. Nudge the corner with --bsw-fb-frame-launcher-right and --bsw-fb-frame-launcher-bottom.

Optional theme ("light" | "dark"). Omit it and the board stays the light cream default: no data-theme, and no prefers-color-scheme or system theme. Hosts may set --bsw-fb-bg, --bsw-fb-panel, --bsw-fb-fg, --bsw-fb-muted, --bsw-fb-line, --bsw-fb-accent, --bsw-fb-accent-fg, and --bsw-fb-radius on the embed root (.bsw-fb; with theme="dark", .bsw-fb[data-theme="dark"]). Those overrides are the supported API. Do not fight .bsw-* classes. primaryColor still sets --bsw-fb-accent inline when it is set. className is layout only.

Security

  • Secret keys stay server-side.
  • Publishable key + host-asserted author.id can be forged if the key leaks; mitigate with origins and requireAuthorToken + secret-minted authorToken.
  • Plain text only. Customer moderates Hosted content.

Related: API overview · Quickstart · Modes · Cursor guide