Configuration reference
mithai is configured with a config.yaml file. All values support ${ENV_VAR} interpolation — the framework substitutes environment variables at load time. Secrets should always live in .env, not in config.yaml.
Quick example
Section titled “Quick example”bot: name: mithai system_prompt: | You are a concise assistant. Always confirm before irreversible actions.
adapter: type: slack slack: bot_token: ${SLACK_BOT_TOKEN} app_token: ${SLACK_APP_TOKEN} respond: mentions
llm: provider: anthropic model: claude-sonnet-4-6 max_tokens: 4096 anthropic: api_key: ${ANTHROPIC_API_KEY}
skills: paths: - ./skills config: shell: allowed_commands: ["df -h", "uptime"]bot: name: mithai # the agent's name; shown in responses and UI system_prompt: | # prepended to every conversation You are a concise assistant.system_prompt is added before skill prompts and memory. Use it to set the agent’s personality, scope, and any organization-wide rules.
adapter
Section titled “adapter”Adapters connect the agent to communication platforms.
Single adapter
Section titled “Single adapter”adapter: type: slack # slack | telegram | cli | api slack: { ... }Multiple adapters
Section titled “Multiple adapters”adapter: types: - slack - telegram slack: { ... } telegram: { ... }All adapters share the same engine and skills. Human MCP approvals always route back through the adapter that received the original message.
adapter: slack: bot_token: ${SLACK_BOT_TOKEN} # xoxb-... (Bot User OAuth Token) app_token: ${SLACK_APP_TOKEN} # xapp-... (App-Level Token, Socket Mode) respond: mentions # "mentions" (default) or "all"respond: mentions — the bot only responds when @mentioned. Use respond: all to respond to every message in channels it’s invited to.
External Slack channels
Section titled “External Slack channels”allow_posting_in_external_channels is an advanced Slack safety setting for teams that want mithai to read and react to Slack Connect or externally shared channels, but never post assistant text back into those channels.
The default is true, which preserves existing behavior. Set it to false only when the agent should observe or process external shared channel messages without writing text back to those channels.
adapter: slack: allow_posting_in_external_channels: falseWhen set to false, the Slack adapter uses Slack conversations.info metadata to identify external channels:
is_ext_shared: trueis_pending_ext_shared: trueis_shared: truewithis_org_sharednot true
This means channel names, glob patterns, and deployment-specific customer-channel conventions are not part of the policy. Slack is the source of truth for whether a channel is external. Enterprise Grid org-shared channels (is_org_shared: true) are treated as internal and keep normal posting behavior.
For detected external channels, mithai suppresses adapter-originated text posts, including:
- Final assistant responses from Slack message handlers.
- Direct Slack adapter sends through
adapter.send(). - Slack MCP send-message tools before the tool call is routed.
- Human approval prompts and timeout notices.
- Canned app-mention replies and onboarding messages.
Reactions and read-only Slack operations are still allowed. If Slack channel metadata cannot be fetched while this setting is false, mithai fails closed and suppresses the attempted text post rather than risking an external-channel message. Failed metadata lookups are not cached, so a transient Slack API failure can recover on the next attempt.
Make sure the Slack app has enough channel visibility for conversations.info on the channels where the bot runs. If metadata lookup fails, logs include Could not resolve Slack channel info for external posting guard.
Telegram
Section titled “Telegram”adapter: telegram: bot_token: ${TELEGRAM_BOT_TOKEN} allowed_chat_ids: - ${TELEGRAM_CHAT_ID} # whitelist of chat IDsNo configuration required. Use mithai chat to start an interactive session.
Headless adapter for webhook and programmatic use. The process stays alive while the embedded API server (MITHAI_UI_PORT) handles all traffic. No Slack or terminal connection required.
adapter: type: apiStart with:
MITHAI_UI_PORT=8080 MITHAI_UI_TOKEN=secret mithai run --adapter apiSend messages via POST /api/trigger:
curl -X POST http://localhost:8080/api/trigger \ -H "Authorization: Bearer secret" \ -H "Content-Type: application/json" \ -d '{"message": "deploy app", "channel_id": "webhook"}'# → 202 Accepted {"status": "accepted", "channel_id": "webhook"}The response returns 202 immediately and the engine runs in the background. Approval-gated tools are cancelled in this non-interactive mode. Use wait=true&stream=approval-v1 plus the approval decision endpoint for interactive API approvals.
Body fields:
| Field | Required | Description |
|---|---|---|
message | yes | Text to send to the agent |
channel_id | no | Session namespace (default: "trigger") |
user_id | no | User identifier (default: "api") |
With wait=true, the JSON response also includes time_boxed, tool_results,
and a provider-neutral coverage summary. With wait=true&stream=true, the
final NDJSON done event carries the same fields. Coverage is complete,
partial, unknown, or not_applicable; arbitrary tool inputs are not copied
into this public summary.
The public coverage shape is:
{ "coverage": { "status": "partial", "observations": [ {"status": "complete", "result_state": "data"}, {"status": "partial", "result_state": "empty"} ] }}observations contains each distinct categorical (status, result_state) pair
at most once; provider identity and raw scopes do not affect this public
de-duplication. Internal unverified-entry counts are not exposed. If at least
one verified observation exists, a partial declaration or any unverified entry
makes the summary partial; with unverified entries but no verified
observations it is unknown. A turn with no coverage-relevant entries is
not_applicable.
Interactive approval protocol
Section titled “Interactive approval protocol”For callers that need to resolve approve/confirm-level Human MCP requests interactively (rather than having them auto-cancel), open an SSE stream on /api/trigger instead of the fire-and-forget POST above.
Requires ui.auth_token to be configured (interactive endpoints return 503 without it) and adapter.type: api.
curl -N -X POST "http://localhost:8080/api/trigger?wait=1&stream=approval-v1" \ -H "Authorization: Bearer secret" \ -H "Content-Type: application/json" \ -d '{"message": "restart payments-api", "channel_id": "webhook"}'The response is Content-Type: text/event-stream with header X-Mithai-Capability: human-interaction-v1. While the run is between events the stream sends event: heartbeat keep-alives (roughly every 15s of inactivity).
Event envelope
Every event is an SSE event: <type> line with a data: line of JSON carrying a common envelope — protocol_version, sequence (monotonically increasing per run, 1-indexed), run_id, channel_id — plus type-specific fields. Besides approval_requested/approval_resolved/question_requested/question_resolved (below), the stream also carries run_started, assistant_text_start/assistant_text/assistant_text_end, tool_started, tool_completed, run_completed, run_failed, and heartbeat events.
Assistant text
Text arrives incrementally as the LLM generates it. Each LLM round that produces text opens a block with assistant_text_start, emits one assistant_text per delta, and closes with assistant_text_end. A run with tool calls therefore has several blocks — the pre-tool preamble, then the answer — with the tool events in between, so a reader can lay them out in the order they happened. A round that produces no text (tools only) opens no block.
{"protocol_version": "v2", "sequence": 2, "run_id": "…", "channel_id": "webhook", "type": "assistant_text_start"}{"protocol_version": "v2", "sequence": 3, "run_id": "…", "channel_id": "webhook", "type": "assistant_text", "text": "Checking the "}{"protocol_version": "v2", "sequence": 4, "run_id": "…", "channel_id": "webhook", "type": "assistant_text", "text": "payments-api logs."}{"protocol_version": "v2", "sequence": 5, "run_id": "…", "channel_id": "webhook", "type": "assistant_text_end"}A run whose LLM produced no deltas at all (a provider without native streaming that also returned nothing incrementally) instead sends the whole answer as one bare assistant_text with no surrounding block.
A block never opens on whitespace alone: leading whitespace is held back until the round produces printable text, then flushed with it, so a reader never has to render an empty message. A round that emits only whitespace opens no block and counts as having streamed nothing.
run_completed carries text: the run’s authoritative final answer. It is the value to persist — concatenating the deltas would also fold in the pre-tool preamble. An empty text means there is no authoritative answer for this run (the agent returned nothing), and a reader should fall back to whatever the deltas carried.
Every assistant_text_start is matched by an assistant_text_end before the terminal event, including when a run fails partway through a block.
tool_started / tool_completed are emitted for every tool call, gated or not, correlated by tool_call_id (the LLM’s tool-use block id). tool_started carries the same redacted, size-capped tool_input presentation as approval_requested, plus target (str, ≤ 128 chars) and/or query_kind ([a-z_], ≤ 32 chars) when the owning skill’s tool_target hook supplies them (see skills-reference.md); tool_completed carries duration and approved. human__ask is excluded — it surfaces as question_requested/question_resolved instead.
A denied or cancelled tool emits neither event — it never starts executing, so there is no tool_started to leave dangling; its outcome arrives as approval_resolved. A tool that does start always gets a matching tool_completed, including when it raises (the engine reports that as approved: true — the flag records the human decision, not success).
When the tool actually executed, tool_completed also carries facts about the router’s raw result, measured before any on_tool_result rewrite and before tool_result_max_chars compaction:
is_error(bool): the router reported an error, or the call raised.result_chars(int,nullif the call raised): length of the raw result string.is_empty(bool,nullon error): the host’s verdict if it gave one — the owning skill’stool_result_empty(tool_name, raw_result) -> bool | Nonehook, then a boolctx["mcp_execution_metadata"]["is_empty"]set by the handler — else whether the stripped result is"",null, or an empty JSON array or object ([],{}, whitespace allowed between the brackets).
Calls that never executed carry none of these fields: those with approved: false (denied, cancelled, preflight-suppressed, deadline, policy- or budget-blocked) and those the router refused after starting them (approved: true, e.g. a scope_changed refusal when before_tool_call rewrote the input).
{"protocol_version": "v2", "sequence": 6, "run_id": "…", "channel_id": "webhook", "type": "tool_started", "approval_id": null, "tool_name": "last9__get_logs", "tool_call_id": "toolu_…", "tool_input": {"service": "payments-api"}, "target": "payments-api", "query_kind": "logs"}{"protocol_version": "v2", "sequence": 7, "run_id": "…", "channel_id": "webhook", "type": "tool_completed", "approval_id": null, "tool_name": "last9__get_logs", "tool_call_id": "toolu_…", "duration": 1.42, "approved": true, "is_error": false, "result_chars": 5120, "is_empty": false}approval_requested / approval_resolved:
{"protocol_version": "v2", "sequence": 2, "run_id": "…", "channel_id": "webhook", "type": "approval_requested", "approval_id": "…", "tool_name": "shell__run", "tool_call_id": "…", "level": "confirm", "safe_description": "Tool: shell__run\nAction: …", "confirmation_challenge": "CONFIRM", "tool_input": {"command": "curl https://example.com/health"}, "expires_at": 1732200000.0}{"protocol_version": "v2", "sequence": 5, "run_id": "…", "channel_id": "webhook", "type": "approval_resolved", "approval_id": "…", "tool_call_id": "…", "outcome": "approved"}question_requested / question_resolved (from ask_human, only available inside this interactive stream):
{"protocol_version": "v2", "sequence": 3, "run_id": "…", "channel_id": "webhook", "type": "question_requested", "question_id": "…", "prompt": "Which environment?", "choices": ["staging", "prod"], "expires_at": 1732200000.0}{"protocol_version": "v2", "sequence": 4, "run_id": "…", "channel_id": "webhook", "type": "question_resolved", "question_id": "…", "outcome": "answered", "answer": "staging"}outcome on approval_resolved is one of approved, denied, timed_out, cancelled. outcome on question_resolved is one of answered, timed_out, cancelled; answer is present only when outcome is answered.
Decision and status routes
All four routes require the same auth as above, plus ui.auth_token configured and adapter.type: api — otherwise 503 {"error": "interactive API authentication required"} or 409 {"error": "interactive API adapter required"}.
| Method & path | Body | Response | Status codes |
|---|---|---|---|
POST /api/approvals/{approval_id}/decision | {"approved": bool, "confirmation": string} | {"approval_id", "run_id", "channel_id", "outcome", "expires_at"} | 200 decided · 400 approved missing/not boolean or confirmation mismatch · 409 already resolved to a different outcome · 410 unknown/expired/cancelled |
GET /api/approvals/{approval_id} | — | same shape as above (outcome: "pending" while unresolved) | 200 · 410 unknown |
POST /api/questions/{question_id}/answer | {"answer": string} | {"question_id", "run_id", "channel_id", "prompt", "choices", "outcome", "expires_at", "answer"} | 200 answered · 400 answer missing/empty/over 4096 chars · 409 already answered differently · 410 unknown/expired/cancelled |
GET /api/questions/{question_id} | — | same shape as above (outcome: "pending" while unresolved) | 200 · 410 unknown |
confirmation is only checked when the approval’s level is confirm and approved is true: it must exactly match the confirmation_challenge sent in the approval_requested event, or the decision is rejected with 400. The challenge is the literal string "CONFIRM"; raw tool arguments are never used as confirmation text or included in presentation events. Denials (approved: false) never check confirmation.
If the client disconnects from the SSE stream before the run completes, the run is cancelled: any pending approval/question resolves as cancelled, and no further tools execute for that run.
engine
Section titled “engine”Optional engine-level guardrails for long-running deployments. All sub-keys are off or empty by default so generic mithai installs behave unchanged; production agents (for example the Last9 supervisor) should set these explicitly.
engine.session
Section titled “engine.session”Per-turn wall-clock budgets and same-session request coalescing limits.
engine: session: deadline_seconds: 150 # default: 150 — raise only when no gateway ceiling applies wrapup_timeout_seconds: 25 # default: 25 inflight_ttl_seconds: 400 # default: 400 — crash-safety bound for in-flight markersdeadline_seconds is the main turn budget before the engine forces a wrap-up answer. The wrap-up forbids tools by instruction but still sends the turn’s tool list, so the cached prompt prefix holds; a wrap-up that requests a tool anyway gets a deterministic fallback. wrapup_timeout_seconds caps that wrap-up call, whether the deadline passes between model calls or cuts one in flight. deadline_seconds + wrapup_timeout_seconds is the nominal execution-plus-wrap-up budget, not a hard wall-clock ceiling: the wrap-up budget starts when the engine detects the expired deadline, which can be after it passed, and approval waits extend the main deadline. Deployments behind an external gateway with a shorter ceiling (for example ~180s) should set deadline_seconds explicitly rather than relying on the framework default.
engine.context
Section titled “engine.context”In-turn eviction of old, large tool results. Long tool loops otherwise re-send every past tool result on every LLM call; the cost of a turn grows with (context size × calls). After each tool round the engine sums the characters of this turn’s tool results as the next call would see them; once that exceeds trigger_chars, it replaces every result except the keep_recent_results most recent ones that is larger than min_chars with a one-line marker plus the result’s first 500 characters. The trigger counts tool-result characters rather than provider input tokens because the static system prompt and tool definitions alone sit near any useful token threshold. A pass leaves only the recent results plus short markers, so the next pass waits until results accumulate again and the prompt-cache prefix stays stable in between (each pass is one cold cache write). Persisted session history, result_summary, coverage and API-exposed tool_results are never affected — only the live turn context is. load_skill results are also exempt (and are neither counted nor take a keep slot): they carry the loaded skill’s full prompt, which only reaches the composed system on the next turn, so evicting them would strip an active skill’s instructions mid-turn.
engine: context: eviction: enabled: true # default: true trigger_chars: 60000 # default: 60000 — live tool-result chars of this turn keep_recent_results: 2 # default: 2 — most recent tool results are never evicted min_chars: 2000 # default: 2000 — smaller results are left alone cached_trigger_chars: 400000 # default: 400000 — trigger when the provider caches prompts cached_low_water_chars: 150000 # default: 150000 — a cached pass stops at or below thisPrompt-cached providers. When the provider marks prompt-cache breakpoints (Anthropic always; Bedrock for cache-capable Claude models), the last breakpoint sits on the newest message, so rewriting any earlier tool result re-writes the cached prefix from that message on. Evicting on the 60K trigger would then fire almost every round and cost more than it saves. Instead the engine waits for real context-window pressure — cached_trigger_chars, about 100K tokens of tool results, roughly half a 200K-token window — and evicts in one batch, oldest first, until the live total is at or below cached_low_water_chars (still never touching the keep_recent_results newest). The gap between the two marks is the headroom before the next pass; until then every call extends the previous one and reads it from cache. cached_low_water_chars is clamped to cached_trigger_chars. Providers without breakpoints (OpenAI) use trigger_chars.
Either trigger is an eviction threshold, not a hard budget: a call can still carry more. The pass runs only after the total already exceeds it, and what survives a pass — the kept results, a marker of roughly 600 characters (header plus the first 500) per evicted result, and every result at or below min_chars — counts towards the next total. Keep trigger_chars and cached_low_water_chars comfortably above keep_recent_results × engine.tool_result_max_chars, or the kept results alone can exceed them. A hard context budget would need a separate policy. The earlier trigger_tokens, keep_recent (rounds) and min_rounds_between keys are no longer read.
Trade-off: the marker invites the model to re-call a tool whose result it needs again, so runs may take a few more tool rounds. How much this saves depends entirely on how large the workload’s tool results are. Measured with the earlier token trigger: on a browser-automation run (36 accessibility snapshots of ~50k chars each) total input tokens fell ~43% while rounds rose from 108 to 128. On a workload whose results are smaller — an MCP observability loop with ~5–40k-char results — the effect is much weaker: across two ON/OFF pairs, mean input tokens per call fell ~6% (96.6k vs 103.1k) and peak context fell from 139.5k to 134.0k, while total tokens per run were dominated by run-to-run variance in round count and flipped sign between pairs. Expect eviction to cap peak context reliably, and to cut the total bill only when individual tool results are large relative to the trigger.
Set enabled: false to restore the previous behaviour. Each pass logs context eviction: evicted=… chars_freed=… prompt_cache=…, sets mithai.context.evicted_results / mithai.context.chars_freed on the current span, and records the mithai.context.eviction.chars_freed histogram.
engine.tool_budget
Section titled “engine.tool_budget”Generic per-turn caps on operator-chosen tools (not hardcoded to any MCP server).
engine: tool_budget: enabled: true tools: - get_logs - get_service_logs max_per_turn: 5 max_long_range_per_turn: 1 long_range_threshold_minutes: 60 long_range_service_name_heuristic: false replan_enabled: false # opt in to one bounded corrective model roundLegacy alias: engine.log_tool_budget (same shape). When only the legacy key is present, enabled defaults to true for backward compatibility.
When a configured cap is reached, unconfigured tools remain available. Mithai
can synthesize an honest partial answer from successful evidence and, when
replan_enabled is true, offers exactly one corrective round with only calls
that can still execute. Private execution counters and rejection codes are not
persisted or returned to the user.
Tool providers may optionally declare searched coverage without Mithai knowing
their domain. MCP servers place the versioned descriptor in
CallToolResult._meta under mithai/coverage by default. Set
coverage_metadata_key on an MCP server to consume a server-owned key such as
last9/coverage without changing the descriptor:
{ "mithai/coverage": { "version": 1, "subject": "events", "attempted_scope": {"window": "requested"}, "covered_scope": {"window": "searched"}, "status": "partial", "result_state": "data" }}status accepts exactly complete or partial; result_state accepts exactly
data or empty. Raw subject, attempted_scope, and covered_scope values
are bounded, untrusted provider declarations retained only in the internal
turn ledger. They never enter recovery prompts, response prose, persisted or
public coverage, or API responses. Public coverage observations contain only
the categorical status and result_state values.
For integrators, subject is limited to 128 characters. Each of
attempted_scope and covered_scope must be a JSON object and is limited to a
maximum depth of 4 (root depth 0), 64 members or elements per container, 128
total value nodes including containers, 128 characters per object key, 512
characters per string value, and 4096 bytes when encoded as compact UTF-8 JSON.
Missing, oversized, deeply nested, or otherwise invalid descriptors degrade to
unknown; Mithai does not inspect provider-specific tool names, arguments, or
result payloads to infer coverage.
Native tools attach the equivalent typed CoverageObservation to
ToolExecutionResult.coverage. Native tools that can emit that descriptor
set declares_coverage: true on the TOOLS list. MCP tools advertise the
same capability with _meta: {"<coverage_metadata_key>": true} on list_tools,
using the configured key or the default mithai/coverage.
Pending or truncated calls never produce a runtime descriptor. Mithai treats
those calls as coverage-relevant when the tool is budgeted or registered as
coverage-capable. A registered coverage-capable tool that runs without a valid
descriptor records unknown. A tool that emits a descriptor at runtime without
advertising still records on the executed path; an orphaned first appearance
of such a tool does not change coverage status.
Before enabling replan_enabled, monitor
mithai.tool_budget.recoveries, mithai.tool_budget.blocked_calls, and
mithai.tool_budget.suppressed_retries. Enabling it adds at most one corrective
model round per exhausted turn; compare the replanned, synthesized, and
fallback dispositions with coverage status and turn latency during rollout.
engine.temporal_guardrails
Section titled “engine.temporal_guardrails”Operator-defined temporal normalization for MCP tools — which tools get time-window handling, per-tool argument profiles, and attribute-discovery clamps.
engine: temporal_guardrails: tools: - get_logs - get_traces tool_profiles: get_alerts: alerts_window get_alert_rule_state: epoch_range prometheus_instant_query: instant_time_iso attribute_discovery_tools: - get_log_attributes - get_log_attributes_for_pipeline attribute_discovery_lookback_minutes: 5When tools is empty, temporal guardrails are disabled.
mithai supports three LLM providers:
anthropic(default): direct Claude API access via theanthropicPython SDK.bedrock: AWS Bedrock. Defaults to the unified Converse API (works across Anthropic/Llama/Cohere/Mistral models on Bedrock); setapi: responsesto reach OpenAI models via Bedrock’s Mantle gateway instead. Requirespip install 'mithai[bedrock]'.openai: direct OpenAI Responses API access via theopenaiPython SDK. Requirespip install 'mithai[openai]'.
Switch by setting llm.provider and providing the matching config block.
Anthropic (default)
Section titled “Anthropic (default)”llm: provider: anthropic model: claude-sonnet-4-6 # or claude-opus-4-6, claude-haiku-4-5 max_tokens: 4096 anthropic: api_key: ${ANTHROPIC_API_KEY}Recommended models:
| Model | When to use |
|---|---|
claude-sonnet-4-6 | Default. Best balance of capability and speed. |
claude-opus-4-6 | Complex reasoning, multi-step tasks, high-stakes decisions. |
claude-haiku-4-5 | High-volume, latency-sensitive, simple queries. |
AWS Bedrock
Section titled “AWS Bedrock”llm: provider: bedrock model: anthropic.claude-sonnet-4-20250514-v1:0 # any Bedrock model ID max_tokens: 4096 bedrock: access_key_id: ${AWS_ACCESS_KEY_ID} secret_access_key: ${AWS_SECRET_ACCESS_KEY} region: ${AWS_REGION} session_token: ${AWS_SESSION_TOKEN} # optional — only for temporary (STS) credentialssession_token is only needed when using temporary credentials (STS-issued, assumed roles). Omit it for long-lived IAM user keys.
Container credentials: set credential_source: container instead of any keys to read short-lived credentials only from the AWS container-credentials endpoint. mithai requires AWS_CONTAINER_CREDENTIALS_FULL_URI (a loopback URL) and AWS_CONTAINER_AUTHORIZATION_TOKEN in its environment, fetches credentials at startup and refreshes them before they expire, with no restart. Shell-skill and other child processes do not inherit AWS_CONTAINER_AUTHORIZATION_TOKEN. It never falls back to env keys, the shared AWS config or the instance profile: a missing variable or a failed first fetch stops startup. AWS_PROFILE and the shared config file are ignored in this mode; AWS_ENDPOINT_URL_BEDROCK_RUNTIME and AWS_ENDPOINT_URL_BEDROCK still set the endpoints. Combining it with api_key, access_key_id, secret_access_key or session_token is a config error, and api: responses does not support it.
llm: provider: bedrock model: anthropic.claude-sonnet-4-20250514-v1:0 bedrock: region: ${AWS_REGION} credential_source: containerThe model name is the Bedrock model ID, not the Anthropic alias. Some examples:
| Bedrock model ID | Equivalent |
|---|---|
anthropic.claude-sonnet-4-20250514-v1:0 | Claude Sonnet 4 |
anthropic.claude-opus-4-20250514-v1:0 | Claude Opus 4 |
anthropic.claude-haiku-4-5-20251001-v1:0 | Claude Haiku 4.5 |
meta.llama3-3-70b-instruct-v1:0 | Llama 3.3 70B |
The Bedrock Converse API is uniform across model families, so switching models is just a config change. Install: pip install 'mithai[bedrock]'.
Prompt caching: for Claude models that support it on Bedrock (Claude 3.5 Haiku, 3.7 Sonnet and every Claude 4+ model), mithai sends Converse cachePoints after the tool list, after the stable system prompt and on the last message, with the default 5-minute TTL. Memory, daily observations and the Temporal Context block go after the last system cachePoint, so their per-turn changes do not invalidate the cached tool list and stable system prefix. They are not excluded from caching: the last-message cachePoint covers the whole cumulative prefix, so they count toward that request’s cache writes. Other model families are sent without cachePoints. When model is an application inference profile ARN, mithai looks up its base model with bedrock:GetInferenceProfile at startup and caches once the lookup resolves; if the lookup fails, calls stay uncached, a warning is logged, and the lookup is retried after a backoff that doubles from 1 minute to at most 1 hour. pricing_model (anthropic.claude-sonnet-4-6 or bare claude-sonnet-4-6) overrides the lookup.
IAM permissions: the credentials need bedrock:InvokeModel for every model the agent will use. If the agent is managed by multi-mithai, the credentials additionally need sts:GetCallerIdentity — the orchestrator uses it for connection validation; standalone mithai never calls STS.
OpenAI
Section titled “OpenAI”Direct access to OpenAI’s Responses API via the openai Python SDK. Requires pip install 'mithai[openai]':
llm: provider: openai model: gpt-5.6-terra max_tokens: 16384 openai: api_key: ${OPENAI_API_KEY} reasoning_effort: medium # optional: none | low | medium | high | xhigh | max (default: medium)Supported models: gpt-5.6-terra, gpt-5.6-sol, and gpt-5.6-luna are certified for use with mithai’s tool-calling loop, each verified on both direct OpenAI and AWS Bedrock Mantle (text turn, tool call, tool-result continuation, streaming). Using a different OpenAI model requires independently verifying it supports the Responses API and client-side function calling — mithai does not validate this for you.
gpt-5.6-luna— fast/cheap, for high-volume use.gpt-5.6-terra— balanced, for everyday use.gpt-5.6-sol— most capable, for frontier/agentic work.
Install: pip install 'mithai[openai]'.
Retention: mithai always sends store: false on direct OpenAI Responses requests, matching the Bedrock Mantle policy. OpenAI’s default is store: true, which retains request input and output for 30 days and makes them browsable in the org’s dashboard logs. store: false also requests include: ["reasoning.encrypted_content"] so encrypted reasoning is replayed on the next request instead of relying on server-side state.
OpenAI via AWS Bedrock (Mantle gateway)
Section titled “OpenAI via AWS Bedrock (Mantle gateway)”GPT-5.6 Terra, Sol, and Luna are also reachable through AWS Bedrock’s “Mantle” gateway, which speaks the OpenAI Responses wire format under a Bedrock-specific base URL. This is a distinct transport from the Bedrock Converse API described above: these models support only the Responses API on Bedrock — not Converse, Invoke, or ChatCompletions. So provider: bedrock with an openai.* model requires api: responses explicitly; omitting api: keeps the default converse behavior, which does not support these models.
llm: provider: bedrock model: openai.gpt-5.6-terra # Bedrock Mantle model IDs are prefixed "openai." — direct OpenAI uses the bare id max_tokens: 16384 bedrock: api: responses # required for OpenAI models — "converse" (default) does not support them api_key: ${AWS_BEDROCK_API_KEY} region: us-east-1 reasoning_effort: medium # optional: none | low | medium | high | xhigh | max (default: medium)Key reference:
| Key | Required for OpenAI models | Notes |
|---|---|---|
api | yes | converse (default) or responses. GPT-5.6 Terra/Sol/Luna require responses. |
api_key | yes | Bedrock API key (bearer token) only — this release supports Bedrock API key auth only for api: responses; IAM/SigV4 auth is supported by the SDK but not yet wired up in mithai. AWS recommends short-term keys for production; long-term keys are documented by AWS as exploration-only. |
region | yes | Model-dependent — see the region table below. GPT-5.6 models on Bedrock Mantle are In-Region only — no Geo or Global cross-region routing. |
reasoning_effort | no | none, low, medium, high, xhigh, max (default: medium). |
Region availability differs per model:
| Model | Regions |
|---|---|
openai.gpt-5.6-terra | us-east-1, us-east-2, us-west-2 |
openai.gpt-5.6-luna | us-east-1, us-east-2, us-west-2 |
openai.gpt-5.6-sol | us-east-1, us-east-2 only — narrower per its AWS model card |
Data residency: because there is no Geo/Global routing option for these models on Bedrock, prompts are processed entirely within the region you configure. This matters for deployments outside the US.
Retention: mithai always sends store: false on Bedrock Responses requests. AWS’s default is store: true, which retains request input and output for 30 days in-region — store: false avoids that retention. To keep reasoning context available across tool-call turns without relying on Bedrock to retain server-side state, mithai additionally requests include: ["reasoning.encrypted_content"] so encrypted reasoning is replayed on the next request instead.
Cost and context window differ by transport for the same model: Bedrock Mantle caps context at 272K tokens versus ~1M direct on OpenAI, for all three models.
| Model | Direct input / output (per 1M) | Bedrock input / output (per 1M) |
|---|---|---|
gpt-5.6-luna | $0.20 / $1.20 | $0.22 / $1.32 |
gpt-5.6-terra | $2.00 / $12.00 | $2.20 / $13.20 |
gpt-5.6-sol | $5.00 / $30.00 | $5.50 / $33.00 |
gen_ai.system telemetry label: openai (direct) vs aws.bedrock (Mantle) — same model string, priced differently per transport.
Install: pip install 'mithai[openai]' (the Bedrock Mantle transport reuses the openai package under the hood — the bedrock extra alone is not sufficient for this path).
Common settings
Section titled “Common settings”max_tokens controls the maximum length of each LLM response. 4096 is a good default. Raise it to 8192 or higher for skills that produce long outputs (e.g., log analysis, code review). On provider: openai and provider: bedrock with api: responses, values below 4096 are raised to 4096 because the Responses API bills reasoning tokens against max_output_tokens; set reasoning_effort: none to opt out of that floor.
skills
Section titled “skills”skills: paths: - ./skills # directories to scan for skills - /opt/shared/skills # additional paths config: shell: # skill name → config dict passed as ctx["config"] allowed_commands: - "df -h" - "uptime" approval_auto_promote: 3 services: services: checkout: url: https://checkout.internal/health billing: url: https://billing.internal/healthskills.paths lists directories. Each subdirectory with a prompt.md and tools.py is loaded as a skill.
skills.config maps skill names to arbitrary config dicts. A skill receives its config as ctx["config"] in every handler call.
Controls the human-in-the-loop protocol globally.
human: timeout_seconds: 300 # how long to wait for approval before timing out (default: 300) overrides: shell__run_command: confirm # escalate a tool's approval level kubernetes__get_pods: null # de-escalate to auto-execute services__restart_service: approve # override regardless of resolve_humanoverrides keys are skillname__toolname. Valid values: null, "approve", "confirm".
Overrides take effect after resolve_human — they are the final word on approval level.
verifier
Section titled “verifier”Post-turn fact-checker. After each agent turn, a secondary LLM call checks that the agent’s response does not contradict what the tools actually returned.
verifier: model: claude-haiku-4-5 # cheap model; falls back to main LLM if omittedThe verifier only runs when at least one skill that has opted in via VERIFY = True (in tools.py) was called during the turn. No skill opts in by default — it is opt-in per skill. When a contradiction is detected, the agent’s response is annotated with a ⚠️ warning.
To opt a custom skill into verification, add to its tools.py:
VERIFY = Truelearning
Section titled “learning”Controls the agent’s memory and self-learning behaviors.
learning: enabled: true reflection: true # write a daily reflection after each session approval_auto_promote: 3 # approve N times with 0 denials → auto-execute memory: backend: filesystem filesystem: path: ./memory # root directory for memory filesreflection: true runs a background LLM call after each conversation and appends a brief summary to memory/daily/YYYY-MM-DD.md.
approval_auto_promote is the global default. Skills can override it in their own config (e.g., skills.config.shell.approval_auto_promote).
Persistent key-value store for session state.
state: backend: filesystem filesystem: path: ./.mithai/stateThe state backend stores session history and tool metadata. Don’t change the path unless you know what you’re doing — the agent won’t find past sessions if you move it.
sessions
Section titled “sessions”Bounds on conversation history — how much is sent to the LLM per turn, how much is kept in storage, and for how long.
sessions: max_stored: 50 # max turns retained per session on disk (default 50) max_history: 10 # max recent turns sent to the LLM per call (default 10) retention_days: 90 # expire a conversation once its last message is this many days old — no defaultretention_days has no default and no fallback — mithai supplies only the mechanics; the duration is a product-policy decision the consumer must supply (see retention below for what happens if you omit it or set it to an invalid value). It expires a whole conversation by inactivity, not individual turns by their own age: a conversation whose last message is older than retention_days is expired in full (its turns and any pending observations); a conversation with even one message inside the window keeps every one of its turns, however old the earliest ones are. This is independent of max_stored (a turn-count cap, unrelated to time).
All three fields are overridable per agent in multi-agent mode (agents.<id>.sessions).
retention
Section titled “retention”Controls the background sweep that enforces sessions.retention_days against stored conversation turns and memory/daily/*.md reflection files — and, identically, filters every read/replay path (session listing, search, live conversation context) so an expired conversation can’t be served before the next sweep physically deletes it.
retention: enabled: true # no default — off unless set explicitly interval: 3600 # seconds between sweeps (default 3600 if enabled and unset)Neither sessions.retention_days nor retention.enabled has a default. Retention is off unless you explicitly set retention.enabled: true, and if you do, sessions.retention_days must also be a valid positive integer — mithai fails startup rather than guessing a duration or leaving retention silently off when you asked for it. retention.interval is generic scheduling mechanics (not a policy value), so it does default to 3600 seconds when retention is enabled but interval is omitted.
The first sweep after this is enabled on an existing deployment retroactively applies retention_days against every pre-existing conversation in one pass — size that impact (how many conversations, not turns, predate the cutoff) before rolling this out to a deployment with real history, since deleted turns and reflection files are not recoverable.
In multi-agent mode every agent shares one sessions store, so session listing, lookup and search are scoped to the agent that owns each session: an agent never reads another agent’s conversations (judged against the wrong retention window), and sessions with no recorded owner are not served to any agent until the sweep claims or quarantines them. The embedded dashboard reads through the running engine’s session manager, so it applies the same window and the same scoping.
Agents that share one memory location — the same filesystem path, Redis URL + prefix, or S3 bucket + prefix — must agree on retention.enabled and sessions.retention_days, because daily reflection files are not scoped per agent; mithai refuses to start otherwise. Give each agent its own memory.path to use different windows.
A memory backend that doesn’t support delete() is logged and skipped for reflection cleanup on that tick — this means the sweep did not fully complete for that store; check the retention log line’s daily-reflection count before assuming a deployment’s reflections are actually being pruned.
mcp_servers
Section titled “mcp_servers”External Model Context Protocol servers. Skills declare which servers they use via MCP_TOOLS. The framework starts only the servers that are needed.
mcp_servers: linear: transport: sse url: https://mcp.linear.app/sse headers: Authorization: Bearer ${LINEAR_API_KEY}
github: transport: sse url: https://api.githubcopilot.com/mcp/ headers: Authorization: Bearer ${GITHUB_TOKEN}Set tools to restrict which of a server’s tools are exposed to the LLM — useful for servers with a large or sensitive tool surface. The default "*" exposes all discovered tools; with a list, the tool router filters before tools are offered to the model.
mcp_servers: last9: transport: streamablehttp url: ${LAST9_MCP_URL} coverage_metadata_key: last9/coverage tools: - get_alerts - get_service_summary - get_exceptionsagents (multi-agent mode)
Section titled “agents (multi-agent mode)”Run multiple independent agents from a single process. Each agent has its own adapter, skill set, system prompt, and memory.
agents: devops: name: "DevOps Agent" system_prompt: | You are a DevOps assistant. Focus on infrastructure and deployments. skills: allowed: [shell, kubernetes, aws, memory] adapter: slack: bot_token: ${DEVOPS_SLACK_BOT_TOKEN} app_token: ${DEVOPS_SLACK_APP_TOKEN} memory: path: ./memory/devops
triage: name: "Triage Agent" system_prompt: | You are an incident triage assistant. skills: allowed: [shell, github, memory] adapter: slack: bot_token: ${TRIAGE_SLACK_BOT_TOKEN} app_token: ${TRIAGE_SLACK_APP_TOKEN} memory: path: ./memory/triageWhen agents is present, the top-level adapter, skills, and llm blocks act as defaults inherited by each agent. Agent-level config overrides defaults.
Environment variables
Section titled “Environment variables”Any ${VAR} in config.yaml is substituted at load time from the process environment. mithai also loads .env in the working directory automatically.
ANTHROPIC_API_KEY=sk-ant-...SLACK_BOT_TOKEN=xoxb-...SLACK_APP_TOKEN=xapp-...If a referenced variable is not set, mithai logs a warning and substitutes an empty string.
CLI flags
Section titled “CLI flags”A subset of config values can be overridden with CLI flags:
mithai run --adapter slack # override adapter.typemithai run --config path/to/config.yaml # use a different config filemithai run --verbose # enable debug loggingmithai chat --agent devops # use a specific agent in multi-agent modeCLI flags take precedence over config.yaml.