Files
turnstone/docs/sdk.md
T
Patrick Buckley c28bfc1e58 feat: skill discovery — search and install skills from external sources (#111)
* feat: skill discovery — search and install skills from external sources

Add discovery UI and API for finding and installing skills from
skills.sh registries and GitHub repositories with one-click install,
SKILL.md frontmatter parsing, and security scan integration.

Core modules:
- skill_parser.py: ParsedSkill dataclass, parse_skill_md() with YAML
  frontmatter support (Anthropic + Hermes tag formats), name validation
- skill_sources.py: SkillsShClient (async search + resolve),
  fetch_skill_from_github (SKILL.md + bundled resource fetching with
  256KB cap, text extension filter, GitHub API tree traversal)

API:
- GET /v1/api/admin/skills/discover — search with installed annotation
  and scan_status for installed skills
- POST /v1/api/admin/skills/install — fetch, parse, duplicate check,
  create with origin="source" readonly=true, store resources, audit

Also fixes pre-existing bug where _skill_to_response omitted scan_status,
scan_report, scan_version fields — scan tier badges in the installed
skills table were silently empty despite data existing in storage.

Admin UI: pill toggle (Installed/Discover), discovery cards with scan
tier badges, GitHub import modal with proper focus trap/Escape/backdrop,
scoped selectors preventing MCP↔Skills cross-tab state corruption.

SDK: discover_skills() + install_skill() on Python (async+sync) and
TypeScript console clients.

48 new tests across 3 test files. All 2632 tests pass.

* fix: address copilot review — 404 vs 502, O(n) lookups, branch fallback

- SkillNotFoundError subclass: install returns 404 when SKILL.md is
  missing, 502 only for connectivity/upstream errors
- get_skill_by_source_url() + list_installed_skill_urls(): indexed
  storage lookups replace O(n) full-table scans with content blobs
- Default branch fallback: tries main then master when URL doesn't
  specify a branch
- Path normalization: strip trailing slash once, remove redundant
  candidate
- SDK install_skill() returns typed SkillInfo with response_model
- Tree size guard: skip resource tree if response >2MB
2026-03-16 18:42:32 -07:00

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, skill) 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, skill) 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
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, cache_creation_tokens, cache_read_tokens
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}`);

// 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                  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.

# 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.