mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-23 20:34:49 -06:00
02d9c5c797
* feat: workstream templates — behavioral profiles for workstream creation Workstream templates define the complete configuration for workstream creation: system prompt, model, auto-approve policy, per-tool auto-approve, temperature, reasoning effort, max tokens, agent max turns, token budget, and completion notifications. Applied once at creation time (snapshot, not live binding). Auto-versioning captures pre-update state on every edit. Schema & storage: - workstream_templates + workstream_template_versions tables (migration 011) - ws_template_id/ws_template_version columns on workstreams table - ws_template column on scheduled_tasks table - Full CRUD + versioning on SQLite and PostgreSQL backends - prompt_template_hash (SHA-256) for drift detection Runtime: - Template resolution before mgr.create() for model override - Post-creation settings application (prompt, temperature, approval, budget) - Token budget enforcement in session.send() — 80% warning, approval gate at 100% via __budget_override__ synthetic tool - WebUI.auto_approve_tools server-side per-tool auto-approve - Prompt template drift detection (hash comparison, log warning on mismatch) Integration: - ws_template field on CreateWorkstreamMessage, bridge, channel router, scheduler dispatch, MQ client - Console admin "WS Templates" tab (11th) with CRUD, version history modal - Profile dropdown on workstream creation modal - WS template dropdown on scheduler create/edit modals - Prompt template name validation on ws_template create/update - 7 console admin API endpoints + read-only summary endpoint - Full OpenAPI spec entries in console_spec.py - Python SDK (sync + async) and TypeScript SDK methods - Pydantic schemas for all request/response models Docs & diagrams: - New 21-ws-template-architecture.puml sequence diagram - Updated governance, storage, MQ protocol diagrams + PNGs - Updated architecture.md, governance.md, api-reference.md, console.md, sdk.md 48 new tests (1788 total). mypy clean. ruff clean. * fix: address PR #49 review feedback - auto_approve_tools uses approval_label (not just func_name) for consistency with tool policy evaluation - inline system_prompt from ws_template persisted as _ws_template_system_prompt in workstream_config, restored on resume (previously lost because _template_content wasn't persisted) - budget gate (__budget_override__) no longer bypassed by blanket auto_approve — requires explicit approval or tool policy allow - diagram 21 field list corrected (removed tool_search/threshold, added prompt_template_hash/notify_on_complete) * fix: address PR #49 review feedback (round 2) - Grant admin.ws_templates permission in migration 011 (tab was hidden) - Center WS template modals and fix radio button alignment - Skip template validation when ws_template overrides prompt - Guard against empty version snapshots on no-op updates - Replace setTimeout race with Promise chain in schedule ws_template select - Validate numeric fields in admin create/update handlers (400 not 500) - Add ws_template to TypeScript OpenAPI specs - Use typed Pydantic response models in SDK ws_template methods
326 lines
12 KiB
Markdown
326 lines
12 KiB
Markdown
# Turnstone Client SDK
|
|
|
|
> See also: [API Reference](api-reference.md) | [Architecture](architecture.md) | [SDK Class Diagram](diagrams/png/13-sdk-architecture.png)
|
|
|
|
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
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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, ws_template)` | `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, ws_template)` | `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` |
|
|
| **WS Templates** | `list_ws_templates()` | `ListWsTemplatesResponse` |
|
|
| | `create_ws_template(*, name, description, ...)` | `WsTemplateInfo` |
|
|
| | `get_ws_template(template_id)` | `WsTemplateInfo` |
|
|
| | `update_ws_template(template_id, *, name=..., enabled=..., ...)` | `WsTemplateInfo` |
|
|
| | `delete_ws_template(template_id)` | `StatusResponse` |
|
|
| | `list_ws_template_versions(template_id)` | `ListWsTemplateVersionsResponse` |
|
|
| **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:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
1. **Username + password** — calls `POST /v1/api/auth/login` with credentials. The server validates against the user database and returns a JWT.
|
|
|
|
2. **API token** — calls `POST /v1/api/auth/login` with a `ts_`-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.
|
|
|
|
```python
|
|
# 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.
|