One surface, two transports
Everything below is generated from the capability table this server dispatches on, so it cannot describe a capability the endpoint does not serve. The table's fingerprint today is 4e1f93d2 — it changes whenever a capability's name, arguments, scope or routing change, and the generated client carries a copy so a client can tell whether it was built for the server it is talking to.
4 of the 21 reach LinkedIn through a sender account you connected. The rest read this server's own database — your campaigns, leads, inbox, the product catalogue, your library and projects. The split decides everything that follows: whether a call spends the LinkedIn budget, whether it lands in the execution ledger, and whether an unconfigured provider can make it fail.
Authenticate
One credential for both transports: a workspace API key, minted on the Developer page in the app. Send it as Authorization: Bearer tcz_live_…. The header X-Api-Key works too, and ?key= exists for clients that accept only a URL — it ends up in access logs, so prefer a header.
Scopes belong to the key, not to the workspace: two keys are how you give a third-party client read access to LinkedIn without also handing it the ability to install products. The scopes in use are credits:read, linkedin:read, products:install, products:read, projects:read, usage:read, workspace:read, workspace:write. A key minted before scopes existed carries none and is allowed everywhere; that is deliberate, so nobody's integration broke the day scopes shipped.
A key is shown once
Call it over HTTP
GET /api/v1/capabilities lists the surface and needs no credential — it describes the server, not a workspace.
curl -s https://topclozer.com/api/v1/capabilitiesPOST /api/v1/capabilities/{name} runs one. The body is the arguments object — the same arguments the MCP tool takes — or no body at all.
curl -s -X POST https://topclozer.com/api/v1/capabilities/search_linkedin_people \
-H "Authorization: Bearer tcz_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"keywords":"VP Engineering","industry":"Financial Services","limit":25}'A success is the capability name and its payload, so a log line says what produced the result:
{
"capability": "search_linkedin_people",
"result": {
"searchedAs": "Marcus Feld",
"count": 25,
"people": [ … ]
}
}Field naming
/api/v1/campaigns, /api/v1/leads, /api/v1/accounts, /api/v1/inbox — return the workspace document as the app stores it, which is camelCase. That inconsistency is real; it is written down here rather than hidden by translating one of them at the edge, and it will be resolved by a v2, not by changing a response somebody is already parsing.Call it from an AI client
The same capabilities, spoken as JSON-RPC at https://topclozer.com/api/mcp. This is the transport Claude, Cursor, VS Code, ChatGPT, n8n and Make understand.
claude mcp add --transport http topclozer https://topclozer.com/api/mcp \
--header "Authorization: Bearer tcz_live_YOUR_KEY"Per-client setup, the tool reference and the response envelopes are in the MCP documentation; the endpoint itself is described at /hosted-mcp.
The CLI
Zero dependencies and no build step. It asks the server what exists rather than carrying its own copy, so it describes the server you point it at — a capability shipped this morning is callable from a CLI installed last year.
export TOPCLOZER_API_KEY=tcz_live_YOUR_KEY
topclozer capabilities # what this server can do
topclozer describe get_linkedin_profile
topclozer call get_stats
topclozer call search_linkedin_people --keywords "VP Engineering" --limit 25
topclozer usage # LinkedIn calls this month, and the limitExit codes are 0 success, 1 usage error, 2 the server refused or failed — so a shell script can branch without parsing output.
The TypeScript client
Capability names and argument names are typed, generated from the server's own registry. Results are typed unknown: each payload is documented in prose and an example on its reference page, and inventing interfaces from an illustrative example would produce types that look authoritative and are not.
import { createClient, TopclozerError } from "@topclozer/sdk";
const topclozer = createClient({ apiKey: process.env.TOPCLOZER_API_KEY });
try {
const people = await topclozer.call("search_linkedin_people", {
keywords: "VP Engineering",
limit: 25,
});
} catch (e) {
if (e instanceof TopclozerError && e.errorClass === "rate_limited") {
// e.retryAfter is in seconds. Backing off is your decision, not the library's.
}
}Not on npm yet
npm --prefix packages/sdk run build.Neither of them retries. A retry loop against the LinkedIn budget is the exact request pattern that gets a LinkedIn account restricted, and a library that hid a 429 behind three silent attempts would be doing that to your account on your behalf.
Budgets
Two limits, and conflating them is how a job gets planned against the wrong number.
| Limit | Budget | Applies to |
|---|---|---|
| requests | 120 per 60s per workspace | Every call on the HTTP surface. |
| 30 per 300s per workspace | The 4 capabilities that reach LinkedIn, on either transport. |
Both are per workspace, not per key: a second key does not buy a second allowance, and splitting traffic between the two transports does not either. The LinkedIn number is published because it is enforced.
Errors
Every failure carries a stable class, so a client can branch without parsing English: { "error": { "class": "rate_limited", "message": "…", "retry_after": 42 } }. On MCP the same classes become JSON-RPC codes, and the ones a model can act on come back as a readable tool result rather than a protocol fault.
| Class | HTTP | Means |
|---|---|---|
| invalid_request | 400 | The body was not a JSON object. |
| unauthorized | 401 | No valid credential was presented. |
| forbidden_scope | 403 | A valid key that is not scoped for this capability. |
| unknown_capability | 404 | No capability by that name. |
| capability_refused | 422 | It ran and said no, for a reason you can act on. |
| rate_limited | 429 | A budget is spent. Carries retry_after. |
| upstream_failed | 502 | Anything else. A bug here, or a provider that failed in a way we did not model. |
| provider_unconfigured | 503 | Our problem: the provider behind it is not configured on this server. |
Every capability
All 21, with the scope each one needs and what it costs. The name is both the MCP tool name and the HTTP path segment.
| Capability | Scope | Provider | Notes |
|---|---|---|---|
| search_linkedin_peopleSearch LinkedIn for people matching an ICP, through a connected sender account | linkedin:read | unipile | read-only · LinkedIn budget · recorded as a run |
| get_linkedin_profileRead one LinkedIn profile: identity, current role, followers, job-change signal and latest post | linkedin:read | unipile | read-only · LinkedIn budget · recorded as a run |
| get_linkedin_postsRecent posts by a LinkedIn member, newest first | linkedin:read | unipile | read-only · LinkedIn budget · recorded as a run |
| list_linkedin_connectionsFirst-degree connections of a connected sender account | linkedin:read | unipile | read-only · LinkedIn budget · recorded as a run |
| list_sender_accountsThe LinkedIn accounts this workspace can act as, with their status, warm-up stage and remaining daily budget | workspace:read | internal | read-only |
| get_usageHow many LinkedIn tool calls this workspace has made this month, and the rate limit in force | usage:read | internal | read-only |
| list_campaignsList all campaigns with performance stats | workspace:read | internal | read-only |
| get_statsGet workspace-wide outreach stats | workspace:read | internal | read-only |
| list_leadsList lead lists and leads | workspace:read | internal | read-only |
| get_inboxGet unified inbox conversations | workspace:read | internal | read-only |
| list_tasksList drafts waiting for approval | workspace:read | internal | read-only |
| pause_campaignPause a campaign by id | workspace:write | internal | writes |
| search_capabilitiesSearch what TopClozer products can actually do, and what performs each capability today | products:read | internal | read-only |
| search_productsSearch the TopClozer catalogue by outcome, kind, tier, price, risk or capability | products:read | internal | read-only |
| get_productOne product in full: capabilities, risk, price, and whether this workspace has bought or installed it | products:read | internal | read-only |
| install_productRecord an installation of a product into an AI client or the hosted runner. Writes a row; runs nothing. | products:install | internal | writes |
| list_installationsWhat this workspace has installed, where, and what it paid for | products:read | internal | read-only |
| list_projectsThe projects in this workspace, with how complete each one's context is | projects:read | internal | read-only |
| get_project_contextThe reusable company / ICP / offer / voice record an agent should read before it writes anything | projects:read | internal | read-only |
| check_creditsThe workspace credit balance and the ledger entries behind it | credits:read | internal | read-only |
| get_platform_usageWhat this workspace owns and has run: installs, purchases, the hosted seat, credits and the execution ledger | usage:read | internal | read-only |
What this API does not do
Stated here because finding out from a 404 wastes a developer's afternoon, and because an API that only advertises its strengths is how integrations get built on assumptions.
- No sending. Nothing here invites, messages or posts on LinkedIn, and there is no plan for a capability that does. Outbound actions are released by the product under the workspace sending policy, which by default waits for a person.
- No webhooks. There is no way to be told that a reply arrived; you poll
get_inbox. - No paging on the workspace reads.
list_leadsandget_inboxreturn everything in one payload. On a large workspace that response is big; filter on your side. - No creating campaigns, importing leads or approving drafts. Those are in the app. An agent can read the approval queue and summarise it; it cannot press send.
- No idempotency keys. The two write capabilities are effectively idempotent — pausing a paused campaign changes nothing, and an install records a row — but there is no header that makes a retry safe in general, so do not build one that assumes it.