* 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
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:
-
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.