Provider profiles
The unit of LLM configuration is the endpoint: a wire format, a base URL, and a credential.
[providers.nougate-anthropic]kind = "anthropic" # which wire format, not which companybase_url = "https://nougate.nouverse.tech/anthropic/v1"api_key = "nougate" # a secret NAME, never the value
[providers.nougate-openai]kind = "openai"base_url = "https://nougate.nouverse.tech/openai/v1"api_key = "nougate" # the same credentialAn agent then says provider = "nougate-anthropic", and moving it to a different deployment is a
one-word change.
The earlier shape — one environment variable per vendor, ANTHROPIC_API_KEY and OPENAI_API_KEY —
could not express this at all. A credential belongs to a base URL, and one gateway key serves both
of its protocol endpoints. Naming the secret once and referencing it twice says exactly that.
kind is only the wire format
Two values, and neither of them names a company:
anthropic | the Anthropic Messages API shape |
openai | the OpenAI Chat Completions shape |
The model behind antigravity:claude-sonnet-4-6 on an OpenAI-shaped endpoint is Claude, and nothing
in the engine needs to care. A proxy can serve either format.
Common profiles
[providers.anthropic]kind = "anthropic"base_url = "https://api.anthropic.com/v1"api_key = "anthropic_api_key"
[providers.openai]kind = "openai"base_url = "https://api.openai.com/v1"api_key = "openai_api_key"
[providers.openrouter]kind = "openai"base_url = "https://openrouter.ai/api/v1"api_key = "openrouter"headers = { "HTTP-Referer" = "https://nouverse.tech" }
# Anything that speaks the OpenAI shape on your own network.[providers.ollama]kind = "openai"base_url = "http://127.0.0.1:11434/v1"api_key = "ollama"headers is for literal, non-secret headers. A value that looks like a credential is refused at load
rather than quietly committed.
Managing them
From the dashboard: Models (admin only) — endpoint, wire format, credential. Or:
nouride provider # profiles, and which have a credentialnouride provider add nougate-anthropic --kind anthropic \ --url https://nougate.nouverse.tech/anthropic/v1 --key-stdinnouride provider rm nougate-anthropicThe key is read from stdin rather than taken as an argument. A key on a command line lands in shell
history, in ps output, and in any CI log that echoes its commands.
Defaults, and per-agent overrides
[llm.defaults]provider = "nougate-anthropic"model = "claude-sonnet-4-20250514"max_tokens = 8192temperature = 0.7One agent overrides any of it in its own pack:
[llm]provider = "nougate-anthropic"model = "claude-sonnet-4-20250514"max_tokens = 8192temperature = 0.7And one conversation overrides it again with /model — which is scoped to that chat, not to the
agent.
Model versus Models is a real distinction. A profile is infrastructure, defined once and shared; a model is one agent’s choice within it. That is what lets one agent move without touching the others.
Budgets and limits
[llm.limits]max_tokens_per_turn = 16384max_tool_iterations = 25max_turn_duration_ms = 300000max_daily_cost_usd = 0 # 0 = offmax_daily_tokens = 0 # 0 = offcost_alert_threshold_pct = 80Two ceilings. Both are checked before the provider is called, and whichever is reached first refuses the turn.
max_daily_cost_usd can only see models that are in the price book. Measured on a live install over
24 hours that was 8 of 88 requests, and a daemon pointed at a self-hosted gateway has no priced
models at all — so there the dollar cap is not a partial measure, it is nothing.
max_daily_tokens counts input and output together across every model, priced or not, because every
response reports its own usage. If you set only one, set this one.
Usage and cost are recorded per turn and the dashboard shows today’s spend against the cap, using the same number the enforcement uses.
Context: compaction rather than truncation
[llm.context]max_context_ratio = 0.8truncation_strategy = "summary" # summary | sliding_windowpreserve_recent_turns = 4compact_at_ratio = 0.8compact_at_tokens = 120000preserve_recent_tool_results = 3# summary_model = "claude-haiku-4-5-20251001"sliding_window drops the oldest turns. Correct for fitting, wrong for remembering: an agent an hour
into a job has no record of what it already did, so it repeats a step or reports success for one it
cannot see.
summary — the default — replaces those turns with one compacted note and keeps it. It costs one
provider call per compaction, not one per turn, and nothing happens at all until history crosses
compact_at_ratio. A summary that fails or comes back empty leaves the history untouched and
truncation proceeds exactly as sliding_window would, so the downside is bounded at the old
behaviour.
compact_at_tokens is a flat ceiling, and the threshold is the lower of the two. It exists
because a ratio scales with the window: on a million-token model the ratio put compaction near 610k,
so a 426k-token chat never compacted once and every iteration of a turn resent all of it — 12M input
tokens for one reply, and the turn died on its five-minute deadline while it was still making
progress. A window says what a model can read; it was never a claim about what it should carry to
answer well.
preserve_recent_tool_results is the same idea inside a single turn. Trimming replaces the
content; the message itself always stays, because an orphaned tool call is rejected outright by
Anthropic.
Prompt caching
The system prompt is split where its stability changes, and marked so a provider can cache the stable part:
| Tier | Holds | Changes when | Cached |
|---|---|---|---|
files | identity, user, agents, skills, memory | the pack is edited | ✓ |
capability | the harness block | the toolset or the exec posture changes | ✓ |
request | timestamp, model, sender | every request | ✗ |
The last tier never carries a marker — a breakpoint written after the timestamp is an entry the next request cannot read, which is a 25% surcharge rather than a saving.
Order is load-bearing independently of the markers, because an implicit prefix cache — all the OpenAI path has — stops matching at the first byte that differs. Putting the harness before the timestamp took the cache-eligible prefix from 5.0k tokens to ~8.0k on every request and on every one of up to 25 tool iterations.
Streaming
[llm.streaming]enabled = true # accumulates the stream; partial replies are not senttyping_indicator = trueprogress_in_chat = trueprogress_in_chat posts one message naming the tool the agent is running, edits it as the turn
progresses, and replaces it at the end with ✓ 4 steps · 12s. One message and not one per step,
because editing re-notifies nobody — a twenty-tool turn would otherwise be twenty phone
notifications. A turn that runs no tools posts nothing at all.
Concurrency
[queue]max_concurrent_per_agent = 1 # per chat. The loader refuses anything highermax_concurrent_turns = 8 # across every conversation. 0 lifts the ceilingmax_queue_depth = 20queue_timeout_ms = 300000Per-chat concurrency is fixed at 1 and that is not a tuning decision: a conversation is stored as one JSON document, so two turns at once in the same chat would both read it and both write the whole thing back. The slower one wins and the other exchange is gone from the transcript with nothing logged. Different chats already run in parallel with no setting.
max_concurrent_turns is the total. Without it, fifty active chats meant fifty simultaneous provider
calls — fifty times the spend per second, and a rate limit hit immediately.
queue_timeout_ms is matched to max_turn_duration_ms rather than picked on its own: a message
queued behind a turn has to outlive that turn, or one slow reply silently costs the next message. The
loader warns if it is the shorter of the two.