Skip to content

Client SDKs

Anything you can do in the Subspace UI, and anything an agent can do through its tool registry, is also scriptable from outside the process. @subspace/sdk (TypeScript, npm) and subspace-sdk (Python, PyPI) give you the same read and edit surface over the knowledge base, hybrid search, the task queue, mail and messaging, calendar and CRM, and agent invocation, all against a single versioned, scope-enforced contract.

The design has three layers:

  1. A contract package, @subspace/api, is a pure Zod catalog of every SDK method: its name, input schema, output schema, required scope, and doc string. TypeScript types fall out of the schemas directly; Python models are generated from the same schemas in CI, so both SDKs describe the identical surface.
  2. A versioned sdk.v1.* namespace, mounted beside the app’s own internal API on the same tRPC router. Handlers delegate to the same domain logic the app and the agent toolkit already call, so there’s no second implementation to drift from the first. What the gateway adds on top is scope enforcement, output validation against the declared schema, and a stability promise: sdk.v1 is additive-only, and a breaking change ships as sdk.v2 alongside it rather than breaking existing scripts.
  3. Two thin clients over that namespace’s plain HTTP wire format, a query is a GET with a JSON input parameter, a mutation is a POST with a JSON body, and a response is either {result: {data}} or {error}. Neither client needs a heavy runtime: the Python client is a small httpx-based transport with generated pydantic models on top.

Both SDKs resolve configuration the same way, in order: explicit constructor arguments, then SUBSPACE_URL and SUBSPACE_TOKEN environment variables, then a saved profile, then (inside a code cell) automatic kernel injection. That means the same zero-argument call works in the three places that matter most:

Profiles use one shared file for TypeScript, Python, the CLI, and MCP:

{
"version": 1,
"defaultProfile": "work",
"profiles": {
"work": { "url": "https://cell.example.com", "token": "sdt_..." }
}
}

The default path is ~/.subspace/sdk.json; SUBSPACE_SDK_CONFIG overrides the path and SUBSPACE_SDK_PROFILE selects a non-default entry.

import { Subspace } from '@subspace/sdk';
const sb = new Subspace();
const page = await sb.kb.read({ page: 'projects/subspace' });
await sb.kb.edit({ page: 'projects/subspace', okf: page.okf + '\n- shipped the SDK' });
const hits = await sb.search.query({ q: 'quarterly numbers', kinds: ['node', 'mail'] });
const { runId } = await sb.agents.invoke({ instruction: 'triage my inbox' });
for await (const step of sb.agents.stream(runId)) console.log(step.kind);

SDK tokens are device tokens, the same underlying mechanism as pairing a desktop or mobile client, so they share revocation and audit with every other paired device. What makes an SDK token different is that it carries a scope map instead of implicit full access:

Scope Grants
kb.read / kb.write Pages, nodes, versions, directory tree, OKF read/edit.
files.read / files.write Blob download / upload.
search.read Hybrid search, grep, corpus queries.
tasks.read / tasks.write Task queue, inbox, reminders, schedules.
comms.read Mail, IM, calendar, CRM views.
comms.send Sending mail, sending IMs, starting outreach. Never granted by default.
agents.read / agents.run Listing and reading runs / invoking, steering, starting workflows.
agents.decide Deciding an agent’s pending approvals.
events.read The outbox change feed and non-own-run WebSocket topics.
sql.read The read-only SQL escape hatch. Implies and requires admin.
admin Everything, including plugin install and GitOps sync.

Mint a scoped token from Settings → API tokens in the app, or let the CLI call the same session-only endpoint. Pass the web session through the environment so it does not land in shell history; --save-profile writes the returned token to a 0600 config file:

Terminal window
SUBSPACE_SESSION=ssn_... subspace-sdk token create \
--url https://cell.example.com \
--scope kb.read,search.read \
--save-profile work --default-profile

Run subspace-sdk verify with that profile to exercise every method the token can reach and validate each live response through @subspace/api/conformance. Add --require-all in CI when the token is expected to carry the full contract scope set.

Browser SDK calls remain same-origin unless the server operator explicitly lists exact origins in SUBSPACE_CORS_ORIGINS. The allowlist covers the tRPC and socket-ticket routes and permits the device-token header; wildcards and credential-bearing origins are rejected.

Two ways to observe change, both already load-bearing inside the app itself:

WebSocket topics, for push. Both SDKs wrap the same topic bus the web app uses, with auto-reconnect and resubscribe, exposed as an async iterator:

for await (const msg of sb.watch(['page:' + id, 'tasks'])) {
console.log(msg);
}

The outbox feed, for pull, resumable delivery. events.feed({afterSeq, topics?, limit}) returns {rows, nextSeq} against the same transactional change feed every internal consumer (search indexer, exporter, dispatcher) reads. A script keeps its own cursor, exactly like an internal consumer does, and survives disconnects without missing a change that happened while it was offline. The outbox prunes behind the slowest internal consumer’s cursor with a 30-day floor, so an external consumer that falls more than 30 days behind needs a full resync rather than an incremental catch-up, the same rule every internal consumer already lives by.

The kernel host gives each signed-in user a separate session for each kernelspec and injects SUBSPACE_URL plus a short-lived, narrowly scoped device token into that code cell kernel, so this just works with no setup:

import subspace
sb = subspace.Subspace()

There’s no key to find or paste. The token is owned by the signed-in user and minted per kernel session with a default scope set (kb.*, search.read, tasks.*, files.*, agents.read, events.read, notably without comms.send or admin), and it’s revoked the moment the kernel session ends. Users selecting the same kernelspec never share state or credentials. The explicit system/null-owner kernel partition receives no automatic token and must use explicit credentials. The blast radius of code running in a notebook cell is exactly the granted scope list, not your full account.

Outside a code cell, the same client works unmodified against a local instance, a tailnet box, or a hosted tenant, cron jobs, CI pipelines, and editor or launcher integrations all drive Subspace through the identical SDK, just with an explicit SUBSPACE_URL and SUBSPACE_TOKEN instead of kernel injection.

Because the contract package already produces a full JSON Schema for every method, it doubles as an MCP tool manifest. The SDK ships a stdio server named subspace-mcp: it authenticates by calling sdk.v1.meta.whoami, lists only the methods covered by that principal’s resolved scopes, and dispatches every call through the normal @subspace/sdk transport. The gateway still checks token revocation, scope, ACL, and input on every call; the discovery-time filter is not an authorization bypass.

Set the same environment variables as an SDK process, then start it through the package runner or an installed subspace-sdk binary:

Terminal window
SUBSPACE_URL=http://127.0.0.1:4780 \
SUBSPACE_TOKEN=sdt_... \
npx -y @subspace/sdk mcp
# equivalent after installing the package
subspace-sdk mcp

An MCP host configuration uses the same command and inherited or explicit environment:

{
"mcpServers": {
"subspace": {
"command": "npx",
"args": ["-y", "@subspace/sdk", "mcp"],
"env": {
"SUBSPACE_URL": "http://127.0.0.1:4780",
"SUBSPACE_TOKEN": "sdt_..."
}
}
}
}

Mint the narrowest useful token under Settings → Devices → API tokens, and protect the host config if it contains the token literally. A kb.read token, for example, discovers the read/resolve/tree methods but no writes. Unknown scopes grant nothing; the server refreshes meta.whoami while it runs, so newly granted scopes appear without a restart and reduced or revoked scopes are removed promptly. Each change emits MCP notifications/tools/list_changed, allowing compatible clients to refresh their cached tool list. This discovery refresh remains advisory: every call still goes through the gateway’s live token, scope, ACL, input, not-now, and audit checks. A temporary network failure may leave the last discovered list visible, but it cannot make a call succeed.

Tool input schemas are the contract’s generated JSON Schemas verbatim. Successful calls return the same value twice: JSON text for broadly compatible clients and typed structuredContent under { "data": ... } (the wrapper is required because MCP structured output must be an object while SDK methods may return arrays or null). Invalid arguments, gateway errors, and invalid gateway outputs return MCP tool errors, not success-shaped data.

This inverts the direction Subspace’s own agents already use MCP in: agents consume external MCP servers through mcp/ pages, while this server makes Subspace itself available to any MCP client.