---
name: newsroom-floor
description: Build and run a white-label AI newsroom on Newsroom Floor. Install the DRM3 SDK, get an admin key, create a desk, set its sections, personas and automation, run the line, read the paper, verify each story's signed filing receipt, and set up a private Regulatory Signal Desk over one client's own record. Use when a user wants to stand up, configure, or operate a Newsroom Floor newsroom or signal desk over the API or MCP.
---

# Build a newsroom on Newsroom Floor

Newsroom Floor is a white-label AI newsroom on a signed record of facts. An agent with an admin key
can do everything the console does: start a paper, hire its writers, set its beats, run the line, and
read the signed result. This skill takes you from a fresh key to a running paper.

## The laws (they are enforced, not advice)

- Every byline is a **declared AI persona**. Say so wherever you render a story.
- A story stands on **two or more separate sources**, or it does not print. `min_sources` never drops below 2.
- Your **plan caps** the properties, topics, and articles a day. A write past a cap returns the number and the way up (HTTP 402).
- A **private paper** (`paper_public: false`, the default) is served only through the API and the tenant's own site, never the open web.
- Every publish carries an **Ed25519 filing receipt**. A signature proves attribution and integrity. It is added trust, not a claim that the story is correct. Never say "no trust required".
- `run_line` is **async**. A reply of "published so far, more still working" is normal, not a failure.

## Prereqs

- Node 20+.
- A DRM3 account and a Newsroom Floor desk. Create the first desk in the console at https://newsroomfloor.com.
- An **admin key** for that desk. Mint it under Desk settings (check "Admin key"). It is shown once. Store it as `NRF_KEY`.

## Install the SDK (optional, for identity + receipt checks)

The data path is plain `fetch`, so the SDK is not required to build a newsroom. It is useful for
verifying receipts and for the `drm3` CLI.

```bash
npm install @drm3/sdk
npx drm3 --help
```

## Connect the MCP

```bash
claude mcp add --transport http newsroom-floor https://newsroomfloor.com/mcp \
  --header "Authorization: Bearer $NRF_KEY"
```

The descriptor is at https://newsroomfloor.com/.well-known/mcp.json. The tools are `get_plan`,
`get_config`, `set_config`, `add_section`, `remove_section`, `add_persona`, `set_persona`,
`delete_persona`, `run_line`, `create_desk`, `set_public`, `list_stories`, `get_story`, `get_proof`,
`publish_story`, `unpublish_story`, `schedule_story`, `list_subscribers`, `add_subscribers`,
`get_template`, `set_template`, `search_record`,
`list_folders`, `read_folder`, `create_folder`, `update_folder`, `add_to_folder`, `move_folder_item`,
`remove_folder_item`, `assign_folder`, `list_feeds`, `add_feed`, `remove_feed`,
`get_signal_desk`, `set_signal_desk`, `disconnect_source`, `signal_access`,
`invite_reader`, `remove_reader`, `send_digest`, `digest_subscribers`, `send_test_mail`, `build_signals`,
`signal_build_status`, `desk_analytics`, `desk_scorecard`, `ask_desk`, `brief_watchlist`, `summarize_signal`,
`list_domains`, `add_domain`, `check_domain`, `remove_domain` (53 in all).
The same surface is an OpenAPI document: `https://newsroomfloor.com/api/v1/openapi.json` carries the public and
read levels, and `GET /api/v1/papers/{paper}/openapi.json` read WITH an admin key adds the admin level (config,
sections, personas, the line, publish, the look, fact folders, per-property analytics, the signal desk, the
paper's own domains) under the `admin` tag.

## The API in one rule

**One tree. The path names the paper. The method is the verb. Your level decides what a path shows.**

```
GET  /api/v1                          who you are, and which paper your key names
GET  /api/v1/papers                   the catalog of public papers
POST /api/v1/papers                   start another paper (an admin key: a sibling under the same owner)
     /api/v1/papers/{paper}/...       EVERYTHING about one paper
```

- **Three levels.** `public`: no credential, a public paper's published face (a private paper answers 404).
  `read`: a read key for that paper, or any seat on it. `admin`: an admin key, or an admin seat; every write.
- **A key names ONE paper.** `GET /api/v1` with the key answers `you.paper`. Set `PAPER` to it and every
  address in this skill works. On another paper's path the key answers 403 `key_paper_mismatch`.
- **On a paper's own domain the host names the paper**, so the same tree sits at `/api/v1`:
  `https://news.example.org/api/v1/stories` is `https://newsroomfloor.com/api/v1/papers/<paper>/stories`.
- **The method is the verb.** `PATCH /config`, `PUT /template`, `PUT /public`, `DELETE /sections?name=`,
  `DELETE /feeds?url=`, `DELETE /domains?host=`, `DELETE /signal/readers?email=`, `POST /line/runs`,
  `POST /stories/actions`. A DELETE names its target in the query or in a JSON body; both work.
- **One error shape:** `{ "error": "<code>", "message": "<a sentence you can act on>" }` with the right status.
  401 = no or dead credential, 403 = the wrong level or the wrong paper, 404 = no such thing, 402 = the plan's cap.
- A console session works on the same URLs a key does.

```bash
PAPER=$(curl -s https://newsroomfloor.com/api/v1 -H "Authorization: Bearer $NRF_KEY" | jq -r .you.paper)
curl -s https://newsroomfloor.com/api/v1/papers/$PAPER -H "Authorization: Bearer $NRF_KEY"   # every route your level reaches
```

## Where things live in the console (the rings)

The console nests its scopes like concentric rings, and **the URL is the trail**:

| Ring | Address | What it holds |
|---|---|---|
| Home | `https://newsroomfloor.com/home` | every paper the signed-in person holds a seat on, and the roll-up over them |
| The Floor | `/floor/<paper>` | ONE paper's landing: its numbers and its rooms. A newsroom floor is for one paper |
| A room | `/floor/<paper>/desk`, `/staff`, `/live`, `/analytics`, `/audience`, `/settings`, `/costs` | the desk is where pieces are written, edited, published and taken off air |
| A page | `/floor/<paper>/settings/domain`, `/settings/brand`, `/settings/signal`, `/staff/<writer>` ... | one ring inside a room |

`GET /api/v1/papers/{paper}/floor` (MCP `get_floor`) returns this map for your key's paper: the address of its
Floor, its numbers, each room and every Settings page with its address. Use it whenever you tell a
person where to click.
This is the only address scheme: the path names the paper.
**Every console address is private**: seat-gated and never indexed. The public paper lives only on
its own address (`<paper>.newsroomfloor.com` or the paper's own domain).

## Build a paper, soup to nuts

Either drive the MCP tools, or call the JSON API directly. Both hit the same routes and the same caps.
The rule is in "The API in one rule" above: every address below is `https://newsroomfloor.com/api/v1/papers/{paper}/...`, and `{paper}` is the paper your key names (`GET /api/v1` says which; in the curl lines it is `$PAPER`).

1. **Read the plan** so you know the caps you are building within: `GET /api/v1/papers/{paper}/plan`.
2. **Set the desk**: `PATCH /api/v1/papers/{paper}/config` with `nameplate`, `news_prompt` (the voice), `line: "daily"`, `daily_pieces`, and a `lane` (the swimlane: `subjects`, `themes`, `topics`) so the paper stays on its beat. A subject only ever pulls facts IN, so a niche desk whose subjects are a trade's own words (shipbuilding, tanker, port) must also set `lane.anchors` (the words that can only mean this desk: `jones act`, `cabotage`, `puerto rico`, `matson`) and `lane.anchor_required: true`; a fact carrying no marker is then refused before any rail runs. Read what each word claims today on the console's Coverage tab (Measure against today's record) before widening a lane.
3. **Hire writers**: `POST /api/v1/papers/{paper}/personas` with `name`, `title`, `beat`, `bio`, `kind`. Every one is a declared AI persona.
4. **Set the beats**: `POST /api/v1/papers/{paper}/sections` with `name`, `query`, and the `personas` who cover it. Optionally give a section its own `prompt` (its standing angle) and `model` (which writer it spends - the front deserves the better one, the listings do not). On an edit, `prompt` and `model` MERGE: omit one to keep the stored value, pass `""` to clear it. A new section counts against the topic cap.
5. **Feed it from the record** (optional): `POST /api/v1/papers/{paper}/record/search` with `{ "q" }` finds facts across the record, and the folder tools (`create_folder`, `add_to_folder`, `assign_folder`, ...) curate a folder a section can stand on. Set instrument streams with `set_config` `sources`; add a custom publisher feed by url with `add_feed` (`POST /api/v1/papers/{paper}/feeds { "url" }`), and its distilled facts flow into the record for this desk.
6. **Run the line**: `POST /api/v1/papers/{paper}/line/runs`. It writes and files a few pieces on the model your plan funds. On a hybrid desk you approve each piece: the line files drafts, and you put a specific one on air with `publish_story` (or `POST /api/v1/papers/{paper}/stories/actions` with `{ "id", "action": "publish" }`), take it off air with `unpublish_story`, or set a time with `schedule_story`. This is the "bring your own agent" flow, whether the agent runs headless over MCP or drives the console in Chrome.
7. **Read the paper**: `GET /news`, then `GET /news/{id}` and `GET /news/{id}/proof`.
8. **Measure it**: `GET /api/v1/papers/{paper}/analytics` (MCP `desk_analytics`) is per-property analytics. For a NEWSPAPER: reads by day, total and signed-in reads, geography by country, the top stories, the named readers and the confirmed subscribers. For a SIGNAL DESK: reads/opens/searches by day, per-signal opens, watch-section picks, readers active 7/30d, digests sent, per-feed records+signals, and AI usage + credits charged.
9. **Go public** when ready: `PUT /api/v1/papers/{paper}/public` with `{ "public": true }` (every agreement must be signed first).
10. **Grow**: `POST /api/v1/papers` starts another paper under the same account and returns its own admin key.

The runnable end-to-end script is in `examples/build.mjs`. It needs only `NRF_KEY` in the environment.

## Set up a Regulatory Signal Desk

A **Signal Desk** is the other thing a property can be: not a newspaper on the shared fact record, but
a private wall of regulatory signals over ONE client's own record - a county's meeting transcripts, a
regulator's filings, an agency's dockets. Each headline is the action a body took; each signal has its
own page with the source record behind it and a deep link into the moment; readers keep watchlists that
email them when something new matches. The whole address goes behind a sign-in wall, and the corpus
never enters DRM3 commons search, another paper, or any data pack.

Everything below is a setting. There is no code to write and nothing to ask an operator for.

1. **Point the property at your data and switch it on.** One call carries the whole setup:

   ```bash
   curl -sX PATCH https://newsroomfloor.com/api/v1/papers/$PAPER/config \
     -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' -d '{
       "signal_desk": {
         "enabled": true,
         "dataset": "raw_acme",
         "table": "transcripts",
         "data_origin": {
           "supplier": "the county clerk",
           "path": "a nightly export to a bucket DRM3 reads",
           "private_clause": ""
         },
         "access": "invite",
         "semantic_floor": 0.5,
         "digest": "daily"
       }
     }'
   ```

   Over MCP that is one `set_signal_desk` call with the same object, and `get_signal_desk` reads it
   back. Take it in steps if you prefer: only what you send changes.

   - `dataset` / `table` name your account's own private data, as it is named in your Data console at
     https://drm3.network/account. They are what the provenance band shows the reader and what the
     build reads.
   - `data_origin` is what the reader is told about the supply chain: `supplier` (who supplies it),
     `path` (how it arrives), `private_clause` (your own privacy sentence; empty = the standard one).
   - `access` is `invite` (only the roster and the desk's own seats read it) or `members` (anyone
     signed in with a DRM3 account on this paper's own door).
   - `semantic_floor` is how close a meaning-search hit must read, 0 to 1; omit or 0 for the house bar.
     A small private corpus usually wants a lower floor than the fleet default.
   - `digest` is the desk digest cadence: what a reader's digest signup and a new watchlist open on,
     and the switch on the scheduled whole-desk digest (`off` stops the schedule; `send_digest` still
     sends by hand). Each reader still chooses their own.
   - `invite_copy` is `admin` (the default: the admin who invites a reader is copied on the invite
     email) or `off` (the reader alone).
   - `portal_alerts` is `true` (the default: every digest the desk sends a reader, and every watchlist
     match it mails, also lands on that reader's DRM3 account as an activity line and a bell alert
     that opens the desk) or `false` (email only).
   - `auto_build` lets the schedule build signals for you whenever a new delivery lands. Off by default.
   - `ai` is the desk's three AI doors, `{ask, brief, summarize}`, each `true` by default and merged
     one switch at a time (send only what you change). Every call rides the DRM3 inference queue and
     is paid from the ASKING reader's own credits, never the owner's. See "The AI doors" below.
   - `data_key` is WRITE-ONLY: the desk's own scoped key for that dataset. It is stored encrypted
     against the desk and returned by nothing - `get_signal_desk` answers `data_key_set` with a
     fingerprint and the last four instead. Send `""` to forget it.
   - `withdraw_signals` is not a setting, it is a CONFIRMATION. See "Turning it off" below.

   **You do not have to paste a key at all, and the share path is the better one.** In the browser:
   **your DRM3 account -> Data -> the dataset -> Access -> Share with... -> Newsroom Floor
   properties**, then pick the property. The key reaches the desk without anybody seeing it,
   `get_signal_desk` reports `connection.via` as `share` with who shared it and when, and the account
   can take the share back from the same control - the desk stops reading at once and every surface
   names the revocation instead of reporting a key that is merely missing.

**A desk stands on MANY datasets, and each is a `source`.** `dataset` / `table` / `data_origin`
above are shorthand for one feed; the real list is `signal_desk.sources`, up to eight, each with its
own provenance and its own key:

```bash
curl -sX PATCH https://newsroomfloor.com/api/v1/papers/$PAPER/config \
  -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' -d '{
    "signal_desk": {
      "sources": [
        {"dataset": "raw_cp", "table": "transcripts",
         "origin": {"supplier": "the data partner", "path": "a nightly export to a bucket DRM3 reads"}},
        {"dataset": "raw_vc", "table": "transcripts",
         "origin": {"supplier": "the county clerk", "path": "a weekly CSV, uploaded by the owner"},
         "data_key": "'"$VC_DATA_KEY"'"}
      ]
    }
  }'
```

- Sending `sources` REPLACES the list (the same dataset+table is one feed; a duplicate collapses).
  Sending only `dataset` / `table` / `data_origin` edits the FIRST feed and leaves the rest standing.
- A share from the account (the Data tab path below) ADDS a feed with its key; a second share stands
  beside the first. Taking a share back removes exactly that feed and its key.
- Drop one feed without resending the rest: `DELETE /api/v1/papers/{paper}/signal/connection
  {"dataset":"raw_vc","table":"transcripts"}` (MCP `disconnect_source`). The other feeds keep their
  keys; the signals that feed built stay on the wall, tagged with the dataset they came from.
- Correct ONE feed's supply chain without resending the list: `PATCH /api/v1/papers/{paper}/config
  {"signal_desk":{"source_origin":{"dataset":"raw_vc","table":"transcripts","supplier":"the county
  clerk","path":"a weekly export of agendas and minutes"}}}` (MCP: the same `source_origin` on
  `set_signal_desk`). Only that feed's `supplier` / `path` / `private_clause` change - a field you
  leave out is unchanged and every other feed stands. A share arrives with the default supplier ("the
  data partner"), so this is how a second feed reads as its OWN supply chain on the provenance band.
  In the console it is the "What readers are told about ..." form on the Data page, one per feed.
- `GET /api/v1/papers/{paper}/signal/connection` lists every feed under `sources` - dataset, table,
  origin, how and when it connected, its share and who shared it, `key.set` with a fingerprint,
  live `records` and `signals` - and still carries the first feed in the top-level fields it always
  did. `get_signal_desk` answers the same list under `signal_desk.sources`.
- What the reader sees: the provenance band names every feed with its supplier, path, dataset and
  record count, then the totals; each card and each signal's page wears a small tag with its
  dataset, the Source & origin block names that feed's supplier, and the Filters panel gains a Feed
  facet when there is more than one. With one feed none of that chrome appears.
- The build reads EVERY feed with a key and reports each one: "raw_cp.transcripts 51 rows -> 48
  signals; raw_vc.transcripts 24 rows -> 12 signals". A feed with no key is named in its place and
  the others still build. `limit` is per feed; with several feeds `next_cursor` is a JSON map by
  feed, passed back as it came.

**A feed arrives in a RECORD SHAPE, and the desk reads the data partner's own meeting zip
natively.** Each source carries `shape`: `meeting-record/1` (the default: the canonical columns
`stable_id, supersedes, jurisdiction, body, meeting, meeting_date, video_url, video_start_seconds,
transcript_text, segments_json, speakers_json`) or `cp-meeting-zip/1` (the partner's export: one zip
per meeting, named by its numeric media id, holding `metadata.json` - title, source link, taxonomy
path, `itemDate`, `audioSeconds`, `recordStatus` - and `transcription.json`, word-level with string
seconds and an optional speaker id per word, or `paragraphs.json` for a document). Set it per feed:

```bash
curl -sX PATCH https://newsroomfloor.com/api/v1/papers/$PAPER/config \
  -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' \
  -d '{"signal_desk":{"source_origin":{"dataset":"raw_cp","table":"transcripts","shape":"cp-meeting-zip/1"}}}'
```

(MCP: the same `source_origin` on `set_signal_desk`; `sources[].shape` when you send the list;
`get_signal_desk` reports each feed's `shape`.) What the desk does with a zip: `stable_id` =
`cp:<id>`; a later record with the SAME id marked `updated` supersedes the earlier one (filed as a new
revision of the same signal; the earlier stays in the archive); the jurisdiction is the most specific
taxonomy entry with the state's postal code ("Orlando, FL", level `city`; "Orange County, FL",
`county`); the body reads off the title's prefix ("City of Orlando - Council" -> "City Council");
the meeting date is `itemDate`'s date part; the video is the source URL unless the record is a
document; the words are grouped by speaker when the engine diarized them, else into windows of about
forty-five seconds so "Watch at" lands on the right minute; the partner's metadata rides along whole
in `origin_meta`, so the signal's page links "View on the source site" and shows the duration. The
rows land in the SAME columns (plus `origin_meta`), so the hub upload door, the build and every
reader page read them as they read any feed. Today the bridge is
`node scripts/cp-zips-to-csv.mjs <folder of zips> transcripts.csv` (the adapter bundled for node:
one row per id, the newest version) uploaded through `POST /v1/hub/upload/<tenant>/transcripts`; the
bucket puller will run the same adapter. Two verbs that go with a re-delivery: `POST
/api/v1/papers/{paper}/signal/builds {"rewrite":true}` over a feed read end to end also RETIRES the signals
whose rows the feed no longer carries (a replaced dataset does not leave the old wall standing beside
the new one), and `DELETE /api/v1/papers/{paper}/signal/connection {"dataset","table","withdraw_signals":true}`
(MCP `disconnect_source` with `withdraw_signals`) takes a dropped feed's signals off the wall too;
both keep every signal in the archive.

**The two config shapes are not the same shape.** `PATCH /api/v1/papers/{paper}/config` takes a PATCH: the fields you
send, under `signal_desk`, merged onto what is stored. `GET /api/v1/papers/{paper}/config` returns the WHOLE resolved
config, with `signal_desk` carrying read-only additions the POST never accepts back:
`data_key_set`, `data_key_fingerprint` / `data_key_hint`, a `connection` block, and each entry of
`sources` carrying its share, its `key` state and its counts. A GET -> POST round trip is safe (the
parser drops what it does not own), but do not treat the GET's shape as the POST's schema.

2. **Invite the readers.** The roster is rows, so it has its own door:

   ```bash
   curl -s https://newsroomfloor.com/api/v1/papers/$PAPER/signal/readers -H "Authorization: Bearer $NRF_KEY"
   curl -sX POST https://newsroomfloor.com/api/v1/papers/$PAPER/signal/readers \
     -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' \
     -d '{"email":"clerk@acme.gov","note":"county clerk"}'
   curl -sX DELETE https://newsroomfloor.com/api/v1/papers/$PAPER/signal/readers \
     -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' -d '{"email":"clerk@acme.gov"}'
   ```

   MCP: `signal_access`, `invite_reader`, `remove_reader`. **Adding an address SENDS the invite**: a
   READER invite, never an admin seat, in the desk's own look (its mark, nameplate and motto on a
   dark masthead, a mono strip with its live feeds / records / signals / coverage), naming who
   invited them to the Signal Desk and what the desk is, saying in words that this is reader access
   and not an admin seat, with ONE button, "Create my DRM3 account": the platform sign-up at
   drm3.network with the desk pre-provisioned as `next`, so the first sign-in lands on the desk
   (someone who already has an account at that address signs in and is through). The inviting
   admin is copied (`invite_copy`). To see either mail yourself, `send_test_mail` with `kind`
   `invite` or `digest` (`POST /api/v1/papers/{paper}/signal/test-mails {kind}`): it mails the desk's admin the real
   mail with the real numbers, and touches nothing (no roster row, no copy, no watermark).
   The answer carries `mail` (`sent`, the provider `id`, or the `error`): the address is on the
   roster either way, and a refused send is said, never hidden behind `ok`. POST the same address
   again to resend. `GET` shows each invited row's last `invite` attempt. The seat IS the address:
   the DRM3 account at it reads the desk; every other account meets the wall. The desk's own seats
   always read it. (The DRM3 account portal lists what an account holds on the floor, seats and
   reader standings both, through an internal door the hub alone holds the key to; an agent reads a
   desk's roster with `signal_access`, never that door.)

   **The desk digest.** A signed-in reader signs up on the desk's own front (Daily / Weekly / Off
   under the watchlists, no watchlist required) and gets the signals filed since their last digest,
   across every section and feed, with a one-click off link in every mail. Two doors for you:

   ```bash
   curl -s https://newsroomfloor.com/api/v1/papers/$PAPER/signal/digests -H "Authorization: Bearer $NRF_KEY"
   curl -sX POST https://newsroomfloor.com/api/v1/papers/$PAPER/signal/digests -H "Authorization: Bearer $NRF_KEY"
   ```

   MCP: `digest_subscribers` (who signed up, on what cadence, last sent) and `send_digest` (every
   signup at once; the answer is the mail log's count: `sent`, `nothing_new`, `failed`, and each
   address's provider id or refusal). The schedule is `signal_desk.digest`: `daily` or `weekly`
   sends on its own on each reader's cadence; `off` leaves only the hand send.

   **The reader's bell.** With `portal_alerts` on (the default), every sent digest and every mailed
   watchlist match also lands on the reader's DRM3 account: an activity line and a bell alert that
   opens the desk. The send's answer says what the bell did, per address: `bell` is `rang` (with the
   hub's `alert_id`), `no_account` (the address has no DRM3 account yet: the invite is still open and
   the mail alone carried the digest), `failed` (the hub did not answer; the mail still went) or
   `off`; the run's `bells` counts them and the `message` reads "rang 3 bells Â· 1 no account yet". A
   test you send yourself (`send_test_mail`) never rings a bell.

   **Who reads it.** `GET /api/v1/papers/{paper}/analytics` (MCP `desk_analytics`) is the console's Analytics
   page as JSON, in the desk's own shape: signed-in reads by day (the sign-in wall is never counted),
   the ten most-opened signals with their quick looks, what readers open (the front, `/data`,
   `/about`, `/api`, signal pages), the watch sections they pick, searches by day with the mean hit
   count (counted by length and hits, never the words), the digests sent and to how many readers,
   what their bells said, invites sent, the roster and seats, readers active in 7 and 30 days, and
   each feed's records and signals. Readers are counts and hashes; no address is in it.

   ```bash
   curl -s https://newsroomfloor.com/api/v1/papers/$PAPER/analytics -H "Authorization: Bearer $NRF_KEY"
   ```

   **The AI doors.** A Signal Desk answers from its own record, and the asker pays. Three doors,
   three switches (`signal_desk.ai`), three tools, one law: the model is shown the desk's own
   passages and nothing else, every sentence it writes cites the passage it stands on as `[n]`, an
   uncited sentence is dropped, and nothing relevant on the desk means NO model call and no charge.
   Every call goes through the DRM3 inference queue on the asking account (on the reader host the
   signed-in reader; through the API and MCP the desk owner's account) with no pass-through key, so
   the ledger meters it; the answer carries `cost` (the credits the ledger charged, read as the
   balance delta, and the balance after) and `read` (the passages the model was shown). Out of
   credits answers 402 with `add_credits`, the account link. The desk records every call
   (`signal_ai_calls`: kind, a reader hash, passages, credits, the queue's answer id) and
   `desk_analytics` reports asks, briefs, summaries, readers and credits.

   - `ask_desk {question}` = `POST /api/v1/papers/{paper}/signal/ask`: the question is matched by meaning
     against the desk's signals at its floor (the same search the front runs), the top eight go to
     the model, the answer is 40 to 120 words with `cited[]` (each signal's page). Readers have the
     same door on the desk host, `POST /signal/ask`, as the "Ask this desk" box in the deck.
   - `brief_watchlist {watchlist_id?}` = `POST /api/v1/papers/{paper}/signal/brief`: 120 to 200 words on
     what is NEW since the last digest (a watchlist's matches since its last alert, or the desk's
     signals since the owner's digest watermark), grouped by watch section, cited. Nothing new = one
     line, no call. Readers: `POST /signal/brief` ("Brief me" in Manage, on a watchlist's feed head
     and on the digest row), and the switch **AI brief in the digest** puts the same brief at the top
     of their digest email, paid from their credits on each send (`brief` on `POST /signal/digest`;
     a short balance sends the digest without it and says so in one line).
   - `summarize_signal {id, force?}` = `POST /api/v1/papers/{paper}/signal/summaries`: one paragraph from
     that signal's transcript segments, cited to the moments (`cites[]`: speaker, seconds, the video
     deep link). Cached per signal on the desk: the first call pays, every later one answers
     `cached: true` with the date. Readers: **Summarize** beside "Watch at" on the signal's page.

   ```bash
   curl -sX POST https://newsroomfloor.com/api/v1/papers/$PAPER/signal/ask -H "Authorization: Bearer $NRF_KEY" \
     -H 'Content-Type: application/json' -d '{"question":"What is happening with the Slavia Road rezoning?"}'
   ```

   Turn any door off with `set_signal_desk {"ai":{"ask":false}}`; the other two stay as they were.
   The desk's private record never leaves it except as the passages sent to the queue for that one
   answer; nothing is written to the commons or any fact record, and nothing is kept beyond the
   cached summary.

3. **Set the watch sections.** The wall groups signals by the paper's own sections, in their order, so
   `add_section` is how you name them: the section's first `cats` entry is the group a signal files
   under, the section's name is the label the reader sees, and the section's `query` is the operator's
   own words for what belongs there - which the build scores each record against, and which the search
   box on the wall uses for its own example chips and tip.

   **`add_section` UPSERTS BY NAME.** Sending a section whose `name` already exists REPLACES that
   section whole - its `query`, its `cats` and its `personas` - rather than adding a second one or
   answering 409. (`orig` renames: send the old name as `orig` and the new one as `name`.) So editing
   a section's words is the same call as creating it, and a partial send is a wipe of the fields you
   left out. Read `get_config` first if you mean to change one field.

   ```bash
   curl -sX POST https://newsroomfloor.com/api/v1/papers/$PAPER/sections \
     -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' \
     -d '{"name":"Rezoning & Land Use","cats":["rezoning-land-use"],"query":"rezoning zoning variance comprehensive plan"}'
   ```

4. **Connect the dataset and build the signals.** This is the step that turns your rows into a wall.
   Give the desk its own data key, then run the build:

   ```bash
   curl -sX PATCH https://newsroomfloor.com/api/v1/papers/$PAPER/config \
     -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' \
     -d '{"signal_desk":{"data_key":"'"$ACME_DATA_KEY"'"}}'
   curl -sX POST https://newsroomfloor.com/api/v1/papers/$PAPER/signal/builds \
     -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' -d '{"limit":200}'
   curl -s https://newsroomfloor.com/api/v1/papers/$PAPER/signal/builds -H "Authorization: Bearer $NRF_KEY"
   ```

   `GET /api/v1/papers/{paper}/signal/connection` answers how this desk came by each feed's key - `share`
   (with `shared_by` and `share_id`) or `pasted` - and carries no key material. A `data_key` on the
   config door goes to the feed the same call names by `dataset` + `table`, else the first feed.

   MCP: `set_signal_desk` with `data_key`, then `build_signals`, then `signal_build_status`. The console
   has the same three on **Desk settings > Signal Desk > Data**, which also shows the dataset, its
   provenance, every delivery, and a search over the rows with each row's own origin.

   What the build does, per source row: files one signal story; mirrors the source beside it for the
   signal's own page; and puts it into meaning search. It is IDEMPOTENT - a row whose source has not
   changed is left exactly as it is, so a second run writes nothing. It is bounded: `limit` rows a
   run, and the `next_cursor` it returns carries you through a bigger dataset. Pass `rewrite: true` to
   re-derive rows you have already built, which is what you do after changing your sections. Pass
   `heal: true` to re-write ONLY the signal lines that fell back to a lifted transcript sentence (a
   build that ran while the inference lane or the desk owner's credits were down); it re-runs the
   model on those alone and leaves every line that already reads as a signal, so a corpus-wide heal
   spends a credit only on the failures. Run it after an outage; a transcript-shaped feed benefits.

   **THE DERIVATION IS EXTRACTIVE AND DETERMINISTIC. No model runs, nothing is paraphrased, and the
   same row derives the same bytes forever.** That is the whole reason a regulatory reader can check a
   headline against the transcript underneath it, and the reason the job can run on a schedule without
   churning what is on air. The rule, in order:

   - **The headline** is the action the motion names: the clause after `, to ` in the first sentence
     carrying "Motion by ...". A record with no motion - a workshop, a briefing, a correction - takes
     its first substantive sentence instead, skipping the roll call and the quorum. Cut at 150
     characters on a word boundary, with the cut shown.
   - **The prose** is the record VERBATIM from its first substantive sentence, then `Outcome:`, then
     the video deep link at the item's own start second.
   - **The outcome** is the chair's own words, read from the sentence that carries them: a tally
     ("Motion carries, four to one" -> `Approved four to one.`), unanimous, failed, continued, a staff
     recommendation for approval or denial, withdrawn. Nothing the record decides is **`On the
     record.`** - a briefing decided nothing, and saying it was approved would be an invention. Each
     test is scoped to its own sentence, so a transcript that mentions an earlier denial in passing
     cannot turn a granted item into a denial.
   - **The section** is scored against your own section `query` words, repeats counting with a
     diminishing return. No word matches = the first section. So your section words ARE the filing
     rule: widen them and re-run with `rewrite` rather than asking for a code change.
   - **Supersession**: a row naming an earlier `stable_id` in `supersedes` retires that signal - off
     the wall, still in the archive with its revisions, its vector removed. When the correction
     arrives in the SAME delivery as the row it replaces, that row is still filed first and then
     retired, so the archive holds what the record said before the correction; the run counts those
     under `superseded` and says so in its message.

   **NORMALISE THE EXPORT BEFORE YOU UPLOAD IT.** The rule reads the columns, not a schema: give each
   row `stable_id` (the supplier's own permanent id), `jurisdiction`, `body`, `meeting`,
   `meeting_date`, `video_url`, `video_start_seconds`, `transcript_text`, `segments_json` (speaker
   turns with `at_seconds`), and `supersedes` where a record replaces an earlier one. A row with no
   `stable_id` or no text is counted as `skipped`, visibly, rather than half-built. And the derivation
   is only as good as the punctuation: a transcript exported as one unbroken block has no sentences to
   read, so the headline will be the first 150 characters of it.

5. **Check the floor, and read the desk as a reader.** `get_signal_desk` reads the whole setup back.
   Then read the wall from the far side of its own wall - the only place the floor is really visible,
   because every hit carries its closeness score:

   - **The desk's reader pages** sit behind the same wall as the front, linked in the rail under the
     header on every page: `/` the signals (one line of facts above the deck: feeds, records, signals,
     coverage, Private), `/data` the provenance (one card per feed: supplier, path, dataset, records,
     signals, the last delivery read, the card tag), `/about` what the desk is (jurisdictions, bodies,
     date range, owner, the private clause, how access works) and `/api` what a reader can read by
     machine on the desk's own address (the session doors `/signal/search`, `/signal/transcript`,
     `/signal/watchlists`, `/signal/digest`; a key opens the paper catalog on `/api/v1` and a member
     key `/api/v1/me`, and the story doors answer empty there, since no key reads a signal today).
     The console preview renders the same four pages.
   - **The reader's door is the member door on the PROPERTY's own host**, never the console:
     `https://<slug>.newsroomfloor.com/member/signin` (or your reader domain). An invited person signs
     in there with the DRM3 account at the address on the roster, and the session is a cookie scoped to
     that host. A seat on the desk always reads it too.
   - **To check that path without an insider's browser**, mint a ten-minute sign-in link for an address
     already on the roster:

     ```bash
     curl -sX POST https://newsroomfloor.com/api/v1/papers/$PAPER/signal/readers/preview-link \
       -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' \
       -d '{"email":"clerk@acme.gov"}'
     ```

     It returns a `link` that lands on the property with that reader's session. It grants exactly what
     the roster already grants, refuses any address that is not on it, expires in ten minutes, and
     writes a `desk_audit` row. It is a way to CHECK the reader path, not a way to hand out access.
   - **A machine gets a machine's answer.** Every `/api/` path and any request asking for
     `application/json` is refused with **401 JSON** on a walled desk, never the sign-in page; a
     browser gets the page. The desk's own tenant key, and a member's own `nrfm_` key, pass the wall
     and reach the API as themselves.

**A SIGNAL DESK IS NEVER PUBLISHED.** Its address serves the moment `enabled` is true, behind the
sign-in wall, and `paper_public` is not consulted. `PUT /api/v1/papers/{paper}/public {"public": true}` is REFUSED on
a Signal Desk with `not_applicable`: publishing is the newspaper switch, and pressing it on a private
property is the one thing the property type exists to prevent. There is no publish step to do.

**Turning it off is a WITHDRAWAL.** Switching the property back to a newspaper takes every signal the
build filed off air - out of search, out of the sitemap, out of every address a reader or a crawler
can reach - because a newspaper serves its stories to the world and this corpus is one client's
private data. So the call is refused unless you confirm it in the same breath:

```bash
curl -sX PATCH https://newsroomfloor.com/api/v1/papers/$PAPER/config \
  -H "Authorization: Bearer $NRF_KEY" -H 'Content-Type: application/json' \
  -d '{"signal_desk":{"enabled":false,"withdraw_signals":true}}'
```

Without `withdraw_signals` the answer is `409 confirm_required` and nothing changes. With it, the
reply says how many signals went off air. Nothing is deleted: every signal keeps its revisions and
comes back exactly as it was the moment you set `enabled` true again. The console has the same
confirmation as a tick box on **Desk settings > Signal Desk**.

**There is no `GET /api/v1/papers/{paper}/signal`.** The setup is read through `GET /api/v1/papers/{paper}/config`,
whose `signal_desk` key carries it; MCP's `get_signal_desk` is a name for that same route and returns
the WHOLE config, not just the Signal Desk block. The Signal Desk's own doors are the roster
(`/api/v1/papers/{paper}/signal/readers`: GET, POST, DELETE, and `POST .../preview-link`), the build (`/api/v1/papers/{paper}/signal/builds`, GET and
POST), the connection (`GET /api/v1/papers/{paper}/signal/connection`), the disconnect
(`DELETE /api/v1/papers/{paper}/signal/connection`), the digest (`GET /api/v1/papers/{paper}/signal/digests`,
`POST /api/v1/papers/{paper}/signal/digests`), the test mail (`POST /api/v1/papers/{paper}/signal/test-mails`) and the analytics
(`GET /api/v1/papers/{paper}/analytics`, the desk shape on a Signal Desk). Read `GET /api/v1/papers/{paper}` with your key: it lists every route your level reaches.

The same page exists in the console at **Desk settings > Signal Desk**, with the roster on it.

## Search engines, with no DNS change

A public paper serves everything a crawler asks for on its own: `robots.txt` naming both sitemaps,
`/sitemap.xml` (every page, real dates), `/news-sitemap.xml` (the Google News format, the last two
days), a canonical address on every page, `NewsArticle` JSON-LD on every piece in the page's
language, and a derived search-engine key file at `/<key>.txt`. None of that reaches Google until the
property is verified and the sitemap submitted, and Google's own listing needs at least one inbound
link. The setting is `search` on the config door, the same whitelist everywhere:

```json
{"search": {"google": "<google-site-verification content>", "bing": "<msvalidate.01 content>",
            "title": "news.pr Â· Puerto Rico, reported daily", "description": "...", "indexnow": true}}
```

- `google` / `bing` accept the bare token or the whole pasted `<meta>` tag; the paper serves the tag
  on every page, so the property verifies by the HTML-tag method. No DNS record.
- `title` (70) and `description` (160) are the FRONT's; a piece carries its own (`meta.seo_title`).
  Empty title = the nameplate with the motto.
- `indexnow: false` stops the instant notices; absent = on. The cron tells Bing, Yandex, Seznam and
  Naver about new pieces on air every few minutes (Google does not take the notice; it is reached
  through the verified property and the sitemap).
- Then submit `https://<host>/sitemap.xml` and `https://<host>/news-sitemap.xml` in Search Console and
  Bing, and give the paper a link: `listed: true` with a `description` puts it in the public catalog
  on newsroomfloor.com, which is the one inbound link every paper can have on day one.
- The console has the same card on the Your own domain page (`/floor/<paper>/settings/domain#search`) with the
  last answer from the engines.

## Put the paper on its own domain

Out of the box a paper is read at `<slug>.newsroomfloor.com`. Its own address is provisioned by the
product: you add the host, the owner adds ONE record at their registrar, and the certificate is ours.
Nobody attaches anything by hand. Publisher plan and above; up to five per paper; a host belongs to one
paper on the floor. The door is `/api/v1/papers/{paper}/domains` (admin key); the tools are `list_domains`,
`add_domain`, `check_domain`, `remove_domain`.

DNS itself decides the lane, and the owner may be on any registrar:

- **A subdomain** (`news.example.org`) becomes a Cloudflare for SaaS hostname: the owner adds one
  `CNAME news.example.org -> cname.newsroomfloor.com`. Any registrar allows it. The answer's `show`
  carries that target and, once the hostname exists, an optional `dcv` record
  (`_acme-challenge.<host> CNAME <host>.<id>.dcv.cloudflare.com`) that lets the certificate renew for
  years with no further record; it is never required to go live.
- **An apex** (`example.org`) cannot be a CNAME at most registrars, and the one change every registrar
  allows is the nameservers, so the zone is hosted with us: `add_domain` creates the zone and the answer's
  `show.ns` carries the two nameservers to set. When the change reaches the registry the records and the
  route follow on their own. (A subdomain may also take this lane: `lane: "hosted"`.)

```json
POST /api/v1/papers/{paper}/domains   {"host": "news.example.org"}
-> {"ok": true, "domain": {"host": "news.example.org", "lane": "cname", "status": "pending",
      "show": {"cname_target": "cname.newsroomfloor.com"}, ...}, "reader_domains": [...]}
POST /api/v1/papers/{paper}/domains   {"host": "example.org"}
-> {"ok": true, "domain": {"host": "example.org", "lane": "hosted", "status": "pending",
      "show": {"ns": ["<a>.ns.cloudflare.com", "<b>.ns.cloudflare.com"]}, ...}}
```

`status` reads `pending` (waiting on the owner's record), `verifying` (the record is seen, the
certificate or the route is on its way), `active` (serving; the host is now in `reader_domains`, and
`domains/primary` makes it the canonical address), or `error` with Cloudflare's own sentence, which
`check_domain` retries. The page and the five-minute sweep re-read every pending host on their own, so
an agent never has to poll; `check_domain` is for an answer right now. `remove_domain` takes the
hostname, our route and the records we made away and never deletes a zone. A refusal is plain: the
plan, the cap, a host another paper holds, or a name that is not a hostname.

## Read and verify

`examples/verify.mjs` fetches a story's receipt and checks it. A receipt is the Ed25519 commitment for
the last publish: an `output_hash`, a signing key, and a signature. The published keys are at
https://feeds.data.drm3.network/.well-known/rss-ingest-keys.json, and the console shows a reader who can
re-run the hash in the browser. A signature that verifies proves the bytes are the ones that were signed,
by the key that signed them. It does not prove the story is correct.

## Traps

- The admin key is shown once. Store it on mint; a lost key is replaced, never recovered.
- `set_config` merges: only the fields you send change, the rest stay. There is no wipe on a partial write.
- **A read key reads the paper's own level and nothing at the admin level.** `get_plan`, `get_template`,
  `get_signal_desk` and every write answer 403 `forbidden` on a read key, and the refusal names the rule.
  A read key's level is the paper: `/stories` (drafts included), `/stories/{id}/proof`, `/search`,
  `/subscribers` as a count, `/config` as the public subset. Mint an admin-scoped key for anything else.
- **A key names ONE paper.** On another paper's path it answers 403 `key_paper_mismatch`. There is no
  `?d=`, no separate admin prefix, and no verb in a path: removing is `DELETE`, config is `PATCH`, the look and
  the public switch are `PUT`.
- **A key that was revoked answers 401 `invalid_key`**, not 404 and not a 400 about naming a desk. If
  you see that, roll the key rather than rewriting the request.
- `daily_pieces` above your plan cap is clamped down silently to the cap. Read `get_plan` first.
