Files
turnstone/docs/architecture.md
T
Patrick Buckley 62d2a0fe6a fix: remove non-auth support from bootstrap wizard (#274)
* fix: remove non-auth support from bootstrap wizard

Auth is now mandatory for all deployments. Remove the
TURNSTONE_AUTH_ENABLED toggle and make JWT_SECRET and AUTH_TOKEN
required in the wizard's system prompt.

* fix: remove auth disable support from runtime and infra

Remove AuthConfig.enabled field — auth is always on. Drop
TURNSTONE_AUTH_ENABLED env var, config toggle, and the
check_request bypass. Update compose.yaml, Helm chart,
Terraform, docs, and tests to match.

* feat: deprecate config tokens, require JWT secret, prefer JWT auth

Phase 1 of config-token removal:

- load_jwt_secret() now exits with error if no secret is configured
  (was: silently auto-generated ephemeral secret)
- _authenticate_token() logs deprecation warning on config token use
- CLI /cluster commands use ServiceTokenManager when JWT secret is set
- turnstone-admin tls-list uses ServiceTokenManager when JWT secret is set
- Update bootstrap wizard, docker.md, security.md to mark
  TURNSTONE_AUTH_TOKEN as deprecated and JWT_SECRET as required
- Console test fixtures use auth token + headers (auth always enforced)

* feat: add service scope for inter-service JWT auth

Add "service" to VALID_SCOPES and SCOPE_HIERARCHY. Service tokens
bypass require_permission() RBAC checks, replacing the old
empty-user-id bypass that config tokens relied on.

All ServiceTokenManager instances that need admin access now include
"service" in their scopes (console proxy, channel gateway, CLI,
admin CLI). Read-only services (collector, notification) unchanged.

* feat: phase 2 config token deprecation

- SDK doc examples now show API tokens (ts_) instead of config tokens
- Remove _get_config_token() from admin CLI (dead code)
- Block config token exchange in handle_auth_login — only password
  and API token login allowed
- Update login tests to use password-based auth instead of config
  token exchange

* feat: phase 3 — remove config tokens entirely

Complete removal of config-file token authentication:

- Delete AuthConfig.tokens, check(), _ROLE_TO_SCOPES, hmac dispatch
  branch, and config token loading from load_auth_config()
- Remove auth_config parameter from _authenticate_token() and
  check_request() — callers updated throughout
- Remove TURNSTONE_AUTH_TOKEN from compose.yaml, Helm charts,
  Terraform, turnstone.example.toml
- Remove --auth-token CLI flags from turnstone, turnstone-admin,
  and turnstone-console
- Simplify console main() — always use ServiceTokenManager
  (no fallback to static tokens)
- Delete config-token-specific tests, rewrite check_request and
  integration tests to use JWT auth with proper audience claims
- Remove all config token references from docs (security.md,
  docker.md, sdk.md, console.md, architecture.md, bootstrap prompt)

* fix: address code review findings

- Fix 33 broken tests: add JWT auth to test_api_versioning,
  test_console_routing_proxy, test_tls_admin, test_tls_manager,
  test_server_live (jwt_secret + audience-scoped auth headers)
- Add TestRequirePermissionServiceScope: 4 tests covering the
  service scope RBAC bypass path
- Remove stale comments referencing config tokens in auth.py and
  console/server.py
- Remove dead proxy_auth_token parameter from console create_app()
  and static token fallback in _proxy_auth_headers()
- Remove TURNSTONE_AUTH_TOKEN from env.py scrub list

* fix: address Copilot review — JWT audience, compose require secret

- CLI /cluster: add audience=JWT_AUD_CONSOLE to ServiceTokenManager
  (console validates audience, JWTs without it were rejected)
- Admin CLI tls-list: same audience fix
- compose.yaml: TURNSTONE_JWT_SECRET now uses :? to fail fast if unset
- SDK console: fix default port from 8081 to 8090

* test: add auth enforcement tests for TLS admin endpoints

5 new tests: unauthenticated requests return 401 (list, renew,
delete), read-only-scoped requests return 403 (renew, delete).
Closes the TLS auth enforcement test gap noted in PROGRESS.md.

* fix: address remaining Copilot review feedback

- Fix token_source="config" → "test" in TLS test fixtures
- Fix AuthResult.token_source docstring to include service origins
- Require TURNSTONE_JWT_SECRET in cluster compose profile (:?)
- Helm: add auth.jwtSecret + auth.existingSecret values, wire
  TURNSTONE_JWT_SECRET into secret.yaml and both deployments
- Terraform: replace auth_token with jwt_secret variable + secret,
  remove orphaned auth_token resources and IAM reference
- Remove [[auth.tokens]] from security.md config example

* fix: address full code review — 10 findings

Critical:
- Terraform: replace concat(common_env, auth_env) with common_env
  (auth_env local was removed but still referenced)
- Channel gateway: remove hmac static token auth from _check_auth(),
  use JWT-only validation. Remove --auth-token CLI arg from channel
- Rebalancer: add token_manager support so migration requests carry
  JWT auth (was sending unauthenticated POST to /internal/migrate)

Major:
- Guard _permissions_to_scopes() against "service" privilege
  escalation from DB role permissions
- Remove dead AuthConfig class, load_auth_config(), and all
  auth_config parameters from create_app() signatures
- Helm: inject JWT secret for both inline and existingSecret paths

Minor:
- Remove dead auth_token param from ClusterCollector
- Remove empty TestLoadAuthConfig class
- Short JWT secret now exits instead of warning
- Compose: add generation command comment above JWT_SECRET
- Clean stale config token references from 6 doc files
- Clean stale AUTH_TOKEN reference from bootstrap wizard prompt

* fix: remove remaining stale config token references from docs

- channels.md: remove --auth-token from options table
- oidc.md: remove "config-file tokens still work" claim
- security.md: remove config token section, fix JWT secret docs
  (now required/exits, no ephemeral fallback), remove hmac from
  ASCII diagram, remove --auth-token reference
2026-04-01 19:38:24 -07:00

65 KiB

Turnstone Architecture

Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or Anthropic's native Messages API via pluggable provider adapters, and gives the model 19 built-in tools plus external tools via MCP (Model Context Protocol) for reading, writing, searching, planning, and executing code.

The core design principle is a UI-agnostic engine with pluggable frontends. The engine (ChatSession) drives the conversation loop -- streaming, tool dispatch, retry, compaction -- while every user-facing interaction is delegated through the SessionUI protocol. Any frontend implements that protocol and plugs in.

Entry Points

Command Module Frontend Purpose
turnstone turnstone.cli TerminalUI Interactive terminal REPL
turnstone-server turnstone.server WebUI Browser-based chat (HTTP + SSE)
turnstone-console turnstone.console.server ClusterCollector Cluster dashboard (aggregates all nodes)
turnstone-eval turnstone.eval NullUI Headless evaluation and prompt optimization
turnstone-channel turnstone.channels.cli ChannelAdapter Channel gateway (Discord, Slack, etc.)
turnstone-admin turnstone.core.admin_cli Offline user and API token management

Module Map

turnstone/
  cli.py              Terminal frontend (TerminalUI, WorkstreamTerminalUI, REPL)
  server.py           Web frontend (WebUI, HTTP handler, static-file serving)
  eval.py             Evaluation harness (HeadlessSession, scoring, prompt optimization)
  core/
    session.py        ChatSession engine, SessionUI protocol, tool dispatch
    providers/        LLM provider adapters (pluggable backend layer)
      _protocol.py    LLMProvider protocol, ModelCapabilities, StreamChunk, CompletionResult
      _openai.py      OpenAIProvider — OpenAI, vLLM, llama.cpp, any compatible API
      _anthropic.py   AnthropicProvider — Anthropic Messages API, native streaming, thinking
      __init__.py     create_provider() + create_client() factory functions
    workstream.py     Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager)
    tools.py          Tool schema loader (JSON -> OpenAI function-calling format)
    mcp_client.py     MCPClientManager — MCP server connections, tool discovery, dynamic refresh
    tool_search.py    Dynamic tool search — BM25 index, session-scoped tool visibility
    watch.py          WatchRunner daemon — periodic command polling, condition DSL, result dispatch
    judge.py          Intent validation — heuristic rules + LLM judge, advisory verdicts
    model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
    memory.py         Persistence facade + structured memory API (delegates to storage backend)
    config.py         Config file loader (config.toml), apply_config(), warn_migrated_settings()
    config_store.py   ConfigStore — database-backed settings with in-memory cache, thread-safe get/set
    settings_registry.py  SettingDef catalog (~40 settings), validation, type coercion, serialization
    storage/          Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
    metrics.py        Prometheus-compatible metrics collector (MetricsCollector)
    healthcheck.py    BackendHealthMonitor — periodic probe + circuit breaker
    ratelimit.py      Per-IP token-bucket rate limiter (RateLimiter, TokenBucket)
    edit.py           File edit utilities (find_occurrences, pick_nearest)
    safety.py         Command safety validation (blocked patterns, sanitization)
    sandbox.py        Math code sandboxing (AST validation, subprocess execution)
    web.py            Web utilities (HTML stripping, SSRF prevention)
  api/
    schemas.py        Shared Pydantic v2 models (auth, errors, WorkstreamState)
    server_schemas.py Server endpoint request/response models
    console_schemas.py Console endpoint request/response models
    openapi.py        OpenAPI 3.1 spec builder
    server_spec.py    Server endpoint catalog → build_server_spec()
    console_spec.py   Console endpoint catalog → build_console_spec()
    docs.py           /openapi.json + /docs (Swagger UI) handler factories
  sdk/
    server.py         AsyncTurnstoneServer + TurnstoneServer (HTTP client)
    console.py        AsyncTurnstoneConsole + TurnstoneConsole (HTTP client)
    events.py         27 SSE event dataclasses with type registry
    _base.py          Shared httpx async client, auth, error handling
    _sync.py          Background event loop for sync wrappers
    _types.py         TurnResult + TurnstoneAPIError
  console/
    collector.py      ClusterCollector — aggregates state from all nodes via SSE
    scheduler.py      TaskScheduler — background cron/at scheduler, dispatches via HTTP
    server.py         Cluster dashboard HTTP server + SSE + CLI entry point
    static/           Cluster dashboard web UI (page-specific HTML, CSS, JS)
  channels/
    cli.py            Unified channel gateway entry point (turnstone-channel)
    _protocol.py      ChannelAdapter protocol, ChannelEvent dataclass
    _routing.py       ChannelRouter — channel/thread ↔ workstream mapping via HTTP
    _config.py        Base ChannelConfig dataclass
    discord/          Discord adapter (bot, cog, views, streaming, config)
  shared_static/      Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
    katex-0.16.44/    Vendored KaTeX math rendering library (MIT, woff2 fonts)
  ui/
    colors.py         ANSI color constants with NO_COLOR support
    markdown.py       Streaming terminal markdown renderer (line-buffered)
    spinner.py        Braille character spinner (daemon thread)
    static/
      index.html      Single-page app shell (links to CSS and JS)
      style.css       Page-specific UI styles (dashboard, markdown elements, approval blocks)
      renderer.js     Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
      app.js          Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
  tools/
    *.json            15 tool schemas (OpenAI function-calling format + turnstone metadata)

Both UIs share a common design system extracted into turnstone/shared_static/: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports base.css and the shared JS modules at /shared/, then adds only page-specific code at /static/.


Core Loop

See also: Conversation Turn diagram

A user message flows through the system as follows:

 User input
     |
     v
 ChatSession.send(user_input)
     |
     v
 _full_messages()  ------------>  system_messages + self.messages
     |
     v
 _emit_state("thinking")
     |
     v
 _create_stream_with_retry()  ---->  provider.create_streaming(client, model, messages, ...)
     |                                  up to 3 retries (4 total attempts), exponential backoff
     v
 _stream_response(stream)  -------->  dispatch tokens to UI:
     |                                  on_reasoning_token() / on_content_token()
     |                                  accumulate tool_calls from deltas
     |                                  track finish_reason
     |                                  _check_cancelled() per chunk (cooperative cancel)
     v
 finish_reason check:
     +--- "length"  --> warn, discard partial tool_calls
     +--- "content_filter" --> warn
     v
 tool_calls present?
     |
     +--- No ---> _print_status_line() -> _emit_state("idle") -> return
     |
     +--- Yes --> _emit_state("running")
                    |
                    v
                  _execute_tools(tool_calls)  <--- three-phase pipeline (see below)
                    |
                    v
                  append tool results to self.messages
                    |
                    v
                  loop back to _full_messages()

Tool Execution Pipeline

See also: Tool Pipeline diagram

Tool execution is a three-phase process:

Phase 1: PREPARE (serial)
  For each tool_call:
    _prepare_tool(tc)
      -> parse JSON arguments (with regex fallback for malformed JSON)
      -> dispatch to _prepare_{tool_name}(call_id, args)
      -> validate inputs, build preview text
      -> return item dict with: header, preview, needs_approval, execute fn

Phase 2: APPROVE (serial, blocking)
  _emit_state("attention")
  ui.approve_tools(items)
    -> display all headers and previews
    -> if any need approval and not auto_approve: prompt user
    -> return (approved, feedback)
  _emit_state("running")

Phase 3: EXECUTE (parallel)
  _check_cancelled()  <-- cancellation checkpoint before execution starts
  if len(items) == 1:
    run_one(items[0])
  else:
    ThreadPoolExecutor(max_workers=4).map(run_one, items)
  Bash tool streams stdout line-by-line via ui.on_tool_output_chunk(call_id, line)
    (cancel_event also checked per line — kills process group on cancel)
  Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
  call_id links tool_info items → streaming chunks → final result
  For plan tool: post-execution gate via ui.on_plan_review()

State Transitions

The engine emits state changes via _emit_state() which calls ui.on_state_change(state). Frontends use these to update indicators (spinner, tab badges, status line).

  send() called
      |
      v
  "thinking"  --->  streaming response
      |
      v
  "running"   --->  tool execution
      |
      v
  "attention"  --->  waiting for user approval / plan review
      |
      v
  "running"   --->  executing approved tools
      |
      v
  "idle"       --->  no more tool calls, turn complete
      |
  (or "error"  --->  exception or KeyboardInterrupt)

  cancel() may be called from any state. It sets a cooperative flag
  checked at each streaming chunk, before tool execution, and inside
  bash commands. The session transitions to "idle" with partial
  content preserved, emitting on_info("[Generation cancelled]").

SessionUI Protocol

See also: Core Engine Classes diagram

Defined in turnstone.core.session.SessionUI as a typing.Protocol with 14 methods. Every frontend must implement all of them.

class SessionUI(Protocol):
    def on_thinking_start(self) -> None: ...
    def on_thinking_stop(self) -> None: ...
    def on_reasoning_token(self, text: str) -> None: ...
    def on_content_token(self, text: str) -> None: ...
    def on_stream_end(self) -> None: ...
    def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
    def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
    def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
    def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
    def on_plan_review(self, content: str) -> str: ...
    def on_info(self, message: str) -> None: ...
    def on_error(self, message: str) -> None: ...
    def on_state_change(self, state: str) -> None: ...
    def on_rename(self, name: str) -> None: ...  # propagate alias to tab/UI label

on_rename is called by the /name command (on success) and after a successful /resume (if the resumed session has an alias or title). WebUI.on_rename broadcasts a ws_rename event on the global SSE channel and updates the in-memory Workstream.name; TerminalUI.on_rename is a no-op.

Three Implementations

Class Module Notes
TerminalUI turnstone.cli ANSI colors, MarkdownRenderer, Spinner, readline-based input() for approval
WebUI turnstone.server SSE event queue per workstream + global broadcast, threading.Event for blocking on approval/plan. on_state_change sends to both per-workstream and global SSE (the browser UI uses per-workstream state_change events to manage busy/idle transitions; stream_end only finalizes markdown rendering).
NullUI turnstone.eval Discards all output; approve_tools always returns (True, None)

WorkstreamTerminalUI

WorkstreamTerminalUI (in turnstone.cli) extends TerminalUI with workstream awareness:

  • Output buffering: When in background (is_foreground is False), tokens are appended to _output_buffer instead of written to stdout. When the user switches to this workstream, flush_buffer() replays them.

  • Approval blocking: approve_tools() and on_plan_review() call _fg_event.wait() when in background, blocking the worker thread until the workstream is foregrounded. This ensures the user sees the approval prompt in the correct context.

  • Foreground/background toggle: set_foreground(bool) sets or clears _fg_event (a threading.Event). The manager calls this during /ws <N> switches.


Workstream Architecture

Workstreams are parallel, independent chat sessions. Each has its own ChatSession, SessionUI, message history, and worker thread.

WorkstreamState

See also: Workstream States diagram

Defined in turnstone.core.workstream.WorkstreamState (5 states):

IDLE       waiting for user input
THINKING   LLM is streaming a response
RUNNING    tools are executing
ATTENTION  blocked on user approval or plan review
ERROR      last operation failed

Data Model

@dataclass
class Workstream:
    id: str                              # uuid hex, 8 chars
    name: str                            # user-visible label
    state: WorkstreamState               # current state
    session: ChatSession | None          # the conversation engine
    ui: SessionUI | None                 # frontend adapter
    worker_thread: threading.Thread | None
    error_message: str
    last_active: float                   # time.monotonic() timestamp, updated on every state change
    _lock: threading.Lock                # per-workstream state lock

WorkstreamManager

class WorkstreamManager:
    MAX_WORKSTREAMS = 10

    def __init__(self, session_factory: Callable[[SessionUI], ChatSession]): ...
    def create(self, name="", ui_factory=None) -> Workstream: ...
    def close(self, ws_id: str) -> bool: ...
    def close_idle(self, max_age_seconds: float) -> list[str]: ...  # auto-close stale IDLE workstreams
    def get(self, ws_id: str) -> Workstream | None: ...
    def get_active(self) -> Workstream | None: ...
    def list_all(self) -> list[Workstream]: ...
    def switch(self, ws_id: str) -> Workstream | None: ...
    def switch_by_index(self, index: int) -> Workstream | None: ...
    def set_state(self, ws_id, state, error_msg=""): ...  # updates last_active

The session_factory pattern decouples session creation from configuration. The factory captures shared config (client, model, temperature, etc.) and accepts only a SessionUI, so the manager can create sessions without knowing API details.

Idle Workstream Lifecycle

The web server runs a background _idle_cleanup_thread (daemon) that calls WorkstreamManager.close_idle() periodically (every timeout / 4, max 5 min). Any IDLE workstream whose last_active is older than the configured timeout is closed; non-IDLE workstreams (THINKING, RUNNING, ATTENTION, ERROR) are never touched. The last workstream is always preserved even if expired. On close, a ws_closed event is broadcast on the global SSE channel so browser clients remove the tab immediately. Controlled by --workstream-idle-timeout (default: 120 minutes, 0 = disable).

Workstream eviction at capacity: When WorkstreamManager.create() would exceed max_workstreams (configurable via [server].max_workstreams, default 50), the oldest IDLE workstream is automatically evicted to make room. The turnstone_workstreams_evicted_total counter is incremented on each eviction. If no IDLE workstream is available the create request fails as before.

CLI Workstreams

  • /ws list -- show all workstreams with state indicators
  • /ws new [name] -- create a new workstream and switch to it
  • /ws <N> -- switch to workstream by 1-based index
  • /ws close [N] -- close a workstream
  • /ws rename <name> -- rename the active workstream

Background notifications: when a background workstream enters ATTENTION state, _bg_attention_notify writes an ANSI escape sequence to stderr (overwrites the line above the prompt) with the workstream name.

Status line: _print_ws_status_line() shows a compact status of all non-idle background workstreams above the input prompt.

Web Workstreams

  • Tab bar: Each workstream renders as a tab with a colored state indicator (CSS @keyframes pulse animation per state). Clicking a tab switches the focused pane's workstream (or focuses an existing pane showing that ws).
  • Split panes: The UI supports tiling multiple workstreams side-by-side or stacked via a binary layout tree. Each Pane instance encapsulates its own SSE connection, message area, input, and state (busy, approval, streaming). Split via right-click context menu, pane header buttons, or keyboard (Ctrl+\, Ctrl+Shift+\). Max 6 panes; no duplicate workstreams across panes. Layout persisted to localStorage.
  • Per-pane SSE: Pane.connectSSE(wsId) opens /v1/api/events?ws_id=<id> for each pane's event stream independently.
  • Global SSE: connectGlobalSSE() opens /v1/api/events/global which receives ws_state broadcasts from all workstreams, used to update tab indicators and pane headers without switching.
  • New tab / close: POST /v1/api/workstreams/new, POST /v1/api/workstreams/close.

Thread Safety

  • WorkstreamManager._lock: guards _workstreams dict and _order list on all create/close/switch/list operations.
  • Workstream._lock: guards per-workstream state mutations in set_state().
  • WorkstreamTerminalUI._print_lock: guards _output_buffer access.
  • WorkstreamTerminalUI._fg_event: threading.Event that blocks background approval until the workstream is foregrounded.

Tool System

Schema Format

Each tool is a JSON file in turnstone/tools/. The file contains an OpenAI function-calling schema (name, description, parameters) plus optional turnstone metadata keys:

Metadata Key Type Meaning
agent bool Include this tool when running as a plan/task sub-agent
task_agent bool Include this tool when running as a task sub-agent
auto_approve bool Tool is read-only; skip user approval
primary_key str Fallback argument name for bare-string JSON recovery

Example (read_file.json):

{
  "name": "read_file",
  "description": "Read the contents of a file. ...",
  "parameters": {
    "type": "object",
    "properties": {
      "path": { "type": "string", "description": "..." },
      "offset": { "type": "integer", "description": "..." },
      "limit": { "type": "integer", "description": "..." }
    },
    "required": ["path"]
  },
  "agent": true,
  "task_agent": true,
  "auto_approve": true,
  "primary_key": "path"
}

At import time, turnstone.core.tools._load_tools() strips the metadata keys from each schema and builds:

  • TOOLS -- list of {"type": "function", "function": {...}} dicts for the API
  • AGENT_TOOLS -- subset with agent: true
  • TASK_AGENT_TOOLS -- subset with task_agent: true
  • AGENT_AUTO_TOOLS / TASK_AUTO_TOOLS -- sets of tool names with auto_approve: true
  • PRIMARY_KEY_MAP -- {name: primary_key} for JSON fallback recovery
  • merge_mcp_tools(builtin, mcp_tools) -- merges built-in + MCP tools at session init

13 Tools by Category

Read-only (auto-approve):

  • read_file -- read file contents with optional offset/limit
  • search -- ripgrep-based codebase search
  • man -- read man pages
  • recall -- search conversation history

Write (requires approval):

  • bash -- execute shell commands (with safety checks via turnstone.core.safety)
  • write_file -- create or overwrite a file
  • edit_file -- string replacement in an existing file (requires prior read_file)
  • math -- execute Python in sandboxed subprocess (via turnstone.core.sandbox)
  • web_fetch -- fetch a URL (with SSRF protection via turnstone.core.web)
  • web_search -- search the web (provider-native for Anthropic/OpenAI, Tavily fallback for local models)

Agent (delegated sub-sessions):

  • task -- delegate to a sub-agent with full tool access (TASK_AGENT_TOOLS)
  • plan -- explore codebase and write a structured plan (AGENT_TOOLS)

Memory (structured persistent store):

  • memory -- save, search, delete, or list memories (typed and scoped)

Prepare / Execute Pattern

Every tool has a _prepare_{name} method and a corresponding _exec_{name} method on ChatSession:

_prepare_bash(call_id, args)   -> item dict with execute=self._exec_bash
_prepare_read_file(call_id, args) -> item dict with execute=self._exec_read_file
...

The prepare method validates inputs and builds the preview. The item dict carries the validated data and a reference to the execute function. This separation allows the UI to show previews before any side effects occur.

Agent Tools

task and plan invoke _run_agent(), which runs a multi-turn loop with a subset of tools and its own system prompt. The sub-agent runs independently, then returns the final content as the tool result.

  • task: uses self._task_tools (TASK_AGENT_TOOLS + MCP tools)
  • plan: uses self._agent_tools (AGENT_TOOLS + MCP tools). Writes output to .plan-<ws_id>.md — unique per ChatSession so concurrent workstreams don't collide. On repeat invocations the prior plan tool call and its result are forwarded from self.messages so the agent refines the existing plan rather than starting over. Planning instructions are injected as a developer message prepended to the agent's conversation.
  • Turn limit: controlled by agent_max_turns (default: -1, unlimited). When a limit is set and reached, the agent is forced to synthesize a final response without tools. When unlimited, the loop only exits when the model stops calling tools or hits finish_reason: "length".
  • Retry: each API call in the agent loop uses the same retry+backoff logic as the main _create_stream_with_retry().
  • Finish reason handling: finish_reason: "length" stops the agent early and returns whatever content was generated. finish_reason: "content_filter" returns a placeholder.

MCP Tool Integration

MCPClientManager (turnstone/core/mcp_client.py) connects to external MCP servers and exposes their tools alongside built-in tools. The MCP SDK is fully async; turnstone bridges this with a background asyncio event loop in a daemon thread.

Configuration sources: MCP servers can be defined in config files (TOML/JSON) or in the database via the admin UI. Database-backed definitions are managed through the console admin panel's MCP Servers tab and stored in the mcp_servers table. On startup, load_mcp_config(storage=) uses first-match-wins priority: DB rows (if any enabled) take precedence over config files. The console can trigger a cluster-wide reload (POST /_internal/mcp-reload) that causes each node to call reconcile_sync(), which diffs the running MCP connections against the current DB state and adds, removes, or reconnects servers as needed.

Lifecycle:

  1. create_mcp_client() reads server configs from TOML/JSON and database
  2. MCPClientManager.start() launches the background event loop thread
  3. _connect_all() connects to each server (stdio subprocess or HTTP), runs initialize() + list_tools(), converts schemas to OpenAI format, detects tools.listChanged capability for push notification support
  4. ChatSession.__init__ receives the manager, builds self._tools (built-in + MCP), and registers a listener callback for tool-change notifications
  5. _prepare_tool() routes MCP tools to _prepare_mcp_tool() / _exec_mcp_tool()
  6. _exec_mcp_tool() calls call_tool_sync() which dispatches to the async loop via asyncio.run_coroutine_threadsafe()

Tool refresh: Three mechanisms keep tools up-to-date without restart:

  • Push: Servers declaring tools.listChanged send ToolListChangedNotification; the registered message_handler triggers immediate single-server refresh.
  • Periodic: Servers without push support are polled on a staggered interval (default 4 h, configurable via [mcp] refresh_interval or --mcp-refresh-interval).
  • Manual: /mcp refresh [server] calls refresh_sync() for on-demand refresh (also attempts reconnection for disconnected servers).

When tools change, _rebuild_tools() creates new _tools/_tool_map objects (copy-on-write for thread safety) and notifies listener callbacks. Each ChatSession rebuilds its merged tool lists and reconstructs ToolSearchManager (preserving expanded tools).

Tool naming: mcp__{server}__{tool} — double underscore delimiter, validated at connection time (server names with __ are rejected).

Error isolation: Per-server connection/refresh failures are caught and logged; other servers are unaffected. Tool execution errors return error strings to the LLM rather than crashing the session.

Registry discovery: The console admin panel provides a registry discovery surface backed by the official MCP Registry (registry.modelcontextprotocol.io). MCPRegistryClient (turnstone/core/mcp_registry.py) is a standalone httpx async client that queries the registry's v0.1 API for server discovery. Search results are annotated with installed status by cross-referencing the mcp_servers table. Installation creates a DB row with registry_name, registry_version, and registry_meta columns (migration 019), then triggers cluster-wide node reload via _notify_nodes_mcp_reload(). The registry URL is configurable via the mcp.registry_url setting for enterprise/private registries.

Provider Adapter Layer

See also: Core Engine Classes diagram

ChatSession is provider-agnostic — it delegates all LLM communication to an LLMProvider protocol (turnstone/core/providers/_protocol.py). Internally, messages use an OpenAI-like format; each provider translates at the API boundary.

ChatSession
    |
    v
LLMProvider (protocol)
    |
    +--- OpenAIProvider  --- OpenAI, vLLM, llama.cpp, any /v1/chat/completions API
    +--- AnthropicProvider --- Anthropic Messages API (native streaming, thinking)

Protocol methods:

Method Purpose
create_streaming() Streaming request, yields normalized StreamChunk objects
create_completion() Non-streaming request, returns CompletionResult
get_capabilities() Per-model flags (ModelCapabilities)
convert_tools() Translate OpenAI tool schemas to provider format
retryable_error_names Exception class names that trigger retry

Normalized data types:

Type Fields
StreamChunk content_delta, reasoning_delta, tool_call_deltas, info_delta, usage, finish_reason
CompletionResult content, tool_calls, finish_reason, usage
ModelCapabilities context_window, max_output_tokens, supports_temperature, token_param, thinking_mode, supports_effort, supports_web_search, supports_tool_search, supports_vision
UsageInfo prompt_tokens, completion_tokens, total_tokens, cache_creation_tokens, cache_read_tokens

OpenAIProvider (_openai.py): passes messages through unchanged (they are already in OpenAI format), including multi-part content blocks (text + images) in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4, O-series, and search models (gpt-5-search-api) — all with supports_vision. For search models, injects web_search_options and removes the web_search function tool (the model always searches). Citations from url_citation annotations are formatted as footnotes. Extended prompt cache retention (prompt_cache_retention: "24h") is enabled for GPT-5.x models at no additional cost. Cached token counts are extracted from usage.prompt_tokens_details.cached_tokens. Unknown models (local servers) get permissive defaults with supports_vision=False and use Tavily for web search.

AnthropicProvider (_anthropic.py): converts OpenAI-format messages to Anthropic content blocks, maps system/developer roles to the system parameter, groups consecutive tool result messages into user-role content blocks (converting image_url parts to Anthropic's image source format), and translates tool schemas from OpenAI function-calling format to Anthropic's input_schema format. Supports both manual and adaptive thinking modes, with effort parameter support for models like Claude Opus 4.6 and Sonnet 4.6. Replaces the web_search function tool with Anthropic's native web_search_20250305 server-side tool — Claude decides when to search, the API executes it, and results stream back as server_tool_use / web_search_tool_result content blocks (emitted as info_delta for UI display). Automatic prompt caching is enabled via top-level cache_control: {"type": "ephemeral"} — the API places the cache breakpoint on the last cacheable block and advances it as conversations grow (90% input cost reduction on cache hits, 1.25x write on first turn). Cache metrics (cache_creation_input_tokens, cache_read_input_tokens) are extracted from both streaming and non-streaming responses. The anthropic SDK is imported lazily so it remains an optional dependency (pip install turnstone[anthropic]).

Factory functions (__init__.py): create_provider(name) returns a singleton provider instance (thread-safe). create_client(name, base_url, api_key) creates the appropriate SDK client.

Multi-Model Registry

ModelRegistry (turnstone/core/model_registry.py) manages named model configurations so workstreams can use different LLM backends.

Config format:

[models.local]
base_url = "http://localhost:8000/v1"
model = "qwen3-32b"
# provider defaults to "openai"

[models.claude]
provider = "anthropic"
api_key = "sk-ant-..."
model = "claude-opus-4-6"
context_window = 200000

[models.openai]
base_url = "https://api.openai.com/v1"
api_key = "sk-..."
model = "gpt-5"
context_window = 400000

[model]
default = "local"
fallback = ["claude", "openai"]
agent_model = "claude"

Each [models.*] entry produces a ModelConfig with a provider field (default: "openai"). Supported values: "openai" and "anthropic". An optional [models.*.capabilities] sub-table overrides per-model ModelCapabilities flags (useful for local models whose capabilities cannot be detected programmatically):

[models.qwen-vl]
base_url = "http://localhost:8000/v1"
model = "qwen-3.5-vl"

[models.qwen-vl.capabilities]
supports_vision = true

Lifecycle:

  1. load_model_registry() reads [models.*] sections from config.toml and builds a "default" entry from CLI --base-url/--model/--api-key args
  2. The registry is passed to the session factory closure in both cli.py and server.py; each workstream resolves its model on creation
  3. ModelRegistry.get_client() lazily creates SDK client instances via create_client()OpenAI for the openai provider, Anthropic for the anthropic provider (thread-safe via _client_lock)
  4. ModelRegistry.get_provider() lazily creates LLMProvider instances via create_provider() (also cached and thread-safe)
  5. /model command shows available models; /model <alias> switches the active workstream's client, model, and context window
  6. _create_stream_with_retry() tries the primary model, then each fallback alias in order if the primary is unreachable
  7. _run_agent() resolves registry.agent_model (if set) for plan/task sub-agents, allowing a cheaper model for autonomous loops

Per-workstream selection: POST /v1/api/workstreams/new accepts an optional "model" field, along with skill (skill name) which can override the model before workstream creation.

Tool Output Truncation

Tool execution results (bash, read_file, search, math, man) are truncated by _truncate_output() when they exceed tool_truncation characters. Truncation preserves the first half and last half of the output, with a message in between:

... [N chars truncated — output exceeded LIMIT char limit] ...

The default limit is 50% of the context window in characters (computed as context_window * chars_per_token * 0.5). For a 131K context window this is ~262K characters. Override with --tool-truncation <chars>.

This truncation message is visible to the model, so it knows output was cut.


Persistence

Storage Architecture

Persistence is managed by the turnstone.core.storage package — a pluggable backend behind a StorageBackend protocol. The memory.py facade provides backward-compatible module-level functions that delegate to the active backend.

session.py / server.py / cli.py
        ↓
    memory.py  (facade — silent-failure wrappers)
        ↓
    storage._registry  (singleton factory)
        ↓
  ┌─────────────┐    ┌──────────────────┐
  │ SQLiteBackend │    │ PostgreSQLBackend │
  │ (FTS5 search) │    │ (tsvector/ILIKE)  │
  └─────────────┘    └──────────────────┘
        ↓                     ↓
    storage._schema  (SQLAlchemy Core tables — single source of truth)
        ↓
    storage._migrate  (programmatic Alembic)

SQLite is the default (zero-config, single file at .turnstone.db). PostgreSQL is the production backend (connection pooling, tsvector full-text search). Select via [database] in config.toml, CLI flags, or environment variables (TURNSTONE_DB_BACKEND, TURNSTONE_DB_URL).

Schema migrations are managed by Alembic and run automatically on startup. Existing SQLite databases created before the migration system are auto-stamped at the baseline revision.

Tables

memories
  key      TEXT PRIMARY KEY
  value    TEXT NOT NULL
  created  TEXT NOT NULL
  updated  TEXT NOT NULL

workstreams
  ws_id       TEXT PRIMARY KEY
  node_id     TEXT NOT NULL
  name        TEXT NOT NULL
  state       TEXT NOT NULL DEFAULT 'idle'
  alias       TEXT UNIQUE            -- user-assigned short name (nullable)
  title       TEXT                   -- LLM-generated title (nullable)
  created     TEXT NOT NULL
  updated     TEXT NOT NULL          -- bumped on every save_message()

conversations
  id            INTEGER PRIMARY KEY AUTOINCREMENT
  ws_id         TEXT NOT NULL
  timestamp     TEXT NOT NULL
  role          TEXT NOT NULL        -- user | assistant | tool_call | tool_result
  content       TEXT
  tool_name     TEXT
  tool_args     TEXT
  tool_call_id  TEXT                 -- links tool_call ↔ tool_result for resume
  provider_data TEXT                 -- raw provider content (e.g. Anthropic encrypted)

workstream_config
  ws_id       TEXT NOT NULL          -- composite PK with key
  key         TEXT NOT NULL
  value       TEXT

conversations_fts                    -- SQLite FTS5 virtual table (optional)
  content     (content=conversations, content_rowid=id)

Table definitions live in storage/_schema.py (SQLAlchemy Core Table objects) and are the single source of truth for both backends and Alembic migrations.

StorageBackend Protocol

Method Purpose
register_workstream(ws_id, node_id, name, state) Create a workstreams row (no-op if exists)
save_message(ws_id, role, content, ...) Log a message to conversations
load_messages(ws_id) Reconstruct OpenAI message format from DB rows
list_workstreams_with_history(limit) List workstreams with >=1 message, ordered by updated DESC
delete_workstream(ws_id) Delete workstream and cascade conversations + config
prune_workstreams(retention_days) Remove empty workstreams and old unnamed workstreams
resolve_workstream(alias_or_id) Resolve alias, exact id, or id prefix to full ws_id
save_workstream_config(ws_id, config) Persist workstream configuration key/value pairs
load_workstream_config(ws_id) Retrieve workstream configuration
set_workstream_alias(ws_id, alias) Set user-friendly alias (returns False if taken)
get_workstream_display_name(ws_id) Return alias if set, else title, else None
update_workstream_title(ws_id, title) Set/update LLM-generated title
update_workstream_state(ws_id, state) Update workstream state and bump timestamp
update_workstream_name(ws_id, name) Update workstream display name
list_workstreams(node_id, limit) List workstreams, optionally by node
kv_get(key) / kv_set(key, value) / kv_delete(key) Generic key-value store (backs memories table)
kv_list() / kv_search(query) List or search key-value pairs
search_history(query, limit) Full-text search (FTS5 on SQLite, tsvector on PostgreSQL)
search_history_recent(limit) Return most recent messages
close() Release resources (connection pool, engine)

Database Configuration

[database]
backend = "sqlite"                  # "sqlite" | "postgresql"
path = ".turnstone.db"              # SQLite file path
url = ""                            # PostgreSQL connection URL
pool_size = 2                       # PostgreSQL connection pool size (per process)

Environment variables: TURNSTONE_DB_BACKEND, TURNSTONE_DB_URL, TURNSTONE_DB_PATH, TURNSTONE_DB_POOL_SIZE.

The default pool is intentionally small (2 base + 3 overflow = 5 per process) because all database operations are short-burst queries that hold connections for milliseconds. For clusters with many nodes sharing a PostgreSQL instance, use PgBouncer in transaction pooling mode.

Persistence and Resume

ws_id is the sole persistent identity for both routing and conversation history. There is no separate session_id — the workstreams table holds alias, title, and state alongside the routing fields (node_id, name). Messages are saved to conversations (keyed by ws_id) as they happen via save_message(). Workstream state changes are tracked via update_workstream_state().

Auto-titling: After the first complete exchange (user message + assistant response), a background thread calls the LLM with a title-generation prompt (reasoning_effort: "low", max_completion_tokens: 200). The generated title (3-8 words) is stored in workstreams.title.

Resume flow: ChatSession.resume(ws_id) calls load_messages() which reconstructs the OpenAI message format from database rows:

  • user and assistant rows map directly
  • Consecutive tool_call rows are grouped into one assistant message's tool_calls array, paired with subsequent tool_result rows via tool_call_id (or positional matching for legacy data)
  • Interrupted conversation repair: If the last assistant message has tool_calls but fewer tool results than expected (conversation was interrupted mid-execution), the incomplete turn is stripped so the LLM can re-generate cleanly
  • The ChatSession adopts the resumed _ws_id, so new messages continue in the same workstream

Config persistence: LLM-affecting parameters (temperature, reasoning_effort, max_tokens, instructions, creative_mode) are persisted to the workstream_config table on creation and whenever changed via slash commands. resume() restores these values so resumed workstreams behave identically to the original.

/clear vs /new: /clear wipes in-memory context but preserves messages in the database for future resume. /new starts a fresh workstream (new _ws_id), leaving the old workstream resumable.

Resolution: resolve_workstream() accepts aliases, exact workstream IDs, or ID prefixes, enabling turnstone --resume refactor or /resume abc12.

Workstream listing: list_workstreams_with_history() only returns workstreams that have at least one saved message (WHERE EXISTS on conversations). Workstreams registered but never used (e.g., from process startup) are invisible until a message is sent.

Workstream pruning: prune_workstreams(retention_days, log_fn) runs once at startup (CLI and server). It removes:

  • Workstreams with no messages (orphaned registrations)
  • Unnamed workstreams (alias IS NULL) older than retention_days days (default 90)

Named (aliased) workstreams are never age-pruned. Configure with --retention-days N (0 = disable age pruning).


Error Handling and Retry

API Retry

ChatSession._create_stream_with_retry() (streaming path) and the agent _api_call() (non-streaming) both use the same retry pattern:

  • Retries: 4 total attempts (1 initial + 3 retries, _MAX_RETRIES = 3)
  • Backoff: exponential, base 1 second (delay = 1s * 2^attempt)
  • Retryable errors: RateLimitError, APITimeoutError, APIConnectionError, InternalServerError, ServiceUnavailableError, APIError (matched by class name to avoid importing backend-specific exception hierarchies)
  • On retry: ui.on_info() notification
  • On final failure: exception propagates

_compact_messages() also wraps its non-streaming API call in the same retry loop.

Finish Reason Handling

_stream_response() tracks finish_reason from the final streaming chunk:

  • "length": warns via ui.on_error() that the response was truncated. Any partial tool calls are discarded (their JSON would be malformed), causing the send() loop to exit cleanly.
  • "content_filter": warns via ui.on_error() that the response was blocked.

Agent sub-sessions (_run_agent()) check finish_reason on each non-streaming response and stop the agent early on "length" or "content_filter".

_compact_messages() checks finish_reason on the compaction response and warns if the summary was truncated.

State Emission on Errors

  • send() catches KeyboardInterrupt and generic Exception: calls _emit_state("error") before re-raising
  • On interrupt: partial tool results and the originating assistant message are popped from self.messages to keep state consistent

Web UI Resilience

  • SSE reconnect: both connectContentSSE() and connectGlobalSSE() use exponential backoff on onerror -- starting at 1 second, doubling on each failure, capped at 30 seconds. On successful message, delay resets to 1s.
  • Disconnection indicator: #status-bar.disconnected class turns the status text red and shows "Reconnecting..."
  • Fetch error handling: all fetch() calls use .catch() to prevent unhandled promise rejections
  • Pending approval across tab switches: WebUI._pending_approval stores the approve_request event payload while the session is blocked waiting for user response. On SSE reconnect (e.g., switching back to the tab), the event is re-injected after history replay. _build_history marks the pending tool call as "pending": true so replayHistory skips the false ✓ approved badge; the live approval UI is rendered by the re-injected event instead.
  • Browser history integration: history.pushState is called in switchTab() with {turnstone: 'workstream', wsId}. The initial state is seeded with history.replaceState({turnstone: 'dashboard'}) on load. The popstate listener restores the correct tab or shows the dashboard, guarded by _historyNavigation = true to prevent re-entrant pushState.
  • Pane focus: mousedown and focusin events on pane containers update focusedPaneId. Approval shortcuts (y/n/a) apply to the focused pane. Ctrl+Alt+Arrow cycles focus between panes.

Eval Resilience

_run_single_test(): wraps session.send_headless() in a retry loop (3 attempts) to avoid transient API errors from poisoning evaluation scores.

Health Monitor & Circuit Breaker

BackendHealthMonitor (turnstone/core/healthcheck.py) runs a daemon thread that probes the LLM backend by calling client.models.list() every backend_probe_interval seconds (default 30). Probe results drive a three-state circuit breaker:

CLOSED  ──(N consecutive failures)──>  OPEN
OPEN    ──(cooldown expires)────────>  HALF_OPEN
HALF_OPEN ──(probe succeeds)────────>  CLOSED
HALF_OPEN ──(probe fails)──────────>  OPEN
  • record_success() / record_failure() update _consecutive_failures and transition the _state (CircuitState enum: CLOSED, OPEN, HALF_OPEN).
  • acquire_request_permit() returns False when the circuit is OPEN or when in HALF_OPEN and the single probe permit has already been consumed. Causes ChatSession._create_stream_with_retry to skip the backend and surface an error immediately.
  • The /health endpoint reads the monitor's state: "status": "ok" when the circuit is closed, "status": "degraded" when open or half-open.

Rate Limiting

RateLimiter (turnstone/core/ratelimit.py) enforces per-client-IP request limits using a token-bucket algorithm. Each IP gets a TokenBucket with requests_per_second (refill rate) and burst (bucket capacity) from [ratelimit] config.

  • Applied via RateLimitMiddleware after authentication but before route dispatch.
  • /health and /metrics are exempt (monitoring must always be reachable).
  • X-Forwarded-For support: when trusted_proxies is configured (comma-separated CIDRs), the middleware parses the X-Forwarded-For header using the rightmost-untrusted approach. IPv4-mapped IPv6 addresses are normalized. The direct client IP must be in the trusted set before XFF is considered.
  • On limit exceeded: HTTP 429 with Retry-After header and JSON body {"error": "Rate limit exceeded", "retry_after": N}.
  • The turnstone_ratelimit_rejected_total counter is incremented on each rejection.

User Identity and Authentication

Turnstone supports three authentication mechanisms, unified behind an AuthResult dataclass that carries user_id, scopes, and token_source:

  1. API tokens — database-backed, prefixed ts_, stored as SHA-256 hashes in the api_tokens table. Can be exchanged for JWTs via POST /v1/api/auth/login.
  2. JWTs — short-lived HMAC-SHA256 session tokens (default 24h) issued after successful credential validation. Contain sub (user_id), scopes, and src (origin) in claims.

Scope Model

Three hierarchical scopes control endpoint access:

Scope Grants Endpoints
read SSE streams, workstream listing, history GET endpoints
write read + send, command, workstream create/close POST to /api/send, /api/command, etc.
approve write + tool approval, admin operations POST to /api/approve, /api/admin/*

Middleware Flow

AuthMiddleware (ASGI) intercepts every request:

  1. Public path check/, /static/*, /shared/*, /health, /metrics, /openapi.json, /docs, /api/auth/*, and /api/auth/setup are always allowed.
  2. Token extractionAuthorization: Bearer <token> header first, then turnstone_auth cookie as fallback.
  3. Token type detection — dots in the token indicate JWT; ts_ prefix indicates API token.
  4. Validation — JWT signature check or API token hash lookup in storage.
  5. Scope checkrequired_scope(method, path) determines the minimum scope; the request is rejected with 403 if the token lacks it.
  6. Context propagation — on success, ctx_user_id is set so structured logging includes the authenticated identity on every log event.

Architecture Split

  • Console is the auth management hub — it hosts the admin endpoints for creating users, issuing API tokens, and managing channel mappings. User records and token hashes live in the shared storage backend. The console dashboard includes an admin panel (14 tabs) for managing credentials, governance, MCP servers, and runtime settings through the browser.
  • Server is a JWT validator only — it validates tokens on each request but never creates users or tokens. Both processes share the same jwt_secret (via TURNSTONE_JWT_SECRET env var or [auth].jwt_secret config).
  • First-time setup — both server and console expose POST /v1/api/auth/setup, a public endpoint that creates the initial admin user when no users exist. This avoids the chicken-and-egg problem of needing approve scope to create the first user via /api/admin/users.

Auth Storage Tables

Three tables in storage/_schema.py support identity:

users
  user_id        TEXT PRIMARY KEY
  username       TEXT NOT NULL UNIQUE
  display_name   TEXT NOT NULL
  password_hash  TEXT NOT NULL       -- bcrypt
  created        TEXT NOT NULL

api_tokens
  token_id       TEXT PRIMARY KEY
  token_hash     TEXT NOT NULL UNIQUE  -- SHA-256 of raw token
  token_prefix   TEXT NOT NULL         -- first 8 chars for display
  user_id        TEXT NOT NULL
  name           TEXT NOT NULL         -- human-readable label
  scopes         TEXT NOT NULL         -- comma-separated
  created        TEXT NOT NULL
  expires        TEXT                  -- optional expiry timestamp

channel_users
  channel_type      TEXT NOT NULL      -- e.g. "slack", "discord"
  channel_user_id   TEXT NOT NULL      -- platform-specific user ID
  user_id           TEXT NOT NULL      -- FK to users
  PRIMARY KEY (channel_type, channel_user_id)

See docs/security.md for full security details including token lifecycle, password hashing, and deployment hardening.


Threading Model

CLI

Main thread          Spinner thread (daemon)       ThreadPoolExecutor
+--------------+     +------------------+          +-----------------+
| REPL loop    |     | Braille animation|          | Tool execution  |
| input() ->   |     | 80ms tick to     |          | max_workers=4   |
|   send() ->  |     | stderr           |          | parallel tools  |
|   stream  -> |     | started/stopped  |          | run concurrently|
|   tools   -> |     | by TerminalUI    |          |                 |
+--------------+     +------------------+          +-----------------+
       |                    ^                              ^
       +-- on_thinking_start/stop -------------------------+
       +-- _execute_tools ---------------------------------+

Key constraint: input() blocks the main thread. The spinner writes to stderr so it does not interfere with readline. Tool execution may use a ThreadPoolExecutor with up to 4 workers for parallel tool calls.

Server

Starlette ASGI app (served by uvicorn)
  |
  +-- Async request handlers (all under /v1/ prefix)
  |     POST /v1/api/send      -> starts worker thread per workstream
  |     POST /v1/api/approve   -> unblocks WebUI._approval_event
  |     POST /v1/api/plan      -> unblocks WebUI._plan_event
  |     POST /v1/api/workstreams/new -> creates workstream + worker
  |     GET  /v1/api/events    -> SSE via EventSourceResponse (per workstream)
  |     GET  /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
  |
  +-- ASGI middleware stack
  |     MetricsMiddleware -> CORSMiddleware -> AuthMiddleware -> RateLimitMiddleware
  |
  +-- Worker thread per workstream (daemon)
  |     Runs session.send() synchronously -- ChatSession is fully blocking
  |     Blocks on WebUI._approval_event / _plan_event (threading.Event)
  |
  +-- Background daemon threads
        Global SSE fan-out: reads global_queue, copies to per-client queues
        Idle cleanup: closes stale workstreams, cleans rate limiter buckets

Starlette handles all HTTP routing, CORS, and middleware. uvicorn runs the ASGI application with async request handling. All API endpoints live under the /v1/ prefix via a Starlette Mount. An OpenAPI 3.1 spec is generated from Pydantic v2 models and served at /openapi.json; Swagger UI is available at /docs. SSE endpoints use EventSourceResponse from sse-starlette with async generators that bridge sync queue.Queue via asyncio.get_running_loop().run_in_executor().

ChatSession.send() remains synchronous, running in daemon worker threads. WebUI keeps threading.Event and queue.Queue primitives (unchanged from the sync era). The _global_fanout_thread and _idle_cleanup_thread remain as daemon threads since they interact with sync primitives. A lifespan context manager handles startup/shutdown (health monitor, MCP client, registry).

Each workstream's WebUI has:

  • _listeners (per-client SSE queues, fan-out on _enqueue())
  • _approval_event / _plan_event (threading.Event for blocking)
  • _global_queue (class variable, shared, for state broadcasts)

The SSE handlers bridge these sync queues to async via run_in_executor(), polling queue.Queue.get(timeout=1) while sse-starlette handles keepalive pings automatically.

Workstream Threading (CLI)

Main thread                  Background workstream thread
+------------------+         +---------------------------+
| REPL input()     |         | session.send()            |
| /ws commands     |         | streams response          |
| active workstream|         | executes tools            |
| send() inline   |         | approve_tools() ->        |
+------------------+         |   _fg_event.wait() BLOCKS |
       |                     +---------------------------+
       |                                ^
       +-- /ws <N> switch ------------->|
       |   old.set_foreground(False)    |
       |   new.set_foreground(True)     |
       |   new.flush_buffer()           |
       +-- _fg_event.set() unblocks --->+

When a background workstream needs approval, its WorkstreamTerminalUI calls _fg_event.wait(), which blocks the worker thread until the user switches to that workstream. The _bg_attention_notify callback writes a bell + status line to stderr to alert the user.

Cluster Console

Monitoring (2 daemon threads)        Control + Proxy (async Starlette)
+------------------+                 +----------------------------+
| Node discovery   |                 | POST /v1/api/cluster/      |
| Service registry |                 |   workstreams/new          |
| every 60 seconds |                 |   → POST to target server  |
+------------------+                 +----------------------------+
| SSE manager      |                 | GET /node/{node_id}/       |
| asyncio loop     |                 |   → httpx.AsyncClient      |
| 1 task per node  |                 |     proxy to server_url    |
| /events/global   |                 | GET /node/{id}/v1/api/events |
| snapshot+deltas  |                 |   → SSE stream proxy       |
+------------------+                 | POST /node/{id}/v1/api/send  |
                                     |   → forwarded to server    |
                                     +----------------------------+

The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE endpoint uses EventSourceResponse with the same listener queue pattern as the main server. ClusterCollector runs two daemon threads: a discovery loop that queries the service registry every 60 seconds, and an SSE manager that runs a single asyncio event loop multiplexing persistent SSE connections to all nodes via GET /v1/api/events/global. Each node delivers a full snapshot on connect followed by real-time delta events — state changes, health transitions, and aggregate metrics arrive sub-second instead of on a 15-second poll cycle.

The console has two write-path capabilities:

  1. Workstream creation — sends HTTP requests to target server nodes to create workstreams. Auto-selects the node with the most available capacity if no target is specified. When a skill field is present, the server resolves the skill BEFORE mgr.create() (applying the model override to the creation request) and snapshot-applies remaining settings (auto-approve, token budget, temperature, etc.) to the workstream config AFTER creation.

  2. Reverse proxy — serves each node's server UI through the console port at /node/{node_id}/. Uses httpx.AsyncClient to proxy HTTP and SSE traffic. A JS shim is injected into the server's app.js to override fetch() and EventSource(), routing root-relative URLs through the proxy prefix. This eliminates the need for direct network access to individual server nodes.

The console also performs version drift detection — flagging when nodes report different versions via the /health endpoint. The overview API includes version_drift and versions fields; the dashboard shows a yellow warning indicator when versions diverge.

Clicking a workstream row in the console opens the proxied server UI at /node/{node_id}/?ws_id=<id> — the server's JS parses this on load and auto-selects the workstream. See docs/console.md for the full API reference.


Conversation Compaction

When the prompt exceeds auto_compact_pct of the context window (default: 80%, configurable via --auto-compact-pct), ChatSession auto-compacts by summarizing the entire conversation into a structured summary (_compact_messages). The summary model call uses compact_max_tokens (default: 32768, configurable via --compact-max-tokens). The summary preserves:

  • Decisions made (architecture, libraries, approaches)
  • Files read, created, or modified
  • Exact identifiers, paths, and code snippets
  • Important tool results
  • Open tasks
  • User preferences

After compaction, _read_files is cleared to force re-reads before edits, since file contents are no longer in the message history.


Client SDK

See also: SDK Architecture diagram | SDK Documentation

The turnstone/sdk/ package provides typed HTTP clients for programmatic access to both the server and console APIs. It wraps REST endpoints with methods that return Pydantic models, and SSE endpoints with async/sync iterators that yield typed event dataclasses.

Two client pairs (sync + async):

  • TurnstoneServer / AsyncTurnstoneServer — server API (workstreams, chat, streaming)
  • TurnstoneConsole / AsyncTurnstoneConsole — console API (cluster overview, nodes, workstreams)

Design: async-first with thin sync wrappers. _BaseClient provides httpx setup, auth headers, _request() (REST) and _stream_sse() (SSE). Sync clients delegate through _SyncRunner which maintains a persistent background event loop on a daemon thread.

Event types: 27 standalone dataclasses in events.py with a type-registry pattern matching OutboundEvent.from_json() from mq/protocol.py. Events are decoupled from server internals.

TypeScript SDK: sdk/typescript/ — separate npm package with the same API surface. Zero browser dependencies, SSE via fetch + ReadableStream parsing.

# Python quick start
from turnstone.sdk import TurnstoneServer

with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
    ws = client.create_workstream(name="demo")
    result = client.send_and_wait("Hello!", ws.ws_id)
    print(result.content)

Channel Integrations

See also: Channel Integrations guide

The turnstone-channel gateway connects external messaging platforms (Discord, Slack, Teams) to the turnstone cluster via HTTP. Each platform adapter implements the ChannelAdapter protocol and translates between platform-native events and turnstone server API calls.

The ChannelRouter manages bidirectional routing: it maps platform channel/thread IDs to turnstone workstream IDs, handles workstream creation and stale-route recovery, and resolves platform users to turnstone identities via the channel_users table. When an evicted workstream is reactivated, the router uses atomic resume via the resume_ws field on the workstream creation request — the server resumes the old workstream's conversation during creation in a single HTTP request, eliminating ordering fragility.

Discord ships as the first adapter. See channels.md for setup instructions, configuration reference, and the adapter development guide.

Notification Subsystem

The notify tool enables the LLM to send notifications to users or channels directly. The server calls the channel gateway directly over HTTP for lower latency: _exec_notify() queries the services database table for healthy channel gateways (heartbeat within 120 seconds), authenticates with a service JWT (aud: turnstone-channel), and POSTs to POST /v1/api/notify on the first healthy gateway. The payload includes the originating ws_id for reply routing. The gateway validates the JWT, resolves the target (username lookup via channel_users or direct channel_type+channel_id), and delegates to ChannelAdapter.send_notification() which sends the message and tracks the outgoing message ID → (ws_id, target_user_id) mapping. Delivery retries up to 3 times with backoff, re-querying the service registry on each attempt. See Notification Flow diagram.

Bidirectional replies: When a user replies to a notification DM, the Discord bot looks up the originating ws_id from the tracked message ID, verifies the replying user matches the notification recipient, and routes the reply to the workstream via router.send_message(). The workstream's response is forwarded back to the DM via a temporary entry in _notify_reply_channels. On TurnCompleteEvent, the response message is itself tracked for further replies, enabling multi-turn DM conversations without requiring the user to open the web UI. Tracking entries are capped at 100 (FIFO eviction) and cleaned up on workstream close.


Governance

See also: Governance documentation | Governance Architecture diagram

Turnstone governance extends the Phase 1 auth system with role-based access control (RBAC), tool execution policies, skills, usage tracking, and audit logging. The permission model has two layers: legacy scopes (read, write, approve) checked by AuthMiddleware, and 15 granular permissions checked per-endpoint by require_permission(). Three built-in roles (admin, operator, viewer) are seeded by migration 008; custom roles can be created with any permission subset. JWTs carry both scopes and permissions claims for backward compatibility.

Tool policies use glob pattern matching (fnmatch) with priority-ordered first-match-wins evaluation to control tool execution (allow/deny/ask). Skills provide reusable system messages with {{variable}} substitution plus session configuration (model, temperature, auto-approve, token budget, etc.). Usage events are recorded per-LLM-request for token accounting. An append-only audit log captures all admin mutations.

Skills are snapshot-applied once at workstream creation — not a live binding. The prompt_templates table (which stores skills) supports auto-versioning, and workstreams record which skill and version spawned them. Token budget enforcement tracks consumption in session.send() with 80% warning and 100% approval gate via the __budget_override__ synthetic tool name.

The console admin panel adds 5 governance tabs (Roles, Policies, Skills, Usage, Audit), a Memories tab, a Settings tab (form-based editor for all ConfigStore settings), and an MCP Servers tab (database-backed server definitions with live connection status and cluster-wide reload) for a total of 13 tabs, all permission-gated. Both Python and TypeScript SDKs expose governance methods on the console client.

Intent Validation

See also: Intent Validation guide | Judge Architecture diagram

Intent validation provides advisory risk assessments for tool calls that require human approval. The system runs a two-tier evaluation pipeline implemented in turnstone/core/judge.py:

  1. Heuristic tier (synchronous, sub-millisecond) -- A priority-ordered rule table using fnmatch tool patterns and regex argument patterns. Four severity levels: critical (deny), high (review), medium (review), low (approve). First match wins. The heuristic verdict is attached to the approve_request SSE event immediately.

  2. LLM judge tier (asynchronous, daemon thread) -- A multi-turn evaluation where the judge LLM receives conversation context and tool call details, optionally uses read_file/list_directory to gather evidence (with security-hardened path blocking), and produces a structured JSON verdict. If the LLM verdict has higher confidence than the heuristic, it replaces it via an intent_verdict SSE event.

The judge is session-scoped (IntentJudge), lazy-initialized on first approval, and configured via the [judge] config section or --judge CLI flags. By default it uses self-consistency (same model), but supports cross-model and cross-provider configurations. Sub-agents (plan, task) are exempt. All verdicts are persisted to the intent_verdicts table (migration 012) with the user's final decision, enabling future calibration. The console exposes GET /v1/api/admin/verdicts for audit queries (requires admin.judge permission).