Iris Docs

Configuration

Environment variables, CLI flags, and the security posture of the internal API.

Iris reads configuration from /iris/.env (written by bootstrap) and CLI flags (--provider, --model, --sandbox, --transport, --api-port). Flags override env vars.

Environment variables

VariableDefaultPurpose
IRIS_PROVIDER / IRIS_MODELanthropic / provider defaultLLM provider and model (see data/models.json)
IRIS_SLACK_APP_TOKEN / IRIS_SLACK_BOT_TOKENSlack tokens; presence enables the Slack transport
TELEGRAM_BOT_TOKENTelegram token; presence enables the Telegram transport
IRIS_WEBUI_PORTPresence enables the built-in web chat transport, bound to 127.0.0.1
IRIS_WEBUI_PASSWORDShared-secret login for the web UI. Unset = no auth gate (fine for loopback-only use; set before exposing via serve-public)
IRIS_ENVprodpreview | prod
IRIS_API_PORT / IRIS_API_HOST3000 / 127.0.0.1Internal HTTP API bind (always on)
IRIS_API_TOKENWhen set, API requires Authorization: Bearer <token> (except /health)
IRIS_BRIDGE_PORT / IRIS_BRIDGE_HOST— / 127.0.0.1Sub-agent bridge server (sub-agents only)
IRIS_BRIDGE_IDLE_TIMEOUT_MS / IRIS_BRIDGE_MAX_MS180000 / 600000Bridge request lifetime: no-progress deadline, and hard ceiling (Sub-agents)
IRIS_BRIDGE_HEARTBEAT_MS / IRIS_BRIDGE_LEGACY_TIMEOUT_MS15000 / 240000Streaming keepalive cadence; ceiling for non-streaming bridge requests
IRIS_BRIDGE_STATUS_THROTTLE_MS3000Minimum gap between chat edits when forwarding sub-agent progress
IRIS_LLM_TIMEOUT_SECS90Per-attempt LLM timeout
IRIS_LLM_MAX_RETRIES / IRIS_LLM_RETRY_BASE_MS3 / 2000Retry with exponential backoff on 429/timeout/transient errors
IRIS_COMPACT_THRESHOLD / IRIS_COMPACT_TARGET0.7 / 0.1Pre-run auto-compaction trigger/target (fraction of context window)
IRIS_SLACK_MAX_CHARS30000Safe Slack message length before splitting
IRIS_ATTACHMENT_DOWNLOAD_TIMEOUT_MS10000How long a live Slack message bound-waits for its own file attachments to finish downloading before dispatching for processing anyway — see Troubleshooting
IRIS_INTERRUPTED_RUN_MAX_AGE_HOURS4On startup, don't re-dispatch an interrupted run whose user message is older than this. Stale placeholders are still cleaned up; only the LLM run is skipped. Raise it if you want long outages resumed, set it very high to always resume
IRIS_TELEGRAM_FORCE_RECLAIMSet true + restart to transfer bot ownership
IRIS_VERBOSE_TOOLS— (quiet)Default verbose tool-call/thinking output on Slack/Telegram. Quiet by default — a run shows a single status line that updates in place instead of a full per-tool-call/thinking dump. Overridable per channel at runtime with verbose on / verbose off / verbose status (Slack) or `/verbose on
IRIS_BASH_POLICYonSet off to disable the bash policy layer's refusals and confirmation gates (the command audit log stays on) — see Bash Policy Layer
IRIS_BASH_AUDIT_FILE<workspace>/meta/bash-audit.logLocation of the append-only bash command audit log — see Bash Policy Layer
IRIS_GITHUB_ORG / IRIS_GITHUB_REPOThe repo Iris commits her own skills, sub-agents, and self-edits to (the github skill's push target — see Extending Iris). Use your own private overlay repo, or a private mirror of iris-core — never the upstream you cloned from, and never a public fork, since Iris's memory lands here. Prompted by bootstrap alongside the GitHub token; also injected into the constitution as Iris's identity source
IRIS_KEY_VAULTAzure Key Vault name (Key Vault profile only)
IRIS_SECRETS_MODEstorestore (default) | proxy | env (legacy opt-out) — credential backend, see Secrets
IRIS_SECRET_KEY_FILE / IRIS_SECRET_STORE_FILE/iris/secret.key / /iris/secrets.json.encEncrypted store paths (store mode)
IRIS_BROKER_PORT / IRIS_BROKER_HOST9099 / 127.0.0.1iris-broker daemon bind (proxy mode)
IRIS_BROKER_SERVICES_FILE/iris/broker/services.jsonOperator overrides for the injection gateway's service map
IRIS_SECRET_BROKER_URL / IRIS_SECRET_BROKER_TOKENWhen set, GET /secrets/:name proxies here instead of env/Key Vault/store — points at the bundled iris-broker (proxy mode), Vault, Infisical, or any HTTP service speaking the same tiny contract
IRIS_BASE_DOMAIN / IRIS_EMAIL_FROMPublic serving domain / outbound email sender
LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEYPresence enables Langfuse tracing (one session-correlated trace per turn). LANGFUSE_HOST, LANGFUSE_ENVIRONMENT, LANGFUSE_RELEASE, LANGFUSE_TIMEOUT_MS, LANGFUSE_ENABLED=false, LANGFUSE_CAPTURE_IO=false (drop prompt/reply/tool payloads) tune it — see Observability
PASSTHROUGH_API_KEYFallback API key for passthrough channels (see Channel Modes)

Models and providers

The runtime loads provider endpoints and model definitions from <workspace>/models.json (generated from data/models.json.template at bootstrap). Anthropic and OpenAI work out of the box; custom endpoints (Azure AI Foundry, DeepSeek, Mistral, AWS Bedrock) are defined in the template. Switch with:

IRIS_PROVIDER=anthropic
IRIS_MODEL=claude-sonnet-4-5

For Azure AI Foundry (azure-foundry), bootstrap asks for the bare account name (e.g. my-account-eastus2), not the full endpoint URL. Pasted URLs or hostnames are trimmed automatically, and the generated baseUrl is validated — bootstrap aborts on a malformed hostname and warns if it doesn't resolve in DNS. Its Kimi-K2.5 / Kimi-K2.6 entries declare "input": ["text", "image"] — both are natively multimodal.

Each model entry's "input" array is a capability declaration, not a hint: pi-ai strips image content out of the request for any model that doesn't list "image", silently on the wire. Iris now catches this at her end too — an image attachment sent to a text-only model is diverted into a <dropped_image_attachments> note in the prompt (and a logged warning) instead of being sent and vanishing, and the read tool reports the same file as unreadable rather than claiming success. If a model here does accept images but a Slack/Telegram photo isn't getting through, check "input" for that model id first.

Separately, "is this attachment an image" is decided from the file's actual content (magic-byte sniffing), not its filename or extension — a Telegram document with no filename, or any attachment with a missing/wrong extension, is still recognized correctly. If "input" includes "image" and an attachment still isn't picked up, it likely isn't a supported format (jpeg, png, gif, webp).

An image over 2000px on its longest edge or 4.5MB once base64-encoded is downscaled before it's sent — an unresized phone photo can otherwise exceed a provider's per-image payload limit and fail the whole turn. This is automatic and has no env var to tune; a decode failure just falls back to sending the original size as-is. An animated GIF or WebP over those limits is sent as-is rather than resized, to avoid silently collapsing it to a single still frame.

A PDF attachment is listed in the prompt like any other non-image file; the read tool then extracts its text layer via the core-shipped pdf-text read handler (pdftotext from poppler-utils, installed by bootstrap.sh) rather than handing the model raw binary. A scanned or image-only PDF has no text layer and reads back empty — there's no OCR fallback in core, so a photo of a document should be sent as an image instead, or an overlay can add its own OCR-capable handler.

The openai provider defaults to gpt-5.6-luna (gpt-5.4-mini and gpt-5.6-terra are also available) and uses pi-ai's openai-responses module, not openai-completions — these models 400 on /v1/chat/completions when tool calls are present unless reasoning_effort is explicitly "none", which openai-completions has no way to force. Do not switch it back to openai-completions.

DeepSeek (deepseek) and Mistral (mistral, including Devstral) need only an API key — both go through pi-ai's openai-completions provider module, since Mistral's /v1/chat/completions endpoint is OpenAI-compatible and pi-ai's native mistral provider module hangs indefinitely on every call (see the Fixed entry in iris-runtime/CHANGELOG.md — do not switch Mistral's api back to "mistral"). Both ship ready-to-use model entries in the template (deepseek-chat / deepseek-reasoner, devstral-medium-latest / mistral-large-latest / mistral-medium-latest). mistral-medium-latest (Mistral Medium 3.5 — 256k context window, text+image input) is the largest-context model in the Mistral lineup; devstral-medium-latest and mistral-large-latest remain selectable.

For any other OpenAI-compatible endpoint (Kimi/Moonshot direct, a self-hosted vLLM/Ollama gateway, etc.), pick custom — bootstrap asks for a short provider name (used as the models.json key), the base URL, the API key, and the exact model id the endpoint expects, and writes a fresh openai-completions provider block. To add one without bootstrap, add a block by hand — see data/README.md for the shape.

azure-foundry was named foundry-e2 before this repo supported more than one custom provider; the name was a leftover from its original eastus2 deployment. Bootstrap migrates IRIS_PROVIDER=foundry-e2 and the FOUNDRY_E2_KEY/FOUNDRY-E2-KEY secret automatically on re-run, but a hand-edited models.json needs its foundry-e2 key renamed manually.

MCP servers

External toolsets connect via <workspace>/meta/mcp.json (optional, hot-reloaded per message; secrets referenced as ${VAR} from .env) — see MCP Servers.

Internal API security

The internal API binds to loopback by default. If sub-agent containers reach Iris via the Docker gateway (172.18.0.1:3000), set IRIS_API_HOST=0.0.0.0 and IRIS_API_TOKEN — never expose the API beyond loopback without a token. Iris logs a warning at startup if you do.

LLM resilience

Two mechanisms keep long-running channels healthy:

  • Retry with backoff — failed LLM calls (429, timeout, connection reset) retry up to IRIS_LLM_MAX_RETRIES times with jittered exponential backoff, posting a visible _Retrying (n/3)..._ notice.
  • Auto-compaction — before each prompt, if the estimated context exceeds IRIS_COMPACT_THRESHOLD (default 70%) of the model window, Iris summarises older history down toward IRIS_COMPACT_TARGET (up to 3 passes). This is a char-count estimate over the system prompt and message history computed before the new turn's message (and any image attachments) is appended, so it can't see an oversized attachment in the turn that introduces it — the post-run check at ≥70% real usage (hardcoded, not env-configurable) is the backstop that catches that case, using actual token counts from the provider's response.

Prompt caching

Anthropic and Bedrock cache the prompt on a strict byte-for-byte prefix match: any change anywhere in the system prompt invalidates the entire cached prefix (tools + system + full message history) for that turn. The system prompt is kept fully static across turns for a given channel and skill set — MEMORY.md contents and live MCP server connection status, both of which change turn to turn, are prepended to the current turn's user message instead (visible as a <dynamic_context> block in last_prompt.jsonl), after the cached history, where their churn doesn't invalidate anything. The Slack/Telegram channel/user directory embedded in the system prompt is also sorted by id before rendering, rather than left in Map insertion order — insertion order reorders every time a new user or channel is discovered, which would invalidate the cache even though nothing about the directory's actual content changed.

Iris's per-channel Agent is constructed with sessionId set to the channel id. Anthropic and Bedrock ignore it — they cache off explicit cache_control breakpoints — but pi-ai's openai-responses provider forwards it as prompt_cache_key, and Mistral's native provider uses it for the x-affinity header (KV-cache prefix affinity). Channel id is a stable, natural session boundary since each channel already gets its own long-lived runner and message history.

Edit this page on GitHub