MCP server.
sayintel speaks the Model Context Protocol over streamable HTTP. Connect an assistant once and it can run the whole conference-outbound loop: create a campaign, watch the pipeline, read drafts, curate. Nothing sends without a human tap.
Gemini, in two minutes
Gemini CLI speaks MCP natively, so there is nothing to install. Paste this into ~/.gemini/settings.json, start gemini, and sign in to sayintel in the browser window it opens. Your tokens stay on your machine.
{
"mcpServers": {
"sayintel": {
"httpUrl": "https://sayintel.com/mcp"
}
}
}Then, inside a Gemini session:
/mcp # shows sayintel and its tools /mcp auth sayintel # re-run the sign-in if it ever expires # try it > list my sayintel campaigns > show me the drafted openers for the newest one
Gemini gets read and curate access. It cannot send anything: pushing to HeyReach needs a send-scoped token you mint in Settings, and a human still arms the campaign.
Two doors
OAuth (connector clients: Claude, ChatGPT, Gemini CLI) https://sayintel.com/mcp The client registers itself, your human approves it, tools run as that user. Scopes: read + write. Bearer token (CLI and headless agents) https://sayintel.com/api/public/mcp Authorization: Bearer si_... Scopes: whatever the token was minted with, including send. No token and no browser? POST /api/public/v1/agent-signup with your human's email; they confirm by email and you receive a read + write token. See /docs/agents, "Getting a key". Discovery https://sayintel.com/.well-known/mcp.json
Connect a client
# Claude Code
claude mcp add sayintel --transport http https://sayintel.com/api/public/mcp \
--header "Authorization: Bearer $SAYINTEL_TOKEN"
# Claude Desktop / claude.ai / ChatGPT connectors
# Add a custom connector pointing at https://sayintel.com/mcp
# and complete the sign-in prompt.
# Cursor (.cursor/mcp.json)
{
"mcpServers": {
"sayintel": {
"url": "https://sayintel.com/api/public/mcp",
"headers": { "Authorization": "Bearer si_..." }
}
}
}
# Gemini CLI (~/.gemini/settings.json)
# OAuth door: the CLI discovers the endpoints, opens your browser to sign
# in, and stores tokens in ~/.gemini/mcp-oauth-tokens.json.
{
"mcpServers": {
"sayintel": {
"httpUrl": "https://sayintel.com/mcp"
}
}
}
# Bearer door: point httpUrl at /api/public/mcp with a headers object.
# Inside a session, /mcp shows connection status; /mcp auth sayintel
# re-runs the sign-in.The recommended loop
whoami → your scopes, and your clients (id, name)
create_campaign(conference_url, client_id | own_company: true)
→ campaign_id. On an account with clients,
exactly one of client_id or own_company
is required; with neither, the error lists
your clients so you can retry. Accounts
with no clients pass neither.
get_run_status(campaign_id) → poll every 15-30s while the pipeline
scrapes, enriches, qualifies, drafts
search_speakers / get_speaker_dossier / list_messages
→ review what it produced
get_draft_qa(campaign_id, only_failing: true)
→ find what needs fixing
request_rewrite / update_message → curate
rescore_drafts(campaign_id, speaker_ids)
→ score hand-edited text without rewriting it
approve_message → record a person's approval (send-scoped
when HeyReach auto-push is armed)
push_approved → send-scoped token onlyTools
57 tools. Every one returns the same envelope as REST, and each bound tool has a matching HTTP endpoint in the REST reference.
Accept borderline / needs-input speakers as fits and record each ruling as a qualifier correction for classifier learning. Omit speaker_ids to accept the whole borderline pile.
Add a new conference URL to a campaign. Must be a public https URL, private/internal hostnames are rejected.
SENDS. Selects the ICP fits and approves their QA-passed drafts, and approving is what arms outbound: it clears needs_human, and a complete QA-cleared sequence on a campaign with HeyReach auto-push active is pushed to HeyReach immediately, which puts a real LinkedIn message in front of a real person. Never approves an unscored or failing draft, and approval is NOT reversible by un-approving (the needs_human flag stays cleared). Pass speaker_ids to approve a specific set; omitting it approves the ENTIRE campaign. Pass dry_run=true first to see how many drafts and speakers would be approved and whether this campaign's auto-push is armed. Requires the 'send' token scope.
Record a PERSON's decision that drafts are fine as written: clears needs_human and needs_redo, removes review-hold flags (e.g. copilot_rewritten_awaiting_review, *_awaiting_review, agent_authored_*, founder_rejected_*) and stamps founder_approved_text_<date>. Only call it when the user has read the text and said to approve it. Target message_ids or speaker_ids (all their unsent slots). Refuses, per row, anything never evaluated or carrying a hard fail. It never invents a score: qa_score is left as is, so a row approved with no score or below 8 is approved but still not ready_to_send, and the response says so (run rescore_drafts first). Does not send or push by itself, but on a campaign with HeyReach auto-push armed clearing the hold is part of sending, so there it requires the 'send' token scope and is refused otherwise.
Say intel: answer one question about one campaign (room) from its own data. Runs a read-only loop over the room's read tools (people, drafts, signals, decisions, approvals, readiness, the saved brief) with campaign_id pinned to that room and every speaker_id checked against it, and returns the answer with its sources and a confidence that is low whenever no tool stood behind it. Never writes, never sends, makes no outbound request: the only URLs it cites are ones its own tool results carried. Spends model calls.
Funnel stats for a single campaign. `qualified` / `disqualified` / `needs_input` are the ICP verdict (qualify_verdict, decided after enrichment; it gates research and drafting). Qualify always decides with a confidence: `check_fit` are qualified at low confidence (drafted, flagged), `maybe` are ruled out at low confidence (recoverable), `needs_input` is only the capped review shortlist of the maybes closest to the ICP (at most 10, optional), and `unjudged` are people the model failed on, retried automatically. `triage_*` is the cheap first pass on the scraped title and company (prequalify_verdict); triage 'unsure' and low-confidence 'disqualified' still flow to qualification, so never read triage as the qualification. `drafted` counts speakers with a complete live 3-message sequence; `drafted_messages` is the raw live row count; `superseded` is rows parked by a repair/redraft pass and awaiting their replacement (never deleted first).
Founder-only model benchmark: compares Claude, xAI Grok, Groq, Google Gemini (through the Lovable gateway / direct Google, GEMINI_COMPARE_MODEL, default google/gemini-3.1-pro-preview) and Perplexity (PERPLEXITY_API_KEY, PERPLEXITY_COMPARE_MODEL, default sonar-pro, may web-search). Writes nothing to campaign data (only spend records). mode 'fixtures' (default) runs eval-fixtures/dataset-v1.json through the real QA judge rubric and reports accuracy / precision / recall on hard fails, mean scores, agreement with the first provider, latency and cost. mode 'judge' (campaign_id) runs the real qaReviewMessages on up to `limit` qualified speakers' stored, scored trios with each provider as the judge and reports per-slot scores, trio pass rate, agreement with the stored verdicts and with claude, rewrite rate, plus example disagreements. mode 'draft' (campaign_id) has each provider write M1 from the identical real skill_v1 prompt, concurrently per speaker, then scores every candidate with the hard-fail gate and (unless judge=false) the Claude judge; returns side-by-side samples with each provider's M1, gate codes, word count, latency and cost next to the stored M1, never persists a candidate. A provider that errors (e.g. a missing key, named in the error) is counted under its errors and the others still return. mode 'human' (campaign_id optional) scores each provider as the QA judge against your own decisions: drafts you rejected with a rewrite request or substantively hand-edited are BAD, your hand-edited versions and messages sent exactly as drafted are GOOD; reports good_pass_rate, bad_catch_rate, balanced_accuracy, auc, pair_win_rate (your edit vs the draft), mean scores, p50/p95 latency and cost per message. mode 'founder' scores each provider as the founder judge: it predicts the founder's own send / don't-send call (and his reason) on his calibration marks in eval-fixtures/founder-labels.json, held-out marks when there are any, otherwise leave-one-out; reports agreement, send_agree, dont_agree, rule_match, cost per message and passes_gate (at least 18 of 20 held-out marks), plus every disagreement in full. mode 'founder_draft' (campaign_id) is the blind writing test: per speaker it puts the stored draft ('current'), the recipe engine ('recipe', with this campaign's m1_recipe or an inline recipe for this campaign) and each writer model (writers, several models per provider allowed) with one identical short founder-style prompt side by side, plus any extra_candidates you pass, and judges every message with the founder judge (judge_provider / judge_model); returns each speaker's inputs, every message with word count, checks and the judge's call, and per-writer totals; never persists. Pass writers=[], include_baselines=false and judge=false to get the speakers' inputs only, with no model calls. mode 'signals' (campaign_id) asks Perplexity, Gemini (Google grounding), Grok (web + X search), Claude (web search), Firecrawl and Apify the same question (this speaker's, or with target='event' this conference's, recent dated signals) and scores every finding the same way: URL loads, cited by the source, dated within 90 days, judged relevant by Claude; reports usable findings per speaker, hit rate, latency and cost per source. mode 'x_signals' (campaign_id) asks xAI live X search for each qualified speaker's handle and recent posts and says whether that signal is new. Each provider stops once its estimated spend passes max_cost_usd. One call does a small page and stops taking new items after 40s: page with offset until next_offset is null; elapsed_ms says how long it took. Pass dry_run=true for the call count first; keys_configured shows which of the five providers have keys.
Create a new outbound campaign, optionally attach a conference URL (public https only) and auto-start the pipeline. On an account that has clients you MUST say who the conference is for: pass client_id (one of the ids whoami returns under clients), or own_company: true for the account's own company. Exactly one of the two; with neither, the call is refused and the error lists the account's clients by id and name. A client's conference takes pitch, positioning, audience and sender from that client only, never from the account profile or another client. An account with no clients needs neither argument and seeds ICP, style memory and sender defaults from the user's profile, as before. Pass who to set this conference's audience in plain words. Scraping runs asynchronously via cron; poll get_run_status to follow progress. Pass dry_run=true to get cost_preview without creating anything (the client check still runs). Returns { campaign_id, client_id, warning, run_started, cost_preview }; warning is non-null when the conference has no pitch yet (for a client: the client has no one-liner), in which case the run parks before research until one is added.
Register an https endpoint to receive signed event callbacks. Events: run.started, run.completed, run.failed, drafts.ready, message.ready, message.approved, push.completed, or "*" for all. Returns the signing secret ONCE, deliveries carry `X-Sayintel-Signature: t=<unix>,v1=<hex hmac_sha256(secret, `${t}.${rawBody}`)>`. Reject deliveries older than 5 minutes.
Act on one decision from list_decisions. action 'primary' on a reply marks the meeting booked (speakers.reply_status = booked); on a row that opens a screen (QA hold, calibration, set aside) it is refused with the href to open instead. action 'dismiss' puts any open row away. Either can be taken back with undo_decision within 12 seconds. Nothing here approves or sends a message.
Delete a webhook endpoint and its pending deliveries.
Write an opener from one signal (a discovered_posts id from list_signals). For a person with a draft it rewrites message 1 through the same path as request_rewrite (hard-fail QA gate, cost cap, audit), anchored on their own post; for a person with no draft yet it selects them and nudges the run. Spends model calls. Writes a draft only: nothing is approved or sent.
Email the campaign owner's own inbox a pre-event briefing: days until the conference, new qualified prospects since the owner last acted (split by whether their first message is ready), the top 5 by fit, and the side events (or, when none are known, people posting their stand, hall or time). It never emails anyone else and never contacts a prospect. At most one briefing per campaign per 10 minutes: a repeat inside the window returns status duplicate and sends nothing. Returns status: sent, duplicate, suppressed (the address is on the do-not-email list), no_event_date (the campaign has no start date yet), or error.
Email the campaign's ranked side events and groups to the signed-in owner's own inbox (the same digest as the in-app 'Email me these' button). It never emails anyone else and never contacts a prospect. At most one digest per campaign per 10 minutes: a repeat inside the window returns status duplicate and sends nothing. Returns status: sent, duplicate, suppressed (the address is on the do-not-email list), nothing_to_send (no side events found yet; run the side events refresh in the app first), or error.
The Approvals Deck: the next drafts worth a person's Si, strongest QA score first (live, unsent, unflagged M1 openers with the lead and why they fit). Read-only. Show each to the person; approve with approve_message only after they say so, skip by moving on.
Conference readiness counter, the same one the Approvals screen shows: per campaign, how many leads are approved (primed), the target, remaining, and state (primed | floor_ready). Omit campaign_id for every campaign.
Get full campaign config: positioning, ICP, pitch, CTA, conference URLs, totals.
Your clearance, outcome based: level and name, the three counts it rises on (meetings booked, rooms floor ready, intel acted on), what the next level needs, and the public dossier slug. Nothing for streaks, taps or messages sent.
Answer 'who is speaking at this conference, and how should I open?' for any public conference by name. Returns up to 25 public speakers (name, role, company, never emails or LinkedIn URLs; anyone who asked to be taken off is left out), the room as counts (seniority, companies with several speakers), the total found, the how-to-open guidance this product's drafter itself enforces (what to anchor on, the banned phrases, the persona set), one real sample opener when you pass your own website, and a room URL where the full roster can be claimed. Cheap and cached for a conference already seen.
Return the user's current credit balance and the last 20 ledger entries.
Machine-readable QA state for drafts. Pass speaker_id alone to inspect one speaker, or campaign_id for the whole campaign. Returns per-message qa_score, qa_evaluated_at, qa_flags, hard-fail codes, actionable fix hints ({code, action, hint}), covering both scored hard-fail codes AND qa_flags-only signals like m2/m3_no_rotation or a founder approval/rejection stamp, which never appear in qa_reasons but can still make needs_human true on an otherwise clean score-8 draft, needs_human / needs_redo, approvable flag, plus per-speaker buckets (ready_to_send | needs_rewrite | needs_human | awaiting_qa | skipped | sent) and a rollup of the most common fail codes. A fix with action 'none' means don't touch this slot (already approved/locked); 'rescore' means the text was hand-edited and has no score yet, so run rescore_drafts (a clean score releases it); 'human_review' means a person must decide, not an automated rewrite (clear it with approve_message once they have). Use this to decide what to rewrite (request_rewrite) before pushing. Filter with only_failing=true. Paginated: pass next_cursor while has_more is true.
Read a campaign's ICP: target titles, industries, company size, buying signals, disqualifiers and notes. Also returns the current qualification split so you can see how the ICP is landing before you change it.
Founder-only: read a held customer preview and its live drafts. Other users are refused.
People who posted on LinkedIn that they're going to a known conference (slug from the conference catalog, e.g. 'aws-reinvent'). Saved results only: never starts a search and never spends. Returns a count and up to 3 first names with a short quote. Works with a sandbox key.
'Who's in the Room' briefing for one campaign, the same one the conference page shows: qualified count, seniority mix (C-suite, founders, VPs, heads), top companies, and the three people most worth meeting. Read-only.
Pipeline status for a campaign: latest run (stage, status, progress, error), needs_pitch and warning (true and the ask in plain words when the run is parked before research because the conference has no pitch), live counts (speakers, drafts, signals), and per-URL scrape status. The pipeline is cron-driven, so progress arrives in batches; poll every 15-30 seconds while a run is active, no faster.
The conference brief (the room as counts by seniority and company, how-to-open guidance, room URL) for a conference sayintel has ALREADY researched. Speaker names are not included until your human confirms the account; then get_conference_brief returns them. Never starts a new search or scrape and never writes a sample opener. Free: works with a sandbox key. A conference not yet researched answers not_found; a full account (after your human confirms) can research it with get_conference_brief.
Full dossier on a speaker: bio, talk, enrichment, AI research signals, drafted messages.
The approved-lead ledger for a campaign: who was approved, when, how many drafts, and from which surface (ui / copilot / mcp / api).
List the user's outbound campaigns (conferences). Paginated: pass the returned next_cursor to fetch the next page while has_more is true.
The Needs-you queue for one campaign (room): the things Q will not do alone. One row per reply not yet booked (effect meeting), and one room-level row each for QA holds, a waiting calibration sample and the people the qualifier could not call (effect intel). Each row has a stable id, a state (open, done, dismissed, closed), a primary_label and, for rows that open a screen instead of changing data, an href. Refreshed from the room's own tables on every call unless refresh=false.
List intent-based leads (LinkedIn signals) for a campaign with intent_enabled=true. Paginated: pass next_cursor while has_more is true.
List drafted/sent messages across a campaign, with QA fields (qa_score, qa_evaluated_at, needs_human, needs_redo). A row with superseded_at set is PARKED: a repair/redraft pass rejected it and the drafter will overwrite it in place (redo_reason says why); it is not a live draft. Filter by sent. Paginated: pass next_cursor while has_more is true.
Everything in a campaign waiting on a human: QA-flagged drafts with reason and preview, plus speakers the qualifier could not decide.
List the conferences sayintel has public speaker pages for. Pass query to filter by name. Free, no account needed: works with a sandbox key. Returns slug, name and the public room URL.
Enumerate the machine-readable vocabulary this API answers with: every QA hard-fail code with its remediation action and hint, the qa_flags-only soft-signal codes (batch-uniformity checks and provenance stamps that never appear in qa_reasons but can still drive needs_human), plus the envelope error codes and their HTTP statuses. Static reference, safe to cache. Use it to map a get_draft_qa code to its fix action without guessing.
People who said in public that they are going to this campaign's conference: confirmed and strong LinkedIn posts, 'see you there' comments and X posts, one row per person, newest first, plus the hosts of side events ranked for the room. Starts at the last 48 hours and widens to 7 then 30 days on a quiet room (window.label says which). Last-year and off-topic posts never count; guest lists are never read. Each row says whether the person already has a draft and whether draft_from_signal can act on it.
Inspect recent webhook deliveries (event, status, attempts, response status, error) so a failing integration is debuggable without guessing.
List the caller's registered webhook endpoints with their subscribed events, active flag and recent failure count. Secrets are never returned.
Record that a drafted message was ALREADY sent by some other route (a hand-send, an export, a push made outside sayintel): stamps sent_at = now and writes the same bookkeeping as approving it in the app, message_audit_log entry, approved-lead ledger row, needs_human cleared, exemplar promotion. It deliberately does NOT push anything to HeyReach: the premise is that the send already happened, so pushing would double-send. Use push_approved (send scope) to actually send, or approve_fits_and_clean_drafts (send scope) to approve-and-push. Idempotent unless force=true.
Pin a Say intel answer as a box on the campaign's Intel OS home. Pass the answer ask_intel returned (question, answer, big, unit, line, sources, confidence). Q asks the question again every morning at 6 in the owner's timezone and the box shows when the answer last changed. A room shows at most twelve pinned boxes. Reversible: the person removes a box from its zoom.
Push every send-ready, not-yet-pushed sequence in a campaign to its wired HeyReach campaign (optionally narrowed to speaker_ids). Only already-approved sequences go: all three slots drafted, QA-evaluated and cleared, not flagged needs_human/needs_redo, LinkedIn URL present, freshness guard passed. Messages are marked sent only after HeyReach confirms, and a human still arms the HeyReach campaign itself. Pass dry_run=true to see how many sequences would go, without pushing. Requires the 'send' token scope.
Founder-only explicit review release. Sends one ready email to the campaign owner, never prospect outreach. Requires send scope and a founder decision.
Rewrite drafted messages per an instruction (e.g. 'shorter, drop the exclamation, lead with the talk'). Targets specific speakers via speaker_ids, specific drafts via message_ids, or the whole campaign if neither is given. By default rewrites EVERY slot (M1/M2/M3) for each targeted speaker, pass slots (e.g. [1] to touch only M1) to scope it to specific slot numbers instead; when message_ids is given without an explicit slots, it's inferred from those messages' own slot numbers, so 'fix this one M1' only touches that M1, not the M2/M3 next to it. Untouched slots are never reworded or re-scored. Rewrites pass the same hard-fail QA gate as fresh drafts, scoped to the slots being rewritten: a rewrite is skipped (not saved) if it introduces a hard fail anywhere or leaves one on a slot it touched; hard fails that already sat on untouched slots do not block it and are listed in the summary so you can rewrite those slots next. Pass dry_run=true to see cost_preview (model calls, no credits) before committing.
Score-only QA on drafts exactly as stored: runs the LLM judge once per speaker on the stored M1/M2/M3 and the deterministic hard-fail gate on the same bytes, then writes qa_score, qa_reasons, qa_flags, role_fit and needs_human. NEVER rewrites a body - use it after update_message (hand-edited rows are unscored and nothing else re-scores them) or to refresh a stale verdict. A clean score releases a founder_edited row; a hard fail anywhere in the trio holds all three; rows flagged awaiting review (e.g. copilot_rewritten_awaiting_review) stay held at any score until a person clears them with approve_message. If the judge would have rewritten a slot, its stored text did not clear 8 and the score is capped at 7. Targets speaker_ids and/or message_ids (message_ids writes only those slots); with neither it takes the campaign's evaluated-but-unscored drafts. Skips speakers with a sent slot, fewer than 3 drafts, or drafts the QA finalizer has not evaluated yet. At most 25 speakers per call; the rest come back in deferred_speaker_ids. Costs model calls, no credits: pass dry_run=true for cost_preview.
Search speakers across the user's campaigns. Returns qualify_verdict (the ICP verdict that gates drafting, with qualify_reason and qualified_at) and triage_verdict (the cheap prequalify first pass; never the qualification). Filter by campaign_id, name (ilike), company (ilike), qualify_verdict, triage_verdict, reply_status, or selected. prequalify_verdict is a deprecated alias of triage_verdict. Paginated: pass next_cursor while has_more is true.
Select every speaker in a campaign whose qualify verdict is a fit. Reversible; approves and sends nothing.
Pin or hide a box on the Intel OS home for one campaign, mark a side event as going (subject = a side event id ranked for that campaign) or a field note as used. on=false removes the mark. Marks carry the campaign they belong to and never cross rooms.
Mark an intent lead as selected (true) or excluded (false).
Set how many approved leads make a campaign 'floor ready' (the Approvals readiness target; the UI offers 5, 10, 15 or 25). Changes no drafts and sends nothing.
Update reply status for a speaker. Allowed: replied, booked, no_reply, or null to clear.
Mark a speaker as selected (true) or excluded (false) from the outbound list.
Start the full pipeline run for a campaign (prequalify, enrich, qualify, research, draft). Idempotent: if a run is already queued or running, returns it instead of stacking a second. Already-enriched speakers are not re-billed. Pass dry_run=true to get cost_preview (credits this run would spend) without starting anything. Poll get_run_status for progress.
Take back a decision within its 12 second window: the row reopens and a reply marked booked goes back to replied. Refused after the window.
Change a campaign's ICP. Merges top-level keys by default (pass mode='replace' to overwrite the whole object). Use this when the wrong people are being qualified or parked - target_titles and company_size are the two fields that decide it. IMPORTANT: this does NOT re-judge speakers who were already qualified; existing verdicts stand until the campaign is re-qualified, and the response tells you how many are affected.
Edit drafted message content. Cannot edit a message after it is sent. The old QA verdict is cleared (it described text that no longer exists, including any hard-fail codes in qa_flags) and the row is stamped founder_edited, which protects your wording from every automated rewrite path. It is not send-ready until scored: nothing re-scores an edited row on its own, so run rescore_drafts on the speaker to score the new text. A clean score releases it; founder_edited alone never holds a row.
Return the SayIntel user id and granted scopes for this MCP token, plus the account's clients (id and name). On an account with clients, create_campaign needs one of those ids as client_id, or own_company: true.