Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

smista.ai

license-mit license-elastic repo-stars conventional-commits ci


What is smista.ai?

smista.ai is a local-first agent and CLI that routes each phase of an AI workflow to the most suitable model using deterministic, configurable policies.

Most developers no longer rely on a single model or a single provider. Switching between CLIs, web apps and providers — copying context around and remembering which model to use for each task — is slow and error-prone. smista.ai keeps one coherent workflow while letting different models handle different phases.

Its main differentiator is deterministic multi-model routing: routing never depends on an LLM’s judgment, and every decision is explainable.

Core idea

You configure how work should be routed, for example:

  • Planning to the strongest reasoning model
  • Simple edits to a local model
  • Code review to a reliable coding model
  • Sensitive files to a local-only model

smista.ai applies these rules consistently and explains every routing decision through a trace.

Components

  • smista-cli — the smista command-line interface for developers.
  • smista-router — the routing and orchestration service exposing a local HTTP JSON API.
  • smista-core — the shared internal runtime and domain types.
  • @smista-ai/sdk — a TypeScript SDK for building clients on top of the router.

Where to next

Get Started

Note

smista.ai is under active development. This guide describes the intended workflow; commands land progressively as the milestones are implemented.

Understand the workflow

smista.ai is a local-first agent and CLI. It lets you use different models in one workflow without choosing a model for every request.

For each request, smista follows the same visible steps:

request -> task intent -> routing rule -> safety checks -> model -> trace

The task intent describes the kind of work, such as plan, edit, or review. Your routing rules map that intent, and optionally matching file paths, to a model. Privacy and tool rules control what the model may see and do. This process is deterministic: an LLM never chooses the route.

The smista binary contains both the CLI and the router. The CLI reads your configuration and shows results. The router selects the model, runs the task, mediates tools, and records an explanation of the decision.

Create your configuration

Start in the project where you want to use smista. Create a project configuration:

smista config init

This creates .smista/config.toml. The file controls providers, routing, privacy, tool permissions, and the CLI connection to the router. You can commit it so that the project team shares the same policy.

Use these commands to work with the file:

smista config path project
smista config edit project
smista config show project
smista config check project

The global configuration applies to every project. Create it with smista config init global. Project values take precedence over global values.

Choose a provider and model

Before you start the router, make sure the configuration contains a provider and a default model that the provider can serve.

Use a remote provider

This example enables OpenAI and sends unmatched tasks to one OpenAI model:

[providers.openai]
type = "openai"

[routing.default]
model = "openai/gpt-5.5-mini"

Store the provider key with the CLI:

smista credentials set openai YOUR_API_KEY

Credentials are local to the current project by default. Add --global to make a credential available to every project:

smista credentials --global set openai YOUR_API_KEY

The credential command uses the operating-system keyring when available and a protected local file as a fallback. It never writes the key to config.toml.

Replace openai with anthropic, gemini, or a named OpenAI-compatible provider when needed. The model in [routing.default] must use the same provider identifier.

Use a local Ollama model

The built-in policy enables the Ollama provider and uses ollama/qwen2.5-coder:7b as its default route. Make sure Ollama can serve that model, then enable the router connection in router.toml:

smista config init router
[router.ollama]
enabled = true
base_url = "http://127.0.0.1:11434"

To require the local Ollama instance and never use Ollama Cloud, add this to .smista/config.toml:

[local_preferences]
local_only = true

Ollama does not need an API key. See Use Local Models with Ollama for the complete setup.

Check your configuration

Validate the project policy before starting:

smista config check project

If you changed router.toml, validate it too:

smista config check router

Validation reports the field to fix. It does not print credential values.

Start the router

Start the local router in the background:

smista start

Check that it is ready:

smista status

Stop it later with smista stop. Run smista start --foreground when you want to watch logs or use a service manager.

Log in

The first time you connect to a local router, create a local user and store its router API key:

smista login

This key signs the CLI in to the router. It is different from the provider API key that you stored with smista credentials set.

Start using smista

Start an interactive session:

smista

You can also run one task directly from the shell:

smista "refactor the auth middleware"

smista detects the task intent, applies the current routing policy, and shows the result. Before a file write, it shows a diff and asks for confirmation unless your policy already allows that action.

Preview routing

From an interactive session, preview a routing decision without calling a model:

/preview review this PR

The preview reports the task classification, selected provider and model, matched rule, estimated cost, context, and required permissions.

Choose what to configure next

smista uses two configuration files with different jobs:

FileControls
.smista/config.tomlProviders, routing, privacy, tools, and CLI-to-router settings.
router.tomlThe router process, storage, limits, and provider endpoints.

Most project changes belong in .smista/config.toml. Most local users only need router.toml to enable Ollama or change a runtime default.

Continue with:

Configure Routing and the CLI

smista.ai reads TOML configuration to decide which model handles each task, what context may be sent where, and which tool calls require approval. Configuration is deterministic, versionable and inspectable — routing never depends on an LLM.

Know which file to edit

smista uses two configuration files. They have different jobs:

FilePurpose
config.tomlProviders, routing policy, privacy, tools, and the CLI connection.
router.tomlRouter process settings, storage, limits, and provider endpoint URLs.

This page describes config.toml. Use it when you want to choose models or change project policy. See Configure the Router when you need to change how the router process runs.

Both files contain a [router] table, but the meaning is different:

  • [router] in config.toml tells the CLI where to find the router.
  • [router] in router.toml configures the router server itself.

Create and inspect configuration

Create a project configuration from the built-in defaults:

smista config init

Use the configuration commands to find, edit, inspect, and check the file:

smista config path project
smista config edit project
smista config show project
smista config check project

These commands use the project configuration by default. Add the global argument to work with the global configuration instead:

smista config path global
smista config edit global
smista config show global
smista config check global

smista config show displays the effective configuration after all layers are merged. Sensitive values are redacted.

Where configuration lives

LayerLocationScope
Global (Linux/macOS)~/.config/smista/config.tomlAll projects
Global (Windows)%USERPROFILE%\.smista\config.tomlAll projects
Project.smista/config.tomlThe current repository

Create the global file with smista config init global. Create the project file with smista config init or smista config init project.

Project configuration is safe to commit when it contains no secrets. This lets a team share one routing and safety policy.

Configuration precedence

smista merges configuration from lowest to highest precedence:

  1. Built-in defaults
  2. Global configuration
  3. Project configuration
  4. Runtime command overrides, such as /model

Higher layers keep values that lower layers set unless they replace them. Some safety settings are stricter: a runtime preference may add a restriction, but it cannot weaken a privacy or tool rule from configuration.

Configure a remote provider

Enable a provider and choose a default model before you start using smista. For example:

[providers.openai]
type = "openai"

[routing.default]
model = "openai/gpt-5.5-mini"

Store the provider API key outside the configuration file:

smista credentials set openai YOUR_API_KEY

Credentials are project-local by default. Use smista credentials --global set openai YOUR_API_KEY to share the credential across your local projects. This does not put the key in config.toml.

Built-in default configuration

With no global or project config.toml, the CLI starts from a valid built-in configuration:

[providers.ollama]
type = "ollama"

[routing.default]
model = "ollama/qwen2.5-coder:7b"

This default is keyless and passes configuration validation on its own. Model availability is still checked later by the router, so using it requires an Ollama endpoint that can serve qwen2.5-coder.

If you keep the built-in default route, keep providers.ollama enabled. If you replace the provider set with only remote providers, also replace [routing.default] with a model from one of those providers.

The router disables its Ollama connection by default. To use this built-in route, enable [router.ollama] in router.toml and make sure Ollama can serve the model. See Use Local Models with Ollama.

How a request becomes a route

smista evaluates each request in a fixed order:

request -> classification -> routing -> privacy and tools -> model

Classification assigns a task intent such as plan, edit, or review. Routing rules match that intent and any relevant file paths. Privacy and tool rules then limit the context and actions available to the selected model.

The following sections describe each part in detail.

Task intents

smista.ai classifies each request into a fixed set of intents: chat, plan, edit, review, summarize, prompt, skill. Classification is deterministic — ordered rules, never an LLM.

[[classification.rules]]
intent = "review"
priority = 10
keywords = ["review", "audit", "check", "inspect"]
requires_any_context = ["git_diff", "pull_request"]

[[classification.rules]]
intent = "edit"
priority = 20
keywords = ["change", "modify", "refactor", "fix", "implement"]

[classification]
default_intent = "chat"

Lower priority wins. API clients may also send an explicit task intent in the request. An explicit intent always beats automatic classification, even when the prompt text would match another rule.

Each [[classification.rules]] entry accepts:

KeyTypeDefaultPurpose
intentstringrequiredIntent assigned when the rule matches.
priorityinteger1000Lower value wins.
keywordslist of strings[]Rule matches when any keyword appears in the prompt.
requires_any_contextlist of strings[]Rule matches when any named context is present (git_diff…).

The [classification] table itself accepts:

KeyTypeDefaultPurpose
default_intentstringchatIntent used when no rule matches.

Routing rules

A routing rule decides which model handles a task. Rules match on intent, file path, or a combination, and may declare a fallback chain.

[[routing.rules]]
name = "plan with strongest reasoning model"
priority = 10
intent = "plan"
model = "openai/gpt-5.5-thinking"
fallbacks = ["anthropic/claude-sonnet"]

[[routing.rules]]
name = "summarize on a local model"
priority = 20
intent = "summarize"
model = "ollama/qwen2.5-coder:7b"
fallbacks = ["openai/gpt-5.5-mini"]

[[routing.rules]]
name = "auth code uses Claude"
priority = 30
intent = "edit"
paths = ["src/auth/**"]
model = "anthropic/claude-sonnet"
fallbacks = ["openai/gpt-5.5-thinking"]

[[routing.rules]]
name = "review security-sensitive code locally"
priority = 5
effort = "low"
intent = "review"
paths = ["src/crypto/**", "src/auth/**"]
local_only = true
model = "ollama/qwen2.5-coder:7b"

Rule fields

Every routing rule supports the keys below. Match conditions are all optional; a rule with none matches every task.

KeyTypePurpose
namestringHuman-readable rule name (required).
priorityintegerLower value wins; defaults to 1000.
effortstringReasoning effort for the matched model; defaults to medium.
intentstringMatch only this task intent.
pathslist of globsMatch when a relevant path matches any glob.
local_onlyboolPin the route to local models; an ollama/ model resolves to the local instance, never Ollama Cloud.
requires_capabilitiestableCapability gate; the model must satisfy each true flag.
modelstringModel selected on match, as provider/model (required).
fallbackslist of stringsModels tried in order when the selected model is unavailable.
required_permissionstableTool permissions the route requires (see below).
cost_limitstringPer-task cost ceiling, as a decimal string (e.g. "0.50").

requires_capabilities gates a rule on what the model can do. Each flag defaults to false; set the ones a matched model must support: streaming, tools, json_output, system_prompt, images, reasoning, memory.

required_permissions declares the tool permissions the matched route needs. It is merged over the project defaults and may only narrow them — see Tool permissions.

cost_limit is written as a quoted decimal string for exact precision (it is never a floating-point number).

[[routing.rules]]
name = "deep remote review of crypto"
priority = 5
intent = "review"
paths = ["src/crypto/**"]
requires_capabilities = { reasoning = true, tools = true }
required_permissions = { permissions = { shell = "deny", network = "ask" } }
cost_limit = "0.50"
model = "anthropic/claude-sonnet"
fallbacks = ["openai/gpt-5.5-thinking"]

Match semantics

  • Fields within one rule combine with AND — every defined field must match.
  • Values within one field combine with ORpaths = ["src/auth/**", "src/security/**"] matches either path.
  • Undefined fields are ignored.

Effort

Each rule may set an effort, telling the matched model how much reasoning effort to spend on the task. Accepted values, from least to most:

  • low
  • medium
  • high
  • xhigh

When omitted, a rule defaults to medium.

[[routing.rules]]
name = "plan with maximum reasoning"
priority = 10
effort = "xhigh"
intent = "plan"
model = "openai/gpt-5.5-thinking"

Rule precedence

When several rules match, exactly one is chosen, in this order:

  1. Explicit model override
  2. Lower priority value (1 is higher priority than 10)
  3. More specific rule

Specificity, most to least specific:

path + intent
> path
> intent
> default

If two overlapping rules share the same priority and specificity, validation fails. Give the rules different priorities or make their match conditions mutually exclusive.

Default route

A policy must define a default route, used when no rule matches:

[routing.default]
model = "openai/gpt-5.5-mini"
fallbacks = ["ollama/qwen2.5-coder:7b"]

When no route is authored, the built-in default route is ollama/qwen2.5-coder:7b.

Privacy

Privacy policies control which context may reach which model class. Restricted files are never sent to remote models unless the policy allows it and the user approves.

[privacy]
restricted_paths = [".env", "secrets/**", "*.pem", "*.key"]

[privacy.remote]
mode = "ask"
blocked_paths = [".env", "secrets/**"]

[privacy.local]
mode = "allow"

The [privacy] table accepts:

KeyTypeDefaultPurpose
restricted_pathslist of globs[]Paths treated as sensitive for every model class.

The [privacy.remote] table controls disclosure to remote providers:

KeyTypeDefaultPurpose
modestringaskallow, ask, or deny for remote disclosure.
blocked_pathslist of globs[]Paths that must never be sent to remote providers.

The [privacy.local] table controls disclosure to local models:

KeyTypeDefaultPurpose
modestringallowallow, ask, or deny for local disclosure.

Tool permissions

Tool permissions define what models may request and what needs approval. Modes are allow, ask, deny.

[tools.permissions]
file_read = "allow"
file_write = "ask"
shell = "ask"
network = "deny"
git = "allow"

Rule-specific permissions (a rule’s required_permissions) may narrow these defaults, tightening a tool from allow to ask to deny, or adding a tool not listed in the defaults. They may never widen them: an override that loosens a stricter mode (for example setting shell = "allow" when the project default is shell = "deny") is a configuration error naming the offending tool, not a silent override.

[tools.permissions] is a map of tool name to mode; a tool with no entry has no configured mode and falls back to the safe default. Each value is one of:

ModeEffect
allowThe tool runs without confirmation.
askThe user is prompted before the tool runs.
denyThe tool is blocked.

The conventional tool keys are:

ToolGoverns
file_readReading files from the workspace.
file_writeWriting or modifying files.
shellRunning shell commands.
networkOutbound network access.
gitGit operations (commit, push, …).

Providers and models

The CLI configuration declares which providers are enabled and how tasks route to them. Provider credentials stay outside the file. The configuration does not describe individual models. Reference a model with provider/model syntax, for example anthropic/claude-sonnet.

The part before the first / is the provider identifier (case-insensitive); everything after it is the model name, which may itself contain / (e.g. ollama/library/llama3). Both parts must be non-empty, and the provider must be one of the identifiers below — otherwise the reference is rejected during validation.

A generic OpenAI-compatible endpoint (a local vLLM or LM Studio server, a gateway, …) is a named instance, and its identifier takes the form openai-compat:<name> — for example openai-compat:my-vllm/llama-3.1-70b. Instance names use lowercase letters, digits, - and _. You can configure as many such instances as you like, each with its own name.

Enable a provider by adding a [providers.<id>] table, keyed by the provider identity. Store remote provider keys with smista credentials set:

[providers.openai]
type = "openai"

[providers.anthropic]
type = "anthropic"

[providers.gemini]
type = "gemini"

[providers.ollama]
type = "ollama"

# A named OpenAI-compatible instance. The key is its full identity, quoted
# because it contains a colon; `type` is omitted.
[providers."openai-compat:my-vllm"]

The optional type field names the provider backend. When present it is case-insensitive and must be one of the supported identifiers:

IdentifierBackend
anthropicAnthropic, serving the Claude models.
geminiGoogle Gemini, serving the Gemini models.
openaiOpenAI, serving the GPT models.
ollamaOllama, serving local models.
openai-compat:<name>A generic OpenAI-compatible endpoint; normally set by the table key with type omitted.

An unknown identifier is rejected during validation. The endpoint URL for a named instance lives on the router — see Configure the Router.

Each [providers.<id>] table accepts:

KeyTypeDefaultPurpose
typestringnoneProvider kind. Optional and redundant with the table key; omit it for openai-compat:<name> instances.
api_keystringnoneOptional ${secret:NAME} reference. Prefer smista credentials set.

Note

A model’s facts — its capabilities, context window, costs, whether it runs locally, and whether it needs authentication — are not declared here. They come from the provider at runtime. A rule’s requires_capabilities is a requirement you state; the router checks it against those facts when it selects a model. Endpoint overrides (such as a custom OpenAI-compatible URL) belong to the router — see Configure the Router.

Note

The Anthropic model list is fetched live from Anthropic, so new Claude models become available without updating smista.ai. The list is refreshed about once an hour. Anthropic does not report prices over its API, so per-token costs are maintained inside smista.ai by model family (Opus, Sonnet, Haiku, Fable); for a model from an unrecognised family the cost is reported as unknown.

Note

The Gemini model list is fetched live from Google, so new Gemini models become available without updating smista.ai. The list is filtered to the chat models smista.ai routes to — Google’s embedding, image, audio and other specialised models are left out — and refreshed about once an hour. Google does not report prices over its API, so per-token costs are maintained inside smista.ai per model; a model with no recorded price has its cost reported as unknown.

Note

For local models through Ollama, see Use Local Models with Ollama.

Warning

An ollama/<model> reference is not treated as local by default. Unless the matched route enforces local_only — a rule’s local_only = true or the local_only local preference — the router may resolve it against the public Ollama Cloud, not your local instance. That is a remote, billed, non-private endpoint, and your [privacy.remote] rules apply to it like any other remote provider. Set local_only whenever a rule must run on the local Ollama instance.

Provider credentials

Never write an API key directly into config.toml. The simplest way to store a key is the credential command:

smista credentials set openai YOUR_API_KEY
smista credentials check openai

The CLI first tries the operating-system keyring. When no keyring is available, it uses a protected local file. Credentials are project-local by default, so a key stored in one project is not automatically available to another project. Add --global to store it for every local project:

smista credentials --global set anthropic YOUR_API_KEY

For automation, a provider may instead use an environment variable through a ${secret:NAME} reference:

[providers.openai]
type = "openai"
api_key = "${secret:OPENAI_API_KEY}"

Here, smista first reads the OPENAI_API_KEY environment variable. If the variable is absent, it checks credential storage for the same name. An unresolved reference leaves the provider without a usable credential. Messages may name the missing key, but they never print a secret value.

smista config init adds the project secrets file to .smista/.gitignore. Use the credential commands instead of editing managed secret files by hand.

Connecting to the router

The CLI needs to know where the router is and how to authenticate to it. This is client configuration, separate from the router’s own runtime config (see Configure the Router).

[router]
url = "http://127.0.0.1:7331"
auto_start = true
connect_timeout_ms = 5000
request_timeout_ms = 120000
auth_source = "keychain"

The [router] table accepts:

KeyTypeDefaultPurpose
urlstringnoneRouter base URL, e.g. http://127.0.0.1:7331.
auto_startboolfalseStart a local router when none is reachable.
connect_timeout_msintegernoneConnection timeout in milliseconds.
request_timeout_msintegernoneRequest timeout in milliseconds.
auth_sourcestringkeychainWhere the auth credential is read: keychain, env, file, or helper.

Local preferences

Local preferences tune your own experience. They live under [local_preferences] in a global or project config.toml. Every field is optional; an unset field keeps the value from a lower configuration layer.

Put personal preferences in the global file when you do not want to share them. If you put them in the project file, they are part of the project configuration and may be committed with it.

[local_preferences]
auto_apply = false
local_only = false
no_network = false
encrypt_sessions = true
FieldEffect
auto_applyApply file writes without prompting for each diff.
local_onlyUse only local models this session; pins ollama/ to the local instance, never Ollama Cloud.
no_networkForbid network access for this session.
encrypt_sessionsCreate new sessions as end-to-end encrypted. Defaults to true; set false to opt out.

Important

Local preferences may tighten safety, never loosen it. Enabling local_only or no_network here adds a restriction, but a preference can never weaken a project’s privacy modes or a tool set to deny.

Validation

Configuration is validated before execution. Validation rejects routing rules that reference a provider you have not enabled, unknown intents, invalid globs, duplicate rule names, a missing default route, invalid fallback references, ambiguous rules, invalid permission values, and secrets stored inline where forbidden. Whether a model exists and whether it satisfies a rule’s requires_capabilities is checked later, at model selection time, against the facts the provider exposes — not from this file. Invalid configuration produces an actionable error — run smista config check project to check it. See Configuration validation for the full list.

Configure the Router

smista-router is the routing and orchestration service the CLI talks to. This page covers how the router process itself runs — the server binding, storage, authentication, runtime limits and logging. It is separate from the routing policy (which model handles a task); that lives in Configure Routing and the CLI.

Start with the defaults

Most local users do not need to create router.toml. Without the file, the router uses safe local defaults: it binds to 127.0.0.1:7331, stores data in an embedded database under the global smista directory, and disables CORS and telemetry.

Create router.toml when you need to enable Ollama, change a runtime limit, use remote storage, or connect to a custom provider endpoint:

smista config init router

Starting and stopping

smista.ai ships as a single binary, so the router runs inside the smista process rather than as a separate program. Start a local router with the CLI:

smista start

By default this daemonizes: the router starts in the background and the command returns. It records its process id in a pidfile, and refuses to start a second router on top of one that is already running. Stop it again with:

smista stop

Pass --foreground to run the router in the current process instead, which is what a service manager (systemd, launchd) wants:

smista start --foreground

The pidfile defaults to a per-user location under the runtime directory. Set a different path with --pidfile <path> on both start and stop, or with the SMISTA_ROUTER_PIDFILE environment variable.

Check that the router is ready with:

smista status

Where configuration lives

SystemDefault location
POSIX~/.config/smista/router.toml
Windows%USERPROFILE%\.smista\router.toml

Use smista config path router to print the location on your system. Pass --config <path> to smista start when you want to use another file.

The router configuration is stored in the global smista directory, but smista config commands access it with the router argument:

smista config path router
smista config edit router
smista config show router
smista config check router

The global argument selects the global CLI configuration (config.toml), not the router configuration (router.toml).

An invalid router configuration prevents the router from starting and reports which field to fix. Run smista config check router before starting to check the file directly.

Server

[router]
host = "127.0.0.1"
port = 7331

The router is meant to be reached locally. Binding it to a public interface in local mode is flagged as unsafe by validation.

The [router] table accepts:

KeyTypeDefaultPurpose
hoststring127.0.0.1Bind host.
portinteger7331Bind port.

Storage

Where users, sessions, tokens, traces and execution metadata are persisted. smista.ai uses SurrealDB, which supports both an embedded local database and a remote server for future deployments.

[router.storage]
engine = "surrealdb"
mode = "embedded"
path = ".smista/db"
namespace = "smista"
database = "local"

The [router.storage] table accepts:

KeyTypeDefaultPurpose
enginestringsurrealdbStorage engine; only surrealdb is supported.
modestringembeddedembedded (on-disk) or remote (server).
pathstringnoneDatabase file path, used in embedded mode.
urlstringnoneDatabase server URL, used in remote mode.
usernamestringnoneAuthentication username, used in remote mode.
passwordstringnoneAuthentication password, used in remote mode.
namespacestringsmistaSurrealDB namespace.
databasestringlocalSurrealDB database name.

Note

username and password authenticate against a remote SurrealDB server and are only used in remote mode. The password is treated as a secret: it is never logged and is never written back out when configuration is serialized.

Authentication

Controls API-key bootstrap and the lifetime of short-lived session tokens.

[router.auth]
token_ttl_seconds = 86400
api_key_version = "01"
local_bootstrap_enabled = true

local_bootstrap_enabled lets the router mint a user and API key locally without a SaaS account. It must be disabled in remote/SaaS mode.

The [router.auth] table accepts:

KeyTypeDefaultPurpose
token_ttl_secondsinteger86400Session token lifetime, in seconds.
api_key_versionstring"01"API key version segment.
local_bootstrap_enabledbooltrueAllow local API-key bootstrap; off in remote mode.

Runtime limits

Protect the router from oversized requests, runaway tools, excessive context and hanging provider calls.

[router.limits]
max_request_body_bytes = 10485760
max_context_bytes = 5242880
max_concurrent_requests = 8
request_timeout_ms = 120000
provider_timeout_ms = 180000
tool_timeout_ms = 60000

The [router.limits] table accepts:

KeyTypeDefaultPurpose
max_request_body_bytesinteger10485760Maximum request body size, in bytes.
max_context_bytesinteger5242880Maximum context size, in bytes.
max_concurrent_requestsinteger8Maximum concurrent requests.
request_timeout_msinteger120000Overall request timeout, in milliseconds.
provider_timeout_msinteger180000Provider call timeout, in milliseconds.
tool_timeout_msinteger60000Tool execution timeout, in milliseconds.

Rate limiting

Caps how fast a single client can hit the router, so a runaway script or a misbehaving client cannot overwhelm it. Limiting is applied per client IP address using a token bucket: each client may send a burst of up to burst_size requests, and its allowance refills by one request every period_ms milliseconds. Requests over the limit get a 429 Too Many Requests response.

Enabled by default with limits sized for local use — roughly 100 requests per second per client with room for a burst of 200. Raise the limits if you drive the router hard from scripts, or set enabled = false to turn it off entirely.

[router.rate_limit]
enabled = true
period_ms = 10
burst_size = 200
trust_proxy_headers = false

The [router.rate_limit] table accepts:

KeyTypeDefaultPurpose
enabledbooltrueWhether rate limiting is enabled.
period_msinteger10Refill period, in milliseconds: the allowance grows by one request every period_ms.
burst_sizeinteger200Maximum number of requests a client may send in a burst before being limited.
trust_proxy_headersboolfalseIdentify clients by proxy headers instead of the connection’s source address.

The sustained rate is one request every period_ms milliseconds — so the default of 10 allows about 100 requests per second per client. When rate limiting is enabled, both period_ms and burst_size must be greater than zero; validation rejects a zero value.

By default each client is identified by the source address of its connection. When the router runs behind a reverse proxy, every request appears to come from the proxy, so they would all share one limit. Set trust_proxy_headers = true to identify clients by the X-Forwarded-For, X-Real-IP or Forwarded header the proxy sets instead.

Warning

Only enable trust_proxy_headers when a trusted reverse proxy sits in front of the router and sets these headers itself. With clients connecting directly, anyone can put any value in the header and hand themselves a fresh limit on every request, which defeats rate limiting entirely.

Logging

[router.logging]
level = "info"
format = "compact"
redact_secrets = true

Keep redact_secrets = true. API keys, provider credentials and auth tokens are never written to logs or traces.

The [router.logging] table accepts:

KeyTypeDefaultPurpose
levelstringinfoLog level filter (e.g. info, debug).
formatstringcompactLog output format.
redact_secretsbooltrueRedact secrets from logs; keep enabled.

OpenTelemetry

The router can export its traces to an OpenTelemetry collector you already run, so you can watch timings and errors in your own dashboards. This is layered on top of the existing logging and changes nothing about how the router behaves or what it decides. It is disabled by default, and when disabled the router exports nothing and does no extra work.

Only span and trace metadata is exported. Secrets are never recorded on traces, so API keys, provider credentials and auth tokens are never sent to the collector.

Turn it on and point it at your collector:

[router.opentelemetry]
enabled = true
endpoint = "http://localhost:4317"
protocol = "grpc"
service_name = "smista-router"
sample_ratio = 1.0

The [router.opentelemetry] table accepts:

KeyTypeDefaultPurpose
enabledboolfalseWhether trace export is enabled.
endpointstringhttp://localhost:4317Collector endpoint to export to.
protocolstringgrpcWire protocol: grpc (port 4317) or http-binary (port 4318).
service_namestringsmista-routerService name reported on every trace.
sample_ratiofloat1.0Fraction of traces to sample, from 0.0 (none) to 1.0 (all).

You can also set the most common options when starting the router, without editing the file. A value given on the command line wins over the file:

smista start \
  --otel \
  --otel-endpoint http://localhost:4317 \
  --otel-protocol grpc \
  --otel-service-name smista-router \
  --otel-sample-ratio 1.0

--otel turns export on and --no-otel turns it off, each overriding the file. The remaining flags map one to one to the table above.

CORS

Disabled by default. Only needed for browser-based clients or a future web dashboard.

[router.cors]
enabled = true
allowed_origins = ["https://app.smista.ai"]

Warning

Never enable CORS with unrestricted origins in production.

The [router.cors] table accepts:

KeyTypeDefaultPurpose
enabledboolfalseWhether CORS is enabled.
allowed_originslist of strings[]Allowed origins when enabled.

Retention

[router.retention]
trace_retention_days = 90
session_retention_days = 365
archived_session_retention_days = 30
cleanup_interval_seconds = 3600

The [router.retention] table accepts:

KeyTypeDefaultPurpose
trace_retention_daysinteger90Days to retain traces.
session_retention_daysinteger365Days to retain sessions.
archived_session_retention_daysinteger30Days to retain archived sessions before purge.
cleanup_interval_secondsinteger3600Interval between cleanup runs, in seconds.

Providers

The router discovers built-in providers without endpoint configuration. OpenAI, Anthropic, and Gemini always use their official endpoints.

Use [router.providers] for named OpenAI-compatible services, such as a local vLLM server or a hosted gateway. Configure a local Ollama connection under [router.ollama] instead.

[router.providers."openai-compat:my-vllm"]
base_url = "http://localhost:8000/v1"
local = true
display_name = "My vLLM"

Each [router.providers.<id>] table accepts:

KeyTypeDefaultPurpose
base_urlstringproviderEndpoint base URL. Honored only by openai-compat:<name> endpoints and the Ollama cloud endpoint; the built-in API providers (OpenAI, Anthropic, Gemini) use their fixed endpoint and ignore it (validation warns).
localboolfalseWhether the provider runs locally; surfaced to consumers to tell local providers from hosted ones.
display_namestringnoneHuman-readable name for the provider; consumers fall back to the provider identifier when omitted.
modelslist of tables[]Models advertised for an openai-compat:<name> endpoint with no model-listing API; each entry declares one model and its facts, see below. Ignored by the built-in providers.

Generic OpenAI-compatible endpoints

You can point the router at any number of OpenAI-compatible endpoints — a local vLLM or LM Studio server, llama.cpp, or a hosted gateway. Each one is a named instance: you choose a name, and routing rules address it as openai-compat:<name>/<model> (for example openai-compat:my-vllm/llama-3.1-70b).

Configure each instance under its full identity as the table key. The name is quoted because it contains a colon. Each model is its own [[...models]] table:

[router.providers."openai-compat:my-vllm"]
base_url = "http://localhost:8000/v1"
local = true
display_name = "My vLLM"

[[router.providers."openai-compat:my-vllm".models]]
name = "llama-3.1-70b"
max_context_tokens = 131072
max_output_tokens = 8192

[router.providers."openai-compat:lmstudio"]
base_url = "http://localhost:1234/v1"

[[router.providers."openai-compat:lmstudio".models]]
name = "qwen2.5-coder-7b"
max_context_tokens = 32768
input_cost_per_million_tokens = "0.0"
output_cost_per_million_tokens = "0.0"

These endpoints expose no catalog of model facts, so routing reads each model’s facts from the [[...models]] tables you declare. The credential, if the endpoint needs one, is set on the CLI side — see the openai-compat:<name> provider in Configure Routing and the CLI.

Each [[router.providers.<id>.models]] table accepts:

KeyTypeDefaultPurpose
namestringrequiredModel name, exactly as the endpoint expects it.
max_context_tokensintegerrequiredMaximum context window the model accepts, in tokens.
display_namestringnoneHuman-readable name; consumers fall back to name when omitted.
authstringnoneHow the model authenticates: none, api_key, or optional_api_key.
max_output_tokensintegernoneMaximum tokens the model emits, if bounded.
capabilitiestableOpenAI-likeWhat the model can do; see below. Defaults suit an OpenAI-compatible endpoint.
input_cost_per_million_tokensstringnoneInput price per million tokens, as a decimal string for exact precision.
output_cost_per_million_tokensstringnoneOutput price per million tokens, as a decimal string for exact precision.

The capabilities table defaults to streaming, tools, json_output, system_prompt and memory enabled, with images and reasoning disabled. Override any of them per model:

[[router.providers."openai-compat:my-vllm".models]]
name = "llava-1.6"
max_context_tokens = 32768

[router.providers."openai-compat:my-vllm".models.capabilities]
images = true

Note

The built-in providers (OpenAI, Anthropic, Gemini) and Ollama discover their model facts at runtime and ignore the models tables. Only generic openai-compat:<name> endpoints, which publish no catalog, read facts from here.

Note

Ollama’s endpoint and model discovery are configured separately, under [router.ollama] — see the next section.

Local models with Ollama

The router connects to Ollama to run local models. Connection and discovery are configured here under [router.ollama]; which tasks actually use an Ollama model is decided by your routing policy in Configure Routing and the CLI. The two must stay consistent — see Use Local Models with Ollama for the full setup.

[router.ollama]
enabled = true
base_url = "http://127.0.0.1:11434"

The [router.ollama] table accepts:

KeyTypeDefaultPurpose
enabledboolfalseWhether the Ollama backend is active.
base_urlstringhttp://127.0.0.1:11434Ollama endpoint base URL.

A complete example

The example below shows every main runtime section. Most local users only need the sections that differ from the defaults.

[router]
host = "127.0.0.1"
port = 7331

[router.storage]
engine = "surrealdb"
mode = "embedded"
path = ".smista/db"
namespace = "smista"
database = "local"

[router.auth]
token_ttl_seconds = 86400
api_key_version = "01"
local_bootstrap_enabled = true

[router.limits]
max_request_body_bytes = 10485760
max_context_bytes = 5242880
max_concurrent_requests = 8
request_timeout_ms = 120000
provider_timeout_ms = 180000
tool_timeout_ms = 60000

[router.rate_limit]
enabled = true
period_ms = 10
burst_size = 200
trust_proxy_headers = false

[router.logging]
level = "info"
format = "compact"
redact_secrets = true

[router.opentelemetry]
enabled = false
endpoint = "http://localhost:4317"
protocol = "grpc"
service_name = "smista-router"
sample_ratio = 1.0

[router.cors]
enabled = false
allowed_origins = []

[router.retention]
trace_retention_days = 90
session_retention_days = 365
archived_session_retention_days = 30
cleanup_interval_seconds = 3600

[router.ollama]
enabled = false
base_url = "http://127.0.0.1:11434"

Validation

Router configuration is validated at startup. Validation rejects an invalid host or port, an unsafe public binding in local mode, missing or unsupported storage configuration, local bootstrap enabled in remote mode, invalid timeouts or size limits, a zero rate-limit period or burst while rate limiting is enabled, unsafe CORS, and invalid OpenTelemetry settings. A failure prevents startup and explains the offending field.

Validation also emits non-blocking warnings, such as setting a base_url on a built-in API provider that ignores it. Warnings are surfaced but do not prevent startup.

Use Local Models with Ollama

Use a local model to reduce cost or keep sensitive code on your machine. smista.ai connects to local models through Ollama.

The setup uses two files:

  1. router.toml tells the router how to connect to Ollama.
  2. config.toml enables the provider and routes tasks to its models.

Ollama is a model service, not the smista router. The smista router still owns every routing decision.

Prepare Ollama

Start Ollama and pull the models you want to use. For example:

ollama pull qwen2.5-coder:7b

Ollama normally serves its API at http://127.0.0.1:11434.

Connect the router to Ollama

Create the router configuration if it does not exist:

smista config init router

Enable the Ollama connection in router.toml:

[router.ollama]
enabled = true
base_url = "http://127.0.0.1:11434"

The router discovers the models that Ollama can serve. The current router configuration supports only enabled and base_url in this section.

Enable Ollama in your policy

Create the project configuration if it does not exist:

smista config init

Enable the Ollama provider in .smista/config.toml:

[providers.ollama]
type = "ollama"

You do not declare individual Ollama models or their capabilities in this file. The router obtains that information from the provider at run time.

Require local execution

An ollama/<model> reference is not enough to guarantee local execution. To keep every task on local models, add:

[local_preferences]
local_only = true

This also prevents routes from falling back to a remote provider. If your policy mixes local and remote models, omit this preference and set local_only = true only on the routing rules that must stay local.

Route tasks to a local model

Set a local default when every task should use Ollama unless another rule matches:

[routing.default]
model = "ollama/qwen2.5-coder:7b"

You can also route only selected work to a local model:

[[routing.rules]]
name = "summaries run locally"
priority = 40
intent = "summarize"
local_only = true
model = "ollama/qwen2.5-coder:7b"

[[routing.rules]]
name = "review sensitive code locally"
priority = 5
intent = "review"
paths = ["src/crypto/**", "src/auth/**"]
local_only = true
model = "ollama/qwen2.5-coder:7b"

Keep the model name identical to the name shown by Ollama.

Check the setup

Check both configuration files:

smista config check project
smista config check router

Then start the router and log in:

smista start
smista login
smista

Inside the interactive CLI, use /providers to check that Ollama is available and /model to view its models.

Capability checks

The router checks each model’s capabilities before using it. For example, a route with requires_capabilities = { tools = true } cannot use a model that does not support tools.

When a model does not meet the rule, the router tries the next configured fallback. If no model is suitable, the task stops with an explanation instead of running with missing capabilities.

Configuration validation

smista.ai validates your configuration and reports every finding as either an error or a warning. Errors make the configuration invalid and must be fixed before smista can use it. Warnings are advisory — they surface potential problems but do not block execution.

When smista validates your configuration it collects all findings in a single pass and reports them together, so you can fix everything at once.

Check each file with the CLI:

smista config check project
smista config check global
smista config check router

CLI / policy configuration (config.toml)

These checks apply to the merged CLI and routing-policy configuration loaded from your global and project configuration, plus any runtime override. CLI configuration validation emits errors only.

CheckSeverityHow to fix
Unknown provider — a routing rule, fallback, or default route references a provider identifier that is not enabled in [providers]ErrorEnable that provider with a [providers.<id>] table, or correct the reference
Invalid glob — a pattern in privacy.restricted_paths, privacy.remote.blocked_paths, or a rule’s paths list fails to compileErrorFix the glob syntax (e.g. close unclosed brackets)
Duplicate rule name — two [[routing.rules]] entries share the same nameErrorGive each rule a unique name
Missing default route — no [routing.default] table is presentErrorAdd [routing.default] with a model field
Invalid fallback — a rule lists its own model as a fallback, or lists the same fallback model more than onceErrorRemove the self-reference or duplicate from fallbacks
Ambiguous rules — two overlapping rules share the same priority value and the same specificity score, making their relative order undefinedErrorGive them distinct priority values or make their match conditions mutually exclusive
Unsafe override — a runtime override sets privacy.remote.mode to a value less strict than the merged config-layer floorErrorDo not weaken the configured privacy mode in a runtime override
Permission widening — a runtime override sets a tool permission (file_read, file_write, shell, etc.) to a less strict value than the config floorErrorKeep the configured stricter permission; runtime overrides may only tighten, never loosen
Inline secret — a provider api_key is a literal string instead of a ${secret:NAME} referenceErrorRemove the value and use smista credentials set, or replace it with a ${secret:NAME} environment-variable reference

Specificity

Two overlapping rules are considered ambiguous when they share both a priority value and the same number of constrained match dimensions (intent, path list, local_only). Rules with mutually exclusive match conditions, such as different explicit intents, are not ambiguous. Give one overlapping rule a lower priority number to resolve the ambiguity:

[[routing.rules]]
name = "review with claude"
priority = 10 # wins over the rule below
intent = "review"
model = "anthropic/claude-sonnet"

[[routing.rules]]
name = "review with gpt"
priority = 20 # distinct priority — no ambiguity
intent = "review"
model = "openai/gpt-5.5-mini"

Unsafe overrides and permission widening

The merged global and project config layers set the safety floor. A runtime override may add restrictions — for example switching a tool from ask to deny — but may not weaken a configured restriction.

# project .smista/config.toml
[privacy.remote]
mode = "deny" # no remote sends

[tools.permissions]
shell = "ask" # shell requires approval
# runtime override — these are rejected
[privacy.remote]
mode = "allow" # error: unsafe override

[tools.permissions]
shell = "allow" # error: permission widening

Model selection (checked at run time)

Two things the CLI does not check during configuration validation are verified later, when the router selects a model for a task — against the facts each provider exposes about its models, not against any config:

  • Model existence — whether the model a rule selects, or any entry in its fallbacks, is actually offered by the provider.
  • Capability requirements — whether a model satisfies a rule’s requires_capabilities. This catches, for example, a rule that needs tool calls but falls back to a local model that cannot call tools.
[[routing.rules]]
name = "tool-using edits"
requires_capabilities = { tools = true }
model = "anthropic/claude-sonnet"
fallbacks = ["ollama/qwen2.5-coder:7b"]

If ollama/qwen2.5-coder:7b does not support tools, the router skips it when this rule needs tool calls and falls through to the next viable option, rather than failing configuration validation up front. Because model facts come from the provider at run time, they are never declared in config.toml.

Router configuration (router.toml)

These checks apply to the smista-router runtime configuration. All must hold for a valid router configuration.

CheckSeverityHow to fix
Invalid portrouter.port is 0ErrorSet a port in the range 1–65535
Invalid hostrouter.host is empty, contains whitespace, includes a port, or is not a valid hostname or IP addressErrorSet a bare hostname or IP address (e.g. 127.0.0.1)
Public bind in embedded moderouter.host is 0.0.0.0 or :: while router.storage.mode = "embedded"WarningBind to 127.0.0.1 unless you intentionally want to expose the local router to the network
Missing storage pathrouter.storage.mode = "embedded" but router.storage.path is not setErrorSet router.storage.path to a writable directory (e.g. .smista/db)
Missing storage URLrouter.storage.mode = "remote" but router.storage.url is not setErrorSet router.storage.url to the SurrealDB WebSocket address (e.g. ws://db:8000)
Local bootstrap in remote moderouter.auth.local_bootstrap_enabled = true while router.storage.mode = "remote"ErrorSet local_bootstrap_enabled = false, or switch to embedded mode
Zero timeout — any of router.limits.request_timeout_ms, router.limits.provider_timeout_ms, or router.limits.tool_timeout_ms is 0ErrorSet a positive value in milliseconds
Zero size limitrouter.limits.max_request_body_bytes or router.limits.max_context_bytes is 0ErrorSet a positive byte limit
Invalid rate limit — rate limiting is enabled but period_ms or burst_size is 0ErrorSet both values above zero, or disable rate limiting
Excessive timeout — a timeout exceeds 3,600,000 ms (1 hour)WarningCheck that the value is in milliseconds, not seconds; lower it if so
Unsafe CORS — CORS is enabled (router.cors.enabled = true) but router.cors.allowed_origins is empty or contains *ErrorList explicit origins: allowed_origins = ["https://app.example.com"]
Invalid OpenTelemetry — export is enabled with an empty endpoint, empty service name, or sample ratio outside 0.01.0ErrorSet a collector endpoint, service name, and valid sample ratio
Ignored provider URLbase_url is set for built-in OpenAI, Anthropic, or GeminiWarningRemove it, or configure a named openai-compat:<name> provider for a custom endpoint

Example: correcting a zero timeout

[router.limits]
# Error: request_timeout_ms = 0
# Fix:
request_timeout_ms = 120000 # 2 minutes
provider_timeout_ms = 180000 # 3 minutes
tool_timeout_ms = 60000 # 1 minute

Example: correcting unsafe CORS

[router.cors]
enabled = true
# Error: allowed_origins = ["*"]
# Fix:
allowed_origins = ["https://app.example.com"]

CLI Commands

The smista CLI runs in two modes: a one-shot prompt from your shell, and an interactive session where commands are typed as slash commands.

Typical first-time order

Configure smista before starting the router:

smista config init
smista config edit project
smista credentials set openai YOUR_API_KEY
smista config check project
smista start
smista login
smista

Local Ollama does not need a provider key. It needs an enabled [router.ollama] connection instead. See Use Local Models with Ollama.

From your shell

CommandWhat it does
smistaStart an interactive session.
smista <prompt>Start a session, run the prompt, show the result.
smista config ...Create, inspect, edit, or check configuration files.
smista apikey ...Add, check, or remove the router API key.
smista credentials ...Add, check, or remove provider API keys.
smista loginBootstrap a router user and store its API key.
smista startStart the local router. Daemonizes by default.
smista statusCheck whether the router is reachable and report its version.
smista stopStop the local router recorded in the pidfile.
smista --versionPrint the CLI version.
smista --helpShow help.

A one-shot prompt is just a shortcut: it starts a session, sends the prompt through the active routing policy, displays the response, and persists the session and trace.

smista "refactor the auth middleware"

Running the router

The CLI talks to a local router. Start it once and leave it running:

smista start

By default smista start daemonizes — it spawns the router as a detached background process and returns, so your shell is free again. Pass --foreground to run it in the current process instead, which is what a service manager wants. smista stop reads the router’s process id from the pidfile and shuts it down.

The main smista command can also start a missing local router automatically. Enable it in CLI configuration:

[router]
url = "http://localhost:7331"
auto_start = true

Auto-start only applies to loopback router URLs such as localhost, 127.0.0.1, or [::1]. When /status is already reachable, smista uses the running router no matter whether auto_start is enabled. When /status is not reachable, auto_start = true starts the local router and waits for it to become healthy. If auto-start is disabled, the command tells you to run smista start; if the configured router URL is remote, the command reports the router as unreachable instead of starting anything.

Both commands accept flags to point at a specific configuration file or pidfile, and smista start exposes flags to configure OpenTelemetry trace export. See Configure the Router for the full list and for the configuration file itself.

Global flags

The logging flags are global: they may appear before or after the subcommand, and each has an environment-variable equivalent.

FlagEnvironment variableWhat it does
-L, --log-file <path>SMISTA_ROUTER_LOG_FILEWrite logs to a file instead of stdout.
-l, --log-filter <f>SMISTA_ROUTER_LOG_FILTERSet the log level filter (e.g. debug). Defaults to off.
smista --log-filter debug start --foreground

Credential storage

The CLI stores router API keys and provider credentials in the operating-system keyring when it is available. If the keyring cannot be used, the CLI falls back to file-backed storage.

Pass --enforce-keyring to make startup fail instead of falling back:

smista --enforce-keyring

Logging in to the router

Run smista login once after the router is reachable. The command calls the router bootstrap endpoint, stores the returned router API key in credential storage, and prints the user id.

smista start
smista login

If an API key is already configured, smista login reports that you are already logged in and exits successfully.

Managing router API keys

Use smista apikey to store, check, and remove the API key used to sign in to the local router. This is the smista.ai router key returned by bootstrap, not an upstream provider key. Most users should prefer smista login; smista apikey is available when you need to import, inspect, or remove a key explicitly.

smista apikey check
smista apikey new
smista apikey remove
smista apikey set sk-smista-api01-...

To stop using the current router identity, remove the stored API key with smista apikey remove. To replace it, remove the old key and then run smista login again.

API keys are stored in the project-local scope by default. Add --global before the apikey subcommand to store or remove the global key instead:

smista apikey --global set sk-smista-api01-...
smista apikey --global remove
CommandAliasesWhat it does
smista apikey checkgetPrint whether a router key is set.
smista apikey newgenerateGenerate a new router API key and print to stdout.
smista apikey removedelete, rmRemove the router key from scope.
smista apikey set <api-key>addStore or replace the router API key.

Managing provider credentials

Use smista credentials to store, check, and remove provider API keys without putting the secret value in config.toml.

smista credentials set openai sk-...
smista credentials check openai
smista credentials remove openai

Credentials are stored in the project-local scope by default. Add --global before the credentials subcommand to store or remove the global credential instead:

smista credentials --global set anthropic sk-ant-...
smista credentials --global remove anthropic
CommandAliasesWhat it does
smista credentials set <provider> <api-key>addStore or replace a provider API key.
smista credentials check <provider>getPrint whether a provider API key is available.
smista credentials remove <provider>delete, rmRemove a provider API key from the scope.

The <provider> value must be one of:

Provider formUse it for
anthropicAnthropic Claude models.
geminiGoogle Gemini models.
openaiOpenAI GPT models.
ollamaOllama endpoints that require a key.
openai-compat:<provider_name>A named OpenAI-compatible provider or gateway.

For OpenAI-compatible providers, replace <provider_name> with the configured instance name, for example:

smista credentials set openai-compat:my-vllm sk-...

Managing configuration files

Use smista config init to create starter configuration files. The command creates missing parent folders and prints the path it wrote.

smista config init

With no target, config init creates the project CLI configuration at .smista/config.toml. It also creates .smista/.gitignore with secrets so project credentials stay out of Git.

Choose another target when needed:

CommandWhat it creates
smista config initProject CLI config at .smista/config.toml.
smista config init projectProject CLI config at .smista/config.toml.
smista config init globalGlobal CLI config for every project.
smista config init routerRouter runtime config, separate from the CLI.

The command refuses to overwrite an existing file. Pass --force to replace the target file, or --config <path> to write a specific path:

smista config --config ./router.toml init router
smista config init --force project

Use smista config show to inspect configuration:

CommandWhat it prints
smista config showEffective merged CLI configuration.
smista config show projectThe project CLI configuration layer.
smista config show globalThe global CLI configuration layer.
smista config show routerThe router runtime configuration layer.

The merged view applies the same precedence used by the CLI: built-in defaults, then global configuration, then project configuration. Single-layer views read and validate one file. The command prints parsed sections and values rather than the raw TOML file, so you can see how smista interpreted the configuration. Sensitive keys such as api_key, password, and secret are printed as [redacted].

Use smista config path to find configuration files:

CommandWhat it prints
smista config pathRouter, global, and project paths with exist status.
smista config path projectProject CLI configuration path only.
smista config path globalGlobal CLI configuration path only.
smista config path routerRouter runtime configuration path only.

Use smista config edit to open a configuration file:

smista config edit project
smista config edit global
smista config edit router

The editor comes from VISUAL, then EDITOR. If neither is set, smista uses the platform default opener. The command refuses to open a missing file and points at smista config init <target> instead.

Use smista config check to validate a single configuration file:

smista config check project
smista config check global
smista config check router

Pass --config <path> before the subcommand to operate on a specific file:

smista config --config ./custom.toml show project
smista config --config ./custom.toml check project

Checking router status

Use smista status to query the router’s /status endpoint and print the router state and version.

smista status

By default, the command uses the configured router URL from CLI configuration. If no router URL is configured, it falls back to the default local router URL. Pass --url to check a specific router instance:

smista status --url http://127.0.0.1:7331

Version and help

smista --version prints the CLI version. Include it in bug reports so the exact build in use is unambiguous.

$ smista --version
smista 0.0.0

smista --help shows the full command and flag reference; smista <command> --help shows the flags for a single command.

Interactive slash commands

Type slash commands at the interactive prompt.

  • /chat leaves plan mode and enters chat mode, which is the default mode for interactive sesssions.
  • /clear clears the terminal and ends the current session. When a session is active, it prints final token usage when available and a /resume command before the next prompt. The next message starts a new session.
  • /model lists models available through the router. Use arrow keys to choose a model, then press Enter to use it for later prompts in the session. The first option, auto, clears the preferred model and returns to deterministic routing.
  • /model <model> sets a preferred model by model id, display name, or provider/model reference. Use /model auto to clear the preferred model.
  • /preview <prompt> shows how the router would handle a prompt without invoking the selected model. The report includes the task classification, provider and model, matched routing rule, estimated cost, included and excluded context, and required permissions. File references use the same @path completion and attachment behavior as normal prompts.
  • /plan leaves chat mode and enters plan mode
  • /providers lists configured providers and whether each one is local or remote.
  • /quit, /q, or /exit closes the interactive CLI.
  • /resume [session-id] resumes a specific session when an ID is provided. Without an ID, it lists existing sessions for the current workspace. Use arrow keys to choose a session, then press Enter to load its transcript.
  • /skills lists available skills from the current workspace.
  • /status queries the router’s /status endpoint and prints the router state and version.

Referencing files

Reference a file in a prompt with @:

review @src/auth/middleware.rs

The CLI auto-completes paths typed after @.

Start typing a relative or absolute path immediately after @. Relative paths are resolved from the directory where smista was started. Absolute paths can reference files outside that directory.

  • Press Down or Up to cycle through matching files and directories.
  • Press Tab or Right to accept the selected path. Accepting a directory keeps its trailing path separator so you can continue completing its contents.
  • Press Escape to close file completion without removing what you typed.

Completion includes hidden and ignored entries and matches one directory segment at a time using a case-sensitive prefix. A space, tab, or newline ends the file reference, so paths containing whitespace are not supported.

When you submit the prompt, every existing regular file referenced by an @path token is attached to the request. The @path text remains unchanged in the prompt. Missing paths, unreadable paths, directories, and a bare @ remain ordinary prompt text and do not produce an input error.

Skills

A skill is a reusable set of instructions for a model. Examples include a code-review checklist or a project’s Rust conventions.

smista discovers skills from disk and offers them to the model serving the task. The model decides whether a skill is relevant. Skills do not change task classification or routing, and the router never guesses a skill from the prompt.

Where skills live

smista.ai looks for skills in two locations:

  • Project: <your project>/.agents/skills — skills committed alongside a repository.
  • Global: ~/.agents/skills — skills shared across all your projects and other agent tools.

Each skill is a directory whose name is the skill’s identity. A directory named rust-conventions defines a skill called rust-conventions, regardless of what its SKILL.md says. This is the same convention used by tools such as Claude Code.

.agents/skills/
├── rust-conventions/
│   └── SKILL.md
└── code-review/
    └── SKILL.md

Project skills win over global skills

When a skill of the same name exists both in your project and globally, the project version wins. This lets a repository override a shared skill with a project-specific one. The global skill of that name is ignored entirely.

List available skills

Start the interactive CLI and enter:

/skills

The command lists the skills discovered for the current project. Before the CLI sends a task, it loads the full instructions for the available skills and sends them with the request. This lets the model apply a relevant skill without reading another local file later.

Writing a SKILL.md

Every skill directory must contain a SKILL.md file. It has two parts: a YAML front matter block with metadata, followed by the Markdown body — the behavioural instructions.

---
name: rust-conventions
description: Enforce the project's Rust style and clippy rules.
---

Follow the project rustfmt config. Run clippy with `-D warnings`.
Use `module_name.rs`, never `mod.rs`.

The front matter fields are:

  • name — should match the directory name. If it differs, smista keeps the directory name as the identity and reports a warning.
  • description — a one-line summary of what the skill does.

smista reads only the front matter during initial discovery. It loads the body when it prepares the available skills for a task. Because the full instructions travel with that request, long skill bodies use more context even when the model does not apply them.

Warnings

Discovery never fails; instead, smista reports advisory warnings for skill directories that look misconfigured.

WarningMeaningHow to fix
Missing SKILL.mdA directory under a skills folder has no SKILL.mdAdd a SKILL.md, or remove the directory
Missing descriptionThe SKILL.md front matter has no descriptionAdd a description line to the front matter
Name mismatchThe front matter name differs from the directory nameRename the directory or the name so they match
Invalid front matterThe --- block is missing or is not valid YAMLAdd a valid --- front matter block with name/description

HTTP API

smista-router exposes a JSON REST API. The CLI uses it, and so can your own tools, scripts, editors or web clients. The API authenticates users, manages sessions, previews routes, executes tasks, and reports traces and usage. Routing logic stays in the router — clients never reimplement it.

Tip

For TypeScript and JavaScript, use the @smista-ai/sdk typed client instead of calling these endpoints by hand.

OpenAPI schema

A machine-readable OpenAPI 3.1 schema for this API is published alongside this page at ./openapi.json. You can use it to generate typed clients in any language, validate requests and responses against the schema, or explore the API interactively in tools such as Swagger UI or Insomnia.

Conventions

  • All endpoints live under /api/v1 — e.g. /api/v1/auth/sign-in.
  • GET reads, POST creates or executes, PUT replaces, DELETE removes.
  • Request and response bodies are JSON.
  • Paths are resource-oriented: /auth, /sessions, /sessions/{id}/..., /llm.

Health check

GET /status

Public, unauthenticated, and the one endpoint that lives outside /api/v1. Use it to check that the router is up and to read the version it is running. It needs no token and no provider credentials:

{ "status": "ok", "version": "0.1.0" }

status is "ok" whenever the server answers; version is the running router’s version.

Authentication

smista.ai separates router authentication (who you are) from provider credentials (keys for OpenAI, Anthropic, Gemini, Ollama, …). They travel in different headers and are never mixed.

HeaderUsed for
Authorization: Bearer <session-token>Authenticated requests after sign-in.
X-Smista-Api-Key: <api-key>Auth endpoints only, to obtain a token.
X-Smista-Provider-<Provider>-Api-Key: <key>Provider credential for a specific request.

For example: X-Smista-Provider-Anthropic-Api-Key: <key>. The <Provider> part is the provider name and is case-insensitive (anthropic, openai, gemini, ollama). For an OpenAI-compatible endpoint, use its instance name directly — X-Smista-Provider-my-vllm-Api-Key for an instance named my-vllm — since the openai-compat: form cannot appear in a header name.

Provider credentials are sent only when the selected model needs them, used for that one request, and never logged, traced or forwarded to the model. Credentials are never accepted in query parameters.

The flow: POST /auth/bootstrap returns a user ID and a long-lived API key (shown once). POST /auth/sign-in exchanges that key for a short-lived session token, which you send as a bearer token on every other request. For how these credentials are formatted, hashed and verified, see Router authentication.

Bootstrap a user

POST /api/v1/auth/bootstrap

Public endpoint, and the only public write: it needs no token, because it mints the first credential you ever hold. It has no request body. Each call creates a new user and returns 201 with that user’s ID and a freshly generated, long-lived API key:

{
  "user_id": "018f9c3e-7a2b-7c4d-8e5f-1a2b3c4d5e6f",
  "api_key": "sk-smista-api01-<user-id>-<secret>"
}

The key is sk-smista-api01- followed by the user id and a random secret. It embeds the user id, so the router identifies the owner from the key alone — you never send the user id alongside it.

The plaintext API key is shown only in this response and can never be retrieved again — the router stores it hashed. Save it now; if you lose it, bootstrap a new user. The response carries no other secrets. A failure to persist the user returns 500 with code internal_error.

Sign in

POST /api/v1/auth/sign-in
X-Smista-Api-Key: <api-key>

Public endpoint. The API key already identifies the user, so no body is needed. Exchanges the API key for a short-lived session token and its expiry:

{
  "token": "0194f1e23a2d7e6f9b0a1c2d3e4f5a6b-3k9q...<64 chars>",
  "expires_at": "2026-05-25T12:00:00Z"
}

The token is <token-id>-<secret>: a 32-hex-digit token id, a hyphen, then a 64-character lowercase-alphanumeric secret. Treat it as opaque and send it back verbatim as Authorization: Bearer <token>. Its lifetime comes from router.auth.token_ttl_seconds. See Router authentication for the format and hashing details.

A missing X-Smista-Api-Key header returns 401 with code missing_credentials. A malformed, unknown or non-matching key returns 401 with code invalid_api_key, reported uniformly so it never reveals which users exist. The API key is never logged, echoed back, or accepted as a query parameter.

Sign out

POST /api/v1/auth/sign-out
Authorization: Bearer <session-token>

Revokes the current session token:

{ "revoked": true }

After sign-out the token can no longer be used: presenting it again fails with 401 token_revoked. A token that simply lapses fails with 401 token_expired instead. Both are reported only to a caller holding the genuine token; an unknown or malformed token always fails with 401 invalid_token. Your API key is unaffected, so you can sign in again for a fresh token.

Current user

GET /api/v1/auth/me
Authorization: Bearer <session-token>

Confirms the session token is valid and reports who you are:

{ "user_id": "018f9c3e-7a2b-7c4d-8e5f-1a2b3c4d5e6f" }

To list a user’s sessions, use GET /api/v1/sessions.

Sessions

POST   /api/v1/sessions                  # create (title required)
GET    /api/v1/sessions                  # list/filter every session, archived included
GET    /api/v1/sessions/{session_id}     # fetch / resume
PUT    /api/v1/sessions/{session_id}     # update title or archive
DELETE /api/v1/sessions/{session_id}     # delete

All session routes require Authorization: Bearer <session-token>. A user can only access their own sessions. A session that belongs to another user is treated as if it did not exist and returns 404, so the API never reveals that someone else’s session exists.

Create a session

POST /api/v1/sessions

{ "title": "Refactor auth middleware" }

A title is required; omitting it returns 422. You may also send a scope: an opaque grouping key the router stores and matches verbatim, so you can later list only the sessions that share it. The CLI sets it from your working directory to group sessions by project, but it can be any string a client chooses; omit it for a session with no scope.

To make the session end-to-end encrypted, send a key_id — the fingerprint of the per-session key your client holds. A session is encrypted when, and only when, a key_id is present, so there is no separate encrypted flag to keep in step with it:

{ "title": "Refactor auth middleware", "key_id": "kf_ab12" }

Whether a session is encrypted is fixed for its life and cannot be changed later. See End-to-end encryption. The response echoes the resulting encrypted flag, which is true exactly when a key_id was supplied, and an encrypted summary carries that key_id back; a plaintext summary omits the field entirely. Returns 201 with the new session summary:

{
  "session": {
    "id": "5f8b1c7e-3a2d-4e6f-9b0a-1c2d3e4f5a6b",
    "title": "Refactor auth middleware",
    "encrypted": false,
    "created_at": "2026-05-25T09:00:00Z",
    "updated_at": "2026-05-25T09:00:00Z",
    "archived": false
  }
}

List sessions

GET /api/v1/sessions

Returns every session that belongs to you, archived ones included, each as a summary and ordered most recently updated first. A summary’s title may be null for a session that has none, a summary carries its scope when the session has one, and an encrypted summary carries its key_id while a plaintext one omits the field:

{
  "sessions": [
    {
      "id": "5f8b1c7e-3a2d-4e6f-9b0a-1c2d3e4f5a6b",
      "title": "Refactor auth middleware",
      "scope": "/home/dev/project",
      "encrypted": false,
      "created_at": "2026-05-25T09:00:00Z",
      "updated_at": "2026-05-25T09:30:00Z",
      "archived": false
    }
  ]
}

Narrow the listing with two optional query parameters, which combine: scope matches a session’s scope exactly, and title matches sessions whose title contains it, case-insensitively. With neither set, every session is returned.

GET /api/v1/sessions?scope=/home/dev/project&title=auth

Fetch a session

GET /api/v1/sessions/{session_id}

Returns the full session, including its messages and free-form metadata. messages are ordered oldest first, and metadata is always present even when empty. An archived session is not returned here, and neither is a session owned by another user; both respond 404, the same as an unknown id.

The fetched session detail carries key_id when the session is encrypted and omits it for plaintext sessions. It does not include the summary-only encrypted flag; key_id presence is the detail view’s encryption marker.

Each message’s content is tagged with how it is stored. A plaintext session returns { "plaintext": "..." }; an end-to-end encrypted session returns { "encrypted": { ... } } with the sealed envelope, since the router holds no key and cannot open it. provider and model name the model behind an assistant turn and are omitted for the other roles.

{
  "session": {
    "id": "5f8b1c7e-3a2d-4e6f-9b0a-1c2d3e4f5a6b",
    "title": "Refactor auth middleware",
    "created_at": "2026-05-25T09:00:00Z",
    "updated_at": "2026-05-25T09:30:00Z",
    "messages": [
      { "role": "user", "content": { "plaintext": "Refactor the auth middleware." } },
      {
        "role": "assistant",
        "content": { "plaintext": "Here is the plan..." },
        "provider": "anthropic",
        "model": "claude-sonnet"
      }
    ],
    "metadata": {}
  }
}

A malformed session_id that is not a valid UUID responds 400 with invalid_session_id.

Update a session

PUT /api/v1/sessions/{session_id}

{ "title": "Refactor auth and sessions", "archived": false }

The body is partial: send only the fields you want to change, and any field you omit keeps its current value. Set archived to true to archive the session or false to restore it. Every successful update refreshes updated_at.

It returns the updated session summary:

{
  "session": {
    "id": "5f8b1c7e-3a2d-4e6f-9b0a-1c2d3e4f5a6b",
    "title": "Refactor auth and sessions",
    "encrypted": false,
    "created_at": "2026-05-25T09:00:00Z",
    "updated_at": "2026-05-25T09:30:00Z",
    "archived": false
  }
}

Only the owner can update a session. A session owned by another user, like an unknown id, responds 404, so its existence is never disclosed. A malformed session_id that is not a valid UUID responds 400 with invalid_session_id.

Delete a session

DELETE /api/v1/sessions/{session_id}

Deletes the session and the context memory tied to it. Returns { "deleted": true }.

Executing a task

POST /api/v1/sessions/{session_id}/execute
Authorization: Bearer <session-token>
X-Smista-Provider-{provider}-Api-Key: <api-key>

The body carries everything the router needs to make a deterministic decision: the user input, a workspace snapshot, the merged policy, local preferences, and the local attachments (files, instructions and skills) the router cannot read for itself. Session history, memory and the assembled context are not sent — the router owns them and recalls them from storage. The policy block is the same routing, tool-permission and privacy vocabulary the CLI loads from config.toml — sent verbatim, not a separate, lossy shape. For the full interaction model, the continuations and the streaming flow, see the execution protocol:

{
  "input": {
    "text": "refactor the auth middleware",
    "command": "edit",
    "explicit_model": null
  },
  "workspace": {
    "root": "/Users/christian/project",
    "git_branch": "main",
    "git_diff": "...",
    "referenced_paths": ["src/auth/middleware.rs"],
    "active_file": null
  },
  "policy": {
    "version": 1,
    "source": "merged",
    "classification": {
      "default_intent": "chat",
      "rules": [
        { "intent": "review", "priority": 10, "keywords": ["review", "audit"], "requires_any_context": ["git_diff"] }
      ]
    },
    "routing": {
      "default": {
        "model": "anthropic/claude-sonnet",
        "fallbacks": ["openai/gpt-5.5-thinking", "ollama/qwen2.5-coder:7b"]
      },
      "rules": [
        {
          "name": "auth edits use Claude",
          "priority": 30,
          "effort": "high",
          "intent": "edit",
          "paths": ["src/auth/**"],
          "local_only": false,
          "model": "anthropic/claude-sonnet",
          "fallbacks": ["openai/gpt-5.5-thinking"],
          "required_permissions": { "permissions": { "file_write": "ask" } },
          "cost_limit": "0.50"
        }
      ]
    },
    "tools": {
      "permissions": { "file_read": "allow", "file_write": "ask", "shell": "ask", "network": "deny" }
    },
    "privacy": {
      "restricted_paths": [".env", "secrets/**", "target/**"],
      "remote": { "mode": "ask", "blocked_paths": [] },
      "local": { "mode": "allow" }
    }
  },
  "local_preferences": { "auto_apply": false, "local_only": false, "no_network": false },
  "attachments": {
    "files": [{ "path": "src/auth/middleware.rs", "content": "...", "content_hash": "sha256:...", "required": true }],
    "instructions": [{ "source": "AGENTS.md", "content": "..." }],
    "invoked_skills": [{ "name": "code-review", "content": "Report findings by severity." }],
    "available_skills": [{ "name": "changelog", "content": "Summarize changes under a heading." }]
  }
}

The top-level fields are:

FieldPurpose
inputThe prompt text, an optional command and an optional explicit_model.
workspaceRepository snapshot: root, git_branch, git_diff, referenced/active files.
policyThe deterministic classification, routing, tools and privacy policy (see below).
local_preferencesResolved client toggles: auto_apply, local_only, no_network.
attachmentsLocal content the router cannot read: files (each required or discardable), instructions, invoked_skills (explicitly invoked, added to the model preamble), available_skills (offered for the model to activate).

input.command forces a task type (edit, review, …) and input.explicit_model forces a provider/model, bypassing routing entirely; both may be null.

The request never lists providers or credential status: the router owns the model catalog and reads any supplied provider credentials from the X-Smista-Provider-<Provider>-Api-Key headers, so it decides availability for itself.

Policy

policy.version is the snapshot schema version and policy.source records how it was assembled (e.g. merged). The four sub-blocks mirror the CLI’s [classification], [routing], [tools] and [privacy] config sections exactly.

classification holds the ordered intent rules and the default_intent the router applies when none match; see Task intent classification. routing holds ordered rules plus an optional default route (model and ordered fallbacks) used when no rule matches. Each rule:

FieldTypePurpose
namestringHuman-readable rule name.
priorityintegerEvaluation order, ascending; first match wins. Defaults to 1000.
effortstringReasoning effort for the matched model (low/medium/high/xhigh).
intenttask type, nullRequired task intent, if scoped.
pathslist of stringsPath globs; a relevant path must match one when non-empty.
local_onlyboolRestrict the fallback chain to local models.
requires_capabilitiesobjectCapability gate the matched model must satisfy; omitted if none.
modelreferenceModel selected when the rule matches.
fallbackslist of refsModels tried, in order, when model is unavailable.
required_permissionsobjectTool permissions the matched route requires.
cost_limitstring, omittedPer-task cost ceiling as a decimal string; omitted if unset.

tools.permissions is a flat map of tool name to mode (allow, ask or deny). privacy carries restricted_paths globs plus a remote and local sub-policy, each with an optional mode (remote defaults to ask, local to allow) and the remote block adds blocked_paths never sent to remote models.

Provider credentials never appear in the body. They travel as X-Smista-Provider-<Provider>-Api-Key headers, and the router combines them with its own model catalog to decide which models are available — the client declares nothing about providers or credential status.

Execute the task

POST /api/v1/sessions/{session_id}/execute

The router classifies the task, applies the policy, selects a model, builds the request and runs one turn. A turn resolves to one envelope of { status, data, allowed_continuations }: status names the outcome, data carries its payload, and allowed_continuations lists the messages the client may send next. A completed turn carries the assistant message and a routing explanation under data:

{
  "status": "completed",
  "data": {
    "message": { "role": "assistant", "content": "..." },
    "classification": { "intent": "edit", "source": "inferred", "reason": "keyword matched rule 0", "confidence": "high" },
    "routing": {
      "task_type": "edit",
      "provider": "anthropic",
      "model": "claude-sonnet",
      "matched_rule": "edit + src/auth/** -> anthropic/claude-sonnet",
      "fallback_used": false,
      "override_used": false
    },
    "context": {
      "included": ["src/auth/middleware.rs", "AGENTS.md", "current git diff"],
      "excluded": [".env", "secrets/**"]
    },
    "usage": {
      "input_tokens": 1200,
      "output_tokens": 500,
      "estimated_cost": "0.08",
      "currency": "USD"
    },
    "trace_id": "trace:xyz"
  }
}

When the model cannot be answered in one turn, status is a continuation instead — the router needs the client to do the next step:

statusThe router needs the client to
completedrender; seal to_encrypt if present, else done.
awaiting_toolrun one or more tools and return the results.
awaiting_approvaldecide a yes/no with no tool to run.
awaiting_decryptopen sealed history so the prompt can be built.
awaiting_encryptseal router-authored content before it is persisted.
idlenothing; the run finished and was persisted.
errornothing; the run is over.

allowed_continuations lists the message types the client may send next; break is always among them while the run is live, and it is empty for a terminal outcome.

An awaiting_tool turn lists the calls to run under data, correlated by call_id; each carries requires_approval of allow (run it) or ask (confirm first):

{
  "status": "awaiting_tool",
  "data": {
    "tool_requests": [
      { "call_id": "c1", "name": "shell", "arguments": { "command": "cargo test" }, "requires_approval": "ask" }
    ],
    "trace_id": "trace:xyz"
  },
  "allowed_continuations": ["tool_results", "inject", "break"]
}

The client does the work and resumes the run with /continue. See the execution protocol for the full set of continuation payloads.

By default /execute buffers the turn as a single JSON TurnResponse. Send Accept: text/event-stream to stream it instead as the Server-Sent Events described under Streaming, ending with the terminal turn_end event that carries the same envelope.

Advance a run

POST /api/v1/sessions/{session_id}/continue

Resumes the in-flight run with a single tagged { type, data } message that answers the current pause. The valid type values are what the previous response advertised in allowed_continuations; break is always valid. It returns the next turn in the same shape as /execute, buffered or streamed by the Accept header.

typeAnswersdata
tool_resultsawaiting_tool{ results: [{ call_id, content, is_error, decision }], encrypted }
approval_decisionsawaiting_approval{ decisions: [{ approval_id, decision, reason }], encrypted }
decryptedawaiting_decrypt{ plaintext } — a content-ref → plaintext map
sealeda folded encrypt{ encrypted } — a content-ref → envelope map
injectany live state{ messages: [{ text, ciphertext }] } — mid-run input; supersedes
breakany live statenone — aborts the in-flight turn

The encrypted and plaintext maps are keyed by a content reference of the form kind:id (message, tool_call, diff, plan, memory or trace).

{
  "type": "tool_results",
  "data": {
    "results": [{ "call_id": "c1", "content": "test result: ok", "is_error": false, "decision": "approved" }]
  }
}

Streaming

/execute and /continue buffer the turn as a single JSON TurnResponse by default. Send Accept: text/event-stream on either to receive the turn as a stream of Server-Sent Events instead, each a structured object with a type:

{ "type": "text_delta", "delta": "The first step is..." }

Event types: text_delta, reasoning_delta, tool_call_started, tool_call_requested, usage, and the terminal turn_end.

Models that expose their reasoning stream it as reasoning_delta chunks. When the model starts calling a tool, a tool_call_started event announces the call’s name as soon as it is known; the matching tool_call_requested event follows once the arguments are complete, correlated by call_id.

The usage event reports token counts and, when the model declares prices, the actual cost of the invocation. Local models report a zero cost.

Every stream ends with exactly one turn_end event, whose status is the same value the buffered response carries (completed, awaiting_tool, awaiting_approval, awaiting_decrypt, awaiting_encrypt, idle or error). It tells the client whether the turn finished or paused for a continuation, so the client never has to infer it. Models that cannot stream still answer over this stream: the full response is replayed as a short stream of the same events.

Preview a route

POST /api/v1/sessions/{session_id}/preview

Same body as /execute, but the selected model is never called: no provider completion request is made and no tokens are spent. Send the same X-Smista-Provider-<Provider>-Api-Key headers as /execute; the router may use them to query provider model catalogs and runs the same credential-aware, deterministic routing /execute would. It returns the task type, chosen provider/model, matched rule, routing explanation, included/excluded context, an estimated cost range, and the required permissions:

{
  "classification": { "intent": "review", "source": "inferred", "reason": "keyword 'review' matched rule 0", "confidence": "high" },
  "routing": {
    "intent": "review",
    "provider": "openai",
    "model": "gpt-5.5-thinking",
    "matched_rule": "task.review -> openai/gpt-5.5-thinking",
    "fallback_used": false,
    "override_used": false,
    "reason": "rule 'task.review' matched the review intent"
  },
  "included_context": ["current git diff", "AGENTS.md"],
  "excluded_context": [".env", "target/**"],
  "estimated_cost": { "min": "0.03", "max": "0.09", "currency": "USD" },
  "required_permissions": [
    { "permission": "read_repository", "mode": "allow" },
    { "permission": "write_files", "mode": "ask" }
  ]
}

routing is the complete deterministic decision that /execute would apply. Its reason explains why the route was selected, while fallback_used and override_used identify whether selection moved away from the configured primary or honored an explicit model override.

required_permissions is the project tool permissions tightened by the matched rule’s required_permissions. estimated_cost is a decimal-string range: min prices only the input (the selected context and the prompt) and max adds an assumed reply, so a model that declares no prices — a local model, for instance — reports a 00 range. The preview is deterministic: the same body, policy, provider credentials, and model catalogs yield the same result. Missing credentials have the same effect as on /execute: models that require them are unavailable, so routing uses an eligible fallback or fails with fallback_exhausted.

Only the owner may preview, and previewing needs no run: it acquires no lock and changes nothing, so it works even while a turn is in flight. An unknown, archived or another user’s session responds 404 session_not_found, alike so existence stays private; a session_id that is not a valid UUID responds 400 invalid_session_id; and a request whose routing cannot resolve responds 422 (no_route, context_window_exceeded), 403 override_not_allowed, or 503 fallback_exhausted.

Approvals

Approvals travel through /continue; there is no separate approval endpoint.

For a tool that needs confirmation (requires_approval: "ask"), the client — the same machine that approves and executes — asks the user, then runs the tool if approved or reports a rejection, and returns the outcome in the tool result’s decision. The approval and the result arrive together.

A standalone awaiting_approval is raised only for a decision with no tool to run: disclosing context to a remote provider when privacy.remote.mode is ask (remote_disclosure), confirming a per-task cost ceiling (cost_limit), or accepting a generated plan before execution begins (plan). The client returns the decision in the approval_decisions bundle:

{ "approval_decisions": [{ "approval_id": "a1", "decision": "approved", "reason": null }] }

decision is approved or rejected.

Traces

A trace is the ordered list of events emitted while the router routed and ran a session’s tasks. A session has a single trace; it grows as the session runs.

Fetch a session’s trace

GET /api/v1/sessions/{session_id}/traces

Returns the session’s trace wrapped under a trace key. events is ordered oldest first. Each event carries its own routing context (task_type, provider, model, optional matched_rule) and a payload. event_type is one of message, classification, routing_decision, context_selection, tool_call, approval or cost.

The payload is either plaintext or encrypted. For a normal session it is { "plaintext": <payload> }, where <payload> is tagged by a type field equal to event_type; the per-type shapes are listed under trace_event_content in the storage schema reference. For an end-to-end encrypted session it is { "encrypted": <envelope> }, the sealed AEAD envelope (version, algorithm, key_id, nonce, ciphertext) that only a client holding the session key can open.

The events are paginated with two optional query parameters:

ParameterTypeDefaultDescription
limitinteger50Maximum number of events to return.
offsetinteger0Number of leading events to skip.

A session with no events in the requested window returns an empty events array, not a 404. Only the owner may read a trace: an unknown, archived or another user’s session responds 404 session_not_found, reported alike so existence stays private, and a session_id that is not a valid UUID responds 400 invalid_session_id:

GET /api/v1/sessions/{session_id}/traces?limit=50&offset=0
{
  "trace": {
    "session_id": "0194f1e2-...",
    "events": [
      {
        "event_type": "routing_decision",
        "task_type": "review",
        "provider": "openai",
        "model": "gpt-5.5-thinking",
        "matched_rule": "task.review -> openai/gpt-5.5-thinking",
        "created_at": "2026-06-04T10:15:00Z",
        "payload": { "plaintext": { "type": "routing_decision", "provider": "openai", "model": "gpt-5.5-thinking", "fallback_used": false, "override_used": false, "reason": "best for review" } }
      },
      {
        "event_type": "tool_call",
        "task_type": "review",
        "provider": "openai",
        "model": "gpt-5.5-thinking",
        "created_at": "2026-06-04T10:15:02Z",
        "payload": { "plaintext": { "type": "tool_call", "tool_name": "read_file", "status": "completed" } }
      }
    ]
  }
}

Providers and models

List providers

GET /api/v1/llm/providers

Lists the provider registry the router can route through. The default router configuration includes the known built-in providers (anthropic, gemini, ollama and openai); additional OpenAI-compatible instances appear when they are configured with a usable base_url. This endpoint does not prove model credentials are present. Use GET /api/v1/llm/models to see which providers can list models with the credentials supplied on that request.

Each entry carries a local flag: true when the provider serves its models on your own host or network with no request leaving the machine (a local Ollama, a self-hosted OpenAI-compatible endpoint), and false for a cloud API. This is the same locality every one of that provider’s models reports, so the two can never disagree:

{
  "providers": [
    { "id": "anthropic", "display_name": "Anthropic", "local": false },
    { "id": "ollama", "display_name": "Ollama", "local": true }
  ]
}

List models

GET /api/v1/llm/models

Lists the available models, returning each one as a full model descriptor. Like /execute, it accepts X-Smista-Provider-<Provider>-Api-Key headers and needs them: the router queries each provider’s list_models, and remote providers such as Anthropic and Gemini reject that call without an API key. A provider the router could not list — most often because its credentials are missing or were rejected — is left out of models and reported under unavailable, each entry naming the provider, a machine-readable reason and an optional human-readable message. This lets you tell an incomplete result from a genuinely empty one and see why each provider dropped out; unavailable is absent when every configured provider was listed. The reason is one of authentication, context_length, invalid_configuration, invalid_credentials, invalid_request, missing_credentials, model_not_found, provider_unavailable, rate_limit, storage, timeout, unknown or unsupported_capability. capabilities is a nested object of boolean flags — streaming, tools, json_output, system_prompt, images, reasoning and memory — where an absent or false flag means the capability is not supported. auth records how the model authenticates (none, api_key, optional_api_key or a { "custom": "<scheme>" } object); display_name, max_output_tokens and the cost fields are present only when known, and the cost fields are decimal strings:

{
  "models": [
    {
      "provider": "anthropic",
      "model": "claude-sonnet",
      "display_name": "Claude Sonnet",
      "local": false,
      "auth": "api_key",
      "capabilities": { "streaming": true, "tools": true, "json_output": true },
      "max_context_tokens": 200000,
      "max_output_tokens": 8192,
      "input_cost_per_million_tokens": "3",
      "output_cost_per_million_tokens": "15",
      "default_parameters": {}
    },
    {
      "provider": "ollama",
      "model": "qwen2.5-coder",
      "display_name": null,
      "local": true,
      "auth": "none",
      "capabilities": { "streaming": true },
      "max_context_tokens": 32768,
      "max_output_tokens": null,
      "default_parameters": {}
    }
  ],
  "unavailable": [
    {
      "provider": "gemini",
      "reason": "missing_credentials",
      "message": "no credentials configured for the provider"
    }
  ]
}

The router lists every provider in parallel under a per-provider deadline, so a single slow provider cannot hold the response open: a provider that does not answer in time is reported under unavailable with reason timeout. The deadline defaults to 10 seconds; send the X-Smista-Timeout-Ms header to tune it, in milliseconds. A value above the 60-second cap is clamped down to it, and a missing, zero or non-numeric value falls back to the default.

Usage

Session usage

GET /api/v1/sessions/{session_id}/usage

Reports the session total plus a per-model and a per-task-type breakdown, aggregated from the session’s cost events in one read. The top-level total, by_model and by_task_type are returned directly, with no enclosing wrapper. Each by_model entry carries its provider, model and request_count, and each by_task_type entry its task_type and request_count. Cost fields are decimal strings priced in USD, and a token count the provider never reported is omitted rather than guessed:

{
  "total": {
    "input_tokens": 12000,
    "output_tokens": 4200,
    "total_tokens": 16200,
    "estimated_cost": "0.42",
    "currency": "USD"
  },
  "by_model": [
    {
      "provider": "openai",
      "model": "gpt-5.5-thinking",
      "input_tokens": 8000,
      "output_tokens": 2200,
      "total_tokens": 10200,
      "estimated_cost": "0.31",
      "currency": "USD",
      "request_count": 3
    }
  ],
  "by_task_type": [
    {
      "task_type": "plan",
      "input_tokens": 4000,
      "output_tokens": 1200,
      "estimated_cost": "0.18",
      "request_count": 1
    }
  ]
}

Only the owner may read a session’s usage. An unknown, archived or another user’s session responds 404 session_not_found, reported alike so existence stays private; a session_id that is not a valid UUID responds 400 invalid_session_id. A session that exists but has recorded no cost yet answers 200 with an empty by_model and by_task_type.

In an end-to-end encrypted session the cost figures are sealed and the router holds no key, so it reports each request’s provider, model and task_type from the plaintext metadata and its request_count, but omits the token and cost fields it cannot read.

Errors

Errors use a consistent JSON shape and never expose secrets:

{
  "error": {
    "code": "missing_provider_credentials",
    "message": "The selected model requires provider credentials, but none were provided.",
    "details": { "provider": "anthropic", "model": "claude-sonnet" }
  }
}

Status codes

CodeMeaning
200Successful read or completed command
201Resource created
202Accepted — long-running or pending operation
204Deleted, no body
400Invalid request payload
401Missing or invalid authentication
403Authenticated but blocked by ownership or policy
404Resource not found
409Conflicting resource state
422Valid JSON that fails domain validation
429Rate limited
500Unexpected server error
501Endpoint recognized but not implemented yet
502Provider error
503Provider or storage unavailable
504Provider timeout

Error codes

The code field is the stable identifier clients should match on. The message is human-readable and may change; the HTTP status is provided alongside for convenience.

CodeStatusMeaning
context_length_exceeded422Request exceeds the provider model’s context window.
context_window_exceeded422Routing rejected a model whose context window cannot fit the input.
credentials_in_query400A credential was passed as a query parameter; credentials are accepted only in headers.
fallback_exhausted503Primary route failed and every configured fallback also failed.
forbidden403Caller is authenticated but not the resource owner.
internal_error500Unexpected server-side failure. Details intentionally omitted.
invalid_api_key401The API key presented to POST /auth/sign-in is malformed, unknown or does not match. Reported uniformly so it never leaks which users exist.
invalid_model_reference422A model reference was not in the expected provider/model form.
invalid_provider_configuration500A provider was configured with contradictory settings, such as an OpenAI-compatible instance whose declared locality disagrees with one of its models.
invalid_provider_credentials503Provider rejected the configured credentials.
invalid_provider_name422A provider identifier in a model or routing reference was not in the expected form.
invalid_request422Provider rejected the request body as malformed.
invalid_session_id400A session id in the path was not a valid UUID.
invalid_token401Session token is malformed or unknown.
missing_capability422Selected model lacks a capability the task requires.
missing_credentials401No credential was presented: a session token on a protected endpoint, or the X-Smista-Api-Key header on POST /auth/sign-in.
missing_provider_credentials503The selected model requires provider credentials none were configured.
model_not_found404The referenced model is not offered by the provider asked to resolve it.
no_route422No routing rule matched and no default route is configured.
not_implemented501The endpoint is recognized but not implemented yet.
override_not_allowed403Caller asked for a model override that policy forbids.
permission_expansion422An override tried to loosen a tool permission that may only be tightened.
provider_authentication503Provider rejected the request at the authentication layer.
provider_error502Provider returned an error that did not match any known category.
provider_unavailable503Provider returned a service-level error and may recover later.
provider_unsupported_capability422Provider reported it does not support a capability the request needed.
rate_limited429Provider rate-limited the request.
request_timeout504Call to the provider timed out before a response was returned.
routing_unsupported_capability422Routing rejected the selected model because it lacks a required capability.
run_in_flight409A turn is already in flight for the session; the run is busy until it reaches a checkpoint.
session_not_found404The session does not exist, is archived, or belongs to another user; the three are reported alike so existence stays private.
storage_error502An error occurred while reading or writing from memory storage.
token_expired401Session token is past its expiry timestamp.
token_revoked401Session token was previously valid but has been revoked.
unknown_effort422A reasoning effort name in the request was not recognized.
unknown_intent422A task intent name in the request was not recognized.
unknown_model422A referenced model is not configured on the router.
unknown_provider422A provider identifier in the request was not recognized.

Rust SDK

The smista-sdk crate is the single dependency you reach for when building a Rust program on top of smista.ai — a companion tool, an automation, or your own frontend. It re-exports everything you need from one place so you don’t have to track which internal crate a type lives in.

Add it to your project

cargo add smista-sdk

Use the domain types

The shared domain vocabulary — task intents, model descriptors, routing policy, permission and privacy models, configuration schemas and errors — lives under smista_sdk::core:

#![allow(unused)]
fn main() {
use smista_sdk::core::policy::PermissionMode;

let mode = PermissionMode::default();
println!("default permission mode: {mode:?}");
}

Any path you would have reached at smista_core::* is available as smista_sdk::core::*.

Talk to the router

The router client lives under smista_sdk::client. The backend-agnostic Client trait and its types are always available; you pick one HTTP backend with a feature. ReqwestClient, the async backend built on reqwest, ships behind the reqwest-client feature:

cargo add smista-sdk --features reqwest-client

The client holds your credentials for you. bootstrap mints and stores the API key, sign_in exchanges it for a session token the client then keeps, and every authenticated call reuses that token — you never pass a credential per call:

use smista_sdk::client::{Client, ReqwestClient, RouterClientConfig};

#[tokio::main]
async fn main() -> smista_sdk::client::Result<()> {
    // Defaults target http://localhost:7331; pass a URL to point elsewhere.
    let client = ReqwestClient::new(RouterClientConfig::default())?;

    // First run only: create the first user and store its API key.
    client.bootstrap().await?;
    // Exchange the held API key for a session token the client keeps.
    client.sign_in().await?;

    // Pass `(None, None)` to list everything, or a scope/title to filter.
    let sessions = client.list_sessions(None, None).await?;
    println!("you have {} session(s)", sessions.sessions.len());

    client.sign_out().await?;
    Ok(())
}

If you already hold an API key or a still-valid token (for example from a keyring), seed it with ReqwestClient::new(config)?.with_api_key(key) or .with_session_token(token) and skip the step you no longer need. The client is cheaply cloneable, and every clone shares the same authentication state.

The keys the router needs to reach upstream models are configured once with .with_provider_credentials(...); they travel as request headers on the calls that can reach a model and are never logged, traced or sent as model context.

Without an async runtime

If your program does not run an async runtime, enable ureq-client instead for UreqClient, a backend built on the blocking ureq HTTP client — no tokio, no reactor:

cargo add smista-sdk --features ureq-client

UreqClient implements the same Client trait and exposes the same methods. They are async to match the trait, but block internally and contain no .await, so any executor drives them — even a trivial one:

use smista_sdk::client::{Client, RouterClientConfig, UreqClient};

fn main() -> smista_sdk::client::Result<()> {
    let client = UreqClient::new(RouterClientConfig::default())?;

    futures::executor::block_on(async {
        client.bootstrap().await?;
        client.sign_in().await?;

        // Pass `(None, None)` to list everything, or a scope/title to filter.
        let sessions = client.list_sessions(None, None).await?;
        println!("you have {} session(s)", sessions.sessions.len());

        client.sign_out().await
    })?;

    Ok(())
}

Each call blocks its thread for the whole request. When you do run under a work-stealing runtime such as Tokio, offload each call with tokio::task::spawn_blocking rather than blocking a worker thread.

What’s coming

The client and the smista_sdk::core types already share the single smista-sdk dependency. Future releases add more backends behind their own features, all reachable through smista_sdk::client.

TypeScript SDK

The @smista-ai/sdk package is a thin, typed client over the smista-router HTTP API. It sends requests and returns typed results; it never reimplements routing, policy evaluation, provider selection or tool mediation — that logic stays in the router.

Add it to your project

npm install @smista-ai/sdk

The package targets Node.js 22 or newer and ships as ES modules.

Talk to the router

SmistaClient implements every router endpoint and holds your credentials for you. bootstrap mints and stores the API key, signIn exchanges it for a session token the client then keeps, and every authenticated call reuses that token — you never pass a credential per call:

import { SmistaClient } from '@smista-ai/sdk';

// Defaults target http://localhost:7331; pass a baseUrl to point elsewhere.
const client = new SmistaClient({ baseUrl: 'http://localhost:7331' });

// First run only: create the first user and store its API key.
await client.bootstrap();
// Exchange the held API key for a session token the client keeps.
await client.signIn();

const sessions = await client.listSessions();
console.log(`you have ${sessions.sessions.length} session(s)`);

await client.signOut();

An authenticated call made before you sign in fails right away, before any network request, so a missing token never reaches the router.

Reuse a session token

If you already hold an API key or a still-valid session token (for example from a secure store), seed it when constructing the client and skip the step you no longer need:

const client = new SmistaClient({ sessionToken: savedToken });

// The seeded token authenticates calls without a fresh sign-in.
const me = await client.me();

Reach upstream models

The keys the router needs to call upstream models are configured once with providerCredentials. They travel as request headers only on the calls that can reach a model — execute, continueRun, streamExecute, streamContinue and listModels — and never appear in a query parameter, log or error:

import { ProviderCredentials, SmistaClient } from '@smista-ai/sdk';

const client = new SmistaClient({
  sessionToken: savedToken,
  providerCredentials: ProviderCredentials.empty().withProvider('anthropic', 'sk-ant-...'),
});

const models = await client.listModels();
console.log(`${models.models.length} model(s) available`);

Stream a turn

streamExecute and streamContinue yield the turn one event at a time. Iterate the result with for await; the stream ends after the terminal turn_end event, which carries the final turn:

for await (const event of await client.streamExecute(sessionId, request)) {
  if (event.type === 'text_delta') {
    process.stdout.write(event.delta);
  } else if (event.type === 'turn_end') {
    console.log('\nturn complete');
  }
}

Handle errors

Every method rejects with a SmistaError, whose kind says what went wrong: an api error pairs the HTTP status with the router’s structured error code, while decode, transport and notAuthenticated cover the rest. Use isSmistaError to narrow an unknown value in a catch:

import { isSmistaError, SmistaClient } from '@smista-ai/sdk';

try {
  await client.getSession(sessionId);
} catch (error) {
  if (isSmistaError(error) && error.kind === 'api') {
    console.error(`router said ${error.code} (status ${error.status})`);
  } else {
    throw error;
  }
}

Architecture

smista.ai is composed of a small set of clearly separated components. The goal is to keep the CLI experience simple while providing an easy-to-run router as its backend.

Components

ComponentKindResponsibility
smista-cliCLI binaryUser interaction, command parsing, rendering, approvals, runs and talks to router.
smista-routerLibraryAuth, sessions, classification, routing, context, tool mediation, traces; embedded and run by the CLI.
smista-router-clientLibraryAsync Rust client for the router HTTP API; used by the CLI and Rust frontends.
smista-coreLibraryShared domain types, config, policy, trace structures, validation, errors.
smista-providersLibraryModel abstraction and provider adapters (OpenAI, Anthropic, Ollama, …).
smista-storageLibraryStorage traits, entities (incl. trace events) and the SurrealDB layer.
@smista-ai/sdkTypeScript SDKTyped client over the router HTTP API.

Principles

  • Local-first by default — the CLI and router run on localhost; config, policies, skills, prompts and traces live locally and can be versioned.
  • Deterministic over magical — routing never relies on an LLM. Explicit rules and policies guide model selection.
  • Traceability — every routing decision is explainable via a trace.
  • Least-context routing — each model receives only the minimum context required for its task; full session context is never forwarded by default.
  • User control before automation — file writes, shell commands, network access and sensitive context disclosure require approval.

How the pieces talk

graph LR
    User([Developer])
    CLI[smista-cli]
    Router[smista-router]
    Storage[(SurrealDB)]
    Providers[Providers<br/>OpenAI · Anthropic · Ollama]

    User -->|prompt, approvals| CLI
    CLI -->|HTTP JSON API| Router
    Router -->|sessions, traces| Storage
    Router -->|model calls| Providers

The CLI never decides which model runs a task — it only expresses preferences and renders results. Every routing decision belongs to the router.

Sign-in and session start

sequenceDiagram
    actor U as Developer
    participant C as smista-cli
    participant R as smista-router
    participant DB as SurrealDB

    U->>C: smista "..."
    C->>R: POST /auth/sign-in (X-Smista-Api-Key)
    R->>DB: look up user by api_key_hash
    R-->>C: session token (short-lived)
    C->>R: POST /sessions (Bearer token)
    R->>DB: create session
    R-->>C: session id

Executing a task

This is the golden path: classify, route, select context, call the model, return a result and a trace.

sequenceDiagram
    actor U as Developer
    participant C as smista-cli
    participant R as smista-router
    participant P as Provider
    participant DB as SurrealDB

    U->>C: prompt
    C->>R: POST /sessions/{id}/execute
    Note over R: Deterministic — no LLM
    R->>R: classify task (intent)
    R->>R: evaluate policy → select provider/model
    R->>R: select minimum context, exclude restricted files
    R->>P: invoke model (provider adapter)
    P-->>R: response or tool-call request
    R->>DB: record routing decision + trace
    R-->>C: result + routing explanation + trace_id
    C-->>U: render response, cost, matched rule

Tool calls and approvals

Models never touch the filesystem, shell or network directly. The router mediates every tool call against the active permissions.

sequenceDiagram
    participant P as Provider
    participant R as smista-router
    participant C as smista-cli
    actor U as Developer

    P-->>R: tool-call request
    R->>R: validate against permissions
    alt mode = deny
        R-->>P: blocked (reported in trace)
    else mode = ask
        R-->>C: approval_required
        C->>U: show diff / command
        U-->>C: approve or reject
        C->>R: POST /approvals/{id}
    end
    R->>R: execute allowed tool via tool runtime
    R->>P: tool result
    P-->>R: continues until final response

Route preview

/preview explains the decision without calling any model.

sequenceDiagram
    actor U as Developer
    participant C as smista-cli
    participant R as smista-router

    U->>C: /preview "review this PR"
    C->>R: POST /sessions/{id}/preview
    R->>R: classify + evaluate policy + select context
    Note over R: Model is never called
    R-->>C: task, model, matched rule,<br/>context, estimated cost, permissions
    C-->>U: render preview

Execution protocol

This document specifies how a client and smista-router talk while a task runs: the request the client sends, the continuations the router asks back for, how results are correlated, and where the streaming events fit. It is the authoritative contract behind the execute, stream, continue and preview sections of the HTTP API reference; that page gives the wire-level JSON, this page gives the model behind it.

The single invariant everything below serves: routing is deterministic and never depends on an LLM. The router classifies, matches policy, selects a model and assembles context by fixed rules; the client only expresses preferences, executes on the user’s machine, and renders results.

Roles and ownership

A task is a conversation between two parties with a strict division of labour.

PartyOwns
RouterClassification, policy evaluation, model selection, context selection, tool mediation, persistence (history, memory, trace).
ClientThe user’s machine: the filesystem, tool execution, the approval UI, and — for encrypted sessions — the session key and the crypto.

The router may be remote. The protocol never assumes the router shares a filesystem or a host with the client, which is why the client must ship file contents itself and why end-to-end encryption exists (see End-to-end encryption).

Two trust boundaries follow, and they are not the same:

  • Client to router. Everything the task needs travels here, sensitive content included. The router is trusted by its owner.
  • Router to model. Privacy is enforced here. Sensitive content may reach a local model but is never placed in a prompt sent to a remote provider. See Privacy and model locality.

What the client sends

When it starts a run the client sends only what the router cannot obtain for itself — the prompt, the local files and skills, and the policy:

FieldContents
inputThe prompt text, an optional command (forces the intent) and an optional explicit_model.
workspaceRepository snapshot: root, git_branch, git_diff, referenced paths, active file.
attachments.filesExplicit @path files with content and a content_hash, each flagged required or discardable.
attachments.instructionsInstruction documents the client read from disk (for example AGENTS.md).
attachments.invoked_skillsSkills the user explicitly invoked, each name + content (the SKILL.md body).
attachments.available_skillsSkills offered for the model to activate, same name + content shape.
policyThe deterministic routing, tools and privacy policy, sent verbatim.
local_preferencesResolved client toggles: auto_apply, local_only, no_network.
providersProviders offered for this run and the per-model credential status.

The router cannot read the filesystem, so every file, instruction and skill the task may need from disk is the client’s to supply. Skills travel as name plus their SKILL.md content, and they do not influence routing. The router never discovers a skill or infers relevance: the invoked skills are added to the model preamble, while the available skills are offered for the model to activate.

What the client does not send: session history, memory, or any assembled context. Those are the router’s.

What the router owns

The router recalls everything that lives in storage, deterministically, and the client never supplies it:

  • Session history — prior messages, each tagged with the provider and model that produced it, so the conversation is model-agnostic and any model can pick it up.
  • Memory — user-wide and per-session facts.
  • Assembled context — the candidate set selected from history, memory and the client’s attachments, filtered by privacy and trimmed to the chosen model’s window.

Context selection is filter-only: the router ranks and trims candidates it already holds. It never gathers from the filesystem.

Run lifecycle

A run is one user prompt carried to a terminal state. A session has at most one in-flight run.

stateDiagram-v2
    [*] --> idle
    idle --> running: start a run
    running --> awaiting_client: needs a tool, approval or crypto
    awaiting_client --> running: advance with results
    running --> idle: completed
    running --> error: terminal error
    awaiting_client --> idle: break, no new input
    error --> idle

awaiting_client means a continuation is outstanding — the router needs the client to run a tool, decide an approval, or seal/open ciphertext before it can proceed.

The run’s state lives in storage, not in router memory, so a dropped connection or a remote router never loses it. The full set of pause states, the processing lock that rejects parallel requests, and where each wait resumes are specified in the run state machine; this page covers the wire protocol. One rule makes abort, mid-run input and reconnection share a single path:

Supersede rule. A new client request for a run cancels any in-flight turn for that run — the partial output is persisted, pending tool calls are cancelled — and the new request starts the next turn.

The turn loop

A run is a loop of turns. A turn is one model invocation. The model that serves a turn can differ from the one that served the previous turn: history is model-agnostic and rebuilt each turn, so a switch is transparent. Each turn:

  1. Classify the current step from current workflow state — the prompt, history so far, the latest tool results, referenced paths and any invoked skill. State grows every turn, so the intent can change.
  2. Assemble candidate context — relevance-filter history, memory and the client’s attachments. Classify each candidate’s privacy (restricted for remote or not) and size. This is model-agnostic.
  3. Select the model under the candidates’ constraints — locality (see Privacy and model locality), required capabilities, and fit to the window — then walk the fallback chain if the first choice is unavailable.
  4. Finalize context — trim to the chosen model’s window and decrypt sealed history if the session is encrypted.
  5. Invoke the model, buffered or streamed.
  6. The turn ends in one of two ways: the model stops with no tool calls, so the run completes; or the model requests tool calls, which the router mediates with the client before looping to step 1.

Re-classification fires before every invocation. Routing follows the drifting intent — a run can plan with a reasoning model, edit with a coding model and review with another, each chosen deterministically. The router never sequences phases itself; phases emerge from the loop, exactly as a turn ends and the user sends the next prompt.

Starting a run

POST /sessions/{session_id}/execute   # buffered, or streamed via Accept

Starts a run from a new user prompt. execute returns the turn’s outcome as one JSON body by default, or as server-sent events when the client sends Accept: text/event-stream (see Streaming). preview takes the same body and provider credential headers. It may query provider model catalogs to mirror execute model availability, but never sends a completion request.

Request body

{
  "input": { "text": "refactor the auth middleware", "command": "edit", "explicit_model": null },
  "workspace": {
    "root": "/Users/christian/project",
    "git_branch": "main",
    "git_diff": "...",
    "referenced_paths": ["src/auth/middleware.rs"],
    "active_file": null
  },
  "attachments": {
    "files": [
      { "path": "src/auth/middleware.rs", "content": "...", "content_hash": "sha256:...", "required": true }
    ],
    "instructions": [{ "source": "AGENTS.md", "content": "..." }],
    "invoked_skills": [],
    "available_skills": []
  },
  "policy": { "version": 1, "source": "merged", "classification": { }, "routing": { }, "tools": { }, "privacy": { } },
  "local_preferences": { "auto_apply": false, "local_only": false, "no_network": false }
}

The policy block is the canonical routing, tool-permission and privacy vocabulary, sent verbatim — see the HTTP API reference for its fields. The body never lists providers or credentials: the router owns the model catalog and reads any provider credentials from the request headers. For an encrypted session the content the router authors and persists is sealed through the encrypt turn (see Decrypt and encrypt).

Response: completion or continuation

A turn resolves to one envelope with three top-level fields: status names the outcome, data carries that outcome’s payload, and allowed_continuations lists the messages the client may send next. The outcomes are:

statusMeaningClient’s next move
completedThe model finished; an assistant message is included.Render; seal to_encrypt if set, else done.
awaiting_toolThe model requested one or more client-executed tools.Run them; advance with the results.
awaiting_approvalThe router needs a yes/no with no tool to run.Ask the user; advance with the decision.
awaiting_decryptThe router needs sealed history opened to build the prompt.Decrypt; advance with the plaintext.
awaiting_encryptThe router needs its own output sealed before it can persist it.Seal; advance with the ciphertext.
idleThe run finished and its content was persisted.Nothing to render; the run is over.
errorA terminal error ended the turn.Surface it; the run is over.

allowed_continuations is empty for a terminal outcome and otherwise always includes break. A completed turn is terminal for a plaintext session; for an encrypted session its data carries a to_encrypt map and allowed_continuations is [sealed, break], after which the router answers idle.

A completed turn:

{
  "status": "completed",
  "data": {
    "message": { "role": "assistant", "content": "...", "provider": "anthropic", "model": "claude-sonnet" },
    "classification": { "intent": "edit", "source": "inferred", "reason": "keyword matched", "confidence": "high" },
    "routing": {
      "task_type": "edit",
      "provider": "anthropic",
      "model": "claude-sonnet",
      "matched_rule": "edit + src/auth/** -> anthropic/claude-sonnet",
      "fallback_used": false,
      "override_used": false
    },
    "context": { "included": ["src/auth/middleware.rs", "AGENTS.md"], "excluded": [".env"] },
    "usage": { "input_tokens": 1200, "output_tokens": 500, "estimated_cost": "0.08", "currency": "USD" },
    "trace_id": "trace:xyz"
  }
}

Every non-terminal status is a continuation; the client does the work and resumes the run with /continue.

Continuations

A continuation is the router handing control to the client because only the client can do the next step — run a tool on the user’s machine, get a decision, or touch the session key.

Tool requests

When the model requests tools, the router checks each call against tools.permissions before involving the client:

ModeWhat the router does
denySynthesizes a “denied by policy” tool result and feeds it back to the model. No client trip.
allowReturns the call to the client to execute.
askReturns the call to the client, flagged for approval, to confirm and execute.

deny never reaches the client. The rest surface as awaiting_tool:

{
  "status": "awaiting_tool",
  "data": {
    "tool_requests": [
      { "call_id": "c1", "name": "read_file", "arguments": { "path": "src/auth/middleware.rs" }, "requires_approval": "allow" },
      { "call_id": "c2", "name": "shell", "arguments": { "command": "cargo test" }, "requires_approval": "ask" }
    ],
    "trace_id": "trace:xyz"
  },
  "allowed_continuations": ["tool_results", "inject", "break"]
}

requires_approval is allow (run it) or ask (confirm with the user first). The client executes each call, correlated by call_id, and advances the run with the results.

A tool that takes a file path may receive a relative one. The client resolves it against the workspace root (workspace.root), and, for a path an active skill refers to, against that skill’s own directory. The router never resolves paths itself.

Approvals

Approval is folded into the tool result. For an ask call the client — the same machine that approves and executes — asks the user, then runs the tool if approved or reports a rejection, in a single advance. The router records the session_approval from the reported decision. There is no separate approval round trip for a tool.

A standalone awaiting_approval is reserved for decisions with no tool to execute — chiefly disclosing context to a remote provider when privacy.remote.mode is ask, an optional cost_limit confirmation, and accepting or rejecting a generated plan before execution begins:

{
  "status": "awaiting_approval",
  "data": {
    "approval": {
      "approval_id": "a1",
      "kind": "remote_disclosure",
      "detail": { "provider": "anthropic", "model": "claude-sonnet", "paths": ["src/auth/middleware.rs"] }
    },
    "trace_id": "trace:xyz"
  },
  "allowed_continuations": ["approval_decisions", "break"]
}

kind is remote_disclosure, cost_limit or plan. The client returns the decision in the advance bundle.

Decrypt and encrypt

In an encrypted session the router holds only ciphertext and no key, so it leans on the client for crypto. The two directions differ (see End-to-end encryption, and the run state machine for the resume points):

  • Decrypt is a standalone step. To build the prompt the router needs sealed history opened, and cannot proceed without it. awaiting_decrypt sends a to_decrypt map; the client opens it and advances with the plaintext. When the same pause also has router-authored content to seal — most often the run-input bundle and the user message sealed at run start — it folds a to_encrypt map alongside to_decrypt, and the decrypted continuation returns the opened plaintext together with the sealed ciphertext.
  • Encrypt rides the data response. Content the router authors (the assistant reply, tool-call arguments, a plan snapshot, trace payloads, an interrupted partial) travels out as a to_encrypt map on the data-bearing response; the client seals it and returns the ciphertext on its next continuation. Only the ciphertext is stored, and the user never waits to see the output. A completed turn with nothing else outstanding uses the dedicated sealed continuation; awaiting_encrypt is the standalone case (an interrupted partial).

Both maps are keyed by a content reference of the form kind:id, where kind is one of message, tool_call, diff, plan, memory, trace or run_input, so the router dispatches each payload to the right content store:

{
  "status": "awaiting_decrypt",
  "data": {
    "to_decrypt": {
      "message:0194": { "version": 1, "algorithm": "xchacha20poly1305", "key_id": "kf_ab12", "nonce": "...", "ciphertext": "..." }
    },
    "trace_id": "trace:xyz"
  },
  "allowed_continuations": ["decrypted", "break"]
}

A plaintext session never sees decrypt or encrypt; to_encrypt is absent and the router stores content directly.

Advancing a run

POST /sessions/{session_id}/continue

continue resumes the in-flight run with a single tagged { type, data } message answering the current pause. It returns the next turn’s outcome in the same shape as /execute, buffered or streamed by the Accept header. The valid type values are what the previous response advertised in allowed_continuations; break is always valid.

typeAnswersdata
tool_resultsawaiting_tool{ results: [{ call_id, content, is_error, decision }], encrypted }
approval_decisionsawaiting_approval{ decisions: [{ approval_id, decision, reason }], encrypted }
decryptedawaiting_decrypt{ plaintext, encrypted } — opened plaintext plus any sealed rows
sealeda folded encrypt{ encrypted } — a content-ref → envelope map
injectany live state{ messages: [{ text, ciphertext }] } — mid-run input; supersedes
breakany live statenone — aborts the in-flight turn (the Esc path)

A tool’s approval rides the decision on its result, so an ask call confirms and executes in one message. In an encrypted session the client’s encrypted map carries the ciphertext for the router-authored rows the response asked it to seal (and for the tool results themselves), keyed by content reference.

{
  "type": "tool_results",
  "data": {
    "results": [
      { "call_id": "c1", "content": "pub fn middleware() { ... }", "is_error": false },
      { "call_id": "c2", "content": "test result: ok", "is_error": false, "decision": "approved" }
    ]
  }
}
{ "type": "break" }

This single endpoint replaces the old standalone approval endpoint: approval decisions ride approval_decisions, and tool approvals ride the decision on a tool result. Mid-run input and aborts are their own messages (inject and break), each superseding the in-flight turn.

Streaming

stream, and continue when the client asks for it, deliver a turn as server-sent events. Each event is one JSON object tagged by type:

typeWhen
text_deltaA chunk of generated text.
reasoning_deltaA chunk of reasoning, for models that stream it.
tool_call_startedA tool call’s name is known; arguments are still streaming.
tool_call_requestedA tool call is complete, correlated by call_id.
usageToken counts and, when the model prices them, the cost.
turn_endThe terminal event; carries the turn’s status and continuation payload.

The stream always ends with exactly one turn_end, whose status is the same value the buffered response carries. For awaiting_tool the calls have already arrived as tool_call_requested events, and turn_end signals “this turn is done generating; execute and continue.” For completed it carries the message, routing, context, usage and trace_id. A failure ends the stream with turn_end of status: error and nothing after it.

Models that cannot stream still answer here: the full response is replayed as a short stream of the same events. The client never has to infer whether a turn completed or paused — turn_end says so explicitly.

Mid-run input and aborting

The user can type while a run is working, or abort it. Both are their own /continue messages, and both are always accepted — even while a turn is generating:

  • inject — mid-run input (“stop, do Y instead”). The router appends it to the conversation after the current turn’s output and tool results, persists it, and the next turn’s classification consumes it as fresh state, so routing adapts on its own. No special engine: injected input is simply more state at the loop head, picked up for free by per-turn re-classification.
  • break — a bare abort with no input (the Esc path).

Both apply the supersede rule: the router cancels the model call, persists the partial assistant turn marked interrupted (with whatever usage the provider reported before the cut — it may be incomplete), and cancels any unresolved pending tool calls. After that, inject continues the run with the new input, while break lets the run go idle and wait for the next prompt. In an encrypted session the interrupted partial is router-authored content, so it rides the encrypt path before it is stored.

Privacy and model locality

Privacy is a routing input, not a redaction step — it shapes which model may serve a turn, so sensitive content reaching a remote model is unreachable by construction, never a state the router has to clean up.

A context item is locality-constrained when its path matches the union of privacy.restricted_paths, privacy.remote.blocked_paths, and the paths of any local_only routing rule. Combined with whether the item can be dropped:

ItemBehaviour
Required, constrainedRemote is foreclosed for the turn. Local-only always wins — over rule precedence and over explicit_model.
Discardable, constrainedExcluded from the prompt when the route is remote. Nothing required is lost.
UnconstrainedSent freely to a local model; to a remote model subject to privacy.remote.mode.

A required item is one the user referenced (@path, the active file) or a path that drove the route; it cannot be silently dropped. A discardable item is supplementary context the window-fit step would trim anyway.

The honest failures — neither is a leak, both are refusals:

  • Required, constrained content with no eligible local model → the run fails loud (no_route-class), rather than ship a gutted prompt to a remote model.
  • An explicit_model override to a remote model on constrained required content → the override is refused (override_not_allowed). Override bypasses routing, never the privacy floor.

privacy.remote.mode independently gates disclosing the unconstrained remainder to a remote model: allow sends it, ask raises an awaiting_approval (remote_disclosure), deny keeps it local. A forward-time strip of any constrained content that slips into a remote prompt remains only as an internal invariant — it should never fire.

Sequence diagrams

The participants: the user, the client (the CLI on the user’s machine), the router, the model, and storage.

Single turn, no tools

sequenceDiagram
    actor User
    participant Client
    participant Router
    participant Storage
    participant Model

    User->>Client: prompt
    Client->>Router: POST /execute, Accept: text/event-stream (input, attachments, policy, ...)
    Router->>Storage: persist user message; recall history + memory
    Router->>Router: classify -> select context -> select model -> finalize
    Router->>Model: invoke (stream)
    Model-->>Router: text deltas
    Router-->>Client: text_delta events
    Model-->>Router: stop, no tool calls
    Router->>Storage: persist assistant message, usage, trace
    Router-->>Client: turn_end (completed)
    Client-->>User: render

Tool mediation

sequenceDiagram
    actor User
    participant Client
    participant Router
    participant Storage
    participant Model

    Router->>Model: invoke
    Model-->>Router: tool_call_requested (read_file, allow)
    Router->>Storage: persist tool_call (requested)
    Router-->>Client: turn_end (awaiting_tool)
    Client->>Client: read file on user's machine
    Client->>Router: POST /continue (tool_results)
    Router->>Storage: persist result, trace
    Router->>Router: re-classify -> select model (may switch)
    Router->>Model: invoke with the result
    Model-->>Router: stop, no tool calls
    Router-->>Client: turn_end (completed)

Approval for a sensitive action

sequenceDiagram
    actor User
    participant Client
    participant Router
    participant Model

    Router->>Model: invoke
    Model-->>Router: tool_call_requested (shell, ask)
    Router-->>Client: turn_end (awaiting_tool, requires_approval=ask)
    Client->>User: run `cargo test`?
    User-->>Client: approve
    Client->>Client: execute on user's machine
    Client->>Router: POST /continue (tool_results: decision=approved)
    Router->>Router: record approval + result
    Router->>Model: invoke with the result

Aborting and mid-run input

sequenceDiagram
    actor User
    participant Client
    participant Router
    participant Storage
    participant Model

    Router->>Model: invoke (stream)
    Model-->>Router: text deltas
    Router-->>Client: text_delta events
    User->>Client: Esc, or types a new instruction
    Client->>Router: POST /continue (inject) or (break)
    Router->>Model: cancel
    Router->>Storage: persist partial assistant (interrupted) + usage
    alt inject (new input)
        Router->>Router: re-classify -> next turn
    else break (bare abort)
        Router-->>Client: run idle
    end

An encrypted run

sequenceDiagram
    actor User
    participant Client
    participant Router
    participant Storage
    participant Model

    Client->>Router: POST /execute, Accept: text/event-stream (prompt plaintext + ciphertext)
    Router->>Storage: persist user ciphertext
    Router->>Router: select relevant history (ciphertext)
    Router-->>Client: turn_end (awaiting_decrypt: to_decrypt map)
    Client->>Client: open envelopes with the session key
    Client->>Router: POST /continue (decrypted: plaintext map)
    Router->>Model: invoke (context now plaintext)
    Model-->>Router: reply
    Router-->>Client: turn_end (completed + to_encrypt: assistant + trace)
    Client->>Client: render reply, seal to_encrypt with the session key
    Client->>Router: POST /continue (sealed: ciphertext map)
    Router->>Storage: persist ciphertext
    Router-->>Client: turn_end (idle)

Error and terminal states

A run ends in completed, error, or idle after a bare break. Errors use the shared error shape and stable code from the HTTP API reference; the ones the execution flow raises most often:

CodeWhen
no_routeNo rule matched and no default route — or no eligible local model for constrained content.
override_not_allowedAn explicit_model override was refused, including a remote override on constrained content.
fallback_exhaustedThe selected model and every fallback failed.
context_window_exceededThe minimum required context cannot fit the selected model’s window.
missing_capabilityThe selected model lacks a capability the task requires.

A terminal error is surfaced once, as status: error (or a turn_end of status: error when streaming), and the run is over; the session stays and the user can start a new run.

Run state machine

A run is one user prompt carried to a terminal state. While it runs, the router pauses between protocol turns and resumes on the next request, so it never holds the run in memory. The points it can pause at are saved as the run state machine, in the session_run_state row of the session. This page is the authoritative description of those states, what each one waits for, and where the run continues once the wait is answered. It is the model behind the execute, stream and continue endpoints described in the execution protocol.

The single invariant everything here serves: routing is deterministic and never depends on an LLM. The router classifies, selects a model and assembles context by fixed rules; the client only executes on the user’s machine and renders results.

The run and its lock

A session has at most one in-flight run, and that run is processed by at most one request at a time. The saved state has two independent parts:

  • phase — the durable checkpoint the run is paused at (Idle or one of the Awaiting* states below). This is the only thing that says where the run is.
  • active — the processing lock. It is present (an ActiveTurn carrying a lease) only while a turn is actively being served, and absent otherwise. Its presence is what “running” means; it is orthogonal to phase.

Every accepted request acquires the lock, does its work, then releases the lock and writes the next checkpoint:

acquire: active = ActiveTurn { started_at, lease };  phase unchanged
process: classify / decrypt / resolve / invoke / mediate / persist …
release: phase = <next checkpoint>;  active = none

Because two requests must never process one run at once, admission is decided against the saved row:

IncomingactiveWhat the router does
breakanyAlways accepted. Abort the in-flight turn (supersede).
injectanyAlways accepted. Supersede and continue with the input.
any other continuationpresent (live)Rejected — the run is busy.
any other continuationabsentAccepted: acquire the lock and process.
execute (start a run)present (live)Rejected — the run is busy.
executeabsent / no rowAccepted: acquire the lock and process.

break and inject are the user’s escape hatches: the user can always stop a run or redirect it, even while a turn is generating. Everything else waits its turn.

Crash recovery

Because phase always holds the last checkpoint and is never overwritten by the lock, a crash in the middle of a turn loses nothing structural. On startup the router clears active on every run-state row, then the run simply resumes from its phase. The client, which never received a response, re-sends the same continuation, and the router re-processes it. (Re-processing is made idempotent by writing rows under deterministic ids, so a retry overwrites rather than duplicates.)

A wedged run — a stuck lock that rejects every request forever — is impossible by construction: a stale lock is just a flag the next startup drops.

The pause states

Every Awaiting* state records what it waits for and a resume step naming where the run continues once the wait is answered:

PauseWaits forresumeOn answer
AwaitingDecryptthe client opening recalled ciphertextBuildPromptfinish building the prompt with the plaintext, then invoke
AwaitingApproval (disclose)the user’s yes/no on a remote disclosureInvokeyes → invoke; no → re-route locally or fail
AwaitingApproval (cost)the user’s yes/no on a cost ceilingInvokeyes → invoke; no → abort to idle
AwaitingApproval (plan)the user accepting or rejecting a planNextTurnaccept → next turn; reject → re-plan or idle
AwaitingToolthe client running the requested toolsNextTurnrecord the results, re-classify, start the next turn
AwaitingEncryptthe client sealing router-authored outputFinalizewrite the sealed content rows, then go idle

The resume step is the run-loop program counter; the approve-versus-reject branch is handled inside that step. Each Awaiting* state carries only references to rows already in storage — never raw content — so the state row itself is never encrypted.

Tool mediation

When the model requests tools, the router checks each call against the session’s tool permissions before involving the client:

ModeWhat the router does
denySynthesizes a denial result and feeds it back to the model. No client pause.
allowReturns the call to the client to run.
askReturns the call to the client, flagged for approval, to confirm and run.

deny never reaches the client, so an AwaitingTool state only ever lists allow and ask calls. Approval for an ask call is folded into the tool result: the same machine that approves and runs the tool reports its decision alongside the result, in one continuation. There is no separate approval round trip for a tool.

Standalone approvals are reserved for decisions with no tool to run — disclosing context to a remote provider, confirming a cost ceiling, and accepting or rejecting a generated plan. Those raise AwaitingApproval.

A tool call that changes files (write_file, edit_file) records its proposed change when the call is requested — the change is known from the model’s arguments — as a session_diff keyed by the call’s id. When the result comes back, the same id moves the diff to applied (a successful result) or rejected (a failed one), folded in with the tool result like the approval. For an encrypted session the diff body is sealed by the client, the same way as the tool result.

Encrypt and decrypt

For an end-to-end-encrypted session the router holds no key, so the client does all the crypto. The two directions are handled differently because they depend on the client differently. A plaintext session skips both entirely.

Encrypt rides the data response. Content the router authors — the assistant message, a plan snapshot, an interrupted partial turn — cannot be stored as plaintext, and the stateless router cannot hold it. So the data-bearing response carries the data and a to_encrypt map. The router stores nothing for the row up front: it keeps the row’s non-secret metadata in the run state (which is short-lived and cleared when the run ends, so it is never sealed) and writes the metadata and the sealed content together when the ciphertext comes back on the next continuation. The user sees the output immediately; only its stored copy waits to be sealed. A tool call carries its sealed result the same way; the client also seals it on the continuation, so the tool-call row’s arguments are left empty and the model’s request rides the sealed assistant message. A file-changing call also folds its proposed diff body into the same to_encrypt map — its non-secret path is held in the run state as metadata — and the sealed diff row is written alongside the tool call when the ciphertext returns. Session memory the model writes during the run is stored in clear by the memory tool, so a finishing encrypted run folds those rows into the final to_encrypt and seals them in place. The deterministic trace the router records during the run is handled the same way: its rows are written in clear as the run proceeds and folded into the finishing seal, so no trace payload is left readable at rest.

Decrypt is its own step. To build a prompt the router must read stored ciphertext, and it cannot proceed without the plaintext, so this is a real pause: the response carries a to_decrypt map, and the client returns the opened plaintext. The request context the resolver replays every turn — the run-input bundle — is short-lived and stored in clear, so it never needs opening; only sealed history does. When a pause also has router-authored content to seal, the response may fold a to_encrypt map alongside to_decrypt, and the decrypted continuation returns both the opened plaintext and the sealed ciphertext.

Content the client itself authors — the prompt, tool results — is sealed by the client and travels as plaintext plus ciphertext together in one message, so it needs no extra round trip.

Both maps are keyed by a content reference of the form kind:id (for example message:0194…), where kind names the store the payload belongs to — message, tool_call, diff, plan, memory or trace — so the router dispatches each payload to the right place.

The protocol messages

execute and continue share one response type; continue is answered with one tagged message.

Responses

A response is one envelope of { status, data, allowed_continuations }: status names the outcome, data carries that outcome’s payload, and allowed_continuations advertises the valid next client messages (always including break while the run is live, empty for a terminal outcome):

statusMeaningThe client’s next move
completedThe model finished; an assistant message is included.Render; if to_encrypt is set, seal it.
awaiting_toolThe model requested one or more client-run tools.Run them; continue with the results.
awaiting_approvalA yes/no decision with no tool to run.Ask the user; continue with the decision.
awaiting_decryptSealed history must be opened to build the prompt.Decrypt; seal any to_encrypt; continue.
awaiting_encryptRouter-authored output must be sealed before it is persisted.Seal; continue with the ciphertext.
idleThe run finished and its content was persisted.Nothing to render; the run is over.
errorA terminal error ended the turn.Surface it; the run is over.

A completed turn for a plaintext session is terminal. For an encrypted session it carries to_encrypt and allowed_continuations of [sealed, break]; the trailing sealed persists the sealed content and the router answers with idle.

{
  "status": "awaiting_tool",
  "data": {
    "tool_requests": [
      { "call_id": "c1", "name": "shell", "arguments": { "command": "cargo test" }, "requires_approval": "ask" }
    ],
    "to_encrypt": { "message:0195": "the assistant turn that requested the tool" },
    "trace_id": "trace:xyz"
  },
  "allowed_continuations": ["tool_results", "inject", "break"]
}

Continuations

A continuation is a single { type, data } message answering the current pause. break carries no data:

typeAnswersCarries
tool_resultsawaiting_toolthe results (approval folded in) plus any sealed router content
approval_decisionsawaiting_approvalthe decisions plus any sealed router content
decryptedawaiting_decrypta content-ref → plaintext map plus any sealed router content
sealeda folded encrypta content-ref → ciphertext map
injectany live statemid-run user input; supersedes the in-flight turn
breakany live statenothing; aborts the in-flight turn
{
  "type": "tool_results",
  "data": {
    "results": [{ "call_id": "c1", "content": "test result: ok", "is_error": false, "decision": "approved" }],
    "encrypted": { "message:0195": { "version": 1, "algorithm": "xchacha20poly1305", "key_id": "kf_ab12", "nonce": "…", "ciphertext": "…" } }
  }
}
{ "type": "break" }

Where trace events are recorded

The router records a deterministic trace as it runs. The events and where each is written:

EventRecorded when
Classificationafter classifying the step
RoutingDecisionafter matching a rule and selecting the model
ContextSelectionafter building and finalizing the context
ToolCallon requesting a call, and again on recording its result
Approvalon each decision — a folded tool decision or a standalone one
Messageon persisting a user or assistant message
Coston recording usage after an invocation

A denyed tool call is recorded as a ToolCall with a denial result — visible to the model, never a client pause. In an encrypted session the trace payloads are router-authored, so they are sealed through the same to_encrypt fold.

Router state machine

The lock (active) is orthogonal and omitted from the node labels; every labelled transition runs under an acquired lock and releases it at the target checkpoint.

stateDiagram-v2
    [*] --> Idle
    Idle --> AwaitingDecrypt: execute, encrypted, sealed history needed
    Idle --> AwaitingApproval: execute, disclosure/cost gate
    Idle --> AwaitingTool: execute, model requested allow/ask tools
    Idle --> AwaitingEncrypt: execute, completed, encrypted (seal output)
    Idle --> Idle: execute, completed, plaintext

    AwaitingDecrypt --> AwaitingApproval: decrypted, then gate
    AwaitingDecrypt --> AwaitingTool: decrypted, then tools
    AwaitingDecrypt --> AwaitingEncrypt: decrypted, completed, encrypted
    AwaitingDecrypt --> Idle: decrypted, completed, plaintext

    AwaitingApproval --> AwaitingTool: decided, next turn requests tools
    AwaitingApproval --> AwaitingEncrypt: decided, completed, encrypted
    AwaitingApproval --> Idle: decided or aborted

    AwaitingTool --> AwaitingTool: results, next turn requests tools again
    AwaitingTool --> AwaitingApproval: results, next turn hits a gate or plan
    AwaitingTool --> AwaitingEncrypt: results, completed, encrypted
    AwaitingTool --> Idle: results, completed, plaintext

    AwaitingEncrypt --> Idle: sealed, content rows written

    AwaitingDecrypt --> Idle: break
    AwaitingApproval --> Idle: break
    AwaitingTool --> Idle: break
    Idle --> [*]

An inject from any live state behaves like a break followed by a fresh turn; it is elided from the diagram for legibility.

Client state machine

The client never leads. Every client transition is driven by the response’s status and allowed_continuations; the client holds no authoritative run state of its own, it only reacts and supplies what only the user’s machine can — tool execution, approvals, and the session key. break and inject are user-driven edges available from any non-idle state.

stateDiagram-v2
    [*] --> Idle
    Idle --> Waiting: send execute
    Waiting --> Idle: completed (plaintext) / error / idle
    Waiting --> Sealing: completed (encrypted), to_encrypt set
    Waiting --> RunningTools: awaiting_tool
    Waiting --> Approving: awaiting_approval
    Waiting --> Decrypting: awaiting_decrypt
    RunningTools --> Waiting: send tool_results
    Approving --> Waiting: send approval_decisions
    Decrypting --> Waiting: send decrypted
    Sealing --> Waiting: send sealed
    RunningTools --> Waiting: send break / inject
    Approving --> Waiting: send break / inject
    Decrypting --> Waiting: send break / inject
    Idle --> [*]

Task intent classification

Classification is the first stage of routing: it maps a request to a single TaskIntentchat, edit, review and so on — which the routing policy then matches to a model. It is purely deterministic and never calls an LLM, the core invariant of smista.ai. The model that serves a turn may change, but the decision of what kind of work this is never depends on a model.

This document follows the types in smista-core: Classification, ClassificationConfig, ClassificationRule, Confidence, IntentSource (the smista_core::policy module) and TaskIntent (the smista_core::intent module).

Where it runs

Classification runs on the router, on every turn (see the execution protocol). The router is the only party that holds the workflow state a classifier reads — session history, the latest tool results, the assembled context — so the client cannot classify for it, and a single prompt can be classified differently as the work progresses.

The user still owns the rules. The ClassificationConfig is authored in the CLI configuration ([classification] in config.toml) and sent to the router in the request, exactly like the routing, tools and privacy policy. The router evaluates it; it never invents rules of its own.

The intents

A TaskIntent is the kind of work one step performs. Each serializes to its lowercase name.

IntentMeaning
chatFree-form conversation with no specialized objective.
planProducing a plan or breaking a task into steps.
editModifying existing code or text.
reviewAssessing code or text and giving feedback.
summarizeCondensing longer content into a shorter form.
promptCrafting or refining a prompt for another model.
skillInvoking a named skill or tool-driven capability.

chat is the default — the intent returned when nothing more specific matches.

What the classifier reads

The classifier is a function of observable signals only:

  • The prompt text.
  • The explicit command (input.command) when the user named one — it overrides inference.
  • The available context kinds for the task, such as git_diff or pull_request, matched by a rule’s requires_any_context.
  • The classification config: the ordered rules and the default_intent.

A ClassificationRule is intentionally narrow today — intent, priority (default 1000), keywords and requires_any_context. The broader, planned signal set (explicit slash command, prompt prefix, workflow state, skill or template invocation) is the direction the rule grows into; see Refinements.

The algorithm

flowchart TD
    A[Request] --> B{input.command set?}
    B -->|yes| C[Explicit: intent = command]
    B -->|no| D[Evaluate rules, ascending priority]
    D --> E{a rule matches?}
    E -->|yes, first match wins| F[Inferred: intent = rule.intent]
    E -->|no| G[Inferred: intent = default_intent]

Explicit override

If input.command is set, that intent is used directly: source is Explicit, matched_rule is None, and no confidence is reported — an explicit intent is certain, not inferred. An explicit command always wins; no rule can override it.

Rule evaluation

With no explicit command, the rules are evaluated in ascending priority (lower first); for equal priorities, configuration order breaks the tie. The first matching rule wins.

A rule matches when every present condition holds, and a condition list is satisfied by any of its entries:

  • keywords — matches when any keyword appears in the prompt.
  • requires_any_context — matches when any named context kind is available.

That is AND across the two condition kinds, OR within each list — the same shape the routing matcher uses. A rule with no conditions matches every request, which makes it a useful low-priority catch-all. A matched rule yields source: inferred and records its index in matched_rule so a trace can point back to the configured rule (classification rules are addressed by index, not by name).

When no rule matches, the result is default_intent (chat unless configured otherwise), with source: inferred and matched_rule: None.

Confidence

Confidence is a coarse, deterministic signal-strength label — low, medium or highnot a probability. It is derived from how strong the winning match was, so the same inputs always produce the same label:

Outcomeconfidence
Explicit commandomitted
A rule matched on both keywords and requires_any_contexthigh
A rule matched on a single condition kindmedium
A conditionless catch-all rule matched, or default_intent usedlow

Confidence never gates the decision — the matched intent is used regardless. It is a diagnostic the trace and /preview surface so a user can see how firm the inference was.

Typo-tolerant keyword matching

Keyword matching is token-based and typo-tolerant, not a raw substring scan. The prompt is split into lowercase alphanumeric tokens, and each keyword is compared to each token using Optimal String Alignment (OSA) edit distance — the transposition-aware variant, so the common impelment/implement swap is a single edit. An exact token match is the fast path; only when no token matches exactly does a keyword fall through to a fuzzy comparison.

The tolerance is a static, length-bucketed edit-distance cap — never user-configurable — so the feature stays invisible and fully deterministic (no model, and the same prompt always classifies the same way). The buckets follow Meilisearch’s stricter model, because the keyword set is small and a false positive mis-routes a turn:

Keyword lengthMaximum OSA distance
< 50 (exact only)
5–81
>= 92

The cap never exceeds two edits. Short keywords such as edit, plan and chat require an exact match, which avoids collisions like edit matching audit. A keyword wholly contained in a longer token (or the reverse) is taken to be a different word rather than a typo, so review does not match preview.

A keyword matched through a typo is a weaker signal than an exact hit, so when a rule matches only through a fuzzy keyword its confidence is capped at medium, even when matching both condition kinds would otherwise make it high.

The classification result

Classification produces one Classification:

FieldTypeMeaning
intentTaskIntentThe detected intent.
sourceexplicit | inferredWhether the user named the intent or the router inferred it.
reasonstringHuman-readable explanation, e.g. keyword 'review' matched rule 0.
matched_ruleinteger, optionalIndex into ClassificationConfig.rules; absent when none matched.
confidencelow | medium | high, optionalSignal strength for an inferred intent; absent when explicit.

The intent flows into the routing stage: it becomes the RoutingContext.intent a RoutingRule matches on, which then selects the model.

Classifying every turn

Because the router re-classifies before every model invocation (see the turn loop), the intent can drift within a single run as the state grows: a run may classify plan on the first turn, edit once it starts changing files, and review at the end — each re-routed to the model best suited to it, transparently.

An explicit input.command applies to the turn that carried it (the run’s first turn). Later inner turns have no new command, so they infer from the evolving state — unless the user injects a fresh command with mid-run input.

Skills

The router never guesses which skills are relevant from the prompt, and skills do not influence routing. A skill is active for a turn in one of two ways, and the client tells the router which:

  • Invoked skills — the ones the user explicitly invoked. Their instructions are added to the model preamble, so the model applies them for sure.
  • Available skills — offered for the serving model to activate by reading their content. The model decides whether to apply one.

Both travel in the request (invoked_skills and available_skills under attachments). Classification stays purely about the intent; skills are carried alongside it, not derived from the prompt and not part of the routing decision.

In the HTTP API

Classification touches the HTTP API at three points:

  • Input. input.command carries an explicit TaskIntent; null asks the router to infer. input.explicit_model is unrelated — it bypasses routing, not classification.
  • Config. The classification rules travel with the request: the policy block carries a classification block (ClassificationConfig) beside routing, tools and privacy, so the router evaluates the user’s own rules.
  • Output. The full Classification (source, reason, confidence, the matched rule) is returned in the execute and /preview responses, beside the routed task_type.

Worked examples

A config with one review rule:

{
  "default_intent": "chat",
  "rules": [
    { "intent": "review", "priority": 10, "keywords": ["review", "audit"], "requires_any_context": ["git_diff"] }
  ]
}
RequestResult
command: "edit", any promptedit, source: explicit, no confidence.
“review my changes”, git_diff availablereview, source: inferred, matched_rule: 0, confidence: high.
“review this idea”, no git_diffchat, source: inferred, matched_rule: None, confidence: low (the rule needs a context kind that is absent).
“what does this function do?”, no rules matchchat, source: inferred, confidence: low.

Refinements

The doc reflects where the types are heading; these refinements are planned:

  • Richer rule signals — explicit slash command, prompt prefix, workflow state, skill or template invocation, beyond keywords and requires_any_context.
  • On-demand skill bodies — today every available skill ships its full content up front. A later refinement may send only metadata and fetch a body when the model activates it.

Routing and model selection

Routing is the stage that turns a classified task into a concrete model. It takes the intent and the task’s context, matches them against the user’s routing rules to pick exactly one rule, then resolves, validates and — if needed — falls back to a usable model. Like every routing decision it is deterministic and never depends on an LLM.

This document follows the smista-core types: RoutingPolicy, RoutingRule, Specificity, DefaultRoute (the policy module), ModelReference, ModelDescriptor, ModelCapabilities, Provider, Effort and ToolsConfig.

The routing context

The routing context is the observable input a rule is matched against. The router builds it for every turn from the stage before it:

FieldBuilt from
intentThe classified TaskIntent (or an explicit input.command).
pathsThe candidate file paths relevant to the task: referenced paths, the active file, the @path attachments and paths touched by the git diff.

These are the same candidate paths the privacy stage classifies, so a rule’s path match and the locality decision read the same set.

Matching a rule

A RoutingRule selects a model when its conditions hold. The rules live in RoutingPolicy.rules, authored by the user and sent in the request alongside the classification, tools and privacy policy.

Match conditions

A rule’s match conditions are all optional, and a rule matches when every present condition holds:

ConditionMatches when
intentit equals the context intent.
pathsany of its globs matches any candidate path.

That is AND across the condition kinds, OR within the paths list — the same shape the classifier uses. A rule with no conditions matches every request, which makes it a deliberate low-priority catch-all. An invalid path glob matches nothing rather than aborting the match.

Precedence

Routing resolves to exactly one rule, in this order:

  1. Explicit override. When input.explicit_model is set, matching is bypassed entirely and that model is used (override_used: true). The override still answers to the privacy floor — see Locality and privacy.
  2. Priority. Otherwise rules are evaluated in ascending priority (lower first; default 1000).
  3. Specificity. Within equal priority, the more specific rule wins. The ladder, most specific first: path+intent > path > intent > none.
  4. Configuration order. A remaining tie is broken by the order the rules appear in the config. Two rules with the same priority and the same specificity are rejected by configuration validation, so the order is always total and the outcome never depends on chance.

The first rule that matches in this order wins.

The default route

When no rule matches, routing uses RoutingPolicy.default — a DefaultRoute with its own model and ordered fallbacks. A policy with no default route and no matching rule is a no_route error: the router never guesses a model.

Selecting the model

A matched rule (or the default route, or an override) names a ModelReference in provider/model form. Selection turns that reference into a model that can actually serve the turn.

flowchart TD
    A[Matched route: model + fallbacks] --> B[Take next candidate]
    B --> C{Available?}
    C -->|no| H{More candidates?}
    C -->|yes| D{Capabilities + context fit?}
    D -->|no, fallback-eligible| H
    D -->|yes| E{Allowed locality?}
    E -->|no| H
    E -->|yes| F[Use this model]
    H -->|yes| B
    H -->|no| G[fallback_exhausted / no_route]

Resolving and availability

The reference is resolved against the router’s own model catalog and the provider credentials the request supplied through its headers. The request itself declares nothing about providers: the router owns the catalog (so it knows each model’s facts, including whether it requires_api_key) and reads any X-Smista-Provider-<Provider>-Api-Key header for itself, so it decides availability without trusting the client. A model is available when the router knows it and — when it needs an API key — a credential for its provider was supplied. A model the router does not know, or one that needs a credential none was supplied for, is not usable and falls through to the next candidate.

Capability and context validation

The router builds the task’s RoutingRequirements from what the turn needs — for example tools when the task may call tools, reasoning for a high-effort step, images when an attachment is an image — unioned with the rule’s requires_capabilities gate. ModelDescriptor::can_handle then checks the candidate:

  • Every required Capability must be supported, or it is missing_capability / routing_unsupported_capability.
  • The estimated input must fit max_context_tokens, or it is context_window_exceeded.

A capability gate normally rejects an unfit model and moves to the next candidate. A policy may permit degraded execution — running a model that lacks a non-essential capability rather than failing — which is a named refinement.

Fallback

On a fallback-eligible failure — an unavailable provider, a missing credential, a failed capability gate, a transient provider error — selection walks the route’s fallbacks in order, applying the same resolution and validation to each. The first candidate that passes is used and the outcome is marked fallback_used: true. When the primary and every fallback fail, the run ends with fallback_exhausted.

An explicit input.explicit_model override has no fallback chain: it is the user’s deliberate choice, so if it cannot be served the run errors rather than silently routing elsewhere.

Locality and privacy

Selection is the point where privacy constrains the model, because a model’s locality (ModelDescriptor.local, inherited from its ProviderDescriptor) decides whether sensitive content may reach it. The rule cited in full by the execution protocol:

  • Local-only always wins. When the turn’s required context is locality-constrained — a path under privacy.restricted_paths, privacy.remote.blocked_paths, or the paths of any local_only rule — only local models are eligible. This dominates rule precedence and an explicit_model override (a remote override on constrained content is refused with override_not_allowed).
  • A local_only rule additionally restricts its fallback chain to local models, so a sensitive task can never fall back to the cloud.
  • If no eligible local model can serve constrained required content, the run fails loud (no_route-class) rather than disclose it to a remote model.

So the bad pairing — sensitive content on a remote model — is never selected, by construction.

What a matched route carries

Beyond the model, a matched rule carries three things into execution:

FieldEffect
effortThe reasoning Effort (low/medium/high/xhigh, default medium) passed to the model.
required_permissionsA ToolsConfig merged over the project tool permissions with ToolsConfig::narrow — it may only tighten a mode (allow → ask → deny), never loosen one; a loosening attempt is permission_expansion. The result governs tool mediation during the turn.
cost_limitAn optional per-task ceiling (a decimal string). When the estimated cost would exceed it, the turn raises a cost_limit approval before calling the model.

The routing outcome

The decision is reported as a RoutingOutcome and recorded in the trace:

FieldMeaning
task_typeThe intent that drove routing.
providerThe selected provider.
modelThe selected model.
matched_ruleA human-readable description of the rule that matched, if any.
fallback_usedWhether a fallback model served the turn.
override_usedWhether an explicit model override was used.

The same shape (plus an estimated cost range and the required permissions) answers /preview, which runs every step above but never calls the model.

Worked example

A policy with one rule and a default route:

{
  "default": { "model": "openai/gpt-5.5-mini", "fallbacks": ["ollama/qwen2.5-coder:7b"] },
  "rules": [
    {
      "name": "auth edits use Claude",
      "priority": 30,
      "effort": "high",
      "intent": "edit",
      "paths": ["src/auth/**"],
      "model": "anthropic/claude-sonnet",
      "fallbacks": ["openai/gpt-5.5-thinking"]
    }
  ]
}
ContextOutcome
intent: edit, path src/auth/middleware.rsmatches the rule → anthropic/claude-sonnet, effort: high.
Same, but Anthropic has no credentialfallback to openai/gpt-5.5-thinking, fallback_used: true.
intent: chat, no auth pathno rule matches → default route openai/gpt-5.5-mini.
intent: edit, src/auth/** is restricted-for-remote and requiredboth rule models are remote → foreclosed; only a local model is eligible, else no_route.

Refinements

These follow where the types are heading:

  • Degraded execution — a policy switch to run a model that lacks a non-essential capability instead of failing the capability gate; there is no field for it yet.
  • Token estimation — the estimated_tokens checked against the window comes from the router’s deterministic estimator over the assembled context.
  • Cost estimation — the source of the estimate compared against cost_limit and reported by /preview.

Context selection

Context selection decides what actually goes into the prompt. It runs on the router, it is deterministic (never an LLM), and it is filter-only: it ranks and trims material the router already holds, and never reads the filesystem and never rewrites or summarizes content. It sits between routing and the model call, and it feeds model selection, because what is in the context decides which models may serve the turn.

This document follows the smista-core types Attachments, ContextOutcome, PrivacyPolicy, ModelDescriptor and the crypto payloads (SealedRecord, PlainRecord), plus the storage entities session_message, user_memory, context_memory and session_context_reference.

What the router draws on

The candidate material comes from two places, never from disk directly:

SourceComes from
Session historysession_message rows recalled from storage, each tagged with the provider and model that produced it.
Memoryuser-wide user_memory and per-session context_memory facts.
Client attachmentsthe request’s attachments: files (with content and a required flag), instructions and skills.
Workspace metadatathe request’s workspace: git branch and diff, referenced paths and the active file.

The filesystem-derived parts (attachments, workspace) arrive in the request because the router cannot read them; everything else is recalled from storage.

Skills arrive in two roles. Invoked skills are authoritative: their instructions join the model preamble, so they are part of what the turn must say. Available skills are offered for the model to activate on its own, so they are supplementary.

Building the candidate set

The router assembles these into a single, model-agnostic candidate set. Each candidate carries a kind (message, file, instruction, skill, memory, diff), a path when it has one, and a size estimate from the router’s deterministic token estimator. This is the universe the rest of the stage filters down.

The size estimate is a deterministic approximation (a fixed characters-per-token ratio), not a provider-exact count. It is consistent rather than precise; the window budget below keeps a margin so an approximate estimate stays safe.

Identical material is collapsed. When the same path, or the same content, appears more than once (for example a file that is both attached and quoted in history), the duplicates are merged into one candidate, keeping the required or higher-ranked copy.

Relevance and privacy

Two deterministic passes shape the set.

Required or discardable. Each candidate is marked. A candidate is required, and so can never be trimmed away, when it is:

  • a file the client flagged required,
  • a file whose path the user referenced,
  • a file whose path matches the globs that drove the route,
  • an instruction document, or
  • an invoked skill.

Everything else is discardable supplementary context: history, memory, the diff, available skills and any other file.

Relevance ranking. Discardable candidates are ranked by a deterministic score so that, when the window is tight, the most useful context is kept and the least useful is dropped first. The score combines three signals:

  • Kind. Attached files rank above the diff, the diff above history, history above memory, and offered (available) skills lowest.
  • Recency. Among history messages, newer messages outrank older ones, so a tight window keeps the recent conversation rather than the start of it.
  • Path affinity. A candidate whose path matches a referenced path or a route-driving glob is boosted, so context about the files in play is preferred.

Required candidates are never ranked against this score: they are always kept.

Privacy. Each candidate is also marked restricted for remote when its path matches PrivacyPolicy::is_restricted_for_remote (the union of restricted_paths, remote.blocked_paths and any local_only rule’s paths). Candidates without a path are never restricted.

These marks feed model selection: required restricted content forecloses remote models, and discardable restricted content is dropped when the route is remote. Privacy here is a routing input, not a redaction after the fact.

Fitting the window

Once the model is selected, the set is finalized to fit. The router does not fit against the raw context window: it first reserves room for the model’s reply (the model’s max_output_tokens, or a default when the model leaves it unbounded). The remaining effective budget is what context must fit, so the reply always has space.

The router keeps every required candidate plus as much relevant discardable context as fits the effective budget, taking discardable candidates in ranked order until the budget is full. The rest are excluded. This is the minimum viable context.

A required candidate is never dropped to make room, and content is never truncated or summarized to squeeze it in. If even the required context cannot fit the effective budget, the turn does not silently cut anything: it raises context_window_exceeded, which the selection stage treats as a fallback-eligible failure and walks the fallback chain toward a model with a larger window.

Opening sealed history

In an encrypted session the recalled history is ciphertext: the router is blind at rest and holds no key. Selection still works, because it runs on the cleartext metadata (role, provider, paths, timestamps) while only the content is sealed. Once the router knows which past rows it needs, it emits an awaiting_decrypt turn carrying those rows as SealedRecords; the client opens them with the session key and returns PlainRecords in the /continue bundle, and the router builds the prompt. A plaintext session skips this entirely.

What gets recorded

The selection is reported to the client as a ContextOutcome (human-readable included and excluded descriptions) and persisted as session_context_reference rows: path, kind, included and a reason. These are references and metadata only, never the file contents, and restricted content is not persisted unless policy allows. A trace therefore shows exactly what was included or excluded and why.

Where it sits in a turn

flowchart LR
    A[Classify] --> B[Build candidates + mark privacy and size]
    B --> C[Select model under constraints]
    C --> D[Finalize: trim to budget, decrypt]
    D --> E[Invoke model]

Context selection straddles model selection: the candidate set and its privacy and size marks are built before the model is chosen (so they can constrain the choice), and the set is trimmed and decrypted after (against the chosen model’s effective budget). Like the rest of the pipeline it re-runs on every turn, so the context tracks the work as it evolves.

Router authentication

The router has to answer one question on every request: who are you? It answers it with two credentials that work together — a long-lived API key that identifies you once, and a short-lived session token that you present on every request after signing in.

This is router authentication only. It is unrelated to the provider credentials (your OpenAI, Anthropic, Gemini or Ollama keys) that travel in separate headers and are never mixed in; see the HTTP API for how those headers look on the wire.

API keys

An API key is issued once, when a user is bootstrapped, and is shown only that one time. It looks like this:

sk-smista-api01-<user-id>-<secret>
  • sk-smista-api01- is a fixed version prefix, so the format can evolve later.
  • <user-id> is the owner’s UUID. The key embeds the user id, so the router knows whose key it is from the key alone — you never send a user id alongside it.
  • <secret> is a long random string.

The router never stores the raw key. It stores only an Argon2id hash of it, salted with a fresh random salt, so the same key never produces the same stored value twice. When you sign in, the router reads the user id out of the key, loads that user, and verifies the presented key against the stored hash.

Session tokens

You do not send your API key on every request. Instead you exchange it once for a session token, which the router issues, stores and hands back. A token looks like this:

<token-id>-<64 lowercase alphanumeric>
  • <token-id> is the token’s UUID in its simple form: 32 hexadecimal digits with no hyphens. It is the token’s stable identifier.
  • The trailing part is a 64-character random secret drawn from lowercase letters and digits.

The router stores only a SHA-512 crypt ($6$) hash of the token, again salted, never the raw token. Each token carries an expiry; its lifetime comes from configuration (see Configuration) and defaults to one day. A token can also be revoked before it expires.

Why secrets are looked up by id, never by hash

Both the API key and the session token are stored as salted hashes. A salted hash of the same input is different every time, so you cannot find a row by comparing hashes for equality — there is nothing stable to match on.

That is why both secrets carry an id in the clear: the user id inside the API key, and the token id inside the session token. The router parses that id, loads the single row it names, and only then verifies the presented secret against that row’s stored hash. A hash is never used as a lookup key.

Two hashing algorithms

The two credentials are hashed with different algorithms on purpose:

CredentialAlgorithmWhy
API keyArgon2idVerified rarely (only at sign-in), so a deliberately expensive hash is fine and resists offline cracking.
Session tokenSHA-512 crypt ($6$)Verified on every request, so it uses a cheaper hash to keep per-request cost low.

Both algorithms salt every hash, so neither stored value can be queried by equality — which is exactly why the load-by-id rule above applies to both.

The sign-in flow

  1. You call the sign-in endpoint with your API key in the X-Smista-Api-Key header. No body and no user id are needed, because the key already names the user.
  2. The router parses the user id from the key and loads that user. An unknown user is rejected.
  3. The router verifies the presented key against the user’s stored Argon2id hash. A mismatch is rejected.
  4. The router generates a new token, stores its SHA-512 crypt hash with an expiry, and returns the raw token to you. The raw token is never stored and cannot be recovered later.

From then on you send that token as Authorization: Bearer <token> on every request.

Validating a request

For each authenticated request the router:

  1. Parses the token id from the presented token.
  2. Loads the matching token row by that id, requiring it to be not expired and not revoked. A missing, expired or revoked token is rejected.
  3. Verifies the presented token against the row’s stored SHA-512 crypt hash.
  4. Resolves the owning user from the token row and scopes the request to that user, so you only ever reach your own data.

Signing out and revocation

Signing out revokes the presented token: the router marks the token row revoked, and from that moment validation rejects it, even though it has not yet expired. Expired and revoked tokens are also cleaned up over time.

Configuration

Two router settings govern authentication, both under [router.auth]:

SettingDefaultMeaning
token_ttl_seconds86400How long an issued session token stays valid, in seconds (one day).
local_bootstrap_enabledtrueWhether the local API-key bootstrap endpoint is available. Must be false when storage runs in remote mode.

See Configure the Router for where these settings live.

Memory

smista.ai can remember things so you don’t have to repeat yourself. There are two kinds of memory: user memory, which follows you across every session, and context memory, which is scoped to a single session. Both are written by the model through a dedicated memory tool, while recall stays deterministic and never depends on an LLM.

Two kinds of memory

KindBelongs toLives
User memoryyou (the user)across all of your sessions
Context memorya single sessionuntil that session is deleted

User memory is for durable facts — your preferences, recurring constraints, how you like work done. Context memory is for facts that only matter to the task at hand and should not leak into unrelated work.

How memory is recorded

The model decides what is worth remembering. When it judges that a fact should be stored, changed, or dropped, it calls the memory tool with one of three operations:

  • record — store a new fact.
  • update — replace a fact that has changed.
  • forget — drop a fact that no longer holds.

Every call is mediated by the router, like any other tool call: it is validated against your tool permissions, persisted, and recorded in the trace. The model never writes to the database directly.

On providers that offer constrained outputs — for example OpenAI structured outputs — smista.ai uses that path so the recorded operation is guaranteed to match the expected shape. On other providers it relies on standard tool calling. Either way, the behaviour is the same from your side.

How recall works

Recall is deterministic. Before a task runs, the router loads your user memory and the context memory of the active session, then adds them to the context it sends to the model — within the usual context-selection and privacy limits. No model chooses what is recalled, so routing stays predictable and remains explainable through the trace.

Enabling memory on a model

Memory only runs on models that can drive the memory tool. Whether a model has the memory capability is a fact the provider reports — you do not declare it in your configuration. To require memory for a particular kind of task, gate a routing rule on the capability:

[[routing.rules]]
name = "remember across the project"
requires_capabilities = { memory = true }
model = "openai/gpt-5.5-mini"

A task gated this way is never routed to a model that cannot record memory.

Where memory is stored

User memory and context memory live in two separate tables. Every row is owned by a user, so a query can never reach another user’s memory.

User memory

FieldTypeDescription
idUUIDv7Unique identifier.
useruser referenceOwner of the memory.
keystring, optionalTopic; lets an update target a fact.
contentstringThe remembered fact.
created_atdatetimeWhen the fact was first recorded.
updated_atdatetimeWhen the fact was last changed.

Context memory

FieldTypeDescription
idUUIDv7Unique identifier.
sessionsession referenceSession the memory belongs to.
useruser referenceOwner, enforced on every query.
keystring, optionalTopic; lets an update target a fact.
contentstringThe remembered fact.
created_atdatetimeWhen the fact was first recorded.
updated_atdatetimeWhen the fact was last changed.

Context memory is removed when its session is deleted. User memory persists and is subject to your retention settings.

Database schema

This document specifies the smista.ai storage schema as landed by issue #8 (storage domain entities and traits). It is the authoritative reference for the SurrealDB tables, their fields, and the relations between them.

The storage layer persists users, sessions, authentication tokens, session messages, routing decisions, context references, tool calls, approvals, plans, diffs, trace events, and memory. Application code depends on the storage traits and domain types, never on SurrealDB directly; SurrealDB-specific query logic is isolated in the storage implementation.

Design invariants

These rules hold for every table in the schema:

  • Record id is the id. The SurrealDB record id (table:⟨key⟩, a RecordId) is the entity id. There is no redundant id column. The key is a UUIDv7 generated in Rust (Uuid::now_v7()), so ids are deterministic, portable, and time-sortable. Session ids are globally unique.
  • Metadata and content are physically separated. Every entity that carries sensitive free-form content has a paired 1:1 <entity>_content table sharing the same record id. The base table holds queryable metadata; the _content table holds only the encryptable payload. This enables end-to-end encryption with no later migration: metadata stays queryable while content is stored either in clear or sealed as a ciphertext envelope. See End-to-end encryption for the full design.
  • User ownership is enforced at the query boundary. Every session-scoped row stores user redundantly so ownership checks and scoped queries never need a join. A user can only ever reach rows they own.
  • Secrets are never stored raw. Only hashes of API keys and tokens are persisted. Tool-call arguments/results and diffs are sanitised for secrets before persistence, subject to the active privacy policy.

Entity relations

Every entity maps to a dedicated table. Relations are explicit references: a session-scoped row points at its session and (redundantly) its owning user.

erDiagram
    user {
        uuid id PK
        string api_key_hash
        datetime created_at
        datetime updated_at
        datetime disabled_at "optional"
    }
    auth_token {
        uuid id PK
        uuid user FK
        string token_hash
        datetime created_at
        datetime expires_at
        datetime revoked_at "optional"
    }
    session {
        uuid id PK
        uuid user FK
        string title "optional"
        bool encrypted
        string key_id "optional"
        datetime created_at
        datetime updated_at
        datetime archived_at "optional"
    }
    session_message {
        uuid id PK
        uuid session FK
        uuid user FK
        enum role
    }
    session_routing_decision {
        uuid id PK
        uuid session FK
        uuid user FK
        enum task_type
    }
    session_context_reference {
        uuid id PK
        uuid session FK
        uuid user FK
        bool included
    }
    session_tool_call {
        uuid id PK
        uuid session FK
        uuid user FK
        enum status
    }
    session_approval {
        uuid id PK
        uuid session FK
        uuid user FK
        enum decision
    }
    session_run_state {
        uuid id PK
        uuid session FK
        uuid user FK
        string run_id
        int turn
        enum phase
        object active
    }
    session_plan {
        uuid id PK
        uuid session FK
        uuid user FK
        enum status
    }
    session_diff {
        uuid id PK
        uuid session FK
        uuid user FK
        enum status
    }
    trace_event {
        uuid id PK
        uuid session FK
        uuid user FK
        enum event_type
        enum task_type
        enum provider
        string model
        string matched_rule "optional"
    }
    user_memory {
        uuid id PK
        uuid user FK
        string key "optional"
    }
    context_memory {
        uuid id PK
        uuid session FK
        uuid user FK
        string key "optional"
    }

    user ||--o{ auth_token : "issues"
    user ||--o{ session : "owns"
    user ||--o{ user_memory : "owns"
    session ||--o{ session_message : "contains"
    session ||--o{ session_routing_decision : "records"
    session ||--o{ session_context_reference : "records"
    session ||--o{ session_tool_call : "records"
    session ||--o{ session_approval : "records"
    session ||--o| session_run_state : "tracks"
    session ||--o{ session_plan : "produces"
    session ||--o{ session_diff : "produces"
    session ||--o{ trace_event : "emits"
    session ||--o{ context_memory : "scopes"

Every session-scoped row above also carries a user reference (omitted from the relation edges for readability) used to enforce ownership without a join.

Metadata and content split

Content-bearing entities are split across two tables that share the same UUIDv7 record id; the identical key is the 1:1 join (session_message:⟨uuid⟩session_message_content:⟨uuid⟩). The base table is always queryable; the _content table holds only the payload that may later be encrypted.

erDiagram
    session_message      ||--|| session_message_content      : "same id"
    session_tool_call    ||--|| session_tool_call_content    : "same id"
    session_plan         ||--|| session_plan_content         : "same id"
    session_diff         ||--|| session_diff_content         : "same id"
    trace_event          ||--|| trace_event_content          : "same id"
    user_memory          ||--|| user_memory_content          : "same id"
    context_memory       ||--|| context_memory_content       : "same id"
    session_run_input    ||--|| session_run_input_content    : "same id"

Metadata-only entities (user, auth_token, session, session_routing_decision, session_context_reference, session_approval, session_run_state) stay single-table.

Each encryptable payload field is typed SecretContent below. A SecretContent holds the value either in clear or sealed as a ciphertext envelope, and is stored as a flexible object in one of two shapes:

  • Plaintext: { "Plaintext": "<text>" }, used by a non-encrypted session.
  • Encrypted: { "Encrypted": { "version": int, "algorithm": string, "key_id": string, "nonce": string, "ciphertext": string } }, used by an encrypted session. The envelope is opaque to storage and the router; only a client holding the session key can open it.

Whether a session’s content is plaintext or encrypted is fixed by the owning session’s encrypted flag. user_memory_content is the one exception: it is user-scoped rather than session-scoped, so it is not yet encryptable and keeps a plain string. See End-to-end encryption.

Tables

Each table below lists its fields. The id field is the SurrealDB record id (UUIDv7 key); it is shown for completeness, not as a separate column. Owner references (user, session) are stored as explicit RecordId references.

Enum-typed fields are stored as their lowercase textual form. A Provider enum value is the provider identifier as it appears in a model reference: one of anthropic, openai, ollama, or openai-compat:<name> for a named OpenAI-compatible instance.

user

An identity that can own sessions. In local-first deployments a user may be created locally with no SaaS account; the same entity can later represent a remote account. The raw API key is never stored — only its hash. A user has a single API key.

FieldTypeDescription
idUUIDv7Unique user identifier (record id).
api_key_hashstringHash of the user’s API key.
created_atdatetimeWhen the user was created.
updated_atdatetimeWhen the user was last updated.
disabled_atdatetime, optionWhen the user was disabled, if at all.

auth_token

A short-lived authentication session for the router. The CLI uses an auth token after signing in with its user id and API key. The raw token is never stored; expired or revoked tokens are rejected and cleaned up over time.

FieldTypeDescription
idUUIDv7Unique token identifier (record id).
useruser referenceOwning user.
token_hashstringHash of the issued token.
created_atdatetimeWhen the token was issued.
expires_atdatetimeWhen the token expires.
revoked_atdatetime, optionWhen the token was revoked, if at all.

session

A resumable user interaction with smista.ai. Each session belongs to exactly one user, who is the only identity allowed to access it.

FieldTypeDescription
idUUIDv7Globally unique session id (record id).
useruser referenceOwning user.
titlestring, optionHuman-readable session title.
scopestring, optionOpaque grouping key the session belongs to.
encryptedboolWhether the session’s content is encrypted.
key_idstring, optionFingerprint of the per-session key, when encrypted.
created_atdatetimeWhen the session was created.
updated_atdatetimeWhen the session was last updated.
archived_atdatetime, optionWhen the session was archived.

encrypted is fixed when the session is created and never changed afterwards; flipping it would orphan content the router cannot re-key. When true, every paired _content row in the session stores a sealed payload and key_id names the per-session key that sealed it. The key itself never reaches storage. See End-to-end encryption.

scope is an opaque grouping key the router stores and matches verbatim without interpreting it. The CLI sets it from the working directory so sessions can be listed per project; another client may scope by repository or workspace id. It is kept in clear even for an encrypted session so that listings can filter on it.

session_message

A message exchanged during a session. Metadata stays queryable; the message body lives in session_message_content.

FieldTypeDescription
idUUIDv7Unique message id (record id).
sessionsession referenceSession the message belongs to.
useruser referenceOwner, enforced on every query.
roleRole enumMessage role (user, assistant, …).
providerProvider enumProvider that produced the message.
modelstringModel that produced the message.
created_atdatetimeWhen the message was recorded.

session_message_content

Paired 1:1 with session_message (same record id).

FieldTypeDescription
idUUIDv7Record id, same as the metadata.
contentSecretContentThe message body.

session_routing_decision

Records which provider/model pair was selected for a task and why. Metadata-only.

FieldTypeDescription
idUUIDv7Unique decision id (record id).
sessionsession referenceSession the decision belongs to.
useruser referenceOwner, enforced on every query.
task_typeTaskIntent enumTask the decision routed.
providerProvider enumSelected provider.
modelstringSelected model.
matched_rulestring, optionRouting rule that matched, if any.
fallback_usedbool, optionWhether a fallback model was used.
override_usedbool, optionWhether a manual override was used.
reasonstringWhy this provider/model was chosen.
created_atdatetimeWhen the decision was recorded.

session_context_reference

Records what context was selected or excluded for a task. Stores references and metadata only — not full file contents. Restricted contents are not persisted unless policy allows. Metadata-only.

FieldTypeDescription
idUUIDv7Unique reference id (record id).
sessionsession referenceSession the reference belongs to.
useruser referenceOwner, enforced on every query.
pathstring, optionPath of the referenced context.
kindstringKind of context reference.
includedboolWhether the context was included.
reasonstringWhy it was included or excluded.
created_atdatetimeWhen the reference was recorded.

session_tool_call

Records a tool request and its execution result. Arguments, result, and error are sensitive and live in session_tool_call_content; they are sanitised for secrets before persistence.

FieldTypeDescription
idUUIDv7Unique tool-call id (record id).
sessionsession referenceSession the tool call belongs to.
useruser referenceOwner, enforced on every query.
tool_namestringName of the invoked tool.
statusenumExecution status.
created_atdatetimeWhen the tool call was requested.
completed_atdatetime, optionWhen the tool call completed.

session_tool_call_content

Paired 1:1 with session_tool_call (same record id).

FieldTypeDescription
idUUIDv7Record id, same as the metadata.
argumentsSecretContentTool-call arguments (sanitised).
resultSecretContent, optionTool-call result (sanitised).
errorSecretContent, optionError, if the tool call failed.

session_approval

Records a user decision for an operation that required confirmation — a tool call, file write, shell command, network access, or remote-provider context disclosure. Metadata-only.

FieldTypeDescription
idUUIDv7Unique approval id (record id).
sessionsession referenceSession the approval belongs to.
useruser referenceOwner, enforced on every query.
target_typestringType of operation being approved.
target_idstringId of the operation being approved.
decisionenumApprove or reject.
reasonstring, optionWhy the decision was made.
created_atdatetimeWhen the decision was recorded.

session_run_state

Persists the execution state machine of a session’s in-flight run, so the router can pause between protocol turns and resume on the next request without holding the run in memory. A session has at most one in-flight run, so this row is keyed by the session id and there is at most one per session; a write replaces it in place. phase is the durable checkpoint the run resumes from; active is the orthogonal processing lock, present only while a turn is being served. Because the lock never overwrites the checkpoint, a crash mid-turn loses nothing: startup clears active and the run resumes from phase. Metadata-only: the phase carries only references to rows already stored, never content, so the row is never encrypted. Reading no row means the run is idle. See Run state machine for the run lifecycle.

FieldTypeDescription
idUUIDv7Record id; the owning session’s id.
sessionsession referenceSession the run belongs to.
useruser referenceOwner, enforced on every query.
run_idstringId of the in-flight run.
turnintCount of turns served so far in this run.
phaseRunPhase enumThe durable checkpoint the run is paused at.
activeActiveTurn objectThe processing lock; present while a turn is served.
updated_atdatetimeWhen the state was last written.

phase is one of: Idle (nothing outstanding), AwaitingDecrypt (blocked on the client opening one or more sealed records before the prompt can be built), AwaitingTool (blocked on client-run allow/ask calls), AwaitingApproval (blocked on a yes/no decision with no tool) and AwaitingEncrypt (blocked on the client sealing one or more records). Every Awaiting* variant also records a resume step naming where the run continues once the wait is answered, and carries only the references it needs, never content. There is no Running phase: an in-flight turn is the presence of active, not a checkpoint. The enum serializes to an object, so phase is stored as a flexible object, and active as an optional flexible object.

session_run_input

Persists a run’s request context — the policy, local preferences and workspace snapshot — so every turn of a multi-turn run can re-run the deterministic resolver without the client re-sending it. A continue carries only the pause answer, so this row is the cross-turn carrier of the original request. A session has at most one in-flight run, so the row is keyed by the session id and there is at most one per session; a write replaces it in place. The non-secret metadata below is stored in clear; the secret half (input text, attachments, instructions, skills and git diff) lives in session_run_input_content.

FieldTypeDescription
idUUIDv7Record id; the owning session’s id.
sessionsession referenceSession the run belongs to.
useruser referenceOwner, enforced on every query.
run_idstringId of the run this context belongs to.
policystringThe deterministic policy snapshot, as JSON.
local_preferencesstringThe client’s local execution preferences, as JSON.
workspacestringThe workspace paths and flags (no git diff), as JSON.
plan_activeboolWhether the run is in plan mode, across turns.
created_atdatetimeWhen the context was recorded.

session_run_input_content

Paired 1:1 with session_run_input (same record id). Holds the secret half of the run context (the input text, attachments, instructions, skills and git diff), serialized as JSON and stored in clear or sealed for an encrypted session.

FieldTypeDescription
idUUIDv7Record id, identical to the base row.
contentSecretContentThe run-input bundle, in clear or sealed.

session_plan

Records a generated or approved execution plan. The plan snapshot lives in session_plan_content; the queryable hash and status stay in the base table.

FieldTypeDescription
idUUIDv7Unique plan id (record id).
sessionsession referenceSession the plan belongs to.
useruser referenceOwner, enforced on every query.
pathstringPath the plan applies to.
statusenumPlan status.
created_atdatetimeWhen the plan was created.
updated_atdatetimeWhen the plan was last updated.
approved_atdatetime, optionWhen the plan was approved.
content_hashstring, optionHash of the plan snapshot.

session_plan_content

Paired 1:1 with session_plan (same record id).

FieldTypeDescription
idUUIDv7Record id, same as the metadata.
contentSecretContent, optionSnapshot of the plan body.

session_diff

Records a proposed or applied file modification. The diff body lives in session_diff_content and is stored only after secret filtering, per the active privacy policy.

FieldTypeDescription
idUUIDv7Unique diff id (record id).
sessionsession referenceSession the diff belongs to.
useruser referenceOwner, enforced on every query.
pathstringPath the diff applies to.
statusenumDiff status.
created_atdatetimeWhen the diff was created.
applied_atdatetime, optionWhen the diff was applied.

session_diff_content

Paired 1:1 with session_diff (same record id).

FieldTypeDescription
idUUIDv7Record id, same as the metadata.
contentSecretContentThe diff body (secret-filtered).

trace_event

A structured, append-only event recorded during task execution; the detailed history surfaced by /trace. The append/get ops live on the storage Database; the assembled read view is smista-core’s Trace. The routing fields (task_type, provider, model, matched_rule) carry the routing context of the task that emitted the event, so get_latest_trace assembles the Trace from trace events alone. The free-form payload lives in trace_event_content.

FieldTypeDescription
idUUIDv7Unique event id (record id).
sessionsession referenceSession the event belongs to.
useruser referenceOwner, enforced on every query.
event_typeenumKind of trace event.
task_typeTaskIntent enumTask the emitting context served.
providerProvider enumProvider that served the task.
modelstringModel that served the task.
matched_rulestring, optionRouting rule that matched, if any.
created_atdatetimeWhen the event occurred.

trace_event_content

Paired 1:1 with trace_event (same record id).

FieldTypeDescription
idUUIDv7Record id, same as the metadata.
contentSecretContentStructured event payload.

The payload is a JSON object serialized to a string, then wrapped as a SecretContent. The shapes below describe that plaintext string; in an encrypted session it is sealed in the envelope and only the holder of the session key can read it. Each shape is tagged with a type field equal to the owning event’s event_type, and carries the fields of the matching metadata entity. ? marks an optional field; int is a JSON number and a monetary cost is a decimal string (never a float).

event_typepayload shape
message{ "type": "message", "role": string, "provider": string, "model": string }
classification{ "type": "classification", "intent": string, "source": string, "reason": string, "matched_rule"?: int, "confidence"?: string }
routing_decision{ "type": "routing_decision", "provider": string, "model": string, "matched_rule"?: string, "fallback_used": bool, "override_used": bool, "reason": string }
context_selection{ "type": "context_selection", "path"?: string, "kind": string, "included": bool, "reason": string }
tool_call{ "type": "tool_call", "tool_name": string, "status": string, "arguments"?: string, "result"?: string, "error"?: string }
approval{ "type": "approval", "target_type": string, "target_id": string, "decision": string, "reason"?: string }
cost{ "type": "cost", "provider": string, "model": string, "input_tokens": int, "output_tokens": int, "cost"?: string }

On read, each event row (metadata + this payload) is mapped to one TraceEvent in the assembled smista-core Trace. The event’s payload is a TraceEventPayload: plaintext carries the parsed, tagged payload above for a normal session, while encrypted carries the sealed envelope for an end-to-end encrypted session, which only the client can open.

user_memory

A long-term, model-populated preference owned by a user. Untouched by session deletion; subject to retention. The fact lives in user_memory_content.

FieldTypeDescription
idUUIDv7Unique memory id (record id).
useruser referenceOwner of the memory.
keystring, optionTopic; lets an update target a fact.
created_atdatetimeWhen the fact was first recorded.
updated_atdatetimeWhen the fact was last changed.

user_memory_content

Paired 1:1 with user_memory (same record id).

FieldTypeDescription
idUUIDv7Record id, same as the metadata.
contentstringThe remembered fact.

This is the one content payload that is not yet encryptable: user_memory is user-scoped, so the per-session encrypted flag cannot reach it. It keeps a plain string until a user-level scheme is designed.

context_memory

A per-session, model-populated memory owned by a session and a user. Cascades on session delete (no SurrealDB FK cascade — delete_session removes context_memory WHERE session = $session and its _content row). The fact lives in context_memory_content.

FieldTypeDescription
idUUIDv7Unique memory id (record id).
sessionsession referenceSession the memory belongs to.
useruser referenceOwner, enforced on every query.
keystring, optionTopic; lets an update target a fact.
created_atdatetimeWhen the fact was first recorded.
updated_atdatetimeWhen the fact was last changed.

context_memory_content

Paired 1:1 with context_memory (same record id).

FieldTypeDescription
idUUIDv7Record id, same as the metadata.
contentSecretContentThe remembered fact.

Indexes

Indexes are declared on the metadata tables only:

  • Owner loaduser_memory by user; context_memory by session. Used to load all memory for an owner before a task runs.
  • Unique upsert-by-key — unique index on (user, key) for user_memory and on (session, key) for context_memory, applied to keyed rows only. Keyless rows (key is null) stay unconstrained, so multiple keyless facts can coexist while a keyed fact can be upserted in place.
  • One run state per session — unique index on session for session_run_state, enforcing the single in-flight run a session may have.

Cascade and retention

  • Deleting a session cascades to all session-scoped rows and their _content pairs, including context_memory and session_run_state. The cascade is explicit in delete_session, not enforced by SurrealDB. The retention purges clear the same rows for the sessions they remove.
  • user_memory survives session deletion and falls under user retention settings.
  • Expired and revoked auth_token rows are cleaned up over time.

Cascade/cleanup mechanics and retention policy are implemented in #10; this schema defines the substrate they operate on.

End-to-end encryption

End-to-end encryption keeps the content of your sessions unreadable at rest. The router stores your messages, plans, tool payloads, diffs, trace events and per-session memory as ciphertext, and it never holds a key to open them. Only your machine can read them back.

What it protects

Anyone who reaches the stored data sees ciphertext, not your content. That includes a stolen laptop, a database backup, or a hosted (SaaS) router whose disk you do not control. The encryption key never leaves your machine, so the stored data is useless without it.

What it does not hide

The router still sees your content while a task runs, because it is the part that calls the model for you. Encryption protects what is stored, not what is processed live. In short: nothing readable is kept on disk, and the router never holds a key. It is not a promise that the router never sees your text.

Some information always stays readable, because the router routes and recalls on it: the message role, the provider and model, timestamps, and which session and user a row belongs to. Only the free-form body of each row is encrypted.

Turning it on

Encryption is chosen per session, when the session is created. Ask for it on POST /api/v1/sessions:

{ "title": "Refactor auth middleware", "key_id": "kf_ab12" }

A session is encrypted when, and only when, key_id is present. The key id is the fingerprint of the key your client generated for this session. The choice is fixed for the life of the session and cannot be turned on or off later, because content already stored could no longer be matched to a key.

What gets encrypted

Every content row of an encrypted session is sealed:

  • Message bodies
  • Plan snapshots
  • Tool-call arguments, results and errors
  • Diff bodies
  • Trace event payloads
  • Per-session memory facts

Long-term, user-wide memory (user_memory) is not covered yet. It belongs to your account rather than to a single session, so the per-session switch does not reach it. It stays readable until a separate, account-level option is added.

Each sealed value is stored as an envelope that names the algorithm and the key that sealed it, plus a one-time value and the ciphertext. The envelope is opaque to the router; only your client can open it.

One key per session

Each encrypted session has its own key, generated on your machine when the session is created. The key never travels to the router; only its fingerprint (key_id) does.

Giving every session its own key keeps the damage from a leak small: if one key is exposed, only that single session can be read, and every other session stays sealed.

The client stores each key as a file under ~/.smista/e2e/, readable and writable only by you (0600), in a directory only you can enter (0700).

How content travels during a task

The router never has a key, so your client does the encrypting and decrypting as a task runs:

  • New content you send (your prompt) travels in clear so the model can be called, together with its sealed form for storage. The router keeps only the sealed form. Tool results work the same way: your client seals them itself and sends both forms together.
  • To build context from earlier messages, the router picks which past rows it needs and asks your client to decrypt just those. This is a standalone step: the router sends a map of sealed records and your client returns the readable text before the model is called.
  • Content the router produces during the task (the model’s reply, tool-call arguments, plans, trace events, an interrupted partial) is handed back to your client to seal before it is stored. This rides the same response that delivers the result, so you see the output immediately and only its stored copy waits to be sealed.

Each record in these maps is named by a reference of the form kind:id, so the router dispatches every payload to the right content store. This keeps routing and context selection on the router while the key stays on your machine. See the run state machine for exactly where each step fits in a turn.

Status

The storage layer described here, the per-session switch, and the envelope format are in place. The encrypt and decrypt steps during a task, and the client-side key files under ~/.smista/e2e/, are being built next and are tracked separately. Until they land, create sessions without encrypted set.