* feat: generation cancellation — stop button, cancel API, cooperative cancel Add cooperative cancellation via threading.Event on ChatSession. The cancel signal is set from outside the worker thread (HTTP handler, MQ bridge, or Escape key) and checked at defined checkpoints: per streaming chunk, before tool execution, inside bash commands, and at each sub-agent turn. Core: GenerationCancelled(BaseException) exception, cancel()/_check_cancelled() methods, partial content preservation in _stream_response, clean rollback in send() with idle state emission (no re-raise). Server: POST /v1/api/cancel endpoint, CancelledEvent SSE emission, worker thread safety net. Frontend: Stop button (■ Stop) with send/stop swap via setBusy(), Escape key shortcut, cancelled event handler. Accessible: aria-label, focus-visible override, light theme contrast, non-color differentiation. MQ: CancelMessage inbound type, bridge _handle_cancel routed handler. SDK: cancel() on Python async+sync clients, CancelledEvent in Python+TypeScript event registries, isCancelledEvent type guard. OpenAPI: CancelRequest schema + endpoint spec. Docs: API reference, architecture, SDK docs updated. Diagrams: conversation turn, tool pipeline, MQ protocol, workstream states, SDK architecture. * fix: address PR #40 review feedback - setBusy() now resets stopBtn.disabled so stop button is re-enabled on next generation after a successful cancel - Gate cancel side effects (resolve_approval, resolve_plan, cancelled SSE event) on worker_thread.is_alive() to avoid spurious events when idle - Add /v1/api/cancel endpoint and CancelRequest schema to TypeScript openapi-server.json to keep it in sync with Python-generated spec
12 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) |
CreateWorkstreamResponse |
|
close_workstream(ws_id) |
StatusResponse |
|
| Chat | send(message, ws_id) |
SendResponse |
approve(*, ws_id, approved, feedback, always) |
StatusResponse |
|
plan_feedback(*, ws_id, feedback) |
StatusResponse |
|
command(*, ws_id, command) |
StatusResponse |
|
cancel(ws_id) |
StatusResponse |
|
| 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) |
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 |
|
| 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 |
history |
HistoryEvent |
messages |
content |
ContentEvent |
text |
reasoning |
ReasoningEvent |
text |
tool_info |
ToolInfoEvent |
items |
approve_request |
ApproveRequestEvent |
items |
tool_result |
ToolResultEvent |
call_id, name, output |
tool_output_chunk |
ToolOutputChunkEvent |
call_id, chunk |
status |
StatusEvent |
prompt_tokens, total_tokens, pct, effort |
plan_review |
PlanReviewEvent |
content |
error |
ErrorEvent |
message |
info |
InfoEvent |
message |
stream_end |
StreamEndEvent |
— |
cancelled |
CancelledEvent |
— |
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
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}`);
// 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 27 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.
Backward Compatibility
The config-file token (TURNSTONE_AUTH_TOKEN) still works as a simple Bearer token for environments that do not use the user/JWT system. When the server receives a non-JWT Bearer token, it falls back to the legacy token check.