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:

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 from the CLI:

Terminal window
subspace token create --scope kb.read,search.read --expires 90d

A token minted with --gated behaves like a human standing behind an agent’s approval gate rather than a fully autonomous script: side-effecting calls (mail.send, im.send, starting outreach) don’t execute immediately. They file the same approval row and Needs-confirmation card an agent’s gated call would, and the SDK call returns {pending: true, approvalId} instead of completing, so you can wait on client.approvals.wait(approvalId) from the script. This makes it a one-flag decision to give a cron job or a CI script quasi-agent powers while keeping a human on the trigger for anything irreversible.

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 injects SUBSPACE_URL and a short-lived, narrowly scoped device token into every code cell kernel it spawns, so this just works with no setup:

import subspace
sb = subspace.Subspace()

There’s no key to find or paste. The token is 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. 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 for free. Running the SDK’s MCP server connects as a normal SDK client, lists the manifest filtered down to whatever scopes the configured token actually has, and serves each method as an MCP tool. That makes every scoped Subspace action available to Claude Code, Cursor, or any other MCP host:

Terminal window
npx @subspace/sdk mcp

This inverts the direction Subspace’s own agents already use MCP in: agents consume external MCP servers through mcp/ pages today, and this makes Subspace itself available as one, to any MCP client, for essentially no additional implementation cost beyond the contract that already exists.