---
name: signaldesk
description: Stand up, configure and operate a Signal Desk desk over the API or MCP. Use when a user wants to connect a private dataset to a desk, switch the Signal Desk on, build signals, check a build, set who may read, invite readers, run digests, use the desk's AI doors, or put the desk on the customer's own address.
---

# Run a Signal Desk desk

Signal Desk is DRM3's white-label data product for anyone who holds data, on DRM3 Rails. It runs at
https://signaldesk.drm3.network. Access is assigned; the public door is https://signaldesk.drm3.network/contact.

An operator points a **desk** at a customer's own private dataset. The desk files one **signal** per
row. The customer's buyers read the **Signal Desk** behind a sign-in wall, on the customer's own brand
and address. Readers get a Data Explorer (search by meaning, with filters), watchlists, email digests,
a bell in their DRM3 account, and AI answers drawn from the desk's own record.

An agent with an admin key can do what the console does. This skill takes you from a fresh key to a
desk that readers use.

## The rules (the product enforces them)

- **Private data stays private.** A signal never appears in the public catalog, in any shared or
  public search, or on another desk.
- **A Signal Desk is never published.** Its address serves behind the sign-in wall as soon as the desk
  is switched on. There is no publish step.
- **The desk reads a dataset only with that dataset's own scoped key.** No read returns a key.
- **A key names one desk.** It works under that desk's path and nowhere else.
- **Nothing is deleted.** A signal taken down stays in the archive and can come back.
- **Do not tell a reader a signal is signed or verified.** A signal stands on its source record. See
  "Check a signal against its source".

## Prereqs

- A DRM3 account with Signal Desk access. Access is assigned. Sign in at
  https://signaldesk.drm3.network.
- A desk. Create the first one in the console.
- An **admin key** for that desk. Mint it in the desk's Settings, under API keys. It is shown once.
  It starts with `nrf_`. Store it as `SIGNALDESK_KEY`.
- The customer's dataset in a DRM3 account (https://drm3.network/account), with one table of rows.
- `curl` and `jq` for the examples below.

## Connect the MCP

```bash
claude mcp add --transport http signaldesk https://signaldesk.drm3.network/mcp \
  --header "Authorization: Bearer $SIGNALDESK_KEY"
```

The descriptor is at https://signaldesk.drm3.network/.well-known/mcp.json. The door is stateless:
POST one JSON-RPC message, get one JSON answer. A tool is a name for an API route, so the same key and
the same level rules apply. The key names the desk, so no tool takes a desk id. A read key gets 403 on
a write tool. A failed call comes back as `HTTP <status>: <the API's error body>`.

The tools this skill uses:

| Job | Tools |
|---|---|
| Read and set the desk | `get_config`, `set_config`, `get_signal_desk`, `set_signal_desk`, `get_plan` |
| Feeds | `disconnect_source` |
| Watch sections | `add_section`, `remove_section` |
| Build | `build_signals`, `signal_build_status` |
| Readers | `signal_access`, `invite_reader`, `remove_reader` |
| Mail | `send_digest`, `digest_subscribers`, `send_test_mail` |
| AI | `ask_desk`, `brief_watchlist`, `summarize_signal` |
| Numbers | `desk_analytics`, `desk_scorecard` |
| Own address | `list_domains`, `add_domain`, `check_domain`, `remove_domain` |
| Another desk | `create_desk` |

## The API in one rule

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

```
GET  /api/v1                       who you are, and which desk your key names
POST /api/v1/desks                start another desk (admin key; the new desk has the same owner)
     /api/v1/desks/{desk}/...    everything about one desk
```

- `{paper}` is your desk id. The path segment `papers` is historical.
- **Two levels matter here.** `read`: a read key, or any seat on the desk. `admin`: an admin key, or
  an admin seat. Every write needs `admin`. With no credential a Signal Desk answers 404.
- Send the key as `Authorization: Bearer <key>`, or in the `X-NRF-Key` header.
- **A key names one desk.** `GET /api/v1` with the key answers `you.paper`. On another desk's path the
  key answers 403 `key_desk_mismatch`.
- **The method is the verb.** `PATCH /config`, `POST /signal/builds`, `DELETE /signal/readers?email=`.
  A DELETE names its target in the query or in a JSON body. Both work. A wrong method answers 405 and
  lists the methods the path takes.
- **One error format:** `{ "error": "<code>", "message": "<a sentence you can act on>" }`.
  401 = no credential or a dead one. 403 = the wrong level or the wrong desk. 402 = the plan's cap.
  404 = no such thing. 409 = the desk is in the wrong state for that call.
- `GET /api/v1/desks/{desk}` with your key lists every route your level reaches.
  `GET /api/v1/desks/{desk}/openapi.json` with an admin key adds the admin level.
- On the desk's own address the host names the desk, so the same tree sits at `/api/v1`.
- A console session works on the same URLs a key does.

```bash
BASE=https://signaldesk.drm3.network
AUTH="Authorization: Bearer $SIGNALDESK_KEY"
DESK=$(curl -s $BASE/api/v1 -H "$AUTH" | jq -r .you.paper)
API=$BASE/api/v1/desks/$DESK
curl -s $API -H "$AUTH"    # every route your level reaches
```

## Set up a desk over a private dataset

Every step is a setting or a call. There is no code to write.

### 1. Switch the desk on and name the feed

```bash
curl -sX PATCH $API/config -H "$AUTH" -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" },
    "access": "invite",
    "digest": "daily"
  }
}'
```

MCP: `set_signal_desk` with the object that sits under `signal_desk`. `get_signal_desk` reads it back.
It returns the whole desk config. The setup is under its `signal_desk` key.

The patch merges. Only the fields you send change.

| Field | What it does |
|---|---|
| `enabled` | `true` makes the property a Signal Desk and puts its whole address behind the sign-in wall. |
| `dataset`, `table` | The first feed: the dataset and table as named in the DRM3 account. Lower case letters, digits and `_`. |
| `data_origin` | What readers are told about the supply: `supplier`, `path`, `private_clause`. Empty `private_clause` uses the standard sentence. |
| `sources` | Every feed, up to eight. See "More than one feed". |
| `access` | `invite` (the default) or `members`. See step 6. |
| `sections` | Optional list of `{slug, label}` to set the watch sections and their order. Empty uses the desk's own sections. |
| `semantic_floor` | How close a search hit must be, above 0 and below 1. Leave it out for the default. A small dataset often wants a lower floor. |
| `digest` | `daily` (the default), `weekly` or `off`. The cadence a reader's signup and a new watchlist open on. `off` stops the scheduled desk digest. |
| `invite_copy` | `admin` (the default) copies the inviting admin on each invite email. `off` does not. |
| `portal_alerts` | `true` (the default) also rings the reader's DRM3 bell for each digest and watchlist alert. `false` is email only. |
| `auto_build` | `true` lets the schedule build signals when a new upload lands on a feed. Off by default. |
| `ai` | Three switches, `{ask, brief, summarize}`, each on by default. Send only the one you change. |
| `data_key` | Write-only. See step 2. |
| `withdraw_signals` | Not a setting. The confirmation for switching the desk off. See "Take a signal down, or turn the desk off". |

### 2. Connect the dataset

The desk needs the dataset's own scoped key. There are two ways to give it one.

**Share it (the better way).** In the DRM3 account at https://drm3.network/account, share the dataset
to this desk. The key reaches the desk and nobody sees it. A share adds the feed if the desk does not
have it yet. The account can take the share back. The desk then stops reading that feed at once.

**Paste it.** Send a scoped data key from the account's Data console:

```bash
curl -sX PATCH $API/config -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"signal_desk":{"data_key":"'"$DATA_KEY"'"}}'
```

The key is stored encrypted against the desk and that feed. It goes to the feed the same call names by
`dataset` and `table`, else to the first feed. Send `""` to forget it.

Check the connection. No key is returned, only its state:

```bash
curl -s $API/signal/connection -H "$AUTH"
```

The answer lists each feed under `sources`: `dataset`, `table`, `origin`, `shape`, `connected`,
`via`, who shared it, `key.set` with a fingerprint, and live `records` and `signals` counts.

### 3. Set the watch sections

A signal files under a watch section. The build refuses to run until the desk has at least one.

```bash
curl -sX POST $API/sections -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"name":"Rezoning & Land Use","cats":["rezoning-land-use"],"query":"rezoning zoning variance comprehensive plan"}'
```

MCP: `add_section`. `name` is the label readers see. The first entry of `cats` is the slug signals
file under. `query` is your own words for what belongs there. The build scores each row against the
`query` words and the name. A row that matches no section files under the first one. A `query` holds
300 characters.

Posting a name that exists edits that section. Fields you leave out keep their stored values. Send
`orig` with the old name to rename. `DELETE /sections?name=` removes one (`remove_section`). A new
section counts against the plan's topic cap (402 `plan_cap`).

### 4. Build signals

```bash
curl -sX POST $API/signal/builds -H "$AUTH" -H 'Content-Type: application/json' -d '{"limit":200}'
```

MCP: `build_signals`. The build reads every feed that has a key and files one signal per row.

| Field | What it does |
|---|---|
| `limit` | Rows to read per feed this run. Default 200, at most 1000. |
| `cursor` | The `next_cursor` from the last run, to carry on through a larger dataset. |
| `rewrite` | `true` derives every row again, including unchanged ones. Use it after you change the watch sections. If a rewrite takes more than one run, send back the `pass` value with each `cursor`. |
| `heal` | `true` rewrites only the signals whose line fell back to a lifted sentence. Use it after an outage. |

The answer carries `ok`, `build_id`, `message`, `next_cursor`, the totals (`rows_read`,
`stories_written`, `stories_retired`, `superseded`, `skipped`, `vectors_indexed`) and `sources`, one
entry per feed with its own counts. A refusal you can fix answers 400 with the reason in `message`:
the desk is not switched on, no feed is named, no feed has a key, or there are no sections.

Run it again with `cursor` until `next_cursor` is `null`.

### 5. Check the build

```bash
curl -s $API/signal/builds -H "$AUTH"
```

MCP: `signal_build_status`. It lists the last ten runs, newest first: who ran each one and how, the
counts, where it stopped, the error if it failed, and `sources_json` with each feed's counts. Then
read `GET /signal/connection`: each feed's `records` and `signals` are the live totals.

A second run over unchanged rows writes nothing. That is correct. Those rows count as `skipped`.

### 6. Set access

`signal_desk.access` decides who passes the sign-in wall:

- `invite`: only addresses on the reader roster, plus the desk's own seats.
- `members`: anyone signed in with a DRM3 account on this desk's own sign-in door.

A seat on the desk always reads it.

### 6b. How the desk's admins sign in

A desk can require two-step sign-in for its **admin seats**: the people who invite, export the
audience and hold the API keys. Read the rule:

```bash
curl -s -H "Authorization: Bearer $SIGNALDESK_KEY" \
  https://signaldesk.drm3.network/api/v1/desks/$DESK/security | jq '{admin_two_step, set_by, set_at}'
```

The MCP tool is `get_admin_sign_in`. `admin_two_step` is `required` or `optional`.

- **There is no call that sets it.** The rule guards the desk against a stolen password, so a person
  changes it: an admin, signed in with a passkey or an authenticator code, on the desk's Settings
  under Admin sign-in. A key cannot switch it on or off. Do not look for a tool that does.
- When it is `required`, an admin who signed in with a password alone, or with Google, GitHub or X,
  gets `403 two_step_required` from every desk door until they sign in again with a passkey or a
  code. They add either one in their DRM3 account settings. Both are free.
- Editors, approvers and readers are not affected. API keys keep working, this one included.
- Tell the operator to turn it on before they invite a second admin.

### 7. Invite readers

```bash
curl -s $API/signal/readers -H "$AUTH"                                   # the roster
curl -sX POST $API/signal/readers -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"email":"clerk@acme.gov","note":"county clerk"}'                  # invite
curl -sX DELETE "$API/signal/readers?email=clerk@acme.gov" -H "$AUTH"    # revoke
```

MCP: `signal_access`, `invite_reader`, `remove_reader`.

- **Adding an address sends the invite email.** It is a reader invite, never an admin seat. It has one
  button, "Create my DRM3 account". The first sign-in lands on the desk.
- The answer carries `mail`: `sent`, the `id`, or the `error`. The address is on the roster either way.
- POST the same address again to resend.
- The seat is the address. The DRM3 account at that address reads the desk. Every other account meets
  the wall.
- A revoke removes an invited address. It does not remove a seat. Seats are kept in the console.

To see a mail yourself, send a test. It goes to the desk's admin and changes nothing:

```bash
curl -sX POST $API/signal/test-mails -H "$AUTH" -H 'Content-Type: application/json' -d '{"kind":"invite"}'
```

`kind` is `invite` or `digest`. MCP: `send_test_mail`.

### 8. Watchlists and digests

**Watchlists belong to readers.** A signed-in reader makes them on the desk: a name, jurisdictions,
terms and a cadence (`daily`, `weekly` or `off`). The schedule emails the reader the new signals that
match, once per signal. The admin API does not create or list watchlists.

**The desk digest** needs no watchlist. A reader picks Daily, Weekly or Off on the desk and gets the
signals filed since their last digest, across every section and feed. Every mail has a one-click off
link.

```bash
curl -s $API/signal/digests -H "$AUTH"            # who signed up, each cadence, last send
curl -sX POST $API/signal/digests -H "$AUTH"      # send to every signup now
```

MCP: `digest_subscribers`, `send_digest`. The send answers with `sent`, `nothing_new`, `failed`, a
`results` entry per address, and `message`. A reader with nothing new gets no mail.

`signal_desk.digest` sets the schedule. `daily` or `weekly` sends on each reader's own cadence. `off`
leaves only the send by hand.

**The bell.** With `portal_alerts` on, each digest and each watchlist alert also lands on the reader's
DRM3 account. The send reports the bell per address: `rang`, `no_account` (the address has no DRM3
account yet, so the mail alone carried it), `failed` or `off`. A test mail never rings a bell.

### 9. Reader AI

Three doors. Each is a switch under `signal_desk.ai`. One rule holds for all three: the answer is
written only from the desk's own passages, and each sentence cites the passage it stands on.

| Door | API | MCP | What it returns |
|---|---|---|---|
| Ask | `POST /signal/ask {question}` | `ask_desk` | A short answer with `cited`, each signal's page. `question` is 3 to 300 characters. |
| Brief | `POST /signal/brief {watchlist_id?}` | `brief_watchlist` | What is new on one watchlist, or on the whole desk, grouped by watch section, cited. |
| Summarize | `POST /signal/summaries {id, force?}` | `summarize_signal` | One paragraph from that signal's transcript, with `cites` to the moments. |

```bash
curl -sX POST $API/signal/ask -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"question":"What is happening with the Slavia Road rezoning?"}'
```

- **Who pays.** Through the API and MCP the call is charged to the desk owner's DRM3 credits. On the
  desk itself the signed-in reader pays from their own credits.
- Each answer carries `cost` and `costLine` (the credits charged).
- **No match means no charge.** If nothing on the desk clears the search floor, Ask answers
  `Nothing on this desk speaks to that.` and makes no AI call. A brief with nothing new is one line.
- A summary is cached per signal. The first call pays. Later calls answer `cached: true` at no cost.
  `force: true` writes it again.
- Out of credits answers 402 with `add_credits`, the account link.
- A door that is switched off answers 409 `off`. A signal with no transcript answers 404
  `no_transcript`.

Turn one door off and leave the others: `{"signal_desk":{"ai":{"ask":false}}}`.

### 10. Read the numbers

```bash
curl -s $API/analytics -H "$AUTH"
```

MCP: `desk_analytics`. Signed-in reads by day, the most-opened signals, searches by day, digests sent,
what the bells reported, invites, the roster, active readers, and each feed's records and signals.
Readers are counts and hashes. Searches are counted by length and hits, never by their words.
`GET /scorecard` (`desk_scorecard`) is the short version.

## More than one feed

A desk can stand on up to eight feeds. Each is a `source` with its own supply line and its own key.

```bash
curl -sX PATCH $API/config -H "$AUTH" -H 'Content-Type: application/json' -d '{
  "signal_desk": { "sources": [
    {"dataset":"raw_a","table":"transcripts","origin":{"supplier":"the data partner","path":"a nightly export"}},
    {"dataset":"raw_b","table":"transcripts","origin":{"supplier":"the county clerk","path":"a weekly file"},
     "data_key":"'"$B_DATA_KEY"'"}
  ]}
}'
```

- **Sending `sources` replaces the list.** A feed you leave out is dropped from the setup.
- Sending only `dataset`, `table` or `data_origin` edits the first feed and keeps the rest.
- A share from the account adds a feed beside the others.
- Drop one feed: `DELETE /signal/connection {"dataset","table"}` (`disconnect_source`). Its signals
  stay up. Add `"withdraw_signals": true` to take that feed's signals down as well.
- Fix one feed's supply line without resending the list:
  `{"signal_desk":{"source_origin":{"dataset":"raw_b","table":"transcripts","supplier":"the county clerk"}}}`.
  A shared feed arrives with no supplier named, so set one this way.
- Each signal is tagged with its feed. Build results and the log report each feed on its own. With
  several feeds `next_cursor` is a JSON map by feed. Pass it back as it came.

## A bespoke feed: the column map

Most partners deliver rows under their own column names. Instead of renaming their export, set a map on
the feed, once. The build then reads their rows as the standard record.

```bash
curl -sX PATCH $API/config -H "$AUTH" -H 'Content-Type: application/json' -d '{
  "signal_desk": { "sources": [ { "dataset": "raw_acme", "table": "dockets", "map": {
    "v": 1,
    "fields": {
      "stable_id":    { "column": "docket_id" },
      "meeting_date": { "column": "filed_on", "transform": "date" },
      "body":         { "column": "board_name", "transform": "trim" },
      "jurisdiction": { "const": "Acme County" },
      "text":         { "columns": ["summary", "detail"], "join": "\n\n" }
    } } } ] }
}'
```

- A spec is `{column}`, `{columns, join?}` or `{const}`, with an optional `transform`: `trim`, `lower` or
  `date` (the first ten characters).
- Canonical fields: `stable_id`, `supersedes`, `jurisdiction`, `body`, `meeting`, `meeting_date`,
  `video_url`, `video_start_seconds`, `text`, `segments`, `level`, `origin_meta`. Any other name, an empty
  spec, a bad column name or an unknown transform refuses the whole map; nothing half-applies.
- A feed with no map reads the standard names below. A share from the account keeps the map you set.
- `GET /signal/connection` returns each feed with its `map` as stored.

## What a row must carry

Each feed has a `shape`. Set it per feed in `sources[].shape` or in `source_origin`.

**`meeting-record/1`** (the default). One row per record, with these columns:
`stable_id`, `supersedes`, `jurisdiction`, `body`, `meeting`, `meeting_date`, `video_url`,
`video_start_seconds`, `transcript_text`, `segments_json`.

- A row with no `stable_id` or no text is counted as `skipped`. It is not half built.
- `stable_id` is the supplier's own permanent id. The same row is the same signal on every run.
- A row that names an earlier id in `supersedes` retires that signal. It leaves the desk and stays in
  the archive.
- The signal is lifted from the record by a fixed rule. No AI runs. The headline is the action the
  motion names. The text is the record's own words, then the outcome, then the link to the source.
  The same row gives the same signal every time.
- Punctuation matters. A transcript with no sentence breaks gives a poor headline.

**`cp-meeting-zip/1`**. A data partner's own meeting export: one zip per meeting with its metadata and
a word-level transcription. A raw transcript has no motion sentence to lift, so one AI call writes the
headline and a short readout. It is charged to the desk owner's DRM3 credits. If that call cannot
run, the fixed rule files the signal instead, and `heal` repairs those lines later. The full
transcript always stands on the signal's own page.

## Read the desk as a reader

The reader's address is `https://<desk id>.signaldesk.drm3.network`, or the desk's own domain.

- Pages: `/` the signals, `/data` the feeds and their supply lines, `/about` what the desk is, `/api`
  the reader's own API page. A signal's page is `/signal/<id>`.
- Sign-in is `/member/signin` on that address. An invited person signs in with the DRM3 account at the
  invited address.
- The Data Explorer's search is `GET /signal/search` on the reader's session: `q` (matched by
  meaning), `jur`, `body`, `sec`, `feed`, `from`, `to` (dates as `YYYY-MM-DD`), `sort` (`match`, `new`
  or `old`). Each hit carries its score.
- A machine gets a machine's answer. Without a session, `/api/` paths and JSON requests on a desk
  address answer 401 JSON, not the sign-in page. The desk's own key passes the wall on `/api/` paths.

To check the reader path without the reader's browser, mint a ten-minute sign-in link for an address
that is already on the roster:

```bash
curl -sX POST $API/signal/readers/preview-link -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"email":"clerk@acme.gov"}'
```

It returns a `link` that opens the desk as that reader. It refuses an address that is not on the
roster (404 `not_invited`), expires in ten minutes, and is written to the desk's audit trail. Use it
to check access. Do not use it to hand out access.

## Put the desk on its own address

A desk is read at `<desk id>.signaldesk.drm3.network` from the start. Its own address takes one DNS
record at the customer's registrar. The customer keeps their DNS zone and their email.

```bash
curl -sX POST $API/domains -H "$AUTH" -H 'Content-Type: application/json' -d '{"host":"signals.example.org"}'
curl -s $API/domains -H "$AUTH"
curl -sX POST $API/domains/checks -H "$AUTH" -H 'Content-Type: application/json' -d '{"host":"signals.example.org"}'
curl -sX PUT $API/domains/primary -H "$AUTH" -H 'Content-Type: application/json' -d '{"host":"signals.example.org"}'
curl -sX DELETE "$API/domains?host=signals.example.org" -H "$AUTH"
```

MCP: `add_domain`, `list_domains`, `check_domain`, `remove_domain`.

- The add answers with the entry. Its `show` carries the exact record to add: `cname_target` for a
  subdomain, or `a` and `aaaa` for a whole domain. Read the values from the answer.
- `status` is `pending` until the record points at us, then `active`. The schedule re-reads pending
  hosts on its own. `POST /domains/checks` asks now.
- `PUT /domains/primary` makes an active host the desk's main address. Invite and digest links then
  use it.
- Send `host` only. Do not send `lane`.
- Refusals are plain: the plan does not include it (402, Publisher plan and above), the desk already
  holds five, another desk holds the host, or the name is not a hostname.

## Check a signal against its source

A signal is a line drawn from one row of the customer's dataset. The check is the row.

- Each signal's page shows the source record under it: the jurisdiction, the body, the meeting, the
  date, the transcript by speaker, and a link to the moment in the source video when the row has one.
- `GET /signal/transcript?id=<signal id>` on the reader's session returns the same record as JSON,
  with `stable_id`, the supplier's own id for the row. Use it to find the row in the dataset.
- The desk's `/data` page shows each feed's supplier, path, dataset, record count and signal count.
- `POST /signal/summaries` cites each sentence to a transcript moment.

A signal is not signed, and no tool or route returns a proof for one. Do not claim a signal is
signed. The source record is the check.

## Take a signal down, or turn the desk off

**One signal.** API only:

```bash
curl -sX POST $API/signal/stories -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"action":"offline","id":"<signal id>","reason":"duplicate"}'
```

`action` is `offline` or `online`. The signal id is the hex id in its page address.

**The whole desk.** Switching a Signal Desk off takes every signal off air. The call is refused unless
you confirm it in the same call:

```bash
curl -sX PATCH $API/config -H "$AUTH" -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
answer says how many signals came down. Set `enabled` to `true` again and they return as they were.

## Traps

- The admin key is shown once. A lost key is replaced, never recovered. A revoked key answers 401
  `invalid_key`. Mint a new one. Do not rewrite the request.
- A read key answers 403 `forbidden` on every admin path. Every call in this skill needs an admin key.
- `PATCH /config` merges, with two exceptions. `signal_desk.sources` replaces the feed list. An empty
  object, `{"signal_desk":{}}`, clears the whole setup.
- `GET /config` returns more than `PATCH /config` accepts. Under `signal_desk` it adds read-only
  fields: `data_key_set`, `data_key_fingerprint`, `data_key_hint`, `connection`, and each source's
  `key`, `records` and `signals`. Do not treat the GET as the PATCH schema.
- There is no `GET /signal`. The setup is read through `GET /config`. The Signal Desk doors are
  `/signal/connection`, `/signal/builds`, `/signal/stories`, `/signal/readers`,
  `/signal/readers/preview-link`, `/signal/digests`, `/signal/test-mails`, `/signal/ask`,
  `/signal/brief` and `/signal/summaries`.
- `PUT /public` is refused on a Signal Desk with `not_applicable`. There is nothing to publish.
- `GET /stories` does not list signals. Signals are private and are read on the desk's own address.
- The MCP door lists more tools than this skill names. The story and persona tools do not apply to a
  Signal Desk. Use the tools in the table above.
- The reader door for a summary is `POST /signal/summarize`. The admin door is
  `POST /signal/summaries`. They run the same code and bill different accounts.
- A build with no key names the cause. A share that was taken back is reported as revoked, with who
  revoked it and when. Share the dataset again. Do not hunt for a key.
- A changed watch section does not move signals that are already filed. Run the build with
  `rewrite: true`.
- A disconnect stops the desk reading a feed. It does not take that feed's signals down unless you
  send `withdraw_signals: true`.
- `create_desk` (`POST /api/v1/desks {name, slug?}`) answers with the new desk's own admin key. It is
  shown once. The new desk is not a Signal Desk until you switch it on.
