Selda MCP Server
The Selda MCP server lets external AI tools (Claude Desktop, Claude Code, ChatGPT, or any Model Context Protocol client) drive a user’s Selda workspace: list projects, inspect leads and campaigns, read threads, and run the GTM pipeline. It is gated behind an API key. Every plan can connect with a test key (test mode, the full product, nothing sends for real); a live key requires a paid plan (Pro and up).
The Selda web app is at https://app.selda.ai.
Transport, in one answer
Read this first if you are writing an integration, because the rest of the page describes three things and you only need one of them.
Use https://mcp.selda.ai/api/mcp. It speaks MCP Streamable HTTP over POST, JSON-RPC 2.0
in the body. It is stateless: there is no session to establish, Mcp-Session-Id is neither
required nor issued, and responses come back as plain JSON, never SSE. GET on the same URL
returns a health object carrying the server version, which is the only thing here you can read
without a key. DELETE is accepted and does nothing.
Protocol version. initialize echoes the version you asked for when it is one of
2025-06-18, 2025-03-26 or 2024-11-05, and answers with the newest otherwise. Nothing on this
server is version-specific, so pick whichever your client speaks.
Authenticate either way:
?key=sk_live_...in the query string, orAuthorization: Bearer sk_live_...- or OAuth: the server publishes
/.well-known/oauth-protected-resource, so Claude.ai, ChatGPT and Cursor can log you in with no key to paste
Connecting from ChatGPT works through the same URL and the same OAuth discovery. ChatGPT
additionally requires a server to expose two tools named search and fetch, with fixed shapes;
Selda serves both, and they are described under Tools.
If you are writing a script rather than connecting an AI client, skip MCP entirely and call the
HTTP API directly: POST to the deployment’s .convex.site host with a body of { fn, args }.
That is what both MCP servers do underneath, and it is simpler than speaking JSON-RPC.
The pieces
There are two MCP servers in front of one HTTP API, and their toolsets are not the same size: the hosted server serves every tool, the local stdio server serves a subset. The exact split is in the generated table under Tools. Unless you specifically need a local process, use the hosted one.
-
The hosted MCP server (recommended),
https://mcp.selda.ai/api/mcp, implemented inapi/mcp.ts, runs on Vercel Edge, speaks MCP Streamable HTTP, stateless. Connect it from Claude.ai (Add custom connector) or any remote-MCP client withhttps://mcp.selda.ai/api/mcp?key=sk_live_xxx. No local install. -
The local Node MCP server,
mcp-server/index.ts. A stdio MCP server spawned by the client (e.g. Claude Desktop vianpx tsx) that forwards each tool call over HTTP to the same backend.
Both call the Convex HTTP endpoints, defined in convex/mcpApi.ts and routed
in convex/http.ts:
| Endpoint | Method | Purpose |
|---|---|---|
/mcp/query | POST | Read operations (queries) |
/mcp/mutate | POST | Write operations (mutations) |
/mcp/run | POST | Actions (pipeline / engine) |
/mcp/material/upload | POST | Raw file bytes in, storageId out, intake for material.import |
Each also has an OPTIONS route for CORS preflight. The base URL is the
deployment’s .convex.site host (prod default:
https://brave-buzzard-349.eu-west-1.convex.site, overridable via
SELDA_API_URL / VITE_CONVEX_SITE_URL).
The HTTP body is always { fn, args }, where fn is a registry key (e.g.
projects.list) and the handler returns { value } on success or
{ error } with a non-200 status on failure.
Authentication & scoping
- API key format:
sk_live_…(prefix + 40 random alphanumeric chars). - Transport: sent as a Bearer token:
Authorization: Bearer sk_live_…. The Node server reads it from theSELDA_API_KEYenv var. - Creation: keys are minted via
apiKeys.createKey(called from the app UI: Settings → Apps → Selda MCP). The full plaintext key is returned once at creation and never stored or shown again. - Storage: only a SHA-256 hash of the key (
keyHash) plus a displaykeyPrefix(first 16 chars +...) are persisted in theapiKeystable. The plaintext key is never written to the database. - Validation: every HTTP call runs
apiKeys.validateKeyover the request’s hashed token. A key is rejected if it is missing/expired, revoked (revokedAt), the owning user is deleted/disabled, or the owning org no longer exists. On success it returns the key’sorgId,userId, andscopes. - Revocation:
apiKeys.revokeKey(UI) setsrevokedAt; the key stops validating immediately.
Scopes
| Scope | Grants | Self-grantable |
|---|---|---|
read | /mcp/query (and required by all read calls) | Yes |
write | /mcp/mutate | Yes |
pipeline | /mcp/run (engine / pipeline actions) | Yes |
admin | cross-org admin queries (all users/orgs/projects/stats) | No |
Default scopes for a newly created key are ["read", "write", "pipeline"].
The admin scope exists for internal cross-org tooling and is not granted
through normal self-service key creation (security-hardened, see
the internal security notes).
Org isolation
Each HTTP handler:
- checks the required scope for that endpoint (e.g.
/mcp/queryrequiresread), returning403if missing; - injects the key’s
orgId(and, where relevant,userId) server-side into the called function’s args.
Because the org id comes from the validated key, never from the request body,
a key can only ever reach data belonging to its own organization. Every
underlying mcpQueries.* function re-verifies org ownership (e.g. a lead is only
returned if its project’s orgId matches the key’s org). One org’s key cannot
read or mutate another org’s projects, leads, campaigns, or messages.
What you can actually do
The MCP tools below are a curated front end. Underneath, the HTTP API exposes the full registry, every fn you can pass to { fn, args }. If a tool doesn’t exist for what you want, the fn
probably does. Reach for this list when you are writing a script rather than chatting with a client.
Every call is scoped to the API key’s organization. Never send orgId, it is injected
server-side from the validated key, and a value in the body is ignored.
Push your own research in
You already produce per-prospect research somewhere else (a Claude Code pipeline, a scraper, a consultant’s PDFs). Two ways to get it in:
| I have | Use |
|---|---|
| Files on disk, laid out one folder per company | /mcp/material/upload per file, then material.import |
| A company + contact + my own analysis text | leads.add with analysis |
Folder shape is the mapping. The path you send in X-Selda-Path is how Selda knows which company
a file belongs to. boreo/filterit/analyysi.pdf means “this belongs to Filterit”. Send the path
relative to the folder you’d otherwise have dragged into the app.
# 1. One upload per file. Keep each returned storageId.
curl -X POST https://api.selda.ai/mcp/material/upload \
-H "Authorization: Bearer $SELDA_API_KEY" \
-H "X-Selda-Path: boreo/filterit/analyysi.pdf" \
-H "Content-Type: application/pdf" \
--data-binary @analyysi.pdf
# → { "value": { "storageId": "…", "path": "…", "sizeBytes": 20481 }, "request_id": "req_…" }
# 2. Turn them into one campaign.
curl -X POST https://api.selda.ai/mcp/run \
-H "Authorization: Bearer $SELDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"fn":"material.import","args":{
"projectId":"<workspace id>",
"files":[
{"path":"boreo/filterit/analyysi.pdf","storageId":"…","sizeBytes":20481,"mimeType":"application/pdf"},
{"path":"boreo/tornokone/tiedot.md","storageId":"…","sizeBytes":512,"mimeType":"text/markdown"}
]}}'Optional args: assignments ([{ folderName, domain }] when you know a company’s domain and don’t
want Selda guessing), excludedFolders, campaignName, autoAdvance.
The message structure
A campaign can carry a document that decides what its messages are made of. Without one, messages are written from the brief alone.
curl -X POST https://api.selda.ai/mcp/mutate \
-H "Authorization: Bearer $SELDA_API_KEY" -H "Content-Type: application/json" \
-d '{"fn":"campaigns.setMessageStructure","args":{
"campaignId":"...",
"blocks":[
{"kind":"generated","role":"subject","aiInstruction":"Name the one thing you found on their site."},
{"kind":"generated","role":"body","label":"Observation","aiInstruction":"State what you verified about them. One observation, plainly, no consequence clause."},
{"kind":"locked","role":"body","label":"Offer","content":"Ensimmäinen vaihe on 380 euroa, alv 0 %. Jos työ ei ole valmis kahdessa viikossa, en laskuta."},
{"kind":"generated","role":"body","label":"Ask","aiInstruction":"Ask whether the direction looks right. Never propose a meeting."}
],
"form":{"paragraphs":6},
"mustNever":["Never call it an audit","Never name another customer"]}}'locked ships word for word. Nothing rewrites it, and the composer assembles around it.
generated carries your instruction and Selda writes that part from real material. The one
thing an instruction cannot do is make a claim true: where it asks for an observation the research
does not establish, that part is left out rather than invented.
campaigns.lockMessageStructure with locked: true freezes the whole document; false takes it
back to draft, because a lock the operator cannot undo is Selda refusing them their own document.
Neither call sends anything. A structure decides how a message is written; a person still approves every send in the app.
material.import creates the campaign and the company list and stops there. Pushing material is
permission to read the material, not to run a campaign, the same rule as dropping a folder in the
app. From there Selda proposes who to reach at each company, justified from what you sent, and a
human accepts each one.
autoAdvance is how a script grants further stages, and it is per stage:
| Value | Grants |
|---|---|
| omitted | nothing, stops at the company list |
["leads"] | contact lookup, then stops before any message is written |
true | contact lookup and message writing (this spends credits) |
Neither form can send. Sending is launchRun behind human approval, and there is no fn for it.
Limits: 25 MB per file, and the same per-key rate limit as every other call. A projectId outside
your key’s organization returns the same “no such workspace” error as one that doesn’t exist.
The full registry
Every function, one page each, with its endpoint, its scope, whether a free test key can call it, what it takes and an example request:
- API functions in the sidebar, one entry per
fnwith its endpoint badge - Function reference for the whole list on one page
Those pages are GENERATED from convex/lib/mcpRegistry.ts, the same table these endpoints dispatch
from, and a test fails if they drift from it. The tables that used to sit here were written by hand,
and a hand-written copy of a registry is a copy that goes wrong in both directions at once: the API
page it replaces listed nineteen functions that are in no registry and was missing thirty-nine that
are.
The API also serves the list itself, no key needed:
GET https://api.selda.ai/mcp/capabilitiesThe same list appears in the app under Settings, Integrations, MCP server.
Webhooks
The other half of the loop. Register an endpoint with webhooks.create and Selda POSTs to it,
instead of you polling messages.byLead on a timer.
Events
| event | fires when |
|---|---|
draft.ready | a draft is written and waiting for someone to approve it. This is the one that makes the loop work: it is how you learn there is something to look at, instead of polling |
draft.failed | drafting was attempted and produced nothing. Carries the reason, so a silent gap is never mistaken for a quiet week |
reply.received | someone answers one of your messages |
meeting.booked | a meeting is booked |
lead.added | a lead is added, including by your own leads.add |
lead.status_changed | a lead’s status changes |
message.sent | a message goes out, after a human approved it |
campaign.completed | a campaign run finishes |
credits.low | the balance is running out |
test.ping | you asked for a test delivery |
What arrives
POST to your URL, Content-Type: application/json, with two headers:
| header | value |
|---|---|
X-Selda-Event | the event name, e.g. reply.received |
X-Selda-Signature | sha256=<hex>, HMAC-SHA256 of the raw body, keyed with your webhook secret |
The body is always the same envelope:
{ "event": "reply.received", "timestamp": 1786174818273, "data": { } }Verifying the signature
Sign the raw body bytes, before any JSON parsing, a re-serialized object will not match.
import crypto from "node:crypto";
const expected =
"sha256=" + crypto.createHmac("sha256", process.env.SELDA_WEBHOOK_SECRET)
.update(rawBody) // the raw string, not JSON.parse(...)
.digest("hex");
// Constant-time compare, never ===
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));Delivery, retries and disabling
- 3 attempts per event, with a 10 second timeout each
- Retry delays: 30 seconds, then 5 minutes. Byte-identical body and signature on every attempt, so a retry verifies exactly like the first try
- Any 2xx counts as delivered. Anything else is a failure
- After 10 consecutive failures the endpoint is disabled rather than retried forever.
webhooks.listshowslastStatus,failureCountanddisabledAt, so you can see it happened
Your endpoint should answer fast and do the work afterwards. Ten seconds is the whole budget, and a slow 200 is treated the same as a timeout.
Zapier, Make and n8n
These do not speak MCP and do not need to. Point webhooks.create at a Zapier Catch Hook
(or a Make/n8n webhook node) and events arrive as plain JSON. In the other direction, Webhooks
by Zapier can POST to https://api.selda.ai/mcp/mutate with an Authorization: Bearer sk_…
header and a {"fn":"leads.add","args":{…}} body. No integration to install on either side.
What MCP does not do
This is the shortest section and the most important one. Everything below is a deliberate absence, not a gap waiting to be filled, and none of it is coming later.
It cannot send
There is no fn that sends a campaign. Sending is launchRun, and launchRun is not in any
registry, has no tool wrapper, and never will be. A script can go all the way to drafted messages
sitting in review. A person opens Selda and presses send.
This is the product, not a limitation of the API. Anyone can build a thing that mails 10,000 strangers overnight; the reason Selda’s messages get replies is that a human looked at each one before it went. An API that could bypass that would be selling something different.
Nothing here changes it: not a parameter, not a flag, not a header, not a plan, not an add-on.
selda_ingest_event can create a lead and put it on a live campaign, and that lead still waits in
review with no message written. If you find a way to make Selda send from a script, that is a bug
and we want to hear about it.
It cannot grant itself permission
autoAdvance is a value you pass. It is recorded on the run and visible in the app, so a person can
see that a script asked to go further and how far. There is no call that turns a gate off, and no
call that approves a draft on a human’s behalf. (messages.approve marks a draft approved; it still
does not send it.)
It cannot create a workspace
A workspace is set up by a person in the Selda app. There is no fn for it, by any key: it was
removed rather than gated, because a rule enforced by a plan tier is a rule with a price on it.
Once the workspace exists, everything inside it can be built and grown from a script: the Brain,
the knowledge, leads, your own material, campaigns.
It cannot read another organization
Every call is scoped to the key’s organization, injected server side from the validated key. An
orgId in your request body is ignored. The admin scope is cross organization, exists for
internal tooling, and is never issued by self service key creation.
A free test key cannot make the engine find people
A test key is free on every plan and gives you the whole product to build against. It can read everything in the workspace and write your own data in. It cannot run the parts that spend real money to discover or enrich a real person. See Test keys and live keys.
The Brain: what Selda knows about your business
The Brain is the structured half of a workspace’s knowledge: products, partners, references and
proof, company facts, notes, and the hard rules Selda must never break. (knowledge.get /
knowledge.set are the other half, free prose.) Both feed every message Selda writes.
This is what makes it possible to stand a workspace up from a script. The workspace itself is created by a person in the app; everything inside it can be filled and grown from here.
fn | Tool | Does |
|---|---|---|
brain.list | selda_list_brain | read every item, plus the allowed types |
brain.add | selda_add_brain_item | add one item |
brain.update | selda_update_brain_item | rewrite one item’s title and/or body |
brain.remove | selda_remove_brain_item | take one item back out |
All four are free on a test key.
Item types
Exactly six, and a value outside them is refused rather than stored. An item the app cannot render is an item nobody can see, edit or delete, which is worse than a rejected call.
type | For |
|---|---|
company | a fact about the business itself |
product | something it sells |
partner | a partner or reseller |
reference | proof: a named customer, a result, a case |
note | anything else worth knowing |
avoid | a hard rule Selda must never break, e.g. “never claim we are the cheapest” |
Example
// POST /mcp/mutate
{ "fn": "brain.add", "args": {
"projectId": "<workspace id>",
"type": "reference",
"title": "Summer campaign 2026",
"body": "Ran this campaign for six weeks and booked 14 meetings from it."
}}
// -> { "value": { "ok": true, "id": "m2x8k1-3-a9f2", "count": 7 }, "request_id": "req_..." }// POST /mcp/query { "fn": "brain.list", "args": { "projectId": "<workspace id>" } }
{
"value": {
"types": ["company", "product", "partner", "reference", "avoid", "note"],
"items": [
{ "id": "m2x8k1-3-a9f2", "type": "reference", "title": "Summer campaign 2026",
"body": "Ran this campaign for six weeks...", "createdAt": 1785321674000 }
]
},
"request_id": "req_9f2c1a4b7e0d3a6f8c2b1e05"
}brain.update and brain.remove answer { "ok": false, "reason": "not_found", "id": "..." } when
that id is not in the workspace, rather than reporting a success that changed nothing.
What you write here may be quoted verbatim in a message to a real customer. Keep it factual. An invented reference in the Brain becomes a lie in an email.
Inbound: telling Selda something happened
Every other lead in Selda starts from Selda’s own search. events.ingest is the other direction:
your website, your form, your ad landing page, your own tooling reports that something happened,
and Selda works out what that means.
One call. Not “add the lead, then write the note, then attach it to a campaign” with your code holding the order straight.
- new person, so a lead is created
- somebody Selda already knows, so the event lands on their existing lead
- an address that asked Selda to stop, so nothing is created and you are told why
MCP tool: selda_ingest_event. HTTP: POST /mcp/mutate with fn: "events.ingest".
Requires the Selda Inbound add on on a live key. Free on a test key, so you can build the
whole integration before you buy anything.
autoAdvance: the answer is written before anyone looks
Without it, an arriving lead is stored and nothing else happens: status: new, no messages. The
lead is safe and somebody still has to notice it, open it and start from a blank box.
Pass autoAdvance: true and Selda reads the workspace’s Brain, its price list, its offer
rules, its tone, and the things it has forbidden itself to say, writes the reply in the language
the enquiry was written in, and leaves it in the Sales Inbox. Then draft.ready fires, so you find
out because you were told rather than because you checked.
It is off by default because it spends credits, and it never sends. The draft waits for the same human approval as every other draft in Selda.
If the workspace has no Brain material to answer from, nothing is drafted and draft.failed says
no_brain_material. That is deliberate: a quote answered from an empty Brain is an invented price,
and an invented price approved by somebody who assumed Selda knew the real one is worse than no
draft at all.
Request
{
"fn": "events.ingest",
"args": {
"projectId": "<workspace id>", // required
"type": "form_submitted", // required. Your own wording.
"identity": { // required. At least one of email / linkedinUrl / a name.
"email": "matti@example.fi",
"domain": "example.fi", // context only, never used to decide identity
"linkedinUrl": "https://www.linkedin.com/in/matti/",
"name": "Matti Meikalainen", // or firstName / lastName separately
"company": "Example Oy",
"jobTitle": "CTO",
"phone": "+358 40 1234567"
},
"payload": { "form": "analysis", "findings": ["Languages: missing"], "price": 290 },
"source": "example.com",
"occurredAt": "2026-08-06T10:21:14.152Z", // ISO 8601 or epoch ms. Defaults to now.
"idempotencyKey": "report-example.fi-2026-08-06",
"attachToRunId": "<campaign run id>", // optional
"analysis": "Full research write-up...", // optional, grounds the eventual message
"tags": ["inbound", "analysis"]
}
}Response
{
"value": {
"leadId": "k57f...",
"created": true,
"matchedExisting": false,
"matchedBy": null,
"blocked": false,
"reason": null,
"eventId": "j92a...",
"replayed": false,
"attached": { "ok": true, "runId": "m31c..." },
"identityScanTruncated": false,
"occurredAtFallback": false,
"message": "Lead created and waiting for review. Nothing has been sent."
},
"request_id": "req_9f2c1a4b7e0d3a6f8c2b1e05"
}Every field is always present, so you never have to decide whether a missing key means false or
means the field does not exist.
| Field | Meaning |
|---|---|
leadId | the lead this event is about. null only when blocked |
created · matchedExisting | exactly one is true, unless blocked |
matchedBy | "email", "linkedin", or null |
blocked · reason | true with suppressed_email / suppressed_domain when the address asked Selda to stop |
eventId | the row on the lead’s timeline |
replayed | true when this idempotencyKey was already seen. Nothing changed |
attached | { ok: true, runId }, or { ok: false, reason } with not_requested, run_not_found, run_in_another_workspace, already_on_run, blocked |
identityScanTruncated | the LinkedIn only match hit its 1000 lead ceiling, so a match may have been missed. Said out loud rather than passed off as “no match” |
occurredAtFallback | your occurredAt was unusable or out of range, so the receive time was used |
Identity is a person, never a company
Matching is on the email address, and failing that the LinkedIn profile. Never the domain.
Two people at the same employer share a domain, and merging them would attach one person’s history
to the other with no way to tell afterwards. So domain is stored as company context and is never
allowed to decide who someone is. A near miss creates a new lead you can merge by hand, which is
recoverable. A wrong merge is not.
An event carrying only a domain is refused, because it names an employer and not a person.
Retries are safe
Pass an idempotencyKey and a repeat returns the first result with replayed: true, creating
nothing. This is what lets you call Selda from a webhook handler that has no way of knowing whether
its first attempt landed.
Joining a campaign
attachToRunId puts the lead on an existing campaign’s review list. The campaign already has a
tone, a signature, a follow up rhythm and safety rules that somebody approved; an inbound lead
joining that beats it inventing a process of its own.
It joins at review, with no message written. A launch selects drafts that are written, approved or queued. A row created this way is none of those, so no launch can pick it up and a human sees it first, exactly like a discovered lead.
Errors specific to this call
| Condition | Result |
|---|---|
identity names no person | 500 function_error, message says what to send instead |
projectId in another organization | 500 function_error, “Project not found or unauthorized” |
| address or domain suppressed | 200 with blocked: true. Not an error: the event is recorded, no lead is made |
| workspace lacks the add on (live key) | 403 addon_required |
Flows: what happens when something arrives
events.ingest records what happened and can draft a reply. A flow is the layer above it: the
workspace has said, once, what should happen to an arrival like this one, and the same definition is
readable and writable from here and from the app.
A flow is a trigger plus ordered steps.
{
"name": "Website enquiries",
"trigger": { "kind": "inbound_event", "eventTypes": ["widget_enquiry"], "sources": ["example.com"] },
"steps": [
{ "id": "s1", "kind": "understand", "skillId": "<from flows.saveSkill>",
"categories": [
{ "key": "video", "label": "Video production", "hint": "shoots, edits, event coverage" },
{ "key": "seo", "label": "Site and SEO", "hint": "visibility, rebuilds, bookings" }
] },
{ "id": "s2", "kind": "route",
"routes": [
{ "categoryKey": "video", "runId": "<campaign run id>" },
{ "categoryKey": "seo", "inboxOnly": true }
] },
{ "id": "s3", "kind": "draft_reply" },
{ "id": "s4", "kind": "notify", "emails": ["you@company.com"], "webhook": true }
]
}An empty eventTypes or sources means any. trigger.kind may also be manual, for a flow
you only run by hand while you are writing it.
The steps, and the line they cannot cross
kind | Does | Never |
|---|---|---|
understand | reads the enquiry against your instruction file and the Brain, and picks ONE of your categories | invents a category. No fit answers unknown, with a reason |
route | sends each category onto a campaign run, or onto none (inboxOnly) | routes a category you have not decided about. It says so in the log instead |
attach | puts the lead on one campaign run’s review list whatever the category | writes a message there |
draft_reply | writes the answer into the Sales Inbox, where it waits for approval | sends |
notify | emails people, and fires this workspace’s webhooks as flow.completed | reaches the enquirer |
There is no sending step and there will not be one. launchRun is refused by path for every
key, and a flow’s furthest act is a draft plus a notification. That is the same gate a person meets
in the app, not a weaker one for automation.
When a flow and autoAdvance both write
If a flow matching the event has a draft_reply step, it owns the drafting and events.ingest’s own
autoAdvance stands down. The response says so (autoDraft.reason: "flow_writes_it") and lists the
flows the event set off, so nothing has to be inferred from an inbox that looks emptier than expected.
Reading what it did
flows.runs returns every step of every run with a status of ok, skipped or failed and a
summary in plain words. A skip is a row with its reason, never an absence. That is the thing to read
when a flow behaves unexpectedly, rather than re-reading its definition.
Test keys and live keys
Two kinds of key, and the difference is not “test data versus real data”. It is what Selda is allowed to spend on your behalf.
sk_test_…test key. Free on every plan, including Free. Read everything in the workspace, write your own data in, build and test a whole integration.sk_live_…live key. Requires a paid plan (Pro and up). Adds the parts that cost money: finding people, enriching them, running discovery, and reaching a real recipient.
What a test key can do
Everything you need to build against Selda without paying:
- every read:
projects.*,leads.list/leads.get,campaigns.*,runs.status,messages.*,knowledge.get,brain.list,credits.info,connectors.list,webhooks.list - every write of your own data:
leads.add/addBatch/update/addTag/merge/delete,knowledge.set/append,brain.add/update/remove,projects.updateContext,campaigns.*(the legacy table),messages.approve,webhooks.*,connectors.create/delete POST /mcp/material/uploadandmaterial.importwithoutautoAdvanceevents.ingest, so you can build and test an inbound integration end to endmessages.generate,replies.draft,replies.classify: drafting against the companies you already have is the point of test mode
What needs a live key on a paid plan
fn | Why |
|---|---|
company.lookup | resolves real people’s contact details through a paid provider, billed per call |
leads.enrich · leads.enrichBatch | same, per record |
engine.start | runs discovery: web search, crawls, per lead credit spend |
material.import with autoAdvance | the grant carries the run into contact lookup and drafting |
connectors.sync | pulls and enriches from an outside source |
There is no send function, on any key. This table used to list messages.send as something a
live key unlocks. No such function exists in the registry and none is planned: sending happens only
in the Selda app, after a human approves each message. A live key buys the calls above, which spend
money or reach a provider, never the one that reaches a recipient. GET /mcp/capabilities has
always said so in its own note; this page did not.
A test key calling one of these gets a 403 whose message is a sentence explaining what the
call does, why it needs a live key, and what your key can still do. The machine readable form is in
code:
{
"error": {
"type": "authorization_error",
"code": "live_key_required:paid_contact_data",
"message": "This looks up real people's contact details through paid data providers, so it needs a live key on a paid plan. Your test key can still add companies you already have, import your own material, and draft against them.",
"request_id": "req_9f2c1a4b7e0d3a6f8c2b1e05"
},
"request_id": "req_9f2c1a4b7e0d3a6f8c2b1e05"
}The reasons you can get back: paid_contact_data, paid_engine_run, external_sync,
workspace_creation, sending, admin_only.
Each capability in GET /mcp/capabilities carries sandboxAllowed and liveOnlyReason, so you can
read the whole line off the manifest instead of discovering it one 403 at a time.
Errors
Every failure has the same shape, on every endpoint:
{
"error": {
"type": "invalid_request_error",
"code": "unknown_fn",
"message": "Unknown query: leads.lst. Available: projects.list, projects.get, …",
"request_id": "req_9f2c1a4b7e0d3a6f8c2b1e05"
},
"request_id": "req_9f2c1a4b7e0d3a6f8c2b1e05"
}request_id also comes back in the X-Request-Id response header, so you can log it without
parsing the body. It contains nothing secret. Quote it in a support message.
| Status | type | code | What happened |
|---|---|---|---|
| 400 | invalid_request_error | invalid_body | body was not JSON, or not { fn, args } |
| 400 | invalid_request_error | missing_fn | no fn in the body |
| 400 | invalid_request_error | unknown_fn | no such fn. The message lists the valid ones |
| 400 | invalid_request_error | missing_path | /mcp/material/upload without X-Selda-Path |
| 400 | invalid_request_error | empty_file | upload body was empty |
| 401 | authentication_error | invalid_api_key | key is wrong, revoked, or expired |
| 403 | authorization_error | missing_scope | key lacks read / write / pipeline / admin |
| 403 | authorization_error | plan_not_eligible | live key whose org is no longer on a paid plan |
| 403 | authorization_error | live_key_required:<reason> | test key on something that spends money |
| 403 | authorization_error | addon_required | the workspace does not hold a required add on |
| 413 | invalid_request_error | file_too_large | upload over 25 MB |
| 429 | rate_limit_error | rate_limit_exceeded | over 100 requests in the current minute. Retry-After: 60 |
| 500 | api_error | function_error | the function itself refused, with a message meant for you |
| 500 | api_error | internal_error | something broke on our side. Send us the request_id |
An invalid_api_key and a projectId outside your organization deliberately look the same as “no
such thing”: a key must not be able to probe what exists in someone else’s workspace.
Limits
| Limit | Value |
|---|---|
| Requests per key | 100 per minute, sliding window. X-RateLimit-Limit and X-RateLimit-Remaining on every response |
| Upload size | 25 MB per file (/mcp/material/upload) |
Leads per leads.addBatch | 500 |
company.lookup results | 15 maximum, 6 by default |
idempotencyKey length | 200 characters. Longer is treated as no key at all |
| LinkedIn only identity match | scans 1000 leads. If it hits that ceiling, the response says identityScanTruncated: true rather than quietly missing a match |
Nothing here expires a key on its own. Keys live until you revoke them.
What things cost
There is no per tool price list, and publishing one would be a guess. Selda charges from observed spend: every LLM call and paid vendor call reports its real cost, the run accumulates it, and credits are that spend times a fixed margin. A discovery run over ten hard to research companies genuinely costs more than one over ten easy ones, and a price table would have to lie about that in one direction or the other.
What is true and worth planning around:
- Reads are free. No query on
/mcp/querycosts a credit. Neither does receiving a reply, registering a webhook, or anything in the Brain. - Writing your own data in is free.
leads.add,material.upload,material.importwithoutautoAdvance,knowledge.*,brain.*. You are not charged for handing Selda what you already had. - Work Selda does costs. Discovery, research, enrichment, message writing, sending. That is
engine.start,company.lookup,leads.enrich*,messages.generate,material.importwithautoAdvance, andconnectors.sync. - Selda’s own failures are never charged. A run that errors and retries is our cost.
- Test-mode work is charged at the same margin as paid work. The free tier’s gift is the SIZE of its grant, 30 credits, about one full run, not a discounted rate, so what a credit buys is the same number wherever you are.
Call credits.info before and after a run to see the actual number for your workload. That is the
only honest way to size it, and it is one call.
Tools
Note what is not here: earlier versions of this server had selda_create_campaign and
selda_add_leads_by_tag, which wrote to a legacy campaigns table the campaign-flow UI does not
read, so a campaign created that way was invisible in the app. They were removed. To create a
real, reviewable campaign, use selda_upload_material + selda_import_material below: it creates
one via the same path the browser’s folder-drop uses.
The table below is generated from the servers themselves. It carried a hand-written list of 22 tools until 3.9.2026, while the hosted server had 62 and the sentence above it said both servers exposed the same set. Both numbers and the claim were wrong, and nothing was checking. Now a tool added to a server is a row on this page or a failing test.
The hosted server serves 68 tools. The local stdio server serves 36, a subset: 36 of the hosted tools are on both, and 30 are hosted only. They are not interchangeable, so pick the hosted one unless you specifically need a local process.
The two tools ChatGPT looks for
OpenAI’s connector contract does not read tool descriptions to decide what a server can do.
It requires two tools by NAME, with fixed argument and result shapes, and a server without
them connects and then has nothing ChatGPT will call. Selda serves both. They are a read-only
view over the tools below, not a second data path, and every id search returns is one that
fetch accepts.
| Tool | Purpose | Arguments |
|---|---|---|
search | Search this Selda workspace: leads (people and companies), projects, and Brain items (the facts a human told Selda about the business). Returns matches with an id you can pass to fetch for the full record. Use this first when the user asks about a person, a company, or what Selda knows. | query, projectId? |
fetch | Fetch the full record behind an id returned by search. Ids look like lead:…, project:… or brain:…. Returns the record as readable text. | id |
// search({ query: "nordic books" })
{ "results": [ { "id": "lead:k57...", "title": "Mika Virtanen · Nordic Books Oy", "url": "https://app.selda.ai/workspace-..." } ] }
// fetch({ id: "lead:k57..." })
{ "id": "lead:k57...", "title": "Mika Virtanen · Nordic Books Oy", "text": "Name: ...\nResearch: ...", "url": "...", "metadata": { "kind": "lead", "draftWaitingForApproval": true } }Ids are lead:…, project:… and brain:…. An id of any other shape is a tool error, not an
empty result, so a client cannot quietly show nothing.
Everything else
Arguments marked ? are optional. A tool marked * is on the hosted server only.
| Tool | Purpose | Arguments |
|---|---|---|
selda_list_projects | List all your Selda projects. Each project represents a product/company you’re doing GTM for. Returns project name, website, status, and ID that you’ll need for other tools. | none |
selda_get_project | Get detailed info about a project: business context, market analysis, ICP, and settings. | projectId |
selda_list_leads | List leads (potential customers) for a project. Shows contact info, company, fit score, and outreach status. | projectId, limit? |
selda_get_lead | Get full details about a lead: contact info, company research, fit analysis, why they’re a good lead, outreach angle, and message history. Returns analysisStored and analysis so you can VERIFY that research you sent with selda_add_lead actually landed, a lead added that way belongs to no run, so selda_get_run_leads cannot see it. Also returns any draft written for it. | leadId |
selda_update_lead | Correct a lead. Notes append by default; status, email, name, title, domain and the analysis are replaced. analysis is the same text you passed to selda_add_lead and it is what Selda writes the message FROM, if it contains a wrong claim, fix it here before starting a campaign, because the message will carry it. | leadId, notes?, appendNote?, status?, analysis?, email?, firstName?, lastName?, jobTitle?, companyDomain?, outreachAngle?, dealStage?, dealValueEur? |
selda_list_campaigns | List campaigns for a project. Campaigns contain leads, messages, and execution status. | projectId |
selda_get_campaign | Get campaign details: status, leads, messages, channels, performance stats. | campaignId |
selda_campaign_stats | Get campaign performance numbers: how many sent, delivered, opened, clicked, replied, bounced. | campaignId |
selda_list_messages | List sent/received messages for a project, newest first. Paged: pass offset from the returned nextOffset to read further back. Bodies are shortened here and bodyTruncated says which ones were, use selda_get_thread to read one conversation in full. | projectId, limit?, offset?, since?, direction? |
selda_get_thread | Get the full email thread with a specific lead, all sent and received messages. | leadId |
selda_run_pipeline | Run Selda’s AI engine to find leads by searching the web. Give it a description of WHO to find, e.g. ‘B2B SaaS founders in Finland’ or ‘AI startups that recently raised seed funding’. Do NOT pass URLs here, the engine searches the web itself. If the user provides a URL/webpage with companies, use selda_add_lead to add them directly instead. | projectId, idea, targetLeadCount? |
selda_add_lead * | Add a lead to a Selda project. PREFERRED method when the user shares a URL, webpage, list, or specific companies. Read the page yourself, extract the companies, and call this tool for each one. Much more reliable than run_pipeline for URL-based prospecting. Call multiple times to add multiple leads. If you have researched or analyzed the company, pass that write-up in analysis, Selda uses it as the authoritative research grounding: it writes the outreach FROM your analysis (skipping its own crawl) and labels the message ‘Based on your analysis’. | projectId, company, firstName?, lastName?, email?, jobTitle?, companyDomain?, linkedinUrl?, phone?, notes?, analysis?, outreachAngle?, whyGoodLead?, source?, mediaImageUrl?, mediaLinkUrl?, mediaAlt?, mediaWidth? |
selda_list_brain | Read the workspace’s Brain: the structured things a human told Selda about this business - products, partners, references and proof, company facts, notes, and the hard rules Selda must never break. This is what every message is written from, so read it before writing anything, and add to it rather than guessing. Returns the allowed item types alongside the items. | projectId |
selda_add_brain_item | Add ONE thing Selda should know about this business. Use this to fill a new workspace and to grow it as you learn more. type must be one of: company (a fact about the business), product (something it sells), partner, reference (proof, a named customer, a result), note, or avoid (a hard rule Selda must never break, e.g. ‘never claim we are the cheapest’). Keep title short and body factual: what you write here may be quoted in a real message, so anything invented here becomes a lie to a customer. | projectId, type, title, body |
selda_update_brain_item | Rewrite the title and/or body of one Brain item. Pass the id from selda_list_brain. Returns ok:false with reason ‘not_found’ if that id is not in this workspace, rather than silently doing nothing. | projectId, id, title?, body? |
selda_remove_brain_item | Remove one Brain item. The human owns what Selda knows, so anything written here can be taken back out. | projectId, id |
selda_ingest_event | Report that something happened outside Selda: someone filled in a form, finished an analysis, answered an ad, signed a contract. ONE call does the whole thing, Selda creates the lead if the person is new, recognises them if they are already known, records the event on their timeline, and can put them on an existing campaign’s review list. Use this instead of selda_add_lead whenever the lead ARRIVED (inbound) rather than being found by a search. Identity is matched on email address or LinkedIn profile, never on the company domain, so two people at the same employer stay two people. Pass idempotencyKey and you can safely retry after a network failure. Pass autoAdvance to have Selda write the reply straight away from the workspace’s Brain (its price list, offer rules, tone and the things it must never promise) and leave it in the Sales Inbox, the draft.ready webhook fires when it is there, so nobody has to poll. This never sends anything: the lead lands in review and a human presses send in the Selda app. | projectId, type, identity, payload?, source?, occurredAt?, idempotencyKey?, attachToRunId?, mediaImageUrl?, mediaLinkUrl?, mediaAlt?, mediaWidth?, analysis?, tags?, autoAdvance? |
selda_credits | Check your Selda credit balance: daily free credits, lead credits, usage. | none |
selda_lookup | Look up a company by name or website and get its decision-makers WITHOUT starting a campaign. Returns enriched company info and the right people to reach, seniority matched to company size. Use for quick research like ‘who runs growth at Acme?’. | company, role?, limit? |
selda_list_runs * | Every campaign run in a project, newest first, with status and phase. Use this when you no longer have a runId, for example when a call’s response did not reach you. Read-only. | projectId, limit? |
selda_get_run_status | Where a run is right now. Returns companiesFound, leadsWithContact, leadsHeldForContact (waiting for a person to decide something), leadsWithoutDraft (no message written) and droppedCompanies (companies that were on the run and are not rows any more, each with the reason). Read leadsWithoutDraft and droppedCompanies before concluding a run is finished: awaiting_approval means the run stopped for a human, not that every company got a message. | runId |
selda_start_campaign_from_leads * | Start a campaign from leads you already pushed in with selda_add_lead, selected by the source label you gave them. This is the loop: you research and write the analysis, push each company with selda_add_lead { source: ‘my-batch-name’, analysis: ’…’ }, then call this once. Selda writes a message per lead FROM the analysis you pushed with it, and skips its own crawl for every lead that has one. A lead you pushed WITHOUT an analysis is researched by Selda instead, because there is nothing else to write from. It stops at the drafts. It SPENDS CREDITS and it SENDS NOTHING, approving and sending are a human press in the app. | projectId, source, campaignId?, campaignBrief? |
selda_confirm_companies * | Confirm a run’s company list so Selda goes on to find the decision-makers and draft the messages. This is the gate selda_get_run_status reports as waiting on the app, it is now reachable here, so a campaign can be built end to end from your own tool. It SPENDS CREDITS (research + drafting) and it SENDS NOTHING: the send is still a human press in the Selda app, and no tool here will ever send. Pass the language and channel the campaign should use; everything else is optional and Selda keeps what it already proposed. | runId, language, channel, targetRoles?, targetMarket?, leadsPerCompany?, excludedDomains? |
selda_list_signals * | The signals Selda has found for a workspace: a company doing something that makes now the moment to reach out. Selda watches for these continuously, every 30 minutes, whether or not anybody is looking. Read-only, spends nothing, changes nothing. Use it to pick the signal IDs for selda_start_campaign_from_signals. | projectId, status?, limit? |
selda_start_campaign_from_signals * | Turn signals into a campaign. Selda builds the run from what it saw, so the outreach is about the thing that actually happened rather than a cold list. It SPENDS CREDITS and it SENDS NOTHING: the send is still a human press in the Selda app, and no tool here will ever send. By default it stops at the company list so a person sees who was picked before any research is paid for. Pass holdAtCompanies: false only if going straight on to research and drafting is what you mean. | projectId, signalIds, label?, holdAtCompanies?, campaignId? |
selda_get_run_leads | Read what a run actually produced: every company it found, the contact it resolved for each, and the message Selda drafted for them. This is how you review or improve drafts from your own tool, selda_get_run_status only returns counts. Nothing here sends; sending happens in the Selda app. | runId, limit?, withDraftOnly? |
selda_draft_reply | Write a reply DRAFT into a lead’s Sales Inbox thread. The draft prefills the thread’s composer in the Selda app, where a person reviews and sends it. This tool can send nothing. Use the leadId from selda_list_leads / selda_get_thread. | leadId, body |
selda_add_inbox_message | Put one message into a lead’s Sales Inbox thread, in either direction, even for somebody who was never in a campaign. direction ‘inbound’ = something THEY wrote; ‘outbound’ = something already sent from elsewhere, recorded as history. Pass identity instead of leadId to create the person. This tool sends nothing. | projectId, direction, body, leadId?, identity?, subject?, occurredAt? |
selda_add_lead_alias | Claim another email address for a lead (they replied from a different mailbox). Future inbound from that address lands in the same conversation, and earlier unlinked inbound from it is adopted onto the lead. | leadId, email |
selda_update_draft | Rewrite the subject or body of one drafted message, using the leadId from selda_get_run_leads. Use this after improving a draft in your own tool. A message that has already been sent is a record and cannot be edited, and editing never approves or sends anything. | leadId, subject?, body? |
selda_remove_draft | Take one drafted message out of a run so it cannot be sent, using the leadId from selda_get_run_leads. Use this when a draft is wrong enough that rewriting it is not the answer. Nothing is deleted: the row stays visible with your reason and a person can put it back in the app. A message that already went out is a record and is refused. | leadId, reason? |
selda_archive_run | Close a campaign run and take it off the active list, e.g. a run that found nothing and sits in awaiting_approval that nobody can approve. Keeps every contact and every message; deleting contacts is a separate decision a person makes in the app. | runId |
selda_rename_campaign | Name a campaign run so a person can tell it apart in the list. Without this the title is derived from the brief’s first line, which makes batches indistinguishable. An empty name restores the derived title. | runId, name |
selda_get_message_structure | Read what a campaign’s message is made of: every block in order, which ones ship word for word, the instruction behind each block Selda writes, the paragraph count, and whether the structure is locked. Use it before changing anything. | campaignId |
selda_set_message_structure | Say what you want a campaign’s message to be. Blocks with kind ‘locked’ ship WORD FOR WORD and nothing rewrites them; blocks with kind ‘generated’ carry your instruction and Selda writes that part from real material. Also takes the paragraph count and what must never appear. A locked structure is refused until you unlock it. This changes how messages are written; it sends nothing and approves nothing. | campaignId, objective?, blocks?, form?, must?, mustNever?, followUpStrategy? |
selda_lock_message_structure | Lock a campaign’s message structure so every locked block ships exactly as written and nothing rewrites it. Pass locked: false to take it back to draft; a lock you cannot undo is Selda refusing you your own document. Sends nothing. | campaignId, locked |
selda_delete_lead * | Delete one lead. Selda never removes a lead on its own, this is the caller’s act, and it is how a trial install clears its own test rows. | leadId |
selda_delete_leads * | Delete several leads at once, by id. Same act as selda_delete_lead, for clearing a batch. | leadIds |
selda_merge_leads * | Merge a duplicate lead into the one you are keeping. The duplicate’s addresses, timeline and messages move onto the primary, so the same person stops appearing twice across campaigns. | primaryId, duplicateId |
selda_create_webhook * | Register an endpoint Selda POSTs to when something happens, so you are told rather than having to poll. Events: lead.added, reply.received, message.sent, lead.status_changed, campaign.completed, credits.low, meeting.booked, test.ping. Deliveries are signed and retried; ten consecutive failures disable the endpoint. | url, events, description? |
selda_list_webhooks * | Every registered webhook for this workspace, with its events and whether delivery has been auto-disabled. | none |
selda_delete_webhook * | Remove a registered webhook. | webhookId |
selda_list_flows * | The workspace’s FLOWS: what Selda does when something arrives from outside (a website widget, a form, anything reported through selda_ingest_event). Each flow is a trigger plus steps in order. Read this before writing one, so you extend what exists instead of adding a second flow that answers the same events. Also returns the workspace’s flow instruction files. A flow can never send: its furthest step leaves a draft in the Sales Inbox and emails a person. | projectId |
selda_flow_runs * | What a flow actually did, newest first: every step, whether it worked, was skipped or failed, and why in plain words. Read this to debug a flow rather than guessing from its definition. | flowId, limit? |
selda_create_flow * | Create a flow. trigger.kind is ‘inbound_event’ (anything arriving through selda_ingest_event, including the website widget) or ‘manual’. Narrow it with eventTypes and sources, or leave them out to answer everything. steps run in order and may be: understand (place the enquiry in one of YOUR categories, using an instruction file and the Brain), route (send each category onto a campaign run or onto none), attach (onto one campaign run whatever the category), draft_reply (write the answer into the Sales Inbox, where it waits for human approval), notify (email people that it happened). NOTHING SENDS: there is no step that reaches the enquirer. Created switched OFF unless you pass enabled:true - read the steps first. | projectId, name, trigger, steps, enabled? |
selda_update_flow * | Rewrite a flow’s name, trigger or steps. Pass only what changes; steps REPLACE the whole list, so read selda_list_flows first and send the full ordered set. | flowId, name?, trigger?, steps? |
selda_set_flow_enabled * | Switch a flow on or off. Off means matching events still create the lead and the timeline row as before; only this flow’s steps stop running. | flowId, enabled |
selda_delete_flow * | Delete a flow and its run log. The leads and campaigns it touched are untouched. | flowId |
selda_save_flow_skill * | Write or rewrite an instruction file that an ‘understand’ step reads: how a person at this company decides which category an enquiry belongs to. Plain language, in the operator’s own words. Pass skillId to rewrite an existing one. This is material, not code: it is read alongside the Brain, and anything factual in it may end up shaping a real reply. | projectId, name, body, skillId? |
selda_get_knowledge * | The workspace’s knowledge notes: the prose that grounds every message Selda writes. Read it before changing it. | projectId |
selda_set_knowledge * | REPLACE the workspace’s knowledge notes. Overwrites what is there, read it first with selda_get_knowledge unless you mean to discard it. For structured facts (products, references, things never to say) use selda_add_brain_item instead. | projectId, text |
selda_append_knowledge * | Add to the workspace’s knowledge notes without touching what is already there. | projectId, text |
selda_add_leads * | Add several known companies or contacts at once. Same as selda_add_lead per row, including the analysis field that grounds the message on research you already did. | projectId, leads |
selda_tag_lead * | Tag a lead. Tags are how selda_start_campaign_from_leads selects which leads a campaign runs on. | leadId, tags |
selda_approve_message * | Mark a drafted message approved. Approval is not sending: the send is still a human press in the Selda app, and nothing here reaches anybody. | messageId |
selda_generate_message * | Draft a message for one lead. Leaves it as a draft; it sends nothing. | leadId, outreachAngle?, channel? |
selda_classify_replies * | Read the workspace’s unclassified inbound replies and label each one (interested, not now, wrong person, unsubscribe…). Changes no drafts and sends nothing. | projectId |
selda_update_project_context * | Rewrite what Selda understands about the business: what they do, the industry, the value proposition, the products. This is what every message is written from, so a change here changes every future message. | projectId, whatTheyDo?, industry?, valueProposition?, products? |
selda_list_connectors * | Data connectors active on this workspace, and their sync state. | projectId |
selda_preview_reply * | Ask how Selda would answer an enquiry, using this workspace’s Brain, WITHOUT creating a lead or storing anything. Use it to tune the Brain: send an example form, read the reply, adjust the price list or the rules with selda_add_brain_item, try again. It runs the same writer the real reply uses, so what you see here is what a real enquiry gets. Stores nothing, sends nothing, leaves no rows to clean up. | projectId, eventType, payload?, who?, company?, source?, language? |
selda_upload_material | Upload one file of prospect material (PDF, Markdown, JSON, plain text) so a campaign can use it as its authoritative source instead of a fresh crawl, e.g. an analysis you already wrote about that prospect. Call once per file, then hand ALL the returned file objects to selda_import_material together. The path also tells Selda which company the file belongs to, use a folder-per-company layout, e.g. ‘boreo/analyysi.pdf’, ‘boreo/tiedot.md’. FOR A PDF OR ANY LARGE FILE, PASS url INSTEAD OF content. Selda fetches it server-side and the bytes never travel through this conversation. Base64 in a tool call is impractical for real documents: a 1.6 MB PDF is about 2.2 million characters. Text you already hold goes in content with encoding ‘utf8’. | projectId, path, url?, content?, encoding?, mimeType? |
selda_upload_file | Upload a file (GIF, PNG, JPEG, PDF, anything) and get back a storageId AND a public url. This is how a picture gets into an email, and there are two ways to use what it returns: • IN THE TEXT — put the returned markdown () into the body you write with selda_update_draft. A GIF only animates this way. The width after = is yours to choose. • AS AN ATTACHMENT — pass the storageId to selda_attach_files, for one draft or for the whole campaign. FOR ANYTHING OVER ~100 KB PASS url INSTEAD OF content: Selda fetches it server-side and the bytes never travel through this conversation. 25 MB per upload, and an attached file is capped at 10 MB by the send path. | url?, content?, encoding?, fileName?, mimeType? |
selda_attach_files | Attach files to a message that is already written. leadId attaches to ONE draft, runId to EVERY message in that campaign that has not gone out yet — one or the other, not both. Each file is either a public https url Selda fetches right now, or a storageId from selda_upload_file. A message that already went out refuses: its attachments are part of what was sent. Attaching is not sending. INLINE IMAGES: inline/cid are accepted but CANNOT be honoured — outreach leaves through the workspace’s own mailbox, and the sending path accepts files but no Content-ID, so <img src="cid:..."> arrives as a broken frame. The response says so and hands back the spelling that works: the picture by address in the body text, optionally wrapped in a link. | files, leadId?, runId? |
selda_remove_attachment | Take one file off a draft (leadId) or off a whole campaign (runId). Removing a campaign file from ONE draft leaves it on everybody else’s message. Nothing is deleted: the bytes stay, and messages already sent keep the record of what they actually carried. | storageId, leadId?, runId? |
selda_list_attachments | What would actually leave with a message. Give leadId for one draft (campaign files included, in send order, each marked fromRun) or runId for what the campaign gives every message. Each file comes with a url you can also use for a picture in the text. | leadId?, runId? |
selda_import_material | Turn uploaded material into a campaign + company list. Pass every file object returned by selda_upload_material. By default this ONLY builds the company list and stops there for human review, it does not research decision-makers or write messages, and it can never send. To let it continue automatically, pass autoAdvance: [‘leads’] (find decision-makers), [‘leads’,‘messages’] or true (also draft messages), or [‘messages’] if leads are already known. Drafted messages still require human approval in the Selda app before anything sends. | projectId, files, campaignName?, targetRunId?, campaignBrief?, autoAdvance? |
* marks a tool the hosted server has and the local stdio server does not.
Usage guidance (from the MCP server instructions)
- The app is at
https://app.selda.ai, never useselda.cityor other domains. - Always call
selda_list_projectsfirst to obtain aprojectIdbefore any other operation. - When a user shares a URL or webpage with specific companies/people, add them
directly (e.g.
selda_add_lead), do not pass URLs toselda_run_pipeline. - Use
selda_run_pipelineonly for open-ended searches like “find SaaS founders in Finland”. selda_run_pipelineruns the engine and costs credits. It stops at the company list and waits for a person in the app; pollselda_get_run_statusand readawaitingHumanbefore saying anything about whether it finished.
Connecting a client
OAuth (recommended for Claude.ai, ChatGPT, Cursor): add https://mcp.selda.ai/api/mcp
as a connector. The server advertises WWW-Authenticate: Bearer resource_metadata=… on an
unauthenticated request, pointing at GET /.well-known/oauth-protected-resource, which in turn
points at the authorization server’s GET /.well-known/oauth-authorization-server
(https://api.selda.ai). A client that supports this discovers the whole flow automatically:
it self-registers via POST /oauth/register (RFC 7591, no human step), sends the user’s
browser to authorization_endpoint (https://app.selda.ai/oauth/authorize) where they log in
and approve a workspace, then exchanges the resulting code for an access token via
POST /oauth/token (Authorization Code + PKCE, code_challenge_method=S256). The access token
is a regular sk_live_… key under the hood, same org-scoping, same revoke button in
Settings → Apps → Selda MCP. OAuth is just a way to mint it without copying anything by hand.
Static key (Claude Code, scripts, CI):
- In the Selda app go to Settings → Apps → Selda MCP and create a key. Copy the
sk_live_…value (shown once). -
Or the equivalent JSON block for any other client that reads MCP config (Claude Desktop, Cursor), same URL, same header, no local install.
claude mcp add --transport http selda https://mcp.selda.ai/api/mcp \ --header "Authorization: Bearer sk_live_xxx" - Every call is automatically scoped to the org that owns the key. Pipeline runs
consume credits, so check
selda_creditsif runs start failing.
Local stdio (mcp-server/index.ts) still exists for local development, spawned by a client
via npx tsx with SELDA_API_KEY in its environment, but the hosted server above is the
current recommended path for every client that supports remote MCP.
Changelog
Tool arguments and response shapes change here first. A dated entry means the behaviour is live.
2026-08-06
Added: selda_ingest_event / events.ingest. One call for an inbound lead: creates the person
if new, recognises them if known, records the event on their timeline, optionally joins a campaign.
Identity matches on email or LinkedIn profile, never on the company domain. Supports
idempotencyKey for safe retries. Requires the Selda Inbound add on on a live key; free on a
test key. It cannot send.
Added: the Brain over MCP. brain.list, brain.add, brain.update, brain.remove and their
selda_*_brain_item tools. The structured knowledge a workspace is built from was previously
readable only in the app.
Changed: selda_update_lead appends notes instead of replacing them. Its description always
said “notes to add” and it overwrote. Pass appendNote: false for the old behaviour. The raw
leads.update fn still replaces by default, so a direct HTTP integration is unaffected; opt in with
appendNote: true.
Changed: leads.add refuses a suppressed address. It returns blocked: true with a reason and
creates nothing, instead of creating a lead that may never be contacted. Its response now always
carries leadId, duplicate, blocked and reason, so the shape no longer varies by path.
Changed: test keys are no longer allowed to spend. A test key can read the whole workspace
and write your own data in, but company.lookup, leads.enrich*, engine.start, connectors.sync
and material.import with autoAdvance now need a live key on a paid plan, and
projects.create was removed from the API entirely. The refusal is a 403 with code: "live_key_required:<reason>" and a sentence
explaining it. If your integration used a test key for contact lookup, this is a breaking change
and it is deliberate.
Added to GET /mcp/capabilities: sandboxAllowed, liveOnlyReason and requiresEntitlement
per capability, so the limits are readable rather than discoverable one error at a time.
Documented: the error envelope and every code, per key rate and size limits, what actually
consumes credits, a single canonical transport answer, and What MCP does not
do.
Source files
| File | Role |
|---|---|
api/mcp.ts | Hosted remote MCP server (mcp.selda.ai, Vercel Edge, Streamable HTTP) |
mcp-server/index.ts | Node stdio MCP server, tool definitions, HTTP client |
mcp-server/handlers.ts | Extra tool handlers (analysis, discussions, channels, drafts) |
mcp-server/setup.ts | Interactive client configuration |
convex/mcpApi.ts | HTTP handlers for /mcp/query, /mcp/mutate, /mcp/run; auth + scope + org injection |
convex/mcpQueries.ts | Org-scoped internal queries/mutations/actions the HTTP layer calls |
convex/apiKeys.ts | API key creation, validation, revocation (SHA-256 hashed storage) |
convex/http.ts | Routes the MCP endpoints |