Agent integration rules

Paste this into AGENTS.md, Cursor rules, or your agent system prompt when adding Bootstrapware RFQ. The same file ships in the package tarball.

# Integrating @bootstrapware/rfq

Use this file as agent instructions. The React package and the Hosted RFQ service are separate. This file is the install contract: widget surfaces, keys, MCP, billing, and the dashboard install path.

`@bootstrapware/rfq` is published to npm. The Cursor Marketplace listing is separate and is not implied by this file.

## What this package does

Embeddable buyer–supplier requests for quote. Three surfaces share one shell:

- `RfqRequester`: buyer drafts, invitations, and decisions
- `RfqSupplier`: supplier quotes and clarifications
- `RfqComparison`: buyer comparison of quotes

`Rfq` is the same shell with an explicit `surface` of `"requester"`, `"supplier"`, or `"comparison"`. `RfqRequester`, `RfqSupplier`, and `RfqComparison` omit `surface`.

`rfqDraftKey(actorId, rfqId, field)` is `bsw-rfq:${actorId}:${rfqId}:${field}`. Composer drafts use that key in `sessionStorage`.

`createLocalAdapter({ storageKey })` is a browser demo with the same transitions and no production security claim. `createByoAdapter(impl)` returns the host implementation unchanged. `createHostedAdapter` talks to the RFQ HTTP API.

**Free:** local adapter and test keys. **BYO ($9.99):** live app config; records stay on the customer backend. **Hosted ($19.99):** live app config, and Bootstrapware stores the RFQ records. Test keys work unpaid. Live config fetch needs BYO or Hosted. Live Hosted writes need Hosted.

Stack discount is company-wide: 25% off the second paid product’s list, 35% off the third and later. There is no RFQ storage add-on, seat charge, or per-quote price. Stripe price env names are `STRIPE_PRICE_RFQ_BYO` and `STRIPE_PRICE_RFQ_HOSTED`. Do not invent Stripe price IDs.

States run draft, then open, then closed, then selected. `cancelled` is terminal. `archived` is a boolean flag, not a state. Selection records intent only. It is not a purchase order, contract, or payment.

Attachment bytes stay on the host. Pass a reference only. Hosted storage is capped at 1 GiB per environment. Attachment bytes do not count toward that cap.

Author tokens use the prefix `bsw_rfqauth_v1.` and the header `x-bootstrapware-author-token`. The widget never mints a token. The host passes `renewAuthorToken`.

Publishable env name: `NEXT_PUBLIC_BSW_RFQ_PUBLISHABLE_KEY`.

Optional `theme` is `"light"` or `"dark"`. Omit it and the shell stays the light cream default: no `data-theme`, and no `prefers-color-scheme`. Hosts may set `--bsw-rfq-bg`, `--bsw-rfq-panel`, `--bsw-rfq-fg`, `--bsw-rfq-muted`, `--bsw-rfq-line`, `--bsw-rfq-accent`, `--bsw-rfq-accent-fg`, and `--bsw-rfq-radius` on `.bsw-rfq`.

## Install algorithm (do this in order)

1. Read this file. Call `list_rfq_capabilities` on RFQ 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 `actor.id`. Wire the real session id (`session.user.id`). The widget prop is `actor`, not a stand-in id.
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_rfq_test_publishable`.

### Path A: local, zero keys

Peer dependencies: `react` and `react-dom` (>= 18).

```bash
pnpm add @bootstrapware/rfq
```

```tsx
"use client";

import { RfqRequester, createLocalAdapter } from "@bootstrapware/rfq";
import "@bootstrapware/rfq/styles.css";

<RfqRequester
  scope={{ appId: "rqa_local", tenantKey: workspace.id }}
  actor={{ id: session.user.id, permissions: ["buyer_read", "buyer_edit"] }}
  adapter={createLocalAdapter({ storageKey: "demo-rfq" })}
/>
```

`RfqSupplier` and `RfqComparison` use the same props with `surface` omitted.

```tsx
import { createLocalAdapter } from "@bootstrapware/rfq";
import "@bootstrapware/rfq/styles.css";

const adapter = createLocalAdapter({ storageKey: "demo-rfq" });
```

Saved JSON that cannot be read shows: `Saved RFQs could not be read. Showing an empty in-memory RFQ.`

Local mode stays in the browser.

### Path B: Hosted

1. Confirm Cursor MCP `bootstrapware-rfq` at `https://rfq.bootstrapware.co/mcp` (OAuth URL-only). If disconnected, tell the human to click **Add to Cursor (OAuth)** on https://app.bootstrapware.co/rfq/keys
2. `list_rfq_capabilities` → `create_rfq_app` → `update_rfq_draft` (`allowedOrigins` for localhost and production; `name` is optional) → `publish_rfq_app`
3. `ensure_rfq_test_publishable`. Put `envLine` in `.env.local`. The full test publishable is returned every time. The env name is `NEXT_PUBLIC_BSW_RFQ_PUBLISHABLE_KEY`.
4. `get_rfq_install_snippet`. `appId` is real. Use `process.env.NEXT_PUBLIC_BSW_RFQ_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. Mount `RfqRequester`, `RfqSupplier`, and `RfqComparison` with the same scope, actor, and adapter. Omit `surface` on those three.

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

1. Call `update_rfq_draft` with `allowedOrigins` for localhost and production. Name is optional. Config may set preset, currencies, units, custom questions, labels, theme, and comparison columns.
2. Call `publish_rfq_app`.
3. Call `ensure_rfq_test_publishable` to get or create a test publishable key.
4. Call `get_rfq_install_snippet`, then embed `RfqRequester`, `RfqSupplier`, or `RfqComparison` with the real session actor id.

`publish_rfq_app` returns:

1. Call `ensure_rfq_test_publishable` to get or create a test publishable key.
2. Call `get_rfq_install_snippet` and wire `RfqRequester`, `RfqSupplier`, or `RfqComparison` with the real session actor id.

Publishable keys may appear in the browser. Secret keys stay on your server.

```tsx
import { RfqComparison, RfqRequester, RfqSupplier, createHostedAdapter } from "@bootstrapware/rfq";
import "@bootstrapware/rfq/styles.css";

const adapter = createHostedAdapter({
  appId: "rqa_...",
  publishableKey: process.env.NEXT_PUBLIC_BSW_RFQ_PUBLISHABLE_KEY!,
  scope: { tenantKey: workspace.id },
  authorToken,
});

<RfqRequester
  scope={{ appId: "rqa_...", tenantKey: workspace.id }}
  actor={{ id: session.user.id, permissions: ["buyer_read", "buyer_edit"] }}
  adapter={adapter}
/>
```

`RfqSupplier` and `RfqComparison` take the same `scope`, `actor`, and `adapter`. Optional `apiBaseUrl` defaults to `https://rfq.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.

### Path C: BYO

Same MCP app config as Path B. Implement `RfqAdapter` on the customer backend, or wrap one with `createByoAdapter`. RFQ records never go to Bootstrapware. A Postgres reference lives in `examples/rfq-nextjs`. The same server-only author-token route applies when the host mints tokens.

## Server-only Next.js author token

The host session is the authority. Ignore `authorId`, `permissions`, and `supplierId` if the browser sends them. Look up the signed-in user and copy permissions from that server record. A supplier token includes `supplierId` from that record. Buyer and supplier permissions cannot be mixed. The secret stays in server env (`BSW_RFQ_SECRET`), never `NEXT_PUBLIC_*`.

`BSW_RFQ_SECRET` is the dashboard secret. Paste the full `bsw_test_sec_…` or `bsw_live_sec_…` value into the server env. Do not commit it.

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

const RFQ_APP_ID = process.env.RFQ_APP_ID ?? "";

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 tenantKey = String(body.tenantKey ?? "");
  const access = await rfqAccess(user.id, tenantKey);
  if (!access) {
    return NextResponse.json({ error: "The requested resource was not found." }, { status: 404 });
  }

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

Buyer permissions are `buyer_read`, `buyer_edit`, `buyer_decide`, and `buyer_export`. Supplier permissions are `supplier_read`, `supplier_quote`, and `supplier_clarify`. Pass those strings on `actor.permissions` for the local adapter. They do not grant Hosted or BYO access. The server session and, when required, the author token are the authority. The widget does not hide buttons from a client-supplied permissions list.

The token is bound to workspace, environment, app, and tenant. A supplier token is also bound to `supplierId`. 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://rfq.bootstrapware.co/mcp`

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

**Fallback:** test secret as `Authorization: Bearer`. Live and secret keys stay on the dashboard. MCP returns a test publishable key only from `ensure_rfq_test_publishable`.

Tools:

- `list_rfq_apps`
- `get_rfq_app`
- `create_rfq_app`
- `update_rfq_draft` (does not publish; `name` optional)
- `publish_rfq_app`
- `get_rfq_published_config`
- `get_rfq_install_snippet`
- `ensure_rfq_test_publishable`
- `list_rfq_capabilities`

MCP configures apps only. It does not list RFQs, submit quotes, or accept prices, supplier lists, or file bytes.

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

`update_rfq_draft` `config` accepts only `preset`, `currencies`, `units`, `questions`, `labels`, `theme`, `comparisonColumns`, `requireAuthorToken`, and `allowedOrigins`. Questions are schema (key, label, kind, options), not answers. To clear theme, send `theme: null`. Do not pass RFQ titles, summaries, quotes, prices, supplier ids, lines, or attachment bytes.

## Dashboard

- Overview: https://app.bootstrapware.co/rfq
- Apps, draft, publish, and restore: https://app.bootstrapware.co/rfq/apps
- Keys: https://app.bootstrapware.co/rfq/keys
- Webhooks and sessions are on that same product nav

Public product pages: https://bootstrapware.co/rfq. Dashboard: https://app.bootstrapware.co/rfq. Live Hosted API: `https://rfq.bootstrapware.co`. The Cursor Marketplace listing is separate and is not implied by this file.

## Pricing and retention

- Free: local mode and test keys
- BYO $9.99: live config; you store RFQ records
- Hosted $19.99: we store RFQ records, 1 GiB per environment, attachment bytes excluded
- Cancel Hosted: writes freeze immediately. Dashboard JSON export for 30 days, then Hosted RFQ records 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 RFQ titles, summaries, quotes, prices, supplier lists, 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.
- `actor.id` is the actor. The author token is the grant.
- Your server mints and renews `authorToken`. The widget never calls the secret-key author-tokens API.

Related: Cursor guide · Quickstart · API overview