# Betamaxx — Integration Guide (for your coding agent)

Betamaxx turns beta feedback into shipped fixes. This package is distributed
from the Betamaxx site (the source repo is private) — download it from
`https://www.betamaxx.dev/integration/betamaxx-integration.tar.gz` (§0.1). It
contains everything you need to integrate Betamaxx into your product:

| Component | What it is | Where |
|---|---|---|
| `widget/betamaxx.js` | Feedback capture widget for your frontend (text **and** voice) | `widget/` |
| `daemon/` | The daemon that runs on your dev machine and pulls tickets for your coding agent | `daemon/` |
| `pi-extension/` | The pi coding-agent extension (watch + evidence + resolution tools) | `pi-extension/` |
| `community/` | **Community Edition** — a free self-hosted single-tenant server (no clustering) | `community/` |

> All of these live in the **integration package** — see §0.1 for the exact
> download URL. Do not try to find them in a git repo; the source is private.

There are two ways to use Betamaxx:

1. **Hosted (default)** — your feedback flows to the Betamaxx platform at
   `https://www.betamaxx.dev`. You only integrate the widget + daemon +
   extension. No server to run.
2. **Community Edition (CE)** — you self-host a lightweight single-tenant
   server. Tell your agent you are using CE and its address.

---

## 0. What you received — the integration line (READ THIS FIRST)

Your product owner pasted ONE line into this session. It looks like this:

```
Betamaxx integration for project "Acme" (token:
bmx_acme_9fK3mPqR7sT2uVwXyZ0aBcDeFgHiJkLmN.4f7b2e9c1a3d5f8b6e0a2c4d8f1e3b5a7c9d0e2f4a6b8c1d3e5f7a9b0c2d4e6):
read https://www.betamaxx.dev/integration/README.md and follow it. Download the
pack from https://www.betamaxx.dev/integration/betamaxx-integration.tar.gz.
First walk the factory checklist (README §0.0) and report the gaps. Split the
token at the last dot — the prefix is the agent token (README §1). This
project: notifications = your app sends them (README §8 Mode 2); voice = the
Betamaxx.dev whisper bank (README §10).
```

That is the whole line on purpose: it carries the four things the README cannot
give you — **which project**, **the one token**, **where this README is**, **where
the pack is** — plus the two per-project settings that change what gets built,
each pointing at the section that spells it out. Everything else lives here.

**Everything you need is in that line.** Extract four things from it:

| What | Where in the line | Example |
|---|---|---|
| **Project name** | `project "..."` | `Acme` |
| **The ONE token** | `token: ...` | `bmx_acme_9fK3...4f7b...` |
| **The package download URL** | `Download the pack from ...` | `https://www.betamaxx.dev/integration/betamaxx-integration.tar.gz` |
| **Your situation** | the `This project: ...` clause | see the table below |

> **The project name is all you need.** The token identifies your project
> (its slug is embedded in the token, and the signing secret after the last
> dot belongs to it). The server-side signed token (§4) carries the slug
> derived from the project name — the server resolves the project from it. No
> 24-char project id is ever needed on your side.

### Your situation → which sections to follow

The line is **tailored to your project's settings**. Find your sentences and
follow exactly the matching path:

| The line says | You must also do |
|---|---|
| `notifications = Betamaxx sends them` | §8 Mode 3 (pubkey + `setUser` + house style + notify draft) |
| `notifications = your app sends them` | §8 Mode 2 (implement `/betamaxx/notify` and register it) |
| `notifications = never` | §8 Mode 1 — build nothing |
| `voice = disabled` | add `data-betamaxx-audio="off"` to the widget tag (§10) |
| `voice = your own sidecar` | §6a (deploy the sidecar + `data-betamaxx-stt-endpoint` + register it) |
| `voice = the Betamaxx.dev whisper bank` | nothing extra — but clear both audio attributes (§10) |
| `PO note: ...` | the product owner's own words — they outrank any default here |
| You are self-hosting **CE** | §7 instead of §4 (no signing — a static widget token) |

### Before STEP 0: is your agent a factory? (§0.0)

Betamaxx hands a ticket to **your** agent and marks it resolved on that agent's
word plus its tests. So before you install anything, check the agent you are
installing into against the requirements for an agentic software factory, and
say the answer out loud to the user. The full list is published on the site:

`https://www.betamaxx.dev/agentic-software-factory.html`

Do this, in order:

1. **Fetch that page** with your web fetch tool.
2. **Walk its 24-item checklist against your own environment**, item by item.
   For each one: can you actually do it, with which tool or rule, yes or no.
3. **Report to the user in plain words**: what you have, what is missing, what
   each gap costs them in practice (not "no browser verification" but "a UI
   ticket can be marked resolved while the page is still broken, and their
   users find it first"), and how to close each gap — what to install, what
   rule to add, and which gaps you can cover yourself during this integration.
4. **Then** go on to STEP 0, and say which steps you will carry out yourself.

**The default yes.** An agent that already runs a factory skill for the full
flow (intake until clear, captured requirements, a plan a human approves, a
test-first build, verification of the deployed artifact) and a diligence layer
that captures specifications and verifies them at task end, with web search and
page fetch tools available, is compliant by default. Say so in one line rather
than staying silent: a silent pass cannot be told apart from a skipped check.

**If the answer is no for the five Betamaxx cannot work around** (claim one
ticket at a time, test before or with the code, the whole suite green, evidence
instead of self-certification, and the end user's message written in the
product's own voice): stop and tell the user before integrating. Betamaxx will
still collect, understand and triage their feedback, but the execution half is
yours, and a ticket marked resolved that never went live is worse than one left
open.

### Get the integration package (STEP 0 — do this first)

Everything below assumes you have the package on disk. Download it from the
Betamaxx site (the source repo is private):

```bash
curl -LO https://www.betamaxx.dev/integration/betamaxx-integration.tar.gz
tar xzf betamaxx-integration.tar.gz
cd betamaxx-integration
```

The tarball contains `widget/`, `daemon/`, `pi-extension/`, `community/`,
`setup.sh` and this README. If you are reading this README on the site
(`/integration/README.md`), the package is at
`/integration/betamaxx-integration.tar.gz` right next to it. The current
version and sha256 checksum are in `/integration/VERSION.json` (the daemon
polls it to detect updates).

### The steps (overview)

**Before step 0:** run the factory check (§0.0) with the agent you are
installing into. It costs a minute, and it decides whether the rest of this
guide ends in shipped software or in a ticket nobody executes.

0. **Download the integration package** (§0.1) — `curl -LO …/betamaxx-integration.tar.gz`.
1. **Serve the widget files** (§2) — copy `widget/betamaxx.js` + `widget/betamaxx-widget.css` into your static assets.
2. **Embed the widget tag** (§3) — one `<script>` tag on your site.
3. **Add the server-side signed-token snippet** (§4) — your app server mints a short-lived token at page render. **This is the step agents get wrong — read §4 fully.**
4. **Start the daemon** (§5) — one process on the dev machine.
5. **Install the pi-extension** (§6) — gives the agent `bmx_*` tools.
6. **Verify everything** (§6) — each step has a check; do them in order.

---

## 1. The ONE secret — your token

There is exactly **one** secret in the whole integration. It is the token in
the line you received, and it is **two things in one string**:

```
bmx_acme_9fK3mPqR7sT2uVwXyZ0aBcDeFgHiJkLmN.4f7b2e9c1a3d5f8b6e0a2c4d8f1e3b5a7c9d0e2f4a6b8c1d3e5f7a9b0c2d4e6
└────────────── agent token ──────────────┘ └────────────── signing secret ──────────────┘
```

* The part **before the last dot** is the **agent token** — used by the
  daemon (§5), the pi-extension (§6) and any direct HTTP call
  (`X-Agent-Token` header). It never contains a dot.
* The part **after the last dot** is the **signing secret** — the HMAC key
  for the server-side signed-token snippet (§4). It is 64 hex characters.

### How to split it (copy one of these)

```bash
# bash — the token is in your .betamaxx/config.json (written by setup.sh)
TOKEN=$(node -e "console.log(require('./.betamaxx/config.json').token)")
AGENT_TOKEN="${TOKEN%.*}"        # everything before the LAST dot
SIGNING_SECRET="${TOKEN##*.}"   # everything after the LAST dot
```

```js
// Node.js
const [agentToken, signingSecret] = TOKEN.split('.');
```

```python
# Python
agent_token, signing_secret = TOKEN.rsplit('.', 1)
```

### Counterexamples — what NOT to do with the token

| ❌ Wrong | What breaks |
|---|---|
| Use the **whole token** as the signing secret | Every minted widget token fails with `401 invalid signature` — the HMAC key must be only the last-dot part |
| Use the **agent token** in `data-betamaxx-token` | You leak the long-lived secret into the browser. Anyone can read the page source and impersonate your project forever. Only a short-lived **signed** token goes in the widget tag (§4) |
| Split on the **first** dot | Same as the first row — wrong key, `401 invalid signature` |
| Lose the token | The daemon and the snippet both need it. It is also in `.betamaxx/config.json` (written by `setup.sh`) and re-viewable in the app (Settings → Integrate) |

---

## 2. Step 1 — serve the widget files

**Serve the widget files from your own origin.** Copy `widget/betamaxx.js`
**and** `widget/betamaxx-widget.css` into your static assets and serve them
same-origin (e.g. `/js/betamaxx.js` + `/js/betamaxx-widget.css`). The widget
injects its styles via an external `<link rel="stylesheet">` pointing at the
CSS file next to the script, so it works under strict CSP
(`style-src 'self'` — no `unsafe-inline` needed).

```bash
# from the integration package
cp widget/betamaxx.js        /your/static/dir/js/betamaxx.js
cp widget/betamaxx-widget.css /your/static/dir/js/betamaxx-widget.css
```

> **Do not** serve the widget from `betamaxx.dev`. It must be same-origin with
> your page (the stylesheet `<link>` is resolved next to the script, and
> same-origin is what makes strict CSP work).

**Adapt the CSS to your style.** `betamaxx-widget.css` is YOUR file — change
colors, radius, fonts, spacing to match your product. The widget also samples
your page's `--primary-color`/`--accent-color` and font-family automatically.
The **only requirement: keep the small bottom bar with the Betamaxx logo**
(the FAB / `data-betamaxx-logo`) so users always know where feedback goes.

---

## 3. Step 2 — embed the widget tag

One `<script>` tag on every page that should collect feedback. **Auth
(v0.24.82):** when the project notifies users directly (mode `direct`), serve
`data-betamaxx-project` + the project's public key and the widget mints its
OWN encrypted-timestamp token (RSA-OAEP with the public key — no host-side
secret, no fixed server-side timeout; the server decrypts with the private
key and rejects a stale/forged timestamp, not even saving the report).
Otherwise the app server issues a short-lived signed token at page render
(see §4) and injects it as the fallback.

> **No project id needed (v0.25.05).** The widget works with the signed token
> alone. Only direct mode needs `data-betamaxx-project` — get it from
> `GET /v1/agent/notify` (the same call that returns the pubkey; the response
> carries `project_id`). You never copy a project id out of the integration
> line — the project NAME is enough.

### 3a. Hosted, standard (notify mode `never` or `host`, voice = Betamaxx bank)

```html
<script
  data-betamaxx-endpoint="https://www.betamaxx.dev"
  data-betamaxx-token="<signed token from your app server — see §4>"
  data-betamaxx-user='{"id":"u1","name":"Ada","role":"admin"}'
  src="/js/betamaxx.js" defer></script>
```

| Attribute | Required? | What it is |
|---|---|---|
| `data-betamaxx-endpoint` | **Yes** | `https://www.betamaxx.dev` for hosted; your CE address for CE. No default — without it the widget posts to your own origin and fails |
| `data-betamaxx-token` | **Yes** (hosted) | The short-lived **signed** token minted by your app server (§4). Never a long-lived secret |
| `data-betamaxx-project` | Only direct mode | Your project id — from `GET /v1/agent/notify` (same call as the pubkey). Not needed otherwise |
| `data-betamaxx-user` | Optional | Static reporter identity. Your SPA usually logs in after mount — prefer `window.betamaxx.setUser({id,name,email,role})` then |
| `src` | **Yes** | Same-origin path to `betamaxx.js` (§2) |

### 3b. Hosted + direct notification (add the pubkey)

```html
<script
  data-betamaxx-endpoint="https://www.betamaxx.dev"
  data-betamaxx-token="<signed token from your app server — see §4>"
  data-betamaxx-project="<project id from GET /v1/agent/notify>"
  data-betamaxx-pubkey="-----BEGIN PUBLIC KEY----- ..."
  src="/js/betamaxx.js" defer></script>
<script>
  // after login — the widget encrypts the email with the project public key
  window.betamaxx.setUser({ id: user.id, name: user.name, email: user.email, role: user.role });
</script>
```

Serve the pubkey from your server the same way you serve the signed token
(`GET /v1/agent/notify` returns it — and `project_id`; it is public by
nature — the private key never leaves Betamaxx). See §8 Mode 3 for the full
path.

### 3c. Hosted + audio disabled

Add `data-betamaxx-audio="off"` — the mic is hidden and no voice path is
built:

```html
<script
  data-betamaxx-endpoint="https://www.betamaxx.dev"
  data-betamaxx-token="<signed token>"
  data-betamaxx-audio="off"
  src="/js/betamaxx.js" defer></script>
```

### 3d. Hosted + your own sidecar

Point the widget at your sidecar with `data-betamaxx-stt-endpoint` (see §6a):

```html
<script
  data-betamaxx-endpoint="https://www.betamaxx.dev"
  data-betamaxx-token="<signed token>"
  data-betamaxx-stt-endpoint="https://your-host.example.com/sidecar"
  src="/js/betamaxx.js" defer></script>
```

### 3e. Community Edition

For **CE**, point `data-betamaxx-endpoint` at your CE address and put your CE
widget token (printed on CE first boot, or `BMX_WIDGET_TOKEN`) in
`data-betamaxx-token` — **no signing, no snippet** (see §7):

```html
<script
  data-betamaxx-endpoint="http://localhost:8787"
  data-betamaxx-token="<your CE widget token>"
  src="/js/betamaxx.js" defer></script>
```

### 3f. Localization

The widget ships **English + the mechanism only** — it follows the page
language, but the translations are **provided by you, the integrating agent**,
following the instructions in this section. The widget never translates
itself.

**Language selection order** (first match wins):

1. `data-betamaxx-lang` on the script tag (explicit host override), or
   `window.betamaxx.setLang('nl')` at runtime (`setLang(null)` clears it);
2. the page's `<html lang="...">` attribute — what multilingual SPAs update
   when the user picks a language (the widget watches it and re-renders
   live);
3. `navigator.language`;
4. `'en'`.

Tags are normalized to a lowercase primary subtag (`EN-us` → `en`,
`nl-BE` → `nl`).

**Providing translations.** Two ways, both per-language dictionaries of the
same keys:

1. `data-betamaxx-strings` — a JSON object on the script tag (for
   server-rendered pages):

```html
<script
  data-betamaxx-endpoint="https://www.betamaxx.dev"
  data-betamaxx-token="<signed token>"
  data-betamaxx-lang="nl"
  data-betamaxx-strings='{"nl":{"fab":"Feedback","send":"Verstuur","placeholder":"Wat zit er in je hoofd? Een bug, een idee — alles."}}'
  src="/js/betamaxx.js" defer></script>
```

2. `window.betamaxx.setStrings('nl', { ... })` — for SPAs that load
   translations asynchronously (call it after the language pack arrives;
   missing keys fall back to English, then to the key itself).

**Keys.** Every user-facing string has a key. The full English dictionary is
`window.betamaxx.lang()` (current language) and the widget's built-in
`BMX_STRINGS_EN` (exported for Node tests). The main keys:

| Key | English default |
|---|---|
| `fab` | Share feedback |
| `placeholder` | What's on your mind? A bug, an idea, a suggestion — anything. |
| `helper` | Every report is read. Context helps us fix things faster. |
| `dictate` | Dictate while browsing |
| `recording` | Recording |
| `stopRecording` | Stop recording — tap the Betamaxx button |
| `dictChipRecording` | Recording {t} — tap the button to stop |
| `voice` | Voice |
| `reRecord` | Re-record voice feedback |
| `removeRec` | Remove voice recording |
| `noTranscript` | · no transcript (STT unavailable) |
| `noSpeech` | · no speech detected — try again |
| `transcribing` | Transcribing… |
| `cancel` | Cancel |
| `send` | Send feedback |
| `close` | Close |
| `poweredBy` | Powered by Betamaxx |
| `sending` | Sending… |
| `stillTrying` | Still trying… |
| `sent` | Sent ✓ (id {id}) |
| `sentShort` | Sent ✓ |
| `sendFailed` | Send failed — tap the button |
| `sendFailedRetry` | Failed to send — please retry. ({err}) |
| `thankYou` | Thank you for your feedback |
| `micPolicy` | Microphone disabled by this site — its Permissions-Policy blocks mic access… |
| `micPolicyTitle` | Microphone disabled by this site (Permissions-Policy) — only the site owner can enable it |
| `micNoDevice` | No microphone found — connect one and try again… |
| `micBusy` | Microphone unavailable — another app may be using it… |
| `micDenied` | Microphone blocked — allow mic access in your browser, then try again. |
| `micOther` | Microphone unavailable ({err}). You can still type your feedback. |
| `micUnavailable` | mic unavailable |
| `transcriptionUnavailable` | transcription unavailable |
| `voiceFeedbackTitle` | Voice feedback |
| `errorTitle` | Error: {msg} |
| `feedbackTitle` | Feedback: {body} |
| `testTitle` | Test: {body} |
| `pipelineCheck` | pipeline check |
| `uncaughtError` | uncaught error |
| `serverReturned` | server returned {status} |

`{var}` placeholders are interpolated at runtime (`{id}`, `{err}`, `{msg}`,
`{body}`, `{status}`, `{t}`).

**Verifying.** `window.betamaxx.lang()` returns the current language;
`window.betamaxx.setLang('fr')` forces one (useful for a language switcher).
The widget re-renders live when `<html lang>` changes.

### Counterexamples — widget tag mistakes

| ❌ Wrong | What breaks |
|---|---|
| `data-betamaxx-token="bmx_acme_..."` (the agent token) | The long-lived secret is now in public page source. Anyone can read it and mint reports as your project. **Only a signed short-lived token goes here** |
| No `data-betamaxx-endpoint` | The widget POSTs to your own origin → 404/405, feedback silently lost |
| `data-betamaxx-token` omitted (hosted) | Every report is rejected `401 invalid token` |
| `data-betamaxx-project` omitted in direct mode | The widget cannot mint its encrypted-timestamp token; contact encryption silently off (get it from `GET /v1/agent/notify`) |

**CSP `connect-src`.** The widget POSTs feedback to `data-betamaxx-endpoint`
(an external server for hosted, or your CE address). If your site has a CSP,
`connect-src` must include that endpoint — e.g.
`connect-src 'self' https://www.betamaxx.dev`. Without it the browser blocks
the POST (same as any external API call).

**`Permissions-Policy` and the microphone.** If you want voice feedback
(dictation), your site must not disable the mic for its own origin. The common
hardening snippet `Permissions-Policy: microphone=()` is an **empty allowlist**
— it blocks *self*, not just third parties — so `getUserMedia` is rejected before
the browser ever prompts. Use `microphone=(self)` instead:

```
Permissions-Policy: camera=(), microphone=(self), geolocation=()
```

This is worth getting right because the failure is misleading: the browser
rejects a policy block with `NotAllowedError` — the *same* error as a user
denying the prompt — so the mic looks "blocked by the user" while the page
setting is still "Ask" and no amount of clicking can fix it. The widget detects
this case (`document.featurePolicy.allowsFeature('microphone')`) and says the
site disabled it, but only you can actually enable it. If you serve the widget
in an `<iframe>`, the frame also needs `allow="microphone"`.

**Style injection modes** (`data-betamaxx-style-src`):
- `external` (default) — `<link>` to `betamaxx-widget.css` next to the script
- `inline` — inline `<style>` (needs `style-src 'unsafe-inline'` or a nonce)
- `none` — inject no styles (you style the widget yourself)

Programmatic API:

| Call | What it does |
|---|---|
| `window.betamaxx.setUser({id,name,email,role})` | Attach reporter identity to every later report. Your SPA usually logs in **after** the widget mounts — call this then. `setUser(null)` clears. |
| `window.betamaxx.setToken(token)` | Install a freshly minted signed token (see §4). Call this on a timer before the current token expires — no page reload needed. |
| `window.betamaxx.open()` / `.close()` | Open/close the feedback panel from your own UI. |
| `window.betamaxx.test()` | Send a TEST report through the whole pipeline (auto-removed, never shows in the inbox) — the fastest way to verify §4. |

Events (on `window`, `CustomEvent.detail`): `betamaxx:ready`,
`betamaxx:submitted`, `betamaxx:submitfailed`, `betamaxx:autoreport`,
`betamaxx:mic`, `betamaxx:micfailed`, `betamaxx:panelopened`.

---

## 4. Step 3 — server-side signed token (READ THIS FULLY)

**This is the one step agents get wrong, so it is explained to death here.**

### 4.1 Why you need it

The widget runs in the user's browser. Anything you put in the page — including
`data-betamaxx-token` — is readable by anyone. So the widget must never hold a
long-lived secret. Instead:

1. **Your app server** mints a **short-lived** token at page render (or on a
   refresh endpoint) using your project's **signing secret**.
2. The widget sends that token with every report (`x-bmx-token` header).
3. The Betamaxx server verifies the HMAC signature and the expiry, then
   accepts the report.

A leaked signed token expires on its own (30 minutes). A leaked signing
secret never expires — keep it server-side, always.

### 4.2 The exact wire format

A signed widget token is **exactly two dot-separated parts**:

```
<base64url(JSON payload)>.<base64url(HMAC-SHA256(signing_secret, payload))>
```

* **Part 1** — `base64url` of this JSON:
  ```json
  {"project":"acme","exp":1788815656127,"nonce":"x7Kp2q"}
  ```
  * `project` — the **slug** of your project, derived from the project NAME in
    the integration line (**required**): lowercase, non-alphanumerics become
    `-`, trim leading/trailing `-` (empty → `project`). `"Acme"` → `acme`;
    `"My App!"` → `my-app`.
  * `exp` — expiry as **milliseconds** since epoch (`Date.now() + ttl`) (**required**)
  * `nonce` — random string (recommended; keeps tokens unique)
* **Part 2** — `base64url` of `HMAC-SHA256(signing_secret, part1)` — the
  signature over the **exact bytes of part 1**.

> **No project id.** The server resolves your project from the slug (and
> verifies with your project's signing secret). Legacy tokens that carry
> `project_id` instead are still accepted.

> **base64url, not base64.** The server decodes with `base64url`. If you use
> standard base64 (`+`, `/`, `=`), the payload fails to decode →
> `401 invalid token payload`.

### 4.3 A real token, walked through

Say your signing secret is `4f7b2e9c1a3d5f8b6e0a2c4d8f1e3b5a7c9d0e2f4a6b8c1d3e5f7a9b0c2d4e6`
and your project name is `Acme` (slug `acme`):

1. Build the payload JSON: `{"project":"acme","exp":1788815656127,"nonce":"x7Kp2q"}`
2. `base64url` it → `eyJwcm9qZWN0IjoiYWNtZSIsImV4cCI6MTc4ODgxNTY1NjEyNywibm9uY2UiOiJ4N0twMnEifQ`
3. HMAC-SHA256 that string with the secret → `S4C5LnGeob0omZPpfgok4jZf7gmSQk96e9bGvyrW_o0`
4. The token is: `eyJwcm9qZWN0IjoiYWNtZSIsImV4cCI6MTc4ODgxNTY1NjEyNywibm9uY2UiOiJ4N0twMnEifQ.S4C5LnGeob0omZPpfgok4jZf7gmSQk96e9bGvyrW_o0`

### 4.4 How to generate it

**Node.js** (this is the canonical snippet — it is what the integration tests
run verbatim):

```js
// Node.js — sign a short-lived widget token at page render
import { createHmac } from 'node:crypto';

const AGENT_TOKEN = process.env.BETAMAXX_AGENT_TOKEN; // from the integration line (ONE secret)
const SECRET = AGENT_TOKEN.split('.').pop();          // signing secret — embedded in the token
const PROJECT_NAME = process.env.BETAMAXX_PROJECT_NAME; // from the integration line
const SLUG = (PROJECT_NAME || 'project').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
const TTL_MS = 30 * 60 * 1000;                        // 30 minutes

function widgetToken() {
  const payload = Buffer.from(JSON.stringify({
    project: SLUG,          // the server resolves your project from this
    exp: Date.now() + TTL_MS,   // MILLISECONDS since epoch — not seconds
    nonce: Math.random().toString(36).slice(2),
  })).toString('base64url');
  const sig = createHmac('sha256', SECRET).update(payload).digest('base64url');
  return `${payload}.${sig}`;   // exactly TWO parts, no prefix
}
// → inject into data-betamaxx-token
```

**Python** (equivalent):

```python
import base64, hashlib, hmac, json, re, secrets, time

AGENT_TOKEN = "bmx_acme_9fK3mPqR7sT2uVwXyZ0aBcDeFgHiJkLmN.4f7b2e9c1a3d5f8b6e0a2c4d8f1e3b5a7c9d0e2f4a6b8c1d3e5f7a9b0c2d4e6"
SECRET = AGENT_TOKEN.rsplit(".", 1)[1]   # part after the LAST dot
PROJECT_NAME = "Acme"                    # from the integration line
SLUG = re.sub(r"^-+|-+$", "", re.sub(r"[^a-z0-9]+", "-", PROJECT_NAME.lower())) or "project"

def widget_token():
    payload = base64.urlsafe_b64encode(json.dumps({
        "project": SLUG,
        "exp": int(time.time() * 1000) + 30 * 60 * 1000,  # MILLISECONDS
        "nonce": secrets.token_urlsafe(8),
    }).encode()).rstrip(b"=").decode()
    sig = base64.urlsafe_b64encode(
        hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).digest()
    ).rstrip(b"=").decode()
    return f"{payload}.{sig}"
```

### 4.5 Where to put it

1. **At page render** — inject the minted token into `data-betamaxx-token` in
   your server-rendered HTML (template placeholder, e.g. `<%= betamaxxToken %>`).
2. **Refresh before it expires** — tokens are short-lived by design. Expose a
   tiny authenticated endpoint that returns a fresh one and hand it to the
   widget — no page reload:

```js
// server: GET /api/betamaxx-token  (require your own session/auth!)
app.get('/api/betamaxx-token', requireLogin, (req, res) =>
  res.json({ token: widgetToken() })
);
```

```html
<!-- browser: refresh a few minutes before the 30-minute expiry -->
<script>
  setInterval(async () => {
    try {
      const r = await fetch('/api/betamaxx-token', { credentials: 'same-origin' });
      const { token } = await r.json();
      window.betamaxx.setToken(token);
    } catch (e) { /* keep the current token; next tick retries */ }
  }, 25 * 60 * 1000);
</script>
```

> **Betamaxx's own app does exactly this.** The platform serves its own
> feedback window with the same mechanism: the HTML carries a
> `__BMX_FEEDBACK_TOKEN__` placeholder and the web-server replaces it at serve
> time with `signWidgetToken(projectId, secret, 3600)` — the same function the
> README snippet mirrors. If you ever doubt the format, that is the reference.

### 4.6 How to TEST it (do this before wiring the UI)

**Test 1 — decode the payload locally.** The first part is just JSON in
base64url. If you cannot read your own payload, the server cannot either:

```bash
node -e "console.log(JSON.parse(Buffer.from(process.argv[1],'base64url')))" \
  eyJwcm9qZWN0IjoiYWNtZSIsImV4cCI6MTc4ODgxNTY1NjEyNywibm9uY2UiOiJ4N0twMnEifQ
# → { project: 'acme', exp: 1788815656127, nonce: 'x7Kp2q' }
```

**Test 2 — verify the signature locally.** Recompute the HMAC and compare:

```bash
node -e "
const { createHmac } = require('crypto');
const [p, s] = process.argv[1].split('.');
const expected = createHmac('sha256', process.argv[2]).update(p).digest('base64url');
console.log(expected === s ? 'SIGNATURE OK' : 'SIGNATURE MISMATCH');
" <token> <signing-secret>
```

**Test 3 — hit the real server.** POST a **test** report (`test:true` — it is
processed like any other message to prove the pipeline, but never shows in the
inbox and is auto-removed):

```bash
curl -s -X POST https://www.betamaxx.dev/v1/reports \
  -H "Content-Type: application/json" \
  -H "x-bmx-token: $WIDGET_TOKEN" \
  -d '{"body":"Integration test — please ignore","test":true}'
# 200 → {"success":true,"data":{"id":"...","duplicate":false}}
# 401 → {"error":"invalid signature"} (or one of the errors below)
```

### 4.7 What the server checks — and what each error means

The server verifies in this exact order. Each failure returns `401` with a
distinct error — **read the error, it tells you exactly what to fix**:

| # | Check | Failure → error |
|---|---|---|
| 1 | Token is a string containing `.` | `invalid token` |
| 2 | Splitting on `.` yields exactly `[payload, signature]` | `invalid token` (a `v1.` prefix or a third part does this) |
| 3 | Part 1 decodes as base64url JSON | `invalid token payload` (standard base64, not base64url, does this) |
| 4 | `payload.project` (slug) or `payload.project_id` (legacy) is a string | `invalid token payload` |
| 5 | The project resolves (by slug, or by id for legacy) and has a signing secret | `invalid signature` (unknown slug, or no secret) |
| 6 | `HMAC-SHA256(secret, part1)` equals part 2 (timing-safe) | `invalid signature` (wrong secret, or the whole token used as the key) |
| 7 | `Date.now() <= payload.exp` | `token expired` (seconds instead of milliseconds does this — born expired) |

### 4.8 Dos and don'ts

| ✅ DO | ❌ DON'T | What happens if you don't |
|---|---|---|
| `exp` in **milliseconds** (`Date.now() + ttl`) | `exp` in **seconds** (`Math.floor(Date.now()/1000)`) | `401 token expired` — the token is born expired |
| Exactly **two** parts | Prefix with `v1.` or add a third part | `401 invalid token` — the server splits on `.` and expects `[payload, signature]` |
| **base64url** encoding | Standard base64 (`+` `/` `=`) | `401 invalid token payload` |
| The correct project slug (derived from the project name) | Wrong or missing slug | `401 invalid signature` — the server resolves the project by slug, then verifies |
| Secret = part **after the last dot** | The whole token as the HMAC key | `401 invalid signature` |
| Secret stays **server-side** | Ship it in the widget tag or client JS | Anyone can mint valid tokens forever — this is the worst possible leak |
| Refresh before expiry (`setToken`) | One token for the whole session | Users hit `401 token expired` after 30 minutes |
| TTL ≈ 30 minutes | 5 minutes (constant re-minting) or 24 hours (defeats the purpose) | 5 min: users who pause mid-sentence get rejected on submit; 24 h: a leaked token is a permanent key |
| `nonce` random | Same payload every time | Works, but identical tokens are easier to replay; a random nonce is one line |

**Never** ship the signing secret to the browser — the widget only ever sees
a minted token, and a leaked token expires on its own. The signing secret
lives server-side, derived from the agent token (see §1).

**CE** needs no signing — use the CE widget token directly (a shared secret).

---

## 5. Step 4 — the daemon

The daemon runs on the developer's machine, connects OUT to the server, and
pulls open tickets for the coding agent to work on. It binds `127.0.0.1` only
and never listens on a public port. **One daemon per machine**, shared by
every pi session on it.

```bash
cd daemon
npm install
npm run build
```

The daemon and every pi session read the SAME config file —
`~/.pi/agent/betamaxx.conf` — which is the single source of truth. There are
NO environment variables. Create it from the shipped example:

```bash
mkdir -p ~/.pi/agent
cp daemon/betamaxx.conf.example ~/.pi/agent/betamaxx.conf
chmod 600 ~/.pi/agent/betamaxx.conf     # it holds tokens
```

Then edit it:

- `daemon.secret` → `openssl rand -hex 32` (gates every local IPC call)
- `servers[0].apiUrl` → your endpoint (hosted: `https://www.betamaxx.dev`)
- `servers[0].token` → the **agent token** from your `.betamaxx/config.json`
  (written by `setup.sh`) — the part before the last dot, NOT the whole token
- `sessions[0].cwd` → the absolute path of the project the agent works in,
  and `globs` → the paths the agent may write to

Start it:

```bash
node daemon/dist/index.js
# or install it as a service — see deploy/betamaxx-daemon.service in the repo
curl http://127.0.0.1:8901/v1/healthz    # → {"ok":true,...}
```

Note: the FIRST pi session that calls the daemon becomes the **primary
executor** — the only session whose watcher auto-claims open tickets on idle.
Every other session pulls tickets explicitly with the tools below.

---

## 6. Step 5 — the pi-extension, and Step 6 — verify everything

The pi extension gives your coding agent the tools to list/claim tickets
(`bmx_list_tickets`, `bmx_claim`), fetch behavior-trail evidence
(`bmx_get_evidence`), and submit resolutions (`bmx_submit_resolution`).

It is a **TypeScript pi extension — there is no build step**; pi loads
`src/index.ts` directly via the `pi.extensions` field in its `package.json`.
Install it where pi looks for extensions:

```bash
mkdir -p ~/.pi/agent/extensions
cp -r pi-extension ~/.pi/agent/extensions/betamaxx
cd ~/.pi/agent/extensions/betamaxx && npm install
```

The extension reads the same `~/.pi/agent/betamaxx.conf` you wrote in step 4 —
no separate configuration, no env vars. Restart pi and the `bmx_*` tools
appear. If they do not, the config file is the first thing to check
(`chmod 600`, valid JSON, matching `daemon.secret`).

### Step 6 — verify everything, in order

| # | Check | How |
|---|---|---|
| 1 | Widget files load | Open your page, open devtools → Network → `betamaxx.js` and `betamaxx-widget.css` return 200 from YOUR origin |
| 2 | Signed token works | §4.6 Test 3 — `curl` a test report → `200` |
| 3 | Widget end-to-end | On your page, open the feedback panel, send a test message — `betamaxx:submitted` fires; or `window.betamaxx.test()` |
| 4 | Daemon up | `curl http://127.0.0.1:8901/v1/healthz` → `{"ok":true,...}` |
| 5 | Agent tools | Restart pi → `bmx_list_tickets` returns the ticket list (empty is fine) |
| 6 | Your situation | §8 (notifications) and §6a/§3c (voice) per your line |

---

## 6a. Voice on your own sidecar (transcription mode: your own sidecar)

When the project's transcription mode is **Your own sidecar**, the widget
uploads audio and transcribes on YOUR endpoint — Betamaxx.dev never sees the
recording. Implement two routes (same contract as Betamaxx.dev's):

| Endpoint | Contract |
|---|---|
| `POST /v1/audio` (`Content-Type: audio/wav`, `x-bmx-token: <widget token>`) | Store the clip → `201 {audio_url}` (any URL your transcriber can fetch) |
| `POST /v1/transcribe` `{audio_url, language?}` | → `200 {transcript, segments}` where `transcript` is a string (`""` = no speech heard) and `segments` is `[{start, end, text}]` |

Then point the widget at it:

```html
<script src="https://www.betamaxx.dev/widget/betamaxx.js"
        data-betamaxx-token="..."
        data-betamaxx-stt-endpoint="https://your-host.example.com/sidecar" defer></script>
```

A minimal reference implementation (faster-whisper, CPU) is what Betamaxx.dev
runs for its own bank: a small HTTP service that fetches `audio_url`, runs
whisper, and returns `{transcript, segments}`. Transcription is **server-side
only** (v0.25.01): the widget always uploads the clip to the server — there is
no in-browser engine.

Then **register the sidecar yourself** so the project records what it is
pointing at (the product owner never types a URL):

```bash
curl -sX POST https://www.betamaxx.dev/v1/agent/stt/endpoint \
  -H "x-bmx-agent-token: <your agent token>" -H "content-type: application/json" \
  -d '{"endpoint":"https://your-host.example.com/sidecar"}'
```

Send `{"endpoint":""}` to say this project uses the Betamaxx.dev bank again —
see §10 for what each transcription mode expects on the widget tag.

---

## 7. Community Edition (self-host)

If you want to self-host instead of using the hosted platform, tell your
agent: *"I am using Betamaxx Community Edition at <address>."* Then:

```bash
# from the integration package
cd community
npm install
BMX_PO_EMAIL=you@example.com BMX_PO_PASSWORD=secret npm start
```

- Admin console: `http://<address>/admin`
- Widget token: printed on first boot (or set `BMX_WIDGET_TOKEN`)
- Agent token: printed on first boot (or set `BMX_AGENT_TOKEN`)
- CE has **no clustering** (each report becomes a ticket 1:1). To plug in
  your own grouping, set `BMX_CLUSTERER` to a module exporting
  `cluster(reports) → [{title, report_ids}]` (see `community/src/clustering-stub.js`).

**CE differences from hosted:**

| | Hosted | CE |
|---|---|---|
| Widget token | Short-lived **signed** token (§4) | Static CE widget token (a shared secret) |
| Server-side snippet | **Required** | Not needed |
| Endpoint | `https://www.betamaxx.dev` | Your CE address |
| Clustering | Yes | No (1 report = 1 ticket) |

---

## 8. User notifications (task completion + plan communications)

When a ticket is resolved, the people who reported it can be told. Same for
plan rounds and plan invites. **How** that happens is a per-project choice the
product owner makes in **Settings → User notifications** in the Betamaxx app.
Ask which mode is set (or read `GET /v1/agent/notify` with your project token)
and build only that one.

### Mode 1 — Never notify

Nothing to build. Betamaxx never contacts your users; the product owner shares
plan invites and resolution news manually.

### Mode 2 — Your app notifies (recommended when you already have a channel)

Betamaxx POSTs the **facts** (and, where it already has them, the **words** —
see `mail` below) to an endpoint you own, and **your app writes and
delivers the message** — your wording, your template, your channel (email,
in-app inbox, chat). No user email address ever leaves your systems.

Betamaxx dictates the path: implement the endpoint at `/betamaxx/notify` on
your own origin, then **register the full URL yourself** with your project
token; the product owner never types it (Settings only displays it):

```bash
curl -sX POST https://www.betamaxx.dev/v1/agent/notify/endpoint \
  -H "x-bmx-agent-token: <your agent token>" \
  -H "content-type: application/json" \
  -d '{"endpoint":"https://your-app.example.com/betamaxx/notify"}'
```

Implement the endpoint:

```js
// POST https://your-app.example.com/betamaxx/notify
import crypto from "node:crypto";

const SECRET = process.env.BETAMAXX_NOTIFY_SECRET; // Settings → User notifications

app.post("/betamaxx/notify", express.raw({ type: "application/json" }), (req, res) => {
  // 1. Only Betamaxx may call this. Add betamaxx.dev's egress IP to your
  //    firewall/allowlist as well — the signature is the second lock, not the first.
  // 2. Verify the signature over the RAW body, timing-safely.
  const sent = Buffer.from(String(req.get("x-betamaxx-signature") || ""));
  const mine = Buffer.from("sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"));
  if (sent.length !== mine.length || !crypto.timingSafeEqual(sent, mine)) return res.sendStatus(401);

  const { event, project_id, payload } = JSON.parse(req.body.toString("utf8"));
  switch (event) {
    case "resolution":
      // payload: { ticket_id, title, resolution, pr_url, resolved_at, area,
      //            cluster_id, users: [{ report_id, user_id, user_name,
      //                                  user_role, report_text }],
      //            mail: { subject?, body?, guidance?, signature?, standard } }
      // NOTE: no email addresses — YOU own your users, look them up by user_id.
      // `mail` is the WORDS (see "Who writes the words" below); the rest is facts.
      break;
    case "plan_round":
      // payload: { plan_id, title, iteration, document_md, triggered_by,
      //            recipients: [{ email }] }
      break;
    case "plan_invite":
      // payload: { plan_id, title, invitee_email, passcode, plan_url, triggered_by }
      break;
  }
  res.sendStatus(200); // respond fast; do the delivery out of band
});
```

Contract details:

- Headers: `x-betamaxx-signature` (`sha256=<hex>`), `x-betamaxx-event`,
  `x-betamaxx-project`.
- Timeout 10s. On failure Betamaxx retries **once, after 5 seconds**, then gives
  up and records the failure in the audit trail. Make the handler idempotent.
- Respond `2xx` as soon as you have the payload.
- **You decide the formatting and the content.** The message that reaches your
  user is yours — your channel, your template, your wording.

**Who writes the words** (v0.25.307, ticket 6ac4df92). Every event that carries
prose for a human also carries a `mail` object beside the facts:

| key | what it is | present when |
|---|---|---|
| `mail.subject` | the subject line Betamaxx's execution agent wrote for this reader | the agent wrote both a subject and a body |
| `mail.body` | the body it wrote — the reader's own words, what changed for them | same |
| `mail.guidance` | **your own** writing guidance, as you submitted it in Settings → User notifications | you set one |
| `mail.signature` | your sign-off line (e.g. `— The Acme team`) | you set one |
| `mail.standard` | the house standard every Betamaxx writer is held to (name the reader, no ticket ids or internal jargon, no filler gratitude, two to five short sentences, no markdown, sign with the project's name) | always |

It is **advisory**: use `mail.subject`/`mail.body` as the message, rephrase them
with your own model, or ignore them — the choice stays yours (that is the point
of Mode 2). What changed is that Betamaxx no longer throws the prose away: the
agent already wrote it to the house standard when the ticket was resolved, and
`resolution` is the **internal technical text** (what changed in the code), so
writing user mail from `title` + `resolution` alone tends to leak internals or
degenerate into a canned "thank you for your feedback". If you generate the
body yourself, follow `mail.guidance` (yours) and `mail.standard` (ours) — and
say nothing when there is nothing true to say: Betamaxx sends no mail at all
rather than a robotic one.

### Mode 3 — Betamaxx notifies directly

Betamaxx emails the user itself. Requires the extended privacy agreement
(accepted in Settings). Three things to build:

**1. Encrypt the contact in the browser.** Add the project's public key to the
widget tag — Betamaxx then never receives an address in plaintext:

```html
<script src="https://www.betamaxx.dev/widget/betamaxx.js"
        data-betamaxx-token="<%= betamaxxToken %>"
        data-betamaxx-pubkey="<%= betamaxxPubkey %>"
        defer></script>
<script>
  // after login — the widget encrypts the email with the project public key
  window.betamaxx.setUser({ id: user.id, name: user.name, email: user.email, role: user.role });
</script>
```

Serve the pubkey from your server the same way you serve the signed token
(`GET /v1/agent/notify` returns it; it is public by nature — the private key
never leaves Betamaxx).

**2. Submit your house style — your brand identity pack** so the mail and your
project space represent *you*, not Betamaxx:

```bash
# your logo, as base64, straight from the file you already ship
curl -sX POST https://www.betamaxx.dev/v1/agent/notify/style \
  -H "X-Agent-Token: $BMX_AGENT_TOKEN" -H "Content-Type: application/json" \
  -d "$(jq -n --arg logo "$(base64 -w0 < public/logo.png)" '{
        brand_name:   "Acme",
        primary_color:"#1f6feb",
        accent_color: "#b0217e",
        font_family:  "Georgia, serif",
        signature:    "— The Acme team",
        guidance:     "Warm and concise, in Dutch. Thank the reporter and say what is live now.",
        logo_base64:  $logo }')"
```

The colors + font theme the project space (member app + planning page) and
are used as the mail's brand header; `guidance` is the free-form voice note
your agent writes in (tone, language, an example). The legacy text fields
(`tone`, `language`, `sample`) are still accepted.

**Send the logo as bytes, not as a link to your site.** The pack takes
`logo_base64` — the base64 of your PNG, JPEG, GIF, WebP or SVG — and Betamaxx
stores it and serves it from its own origin. That is deliberate: the member app
only loads images from its own origin (`img-src 'self' data: blob:`), so a URL
into your site would be blocked and you would see nothing; and Betamaxx does
not fetch URLs you hand it. What your logo *is* is decided by the bytes, never
by the name or the declared type, and it is capped at 512 KB — it is a mark,
not a photograph. A bad one comes back as an error you can fix, not a silent
no-op.

With a logo in the pack, Betamaxx shows it where your project appears: on your
card in the project list, next to the project name in the member app and the
planning page, and in the house-style summary in Settings. Send it again
whenever the brand changes — the same call replaces the previous file (this is
part of your periodic "keep the Betamaxx integration current" run, alongside
re-checking the widget files and the daemon).

**3. Write the message when you resolve the ticket.** Your service agent knows
what was fixed and how the house speaks, so *it* writes the user-facing text and
submits it with the resolution:

```json
POST /v1/agent/tickets/<id>/resolution
{
  "status": "resolved",
  "pr_url": "https://github.com/acme/app/pull/812",
  "summary": "Fixed the crash on the ideas page",
  "notify": {
    "subject": "Your report about the ideas page — fixed",
    "body": "Hi Ada,\n\nYou told us the ideas page went blank ...\n\n— The Acme team"
  }
}
```

> **No draft, no mail.** There is no template fallback: if your agent does not
> write the message, Betamaxx sends nothing. A generic robot mail in your name
> is worse than silence.

### What Betamaxx does with contact data (all modes)

- Email addresses are **never stored unencrypted** — only a SHA-256 hash (for
  GDPR erase) and, in mode 3, a blob encrypted with your project's public key.
- The plaintext exists **in memory only**, at the moment of sending.
- The blob is **scrubbed** after sending and at every terminal point: report
  deleted / ignored / archived, ticket resolved / canceled / closed / ignored,
  GDPR erase.
- Sent emails are **never saved**. The audit trail records counts and hashes.
- Per-project send cap (default 50/hour) and a per-visitor report cap (default
  60/hour, over which the visitor is flagged as likely abuse) — both editable in
  Settings.

---

## 9. Migrating from the OLD self-hosted betamaxx widget

If you currently run the **old** self-hosted betamaxx server and its widget
(the one configured with `data-betamaxx-key` / `INGEST_KEY`), migrate in
**exactly two steps — in this order**:

1. **FULLY REMOVE the old widget.** Delete the old `<script>` tag (the one
   with `data-betamaxx-key="..."`) and any `window.betamaxx` usage your
   frontend relied on. Do not keep it around, do not run both widgets.
2. **Integrate the new widget per the standard instructions above** (§2
   frontend, §4 signed token). Point it at the hosted platform or your CE.

There is **no dual-running and no compatibility shim** — the old ingest
protocol and the new signed-token protocol are different and must not
coexist. Old feedback history stays in your old server (export it if you
need it); new feedback flows to the new endpoint.

---

## 10. Reference

### Voice (transcription modes)

The widget records voice and turns it into text. **Transcription is
server-side only** (v0.25.01) — there is no in-browser engine. **Which
pipeline runs is a per-project setting** (Settings → Voice transcription):

- **Betamaxx.dev server** (default) — audio is sent to Betamaxx.dev and
  transcribed by the whisper bank there. Requires the **extended data
  agreement** (accepted in Settings).
- **Your own sidecar** — see §6a. The widget uploads the clip to YOUR
  sidecar; no audio reaches Betamaxx.dev.
- **No audio** — `data-betamaxx-audio="off"` on the widget tag; the mic is
  hidden. Do not build any voice/dictation path.

**Tell Betamaxx which one you are on** (the product owner never types it — your
agent reports it with its own token):

```bash
curl -sX POST https://www.betamaxx.dev/v1/agent/stt/endpoint \
  -H "x-bmx-agent-token: <your agent token>" -H "content-type: application/json" \
  -d '{"endpoint":""}'          # or the sidecar URL in sidecar mode
```

The widget tag must match the mode, and the two leftovers are the usual
suspects when the mic is missing or the clip goes nowhere:

| Mode | The tag must carry |
|---|---|
| Betamaxx.dev bank | **neither** `data-betamaxx-audio="off"` **nor** `data-betamaxx-stt-endpoint` — a leftover `off` hides the mic, a leftover endpoint points at a dead sidecar |
| Your own sidecar | `data-betamaxx-stt-endpoint="<your sidecar>"` (§6a) |
| No audio | `data-betamaxx-audio="off"` |

Because there is no CDN-loaded engine, a strict CSP (`script-src 'self'`)
needs **no extra allowances** — the widget only talks to the server
(`connect-src 'self'` plus your sidecar origin in sidecar mode). The server
must have STT configured; check `GET /v1/health` → `{"stt_ok":true}`.

If transcription is unavailable the mic still records and the report is still
sent — it just carries `"no transcript (STT unavailable)"` instead of text.

Voice endpoints used by the widget (all authenticated with the same
`x-bmx-token`; in sidecar mode the widget calls YOUR sidecar, not
Betamaxx.dev — see §6a):

| Endpoint | Purpose |
|---|---|
| `POST /v1/audio` (`Content-Type: audio/wav`) | Upload the recorded clip → `201 {audio_url}` |
| `POST /v1/transcribe` `{audio_url, language?}` | → `200 {transcript, segments}`; `501` when no STT sidecar is configured; `403` when the project's mode is `none` or `sidecar` |
| `POST /v1/reports/:id/transcript` | Attach the transcript to an already-sent voice report |

Voice reports are sent **immediately** with a pending marker and the
transcript is attached when it lands — the user never waits. Recorded audio
is never downloadable over the public interface, and the WAV is **deleted as
soon as a transcript exists** (transcript-only retention).

### How feedback flows

```
widget (your site) ──POST /v1/reports──▶ Betamaxx (hosted or CE)
                                              │
                              reports → tickets (CE: 1:1, no clustering)
                                              │
        daemon/pi-extension ◀──GET /v1/agent/tickets──┘
        (coding agent claims, reads evidence, resolves)
```

### The `.betamaxx/config.json`

`setup.sh` writes `.betamaxx/config.json` in your project dir:

```json
{
  "endpoint": "https://www.betamaxx.dev",
  "token": "bmx_...",
  "mode": "hosted"
}
```

Your agent should read this file to know where to send/read Betamaxx traffic.
The `token` here is the **whole** integration token (agent token + signing
secret) — split it per §1 before use.
