# ReachIQ Production MCP — Operator Manual

**Connector:** `reachiq-prod` · **Endpoint:** `https://mcp.reachiq.ai/mcp` · **Version:** 1.0.0

This server is a live connection to the **production** ReachIQ workspace. It reads and writes
real customer data across all 26 tenants. Every call — read and write — is recorded in the
Elasticsearch index `mcp_logs`, with before/after snapshots on anything that changed.

---

## 1. What you can do with it

| You ask | It does |
|---|---|
| "Go to the internal tenant and get all the lists" | `list_all` |
| "Which campaigns are running for lama?" | `campaign_list` |
| "Audit campaign C7 — what's wrong with it?" | `campaign_get` + `tracking_stats` + `prompt_validate` |
| "Why did this campaign go to *interrupted*?" | `prompt_validate` — almost always a 0-step prompt |
| "Fix this prompt" | `prompt_validate` → `prompt_update` (refuses a body that would break it) |
| "Find VP Sales at IT companies with verified emails" | `prospect_search` (14M-person pool) |
| "Which companies do we have 20+ contacts at?" | `company_search` |
| "Turn tracking off for this campaign" | `campaign_update_settings` |
| "Rewrite this queued email before it sends" | `email_get` → `email_update` |
| "Show me the real email this campaign will send to X" | `email_written` |
| "Run a health check on this campaign" | `campaign_health` |
| "Show me everything about the workdigital tenant" | `tenant_get` + `tenant_users` |
| "Which tracking domains does each pool have, and who uses them?" | `domain_pool_overview` |
| "Give this tenant its own tracking-domain pool" | `domain_pool_create` → `domain_update` → `tenant_pool_assign` |
| "What changed on this tenant recently?" | `mcp_logs` |

Tenants are addressed by **database name** (`internal`), **url** (`internal.reachiq.ai`) or
**id** — all three work. `tenant_list` shows them all.

---

## 2. Safety model

Three classes of tool:

| Class | Behaviour |
|---|---|
| **READ** | Always available, all tenants. |
| **WRITE** | Reversible change. Requires `reason` (goes in the audit log), writes enabled, and a tenant on the write allowlist. |
| **CONFIRM** 🔒 | Destructive or irreversible. **The first call always refuses** and returns exactly what it would do plus a single-use `confirm` token. Show that to a human, get an explicit yes, then call again with the token. |

Confirm tokens are **single-use**, expire in **10 minutes**, and are **bound to a hash of the
arguments** — an approval for one list cannot be replayed against another tenant, another list,
or a larger batch.

Current policy (see `server_capabilities` for live values):

```
writes_enabled   : true
writable_tenants : *      (all 26 tenants)
confirm_ttl      : 600s
```

**Deletes are verified, not assumed.** The platform's own delete API reports
`partial_success` even when it worked, so every delete here re-reads the record afterwards and
reports what it actually found.

---

## 3. Tools (78)

### Tenants & session
| Tool | |
|---|---|
| `tenant_list` | Every tenant with the identifiers other tools accept |
| `server_capabilities` | Whether writes are on, which tenants are writable, confirm policy |
| `declare_intent` | State your goal in one sentence — attached to the audit trail |
| `report_issue` | Report friction or a wrong result |
| `server_manual` | This document |

### Platform administration
| Tool | Class | |
|---|---|---|
| `tenant_get` | read | Full admin view: settings, credits, seats, site domains, assigned tracking-domain pool |
| `tenant_users` | read | A tenant's login users with role/status/activity — passwords are never returned |
| `tenant_update` | write | Status, seat limit, credits, company details, timezone. Identity fields (url, database name) are immutable here |
| `tenant_user_update` | write | account_status / role / name of a login user — writes both the central directory and the tenant's own users collection |
| `domain_pool_overview` | read | Every pool with its domains + every tenant assignment, in one view |
| `domain_pool_create` | write | New empty pool |
| `domain_pool_update` | write | Rename / activate / deactivate (refused while tenants are assigned, unless forced) |
| `domain_add` | write | Register a tracking domain, optionally into a pool |
| `domain_update` | write | Activate/deactivate a domain or move it between pools |
| `tenant_pool_assign` | write | Point a tenant at a pool — or detach it back to the global rotation |

Two admin behaviours worth knowing:

- **`domain_add` only creates the database row.** The hostname must *already* have DNS, a TLS
  certificate and its Cloudflare proxy configured, or every link stamped with it is dead. Those
  steps are an infrastructure runbook, not an API call.
- **Platform-wide writes (domains, pools) require the full `*` write allowlist** — a per-tenant
  allowlist can never authorize a change that affects every tenant.

LLM provider/model configuration lives in the same collection as prompts — use the prompt tools
with `feature: "tenantPrompt_model"`.

### Tenant users vs sender mailboxes

`tenant_users` shows **login accounts** (who can sign in to the app). **Sender mailboxes** (the
inboxes campaigns send from, with their connected/disconnected state) are a different dataset and
are not yet exposed here.

### Prompts
| Tool | Class | |
|---|---|---|
| `prompt_list` | read | Browse prompts; shows the parsed `step_count` of each |
| `prompt_get` | read | Full body + exactly how the production parser reads it |
| `prompt_validate` | read | Lint a saved prompt **or a draft before saving** |
| `prompt_create` | write | Validated first; a 0-step body is refused |
| `prompt_update` | write | Same validation; `force:true` overrides |
| `prompt_delete` | 🔒 | Preview lists campaigns built from it |

**Why validation matters:** a prompt that parses to **zero `[STEP:…]` blocks** sends its campaign
straight to `interrupted`. That has happened in production. `prompt_validate` runs the *real*
campaign parser, so it agrees with what the platform will actually do.

Errors it catches: no `[STEP:]` markers · missing `[MAIN_INSTRUCTIONS]` · duplicate step numbers
(N markers = N steps, which is how an 8-step sequence got built by accident) · empty step blocks.
Warnings: non-sequential steps · threaded step 1 · a competing "REQUIRED OUTPUT FORMAT" block
(this produced raw JSON inside email bodies).

### Campaigns
| Tool | Class | |
|---|---|---|
| `campaign_list` | read | State, senders, tracking/unsub, customization keys. Paginated (`offset`) |
| `campaign_get` | read | Detail + row breakdown + delivery stats |
| `campaign_health` | read | **One-call audit**: prompt lint + state + empty bodies + literal `{{tokens}}` + bounce rate + tracking wiring, as a ranked findings list |
| `campaign_update_settings` | write | tracking / unsubscribe / customization_keys |
| `campaign_pause` | write | Stop sending; remembers state and holds every unsent row |
| `campaign_resume` | write | Restore the pre-pause state and re-arm held rows |
| `campaign_rename` | write | Unique-name enforced |
| `campaign_set_state` | write | Escape hatch — force a state (e.g. lift "interrupted") |
| `campaign_delete` | 🔒 | Preview shows rows + already-sent count; stats history is kept |

Campaign state lives in **two stores** (Mongo `campaigns_records` + an ES index) and the queued
rows in a third — every lifecycle tool updates all of them together, the way the platform does,
and re-reads Mongo to verify. Pausing that only updated one store is the classic bug where a
"paused" campaign keeps sending.

### Templates
| Tool | Class | |
|---|---|---|
| `template_list` / `template_get` | read | Body is decompressed back to HTML on read |
| `template_create` | write | HTML is compressed the platform's way so the app can open it |
| `template_update` | write | Name / subject / body |
| `template_delete` | 🔒 | Preview + confirm |

### Suppression (opt-out)
| Tool | Class | |
|---|---|---|
| `suppression_list` | read | The tenant's opt-out list |
| `suppression_check` | read | Are these emails suppressed? Run before enrolling people |
| `suppression_add` | write | Unsubscribe contacts — writes the opt-out list, marks the contact, and pulls their queued rows (exactly what the unsubscribe link does) |
| `suppression_remove` | 🔒 | Re-subscribe (compliance-sensitive, so confirm-gated) |

`tracking: off` means no open pixel, no click wrapping — **opens and clicks will read 0 by
design**, that is not a bug. `unsubscribe: off` removes the unsubscribe line entirely.
`customization_keys` limits which prospect fields reach the LLM.

### Lists
| Tool | Class | |
|---|---|---|
| `list_all` | read | Lists with contact counts |
| `list_fields` | read | Every merge field in a list with `{{tag}}` + a real sample value |
| `list_contacts` | read | Sample the actual rows |
| `list_create` | write | Returns the new `list_id` |
| `list_rename` | write | Carries `permission` through (team lists otherwise revert to private) |
| `list_update_fields` | write | Write custom columns (why_fit, hook, tier…) onto saved contacts |
| `list_delete_contacts` | 🔒 | Remove selected records |
| `list_delete` | 🔒 | Preview names any campaign referencing the list |

### Prospect & company pool (14M people, 4.3M companies)
| Tool | |
|---|---|
| `prospect_lookup_values` | **Call this first.** The real values present in the pool — a value not listed here silently matches nothing |
| `prospect_count` | Size an audience before building it |
| `prospect_search` | Full records, emails unmasked |
| `prospect_get` | Batch lookup by email (up to 500) or any exact field |
| `company_search` | One row per company: contact counts, available titles, firmographics |
| `prospect_export_emails` | Uncapped email export with slicing |

Pool ids are **not** saved-record ids — every response labels which kind it returned.

### Imports · Tracking · Email
| Tool | Class | |
|---|---|---|
| `import_list` / `import_get` | read | CSV imports, status, column mapping |
| `tracking_stats` | read | Totals with **machine opens separated** from human ones |
| `tracking_events` | read | Per-recipient sent/open/click/reply/bounce |
| `email_list` / `email_get` | read | Queued rows. For agent campaigns the body is the GENERATION PROMPT until generated — `email_get` flags this. Paginated |
| `email_written` | read | The **real written email** the app shows — generated (pre-send) or sent. Use this to audit content, not `email_get` |
| `email_update` | write | Rewrite subject/body before send; refuses sent rows |

### Sender mailboxes
| Tool | Class | |
|---|---|---|
| `sender_list` | read | Every sending inbox with live connect status (connected/connecting/disconnected), provider, primary flag, and daily/hourly limits |

### Audit
| Tool | |
|---|---|
| `mcp_logs` | Every call this server made: tool, tenant, target, reason, and before/after content for writes. `include_snapshots:true` shows the old content so a change can be reverted by hand. |

---

## 4. Worked flows

**Fix a broken campaign prompt**
```
campaign_get            → note prompt_id and state
prompt_validate         → read every error
prompt_update           → with a reason; refused if still broken
```

**Audit a campaign end-to-end**
```
campaign_get       → settings, senders, per-step row counts
tracking_stats     → sent / human opens / replies / bounces
tracking_events    → per-recipient detail
prompt_validate    → parse errors explain an "interrupted" state
email_list/get     → sample the queued copy
list_fields        → do the merge fields the copy uses actually exist?
mcp_logs           → what changed recently, and why
```

**Build a target audience**
```
prospect_lookup_values  → real industries / technologies / size buckets
company_search          → accounts with enough contacts to be worth it
prospect_count          → size it
prospect_search         → pull the people
list_create             → somewhere to put them
```

---

## 5. Things that will surprise you

1. **Zero opens with sends present** usually means the campaign was created with `tracking: off`, not that tracking is broken. Check `campaign_get`.
2. **A prompt with 0 steps kills its campaign** — it goes to `interrupted` immediately. Always `prompt_validate`.
3. **Duplicate `[STEP:]` markers** double the sequence: two copies of a 4-step set builds 8 steps.
4. **Empty field values are skipped in the LLM prompt**, so a selected field with no data simply doesn't appear — that is correct behaviour, not a dropped field.
5. **Already-queued rows keep the settings stamped when they were scheduled.** Changing a campaign's tracking or customization keys affects content generated *from now on*; reschedule to restamp existing rows.
6. **Machine opens are real** — Apple/Gmail prefetch. `tracking_stats` reports human and machine separately; trust `human_opens`.
7. **Pool ids ≠ saved-record ids.** A `pool_id` from `prospect_search` cannot be used where a tenant record id is expected.

---

## 6. Needs the platform pipeline, not a direct write

A few actions look like simple writes but are not — doing them by writing database rows directly
produces something that *looks* right and behaves wrong. They run background pipelines this
server deliberately does not reimplement:

- **Creating a campaign** — consumes credits and kicks off AI generation, ESP validation, sender
  pinning and SQS dispatch workers. A hand-written `campaigns_records` row has none of that, so it
  would sit stuck or send garbage. (You CAN pause/resume/rename/delete/re-state an existing one —
  those are just state changes, and they are built.)
- **Importing a CSV / adding pool prospects to a list** — the import pipeline validates emails,
  dedupes, enriches and indexes to ES. A direct insert skips all of it.

These need the platform API path (a logged-in service account), which is not available on
production today. If that changes, they can be wired up.

## 7. Not built yet (but safe to build)

Email sequences (the standalone sequence builder), `email_fill` bulk content generation, the
composite `campaign_health` audit tool, sender-mailbox management (connect state, hourly/daily
caps), and the meetings / leads / payment / copilot areas.

Admin-panel functions on the separate admin host — plans, billing, admin-panel logins — are a
different system and are not reachable from this server.

Campaign creation, adding pool prospects to a list, and CSV import deliberately are **not** done
by direct database writes — they consume credits and run background pipelines (AI generation,
ESP validation, sender pinning, SQS workers). Writing those rows directly would produce a
campaign that looks right and behaves wrong. They need the platform API path.

---

## 8. If something looks wrong

Use `report_issue` — it lands in the audit trail. For anything destructive that you did not
intend, `mcp_logs` with `include_snapshots:true` holds the previous content of every write.

---

## 9. Platform notices (read these before diagnosing a campaign)

Dated, operator-maintained. Newest first.

**2026-10-02 — RESOLVED: database connection string inside tracking and Unsubscribe links.**
Emails sent by the new sending engine between July 2026 and 2 Oct 2026 12:50 UTC carried the tenant database connection string inside the signed token of the open pixel, the click link and the Unsubscribe link. The sender was fixed and deployed on 2 Oct 2026 at 12:50 UTC: tokens now carry identifiers only (tenant, campaign, step, email, tracking domain). Every email sent after that time is clean. No campaign, prompt or setting had to change, and in-flight links keep working. Emails already delivered still contain the old token; the ReachIQ team has decided not to rotate the database credential for now. The old sending engine was never affected.
About the Unsubscribe line (rule since 2 Oct 2026 14:56 UTC): with `unsubscribe` on, the signature's unsubscribe block is appended as written. It becomes a tracked Unsubscribe link only where the block contains `{{unsubscribe_tag}}`; a plain sentence such as "reply no" goes out as plain text with no link. With `unsubscribe: false` the whole block is dropped.

**2026-10-02 — RESOLVED: campaigns stayed "scheduled" while sending; reduced send throughput.**
From 30 Sep 2026 to 2 Oct 2026 12:58 UTC the email dispatcher hit its time limit on every run. The scheduled-rows collection had no database indexes, and the Internal tenant's bulk campaigns (about 28,000 queued rows) made every lookup a full scan. Effects: emails still went out, but fewer per minute, and campaign state never moved from `scheduled` to `active` or `completed`. The "Sent" counts were always accurate. Fixed on 2 Oct 2026: indexes added on every tenant, dispatcher redeployed with a time budget so state updates always run, and runs are now 10 to 20 seconds. States correct themselves on the next sending tick; campaigns that finished sending while the bug was active may still read `scheduled` until an operator flips them.
