smista.ai
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
smistacommand-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
- Read the Get Started guide.
- Understand the Architecture.
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:
| File | Controls |
|---|---|
.smista/config.toml | Providers, routing, privacy, tools, and CLI-to-router settings. |
router.toml | The 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 to choose models and define project policy.
- Configure the router to change how the local service runs.
- CLI Commands for the full command reference.
Configure Routing and the CLI
- Configure Routing and the CLI
- Know which file to edit
- Create and inspect configuration
- Where configuration lives
- Configuration precedence
- Configure a remote provider
- Built-in default configuration
- How a request becomes a route
- Task intents
- Routing rules
- Privacy
- Tool permissions
- Providers and models
- Provider credentials
- Connecting to the router
- Local preferences
- Validation
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:
| File | Purpose |
|---|---|
config.toml | Providers, routing policy, privacy, tools, and the CLI connection. |
router.toml | Router 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]inconfig.tomltells the CLI where to find the router.[router]inrouter.tomlconfigures 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
| Layer | Location | Scope |
|---|---|---|
| Global (Linux/macOS) | ~/.config/smista/config.toml | All projects |
| Global (Windows) | %USERPROFILE%\.smista\config.toml | All projects |
| Project | .smista/config.toml | The 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:
- Built-in defaults
- Global configuration
- Project configuration
- 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
intent | string | required | Intent assigned when the rule matches. |
priority | integer | 1000 | Lower value wins. |
keywords | list of strings | [] | Rule matches when any keyword appears in the prompt. |
requires_any_context | list of strings | [] | Rule matches when any named context is present (git_diff…). |
The [classification] table itself accepts:
| Key | Type | Default | Purpose |
|---|---|---|---|
default_intent | string | chat | Intent 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.
| Key | Type | Purpose |
|---|---|---|
name | string | Human-readable rule name (required). |
priority | integer | Lower value wins; defaults to 1000. |
effort | string | Reasoning effort for the matched model; defaults to medium. |
intent | string | Match only this task intent. |
paths | list of globs | Match when a relevant path matches any glob. |
local_only | bool | Pin the route to local models; an ollama/ model resolves to the local instance, never Ollama Cloud. |
requires_capabilities | table | Capability gate; the model must satisfy each true flag. |
model | string | Model selected on match, as provider/model (required). |
fallbacks | list of strings | Models tried in order when the selected model is unavailable. |
required_permissions | table | Tool permissions the route requires (see below). |
cost_limit | string | Per-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 OR —
paths = ["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:
lowmediumhighxhigh
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:
- Explicit model override
- Lower
priorityvalue (1 is higher priority than 10) - 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
restricted_paths | list of globs | [] | Paths treated as sensitive for every model class. |
The [privacy.remote] table controls disclosure to remote providers:
| Key | Type | Default | Purpose |
|---|---|---|---|
mode | string | ask | allow, ask, or deny for remote disclosure. |
blocked_paths | list of globs | [] | Paths that must never be sent to remote providers. |
The [privacy.local] table controls disclosure to local models:
| Key | Type | Default | Purpose |
|---|---|---|---|
mode | string | allow | allow, 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:
| Mode | Effect |
|---|---|
allow | The tool runs without confirmation. |
ask | The user is prompted before the tool runs. |
deny | The tool is blocked. |
The conventional tool keys are:
| Tool | Governs |
|---|---|
file_read | Reading files from the workspace. |
file_write | Writing or modifying files. |
shell | Running shell commands. |
network | Outbound network access. |
git | Git 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:
| Identifier | Backend |
|---|---|
anthropic | Anthropic, serving the Claude models. |
gemini | Google Gemini, serving the Gemini models. |
openai | OpenAI, serving the GPT models. |
ollama | Ollama, 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
type | string | none | Provider kind. Optional and redundant with the table key; omit it for openai-compat:<name> instances. |
api_key | string | none | Optional ${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_capabilitiesis 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 enforceslocal_only— a rule’slocal_only = trueor thelocal_onlylocal 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. Setlocal_onlywhenever 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
url | string | none | Router base URL, e.g. http://127.0.0.1:7331. |
auto_start | bool | false | Start a local router when none is reachable. |
connect_timeout_ms | integer | none | Connection timeout in milliseconds. |
request_timeout_ms | integer | none | Request timeout in milliseconds. |
auth_source | string | keychain | Where 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
| Field | Effect |
|---|---|
auto_apply | Apply file writes without prompting for each diff. |
local_only | Use only local models this session; pins ollama/ to the local instance, never Ollama Cloud. |
no_network | Forbid network access for this session. |
encrypt_sessions | Create 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_onlyorno_networkhere adds a restriction, but a preference can never weaken a project’s privacy modes or a tool set todeny.
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
| System | Default 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
host | string | 127.0.0.1 | Bind host. |
port | integer | 7331 | Bind 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
engine | string | surrealdb | Storage engine; only surrealdb is supported. |
mode | string | embedded | embedded (on-disk) or remote (server). |
path | string | none | Database file path, used in embedded mode. |
url | string | none | Database server URL, used in remote mode. |
username | string | none | Authentication username, used in remote mode. |
password | string | none | Authentication password, used in remote mode. |
namespace | string | smista | SurrealDB namespace. |
database | string | local | SurrealDB database name. |
Note
usernameandpasswordauthenticate against a remote SurrealDB server and are only used inremotemode. 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
token_ttl_seconds | integer | 86400 | Session token lifetime, in seconds. |
api_key_version | string | "01" | API key version segment. |
local_bootstrap_enabled | bool | true | Allow 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
max_request_body_bytes | integer | 10485760 | Maximum request body size, in bytes. |
max_context_bytes | integer | 5242880 | Maximum context size, in bytes. |
max_concurrent_requests | integer | 8 | Maximum concurrent requests. |
request_timeout_ms | integer | 120000 | Overall request timeout, in milliseconds. |
provider_timeout_ms | integer | 180000 | Provider call timeout, in milliseconds. |
tool_timeout_ms | integer | 60000 | Tool 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
enabled | bool | true | Whether rate limiting is enabled. |
period_ms | integer | 10 | Refill period, in milliseconds: the allowance grows by one request every period_ms. |
burst_size | integer | 200 | Maximum number of requests a client may send in a burst before being limited. |
trust_proxy_headers | bool | false | Identify 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_headerswhen 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
level | string | info | Log level filter (e.g. info, debug). |
format | string | compact | Log output format. |
redact_secrets | bool | true | Redact 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
enabled | bool | false | Whether trace export is enabled. |
endpoint | string | http://localhost:4317 | Collector endpoint to export to. |
protocol | string | grpc | Wire protocol: grpc (port 4317) or http-binary (port 4318). |
service_name | string | smista-router | Service name reported on every trace. |
sample_ratio | float | 1.0 | Fraction 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
enabled | bool | false | Whether CORS is enabled. |
allowed_origins | list 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
trace_retention_days | integer | 90 | Days to retain traces. |
session_retention_days | integer | 365 | Days to retain sessions. |
archived_session_retention_days | integer | 30 | Days to retain archived sessions before purge. |
cleanup_interval_seconds | integer | 3600 | Interval 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
base_url | string | provider | Endpoint 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). |
local | bool | false | Whether the provider runs locally; surfaced to consumers to tell local providers from hosted ones. |
display_name | string | none | Human-readable name for the provider; consumers fall back to the provider identifier when omitted. |
models | list 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:
| Key | Type | Default | Purpose |
|---|---|---|---|
name | string | required | Model name, exactly as the endpoint expects it. |
max_context_tokens | integer | required | Maximum context window the model accepts, in tokens. |
display_name | string | none | Human-readable name; consumers fall back to name when omitted. |
auth | string | none | How the model authenticates: none, api_key, or optional_api_key. |
max_output_tokens | integer | none | Maximum tokens the model emits, if bounded. |
capabilities | table | OpenAI-like | What the model can do; see below. Defaults suit an OpenAI-compatible endpoint. |
input_cost_per_million_tokens | string | none | Input price per million tokens, as a decimal string for exact precision. |
output_cost_per_million_tokens | string | none | Output 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
modelstables. Only genericopenai-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:
| Key | Type | Default | Purpose |
|---|---|---|---|
enabled | bool | false | Whether the Ollama backend is active. |
base_url | string | http://127.0.0.1:11434 | Ollama 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:
router.tomltells the router how to connect to Ollama.config.tomlenables 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.
| Check | Severity | How to fix |
|---|---|---|
Unknown provider — a routing rule, fallback, or default route references a provider identifier that is not enabled in [providers] | Error | Enable 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 compile | Error | Fix the glob syntax (e.g. close unclosed brackets) |
Duplicate rule name — two [[routing.rules]] entries share the same name | Error | Give each rule a unique name |
Missing default route — no [routing.default] table is present | Error | Add [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 once | Error | Remove 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 undefined | Error | Give 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 floor | Error | Do 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 floor | Error | Keep 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} reference | Error | Remove 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
modela rule selects, or any entry in itsfallbacks, 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.
| Check | Severity | How to fix |
|---|---|---|
Invalid port — router.port is 0 | Error | Set a port in the range 1–65535 |
Invalid host — router.host is empty, contains whitespace, includes a port, or is not a valid hostname or IP address | Error | Set a bare hostname or IP address (e.g. 127.0.0.1) |
Public bind in embedded mode — router.host is 0.0.0.0 or :: while router.storage.mode = "embedded" | Warning | Bind to 127.0.0.1 unless you intentionally want to expose the local router to the network |
Missing storage path — router.storage.mode = "embedded" but router.storage.path is not set | Error | Set router.storage.path to a writable directory (e.g. .smista/db) |
Missing storage URL — router.storage.mode = "remote" but router.storage.url is not set | Error | Set router.storage.url to the SurrealDB WebSocket address (e.g. ws://db:8000) |
Local bootstrap in remote mode — router.auth.local_bootstrap_enabled = true while router.storage.mode = "remote" | Error | Set 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 0 | Error | Set a positive value in milliseconds |
Zero size limit — router.limits.max_request_body_bytes or router.limits.max_context_bytes is 0 | Error | Set a positive byte limit |
Invalid rate limit — rate limiting is enabled but period_ms or burst_size is 0 | Error | Set both values above zero, or disable rate limiting |
| Excessive timeout — a timeout exceeds 3,600,000 ms (1 hour) | Warning | Check 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 * | Error | List 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.0–1.0 | Error | Set a collector endpoint, service name, and valid sample ratio |
Ignored provider URL — base_url is set for built-in OpenAI, Anthropic, or Gemini | Warning | Remove 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
| Command | What it does |
|---|---|
smista | Start 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 login | Bootstrap a router user and store its API key. |
smista start | Start the local router. Daemonizes by default. |
smista status | Check whether the router is reachable and report its version. |
smista stop | Stop the local router recorded in the pidfile. |
smista --version | Print the CLI version. |
smista --help | Show 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.
| Flag | Environment variable | What it does |
|---|---|---|
-L, --log-file <path> | SMISTA_ROUTER_LOG_FILE | Write logs to a file instead of stdout. |
-l, --log-filter <f> | SMISTA_ROUTER_LOG_FILTER | Set 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
| Command | Aliases | What it does |
|---|---|---|
smista apikey check | get | Print whether a router key is set. |
smista apikey new | generate | Generate a new router API key and print to stdout. |
smista apikey remove | delete, rm | Remove the router key from scope. |
smista apikey set <api-key> | add | Store 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
| Command | Aliases | What it does |
|---|---|---|
smista credentials set <provider> <api-key> | add | Store or replace a provider API key. |
smista credentials check <provider> | get | Print whether a provider API key is available. |
smista credentials remove <provider> | delete, rm | Remove a provider API key from the scope. |
The <provider> value must be one of:
| Provider form | Use it for |
|---|---|
anthropic | Anthropic Claude models. |
gemini | Google Gemini models. |
openai | OpenAI GPT models. |
ollama | Ollama 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:
| Command | What it creates |
|---|---|
smista config init | Project CLI config at .smista/config.toml. |
smista config init project | Project CLI config at .smista/config.toml. |
smista config init global | Global CLI config for every project. |
smista config init router | Router 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:
| Command | What it prints |
|---|---|
smista config show | Effective merged CLI configuration. |
smista config show project | The project CLI configuration layer. |
smista config show global | The global CLI configuration layer. |
smista config show router | The 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:
| Command | What it prints |
|---|---|
smista config path | Router, global, and project paths with exist status. |
smista config path project | Project CLI configuration path only. |
smista config path global | Global CLI configuration path only. |
smista config path router | Router 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.
/chatleaves plan mode and enters chat mode, which is the default mode for interactive sesssions./clearclears the terminal and ends the current session. When a session is active, it prints final token usage when available and a/resumecommand before the next prompt. The next message starts a new session./modellists 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, orprovider/modelreference. Use/model autoto 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@pathcompletion and attachment behavior as normal prompts./planleaves chat mode and enters plan mode/providerslists configured providers and whether each one is local or remote./quit,/q, or/exitcloses 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./skillslists available skills from the current workspace./statusqueries the router’s/statusendpoint 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
DownorUpto cycle through matching files and directories. - Press
TaborRightto accept the selected path. Accepting a directory keeps its trailing path separator so you can continue completing its contents. - Press
Escapeto 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.
| Warning | Meaning | How to fix |
|---|---|---|
Missing SKILL.md | A directory under a skills folder has no SKILL.md | Add a SKILL.md, or remove the directory |
| Missing description | The SKILL.md front matter has no description | Add a description line to the front matter |
| Name mismatch | The front matter name differs from the directory name | Rename the directory or the name so they match |
| Invalid front matter | The --- block is missing or is not valid YAML | Add a valid --- front matter block with name/description |
HTTP API
- 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/sdktyped 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. GETreads,POSTcreates or executes,PUTreplaces,DELETEremoves.- 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.
| Header | Used 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:
| Field | Purpose |
|---|---|
input | The prompt text, an optional command and an optional explicit_model. |
workspace | Repository snapshot: root, git_branch, git_diff, referenced/active files. |
policy | The deterministic classification, routing, tools and privacy policy (see below). |
local_preferences | Resolved client toggles: auto_apply, local_only, no_network. |
attachments | Local 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:
| Field | Type | Purpose |
|---|---|---|
name | string | Human-readable rule name. |
priority | integer | Evaluation order, ascending; first match wins. Defaults to 1000. |
effort | string | Reasoning effort for the matched model (low/medium/high/xhigh). |
intent | task type, null | Required task intent, if scoped. |
paths | list of strings | Path globs; a relevant path must match one when non-empty. |
local_only | bool | Restrict the fallback chain to local models. |
requires_capabilities | object | Capability gate the matched model must satisfy; omitted if none. |
model | reference | Model selected when the rule matches. |
fallbacks | list of refs | Models tried, in order, when model is unavailable. |
required_permissions | object | Tool permissions the matched route requires. |
cost_limit | string, omitted | Per-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:
status | The router needs the client to |
|---|---|
completed | render; seal to_encrypt if present, else done. |
awaiting_tool | run one or more tools and return the results. |
awaiting_approval | decide a yes/no with no tool to run. |
awaiting_decrypt | open sealed history so the prompt can be built. |
awaiting_encrypt | seal router-authored content before it is persisted. |
idle | nothing; the run finished and was persisted. |
error | nothing; 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.
type | Answers | data |
|---|---|---|
tool_results | awaiting_tool | { results: [{ call_id, content, is_error, decision }], encrypted } |
approval_decisions | awaiting_approval | { decisions: [{ approval_id, decision, reason }], encrypted } |
decrypted | awaiting_decrypt | { plaintext } — a content-ref → plaintext map |
sealed | a folded encrypt | { encrypted } — a content-ref → envelope map |
inject | any live state | { messages: [{ text, ciphertext }] } — mid-run input; supersedes |
break | any live state | none — 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 0–0 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:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Maximum number of events to return. |
offset | integer | 0 | Number 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
| Code | Meaning |
|---|---|
| 200 | Successful read or completed command |
| 201 | Resource created |
| 202 | Accepted — long-running or pending operation |
| 204 | Deleted, no body |
| 400 | Invalid request payload |
| 401 | Missing or invalid authentication |
| 403 | Authenticated but blocked by ownership or policy |
| 404 | Resource not found |
| 409 | Conflicting resource state |
| 422 | Valid JSON that fails domain validation |
| 429 | Rate limited |
| 500 | Unexpected server error |
| 501 | Endpoint recognized but not implemented yet |
| 502 | Provider error |
| 503 | Provider or storage unavailable |
| 504 | Provider 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.
| Code | Status | Meaning |
|---|---|---|
context_length_exceeded | 422 | Request exceeds the provider model’s context window. |
context_window_exceeded | 422 | Routing rejected a model whose context window cannot fit the input. |
credentials_in_query | 400 | A credential was passed as a query parameter; credentials are accepted only in headers. |
fallback_exhausted | 503 | Primary route failed and every configured fallback also failed. |
forbidden | 403 | Caller is authenticated but not the resource owner. |
internal_error | 500 | Unexpected server-side failure. Details intentionally omitted. |
invalid_api_key | 401 | The 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_reference | 422 | A model reference was not in the expected provider/model form. |
invalid_provider_configuration | 500 | A provider was configured with contradictory settings, such as an OpenAI-compatible instance whose declared locality disagrees with one of its models. |
invalid_provider_credentials | 503 | Provider rejected the configured credentials. |
invalid_provider_name | 422 | A provider identifier in a model or routing reference was not in the expected form. |
invalid_request | 422 | Provider rejected the request body as malformed. |
invalid_session_id | 400 | A session id in the path was not a valid UUID. |
invalid_token | 401 | Session token is malformed or unknown. |
missing_capability | 422 | Selected model lacks a capability the task requires. |
missing_credentials | 401 | No credential was presented: a session token on a protected endpoint, or the X-Smista-Api-Key header on POST /auth/sign-in. |
missing_provider_credentials | 503 | The selected model requires provider credentials none were configured. |
model_not_found | 404 | The referenced model is not offered by the provider asked to resolve it. |
no_route | 422 | No routing rule matched and no default route is configured. |
not_implemented | 501 | The endpoint is recognized but not implemented yet. |
override_not_allowed | 403 | Caller asked for a model override that policy forbids. |
permission_expansion | 422 | An override tried to loosen a tool permission that may only be tightened. |
provider_authentication | 503 | Provider rejected the request at the authentication layer. |
provider_error | 502 | Provider returned an error that did not match any known category. |
provider_unavailable | 503 | Provider returned a service-level error and may recover later. |
provider_unsupported_capability | 422 | Provider reported it does not support a capability the request needed. |
rate_limited | 429 | Provider rate-limited the request. |
request_timeout | 504 | Call to the provider timed out before a response was returned. |
routing_unsupported_capability | 422 | Routing rejected the selected model because it lacks a required capability. |
run_in_flight | 409 | A turn is already in flight for the session; the run is busy until it reaches a checkpoint. |
session_not_found | 404 | The session does not exist, is archived, or belongs to another user; the three are reported alike so existence stays private. |
storage_error | 502 | An error occurred while reading or writing from memory storage. |
token_expired | 401 | Session token is past its expiry timestamp. |
token_revoked | 401 | Session token was previously valid but has been revoked. |
unknown_effort | 422 | A reasoning effort name in the request was not recognized. |
unknown_intent | 422 | A task intent name in the request was not recognized. |
unknown_model | 422 | A referenced model is not configured on the router. |
unknown_provider | 422 | A 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
| Component | Kind | Responsibility |
|---|---|---|
smista-cli | CLI binary | User interaction, command parsing, rendering, approvals, runs and talks to router. |
smista-router | Library | Auth, sessions, classification, routing, context, tool mediation, traces; embedded and run by the CLI. |
smista-router-client | Library | Async Rust client for the router HTTP API; used by the CLI and Rust frontends. |
smista-core | Library | Shared domain types, config, policy, trace structures, validation, errors. |
smista-providers | Library | Model abstraction and provider adapters (OpenAI, Anthropic, Ollama, …). |
smista-storage | Library | Storage traits, entities (incl. trace events) and the SurrealDB layer. |
@smista-ai/sdk | TypeScript SDK | Typed 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
- 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.
| Party | Owns |
|---|---|
| Router | Classification, policy evaluation, model selection, context selection, tool mediation, persistence (history, memory, trace). |
| Client | The 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
localmodel 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:
| Field | Contents |
|---|---|
input | The prompt text, an optional command (forces the intent) and an optional explicit_model. |
workspace | Repository snapshot: root, git_branch, git_diff, referenced paths, active file. |
attachments.files | Explicit @path files with content and a content_hash, each flagged required or discardable. |
attachments.instructions | Instruction documents the client read from disk (for example AGENTS.md). |
attachments.invoked_skills | Skills the user explicitly invoked, each name + content (the SKILL.md body). |
attachments.available_skills | Skills offered for the model to activate, same name + content shape. |
policy | The deterministic routing, tools and privacy policy, sent verbatim. |
local_preferences | Resolved client toggles: auto_apply, local_only, no_network. |
providers | Providers 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:
- 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.
- 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.
- 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.
- Finalize context — trim to the chosen model’s window and decrypt sealed history if the session is encrypted.
- Invoke the model, buffered or streamed.
- 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:
status | Meaning | Client’s next move |
|---|---|---|
completed | The model finished; an assistant message is included. | Render; seal to_encrypt if set, else done. |
awaiting_tool | The model requested one or more client-executed tools. | Run them; advance with the results. |
awaiting_approval | The router needs a yes/no with no tool to run. | Ask the user; advance with the decision. |
awaiting_decrypt | The router needs sealed history opened to build the prompt. | Decrypt; advance with the plaintext. |
awaiting_encrypt | The router needs its own output sealed before it can persist it. | Seal; advance with the ciphertext. |
idle | The run finished and its content was persisted. | Nothing to render; the run is over. |
error | A 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:
| Mode | What the router does |
|---|---|
deny | Synthesizes a “denied by policy” tool result and feeds it back to the model. No client trip. |
allow | Returns the call to the client to execute. |
ask | Returns 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_decryptsends ato_decryptmap; 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 ato_encryptmap alongsideto_decrypt, and thedecryptedcontinuation 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_encryptmap 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 dedicatedsealedcontinuation;awaiting_encryptis 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.
type | Answers | data |
|---|---|---|
tool_results | awaiting_tool | { results: [{ call_id, content, is_error, decision }], encrypted } |
approval_decisions | awaiting_approval | { decisions: [{ approval_id, decision, reason }], encrypted } |
decrypted | awaiting_decrypt | { plaintext, encrypted } — opened plaintext plus any sealed rows |
sealed | a folded encrypt | { encrypted } — a content-ref → envelope map |
inject | any live state | { messages: [{ text, ciphertext }] } — mid-run input; supersedes |
break | any live state | none — 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:
type | When |
|---|---|
text_delta | A chunk of generated text. |
reasoning_delta | A chunk of reasoning, for models that stream it. |
tool_call_started | A tool call’s name is known; arguments are still streaming. |
tool_call_requested | A tool call is complete, correlated by call_id. |
usage | Token counts and, when the model prices them, the cost. |
turn_end | The 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:
| Item | Behaviour |
|---|---|
| Required, constrained | Remote is foreclosed for the turn. Local-only always wins — over rule precedence and over explicit_model. |
| Discardable, constrained | Excluded from the prompt when the route is remote. Nothing required is lost. |
| Unconstrained | Sent 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_modeloverride 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:
| Code | When |
|---|---|
no_route | No rule matched and no default route — or no eligible local model for constrained content. |
override_not_allowed | An explicit_model override was refused, including a remote override on constrained content. |
fallback_exhausted | The selected model and every fallback failed. |
context_window_exceeded | The minimum required context cannot fit the selected model’s window. |
missing_capability | The 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 (Idleor one of theAwaiting*states below). This is the only thing that says where the run is.active— the processing lock. It is present (anActiveTurncarrying alease) only while a turn is actively being served, and absent otherwise. Its presence is what “running” means; it is orthogonal tophase.
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:
| Incoming | active | What the router does |
|---|---|---|
break | any | Always accepted. Abort the in-flight turn (supersede). |
inject | any | Always accepted. Supersede and continue with the input. |
| any other continuation | present (live) | Rejected — the run is busy. |
| any other continuation | absent | Accepted: acquire the lock and process. |
execute (start a run) | present (live) | Rejected — the run is busy. |
execute | absent / no row | Accepted: 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:
| Pause | Waits for | resume | On answer |
|---|---|---|---|
AwaitingDecrypt | the client opening recalled ciphertext | BuildPrompt | finish building the prompt with the plaintext, then invoke |
AwaitingApproval (disclose) | the user’s yes/no on a remote disclosure | Invoke | yes → invoke; no → re-route locally or fail |
AwaitingApproval (cost) | the user’s yes/no on a cost ceiling | Invoke | yes → invoke; no → abort to idle |
AwaitingApproval (plan) | the user accepting or rejecting a plan | NextTurn | accept → next turn; reject → re-plan or idle |
AwaitingTool | the client running the requested tools | NextTurn | record the results, re-classify, start the next turn |
AwaitingEncrypt | the client sealing router-authored output | Finalize | write 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:
| Mode | What the router does |
|---|---|
deny | Synthesizes a denial result and feeds it back to the model. No client pause. |
allow | Returns the call to the client to run. |
ask | Returns 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):
status | Meaning | The client’s next move |
|---|---|---|
completed | The model finished; an assistant message is included. | Render; if to_encrypt is set, seal it. |
awaiting_tool | The model requested one or more client-run tools. | Run them; continue with the results. |
awaiting_approval | A yes/no decision with no tool to run. | Ask the user; continue with the decision. |
awaiting_decrypt | Sealed history must be opened to build the prompt. | Decrypt; seal any to_encrypt; continue. |
awaiting_encrypt | Router-authored output must be sealed before it is persisted. | Seal; continue with the ciphertext. |
idle | The run finished and its content was persisted. | Nothing to render; the run is over. |
error | A 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:
type | Answers | Carries |
|---|---|---|
tool_results | awaiting_tool | the results (approval folded in) plus any sealed router content |
approval_decisions | awaiting_approval | the decisions plus any sealed router content |
decrypted | awaiting_decrypt | a content-ref → plaintext map plus any sealed router content |
sealed | a folded encrypt | a content-ref → ciphertext map |
inject | any live state | mid-run user input; supersedes the in-flight turn |
break | any live state | nothing; 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:
| Event | Recorded when |
|---|---|
Classification | after classifying the step |
RoutingDecision | after matching a rule and selecting the model |
ContextSelection | after building and finalizing the context |
ToolCall | on requesting a call, and again on recording its result |
Approval | on each decision — a folded tool decision or a standalone one |
Message | on persisting a user or assistant message |
Cost | on 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
TaskIntent — chat, 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.
| Intent | Meaning |
|---|---|
chat | Free-form conversation with no specialized objective. |
plan | Producing a plan or breaking a task into steps. |
edit | Modifying existing code or text. |
review | Assessing code or text and giving feedback. |
summarize | Condensing longer content into a shorter form. |
prompt | Crafting or refining a prompt for another model. |
skill | Invoking 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_difforpull_request, matched by a rule’srequires_any_context. - The classification config: the ordered
rulesand thedefault_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 high — not a probability. It is derived from how strong the winning
match was, so the same inputs always produce the same label:
| Outcome | confidence |
|---|---|
| Explicit command | omitted |
A rule matched on both keywords and requires_any_context | high |
| A rule matched on a single condition kind | medium |
A conditionless catch-all rule matched, or default_intent used | low |
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 length | Maximum OSA distance |
|---|---|
| < 5 | 0 (exact only) |
| 5–8 | 1 |
| >= 9 | 2 |
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:
| Field | Type | Meaning |
|---|---|---|
intent | TaskIntent | The detected intent. |
source | explicit | inferred | Whether the user named the intent or the router inferred it. |
reason | string | Human-readable explanation, e.g. keyword 'review' matched rule 0. |
matched_rule | integer, optional | Index into ClassificationConfig.rules; absent when none matched. |
confidence | low | medium | high, optional | Signal 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.commandcarries an explicitTaskIntent;nullasks the router to infer.input.explicit_modelis unrelated — it bypasses routing, not classification. - Config. The classification rules travel with the request: the policy block
carries a
classificationblock (ClassificationConfig) besiderouting,toolsandprivacy, 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/previewresponses, beside the routedtask_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"] }
]
}
| Request | Result |
|---|---|
command: "edit", any prompt | edit, source: explicit, no confidence. |
“review my changes”, git_diff available | review, source: inferred, matched_rule: 0, confidence: high. |
“review this idea”, no git_diff | chat, source: inferred, matched_rule: None, confidence: low (the rule needs a context kind that is absent). |
| “what does this function do?”, no rules match | chat, 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
keywordsandrequires_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:
| Field | Built from |
|---|---|
intent | The classified TaskIntent (or an explicit input.command). |
paths | The 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:
| Condition | Matches when |
|---|---|
intent | it equals the context intent. |
paths | any 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:
- Explicit override. When
input.explicit_modelis 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. - Priority. Otherwise rules are evaluated in ascending
priority(lower first; default1000). - Specificity. Within equal priority, the more specific rule wins. The
ladder, most specific first:
path+intent>path>intent> none. - 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
Capabilitymust be supported, or it ismissing_capability/routing_unsupported_capability. - The estimated input must fit
max_context_tokens, or it iscontext_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 thepathsof anylocal_onlyrule — onlylocalmodels are eligible. This dominates rule precedence and anexplicit_modeloverride (a remote override on constrained content is refused withoverride_not_allowed). - A
local_onlyrule 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:
| Field | Effect |
|---|---|
effort | The reasoning Effort (low/medium/high/xhigh, default medium) passed to the model. |
required_permissions | A 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_limit | An 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:
| Field | Meaning |
|---|---|
task_type | The intent that drove routing. |
provider | The selected provider. |
model | The selected model. |
matched_rule | A human-readable description of the rule that matched, if any. |
fallback_used | Whether a fallback model served the turn. |
override_used | Whether 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"]
}
]
}
| Context | Outcome |
|---|---|
intent: edit, path src/auth/middleware.rs | matches the rule → anthropic/claude-sonnet, effort: high. |
| Same, but Anthropic has no credential | fallback to openai/gpt-5.5-thinking, fallback_used: true. |
intent: chat, no auth path | no rule matches → default route openai/gpt-5.5-mini. |
intent: edit, src/auth/** is restricted-for-remote and required | both 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_tokenschecked against the window comes from the router’s deterministic estimator over the assembled context. - Cost estimation — the source of the estimate compared against
cost_limitand 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:
| Source | Comes from |
|---|---|
| Session history | session_message rows recalled from storage, each tagged with the provider and model that produced it. |
| Memory | user-wide user_memory and per-session context_memory facts. |
| Client attachments | the request’s attachments: files (with content and a required flag), instructions and skills. |
| Workspace metadata | the 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:
| Credential | Algorithm | Why |
|---|---|---|
| API key | Argon2id | Verified rarely (only at sign-in), so a deliberately expensive hash is fine and resists offline cracking. |
| Session token | SHA-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
- You call the sign-in endpoint with your API key in the
X-Smista-Api-Keyheader. No body and no user id are needed, because the key already names the user. - The router parses the user id from the key and loads that user. An unknown user is rejected.
- The router verifies the presented key against the user’s stored Argon2id hash. A mismatch is rejected.
- 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:
- Parses the token id from the presented token.
- 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.
- Verifies the presented token against the row’s stored SHA-512 crypt hash.
- 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]:
| Setting | Default | Meaning |
|---|---|---|
token_ttl_seconds | 86400 | How long an issued session token stays valid, in seconds (one day). |
local_bootstrap_enabled | true | Whether 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
| Kind | Belongs to | Lives |
|---|---|---|
| User memory | you (the user) | across all of your sessions |
| Context memory | a single session | until 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
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique identifier. |
user | user reference | Owner of the memory. |
key | string, optional | Topic; lets an update target a fact. |
content | string | The remembered fact. |
created_at | datetime | When the fact was first recorded. |
updated_at | datetime | When the fact was last changed. |
Context memory
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique identifier. |
session | session reference | Session the memory belongs to. |
user | user reference | Owner, enforced on every query. |
key | string, optional | Topic; lets an update target a fact. |
content | string | The remembered fact. |
created_at | datetime | When the fact was first recorded. |
updated_at | datetime | When 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
- Database schema
- Design invariants
- Entity relations
- Metadata and content split
- Tables
- user
- auth_token
- session
- session_message
- session_message_content
- session_routing_decision
- session_context_reference
- session_tool_call
- session_tool_call_content
- session_approval
- session_run_state
- session_run_input
- session_run_input_content
- session_plan
- session_plan_content
- session_diff
- session_diff_content
- trace_event
- trace_event_content
- user_memory
- user_memory_content
- context_memory
- context_memory_content
- Indexes
- Cascade and retention
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⟩, aRecordId) is the entity id. There is no redundantidcolumn. 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>_contenttable sharing the same record id. The base table holds queryable metadata; the_contenttable 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
userredundantly 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique user identifier (record id). |
api_key_hash | string | Hash of the user’s API key. |
created_at | datetime | When the user was created. |
updated_at | datetime | When the user was last updated. |
disabled_at | datetime, option | When 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique token identifier (record id). |
user | user reference | Owning user. |
token_hash | string | Hash of the issued token. |
created_at | datetime | When the token was issued. |
expires_at | datetime | When the token expires. |
revoked_at | datetime, option | When 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Globally unique session id (record id). |
user | user reference | Owning user. |
title | string, option | Human-readable session title. |
scope | string, option | Opaque grouping key the session belongs to. |
encrypted | bool | Whether the session’s content is encrypted. |
key_id | string, option | Fingerprint of the per-session key, when encrypted. |
created_at | datetime | When the session was created. |
updated_at | datetime | When the session was last updated. |
archived_at | datetime, option | When 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique message id (record id). |
session | session reference | Session the message belongs to. |
user | user reference | Owner, enforced on every query. |
role | Role enum | Message role (user, assistant, …). |
provider | Provider enum | Provider that produced the message. |
model | string | Model that produced the message. |
created_at | datetime | When the message was recorded. |
session_message_content
Paired 1:1 with session_message (same record id).
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id, same as the metadata. |
content | SecretContent | The message body. |
session_routing_decision
Records which provider/model pair was selected for a task and why. Metadata-only.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique decision id (record id). |
session | session reference | Session the decision belongs to. |
user | user reference | Owner, enforced on every query. |
task_type | TaskIntent enum | Task the decision routed. |
provider | Provider enum | Selected provider. |
model | string | Selected model. |
matched_rule | string, option | Routing rule that matched, if any. |
fallback_used | bool, option | Whether a fallback model was used. |
override_used | bool, option | Whether a manual override was used. |
reason | string | Why this provider/model was chosen. |
created_at | datetime | When 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique reference id (record id). |
session | session reference | Session the reference belongs to. |
user | user reference | Owner, enforced on every query. |
path | string, option | Path of the referenced context. |
kind | string | Kind of context reference. |
included | bool | Whether the context was included. |
reason | string | Why it was included or excluded. |
created_at | datetime | When 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique tool-call id (record id). |
session | session reference | Session the tool call belongs to. |
user | user reference | Owner, enforced on every query. |
tool_name | string | Name of the invoked tool. |
status | enum | Execution status. |
created_at | datetime | When the tool call was requested. |
completed_at | datetime, option | When the tool call completed. |
session_tool_call_content
Paired 1:1 with session_tool_call (same record id).
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id, same as the metadata. |
arguments | SecretContent | Tool-call arguments (sanitised). |
result | SecretContent, option | Tool-call result (sanitised). |
error | SecretContent, option | Error, 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique approval id (record id). |
session | session reference | Session the approval belongs to. |
user | user reference | Owner, enforced on every query. |
target_type | string | Type of operation being approved. |
target_id | string | Id of the operation being approved. |
decision | enum | Approve or reject. |
reason | string, option | Why the decision was made. |
created_at | datetime | When 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id; the owning session’s id. |
session | session reference | Session the run belongs to. |
user | user reference | Owner, enforced on every query. |
run_id | string | Id of the in-flight run. |
turn | int | Count of turns served so far in this run. |
phase | RunPhase enum | The durable checkpoint the run is paused at. |
active | ActiveTurn object | The processing lock; present while a turn is served. |
updated_at | datetime | When 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id; the owning session’s id. |
session | session reference | Session the run belongs to. |
user | user reference | Owner, enforced on every query. |
run_id | string | Id of the run this context belongs to. |
policy | string | The deterministic policy snapshot, as JSON. |
local_preferences | string | The client’s local execution preferences, as JSON. |
workspace | string | The workspace paths and flags (no git diff), as JSON. |
plan_active | bool | Whether the run is in plan mode, across turns. |
created_at | datetime | When 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id, identical to the base row. |
content | SecretContent | The 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique plan id (record id). |
session | session reference | Session the plan belongs to. |
user | user reference | Owner, enforced on every query. |
path | string | Path the plan applies to. |
status | enum | Plan status. |
created_at | datetime | When the plan was created. |
updated_at | datetime | When the plan was last updated. |
approved_at | datetime, option | When the plan was approved. |
content_hash | string, option | Hash of the plan snapshot. |
session_plan_content
Paired 1:1 with session_plan (same record id).
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id, same as the metadata. |
content | SecretContent, option | Snapshot 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique diff id (record id). |
session | session reference | Session the diff belongs to. |
user | user reference | Owner, enforced on every query. |
path | string | Path the diff applies to. |
status | enum | Diff status. |
created_at | datetime | When the diff was created. |
applied_at | datetime, option | When the diff was applied. |
session_diff_content
Paired 1:1 with session_diff (same record id).
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id, same as the metadata. |
content | SecretContent | The 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique event id (record id). |
session | session reference | Session the event belongs to. |
user | user reference | Owner, enforced on every query. |
event_type | enum | Kind of trace event. |
task_type | TaskIntent enum | Task the emitting context served. |
provider | Provider enum | Provider that served the task. |
model | string | Model that served the task. |
matched_rule | string, option | Routing rule that matched, if any. |
created_at | datetime | When the event occurred. |
trace_event_content
Paired 1:1 with trace_event (same record id).
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id, same as the metadata. |
content | SecretContent | Structured 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_type | payload 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique memory id (record id). |
user | user reference | Owner of the memory. |
key | string, option | Topic; lets an update target a fact. |
created_at | datetime | When the fact was first recorded. |
updated_at | datetime | When the fact was last changed. |
user_memory_content
Paired 1:1 with user_memory (same record id).
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id, same as the metadata. |
content | string | The 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.
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Unique memory id (record id). |
session | session reference | Session the memory belongs to. |
user | user reference | Owner, enforced on every query. |
key | string, option | Topic; lets an update target a fact. |
created_at | datetime | When the fact was first recorded. |
updated_at | datetime | When the fact was last changed. |
context_memory_content
Paired 1:1 with context_memory (same record id).
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Record id, same as the metadata. |
content | SecretContent | The remembered fact. |
Indexes
Indexes are declared on the metadata tables only:
- Owner load —
user_memorybyuser;context_memorybysession. Used to load all memory for an owner before a task runs. - Unique upsert-by-key — unique index on
(user, key)foruser_memoryand on(session, key)forcontext_memory, applied to keyed rows only. Keyless rows (keyis 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
sessionforsession_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
_contentpairs, includingcontext_memoryandsession_run_state. The cascade is explicit indelete_session, not enforced by SurrealDB. The retention purges clear the same rows for the sessions they remove. user_memorysurvives session deletion and falls under user retention settings.- Expired and revoked
auth_tokenrows 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.