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:
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);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 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:
SUBSPACE_SESSION=ssn_... subspace-sdk token create \ --url https://cell.example.com \ --scope kb.read,search.read \ --save-profile work --default-profileRun 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.
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 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 subspacesb = 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.
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. 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:
SUBSPACE_URL=http://127.0.0.1:4780 \SUBSPACE_TOKEN=sdt_... \npx -y @subspace/sdk mcp
# equivalent after installing the packagesubspace-sdk mcpAn 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.