* refactor(session): make ModelLane the provider boundary (#979) ## Summary This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot. - Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding. - Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call. - Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references. - Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results. - Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts. ## Validation - SQLite suite: 11,188 passed, 9 skipped, 10 deselected - PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected - Live backend: 3 passed - SSE recovery: 6 passed; browser recovery harness passed all scenarios - Ruff: clean; 595 files correctly formatted - mypy: 243 source files clean - TypeScript: typecheck/build and 35 tests passed - OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte - `git diff --check` and Git LFS integrity clean Closes #979. * fix(deps): update nanoid for GHSA-2v37-7h3g-55p8 Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation. Validation: - npm ci - npm audit --audit-level=moderate: 0 vulnerabilities - TypeScript typecheck and build - TypeScript tests: 35 passed * fix(test): assert canonical model registry URLs Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation. Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
15 KiB
Turnstone Client SDK
See also: API Reference | Architecture | SDK Class Diagram
Typed HTTP client libraries for programmatic access to the turnstone server and console APIs. Available in Python (sync + async) and TypeScript.
Python SDK
The Python SDK is included in the turnstone package — no extra install required. It wraps the REST and SSE endpoints with typed methods that return Pydantic models directly.
Quick Start
from turnstone.sdk import TurnstoneServer
# Synchronous client — login with username/password
with TurnstoneServer("http://localhost:8080") as client:
client.login(username="alice", password="s3cret")
# Create a workstream
ws = client.create_workstream(name="Analysis")
# Send a message and wait for the full response
result = client.send_and_wait("Summarize this codebase.", ws.ws_id)
print(result.content)
# Stream events in real time
for event in client.stream_events(ws.ws_id):
if event.type == "content":
print(event.text, end="", flush=True)
# Close when done
client.close_workstream(ws.ws_id)
Alternatively, authenticate with an API token:
with TurnstoneServer("http://localhost:8080") as client:
client.login(token="ts_abc123...")
ws = client.create_workstream(name="CI run")
result = client.send_and_wait("Run the test suite.", ws.ws_id)
Async Client
import asyncio
from turnstone.sdk import AsyncTurnstoneServer
async def main():
async with AsyncTurnstoneServer("http://localhost:8080") as client:
await client.login(username="alice", password="s3cret")
ws = await client.create_workstream(name="demo")
async for event in client.stream_events(ws.ws_id):
if event.type == "content":
print(event.text, end="", flush=True)
asyncio.run(main())
Server Client API
Both TurnstoneServer (sync) and AsyncTurnstoneServer (async) expose:
| Category | Method | Returns |
|---|---|---|
| Workstreams | list_workstreams() |
ListWorkstreamsResponse |
dashboard() |
DashboardResponse |
|
create_workstream(*, name, model, auto_approve, resume_ws, skill, persona, initial_message, project_id, attachments, ...) |
CreateWorkstreamResponse |
|
close_workstream(ws_id) |
StatusResponse |
|
| Attachments | upload_attachment(ws_id, filename, data, *, mime_type=...) |
UploadAttachmentResponse |
list_attachments(ws_id) |
ListAttachmentsResponse |
|
get_attachment_content(ws_id, attachment_id) |
bytes |
|
delete_attachment(ws_id, attachment_id) |
StatusResponse |
|
| Chat | send(message, ws_id) |
SendResponse |
approve(*, ws_id, approved, feedback, always, cycle_id, call_id) |
ApproveResponse |
|
command(*, ws_id, command) |
StatusResponse |
|
cancel(ws_id, *, force=False) |
CancelResponse |
|
| Streaming | stream_events(ws_id) |
Iterator[ServerEvent] |
stream_global_events() |
Iterator[ServerEvent] |
|
| High-level | send_and_wait(message, ws_id, *, timeout, on_event) |
TurnResult |
| Saved | list_saved_workstreams() |
ListSavedWorkstreamsResponse |
| Auth | login(username=..., password=...) |
AuthLoginResponse |
login(token="ts_xxx") |
AuthLoginResponse |
|
logout() |
StatusResponse |
|
auth_status() |
AuthStatusResponse |
|
| Health | health() |
HealthResponse |
Console Client API
Both TurnstoneConsole (sync) and AsyncTurnstoneConsole (async) expose:
| Category | Method | Returns |
|---|---|---|
| Cluster | overview() |
ClusterOverviewResponse |
nodes(*, sort, limit, offset) |
ClusterNodesResponse |
|
workstreams(*, state, node, search, sort, page, per_page) |
ClusterWorkstreamsResponse |
|
node_detail(node_id) |
NodeDetailResponse |
|
snapshot() |
ClusterSnapshotResponse |
|
create_workstream(*, node_id, name, model, initial_message, skill, persona, resume_ws) |
ConsoleCreateWsResponse |
|
| Schedules | list_schedules() |
ListSchedulesResponse |
create_schedule(*, name, schedule_type, initial_message, ...) |
ScheduleInfo |
|
get_schedule(task_id) |
ScheduleInfo |
|
update_schedule(task_id, *, name=..., enabled=..., ...) |
ScheduleInfo |
|
delete_schedule(task_id) |
StatusResponse |
|
list_schedule_runs(task_id, *, limit=50) |
ListScheduleRunsResponse |
|
| MCP Registry | search_mcp_registry(q="", *, limit=20, cursor=None) |
RegistrySearchResponse |
install_from_registry(registry_name, source, *, index=0, name="", variables=None, env=None, headers=None) |
McpServerDetail |
|
| Skill Discovery | discover_skills(q="", *, limit=20) |
SkillDiscoverResponse |
install_skill(source, *, skill_id="", url="") |
dict |
|
| Streaming | stream_cluster_events() |
Iterator[ClusterEvent] |
| Auth | login(username=..., password=...) / login(token="ts_xxx") |
AuthLoginResponse |
logout() |
StatusResponse |
|
| Health | health() |
ConsoleHealthResponse |
Event Types
SSE events are deserialized into typed dataclasses. Use event.type to discriminate.
Per-workstream events (from stream_events(ws_id)):
| Type | Class | Key Fields |
|---|---|---|
connected |
ConnectedEvent |
model, model_alias, skip_permissions |
content |
ContentEvent |
text |
reasoning |
ReasoningEvent |
text |
tool_info |
ToolInfoEvent |
items |
approve_request |
ApproveRequestEvent |
cycle_id, items |
tool_result |
ToolResultEvent |
call_id, name, output, is_error |
tool_output_chunk |
ToolOutputChunkEvent |
call_id, chunk |
status |
StatusEvent |
prompt_tokens, total_tokens, pct, effort, cache_creation_tokens, cache_read_tokens |
error |
ErrorEvent |
message |
info |
InfoEvent |
message |
stream_end |
StreamEndEvent |
— |
state_change |
StateChangeEvent |
state ∈ running/thinking/attention/idle/error |
in_progress_snapshot |
InProgressSnapshotEvent |
content, reasoning (one-shot mid-stream refresh resume) |
approval_resolved |
ApprovalResolvedEvent |
cycle_id, call_ids, approved, feedback, always |
cancelled |
CancelledEvent |
— |
Current servers bootstrap conversation history through
GET /v1/api/workstreams/{ws_id}/history before the SSE stream; they do not
emit a history event. HistoryEvent remains deserializable only for
compatibility with older servers. The Python client does not yet expose a
typed helper for this bootstrap endpoint.
Global events (from stream_global_events()):
| Type | Class | Key Fields |
|---|---|---|
ws_state |
WsStateEvent |
ws_id, state, tokens, activity |
ws_activity |
WsActivityEvent |
ws_id, activity, activity_state |
ws_rename |
WsRenameEvent |
ws_id, name |
ws_closed |
WsClosedEvent |
ws_id |
Cluster events (from stream_cluster_events()):
| Type | Class | Key Fields |
|---|---|---|
node_joined |
NodeJoinedEvent |
node_id |
node_lost |
NodeLostEvent |
node_id |
cluster_state |
ClusterStateEvent |
ws_id, node_id, state, tokens |
ws_created |
ClusterWsCreatedEvent |
ws_id, node_id, name |
ws_closed |
ClusterWsClosedEvent |
ws_id |
ws_rename |
ClusterWsRenameEvent |
ws_id, name |
snapshot |
ClusterSnapshotEvent |
nodes, overview, timestamp |
TurnResult
The send_and_wait() method returns a TurnResult that aggregates the full response:
result = client.send_and_wait("Hello", ws_id, timeout=60)
result.content # Full text response
result.reasoning # Chain-of-thought (if shown)
result.tool_results # List of (tool_name, output) tuples
result.errors # Any error messages
result.ok # True if no errors and not timed out
result.timed_out # True if timeout expired
Attachments
Upload files to a workstream and attach them to the next user turn:
# Upload separately, then send a message — attachments auto-attach
with open("screenshot.png", "rb") as f:
att = client.upload_attachment(ws.ws_id, "screenshot.png", f.read(), mime_type="image/png")
client.send("What's wrong in this screenshot?", ws.ws_id)
# Or attach at workstream-creation time (multipart upload)
from turnstone.sdk import AttachmentUpload
with open("notes.txt", "rb") as f:
ws = client.create_workstream(
name="triage",
initial_message="Summarize the notes",
attachments=[AttachmentUpload(data=f.read(), filename="notes.txt", mime_type="text/plain")],
)
Limits: images ≤ 4 MiB (png/jpeg/gif/webp), text ≤ 512 KiB (UTF-8),
10 pending per (workstream, user). The SDK auto-generates ws_id on the
client so cluster-routed callers bind attachments to the owning node
before the request lands.
Forking a workstream
resume_ws is the API's compatibility name for an atomic fork. It creates a
new workstream ID while the source remains unchanged:
fork = client.create_workstream(
resume_ws=ws.ws_id,
name="analysis-branch",
initial_message="Try the alternative plan.",
)
assert fork.resumed
The server transaction clones the source's checkpoint-bounded history, saved
session configuration, persona, project, and attachment references. Do not
combine resume_ws with attachments; fork first, then upload to the new ID.
To rehydrate the original ID rather than branch it, call the server's
POST /v1/api/workstreams/{ws_id}/open endpoint.
Error Handling
Non-2xx responses raise TurnstoneAPIError:
from turnstone.sdk import TurnstoneServer, TurnstoneAPIError
try:
client.send("hi", "bad_ws_id")
except TurnstoneAPIError as e:
print(e.status_code) # 404
print(e.message) # "Unknown workstream"
TypeScript SDK
Located at sdk/typescript/. Zero runtime dependencies for browsers; uses native fetch and ReadableStream for SSE parsing.
Quick Start
import { TurnstoneServer } from "@turnstone/sdk";
const client = new TurnstoneServer({ baseUrl: "http://localhost:8080" });
// Login with username/password or API token
await client.login({ username: "alice", password: "s3cret" });
// or: await client.login({ token: "ts_abc123..." });
// Create workstream and send message
const ws = await client.createWorkstream({ name: "demo" });
const result = await client.sendAndWait("Hello!", ws.ws_id);
console.log(result.content);
// Stream events
for await (const event of client.streamEvents(ws.ws_id)) {
if (event.type === "content") {
process.stdout.write(event.text);
}
}
Console Client
import { TurnstoneConsole } from "@turnstone/sdk";
const client = new TurnstoneConsole({ baseUrl: "http://localhost:8090" });
await client.login({ username: "alice", password: "s3cret" });
const overview = await client.overview();
console.log(`Nodes: ${overview.nodes}, Workstreams: ${overview.workstreams}`);
// Search and install from the MCP Registry
const results = await client.searchMcpRegistry({ q: "github", limit: 10 });
const server = await client.installFromRegistry({
registry_name: results.servers[0].name,
source: "remote",
});
// Search and install skills from external registries
const skills = await client.discoverSkills({ q: "code review" });
const skill = await client.installSkill({
source: "github",
url: "https://github.com/owner/skill-repo",
});
// Stream cluster events
for await (const event of client.clusterEvents()) {
console.log(event.type, event);
}
Type Safety
All event types are modeled as a discriminated union:
import { isContentEvent, isErrorEvent } from "@turnstone/sdk";
import type { ServerEvent } from "@turnstone/sdk";
function handleEvent(event: ServerEvent) {
if (isContentEvent(event)) {
// event is narrowed to ContentEvent
console.log(event.text);
} else if (isErrorEvent(event)) {
console.error(event.message);
}
}
Custom Fetch
The client accepts a custom fetch implementation for testing or Node.js environments:
const client = new TurnstoneServer({
baseUrl: "http://localhost:8080",
fetch: myCustomFetch,
});
Architecture
turnstone/sdk/ Python SDK (sub-package)
_base.py Shared httpx async client, auth, error handling
_sync.py Background event loop for sync wrappers
_types.py TurnResult + TurnstoneAPIError
events.py 38 SSE event dataclasses with type registry
server.py AsyncTurnstoneServer + TurnstoneServer
console.py AsyncTurnstoneConsole + TurnstoneConsole
sdk/typescript/ TypeScript SDK (npm package)
src/base.ts fetch wrapper, auth, SSE streaming
src/server.ts TurnstoneServer class
src/console.ts TurnstoneConsole class
src/events.ts Discriminated union events + type guards
src/sse.ts ReadableStream SSE parser
src/types.ts Request/response interfaces
The Python SDK reuses Pydantic models from turnstone/api/ directly — no schema duplication. The TypeScript SDK has hand-written interfaces matching those models.
Both SDKs follow the same design: typed methods for REST endpoints, async iterators for SSE streams, and a high-level send_and_wait method for simple request-response patterns.
Authentication
When auth is enabled on the server, the SDK handles JWT-based authentication automatically.
Login Flow
There are two ways to authenticate:
-
Username + password — calls
POST /v1/api/auth/loginwith credentials. The server validates against the user database and returns a JWT. -
API token — calls
POST /v1/api/auth/loginwith ats_-prefixed token string. The server looks up the token, resolves the associated user, and returns a JWT.
In both cases the server returns the JWT in the response body and as a Set-Cookie header. The SDK extracts the JWT and includes it as a Bearer token in the Authorization header on all subsequent requests.
# Username + password
client.login(username="alice", password="s3cret")
# API token (created via admin API or turnstone-admin CLI)
client.login(token="ts_abc123...")
Token Lifecycle
- JWTs have a configurable expiry (default: 24 hours).
client.auth_status()returns the current user identity and scopes without refreshing the token.client.logout()clears the stored JWT from the client.- If a request returns 401, the SDK raises
TurnstoneAPIError— the caller is responsible for re-authenticating.
Token Types
The SDK accepts any Bearer token — JWTs (from ServiceTokenManager or login) and API tokens (ts_ prefix) are both supported. Use token_factory for auto-rotating JWTs or a static token for API tokens.