Native brain — operator runbook¶
Istota has three model-invocation backends behind one protocol:
claude_code(default) — wraps the headlessclaude -pCLI subprocess. Battle-tested; delegates the agentic loop, tool use, and context management to Claude Code.native— istota's own in-process agent loop against an OpenAI-compatible provider. Gives istota direct control over the loop, tool execution, context compaction, and model selection.tmux_claude— drives the interactiveclaudeTUI in a detached tmux session (keeps traffic on subscription billing), with automatic fallback toclaude_codeand a circuit breaker. Configured under[brain.tmux]; seeconfig.example.tomlfor the full block.
All coexist permanently and are switchable per instance or per task. Switching does not touch executor orchestration (memory, skills, sandbox, deferred writes) — only which Brain implementation runs.
This runbook covers the native backend.
Enabling the native brain¶
Instance-wide:
[brain]
kind = "native"
[brain.native]
provider = "openai_compat" # only provider currently
model = "claude-sonnet-4-6" # explicit id — openai_compat has no aliasing
effort = "" # default reasoning effort (see below)
base_url = "https://api.anthropic.com/v1" # any OpenAI-compatible endpoint
max_turns = 100 # hard cap on assistant turns per task
max_tokens = 16384 # per-completion output cap
# prompt_caching # omit to derive from base_url (see below)
The API key never goes in the TOML file. Set it via the env override:
(loaded from the systemd EnvironmentFile=, direnv, or .env).
Model id format¶
openai_compatneeds an explicit model id (e.g.claude-sonnet-4-6). It does not understand role aliases (smart) or Claude-CLI short names (opus).- The
claude_codebrain (default) keeps Claude Code's aliasing —opusresolves to the latest Opus. The native brain does not; map role names with[models.aliases]if you want them.
OpenRouter¶
openai_compat targets any OpenAI chat-completions endpoint (src/istota/llm/openai_compat.py), so OpenRouter is just a base_url + model + key:
[brain]
kind = "native"
[brain.native]
provider = "openai_compat"
base_url = "https://openrouter.ai/api/v1"
model = "anthropic/claude-sonnet-4" # OpenRouter ids are slash-namespaced: <vendor>/<model>
prompt_caching = true # REQUIRED for OpenRouter — see below
Two things to get right:
- Model id. OpenRouter uses slash-namespaced ids (
anthropic/claude-sonnet-4,openai/gpt-4o,google/gemini-2.5-pro). Becauseopenai_compatdoes no aliasing (above), paste the id exactly as OpenRouter lists it on its models page — a bareclaude-sonnet-4-6will not resolve there. - Prompt caching must be explicit. The auto-default only turns caching on when the base_url contains
api.anthropic.com(make_providerinsrc/istota/llm/__init__.py). For anopenrouter.aibase_url it defaults off, so setprompt_caching = true(Ansible:istota_brain_native_prompt_caching: true) to getcache_controlbreakpoints on caching-capable models routed through OpenRouter.
Ansible deployment¶
The role renders the [brain] block from inventory variables. The [brain.native] and [brain.source_type_overrides] tables are only written when istota_brain_kind is native, istota_brain_fallback is native, or istota_brain_source_type_overrides is non-empty, so existing deployments stay byte-identical until you opt in. After templating, files/validate_config.py parses the rendered config and gates the scheduler restart, so a malformed brain block fails the play instead of the running daemon.
Instance-wide native brain:
istota_brain_kind: "native"
istota_brain_native_provider: "openai_compat"
istota_brain_native_model: "claude-sonnet-4-6"
istota_brain_native_base_url: "https://api.anthropic.com/v1"
istota_brain_native_effort: "" # default reasoning effort (thinking models only)
# istota_brain_native_prompt_caching # "" (default) derives from base_url; set true/false to force
istota_brain_native_api_key: "{{ vault_native_api_key }}" # → ISTOTA_BRAIN_NATIVE_API_KEY
Gradual rollout (keep the default brain, move background work to native):
istota_brain_kind: "claude_code"
istota_brain_native_model: "claude-sonnet-4-6"
istota_brain_native_api_key: "{{ vault_native_api_key }}"
istota_brain_source_type_overrides:
scheduled: native
heartbeat: native
The full variable set is documented in deploy/ansible/defaults/main.yml: istota_brain_native_{provider,model,effort,base_url,extra_headers,context_window,max_turns,max_tokens,model_catalog_fetch,model_catalog_cache_ttl_hours,prompt_caching,bash_spill_full_output,turn_budget_nudge,turn_budget_nudge_early_percent,turn_budget_nudge_remaining,api_key}, the istota_brain_native_web_fetch_* family, and istota_brain_source_type_overrides. istota_brain_native_prompt_caching defaults to "" (derive from base_url); set it to true/false only to force.
The Ansible defaults are not the code defaults
Several Ansible variables ship opinionated values rather than mirroring the dataclass:
| Variable | Ansible default | Code default |
|---|---|---|
istota_brain_native_model |
z-ai/glm-5.2 |
"" |
istota_brain_native_base_url |
https://openrouter.ai/api/v1 |
https://api.anthropic.com/v1 |
istota_brain_native_effort |
medium |
"" |
istota_brain_native_max_tokens |
32000 |
16384 |
istota_brain_fallback_cooldown_seconds |
3600 |
900 |
The base_url one has a consequence worth spelling out: because a stock Ansible deploy points at OpenRouter, the "prompt caching defaults off for a non-Anthropic base_url" note above applies to it. Set istota_brain_native_prompt_caching: true if you want caching there.
[brain.native.web_fetch]¶
The native harness ships its own daemon-side WebFetch tool. It runs in the daemon's network namespace, so it is not gated by the sandbox CONNECT allowlist — but it is credential-free (no cookies, trust_env=False) and SSRF-hardened: every resolved IP is validated against a private/reserved blocklist on each request and each redirect hop, the connection is pinned to the validated IP to close DNS rebinding, and it is GET/text-only with size and time caps. Fetched content is wrapped in an untrusted-content delimiter.
| Setting | Default | Description |
|---|---|---|
enabled |
true |
Master switch; false omits the tool entirely |
allow_http |
false |
Permit cleartext http:// |
timeout_seconds |
20 |
Total wall-clock per fetch |
max_bytes |
5000000 |
Response body cap (streamed) |
max_content_chars |
100000 |
Extracted-text cap returned to the model |
max_redirects |
5 |
Redirect hops before giving up |
require_url_provenance |
false |
Only fetch URLs that appeared in the task — for sensitive deployments |
allow_hosts |
[] |
If non-empty, a host allowlist (suffix match) |
block_hosts |
[] |
Always-denied hosts (suffix match) |
Availability fallback¶
[brain] carries a failover mechanism independent of which brain is primary:
| Setting | Default | Description |
|---|---|---|
fallback |
"" |
Brain to rerun a request on when the primary is unavailable |
fallback_on_transient |
true |
Also reroute a persistent transient_api_error |
fallback_cooldown_seconds |
900 |
Skip an unavailable primary this long before retrying it; 0 disables |
The same availability breaker is what the nightly sleep cycle consults before deciding to run at all. Pin istota_brain_native_model whenever native is either primary or fallback — an empty model id 400s on failover, which is the worst moment to discover it.
Key handling:
istota_brain_native_api_keyis never written toconfig.toml. Withistota_use_environment_file: true(the default) it's rendered into the systemdEnvironmentFileasISTOTA_BRAIN_NATIVE_API_KEY; vault it.- Per-user keys go through the existing
istota_user_secretsmechanism (thenative_brainservice is in the connected-service schema, flaggedcli_onlyso it's operator-provisioned only — not exposed in the web UI), and overlay the instance key for that user's tasks:
istota_user_secrets:
alice:
- { service: native_brain, key: api_key, value: "{{ vault_alice_native_key }}" }
istota_brain_native_extra_headersis rendered as a[brain.native.extra_headers]sub-table (a TOML inline table would be mis-emitted by the JSON filter), so header names with dots or dashes (anthropic-beta) are safe.
Gradual rollout: per-source-type routing¶
Rather than flipping the whole instance at once, route specific task types to the native brain while everything else stays on claude_code. This is the recommended rollout path: move low-risk background work first, keep interactive talk/email on the proven backend, watch for regressions, then widen.
[brain]
kind = "claude_code" # default for everything not listed below
[brain.source_type_overrides]
scheduled = "native" # cron jobs
heartbeat = "native" # health checks
source_type values match the task's origin: talk, email, briefing, scheduled, heartbeat, subtask, cli, istota_file. A routing typo (unknown brain kind) is logged and ignored — the task falls back to the instance default rather than failing. Each routed task logs one INFO line (brain routing: task … -> kind=native).
Local development¶
Bubblewrap is Linux-only, so on a Mac dev box run with the sandbox off. Keep a gitignored config/config.dev.toml (copy config/config.dev.toml.example):
[brain]
kind = "native"
[brain.native]
provider = "openai_compat"
model = "claude-sonnet-4-6"
base_url = "https://api.anthropic.com/v1"
[security]
sandbox_enabled = false # bwrap is Linux-only
skill_proxy_enabled = false # simplifies the inner loop
[users.dev]
display_name = "Dev"
Sandbox correctness cannot be validated on the Mac. "Works locally" means "logic is correct," not "isolation is correct" — check isolation on a Linux box or in the Docker image.
Dev tiers¶
Standalone loop runner (scripts/native_repl.py) — runs one prompt through a NativeBrain with no executor/scheduler/Talk/DB. Tools operate in a throwaway temp dir. Prints the streamed events, the BrainResult, and TaskUsage (so cost is visible).
# Offline, deterministic — a scripted mock provider drives the loop.
uv run python scripts/native_repl.py --provider mock \
--script tests/native/fixtures/two_tool_turn.json "write and read a file"
# Replay a recorded SSE session through the real parser (no credits).
uv run python scripts/native_repl.py --provider replay \
--fixture tests/native/fixtures/text_completion.jsonl --tools "" "summarize this repo"
# Live, against whatever the dev config points at (needs a key).
uv run python scripts/native_repl.py -c config/config.dev.toml --provider live "..."
Recorded-SSE replay — ReplayProvider feeds committed JSONL SSE fixtures through the real provider parser (CI default, offline). RecordingProvider regenerates fixtures from the live API (ISTOTA_NATIVE_RECORD=1 + a real key), run rarely.
Full CLI task path — point the existing istota task CLI at the dev config:
uv run istota init -c config/config.dev.toml
uv run istota task "read README and summarize it" -u dev -x -c config/config.dev.toml
Zero-cost live loop: point [brain.native] at a local Ollama model (base_url = "http://localhost:11434/v1"). Quality is lower — small models loop and mis-call tools, which is itself useful for exercising the loop detector and JSON repair — but it validates the whole stack offline.
Shadow compare¶
Before flipping a task type to native, run the same prompt through both brains and diff the output:
uv run python scripts/brain_shadow.py -c config/config.dev.toml \
"read README and summarize it in one sentence"
It diffs result text (similarity + unified diff), tool-call sequence, and native TaskUsage. Exact parity is not expected — the brains manage context differently and expose different tool schemas — but outcomes should be equivalent. Large text divergence or wildly different tool sequences are the signal to investigate.
Operational notes¶
- Cost telemetry. The native brain computes per-task token usage and cost and writes it to
task_logs(ausage {...}info line) plus annative_usagelog line. Cost prefers the provider's own reported figure (OpenRouter returns real charged cost); otherwise it falls back to the model catalog's per-mtok prices — which, for an OpenRouter deployment, are the live-fetched real prices, and otherwise 0.0 (unknown rather than wrong).claude_codeleaves usage opaque — the CLI doesn't surface per-call usage. - Per-user API keys. Beyond the instance-wide
[brain.native] api_key/ISTOTA_BRAIN_NATIVE_API_KEY, each user can have their own provider key in the encrypted secrets table:istota secret ensure -u <user> -s native_brain -k api_key -v <key>. This is operator-provisioned only (CLI/Ansible) — it's deliberately not in the web UI, since it overrides only the key and not the provider/model/base_url, so a self-serve knob would imply more than it delivers. The per-user key overlays the instance key for that user's tasks. - Reasoning effort.
[brain.native] effort(low/medium/high/xhigh/max, default empty) sets a default reasoning budget; per-task overrides (e.g.!model opus:high,[models.aliases]) win. It is sent as the OpenAI-compatiblereasoning_effortfield only when the target model is thinking-capable (supports_thinking, resolved from the live-fetched OpenRouter catalog or amodel_overridesentry) — for a non-reasoning endpoint it is dropped silently so the request never 400s.xhighandmaxfold tohighon the wire (the compat field exposes no finer knob); the original tier still tracks on the task row. Extended-thinking output is parsed but excluded from the visible result. - Prompt caching.
[brain.native] prompt_cachingaddscache_controlbreakpoints covering the tool definitions, the system message, the first user message, and a rolling breakpoint on the latest message each turn (up to Anthropic's 4-breakpoint cap), which is what produces cross-turn cache hits. The default is derived frombase_url: on forapi.anthropic.com, off for any other endpoint. Set it explicitly to force either way — a plain-OpenAI, LM Studio, Ollama, or vLLM endpoint that doesn't understand the extension needsprompt_caching = false. A per-task cache hit-rate line is logged at task end (native cache hit_rate=… read=… input=…). - Context-overflow recovery. If a turn exceeds the context window mid-task, the native brain force-compacts the accumulated transcript and continues from the summary instead of failing — up to two recovery attempts, sharing the task's wall-clock deadline. The proactive compaction hook (
prepare_next_turn) is the first line of defense; this is the reactive safety net beneath it. - Image tool results. A tool result carrying image content renders as a follow-up
role:"user"block on vision-capable models (supports_vision); on a no-vision model the image is dropped with a text note so the request still validates. - Model ids.
openai_compatneeds explicit ids and does not translate Anthropic aliases —opusis sent verbatim, not turned intoclaude-opus-5(that mapping is theclaude_codebrain's, not the native brain's). Map role names per deployment with[models.aliases]if you wantfast/general/smartunder native. - Cancellation /
!stop. Works on both brains. The native brain bridges the scheduler's cancel poll into anasyncio.Eventthreaded through the loop, tools, and retry backoff. A failing cancel poll (e.g. transient SQLite lock) is tolerated rather than silently disabling!stop. - Task timeout. The native loop runs under a wall-clock deadline of
scheduler.task_timeout_minutes(istota_scheduler_task_timeout_minutes, 60 on an Ansible deploy; 30 is the in-code default). On expiry it signals abort (killing any in-flight bash subprocess at the next poll), waits a short grace, then hard-cancels, and returnsstop_reason="timeout". This matchesclaude_codeand prevents a runaway loop from outliving the scheduler's stuck-task reclaim (which would otherwise double-execute the task).max_turnsis a second, coarser backstop. - Context management. The native brain owns compaction (runs in
prepare_next_turn, file-operation aware across cycles).claude_codedelegates it to Claude Code. The two are independent. - Sandboxing.
claude_coderuns the whole subprocess inside bwrap. The native brain runs the loop in-process and sandboxes each tool execution per-call (the loop itself never runs user-controlled code). Validate the per-tool sandbox on Linux, not on the Mac.
Rollback¶
Set [brain] kind = "claude_code" (or remove the source_type_overrides entry) and restart the scheduler. ClaudeCodeBrain is never removed — rollback is a one-line config change.