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. DELETE is accepted and does nothing.
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
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 (with slightly different toolsets) in front of one HTTP API:
-
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 → API Key). 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.
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
The authoritative list is served by the API itself:
GET https://api.selda.ai/mcp/capabilitiesNo key needed. It is derived from the same dispatch tables the endpoints run on
(convex/lib/mcpRegistry.ts), so it is current by construction, the tables below are a
reading copy. Where they differ, the manifest is right. The same list appears in the app under
Settings → Integrations → MCP server.
Reads. POST /mcp/query, needs the read scope.
fn | Returns |
|---|---|
projects.list | your workspaces (start here, everything else needs a projectId) |
projects.get | one workspace: business context, market analysis, ICP, settings |
leads.list · leads.get | leads for a workspace · one lead in full (research, fit, angle, notes) |
campaigns.list · campaigns.get · campaigns.stats | campaigns · one campaign · sent/delivered/opened/clicked/replied/bounced (legacy campaigns table) |
runs.status | status of one real campaign run (campaignRuns, what material.import/engine.start create): phase, companies found, contacts resolved, drafts written, errors — plus awaitingHuman, non-null when the run has stopped for a person to decide |
messages.byProject · messages.byLead | messages in a workspace · the whole thread with one lead |
knowledge.get | what Selda knows about your business (the prose that grounds every message) |
brain.list | the structured Brain: products, partners, references, company facts, hard rules |
credits.info | balance, daily free credits, usage, plan |
connectors.list · webhooks.list | data connectors · registered outbound webhooks |
Writes. POST /mcp/mutate, needs write.
fn | Does |
|---|---|
leads.add · leads.addBatch | add a known company/contact. Pass analysis with your own research and the message is grounded on it instead of a fresh crawl |
leads.update · leads.addTag · leads.merge | edit a lead · tag it · merge duplicates |
leads.delete · leads.deleteBatch | remove leads |
projects.updateContext | rewrite a workspace’s business context |
campaigns.create · campaigns.update | new campaign · change one. legacy campaigns table, not what the app’s campaign-flow UI reads; use material.import to create a campaign that shows up in the app |
campaigns.addLeads · campaigns.addLeadsByTag | put leads into a campaign, individually or by tag, same legacy table |
campaigns.addRule | add a campaign rule |
messages.approve | approve a drafted message (approval only, it does not send) |
knowledge.set · knowledge.append | replace or extend what Selda knows about your business |
brain.add · brain.update · brain.remove | one structured Brain item at a time (above) |
events.ingest | something happened outside Selda (above). Creates or recognises the lead, records the timeline, can join a campaign. It never sends |
connectors.create · connectors.delete | manage data connectors |
webhooks.create · webhooks.delete | register an endpoint for events like reply.received |
Actions. POST /mcp/run, needs pipeline. These cost credits and take time.
fn | Does |
|---|---|
material.import | your folder → campaign + company list (above) |
engine.start | the full pipeline from a brief: find companies → research → fit → hook → draft. Stops at the company list (awaiting_profile) and waits for a person to confirm it in the app — that gate is not reachable from the API |
company.lookup | resolve a company and return the right people to reach, without starting anything |
messages.generate | draft a message for a lead |
replies.classify · replies.draft | classify inbound replies · draft an answer |
runs.leads | every company a run found, its contact, and the message drafted for it |
drafts.update | rewrite the subject or body of one drafted message; refuses one already sent |
leads.updateStatus · leads.skip | set a lead’s status · skip a lead |
leads.enrich · leads.enrichBatch | enrich leads from a natural-language instruction |
connectors.sync | pull from a connected data source |
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 |
|---|---|
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": "Terassikesa",
"body": "Ran the summer terrace campaign in 2026 and booked 14 venue meetings in six weeks."
}}
// -> { "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": "Terassikesa",
"body": "Ran the summer terrace campaign...", "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.
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@yritys.fi",
"domain": "yritys.fi", // context only, never used to decide identity
"linkedinUrl": "https://www.linkedin.com/in/matti/",
"name": "Matti Meikalainen", // or firstName / lastName separately
"company": "Yritys Oy",
"jobTitle": "CTO",
"phone": "+358 40 1234567"
},
"payload": { "form": "analysis", "findings": ["Languages: missing"], "price": 290 },
"source": "evexcreative.com",
"occurredAt": "2026-08-06T10:21:14.152Z", // ISO 8601 or epoch ms. Defaults to now.
"idempotencyKey": "report-yritys.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 |
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 |
messages.send | reaches a real recipient |
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
The hosted server (api/mcp.ts) and the local stdio server (TOOLS in
mcp-server/index.ts) expose the same 22 tools. Each maps to one or more
{ fn, args } calls against the Convex HTTP API.
Note what’s 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 doesn’t read, 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.
| Tool | Purpose | Key args |
|---|---|---|
selda_list_projects | List your Selda projects (name, website, status, id). Returns the projectId needed by other tools. | none |
selda_get_project | Project detail: business context, market analysis, ICP, settings. | projectId |
selda_list_leads | List leads for a project (contact, company, fit score, status). | projectId, limit? (default 50) |
selda_get_lead | Full lead detail: contact, company research, fit, why-good-lead, outreach angle, notes. | leadId |
selda_update_lead | Update a lead’s status (new to contacted to responded to qualified / unqualified) and/or notes. Notes are appended, so writing a timeline one line at a time works. Pass appendNote: false to replace instead. Returns the resulting notes. | leadId, status?, notes?, appendNote? (default true) |
selda_list_campaigns | List a project’s campaigns (legacy table, status, type, channels). | projectId |
selda_get_campaign | Campaign detail from the legacy table. | campaignId |
selda_campaign_stats | Campaign performance from the legacy table: sent / delivered / opened / clicked / replied / bounced. | campaignId |
selda_list_messages | List sent/received messages for a project (direction, recipient, subject, status). | projectId |
selda_get_thread | Full email thread with one lead, all sent and received messages. | leadId |
selda_run_pipeline | Run the full GTM pipeline: find companies → research → find the decision-makers → write personalized messages. The same engine the app runs. Stops at the company list and waits for a person to confirm it in the app. | projectId, idea, targetLeadCount? (a hard cap when set; omitted = Selda sizes the run, no number is invented) |
selda_add_lead | Add a known contact/company directly as a lead (preferred when you already have the company, e.g. extracted from a page). Pass analysis with your own company research and Selda grounds the message on it (“Based on your analysis”) instead of crawling from scratch. Deduplicates on email and always returns leadId, so the same person submitting twice is one lead. A suppressed address returns status: "blocked" and creates nothing. For a lead that arrived rather than one you found, use selda_ingest_event. Backed by leads.add. | projectId, company, contact fields, analysis? |
selda_ingest_event | Report that something happened outside Selda: a form was filled in, an analysis finished, an ad was answered, a contract was signed. Creates or recognises the person, records the event on their timeline, and can join them to an existing campaign. Identity is the email or LinkedIn profile, never the domain. It never sends. Full contract above. | projectId, type, identity, payload?, source?, occurredAt?, idempotencyKey?, attachToRunId?, analysis?, tags? |
selda_list_brain | Read the workspace’s structured knowledge, plus the allowed item types. Read this before writing anything: it is what messages are built from. | projectId |
selda_add_brain_item | Add one thing Selda should know. type is one of company, product, partner, reference, note, avoid. | projectId, type, title, body |
selda_update_brain_item | Rewrite one Brain item’s title and/or body. | projectId, id, title?, body? |
selda_remove_brain_item | Take one Brain item back out. | projectId, id |
selda_credits | Check credit balance: daily free credits, lead credits, usage, plan. | none |
selda_lookup | Company → decision-makers lookup, without starting a campaign: resolve a company by name or website, return enriched company info (industry, size, location) and the right people to reach, seniority matched to company size, optional role/department filter. | company, role?, limit? (default 6, max 15) |
selda_upload_material | Upload one file of prospect material (PDF, Markdown, JSON, plain text) so a campaign can use it as its authoritative source. Call once per file, then hand every returned file to selda_import_material. The path’s leading folder groups files by company, e.g. boreo/analyysi.pdf. | projectId, path, content, encoding? (utf8|base64), mimeType? |
selda_import_material | Turn uploaded material into a real, reviewable campaign (campaignRuns, the same table the app UI reads). Stops at the company list by default; autoAdvance grants continuing to decision-maker research and/or message drafting. Never sends. | projectId, files, campaignName?, targetRunId? (add to an existing campaign instead), autoAdvance? |
selda_get_run_status | Poll a campaign run created by selda_import_material or selda_run_pipeline, phase, companies found, contacts resolved, messages drafted, any error. awaitingHuman is non-null when the run has finished a stage and stopped for a person — read it before calling a run hung or finished. Use this, not selda_get_campaign, for anything selda_import_material created. | runId |
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 → 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 → 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,
messages.send 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 |