For agents

Hand this to your agent

Any of these can drive it. None needs a special integration.

Give your own agent this link and a key, and it can run outreach here without you driving it. It says what it wants: a trade, a town, who you are after. MastroSDR does the work, finds the businesses, writes to each one, and queues the drafts.

Your agent is told where MastroSDR looked and what it left out, so it can explain the answer instead of handing you a list to take on trust.

The rules hold whoever is calling. Every first email waits for you, nothing is sent to anyone who opted out, and an agent cannot grant itself the right to send.

Hand this to an agent

The link stays current as this changes. A pasted copy is a snapshot, so prefer the link unless your agent cannot fetch a URL.

Connect your assistant

MastroSDR speaks the Model Context Protocol, so an assistant discovers the tools itself. There is no integration to write. Paste the address below and approve what it may do.

Any host that speaks MCP over HTTP works, named here or not. Only where you paste the address differs.

https://your-workspace-url/api/mcp

Settings, Connectors, Add custom connector. The same address works in claude.ai, Claude Desktop and ChatGPT. Paste it, sign in, and approve. No key to copy.

What an assistant may do

You tick the permissions on the approval screen, and sending is not ticked for you. Leave it off and the assistant writes drafts that wait for you in Review, however it is asked.

It can never change your settings or read your provider keys. Disconnect it in Settings, API, and its access ends immediately rather than at the end of a session.

Connecting through MastroSDR means your prospect data reaches whichever assistant you connect. That is your choice and your relationship with them, the same as the model and mailbox providers you connect.

# MastroSDR, for agents

You are working with MastroSDR, a cold outreach platform. Everything below is
what you need; you do not need the dashboard.

There are two ways in, and they are not the same size. Do not plan around one
on the assumption the other has it:

1. An MCP server at https://mastrosdr.com/api/mcp, where 47 tools
   describe themselves. Authorize with OAuth, or send an API key as a bearer
   token.
2. The REST API below, which is 25 operations and is the
   only one of the two you can read as a contract.

This called the two surfaces interchangeable until 2026-09-29, which is false
and was worse than useless: an agent that believed it would plan a workflow
around a capability, find it missing on the surface it chose, and conclude the
product could not do it at all. See `Only over MCP` near the end for what REST
cannot reach.

Base URL: https://mastrosdr.com/api/v1
Auth: send `Authorization: Bearer <your key or access token>` on every request.
Spec: https://mastrosdr.com/api/v1/openapi.json

Every successful response is wrapped in a `data` object, and every field named
below lives inside it: `{ "data": { "prospects": [...], "nextCursor": null } }`.
Errors are not wrapped; they are `{ "error": { ... } }` as described at the end.
Read `data.nextCursor`, not `nextCursor`, or you will read one page of a list
and believe you have seen all of it.

## The one rule that matters

Sending is irreversible and lands in a real person's inbox, signed by your
operator. Everything else here can be undone; that cannot.

- `messages:send` is a SEPARATE permission from `messages:write`, and is never
  implied by it. If your key does not have it, you may draft but a human
  approves every send. That is normal and intended.
- The do-not-contact list is checked before every send and cannot be undone
  through this API by design. Do not try to route around it.
- Never invent an email address. A guessed address that bounces damages the
  sending domain permanently.

## Step 1: find out what is blocking you

    GET /health

Returns `status` (`ready` or `needs_setup`), `canGenerate`, `canSend`, a
`blockers` array, and remaining budget. Call this first, and call it again
whenever something fails. Each blocker has an `id`, a `title`, and
`actionable` (false means it is waiting on an earlier step).

## Step 2: finish setup, if you can

Connect the AI provider your operator wants to use:

    POST /workspace
    { "integration": "deepseek", "apiKey": "<their provider key>" }

Valid integrations: `deepseek`, `anthropic`, `openai`, `openrouter`, `groq`
and `custom`. Every one is an AI provider. Sending is deliberately not
configurable here, because it needs a mailbox a person signs into. The key is
verified against the provider and the response tells you whether it worked.

`custom` is any endpoint that speaks the OpenAI chat-completions shape, hosted
or local: Ollama, LM Studio, vLLM and LiteLLM on a machine the operator owns,
or a vendor such as Gemini, Mistral, xAI, Together, Fireworks or Cerebras
through its compatible endpoint. Its URL and model ARE settable here: PATCH
`/workspace` with `customBaseUrl` and `customModel` alongside
`aiProvider: "custom"`, then POST the key the same way you would for a named
vendor. Set all three, because `custom` selected without a URL is a
misconfiguration you should report rather than retry.

This sentence said the opposite until 2026-09-18, and said a person had to do
it in Settings. That was never true of this API and it steered agents to
OpenRouter for a restriction that did not exist.

`openrouter` is the other way to reach a model this list does not name, and it
is equally settable here. Prefer whichever the operator already pays for; do
not default to one because the other looks harder.

Set what you actually sell. Generation refuses until this is replaced, because
the default is a sample:

    PATCH /workspace
    { "pitchText": "...", "fromName": "...", "fromEmail": "you@yourdomain.com" }

`fromEmail` must match a mailbox the operator has connected, and a
`postalAddress` is legally required before anything sends. There is no route
that sends without a mailbox. That is the point: it is their own account, so
replies come back to them and the reputation earned is theirs.

## Step 3: add prospects

    POST /prospects
    { "prospects": [ { "companyName": "Acme Ltd", "email": "jane@acme.com" } ] }

Read the `summary` in the response. It reports rows dropped as duplicates,
suppressed or invalid, and the counts will not match what you sent. That is
the API telling you something, not an error.

To page through what exists:

    GET /prospects?limit=200&cursor=<nextCursor>

Keep calling while `nextCursor` is not null. If you stop early you have seen
part of the list, not all of it.

### Finding businesses rather than bringing your own

    POST /prospects/discover
    { "niche": "barbershops", "location": "Austin, TX" }

Three fields in the response exist so you can explain your own answer, and you
should read all three.

`searched` is every phrasing actually run, in order. It is empty when the
answer came from the shared index, which means the call cost nothing. It has
more than one entry when the plain phrasing was used up and the search went
looking in a district or a neighbouring town. Those businesses are real and
they are not where the caller asked, so say so.

`exhausted` is true only when there is genuinely nothing left. One search
reaches about sixty businesses however many exist, so an empty page is
usually a spent phrasing rather than an empty town. Call again before
concluding a place has been used up.

`existing` and `suppressed` are marked per business, before you import
anything. Filter on them rather than importing and reading the summary
afterwards.

Nothing here is imported. This searches and annotates; `POST /prospects` is
what writes, and it takes `companyName` and `email` from the rows you chose.

So import before you search the same thing again. What the workspace already
holds is what gets excluded, which is what makes the next call return the NEXT
businesses; calling twice without importing in between returns the same twenty,
because nothing changed in between. Measured, not assumed: the same twenty, in
the same order, and the second call cost nothing because it came from the
shared index.

## Step 4: draft

    POST /messages/generate
    { "prospectId": "..." }

One draft per prospect per step. Costs a generation from the monthly
allowance, so check `plan.remaining` in `GET /analytics` before a big batch.

### If you wrote the email yourself

    POST /messages/compose
    { "prospectId": "...", "subject": "...", "body": "..." }

Saves your words as a draft and spends NO generation, because nothing was
generated. Use this whenever you did the writing.

What comes back is an ordinary draft: it waits in Review, the send endpoint
delivers it, and the same check for invented claims runs over your text as over
ours. The allowance exists to cover the cost of asking a model for words, and
your words did not cost that.

## Step 5: send

    POST /messages/{id}/send

Requires `messages:send`. Refused, correctly, if the address is suppressed,
the prospect replied, the campaign is paused, the daily cap is reached, or no
postal address is set.

## Channels: this product delivers email and nothing else

A campaign step carries a `channel`: `email` (the default), `linkedin`,
`x` or `instagram`. Omit it and you get email, so cadences written before
channels existed behave identically.

MastroSDR writes every touch and sends only the email ones. LinkedIn, X and
Instagram have no way for a tool to send on a customer's behalf, and the
libraries that appear to are driving a logged-in browser session against the
terms of service, which gets the operator's account restricted. So those
messages are drafted and wait for a person to send them by hand.

What that means for you:

- A non-email step is never `scheduled` and is never dispatched. It stays
  `draft` until a human marks it sent. Do not wait on it and do not retry it.
- `POST /messages/{id}/send` is email only. It will refuse anything else.
- A prospect with no destination for a step's channel is skipped for that step
  rather than downgraded to email. Populate `linkedinUrl`, `xHandle` or
  `instagramHandle` if you want those touches to happen.
- Social messages are written for the medium: no greeting, no sign-off, no
  links, and far shorter. The `subject` on one is an internal label the
  recipient never sees.

## Capacity: it comes from mailboxes, not from how many of you there are

Running more agents does not raise how much can be sent. The daily ceiling is
the sum of what the connected mailboxes can carry, each with its own warmup
ramp, floored by the operator's own setting.

`GET /health` reports what you have left today, and today is the number that
binds. Read `budget.sendsRemainingToday` from it before a run: when it is
zero, stop, and a send will return `rate_limited` with `Retry-After` in hours
rather than seconds, because the cap resets at midnight UTC. The honest fix is
another sending mailbox and domain, not more concurrency from you. An SMTP
mailbox you can add yourself with `connect_smtp_mailbox`; an OAuth one needs
the operator at a consent screen.

`GET /analytics` carries the monthly numbers, and it is a trap worth naming.
Three of the four are fair-use ceilings that report six figures and will
not stop anybody, so an agent that polls them waiting to be refused polls
forever. The fourth, `plan.remaining.prospectSearches`, is the only monthly
allowance that can still refuse work, and `GET /health` reports it as
`budget.prospectSearchesRemainingThisMonth`. Read that one, and read
`budget.includedDraftsRemaining` before a big batch of generations: it is the
binding pool for a workspace with no provider key of its own.

Every draft records which key made it, so your work is attributable and
separable from every other agent's.

## Booking: how meetings happen, and what your own calendar access is for

When a prospect replies asking for a call, MastroSDR reads the operator's
free/busy, proposes up to three times, and books the event with an invite once
the prospect picks one. You do not do any of this, and you do not need a
calendar integration of your own for it to work.

What it requires, so you can tell the operator what is missing:

- An Outlook mailbox connected with calendar permission. Booking needs a
  calendar to read free time from and to write the event into, and a Gmail
  connection grants neither: it grants permission to send and nothing else.
- A Gmail mailbox can therefore send and can never book, and MastroSDR does
  not ask Google for permission to read a mailbox either, so a reply to a Gmail
  address is never seen: no cadence stops, no inbox entry, no booking from it.
  A mailbox on the operator's own server or sending domain can send and read
  replies and can never book, because there is no calendar to reach.
- This is a deliberate scope decision rather than a setting somebody forgot,
  and the Sending screen says which state each mailbox is in. If the operator's
  only mailbox is Gmail, say plainly that replies and booking will not work and
  why, rather than retrying.
- Booking switched on in Settings. It is off by default, because answering
  replies means reading their bodies, and that is the operator's call.
- By default the proposed times wait for the operator's approval in Meetings.
  After approval, confirmation and the calendar event are automatic.

If you have your own calendar access, through an MCP server or otherwise, the
rule is simple: keep the operator's calendar accurate and do not create events
for meetings MastroSDR is negotiating. It reads free/busy immediately
before booking, so an event you add blocks that slot. Busy always wins, which
makes the safe failure a missed slot rather than a double booking.

It reads free/busy only, never event titles or attendees, and it never
answers a reply that is not clearly asking for a meeting. Ambiguity goes to
the operator instead.

## Sending domains: advice your operator needs before the first send

You CAN connect a mailbox, if it is one that speaks SMTP: call
`connect_smtp_mailbox` over MCP with the address, host, port and an app
password. Gmail and Microsoft 365 both issue app passwords, and both are
obtained in a browser at the provider rather than here. What you cannot do is
the OAuth grant, which is a consent screen a person has to see.

This guide said flatly "You cannot connect a mailbox" until 2026-09-18, which
sent agents to a browser for the one step that was blocking them and that they
could have done.

Either way, you are the one who should say this before the wrong domain gets
connected:

- Cold outreach should come from a domain bought for it, like
  try-theirbrand.com, with a Gmail or Microsoft 365 inbox on it. Never from
  the domain their real business email runs on. A domain marked as spam stays
  marked, and their invoices and password resets live on that domain.
- Gmail and Outlook are the right carriers at this volume. Their servers have
  the best sending reputation there is; the thing at risk is the domain, not
  the route.
- More volume means more mailboxes on more domains, never more sends per
  mailbox. The warmup ramp and the per-mailbox caps enforce this and are not
  negotiable, so do not plan around raising them.
- The Sending screen checks SPF, DKIM and DMARC on the connected domain and
  says what is missing. Point the operator there after they connect.

If the operator's fromEmail is on their primary business domain, that is
worth flagging to them once, plainly, before a campaign starts. It is their
call to make, but it should be made knowingly.

## Errors: branch on the code, never on the message

Every error is `{ "error": { "code", "message", "docs" } }`. The codes are
stable:

- `unauthorized`: key missing, invalid, revoked or expired. Stop.
- `forbidden`: key lacks the scope in `required_scope`. Stop and ask.
- `invalid_request`: the body is wrong and the message names the field. Fix
  and retry.
- `not_found`: no such record in this workspace.
- `conflict`: the current state contradicts the action. Retrying the same
  call will not help. Change something first.
- `quota_exceeded`: plan limit. Stop; a human must upgrade.
- `rate_limited`: back off for `Retry-After` SECONDS. Read the header: on a
  send this can mean the daily cap, which is hours, not the per-minute limit.
- `provider_error`: the upstream AI or email provider failed. Usually worth
  one retry.
- `service_unavailable`: this deployment cannot do this at all, for instance
  because storing credentials is switched off. No call and no scope changes it,
  so stop and say so. The line between this and `conflict` is who can fix it: a
  provider key or a mailbox belongs to the workspace and you can connect one.

## Things that will trip you up

- Quotas are monthly and enforced server-side. `GET /analytics` before a batch,
  for the monthly numbers, and `GET /health` for today's.
- Campaign step limits differ by plan: 1 on Free, 8 on Pro, 12 on Scale. They were cut from
  twenty on Scale, so plan around what the workspace is on rather than what a
  competitor prints.
- There is no single daily number. One mailbox carries at most
  50 outreach emails a day, less than that while it is
  warming up, and the day's ceiling is the sum across every connected mailbox.
  This said "defaults to 200 across all plans", which was true of nothing: the
  outreach ceiling is 50 and the separate per-mailbox
  provider cap defaults to 400. Read what is left from `GET /health` rather
  than working it out from a number in a document.
- Team management, billing and the audit log are deliberately NOT in this API.
  An agent should not be able to invite people or change the plan.

## MCP

There is an MCP server at https://mastrosdr.com/api/mcp using the same key,
if your host speaks it. Tools are filtered to your key's scopes, so a tool you
cannot see is one you were not granted.

Transport is HTTP, and auth is either OAuth 2.1 or `Authorization: Bearer`.
That is all it takes, so hosts known to connect include claude.ai, Claude
Desktop, ChatGPT, Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Zed, Cline,
Roo Code, Continue, Copilot in VS Code, JetBrains AI, Amp, Goose, OpenCode,
Aider, Kiro and Warp. The list is an example, not a gate: nothing here checks
which host is calling.

The connector screens inside claude.ai and ChatGPT use OAuth and have nowhere
to paste a key. Everything else on that list takes the URL plus a bearer key.

## Only over MCP

47 tools against 25 REST operations, and
20 of those tools have no REST equivalent at all. They are
capabilities you cannot reach over REST, so if you chose REST they are not
options you can fall back to later:

- `list_replies`: the inbox: what people wrote back, paged
- `mark_reply_handled`: clear one reply from the inbox without opening it
- `list_sdrs`: every agent account on the workspace and what each has sent
- `propose_targets`: propose a segment to write to and gather the evidence for it
- `list_targets`: the segments already saved
- `update_target`: edit a proposed segment and its bounds
- `list_tasks`: the calls and follow-ups a human still has to make
- `complete_task`: close one of them
- `list_meetings`: booked and proposed meetings, and their state
- `list_call_queue`: call notes waiting to be written up
- `list_sending_domains`: the domains this workspace sends from, and what DNS is missing on each
- `add_sending_domain`: start the DNS records for a new sending domain
- `add_sending_address`: the addresses a verified sending domain writes from, each starting a 21-day warmup
- `buy_sending_domain`: buy up to the sending domains the plan includes, with the operator's registrar account and no card. No plan includes any now, so every call is refused with quota_exceeded; a person buys a domain by card on the Sending domains screen instead, and add_sending_domain covers one the workspace already owns
- `enrich_domains`: find and fill contact details for domains you already hold
- `read_website`: read one page and hand back the text, which spends a generation
- `connect_smtp_mailbox`: connect an SMTP mailbox, which the REST API cannot do at all
- `discard_draft`: throw a draft away instead of sending it. There is no REST delete on purpose: a delete landing on a message already in flight leaves one that reached the provider with nothing recording it
- `list_activity`: the workspace's activity log, which REST deliberately does not expose. Published, but callable only from a signed-in session: no API key or connected assistant reaches it, whatever scopes it holds
- `list_team`: who is on the workspace and what role they hold. Same rule as list_activity: a signed-in session only

Each carries its own scope, and a key without it does not see the tool in
`tools/list` at all rather than being refused at the call. That is worth
knowing before you conclude a capability does not exist: it can mean your key
was never granted it.