Q&A bot
Ask your infrastructure questions in chat and get answers grounded in real data: "is anything firing right now?", "what's happening with disk on prod?", "what did this morning's digest say?". An LLM agent answers by calling the same read-only tools the MCP server exposes — source health, digests, the reports archive, live alerts, configured metric queries, and arbitrary read-only PromQL and LogQL log queries. The agent can also consume read-only tools from external MCP servers you configure (a runbook server, a cloud provider's MCP endpoint).
Two transports are supported and can run at once: Telegram (getUpdates long-poll) and Slack (Socket Mode). Each is enabled independently by its own fail-closed allowlist; both share one reasoning provider and one daily budget.
Enable — Telegram
bot:
telegram:
# Optional: falls back to notify.telegram.token when empty — the common
# case is one bot identity doing both delivery and Q&A.
token: ""
# The transport is enabled iff this list is non-empty. Messages from any
# other chat are dropped without a reply.
allowed_chat_ids: [123456789]
llm:
provider: openai # a reasoning provider is required — see below
Getting a bot token and your chat ID works exactly like for the Telegram notification channel. Reusing the delivery bot's token is fine: replies and digest deliveries do not conflict.
In the Helm chart, list the chat IDs under config.bot.telegram.allowed_chat_ids;
the token falls back to TELEGRAM_TOKEN from existingSecret, or provide
BOT_TELEGRAM_TOKEN there for a separate bot identity.
Enable — Slack
bot:
slack:
# App-level token (xapp-…) for the Socket Mode WebSocket. Required.
app_token: "xapp-..."
# Bot token (xoxb-…) for replies. Falls back to notify.slack.token when
# empty — reusing the Web API delivery token is fine.
bot_token: ""
# The transport is enabled iff this list is non-empty. Mentions in any
# other channel are dropped without a reply.
allowed_channel_ids: [C0123ABCD]
llm:
provider: openai
Slack uses Socket Mode, so
the pod dials out to Slack over a WebSocket — there is no inbound endpoint to
expose, matching the self-hosted posture of the Telegram long-poll and the MCP
stdio server. Create a Slack app, enable Socket Mode, add an app-level token
with the connections:write scope (xapp-…, → app_token) and a bot token
(xoxb-…, → bot_token) with app_mentions:read and chat:write, then
subscribe to the app_mention event. Invite the bot to each channel and copy
its channel ID (the C…/G… id, not the name) into allowed_channel_ids.
Operators talk to the Slack bot by @-mentioning it in an allowlisted
channel; the answer lands as an in-thread reply. Direct messages are not part
of v1 — the allowlist is by channel. In the Helm chart, list the channel IDs
under config.bot.slack.allowed_channel_ids; provide BOT_SLACK_APP_TOKEN
(required) in existingSecret, and BOT_SLACK_TOKEN (falls back to
SLACK_TOKEN).
Reasoning provider
The bot requires a reasoning provider (openai or anthropic). With
llm.provider: none (or mock) the config is rejected at validation:
free-form questions cannot be answered deterministically, and a template
answer would be a fake one.
How it answers
Each question runs an agent loop: the model inspects the question, calls tools
(up to 8 rounds), and composes a reply. Telegram replies are capped at
Telegram's 4096-character limit and formatted via Telegram HTML (bold, italic,
inline code, code blocks); if Telegram rejects a formatted payload, the reply
is resent as plain text — a formatting problem degrades to an unformatted
reply, not a lost one. Slack replies are capped at 4000 characters and use
Slack mrkdwn (*bold*, _italic_, `code`, fenced blocks).
- Budget: every reasoning turn takes one unit from
llm.max_calls_per_day, so one question costs up to 9 calls (see the call-volume table). The cap is shared across both transports. When the daily budget is exhausted, the bot refuses honestly in chat — it never synthesizes an answer without the model. - Read-only: the bot's tool set excludes
trigger_digest(and any future tool with side effects). Asking it to "send the digest now" cannot make it happen. - One at a time: each transport processes questions sequentially; a burst queues (on Telegram's side, or in a bounded in-memory queue for Slack) and is answered in order.
- Time limits: a question is bounded by a 3-minute wall clock and per-tool timeouts; hitting a limit produces an honest "try a narrower question" reply.
External MCP tool servers
The agent can consume tools from external MCP servers you run — a
runbook lookup server, a cloud provider's MCP endpoint — so it can answer with
context InfraSigns itself doesn't hold. Discovered tools join the agent's
catalog namespaced as <server>__<tool> (so they never collide with native
tools) and are offered to the model alongside the built-in ones. This is a
self-hosted-only feature: like the MCP server and the Q&A bots, it is
suppressed in the hosted service.
bot:
telegram:
allowed_chat_ids: [123456789] # a Q&A transport must be enabled to consume them
mcp_servers:
- name: runbooks # [A-Za-z0-9_-], ≤40; prefixes every tool
url: https://runbooks.internal/mcp # MCP HTTP (streamable) endpoint
token: "${RUNBOOKS_TOKEN}" # optional bearer; https required off localhost
tools: [list_runbooks, get_runbook] # per-server allowlist (see below)
- Per-server allowlist is the authority. Only the tools you name in
toolsare ever exposed to the agent — a server that advertises more is ignored for the rest. This is the trust decision, not the server's own metadata. - Read-only, defense in depth. A tool the server affirmatively marks as a
writer (
readOnlyHint: false) is rejected even if allowlisted — the agent takes no actions. A tool with no read-only annotation is trusted to your allowlist. The server's hints can only veto, never grant. - Connected at startup. Servers are dialed and their tools discovered when the daemon starts; a server that is down contributes no tools and logs a warning — the daemon keeps running with its native tools and the reachable servers. Sessions persist and reconnect for the process lifetime.
- Same untrusted-data handling for results. An external tool's result is the most untrusted class there is: it is framed as untrusted data, control-stripped, and length-capped exactly like every native tool result (the injection posture below).
- Trust the server for its tool metadata, not just its results. A tool's
name, description, and input schema arrive off the wire and are sent to the
model as catalog text (a description is authoritative capability text, a
stronger position than a result labelled untrusted). InfraSigns control-strips
and length-caps the description and rejects a non-object/oversized schema, but
it cannot neutralize a plain-language instruction a malicious server puts in a
description. Point
mcp_serversonly at servers you trust to describe their own tools honestly — the allowlist gates which tool names run, not what their descriptions say. - Not re-exported. External tools join the agent only; they are never advertised on InfraSigns' own MCP server (no proxying a third party's tools through our endpoint).
Security model
- Fail-closed allowlist:
allowed_chat_ids/allowed_channel_idsis the trust boundary — both for who can read your infrastructure data and for who can burn LLM budget. Messages from non-allowlisted chats/channels are dropped silently (no reply, so the bot's existence is not confirmed to strangers) and counted in theunauthorizedmetric status. A flood from an allowlisted chat is an accepted residual, bounded by the daily budget cap. - Prompt injection (#81 posture): tool results carry external data (label values, alert annotations) and are framed as untrusted data, control-stripped, and length-capped; the operator question is length-capped and never spliced into the system prompt.
Operational notes
- Run a single instance. On Telegram this is a hard requirement: Telegram
allows only one
getUpdatesconsumer per token, so a second replica — or a dev instance on the prod token — makes both flap with409 Conflict. Slack Socket Mode is more forgiving (it permits several concurrent connections and load-balances events across them), but a second replica would run its own uncoordinated daily budget, sollm.max_calls_per_daywould only hold per process. Either way, keep it to one instance (the Helm chart is single-replica already) and use separate tokens for local experiments. Using the same token for notification delivery is not a conflict — only two inbound listeners are. - Restarts: on Telegram a question that was in flight (or arrived while the daemon was down) is re-delivered on the next start — updates are kept for 24 hours — with the flip side that questions answered since the last poll cycle may be answered a second time after a restart (confirmation is one poll behind), re-spending their budget cost. On Slack an in-flight question is not re-delivered (the mention was already acknowledged); the operator re-asks, and a best-effort "restarting" reply says so.
- Metrics:
infrasigns_bot_questions_total{transport,status}—transportistelegramorslack;statusisanswered,refused_budget,error, orunauthorized.infrasigns_bot_connected{transport}is1while the transport's live connection is up and0once it has been lost — the alerting surface for a zombie bot. Alert with a hold (infrasigns_bot_connected == 0 for 5m) so a brief reconnect doesn't page. The gauge tracks the connection lifecycle, not whether the bot is answering (see the residual cases below). The provider calls themselves land on the sharedinfrasigns_llm_requests_total{path="bot"}andinfrasigns_llm_request_duration_seconds{path="bot"}— one sample per conversation turn, so a question that takes N tool-calling steps records N, which is what it spends from the daily budget. - Readiness: each running transport appears in
/readyzundersubsystems.bot-telegram/subsystems.bot-slack. The value isokwhile the listener goroutine is running (a structural, gating check — it never fails on a transient hiccup), ordisconnectedwhen the transport's live connection has been lost: three consecutive Telegram poll failures (e.g. a persistent 409 from a second consumer on the same token) or three consecutive failed Slack Socket Mode dials — and immediately when a token is rejected (invalid_auth) or the connection gives up entirely. Thedisconnectedvalue is advisory and non-gating — the top-levelstatusstaysokand the pod keeps serving webhooks/UI/metrics, because a dead chat listener must not evict the pod from its Service endpoints. Only a structurally dead worker (which happens on shutdown) reports anerror:and gates. Alert on theinfrasigns_bot_connectedgauge for a machine signal. - What the connection signal does not catch: it tracks the connection
lifecycle, so
connected(gauge1,/readyzok) means "the transport is connected", not "the bot is answering". It can't see (a) a connection that fails intermittently but never three times in a row — one success resets the streak (both transports debounce on consecutive failures); or (b) a Slack socket that is connected but has silently stopped delivering events, or whose ack writes fail (Socket Mode's own keepalive eventually recycles such a socket). For those, watch theinfrasigns_bot_questions_totalrate alongside the connection gauge.