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.
One contract, one gateway, two clients
Section titled “One contract, one gateway, two clients”The design has three layers:
- 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. - 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.v1is additive-only, and a breaking change ships assdk.v2alongside it rather than breaking existing scripts. - Two thin clients over that namespace’s plain HTTP wire format, a query is a
GETwith a JSON input parameter, a mutation is aPOSTwith a JSON body, and a response is either{result: {data}}or{error}. Neither client needs a heavy runtime: the Python client is a smallhttpx-based transport with generated pydantic models on top.
Getting a client
Section titled “Getting a client”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);from subspace import Subspace
sb = Subspace()page = sb.kb.read(page="projects/subspace")sb.kb.edit(page=page.page.slug, okf=page.okf + "\n- analyzed in a notebook")
hits = sb.search.query(q="quarterly numbers", kinds=["node", "mail"])df = sb.sql("select type, count(*) from ops.events group by 1").df()
run = sb.agents.invoke(instruction="summarize this week's meetings")result = run.wait(timeout=300)Token minting and scopes
Section titled “Token minting and scopes”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:
subspace token create --scope kb.read,search.read --expires 90dA 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.
Realtime: watch and the outbox feed
Section titled “Realtime: watch and the outbox feed”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);}async for msg in sb.watch([f"page:{page.id}", "tasks"]): print(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.
Zero-config access inside code cells
Section titled “Zero-config access inside code cells”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 subspacesb = 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.
An MCP server on the same contract
Section titled “An MCP server on the same contract”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:
npx @subspace/sdk mcpThis 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.