API & MCP
Your account is an MCP server. Issue a Bearer key at Settings → API Keys, point Claude Cowork (or any MCP-compatible agent) at /api/v1/mcp, and it can call the tools below against your account. Every tool is also a plain REST endpoint if you'd rather not speak MCP.
Authentication
Issue a key at /settings/api-keys. Keys are Bearer-style:
Authorization: Bearer sak_abc1…xyz - The raw key is shown once at creation; only a SHA-256 hash + short prefix are stored.
- Keys are scoped per action. The six data tools each require a scope (below); the control-plane and content-engine tools need only a valid, non-revoked key acting on its own account.
- Data & brain tools require an active entitlement. Every tool that reads or writes your data or brain — the six data verbs plus
emit_telemetry,sync_brain,start_run,run_heartbeat,connect_integration, and thestudio_*tools — is callable only while the account holds at least one active paid entitlement (a plugin, bundle, suite, membership, or team seat). Without one they returnnot_entitled(MCP error-32003/ HTTP403) pointing you to /pricing. The onboarding tools stay open — see below. This is additive to the scope check, not a replacement. - Every call writes a row to your audit log (
api_key_calls): which key, which tool, ok/error/denied, duration, sizes. - Revoke any time from the same panel; the next call from a revoked key returns 401 immediately.
- Keys are tenant-scoped — a key issued by one account can never touch another's data.
Two ways to call
Same tools, same keys, same scopes, same audit log — pick the transport that fits your client.
MCP (JSON-RPC)
Point an MCP client at https://salesascode.com/api/v1/mcp. It calls tools/list to introspect, then tools/call to invoke. This is what the Sales as Code Cowork plugins use.
REST
POST JSON to /api/v1/tools/<name> with a Bearer header:
curl -X POST https://salesascode.com/api/v1/tools/log_capture \
-H 'authorization: Bearer sak_…' \
-H 'content-type: application/json' \
-d '{"source":"cowork","text":"Acme champion confirmed Q3 budget"}' stdio (local bridge)
HTTP is the canonical transport. For MCP clients that only speak stdio (Claude Desktop, some Cowork setups), run the bundled bridge — a thin local proxy that forwards newline-delimited JSON-RPC to the same /api/v1/mcp endpoint with your Bearer key. Same tools, same scopes, same audit log; no server credentials required beyond the key.
From the repo: npm run mcp:stdio (reads SALES_AS_CODE_API_KEY and optional SALES_AS_CODE_URL from the environment). To wire it into Claude Desktop, add a server to claude_desktop_config.json:
{
"mcpServers": {
"sales-as-code": {
"command": "node",
"args": ["/abs/path/to/scripts/mcp-stdio.mjs"],
"env": {
"SALES_AS_CODE_API_KEY": "sak_…",
"SALES_AS_CODE_URL": "https://salesascode.com"
}
}
}
} The bridge holds no business logic — it relays stdio ⇆ HTTP. Auth, scopes, rate limits, and the audit log all live on the server, exactly as with a direct HTTP client.
Per-user and per-team keys
Every key belongs to the user who issues it. A team owner can instead issue a team key (a toggle in the New key dialog): it still writes into the owner's workspace, but the control-plane tools resolve entitlements against the team's plan — the union of the owner's grants and every plugin the team's seat packs license. A per-user key is unchanged; it authorizes against that user's own plan. verify_session reports scope: "team" or "user" and, for team keys, the team id.
OAuth-provider mode (optional)
Instead of pasting a key, an MCP client can obtain access through a standard OAuth 2.0 authorization-code + PKCE flow. This is env-gated — off unless the deployment sets MCP_OAUTH_ENABLED=true; when off, the endpoints below return 404 and pasted Bearer keys are the only path.
The honest trick that keeps it small: the OAuth access token is a managed API key. The token endpoint mints a scoped api_keys row under the hood, so an OAuth-obtained token revokes, audits, and rate-limits exactly like a hand-issued key — you'll see it in Settings → API keys named OAuth (<client>).
- Discovery.
GET /.well-known/oauth-authorization-server(RFC 8414) advertises the endpoints andS256PKCE;GET /.well-known/oauth-protected-resource(RFC 9728) points a client at this authorization server. - Authorize.
GET /api/v1/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&scope=…&code_challenge=…&code_challenge_method=S256&state=…. The consent step uses your logged-in Sales as Code session (you'll be sent to log in first if needed), shows the requested scopes, and on Allow redirects back with a one-timecode. - Token.
POST /api/v1/oauth/tokenwithgrant_type=authorization_code,code,redirect_uri,client_id, and the PKCEcode_verifier. Returns{ "access_token": "sak_…", "token_type": "Bearer", "scope": "…" }. Use that token exactly like any pasted key.
Honest scope: PKCE (S256) is required; there is no dynamic client registration (clients pass an opaque client_id; the code binds the redirect_uri) and no refresh tokens (the minted key doesn't expire — revoke it in Settings). Codes are single-use and expire in 10 minutes.
The tools
Sixteen tools in three groups.
Data verbs (scoped)
Thin facades over the same store the app uses, so external writes and in-app data converge on one record shape. Each needs the scope shown.
log_capture·capture.write— log a free-form note or transcript; optionally attach to an account or prospect.update_prospect·prospect.write— create or update a prospect (omitidto create).add_to_plan·plan.write— append to a named section of an account plan (next_steps,risks,champion_notes, …).mark_meeting_outcome·meeting.write— record a meeting outcome (advance/stall/lostor free-form) with notes + next step.read_account·account.read— read an account; optionally include its plan, recent meetings, or captures.pull_brief·brief.read— a structuredtoday/weeksummary: accounts active, recent captures + meetings, open plan sections.
Control-plane tools
The bridge every Cowork plugin uses: verify the licensed session and gate on entitlement first, then report telemetry and brain state last. No extra scope — a valid key on its own account is enough.
Onboarding stays open; the rest need entitlement. A brand-new account with a key but no purchase can always call verify_session, check_entitlement, and pull_config — that's how a plugin connects and discovers it needs to buy. The telemetry and brain tools in this group (start_run, run_heartbeat, emit_telemetry, sync_brain) write your brain/run state, so they require an active entitlement just like the data verbs and return not_entitled otherwise.
verify_session— returns the account, plan, and entitled plugins. Every skill calls this first and stops if it errors.check_entitlement— is this account licensed for a named plugin? Stops and asks the user to buy/connect if not.start_run— declare that a skill run has started so the rollup can show it live. Returns arun_refcorrelation id (server-generated unless you supply one): pass it torun_heartbeatwhile working, toemit_telemetryasrun_idwhen done, and tosync_brainasrun_ref. Optional — skills that only report at the end keep working.run_heartbeat— signal a started run is still working. At most one per 60s, and only needed for runs longer than a few minutes; a started run silent for 30 minutes is flaggedstale. Returnsrunning: false(not an error) when the run already finished.emit_telemetry— record a run (plugin, skill, counts, outcome, duration). Whenrun_idcarries therun_reffromstart_run, this settles the declared run (running→ok/error) instead of recording a second row. Optional error taxonomy:error_code(one ofauth·entitlement·rate_limit·provider·skill_logic·unknown) +error_detail(≤2000 chars) categorize failures for the rollup's run drill-down; free-formoutcomestrings keep working. The full payload is stored for the drill-down, capped at 64KB — oversized calls are rejected withpayload_too_large, never silently truncated. Optionalaccount_ref(≤200 chars) tags the run with the account it worked (see below). Metadata only. Powers the rollup.sync_brain— push brain state for a scope (signal-weights, icp-registry, decision-log, funnel-brain, content calendar, …). Optionalrun_refattributes the sync to the exact run that produced it (otherwise the rollup falls back to a time-window match). Optionalaccount_ref(≤200 chars) tags the brain state with the account it is about. No raw contact PII by default; 256KB cap.pull_config— plan, entitled plugins, and feature flags so a skill can adapt at run start.
Content-engine tools
Used by the Content Engine plugin to connect channels and drive the studio from Cowork. Also no extra scope.
connect_integration— write back an OAuth token the Cowork connector obtained for a channel (linkedin, linkedin_company, youtube, reddit, google_drive), stored encrypted so server-side publish/ingest can run.studio_list_drafts— list the Content Studio draft queue, newest first, with an optional status filter.studio_review_draft— approve, schedule, or hold a draft. Approving hands it to the publisher. It can't markpublished— only the publisher does that.
Telemetry lifecycle for plugin skills
What a well-behaved skill emits, in order. Everything here is optional beyond the final emit_telemetry — old skills keep working — but the more of it a skill sends, the better the rollup gets (live "Running now" strip, honest durations, exact run ↔ brain correlation).
- Start. Call
start_run(afterverify_session/check_entitlement) with the plugin + skill. Keep the returnedrun_reffor the rest of the run. The run appears on the rollup as running immediately. - Heartbeat. For runs longer than a few minutes, call
run_heartbeatwith therun_ref— at most once every 60 seconds (cadence floor; heartbeats multiply rows of noise if spammed, and once per minute is all the staleness rule needs). A started run with no heartbeat for 30 minutes is flagged stale on the dashboard, so a crashed skill never looks "running" forever. - Brain syncs. Pass the same
run_refon everysync_braincall the run makes. That upgrades the run drill-down from "syncs that landed around this run" (time-window guess) to exact attribution. - Final. End with one
emit_telemetrycarrying therun_refasrun_id, plus counts, outcome, and the error taxonomy on failure. This settles the declared run (running→ok/error) in place — one run, one row. Omitduration_msand it is computed from the declared start.
Report silent work too. If a Cowork session did exploratory or advisory work that never triggered a discrete skill (research, Q&A, drafting that wasn't saved), emit a session summary before the session ends: emit_telemetry with skill: "session-summary" and honest counts (e.g. {"questions_answered": 4, "accounts_researched": 2}). Otherwise that work is invisible on the rollup and the user can't see what their plugins actually did for them.
start_run {"plugin":"gtm-engine","skill":"daily-signals"} → {"run_ref":"run_kJ2…"}
run_heartbeat {"run_ref":"run_kJ2…"} (every ≥60s while working)
sync_brain {"scope":"signal-weights","payload":{…},"run_ref":"run_kJ2…"}
emit_telemetry {"plugin":"gtm-engine","skill":"daily-signals",
"run_id":"run_kJ2…","outcome":"ok","counts":{"signals_sensed":12}} Account scoping — account_ref
Both emit_telemetry and sync_brain accept an optional account_ref: a caller-supplied string label for the account a run worked or a brain snapshot is about — a domain ("acme.com") or an account name ("Acme Corp"). It is capped at 200 characters and otherwise unvalidated — an honest free-text label, not a foreign key and never synced CRM records or contact PII. It preserves the metadata-only trust posture: the same label the seller already sees, nothing more.
- Pass it where the skill knows the account. A skill working a specific account (researching
acme.com, prepping the Acme deal) should setaccount_refto a stable label — prefer the domain, since it de-duplicates cleanly. A skill with no single account (a cross-account digest) should omit it. - Use one consistent label per account. The rollup groups by exact string match, so
"acme.com"and"Acme Corp"are two different accounts. Pick one convention (domain is recommended) and reuse it across skills. - Fully optional, fully back-compatible. Omit it and everything works exactly as before — the account filter on /rollup and the team deconfliction card on /team-admin/rollup simply show their honest "no account labels yet" empty state until skills start sending it.
Where it surfaces: the per-account filter on the personal rollup scopes the run history + by-plugin summary to one account; the team rollup shows per-account, per-member activity and a "heads up — teammates worked the same account this week" deconfliction card. Both are metadata-only reporting over these labels — no account data is ever synced to render them.
Error envelope
Non-2xx responses carry a consistent JSON shape:
{ "error": "unauthorized", "message": "Key is missing or revoked." } Status codes: 200 ok · 400 invalid input · 401 missing/revoked key · 403 key lacks the required scope or the account has no active entitlement (not_entitled — buy at /pricing) · 404 unknown tool / resource · 429 rate-limited · 500 server error. Over MCP JSON-RPC the entitlement denial is error code -32003.
Audit & limits
Every call — MCP or REST — writes to your audit log, visible to the issuing user. Keys are rate-limited per key; a misbehaving client on one key can't 429 another. See the API Keys & MCP feature page for the issuing flow and the security model, and Concepts → the MCP contract for how the plugins use these tools.