ReferenceMCP server reference

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).

Paste into Claude or ChatGPT alongside your question

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, or Authorization: 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:

  1. The hosted MCP server (recommended), https://mcp.selda.ai/api/mcp, implemented in api/mcp.ts, runs on Vercel Edge, speaks MCP Streamable HTTP, stateless. Connect it from Claude.ai (Add custom connector) or any remote-MCP client with https://mcp.selda.ai/api/mcp?key=sk_live_xxx. No local install.

  2. The local Node MCP server, mcp-server/index.ts. A stdio MCP server spawned by the client (e.g. Claude Desktop via npx 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:

EndpointMethodPurpose
/mcp/queryPOSTRead operations (queries)
/mcp/mutatePOSTWrite operations (mutations)
/mcp/runPOSTActions (pipeline / engine)
/mcp/material/uploadPOSTRaw 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 the SELDA_API_KEY env 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 display keyPrefix (first 16 chars + ...) are persisted in the apiKeys table. The plaintext key is never written to the database.
  • Validation: every HTTP call runs apiKeys.validateKey over 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’s orgId, userId, and scopes.
  • Revocation: apiKeys.revokeKey (UI) sets revokedAt; the key stops validating immediately.

Scopes

ScopeGrantsSelf-grantable
read/mcp/query (and required by all read calls)Yes
write/mcp/mutateYes
pipeline/mcp/run (engine / pipeline actions)Yes
admincross-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:

  1. checks the required scope for that endpoint (e.g. /mcp/query requires read), returning 403 if missing;
  2. 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 haveUse
Files on disk, laid out one folder per company/mcp/material/upload per file, then material.import
A company + contact + my own analysis textleads.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:

ValueGrants
omittednothing, stops at the company list
["leads"]contact lookup, then stops before any message is written
truecontact 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/capabilities

No 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.

fnReturns
projects.listyour workspaces (start here, everything else needs a projectId)
projects.getone workspace: business context, market analysis, ICP, settings
leads.list · leads.getleads for a workspace · one lead in full (research, fit, angle, notes)
campaigns.list · campaigns.get · campaigns.statscampaigns · one campaign · sent/delivered/opened/clicked/replied/bounced (legacy campaigns table)
runs.statusstatus 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.byLeadmessages in a workspace · the whole thread with one lead
knowledge.getwhat Selda knows about your business (the prose that grounds every message)
brain.listthe structured Brain: products, partners, references, company facts, hard rules
credits.infobalance, daily free credits, usage, plan
connectors.list · webhooks.listdata connectors · registered outbound webhooks

Writes. POST /mcp/mutate, needs write.

fnDoes
leads.add · leads.addBatchadd 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.mergeedit a lead · tag it · merge duplicates
leads.delete · leads.deleteBatchremove leads
projects.updateContextrewrite a workspace’s business context
campaigns.create · campaigns.updatenew 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.addLeadsByTagput leads into a campaign, individually or by tag, same legacy table
campaigns.addRuleadd a campaign rule
messages.approveapprove a drafted message (approval only, it does not send)
knowledge.set · knowledge.appendreplace or extend what Selda knows about your business
brain.add · brain.update · brain.removeone structured Brain item at a time (above)
events.ingestsomething happened outside Selda (above). Creates or recognises the lead, records the timeline, can join a campaign. It never sends
connectors.create · connectors.deletemanage data connectors
webhooks.create · webhooks.deleteregister an endpoint for events like reply.received

Actions. POST /mcp/run, needs pipeline. These cost credits and take time.

fnDoes
material.importyour folder → campaign + company list (above)
engine.startthe 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.lookupresolve a company and return the right people to reach, without starting anything
messages.generatedraft a message for a lead
replies.classify · replies.draftclassify inbound replies · draft an answer
runs.leadsevery company a run found, its contact, and the message drafted for it
drafts.updaterewrite the subject or body of one drafted message; refuses one already sent
leads.updateStatus · leads.skipset a lead’s status · skip a lead
leads.enrich · leads.enrichBatchenrich leads from a natural-language instruction
connectors.syncpull 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

eventfires when
reply.receivedsomeone answers one of your messages
meeting.bookeda meeting is booked
lead.addeda lead is added, including by your own leads.add
lead.status_changeda lead’s status changes
message.senta message goes out, after a human approved it
campaign.completeda campaign run finishes
credits.lowthe balance is running out
test.pingyou asked for a test delivery

What arrives

POST to your URL, Content-Type: application/json, with two headers:

headervalue
X-Selda-Eventthe event name, e.g. reply.received
X-Selda-Signaturesha256=<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.list shows lastStatus, failureCount and disabledAt, 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.

fnToolDoes
brain.listselda_list_brainread every item, plus the allowed types
brain.addselda_add_brain_itemadd one item
brain.updateselda_update_brain_itemrewrite one item’s title and/or body
brain.removeselda_remove_brain_itemtake 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.

typeFor
companya fact about the business itself
productsomething it sells
partnera partner or reseller
referenceproof: a named customer, a result, a case
noteanything else worth knowing
avoida 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.

FieldMeaning
leadIdthe lead this event is about. null only when blocked
created · matchedExistingexactly one is true, unless blocked
matchedBy"email", "linkedin", or null
blocked · reasontrue with suppressed_email / suppressed_domain when the address asked Selda to stop
eventIdthe row on the lead’s timeline
replayedtrue 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
identityScanTruncatedthe 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”
occurredAtFallbackyour 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

ConditionResult
identity names no person500 function_error, message says what to send instead
projectId in another organization500 function_error, “Project not found or unauthorized”
address or domain suppressed200 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/upload and material.import without autoAdvance
  • events.ingest, so you can build and test an inbound integration end to end
  • messages.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

fnWhy
company.lookupresolves real people’s contact details through a paid provider, billed per call
leads.enrich · leads.enrichBatchsame, per record
engine.startruns discovery: web search, crawls, per lead credit spend
material.import with autoAdvancethe grant carries the run into contact lookup and drafting
connectors.syncpulls and enriches from an outside source
messages.sendreaches 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.

StatustypecodeWhat happened
400invalid_request_errorinvalid_bodybody was not JSON, or not { fn, args }
400invalid_request_errormissing_fnno fn in the body
400invalid_request_errorunknown_fnno such fn. The message lists the valid ones
400invalid_request_errormissing_path/mcp/material/upload without X-Selda-Path
400invalid_request_errorempty_fileupload body was empty
401authentication_errorinvalid_api_keykey is wrong, revoked, or expired
403authorization_errormissing_scopekey lacks read / write / pipeline / admin
403authorization_errorplan_not_eligiblelive key whose org is no longer on a paid plan
403authorization_errorlive_key_required:<reason>test key on something that spends money
403authorization_erroraddon_requiredthe workspace does not hold a required add on
413invalid_request_errorfile_too_largeupload over 25 MB
429rate_limit_errorrate_limit_exceededover 100 requests in the current minute. Retry-After: 60
500api_errorfunction_errorthe function itself refused, with a message meant for you
500api_errorinternal_errorsomething 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

LimitValue
Requests per key100 per minute, sliding window. X-RateLimit-Limit and X-RateLimit-Remaining on every response
Upload size25 MB per file (/mcp/material/upload)
Leads per leads.addBatch500
company.lookup results15 maximum, 6 by default
idempotencyKey length200 characters. Longer is treated as no key at all
LinkedIn only identity matchscans 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/query costs 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.import without autoAdvance, 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.import with autoAdvance, and connectors.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.

ToolPurposeKey args
selda_list_projectsList your Selda projects (name, website, status, id). Returns the projectId needed by other tools.none
selda_get_projectProject detail: business context, market analysis, ICP, settings.projectId
selda_list_leadsList leads for a project (contact, company, fit score, status).projectId, limit? (default 50)
selda_get_leadFull lead detail: contact, company research, fit, why-good-lead, outreach angle, notes.leadId
selda_update_leadUpdate 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_campaignsList a project’s campaigns (legacy table, status, type, channels).projectId
selda_get_campaignCampaign detail from the legacy table.campaignId
selda_campaign_statsCampaign performance from the legacy table: sent / delivered / opened / clicked / replied / bounced.campaignId
selda_list_messagesList sent/received messages for a project (direction, recipient, subject, status).projectId
selda_get_threadFull email thread with one lead, all sent and received messages.leadId
selda_run_pipelineRun 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_leadAdd 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_eventReport 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_brainRead 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_itemAdd one thing Selda should know. type is one of company, product, partner, reference, note, avoid.projectId, type, title, body
selda_update_brain_itemRewrite one Brain item’s title and/or body.projectId, id, title?, body?
selda_remove_brain_itemTake one Brain item back out.projectId, id
selda_creditsCheck credit balance: daily free credits, lead credits, usage, plan.none
selda_lookupCompany → 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_materialUpload 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_materialTurn 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_statusPoll 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 use selda.city or other domains.
  • Always call selda_list_projects first to obtain a projectId before 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 to selda_run_pipeline.
  • Use selda_run_pipeline only for open-ended searches like “find SaaS founders in Finland”.
  • selda_run_pipeline runs the engine and costs credits. It stops at the company list and waits for a person in the app; poll selda_get_run_status and read awaitingHuman before 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):

  1. In the Selda app go to Settings → MCP and create a key. Copy the sk_live_… value (shown once).
  2. claude mcp add --transport http selda https://mcp.selda.ai/api/mcp \
      --header "Authorization: Bearer sk_live_xxx"
    Or the equivalent JSON block for any other client that reads MCP config (Claude Desktop, Cursor), same URL, same header, no local install.
  3. Every call is automatically scoped to the org that owns the key. Pipeline runs consume credits, so check selda_credits if 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

FileRole
api/mcp.tsHosted remote MCP server (mcp.selda.ai, Vercel Edge, Streamable HTTP)
mcp-server/index.tsNode stdio MCP server, tool definitions, HTTP client
mcp-server/handlers.tsExtra tool handlers (analysis, discussions, channels, drafts)
mcp-server/setup.tsInteractive client configuration
convex/mcpApi.tsHTTP handlers for /mcp/query, /mcp/mutate, /mcp/run; auth + scope + org injection
convex/mcpQueries.tsOrg-scoped internal queries/mutations/actions the HTTP layer calls
convex/apiKeys.tsAPI key creation, validation, revocation (SHA-256 hashed storage)
convex/http.tsRoutes the MCP endpoints