Skip to content

ChatOps

Chat is the harness’s primary interface — for requests from humans and for the unprompted messages the harness raises itself (Proactive alerts). The channels shipping today are Google Chat (the reference channel; enable with the installer’s --enable-google-chat) and Slack (enable it in the installer’s chat menu, or with --enable-slack). Both are opt-in and default to disabled.

Both channels terminate at the Planning Agent — the default Hermes profile in the agent pod, and the only profile that receives chat ingress. It knows which specialists exist because the roster is injected into every turn by the agent_roster plugin (its router MCP tool list_agents re-reads the same list on demand), and files the work as a card on the shared kanban board (kanban_create), assigned to the specialist that can execute it. Results come back on their own: the gateway posts each completed card’s answer into the thread verbatim, and the Planning Agent handles the hand-off and anything that blocks or fails. The Platform Agent does the actual infrastructure work as a delegated kanban worker, and per-cluster Cluster Agents handle single-cluster runtime debugging; neither receives chat directly. A user still sees a single conversational agent regardless of channel — the delegation is visible only as progress updates in the thread. The design of record for this coordination model is docs/designs/agent-communication.md.

Under spec.mode: next, Google Chat terminates at the A2A gateway instead: the same Chat app, topic and subscription, pulled through the credential broker by the gateway rather than by the Planning Agent, with the same allowedUsers list deciding who is answered. The Planning Agent’s Google Chat platform is off on such an install, so proactive alerts and scheduled reports reach the home channel through the gateway instead, which posts them there as new threads or as replies in those threads; a reply a person types in one of those threads starts a new conversation rather than continuing the investigation that raised the alert. The reports of the cards a triage files reach the alert’s thread the same way, and so do the answers of the cards the agent files for a question you asked it in chat: they post in the thread or DM you asked in, whether or not a home channel is configured (a home channel that is set but is not a space name turns this off too). Either way there are two differences from the Planning Agent’s own delivery: each progress note posts as a message of its own, because the gateway can post but not edit, and a file the card produced is not attached. The design of record is docs/designs/spec-chatops-gateway.md.

Google Chat is the reference channel. Setup is automated by the install: the chat-pubsub Terraform module provisions the backend when Google Chat is enabled (the installer’s --enable-google-chat, or enable_google_chat = true in terraform.tfvars).

  • A Pub/Sub topic and subscription are created in the target GCP project.
  • Your Google Chat app (configured separately in the Chat API console) publishes events to the topic. A GCP project holds only one Chat app, so a project already running one cannot also run this — see Prerequisites.
  • The Planning Agent (the pod’s default Hermes profile) consumes the subscription through Hermes’ bundled Google Chat adapter, configured by the platforms.google_chat block of agents/chat/config.yaml.
  • Environment variables GOOGLE_CHAT_PROJECT_ID and GOOGLE_CHAT_SUBSCRIPTION_NAME are wired into the pod by the operator.

Google Chat ingress can be gated by GOOGLE_CHAT_ALLOWED_USERS (a comma-separated list of user emails, collected by the installer as ALLOWED_USERS). Leaving it empty allows all users — the operator sets GOOGLE_CHAT_ALLOW_ALL_USERS=true in that case.

spec.integration.googleChat.homeChannel on the PlatformAgent (surfaced to the pod as GOOGLE_CHAT_HOME_CHANNEL) is the space a notification lands in when there is no thread to reply into — which is every alert-driven investigation, since nobody started the conversation.

Unlike Slack’s, this one is not covered by /sethome. That command writes the Planning Agent profile, which is enough for the gateway’s own delivery, but a specialist runs as a kanban worker against its own profile and reads the value from the pod environment instead. Set GOOGLE_CHAT_HOME_CHANNEL in install.env (or pass --google-chat-home-channel), or configure spec.integration.googleChat.homeChannel on the PlatformAgent resource, before the first Google Chat message if you want the Planning Agent to use the same space (below). If left unset, alert-driven reports have nowhere to go — the investigation still runs and still opens its remediation PR, so the only visible symptom is silence in chat.

When no home channel is set and the install’s first chat message, on either platform, comes from Google Chat, it makes its space the Planning Agent’s home channel, the same write /sethome makes, so scheduled deliver: chat reports reach that space from then on. That is often a one-person DM: send the first message from the space the team watches, or run /sethome there later. The specialists above still read only the pod environment. Like /sethome, the write also goes to the profile’s .env, which outranks a homeChannel set on the CR afterwards. After the first message, a CR value still reaches the specialists, but the Planning Agent’s home channel moves only with /sethome.

Under spec.mode: next only the resource field counts. The operator hands it to the A2A gateway, which posts the agent’s proactive messages to that space and nowhere else, and /sethome does not reach it. The field must name a space (spaces/<id>); with it unset or malformed the agent posts nothing to Google Chat unprompted. Unset, the PlatformAgent carries the HomeChannelUnset condition and the installer warns before it applies.

  1. User DMs the app or @-mentions it in a space.
  2. Chat sends the message event to the topic; the Planning Agent consumes it from the subscription.
  3. The Planning Agent picks the right specialist from the injected roster and files a kanban card with the full request context (kanban_create).
  4. The gateway’s kanban dispatcher spawns the specialist — for infrastructure work, the Platform Agent (hermes -p platform) — which runs the tool loop and completes the card with a one-line summary and the full answer in result. A card filed from chat goes ahead of background triage, and one worker slot is held for it (Why dispatch is capped by default). If it has to wait anyway, the thread says ⏳ Queued: the system is busy. Your request will start when a worker frees up. and the specialist’s progress joins that message once it starts.
  5. The originating chat session is auto-subscribed to the card, so the thread fills itself: each kanban_heartbeat(note=…) milestone adds a ⏳ line to one rolling message that the notifier edits in place while the specialist works — so a card with five milestones notifies the space once, not five times — and the completion posts as a separate ✔ … done message carrying the result verbatim. The rolling message is then marked finished (✓, or ⏹ for a card that failed). The notifier delivers all of it directly and the Planning Agent is woken for none of it; it is woken when a card blocks or fails.

A card that also produced a file gets the file’s contents pasted into the thread rather than attached: Chat’s media.upload refuses app authentication, so an install reaching Chat through the credential proxy can never produce a native attachment. Text deliverables under a size ceiling are posted as message text, split across messages where they have to be; anything else — a PDF, an image, an oversize file — gets a notice naming the path the agent wrote it to instead. Which formats and what ceiling are in agents/platform/scripts/google_chat_relay_patch.py.

The agent has to declare the file for any of that to happen. Its terminal and file tools run in a separate sandbox pod, so a file it writes is on a volume the gateway cannot read; only paths the card names in its artifacts list get copied across to the gateway for delivery, and only for as long as the delivery takes. Four ceilings apply to that copy — 8 MiB per file, 16 files per card, 16 MiB across the card, and a two-minute deadline for the lot — and a file that exceeds one is skipped with a warning in the gateway log while the rest of the card still delivers. Paths under the sandbox’s credential and system directories are refused outright. agents/platform/scripts/sandbox_artifact_patch.py holds the limits and the denied prefixes.

Every Chat message carries session context (space, user, thread) that flows through Hermes and out as OpenTelemetry spans. The full trace is documented in docs/designs/gchat-session-metadata-data-flow.md.

Slack is opt-in. Enable it in the installer’s chat menu, where the installer prompts for the token values below, or pass --enable-slack with the tokens as flags.

  • The installer’s Slack interview collects SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_ALLOWED_USERS, SLACK_HOME_CHANNEL, and SLACK_HOME_CHANNEL_NAME; the chart stores the tokens in the credentials Secret and wires the CR’s slack section.
  • The Slack listener itself lives inside the Hermes runtime; it uses Socket Mode (no public webhook required) driven by the app token.
  • Slack app setup covers creating the app, its tokens, the allowlist and how to verify, for both modes.

Under spec.mode: next, Slack terminates at the A2A gateway instead, on the same app and tokens, unless Google Chat is enabled too (Chat then holds the gateway and Slack stays on Hermes). The Hermes Slack listener and the credential broker’s Socket Mode relay are off on such an install, so one app has one Socket Mode consumer, except on version skew (a spec.mode the operator does not recognise): the legacy consumer renders again beside the frozen gateway’s connection, and each message is answered by whichever consumer receives it, both admitting by the same list (only the gateway refuses another workspace’s member) (the slack field of spec.integration has the detail). Who is answered is decided by the same allowedUsers list, as on Google Chat: a sender must be on the list (or the list must be empty, which allows all), and one who is not is dropped with a notice. A member of another workspace, posting in a channel shared with yours through Slack Connect, is never served, whatever the list says. The gateway’s user_id principal map (the a2a-slack-principal-map Secret) is optional: a sender it names is attributed by the identity it maps them to, and any other admitted sender by their Slack member id. Proactive alerts and scheduled reports reach the home channel through the gateway, and the gateway takes a single bot token. When the agent sends a structured message beside its answer (a report’s headline and rows, a question with its choices, the plan of the cards it filed), the gateway lays it out in Slack the way kube-agents does under today: the headline in bold, the rows with their severity, a collapsed section for the detail, buttons, and a coloured side bar; a message Slack cannot show posts as its text instead. A click on a choice button answers as your reply (below); a link button opens its page. The design of record is docs/designs/spec-chatops-gateway.md.

Under next the gateway draws the interactive parts itself, from what the agent’s messages carry. Your message gets the reaction for its kind of ask (the emoji listed under Reactions), ⏸️ while the work waits on you and ❌ if it failed; the kind comes off when the answer posts, on the message that asked for the work even when its cards finish after you have asked something else (as long as each card sent its first marked message before you asked again), and the gateway only ever removes its own reactions. A button on a question, or the one next step an answer offers, is your reply: clicking it sends the button’s answer as your message in the thread, checked against the allowed-users list like a typed one, so a click from someone the gateway would not answer changes nothing. The question then loses its buttons and shows ✓ <your name>: <the button>, by your Slack display name, never an @-mention; a reply you type in the thread instead answers it the same way with your reply’s first words, unless the reply is stop, a status question or a short question about progress (such as “how is the upgrade doing”), /session or a delegate … request. A question is answered once, by the first click or reply. A next step an answer offers is not a question, so a reply you type leaves its button in place. Slack shows Working… on the thread from your message until the work, the agent’s cards included, has settled, paused while it waits on you, and the thread is titled after your first ask, up to its first question. Opening a new assistant thread, or the bot’s Messages tab on an app using agent view, shows the three suggested prompts listed under Agent view. These need the app to hold reactions:write, users:read and assistant:write, to subscribe assistant_thread_started (and app_home_opened for agent view), and to have interactivity on; the manifest hermes slack manifest prints has all of them unless it was generated with --no-assistant. On an app built with only the scopes the gateway needs to read and answer messages, add them and reinstall the app, or the reactions, the answered line, Working… and the prompts silently do not appear, while the answers still arrive. A button on a message posted before a gateway restart still answers when clicked, but a reply typed under it leaves its buttons in place.

Slack ingress is gated by SLACK_ALLOWED_USERS, a comma-separated list of Slack member IDs such as U0123ABCD, matched exactly: an email or display name matches nobody. Slack app setup says how to find a member ID. Under spec.mode: today a message from a member not on the list is ignored with no reply; under next the A2A gateway answers once with a notice naming the member’s ID, so an admin can add it. Leaving the list empty allows every member of the workspace (the operator sets SLACK_ALLOW_ALL_USERS=true in that case), which in a shared workspace lets anyone there drive the agent.

Slack only routes a leading-slash message to the app’s slash handler if that slash is registered on the Slack app, and the install configures the integration from tokens alone. Registering them is an optional post-install step — see INSTALL.md for the procedure.

Until you do, a typed /hermes <subcommand> arrives as an ordinary channel message rather than a command. The legacy_slash_commands plugin on the Planning Agent profile unwraps that form before the gateway resolves it, so /hermes sethome behaves as /sethome either way — registering the slashes adds Slack’s autocomplete, not the behaviour. The plugin’s README is the design of record.

/new (alias /reset) starts a fresh session and history, after the gateway asks the user to confirm. Asking the Planning Agent in words to reset the chat gets that command named back: the agent cannot clear a conversation itself and does not claim to. /undo is disabled on this profile, because rewinding the chat reverses nothing a specialist has done.

With KAGE_SLACK_UX on, the default (see Reactions for turning it off), the Slack bot offers three suggested prompts (is anything unhealthy in my clusters right now, what’s on the board, which clusters are behind their release channel), and tapping one sends it as the user’s message. Slack shows them when a user starts a new assistant thread or, on an app using agent view, opens the bot’s Messages tab. An assistant thread that already exists, a channel mention, and an app whose manifest carries neither view (one generated with --no-assistant, or built by hand without them) do not show them. No PlatformAgent field replaces the three.

Slack’s agent view, where the bot’s DM opens on a Messages tab, needs a new manifest, and the switch cannot be undone: once Slack applies a manifest carrying agent view, the app cannot go back to the assistant view it has today. The manifest also replaces the whole app definition, so pass the name, description and long description your app already has; left out, the name and description become Hermes’s and the long description is dropped. Drop --long-description if your app has none; Hermes refuses one under 175 characters or over 4,000. Run it with the flag on, which is what names the agent view after --name; with the flag off it reads “Chat with Hermes in Slack Messages.”:

Terminal window
kubectl exec -n kubeagents-system deploy/platform-agent-gateway -c platform-agent -- \
hermes slack manifest --agent-view --name "<app name>" --description "<app description>" \
--long-description "<app long description>"

Paste the output into your app’s App Manifest page in the Slack App Console and reinstall the app when Slack asks. Try agent view on an app nobody else depends on first. The manifest does not subscribe agent_session_stopped, so Slack offers no Stop button on a running reply. With the flag off the manifest and the DM are Hermes’s own; with it on or off, hermes slack manifest without --agent-view prints the same manifest and never switches the view.

SLACK_HOME_CHANNEL designates the channel an unprompted message lands in when no user thread is involved. Set it to a monitoring/oncall channel your team already watches.

It is optional at install time. Leave the prompt empty and, if the install’s first chat message on either platform comes from Slack, it makes its channel or DM the home channel. Otherwise Slack has none until you run /sethome (or /hermes sethome) in the channel you want, which is also how you move it. That writes the value into the Planning Agent profile — the one that owns Slack ingress — which is why the command has to run through the gateway rather than being applied by an agent on its own profile.

Under spec.mode: next only the resource field counts: spec.integration.slack.homeChannel on the PlatformAgent. The operator hands it to the A2A gateway, which posts the agent’s proactive messages (alerts, scheduled reports, and the fleet audit’s report card) into that channel and nowhere else, as new threads or as replies in them, and /sethome does not reach it. The field must be a channel ID (C..., or G... for a private channel), not a name; with it unset or malformed the agent posts nothing to Slack unprompted. Unset, the PlatformAgent carries the HomeChannelUnset condition and the installer warns before it applies. A reply a person types in one of those threads starts a new conversation rather than continuing the investigation that raised the alert.

A scheduled brief posts flat in that channel, never inside a thread. /sethome also records whichever thread it happened to be typed in, and threading every scheduled report under one ageing thread leaves only the first one visible — so cron delivery drops the thread deliberately. A job that wants its output in a thread names an explicit deliver= target instead.

The KAGE_SLACK_UX flag is on by default. It changes how reactions work, turns on the buttons below, turns the first inventory report and fleet-audit reports into cards, and changes how the harness’s own messages read on Slack (below). With it on, your message gets a reaction for the kind of ask it is as soon as the agent picks it up, chosen from its words: 🔍 something to investigate, 🌐 the whole fleet, ⬆️ an upgrade or version, 💰 cost, 🛡️ security, 🛠️ a change, 📋 the board, 🚨 an incident, and 👀 anything else. That reaction comes off when the answer posts, and work that succeeded is left with no reaction; work that failed is left with ❌. A turn the agent answers itself settles as soon as it ends. For work the agent hands to the board, the reaction stays on past the acknowledgement until every card the turn opened for your message has finished, along with any follow-up cards those cards’ workers filed in the thread, and ❌ goes on if any of them gave up, stopped on something no agent can do (a block of kind capability, such as a cluster that does not exist), or the turn itself failed. While a card the turn opened waits on you, or a follow-up filed by one that has since finished, ⏸️ joins it, and comes off once you answer. A follow-up that had already given up, or stopped on a capability block, when the card above it completed is told in the thread on its own and leaves your message without ❌. A cancelled turn has its reaction taken off. Work still running when the agent restarts, or a card closed by hand rather than finishing, leaves the reaction on; a card archived by hand counts as finished, without ❌. The agent only ever removes its own reactions, and a reaction it cannot remove stays on. To turn the flag off, set KAGE_SLACK_UX=false in the PlatformAgent CR’s spec.deployment.env (the chart’s platformAgent.deployment.env); an install that never set it has it on, and any value other than 1, true, yes or on turns it off. With it off the agent follows the Hermes Slack adapter, adding 👀 to your message when it starts and ✅ or ❌ when the turn ends, and nothing else on this page that the flag gates changes.

With the flag on, a thread’s board work shows as one status message instead of a progress message per card: a plan with a line for each card, including each card a fanned-out card filed (one per cluster for a fleet question), showing its latest notes and whether it is running, waiting on you (a question, work handed to you for review or sent back for changes, or a card stopped for blocking over and over), done or failed, edited in place as notes arrive. A card’s row appears, running, as soon as the card starts work, before its first note; a card that finishes, fails or stops to wait on you before its row appears gets its row then. A row’s title is the card’s name until its first note, then the card’s latest note while it runs, with a step count once it has more than one, and that note alone if it fails; waiting on you while it waits; and the first line of the card’s summary once it is done (or of its result, when it gave no summary), or the card’s name when it finished without either. In a plan of several cards, each title starts with the card’s name. A card’s line opens to its latest notes. A card stopped on something no agent can do (a block of kind capability) shows as failed rather than waiting on you. A card you unblock shows as running again, a running or waiting card archived by hand shows as failed with an Archived note, and a card the dispatcher retries after a crash or a timeout stays running. The card’s report still posts as its own message when it finishes, except for a card beneath one that filed several and is still working in the same thread, such as one per cluster for a fleet question, or the card one of those hands on to the cluster’s own agent: its row carries its result, and the filing card’s report, which waits for every card it filed, gives the answer. A filed card that fails or asks you something is told as any other card is. The thread shows Slack’s Working… status while the agent answers any message, which needs slack-sdk 3.44 or later in the image; once the agent hands your message’s work to the board, kube-agents also marks the thread’s session as working from the acknowledgement on, between the agent’s replies (from when the first card starts instead, for a message the agent puts no reaction on, such as a thread reply without a mention), as paused while the only cards left are waiting on you, and as closed once every card has settled. A plan with no new note or settled card for half an hour is set aside: its cards still update their rows on it, the status clears unless one of them is waiting on you, answering one brings Working… back on that plan, and a card silent that long can leave the thread without Working… while it still runs; its next note starts a new plan and brings Working… back. A set-aside plan with no card waiting on you or failed is dropped four hours after it was set aside, or after a card on it was answered or unblocked (a card settling on it does not extend that); a card on it that settles later still posts its report but no longer updates its row. When the agent restarts, a card’s next note, its settling, or the next liveness signal from a card still running starts a new plan; a card that settles there, other than by being archived, closes the thread’s session, or pauses it while the card waits on you, so Working… from before the restart does not stay; it can also clear Working… for another card from before the restart that is still running, until that card’s next liveness signal or note. Past 20 cards the oldest finished ones leave the plan first. A channel thread is titled after its first ask, up to the ask’s first question, as a DM thread is titled after its ask; a thread a crash-looping workload’s alert started is titled for the incident instead, such as payments-api crashloop in prod-east. If Slack refuses the plan, the thread falls back to a rolling progress message per card until those cards settle, or for half an hour after the last of them goes quiet; a card settling still updates its row on the plan, and Working… holds while those cards run and pauses while they wait on you.

With the flag on, work handed to the board also reads more quietly in Slack. When a card completes, blocks or fails, its rolling progress message (the plan’s fallback) collapses to its last line, and a completed card’s message posts the worker’s report, or its summary when there is no report or the report adds nothing to it, without the ✔ … done head line; a card that finished with neither keeps the head line. That message leads with its first sentence in bold, code kept as code, and folds the rest under why, collapsed; a closing question stays below the fold, as written. A first sentence longer than 150 characters is cut at its last comma, semicolon or spaced dash, or before “due to” or “because”, that leaves a headline short enough, and the rest of the sentence opens what follows (a comma before “which”, “who” or “that” is not used, since what follows it is no sentence). In a thread, when the answer ends by asking which of two to five listed options to take (“Which of these would you like?” or “How would you like to proceed?”, not a yes/no question such as “Shall I proceed?” or an open one such as “Anything else?”), each option gets a button labelled with its bold lead (an “Option 2:” prefix dropped), the one marked “(Recommended)” drawn as the primary button; its opening paragraph, each option’s label and first sentence (when every option has one), and the question stay in sight, and the rest folds under why. A click answers with the button’s label as your reply in the thread. When there is one obvious next step instead, the agent marks it for the presenters, and in a thread it becomes a single button under the answer, such as upgrade plan for seeded-c; clicking it, even on an older answer, sends those words as your reply, exactly as typing them would. Options the answer asks you to choose from take the step’s place. The mark is never shown as text: on Google Chat, outside a thread, with the flag off, and wherever no button can be drawn, you read the answer alone. The one exception is a reply streamed into Slack as it is written, which happens only with the agent’s streaming.enabled setting on: it keeps the mark as its last line, with no button. An answer that posts whole, as below, still gets the button under it, except a report with options to act on, one too long to draw in one message, and one sent where the Slack adapter sends Slack’s own markdown blocks or no rich blocks. An answer posts whole, as before, when it is a report with options to act on, opens with a heading, a list, a code block, a quote, a table row or a bold label, its first sentence holds a link or a mention, or runs onto a second line, or runs past 150 characters with no such place to cut it, its first line ends in a colon, nothing follows that sentence, the rest holds a horizontal rule, it runs past 12,000 characters, or the Slack adapter is set to send Slack’s own markdown blocks or no rich blocks. A card that blocks, crashes, times out or gives up posts no line of its own (except a question, below) once the wake asking the agent that created the card to explain it has been queued (the Planning Agent, for work you asked for), so the failure is told by that agent. If that wake fails, the card’s own failure line posts instead, and a wake that then succeeds on a retry tells the failure a second time; a failure line still waiting on its wake when the same card recovers (it completes, hands off for review or is moved to done) is dropped, so it never lands beneath the recovery. The agent’s reply to that wake opens with its first sentence in bold, and when it ends on one short yes/no question, such as “Check it there?”, a Try again button follows it (an open or either/or question stays text): clicking it sends “Try again” as your reply in the thread, and the message is rewritten to name who clicked. When the reply marks its next step, that step is the button instead, such as check seeded-a, whatever the question. The reply posts as written if the agent restarts between the wake and its reply; outside a thread the question stays text, and a step the reply marks is not shown. Six gaps remain, each leaving the failure untold, and the last three are logged: a queued wake that is lost to a restart or an interrupt before the Planning Agent answers; a restart between the card failing and the wake being queued; the card’s notifications stopping, after repeated failed posts or a sweep of stale subscriptions, before the wake is tried; a failure line whose post keeps failing; a held line dropped because too many cards are holding lines at once; and a held line dropped because the card recovered, when the wake after it never succeeds. The lines a card does post name the agent working it when the card has one, such as @platform, without the board name or the card’s id. Other platforms are unchanged.

Two moments get a message of their own with the flag on. When a card opens a pull request on GitHub, or a merge request on gitlab.com or on the GitLab host of your GitOps repository or one of your managed repositories, the thread gets it beside a green side bar, with Open PR (Open MR for a merge request) and Files changed buttons, once per pull or merge request in each thread, until the agent restarts. It is posted as soon as the card’s worker opens it, whatever the card goes on to say about it, under the pull request’s title, or under the worker’s line naming it when that line reaches the thread in the same update. A pull request opened outside a card’s work, such as by a scheduled audit, is posted only when a progress note or report says it was opened. When a card blocks because it needs something from you, its question posts in place of the blocked line, beside a yellow side bar, with up to 2,000 characters of its detail and a “waiting on you” line, and the note that wakes the Planning Agent tells it the question is already posted so that it need not ask it again. A question that ends with two to five short options, right after a line ending in a question mark, gets a button for each, unless that line only asks whether to go on (“Shall I proceed?”, “Sound good?”), in which case the list is the plan and stays as text; any other question keeps its options as text, as does a question outside a thread, since a click answers in the thread it was clicked in. Clicking one answers as if you had typed it as your reply in the thread, with the card’s name added while the agent still holds the question (after a restart the click answers with the label alone): it runs as your message and passes the same user and channel checks a typed reply does, so a user the adapter would not answer changes nothing by clicking. A click never needs an @-mention. A typed reply in the thread needs one only where the Slack adapter is set to require a mention in threads, or, in a channel that requires a mention, to ignore every message without one. Where it is set to ignore a message addressed to someone else, a typed reply that opens by mentioning another person is ignored unless it also mentions the agent. The Planning Agent still takes your answer back to the card; the question’s text also names the card, so a typed reply that starts a new conversation with the agent still finds it. Once the card moves on the buttons and the “waiting on you” line come off the question, however you answered; a restart in between leaves them on it. In a thread, the question then shows who answered and what (✓ <name>: <answer>, your Slack display name, else your real name or handle, in plain text, never an @-mention): a click leaves that line at once, and a typed reply leaves it with your reply’s first words, less any mention of the agent it opens with, once the card resumes; one reply answers one question, so where two cards ask in one thread, the reply goes to the first card to resume. A card archived, finished or given up without resuming loses its buttons without the line. In a thread, the Planning Agent posts nothing after carrying your answer to the card, unless carrying it fails, when it says so. A card stops on a question with options when you ask to choose yourself, such as “give me two or three ways to fix it and ask me which one”; otherwise it answers with the fix it recommends. With the flag off, and on Google Chat, the card’s blocked line posts and the agent’s reply to it asks the question.

With KAGE_SLACK_UX on, the alert for a crash-looping workload reads as one line on Slack, such as “🚨 payments-api in prod-east keeps crashing. Looking now.”; alerts for other reasons, and alerts on other platforms, read as before. An incident’s triage edits its alert in place instead of replying under it. The alert becomes a one-line headline, a button per proposed option showing its title, with the recommended one highlighted and marked “(recommended)”, a button for each console link on the report’s 🔗 line, and the report, up to its first horizontal rule, folded underneath. Clicking an option answers as if you had replied in the thread yourself with apply Option B: <its title>, or apply: <its title> when there is a single fix: the agent replaces the option buttons with a line such as “✓ : ”, your Slack name in plain text (or, if Slack refuses that edit, posts that line in the thread), and your reply passes the same user and channel checks a typed reply does. A click never needs an @-mention. A typed reply in the alert’s thread needs one only where the Slack adapter is set to require a mention in threads, or, in a channel that requires a mention, to ignore every message without one. Where it is set to ignore a message addressed to someone else, a typed reply that opens by mentioning another person is ignored unless it also mentions the agent. If someone the agent answers has already replied in the thread since the options appeared with apply, apply B or apply Option B (a letter the alert offers, when its options are lettered), with or without a please or thanks after it, or with apply Option B: and that option’s title, whole or clipped as the button shows it, a later click runs nothing, and the option buttons are replaced with a line saying the alert was answered in the thread; link buttons stay. A report that does not parse into options, a second report in the same thread, a report too long to fold, a report with a table above its first horizontal rule, or with a horizontal rule above its headline, options or links, or an edit Slack refuses arrives as a threaded reply as before.

With KAGE_SLACK_UX on, the harness’s own messages stay out of the way on Slack. Scheduled reports arrive without the Cronjob Response header, job ID and footer. The long-turn heartbeat, if it is enabled for Slack, says the agent is still working without naming the tool running. The agent does not announce that it is back after a restart, post the session-database warnings (the error stays in the log), or append Hermes’ tip about its queueing setting. Its replies about restarting, shutting down, stopping, being busy with an earlier message, a command sent while it is working, a /steer it could not take, or the model provider failing or turning a request down are written plainly in its own voice, with no console commands or error text. That covers the English replies; an install set to another display language gets Hermes’ translation of the restart and stop replies. Other chat platforms are unchanged, and with the flag off nothing changes.

A message sent while the Planning Agent is still answering an earlier one in the same conversation interrupts that turn, and the agent takes the new message up in its place. This is Hermes’ default interrupt mode, which kube-agents does not change; Hermes queues the message instead while the turn has subagents running or is compressing its context. The agent acknowledges with a short busy reply, at most once every 30 seconds per conversation. Work already filed on the board keeps running, and its progress and report still post into the thread. Under spec.mode: next a follow-up is queued behind the running task instead; see Follow-ups while a task runs.

Under spec.mode: next, the A2A gateway takes chat in place of the Planning Agent’s chat platforms. It holds Google Chat when Google Chat is enabled, and Slack otherwise. With both enabled, Slack stays on the today path described above. On the backend the gateway holds, nothing above about the Planning Agent’s Slack or Google Chat platform applies. The gateway holds no model. It decides which messages are turns, keeps one status line per task, and answers a few fixed commands itself. Every other message goes as a task, by default to the platform route: the Hermes bridge in the agent’s pod runs it as a Planning Agent turn in that conversation’s own Hermes session, and the Planning Agent answers it or files board cards as it does under today. How the agent then answers the question is in How a chat question is answered under next; this section covers what you see in the conversation.

On Slack:

  • A direct message. Every message is a turn, with or without a mention. Each top-level message starts a conversation of its own in the thread under it, and the gateway posts its replies in that thread. A message typed in that thread is the same conversation: a follow-up while its task runs, and a new task with the earlier ones in context after it ends.
  • A channel. A top-level message is a turn only when it mentions the bot. It starts a conversation in the thread under that message, and every reply to it posts in that thread. A reply in the thread is a turn when it mentions the bot. Without a mention, it is a turn only while the thread’s conversation is live: a task the gateway started in the thread is still running, or the thread’s last task started or ended in the last 30 minutes (the gateway’s A2A_IDLE_TTL). After that, mention the bot again. A thread someone else started becomes live the same way, once a mention in it starts a task. A thread under one of the bot’s own posts, such as an alert or another post it makes in the home channel, is different: every reply in it is a turn, with or without a mention, for as long as the thread exists.
  • A message that is only a mention, an edit, or another bot’s message is not a turn and gets no reply. A message from a member of another workspace in a Slack Connect channel is not a turn either, whatever it says. The first time such a member mentions the bot, a bare mention included, sends it a direct message, or clicks one of its buttons, they get one reply that says the bot answers only its own workspace.

On Google Chat:

  • A direct message. Every message is a turn. Top-level messages share one conversation, and the gateway answers them top-level. A message typed in a thread is a conversation of its own, answered in that thread; the next message typed there joins it.
  • A space. Chat sends the app only the messages that mention it. In a space with threads, each thread is its own conversation. In a space without threads, and in a group chat, the whole space is one conversation.

A sender the allowlist does not admit gets one notice, once per sender until the gateway restarts, and nothing else. The exception is a Slack reply under one of the bot’s own posts that doesn’t mention the bot: that is dropped with only an info-level log line and no notice, since the sender was likely talking to someone else. A mention gets them the notice. On Slack it reads ⛔ I can't verify who you are on slack (id U0123ABCD), so I can't take asks from you yet — an admin has to add you to the allowed users list (or correct their principal map entry). On Google Chat it names gchat, the sender’s email and the allowed users list. The fix is in Troubleshooting, for Slack and for Google Chat.

Under today Under next, on the backend the gateway holds
Slash commands: /new, /sethome, /steer, /hermes … None of them. The gateway reads two commands, /session and /feedback.
Reactions, buttons, cards and the plan status message (KAGE_SLACK_UX) On Slack, what the agent sends laid out as under today, with reactions, answered buttons and Working… (Slack). On Google Chat, one status line per task, edited in place.
Slack agent view and suggested prompts The three suggested prompts, in a new assistant thread or on an agent-view app’s Messages tab.
The home channel (Google Chat, Slack): proactive alerts and scheduled reports Posted by the gateway to the home channel on the PlatformAgent: spec.integration.googleChat.homeChannel on Google Chat, spec.integration.slack.homeChannel (a channel ID) on Slack.
A board card’s progress and report post into the thread A card the agent files while answering posts each progress note as a message of its own, and its report, into the conversation you asked in; a file the card produced is not attached.
A message sent while the agent works interrupts its turn It is queued as a follow-up to the running task.
An unlisted Slack sender is ignored silently An unlisted sender gets one notice (not for an unmentioned reply under the bot’s own post).
Several Slack workspaces from one install One workspace.

On Slack the gateway posts no status line: Working… on the thread, the reaction on your message and the plan of the agent’s cards show where the task is, and a task whose submission never reached the gateway’s bus posts ❌ could not reach the bus; try again. Elsewhere, each turn that becomes a task posts a status line, ⏳ submitted…, which the gateway edits as the task moves: ⚙️ **working**, followed by the agent’s latest progress note (on Google Chat only with googleChat.mode: debug). The answer posts as a message of its own, and the status line then reads ✅ **completed**. A task that fails posts ❌ failed: <reason>; one that is cancelled posts 🛑 canceled; one the executor refuses posts 🚫 the executor rejected the task: <reason>. When the agent needs something from you it posts ❓ <its question>; your next message in the conversation is the answer.

A conversation runs one task at a time. A message sent while its task runs, other than the commands below, goes to that task as a follow-up, and the gateway answers ✏️ got it, I'll take that next. The agent finishes the turn it is on, then takes each follow-up in the order it arrived as a further turn in the same session, so it answers with the earlier turns in context. Each turn’s answer posts as soon as the next turn starts, and the last one closes the task. A task takes at most 16 follow-ups. One the agent cannot take is answered ⚠️ not taken: <why>. Send it again after the answer., where the reason is that the task has already taken as many follow-ups as it runs, that it was already finishing, that the message had no text, that the task’s capability check did not pass, or that the agent’s executor cannot continue a session (the bridge’s cli executor; the default api executor can). When the task ends, follow-ups that never ran are counted in ⚠️ N queued follow-up(s) did not run before the task ended; send them again if they still matter, and follow-ups that arrived as it finished in ⚠️ N follow-up(s) arrived as the task finished and were not taken; send them again.

If the task has produced nothing yet, a follow-up gets ✏️ steering sent — task <id> has shown nothing on its event stream yet, so no reply is promised: it may still be starting, or nothing may have taken it; if it is still silent 10m0s after it was submitted, your next message here starts a new task instead. When that time is up and nobody has written since, the gateway posts ⚠️ task <id> has produced nothing on its event stream in 10m0s; unless it starts first, your next message here starts a new task instead of going to it. The next message then posts ⚠️ task <id> has produced nothing on its event stream in 10m0s, so this conversation is released and this message is handled as a new turn and starts a new task. The 10 minutes are the gateway’s A2A_FIRST_EVENT_GRACE.

Besides /session and a bare /feedback, two kinds of message are commands rather than follow-ups. Each must be the whole message; case and punctuation are ignored, and on Slack a mention of the bot is stripped first, so @bot stop counts.

  • stop, cancel or abort. With a task running, the gateway answers 🛑 cancel sent — the task ends when the executor confirms, and the status line ends at 🛑 **canceled** when it does. Your next message starts a new task. A second stop before the task ends gets 🛑 cancel already sent — the task ends when the executor confirms. With nothing running in the conversation, stop gets 🤷 nothing is running. In a conversation that has run a task, the reply adds that if the agent filed a card for it, that card keeps working until it finishes: a card outlives the turn that filed it, and by default stop cannot reach it. On an install whose operator sets A2A_CARD_STOP=true, stop stops the cards instead, except in a conversation whose tasks go to a session worker (the /session route below, or after delegate), which keeps the card notice. It posts 🛑 stopping this conversation's cards…, which becomes 🛑 stopped 2 cards: <title>, <title> naming up to five cards, with a note for anything it could not promise, such as cards that had already finished or a card that may still start. With no card open it answers 🤷 nothing is running, or 🤷 no card was open in this conversation if you’ve asked something new in the meantime. Card titles are shown as plain text, so a title can’t mention anyone or add a link; dots and colons in a title show as look-alikes, so a URL in it isn’t clickable. With a task running, the cards are stopped right after the cancel. A second stop while the cards are being stopped gets 🛑 already stopping this conversation's cards — the answer comes when the executor confirms, and once the first stop finishes the gateway checks once more for cards filed since, saying nothing unless it stops one. It skips that check, and says why, if you’ve asked something new by then, if the conversation has moved to a session worker, or if card stopping has been turned off. If the cards can’t be stopped, or the answer takes longer than 75 seconds, you get ⚠️ couldn't confirm that this conversation's cards stopped; if the agent filed a card for it, it may still be working. In the console the page may also show the agent’s own short summary of the stop beside this reply. A stop acts only on the conversation it is sent in, so on Slack send it in the task’s thread: a new top-level message in a channel or a DM is a new conversation. A top-level stop in a Slack DM starts nothing and gets 🤷 nothing is running here — each question runs in its own thread, so reply in that question's thread.
  • A status question, exactly one of status, progress, any progress, any update, any updates, what is it doing, what's it doing, what are you doing, what's happening, what is happening, what's going on, what is going on, where are we, how's it going or how is it going. With a task running, the gateway answers from the task’s event stream without asking the agent: 🔎 task <id> is **working** (2m14s so far), the request it is working on, its state history, its latest progress note, and the line (answered by stream replay — no live connection to the executor). A task with no events yet gets 📭 no events on the stream for this task yet. With nothing running, a status question is an ordinary message and starts a task, except at the top level of a Slack DM, where it gets the same nothing is running here reply as stop. Any other wording, such as “how is the upgrade doing”, is a follow-up, which the agent answers itself.

The agent answers a fixed number of tasks at once: the Hermes bridge’s worker count, 10 unless the operator’s A2A_BRIDGE_CONCURRENCY sets another. When a new task starts while at least that many tasks from other conversations are outstanding, counting the running ones, its status line reads ⏳ **queued** — 12 requests are ahead of yours; I'll start on it as soon as there's room in place of ⏳ submitted…, and changes to ⚙️ **working** when the task starts; on Slack, which keeps no status line, the notice posts as a message of its own and stays as written. The notice is informational: the task is already submitted. The operator’s A2A_BUSY_NOTICE_AT moves the threshold. A conversation on the session route below never gets the notice, since it runs on a pod of its own.

By default every conversation’s tasks go to the platform route above. /session moves the conversation to the session route, where the conversation gets a session pod of its own, started from the chat profile. The same pod takes each of the conversation’s tasks in turn, so each one carries on from the last. The pod is deleted 30 minutes after your last message in the conversation, and at most 4 hours after it started. Your next message after that starts a new pod, which reads the conversation so far. An install runs at most 10 session pods at once (spec.harness.tuning.maxSessions). When a conversation needs a new pod at that cap, the pod that has been idle longest is deleted to make room, and its conversation is told ⏸️ this session was paused to make room for another conversation; your next message here starts it fresh with the conversation so far. If every other conversation with a pod still has a task in progress, the new task is refused instead, with 🚦 not started: 10 session workers are already running (cap 10). …. A follow-up to a task on the session route gets ✏️ steering sent — the worker picks it up at its next turn boundary if the task is still running.

On Slack, start the message with the bot’s mention, @bot /session, because Slack takes a bare leading / as one of its own slash commands. In a Google Chat space, mention the app as with any message.

You send The gateway answers
/session 🧵 session route on — your next message opens a session pod; /session off returns to platform. In a Slack channel thread it asks for a mention instead: 🧵 session route on — mention me in your next message to open a session pod (a Slack thread carries unmentioned replies only once a task has started); /session off returns to platform. In a Slack DM, where /session sent top-level opens a thread of its own: 🧵 session route on — reply in this thread to open a session pod; /session off returns to platform.
/session <text> Turns the route on and runs <text> as its first task.
/session off ↩️ session route off — back to platform. With a session task still running: ⚠️ a session task is still running — stop it first, then /session off. When the route was not on: ℹ️ not on the session route; nothing to turn off. Sent top-level in a Slack DM: ℹ️ /session off acts on the thread it's typed in — reply /session off in that question's thread.
/session again ℹ️ already on the session route.
/session stop ℹ️ to leave the session route say /session off; to stop a running task say stop. Nothing is stopped.

If a task is already running on the platform route when you send /session, that task finishes there, and the message after it opens the first session pod; /session <text> sent then is not run, and the gateway says so. A message that starts with delegate, such as delegate check the node pools, sends that one task to a fresh session pod and leaves the conversation on the platform route.

/feedback is the gateway’s other command: how to report a problem with kube-agents itself (the harness, the agent, the operator, the docs) rather than with your cluster. Sent on its own, the gateway answers in place, starts no task, and whatever the conversation is doing carries on:

📝 To report a problem with kube-agents itself:
• Open an issue: https://github.com/gke-labs/kube-agents/issues
• Or use the feedback form, which needs no account: https://gke-labs.github.io/kube-agents/feedback
A report needs a one-line summary, the kind (Bug, Feature request, Question or Other) and what happened. A submission becomes a public issue, so keep cluster names, project ids and anything read from a Secret out of it.
To have it written up and filed for you, send `/feedback <what happened>` — I'll show you the exact text and ask before anything is filed.

Sent with text, /feedback the cost report posted twice this morning, the message is an ordinary turn and the Planning Agent answers it. It writes the report up as the form would receive it, a one-line summary, the kind and what happened in your words, says that a submission becomes a public issue on gke-labs/kube-agents, and asks for a yes. Nothing is filed until you give one. On yes it hands the exact text to the platform specialist, which files it through the public feedback form; the reply names the issue the form opened. The form needs no account and the install needs no GitHub credential for it. The same text sent again within a day is not filed twice: the reply names the issue already open.

When the install cannot reach the form, the reply says that nothing was filed and gives the composed text back with the two links above, so you can file it yourself. An air-gapped install, or one whose credential broker has no route to the form’s hosts, answers this way every time.

On Slack, start the message with the bot’s mention, @bot /feedback, as for /session.

Opening the app’s Home tab in Slack shows a dashboard of the fleet, drawn without a model call, so it costs no tokens. Fleet lists the clusters the agent manages, and any other cluster an open finding names, worst first, a cluster whose name repeats across projects shown as project/cluster, each with its worst open finding and how many more it has, or no open findings; past 15 it names how many more clusters there are. Waiting on you lists the questions with buttons that are still open in the threads where you asked, each with an Open thread button. Running lists the cards in progress, each with the cluster its agent is scoped to. Recent pull requests lists the last five pull requests cards opened. Last audit names the fleet audit that finished most recently, with its counts and a button to its ledger issue. Any panel but Fleet is left out when it is empty, and times show in your own time zone. If the agent’s pod cannot be read, the tab says so instead of showing an empty fleet.

The tab is drawn when you open it and drawn again, for about ten minutes after that, when a card moves or a question is asked or answered. Waiting on you comes from questions the gateway posted since it last restarted. Only a member on the allowed users list sees the dashboard; anyone else who opens the tab sees one line saying the fleet is shown only to the people the install lets ask it. With the list left empty, that is every member of the app’s own workspace, checked with users.info; if that check fails, the tab is left blank rather than refused. The app needs the Home tab turned on and the app_home_opened event in its manifest (Slack app setup).

How a chat question is answered under next

Section titled “How a chat question is answered under next”

Under spec.mode: next, a message from Google Chat, or from Slack when Google Chat is not also enabled, reaches the A2A gateway rather than a listener in the agent pod. The Planning Agent still answers it, and still hands cluster work to a specialist as a kanban card. What differs from today is how the message reaches the Planning Agent, over the message bus, and how the card’s answer gets back to the thread, through the gateway. Setting up the Slack app for the gateway is covered in Slack app setup.

The commands below assume the default resource names (platform-agent) and the kubeagents-system namespace. The gateway logs one JSON object per line.

  1. The gateway takes the message. It refuses a sender the channel’s allowed-users list does not admit, posts a status line in the thread that reads ⏳ submitted… (on Google Chat; Slack shows Working… on the thread instead), and publishes the message as a task for the platform addressee. When at least as many requests are already waiting or running as the bridge runs at once (10 by default), the status line says how many are ahead of yours instead: ⏳ queued — 10 requests are ahead of yours; I'll start on it as soon as there's room. Each message that starts a turn logs an ingress line with its taskId, conversation and addressee:

    Terminal window
    kubectl logs deployment/platform-agent-a2a-gateway -n kubeagents-system -c gateway | grep '"msg":"ingress"'
  2. The bridge hands it to the Planning Agent. The hermes-bridge container in the agent pod takes the task from the bus and sends its text to the Hermes API server in the same pod, which runs it as a turn under the default profile: the Planning Agent (on an install that enables the experimental platformFrontDoor, the Platform Agent instead). Every message in one thread lands in the same Hermes session, so a turn sees the thread’s earlier turns. The status line changes to ⚙️ working. The bridge logs which executor it runs when it starts, and a task accepted line for each task:

    Terminal window
    kubectl logs deployment/platform-agent-gateway -n kubeagents-system -c hermes-bridge | grep -E 'hermes bridge consuming|task accepted'

    The startup line should carry "executor":"api". Its "profile":"platform" is the bus address the bridge takes tasks from, not the Hermes profile that runs the turn. With "executor":"cli" (the operator setting A2A_BRIDGE_EXECUTOR=cli, or a bridge with no API server key), each message runs as a one-off hermes -p platform command instead: the Platform Agent answers it directly, with no Planning Agent and no memory of the thread, and a card it files does not report back to the thread.

  3. The Planning Agent files a card and acknowledges. It routes the request the way it does under today: to the Cluster Agent for a named cluster’s live runtime state when one is on the roster, otherwise to platform. It files a kanban card for that specialist, then replies with one short line naming what is being checked, such as checking prod-east. The gateway posts that line as a message (and, on Google Chat, the status line becomes ✅ completed). The turn has ended; the work has not. The gateway logs the end of the turn:

    Terminal window
    kubectl logs deployment/platform-agent-a2a-gateway -n kubeagents-system -c gateway | grep '"msg":"task terminal"'

    A message sent while a turn is still running is queued as the next turn in the same session (✏️ got it, I'll take that next); the cli executor refuses it instead. One sent after the acknowledgement starts a new turn.

  4. The specialist works the card. The kanban dispatcher in the agent pod starts the specialist against its own profile, with the same identity, permissions and cluster access it has under today (Security and IAM is canonical). No setting is needed to let it read a cluster. To inspect the cards and read one:

    Terminal window
    kubectl exec deployment/platform-agent-gateway -n kubeagents-system -c platform-agent -- hermes kanban list
    kubectl exec deployment/platform-agent-gateway -n kubeagents-system -c platform-agent -- hermes kanban show <task_id>
  5. The answer comes back to the thread. Before a task’s first turn the bridge records which conversation its Hermes session answers, so the card is addressed to that conversation. As the specialist works, the gateway posts each progress note into the thread or DM you asked in, and then the card’s result. Two things differ from delivery under today: each progress note is a message of its own rather than a line added to one message, because the gateway posts but does not edit, and a file the card produced is not attached. If the card blocks or fails, the thread gets the card’s own blocked or failed line from the notifier. The Planning Agent’s wake for it runs in the bridge’s session, and its reply is not posted. The gateway posts only into a conversation it is still holding, the one whose session record carries the card’s context, and refuses anything else. It logs each post it makes:

    Terminal window
    kubectl logs deployment/platform-agent-a2a-gateway -n kubeagents-system -c gateway | grep -E '"msg":"(chat.notify route armed|notify posted|notify refused)'

    At startup chat.notify route armed should carry "conversations":true. Each notify posted line names the conversation it posted into in its thread field, the same key as the conversation field on the question’s ingress line.

The session pods that Security and IAM describes are a different path. The gateway starts one only for a message that begins with the word delegate (delegate: check prod-east), which runs that one task in its own pod, or for a conversation moved onto its session route with the /session command. Such a pod reads the cluster only when the operator runs with A2A_SESSION_CLUSTER_VIEW=true. Any other message follows the steps above, which that setting does not affect.

The harness doesn’t only reply to messages. A cluster event posted to the in-pod triage endpoint (inject_message in agents/platform/scripts/session_kv_server.py) opens a thread unprompted: it posts the alert first, then runs the triage turn in the thread that alert created. On Slack with KAGE_SLACK_UX on, the triage then edits the alert itself. The first-run inventory report arrives the same way, into the thread bootstrap_onboarding bound. On Slack, with KAGE_SLACK_UX on, the inventory report is also reshaped on its way, into a card with one number on it: a headline saying what was scanned and how many things it found to look at, a line under it when the report says something could not be scanned, every finding the report lists a row each, led by their severity when the report labels it and with the sentence under each kept, the report’s line about critical findings it held back, when it has one, then a button to fix the first one and, when it found more than it shows, one to see them all. Clicking one answers as your turn in the thread; a click from someone the agent does not answer changes nothing. Where Slack refuses the card, or the post fails for any other reason, the report is posted as text with the same headline and findings, and its closing lines, or a line asking to see them all, in place of the buttons. A report that does not parse to that shape is posted as written. The report closes with a short note on the first-run audits (see First-run onboarding); beside the card, that note is a separate message after it. Where an unprompted message lands:

  • Google Chat: to the space that owns the interaction, or the space set via GOOGLE_CHAT_HOME_CHANNEL.
  • Slack: to SLACK_HOME_CHANNEL.

An alert goes to one of them, not both: it opens a thread that the triage turn then replies into, and a thread belongs to a single platform. With both integrations enabled the alert goes to Google Chat, falling through to Slack if that send is not accepted. A scheduled report has no such constraint and is posted to every enabled platform — the relay design is canonical for both rules.

A governance watchdog’s findings live on its ledger issue and the remediation pull requests that link back to it. Its scheduled report on this path is usually one line of counts ending with the ledger link, so the report to read is the issue — see Proactive autonomy and Autonomous watchdogs for the schedules.

With KAGE_SLACK_UX on, Slack gets a card built from the open ledger issue. Its headline carries one number, how many findings of the most severe level the issue lists (“Security & RBAC Posture Audit: 2 critical findings”), and up to ten critical findings are listed under it, each led by its severity and tagged “new” when it was not there on the last run; with none critical listed, the headline counts the most severe level listed and lists the top two. A line under them counts the rest by severity (“5 more (1 major, 4 minor) are in the ledger issue.”), and another names any clusters the run could not check, since a silent gap reads as clean. Then come a “Look at the first one” button and a button that opens the issue. Clicking “Look at the first one” starts a turn for you in the card’s thread, as if you had typed the button’s label; the buttons give way to a line naming who chose, only the first click counts, and a click from someone the agent does not answer changes nothing. A clean run closes its ledger, so its card is one line: “Security & RBAC Posture Audit: clean. Ledger closed.” A run with no findings that left the ledger open because it could not check everything is posted as the report’s own line in bold with the link, since the issue does not show this run’s counts (a ledger that a coverage gap opened shows this run’s gaps, but no findings). Nothing else is posted in the card’s thread: the ledger issue holds the rest. The headline is posted as text where Slack refuses the buttons or the message, and also where the report was truncated, too little time is left, or the buttons cannot be posted at all. A reply in the thread is answered with the full report in context either way.

A scheduled audit’s report, for an audit the install ships, is the one its finish step rendered, not the agent’s last message. An audit run that failed posts the scheduler’s one line saying why, and one that stopped before that step without failing posts one line saying it did not finish, with the job to look up in the run log; a run over several repositories that finished only some of them posts their reports followed by a line naming the rest, and that message is posted whole rather than as a card. Under spec.mode: next the card goes through the A2A gateway, which lays out the same card.

Each cluster-event alert and each drift alert spends one unit of a daily budget. When a bucket is spent, the alert does not go to chat, and no triage turn starts. The budget is fleet-wide: one count serves all the clusters that the install watches. It resets at 00:00 UTC. A value of 0 removes the cap for that bucket.

Bucket What it counts Default install.env key
Critical Critical cluster events 10 ALERT_DAILY_LIMIT_CRITICAL
Warning Warning cluster events 5 ALERT_DAILY_LIMIT_WARNING
Info Nothing today: the severity gate drops Info events 5 ALERT_DAILY_LIMIT_INFO
GitOpsDrift Drift alerts for cluster changes that people make by hand 20 ALERT_DAILY_LIMIT_DRIFT

agents/platform/docs/session_management.md gives the reasons for these defaults and for each bucket.

Most drift changes that reach the drift detector late do not spend the GitOpsDrift budget. A change is late when its audit record was published before the detector started to read, for example most changes that an install or a restart of the agent pod makes, or when the record waited more than 30 minutes. The agent posts one “Drift backlog” message for each group of late changes. The message counts the changes by principal and names some of them. Late changes in the message get no triage turn, and the feed GET /v1/intercepted-events lists them with the status backlog. Some late changes get a triage card and spend the budget instead: a change made with kubectl, and every late change of a principal with fewer than three late changes in the group. When the budget is spent, a late change of such a principal is counted in the message instead; a late kubectl change is handled as a live change is, so it is lost from chat and the feed lists it as suppressed.

Set a ceiling in one of these places:

  • The ALERT_DAILY_LIMIT_* keys in install.env. An empty key keeps the default. install.sh, the Day-2 menu and upgrade.sh --upgrade-mode=full apply these keys. The other modes of upgrade.sh use the values that the release recorded. But each upgrade.sh run examines the keys, and a value that is not a whole number stops the run.
  • The chart value platformAgent.deployment.alertDailyLimits, with the keys critical, warning, info and drift.
  • The Terraform variable alert_daily_limits in terraform/examples/full-install, with the same keys.
  • An ALERT_DAILY_LIMIT_* entry in the PlatformAgent CR’s spec.deployment.env, on an install that no chart manages.

Each of the first three routes renders the keys that are set into spec.deployment.env. Helm replaces the full spec.deployment.env list on the CR at each apply that changes the list that the chart renders. This includes the apply that sets the first key, an apply that changes a value, and the apply that removes the last key. Entries that you added to the CR by hand are then lost. Before you set a key, move a ceiling to its key, and move another name to the chart value platformAgent.deployment.env (extra_helm_values on the Terraform install).

A change to a ceiling restarts the agent pod and the credential-proxy pod. A re-tag with upgrade.sh --upgrade-mode=operator or harness to a release whose chart does not declare alertDailyLimits needs --drop-undeclared-values. The ceilings then go back to the defaults.

Suppression is silent in chat. No message says that a bucket is spent. To see the counts, read /v1/alert-quota from the agent pod:

Terminal window
kubectl -n kubeagents-system exec deployment/platform-agent-gateway -c platform-agent -- \
sh -c 'curl -s -H "Authorization: Bearer $SESSION_KV_API_KEY" \
http://127.0.0.1:8699/v1/alert-quota'

Add ?day=YYYY-MM-DD to the URL for an earlier day. The reply gives limit, sent and suppressed for each bucket that has a cap. suppressed counts each offer that the ceiling refused. The event watcher offers a refused workload again on its next sighting, so one workload can add many offers to suppressed. The event watcher also counts these refusals in the k8s_event_watcher_events_quota_suppressed_total metric.

The daily recap has the line “N alerts from M workloads withheld by the daily ceiling and never reached chat”. N counts each pod and reason one time, so it can be lower than suppressed. M counts the workloads that sent those alerts. The pods of one Deployment are one workload. N is one count for the Critical and Warning buckets together. The recap runs at 21:00 UTC on weekdays, and each recap covers the time since the previous recap. Thus the recap period is not the UTC day of /v1/alert-quota, and the Monday recap covers the weekend. Drift refusals never appear in the recap. The GitOpsDrift row of /v1/alert-quota and the session server log show them. The drift detector does not offer a refused record again, so that alert is lost from chat.

The recap line “N alerts failed to post to chat” is a different problem. It means that chat refused the post, or did not confirm it. Some of these alerts can be in the channel. A higher ceiling does not change this count. Look in the session server log for the cause.

The counter is fleet-wide, so the demand on it increases with the number of clusters. Size each bucket from a week of data. A bucket with a value of 0 counts nothing, and /v1/alert-quota does not show it. To measure that bucket, first set a high cap on it for the week.

  1. For each of 7 days, read sent and suppressed for the bucket from /v1/alert-quota?day=YYYY-MM-DD. For Critical and Warning, suppressed counts repeat offers, so the sum can be higher than the real demand. For GitOpsDrift, suppressed has no repeat offers. The recap’s withheld line cannot give a count for one bucket or for one UTC day.
  2. Add the sent and the suppressed counts, and divide the sum by the number of clusters. The result is the demand for each cluster.
  3. Set the bucket to that demand multiplied by the number of clusters that you will run. Do not set it higher than the number of alerts each day that the on-call can act on.
  4. If the demand is higher than that number, reduce the inflow: fix the workloads that alert most. Do not raise the bucket.

On a fresh install the first chat interaction gets a guided onboarding instead of a cold start. One no_agent cron job on the Planning Agent profile (agents/chat/defaults/cron/jobs.json), oobe, drives it. Every minute it runs three stages: delivery first, then the scan, then the first-run audits. The old bootstrap-inventory-scan and bootstrap-inventory-delivery entries ship disabled.

  • The scan stage files a kanban card assigned to the Platform Agent, recording the card’s id in /opt/data/.bootstrap_scan_filed so it never files a second one. That worker runs the environment-discovery SOP (agents/platform/governance/inventory.md): it lists the fleet, audits any cluster the Cluster Agent roster has no agent for, and completes. The same cron job files one audit card per Cluster Agent on the roster, each pointing that agent at the single-cluster audit SOP (agents/platform/governance/cluster_inventory_audit_sop.md). Once those cards finish, the same cron job’s hand-off script merges their structured results — fleet topology, Workload Identity and the workload checks the scan runs — into the complete findings at /opt/data/INVENTORY.raw.md, naming any cluster that did not report as a gap, and files a second card, which runs the prioritization SOP (agents/platform/governance/inventory_prioritize_sop.md) against those findings alone, scoring each one against a fixed rubric and registering all of them in the findings queue before writing the ranked report the user is sent to /opt/data/INVENTORY.md. The scan leaves liveness and readiness probes, requests and limits, autoscaling, NetworkPolicy and pod security to the first-run audits stage below. The full findings stay on disk. The report names the most urgent critical findings, at most FINDINGS_FIRST_REPORT_CRITICALS of them, and nothing less severe; it says how many items are queued behind them and how many of those are critical, and the findings nudge, whose page also covers that limit, brings those to chat a few a day. A scan with no critical finding gets a report that says so and lists none. If no cluster’s audit reached the findings, no second card is filed: the hand-off writes a short report saying no cluster was audited and why, so an empty scan is never presented as a clean environment.
  • The delivery stage posts that report into the chat, as written unless KAGE_SLACK_UX is on and the chat is Slack (where it is reshaped as described above), once two conditions hold: the ranked report has been written, and a human has connected. It closes the report with a short note on the first-run audits: that they are running, have started or have run, naming any that were not run, or why they did not run. An audit that finished before anyone opened the chat had no home channel to post its summary to, so the note names every audit the stage started or adopted whose summary was not posted. On Slack with the card, the note is a separate message after it. It claims the delivery atomically first, so overlapping runs can’t post the report twice.
  • The first-run audits stage runs four of the fleet audits once, one after another (fleet-wide-cost-analysis, compliance-audit, obtainability-audit, stockout-prevention), starting as soon as the scan’s ranking card has finished and none of the four is already running, or at a fallback of 90 minutes or more after the sweep was filed (longer for a larger fleet) if it never does, so their first ledger issues arrive soon after the scan rather than at the next scheduled run. An audit that already completed a scheduled run since the scan began is not run again. It does not wait for a human. On an install with no GitOps repository, each audit keeps its report in the report store, and a run that changed something posts a summary that says no forge is configured. It records what it started in /opt/data/.oobe_audits_fired. Once the stage is done and the report was delivered about a quarter of an hour ago, oobe removes itself. The design is docs/designs/oobe.md.

The bootstrap_onboarding plugin (enabled in agents/chat/config.yaml) hooks the first human turn: it greets the user, binds the delivery job to that chat thread, makes that chat the platform’s home channel if none is set, and marks that a human is present. Under spec.mode: next it records the conversation the message came from instead, and the report is posted into that conversation through the gateway; it sets no home channel there. Once the report is delivered, the flow marks itself complete and the disabled bootstrap entries are removed; oobe removes itself about a quarter of an hour after delivery once its audits stage is done. None of them runs again on that data volume.

The job fires every minute (* * * * *, see Autonomous watchdogs) while the work it guards takes minutes, so each stage records its own marker the moment it acts rather than inferring from the report or from completion. oobe‘s design is docs/designs/oobe.md; the onboarding stages’ design, state markers, and maintenance rules live in the plugin’s README.

  • No user-facing web UI. Chat is the primary surface. The one web UI the install can run is the Hermes dashboard, a per-pod debugging view the installer offers as --enable-hermes-dashboard (off by default there; the CRD’s own default is on) that binds loopback inside the pod, so reaching it means a port-forward or the tunnel script — PlatformAgent CRD is canonical. A local admin console runs from a repository checkout on your own machine, loopback only; the install does not deploy it.
  • No CLI beyond the Hermes CLI inside the pod. kubectl exec into the agent pod and run the Hermes CLI there — note the pod hosts several profiles, so a bare hermes command talks to the locked-down Planning Agent; use hermes -p platform to reach the Platform Agent (or hermes -p <cluster-profile> for a Cluster Agent). kubectl port-forward is not a way in on a GKE Sandbox (gVisor) node pool, which is the install default: the forward is established in the host-side netns and cannot see a listener inside the sandbox — PlatformAgent CRD is canonical on that. kubectl exec has no such problem, because it enters the sandbox.
  • No email, PagerDuty, or generic webhook ingress. Chat channels only.

Both channels are opt-in and default to disabled, so an install that enabled neither has no chat to talk to. The Hermes CLI above is one way in, and the installer prints these two commands when you choose “None” at its chat prompt and again when it finishes:

Terminal window
gcloud container clusters get-credentials <cluster> --location <region> --project <project> --dns-endpoint
kubectl exec -it deployment/platform-agent-gateway -n kubeagents-system -c platform-agent -- hermes -p platform

Pass --dns-endpoint only on a cluster that publishes an externally reachable DNS endpoint; gcloud rejects the flag on one that does not. The installer resolves that per cluster and prints the flag only when it applies (scripts/installer/gke_dns_endpoint.sh). The -c platform-agent is the Hermes container; the gateway pod runs three, so omitting it works but makes kubectl warn about which one it picked.

This reaches the Platform Agent directly, bypassing the front door: a request typed here is executed by the profile that receives it rather than being planned and filed as a kanban card. A bare hermes reaches the Planning Agent instead, which is where a chat message would have landed and which delegates as described above.

The other way in is the admin console, started from a repository checkout on your own machine: its Chat page reaches the Planning Agent through the front door, so a request typed there is planned and delegated the way a chat message would be.

Two things a chat-less install does not exercise. Scheduled reports and alert-driven triage are delivered only to enabled chat platforms — the delivery resolver enumerates Google Chat and Slack and nothing else — so neither arrives anywhere. And the onboarding delivery stage waits for a human to connect over chat before it posts the first-run inventory report, so that report stays at /opt/data/INVENTORY.md on the shell sandbox pod’s volume; read it with kubectl exec -n kubeagents-system platform-agent-shell-0 -c shell -- cat /opt/data/INVENTORY.md rather than waiting for it.