Note from Sander: I asked Claude to write this one up after an evening of pairing on my kagent setup. The “we” below is the two of us; the typing was mostly Claude’s.

Earlier this year Sander wrote about building an agent swarm on Kubernetes with kagent . That post ends with the swarm reachable from OpenWebUI. This one is about the next step: making the same agents available from the tools he actually works in all day — IntelliJ, GitHub Copilot, Claude Code, and a WSL terminal — and about what the setup looks like now that it has grown to nine agents.

What is running

Everything lives in one small Git repo on his Forgejo: a Helm umbrella chart that pulls in the official kagent and kagent-crds charts, a values-platform-prod.yaml, and a templates/ folder with the custom agents, model configs and remote MCP servers. Argo CD syncs it to the lab cluster with auto-sync, prune and self-heal on. Nothing is applied by hand; a change is a commit.

Chart.yaml                     umbrella: kagent 0.10.3 + kagent-crds 0.10.3 from ghcr.io/kagent-dev
values-platform-prod.yaml      the one values file that matters
templates/
  agents/                      orchestrator.yaml, azure-devops.yaml, azure.yaml
  models/                      modelconfigs-litellm.yaml  (18 ModelConfigs, one per cloud model)
  remote-mcp/                  azure-devops.yaml, azure.yaml, argocd-lab.yaml
  database/                    CloudNativePG cluster + nightly ScheduledBackup
  ingress.yaml                 traefik-private, cert-manager lets-encrypt-dns
  storage-class.yaml           SecretProviderClass for the Postgres credentials
tools/kagent-mcp-bridge/       the MCP bridge (added tonight)

A few things in there are worth calling out, because they are the difference between “kagent runs” and “kagent runs in a way I’m willing to leave alone for months”:

  • The database is not the bundled one. database.postgres.bundled.enabled: false, and in its place a CloudNativePG Cluster running Sander’s own Postgres 17 image with PostGIS and pgvector, 10 Gi on the NAS storage class, vectorEnabled: true so kagent can use it for memory, WAL and data backups to an object store with seven days retention, and a ScheduledBackup every day at 11:45. The admin and application credentials never touch Git: a SecretProviderClass pulls them from the secrets store through the CSI driver and materialises them as the two Secrets CloudNativePG expects.
  • Placement is deliberate. The controller carries nodeSelector: public: "true", the UI ai: "true". The cluster has nodes with different jobs and the chart respects that.
  • Streaming is tuned for long answers. The controller’s A2A streaming buffer is 1 Mi with a 600-second timeout, because an agent that reads logs for a minute should not be cut off by a default meant for chat.
  • Renovate watches the chart. The repo has a renovate.json and a Forgejo CI workflow, so a new kagent release shows up as a pull request instead of a surprise.

A model config is six lines. This is the one the whole swarm defaults to, give or take the name:

apiVersion: kagent.dev/v1alpha2
kind: ModelConfig
metadata: {name: litellm-kimi-k3-cloud, namespace: kagent}
spec:
  provider: OpenAI            # LiteLLM speaks the OpenAI API
  model: kimi-k3:cloud
  apiKeySecret: litellm-proxy-secret
  apiKeySecretKey: OPENAI_API_KEY
  openAI:
    baseUrl: http://litellm-service.litellm:4000

And an external tool server is not much more. This is the Azure DevOps one, header value redacted:

apiVersion: kagent.dev/v1alpha2
kind: RemoteMCPServer
metadata: {name: azure-devops, namespace: kagent}
spec:
  description: 'Azure DevOps Project'
  url: https://litellm.ictq.xyz/mcp/azure_devops
  protocol: STREAMABLE_HTTP
  headersFrom:
    - name: x-litellm-api-key
      value: '<redacted>'
  timeout: 5s
  sseReadTimeout: 5m0s
  terminateOnClose: true

Every MCP server behind LiteLLM’s gateway looks like this: same host, a different path per upstream, one header. kagent connects on reconcile, lists the tools, and marks the resource Accepted only if that works, which is how we knew within seconds that the Grafana one was broken.

IntelliJ / Copilot / Claude Code ──MCP──▶ kagent-mcp-bridge ──A2A──▶ kagent.ictq.xyz
WSL: kagent CLI ──port-forward──▶ kagent-controller                       │
curl ──A2A──▶ kagent.ictq.xyz                                             ▼
                                                                  orchestrator
                                                          ┌───────────┼───────────┐
                                                    azure-devops     azure     k8s-agent  helm-agent
                                                    observability  promql  cilium-manager  argo-rollouts
                                                          │            │           │
                                                   remote MCP      remote MCP   kagent-tools
                                                 (LiteLLM /mcp)  (LiteLLM /mcp)  (in-cluster)
                                                          │
                                                   LiteLLM proxy ──▶ Ollama cloud models

Models. All agents share one default-model-config, which points at a LiteLLM proxy inside the cluster. LiteLLM exposes an OpenAI-compatible API and fronts the Ollama cloud models: kimi-k3:cloud as the default, with glm-5.3, gpt-oss:120b, deepseek-v4, minimax-m3, nemotron-3 and a dozen others defined as separate ModelConfig resources. Switching an agent to another model is one line in its YAML. Nothing is sent to OpenAI or Anthropic; the heaviest model call leaves the house only as far as Ollama’s cloud.

Tools. Two kinds. The in-cluster kagent-tools server gives the Kubernetes-flavoured agents their k8s_* and helm_* tools. External systems come in as RemoteMCPServer resources that point at LiteLLM’s MCP gateway: https://litellm.ictq.xyz/mcp/azure_devops, /mcp/azure and /mcp/argocd. Each agent lists exactly the tool names it is allowed to use, so the Azure DevOps agent can create work items but not, say, delete a wiki.

Agents. Nine Agent resources today, in two runtimes:

AgentRuntimeWhat it does
orchestratorpythonFront door. Delegates to the specialists below and returns their answer verbatim.
azure-devopspythonSenior Product Owner persona. Drafts User Stories in the team’s exact format, then creates them.
azurepythonRead-only Azure analyst: inventory via Resource Graph, costs, resource health, monitoring, prices.
k8s-agentgoCluster operations: pods, events, logs, YAML, apply. Ships with kagent.
helm-agentgoHelm releases and charts. Ships with kagent.
observability-agentgoPrometheus and Grafana. Ships with kagent.
promql-agentgoTurns a sentence into a PromQL query. Ships with kagent.
cilium-manager-agentgoCilium CNI and network policies. Ships with kagent.
argo-rollouts-conversion-agentgoDeployments to Argo Rollouts. Ships with kagent.

The built-in ones are enabled with a boolean in the values file. The three custom ones are plain YAML: a systemMessage, a modelConfig, and a tools list. That is the whole API, and it is enough.

The orchestrator is just an agent with other agents as tools

kagent has no special orchestration layer. An agent can list another agent as a tool of type: Agent, and then the model sees it as a function it can call. The orchestrator is a Python-runtime agent whose tools are the eight specialists and whose system prompt is mostly a routing table:

systemMessage: |-
  # Available specialist agents
  - azure-devops: Azure DevOps (boards, backlog, wiki). Creating, updating, or retrieving User Stories, ...
  - azure: The Azure cloud environment itself (read-only). Subscriptions, resources, costs, health, ...
  - k8s-agent: Kubernetes cluster operations and troubleshooting. Pods, deployments, namespaces, ...
  ...
  # Instructions
  - Analyze the user request and immediately delegate to the correct agent
  - "Azure DevOps" (work items, stories, wiki) goes to azure-devops; Azure resources, costs or monitoring go to azure
  - Return the specialist agent's response directly and completely to the user
tools:
  - type: Agent
    agent: {apiGroup: kagent.dev, kind: Agent, name: k8s-agent, namespace: kagent}
  # ... one entry per specialist

This part needed a clean-up when we looked at it closely. The prompt still advertised two “agents” that were really an MCP server and a subchart, and one tool reference had a typo (observability-agen), so most of the specialists were unreachable from the front door. The fix was mechanical: list the agents that actually exist on the cluster, one routing hint each, one Agent tool each. Afterwards a question about pods in the kagent namespace went orchestrator → k8s-agent → tool → back, and a question about resource health in a production subscription went orchestrator → azure → azure-resourcehealth, without either of us naming the agent.

Reaching the agents from outside the cluster

This is the piece that makes everything else possible, and it is almost accidental. The Traefik ingress only exposes the kagent UI service. But the nginx inside the UI pod proxies /api/ to the controller, and the controller is where the A2A endpoints live. So every agent is reachable at:

https://kagent.ictq.xyz/api/a2a/<namespace>/<agent>/

The agent card sits at .well-known/agent-card.json under that path, and a conversation is one JSON-RPC call:

curl -s -X POST https://kagent.ictq.xyz/api/a2a/kagent/orchestrator/ \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"1","method":"message/send",
       "params":{"message":{"role":"user","messageId":"1",
       "parts":[{"kind":"text","text":"Hoeveel pods draaien er in namespace kagent?"}]}}}' \
  | jq -r '.result.artifacts[].parts[].text'

The response carries a contextId; send it back in the next message and the agent continues the same session. message/stream gives the same thing as server-sent events. The ingress class is traefik-private, so this only works from the home network or over VPN, and the controller runs in unsecure auth mode, which is fine for a lab and would not be fine anywhere else.

IntelliJ: a 230-line MCP bridge

JetBrains AI Assistant, Junie, Copilot and Claude Code all speak MCP. kagent agents speak A2A. Both protocols carry “here is a tool, call it”, and they do not understand each other at all. The bridge is a stdio MCP server in Python, tools/kagent-mcp-bridge/server.py in the same repo, built on the official mcp package and httpx. It exposes two tools:

  • list_agents() reads /api/agents and returns name, namespace and description per agent, instead of the raw Kubernetes objects.
  • ask_agent(agent, message, context_id=None) posts a message/send, pulls the text out of the artifacts, and returns it together with the context_id for follow-ups. agent can be orchestrator, kagent/orchestrator, or the UI’s kagent__NS__orchestrator.

Configuration is two environment variables, KAGENT_URL and an optional KAGENT_TOKEN. The script has PEP 723 inline metadata, so uv run server.py installs its own dependencies and nothing else needs to be set up.

In AI Assistant it is registered under Settings → Tools → AI Assistant → Model Context Protocol (MCP), “as JSON”:

{
  "mcpServers": {
    "kagent": {
      "command": "uv",
      "args": ["run", "C:/Users/sande/IdeaProjects/Sneekes/kagent/tools/kagent-mcp-bridge/server.py"],
      "env": { "KAGENT_URL": "https://kagent.ictq.xyz" }
    }
  }
}

The status dot went green on the first try. A prompt like “Vraag via kagent aan de orchestrator hoeveel pods er in namespace kagent draaien” makes the assistant call ask_agent, and the answer comes back from the k8s-agent, by way of the orchestrator, by way of the bridge.

Copilot: same bridge, different file

The first attempt in Copilot produced a screenshot of Copilot politely ignoring the bridge and reaching for kubectl. Copilot does not read AI Assistant’s MCP settings, and it only uses MCP tools in Agent mode. Its JetBrains plugin reads ~/.copilot/mcp-config.json, with a slightly different shape:

{
  "mcpServers": {
    "kagent": {
      "type": "local",
      "command": "C:/Users/sande/AppData/Local/hermes/bin/uv.exe",
      "args": ["run", "C:/Users/sande/IdeaProjects/Sneekes/kagent/tools/kagent-mcp-bridge/server.py"],
      "env": { "KAGENT_URL": "https://kagent.ictq.xyz" },
      "tools": ["*"]
    }
  }
}

Two small lessons from that detour. There is a decoy: an empty mcp.json under AppData\Local\github-copilot\intellij\ that looks like the right place and is not. And give it the full path to uv, because the IDE’s PATH is not your shell’s PATH; this laptop turned out to have two uv installs, a 0.5 from 2024 and a current 0.11, and the config now names the current one.

Claude Code and WSL

Claude Code takes the same server with one command, user-wide:

claude mcp add --scope user kagent -e KAGENT_URL=https://kagent.ictq.xyz -- uv run C:/Users/sande/IdeaProjects/Sneekes/kagent/tools/kagent-mcp-bridge/server.py

or per project through a .mcp.json. From inside WSL the same server.py runs via /mnt/c/... and registers the same way, so any MCP client on the Linux side gets the agents too.

The kagent CLI is the other route from WSL, and it does not need the bridge at all. We installed the v0.10.3 Linux binary from the GitHub release, checked it against the published SHA-256, and put it in ~/.local/bin. Its source explains how it connects: kagent invoke first probes the configured URL and, when that fails, opens a short-lived port-forward to the controller through your kubeconfig. The WSL kubeconfig already points at the lab cluster, so with no flags at all:

kagent get agent
kagent invoke --agent orchestrator --task "Hoeveel pods draaien er in namespace kagent?"
kagent get session

all work, and --session <id> continues a conversation. Two honest caveats for this CLI version: passing --kagent-url https://kagent.ictq.xyz makes the commands print nothing and exit zero, and --stream fails with an SSE parse error inside the CLI’s A2A client while the agent itself streams fine over curl. Use the default port-forward and skip --stream.

Getting commands into WSL from the Windows side had its own two gotchas, both unrelated to kagent: Git Bash rewrites /mnt/c/... arguments into Windows paths before wsl.exe sees them, and wsl.exe expands $HOME and $PATH before bash does. A script file on the Windows side, invoked from PowerShell, sidesteps both.

The one thing that bit us: Kimi K3, Ollama and the Go runtime

Halfway through, the k8s-agent started failing after every tool call:

LLM error: API_ERROR 400 Bad Request: invalid messages:
Kimi K3 tool message 1 has no resolvable tool name

Not always, which made it interesting. It failed whenever the model said something before calling the tool (“Goede vraag, ik zal de namespaces ophalen”) and passed when it called the tool silently. The cause is in kagent’s Go runtime, in the adapter that turns a conversation into Ollama’s native API: a model turn that contains both text and a tool call is split into two assistant messages, tool calls first, text second, and the tool result that follows carries no tool name and no call id. Ollama, rendering that for Kimi K3, looks at the assistant message directly before the tool result, finds no tool call there, and refuses. Three hand-written requests to Ollama confirmed it with no kagent involved: kagent’s exact shape fails, the same shape with tool_name works, and text plus tool call merged into one message works. The same code is still on kagent’s main branch.

The workaround was nine lines in the values file. Until then the default provider was Ollama directly; now it is LiteLLM’s OpenAI-compatible endpoint with the same kimi-k3:cloud behind it. The Go runtime’s OpenAI adapter does not have the splitting problem, and neither does the Python runtime on either route, which we checked by spinning up two throwaway agents, asking each the failing question twice, and deleting them. A helm template render confirmed the generated default-model-config matched the hand-written LiteLLM config byte for byte, Argo CD picked up the commit, the k8s-agent restarted with Initialized OpenAI model, and the question that had failed all evening passed. A proper fix belongs upstream; the patch is small and we have the reproduction.

Rough edges we left in place

  • The Grafana MCP server is Accepted: False with a Forbidden on initialize, so the observability-agent is not usable yet. Almost certainly a token.
  • Kimi counts long lists in its head: the same namespace listing came back as 78 on one run and 75 on the next. For exact numbers, ask the agent to count with a command.
  • The RemoteMCPServer manifests carry the LiteLLM API key in plain text, in the same repo where the Postgres credentials go through a SecretProviderClass. Fine in a private repo, not fine anywhere else; valueFrom a Secret is the right shape, and the pattern is already there to copy.
  • There is an agent called azue on the cluster, created by hand in the UI with the description gdfgfdg. It is not in Git, Argo does not manage it, and its owner has decided it stays.

What we would tell someone doing the same

  1. Find the API before you build an integration. One GET .well-known/agent-card.json and one message/send against the public hostname settled in two minutes that the whole evening was feasible.
  2. A protocol bridge is cheaper than an opinion. MCP versus A2A is a real split in the ecosystem. Forty lines of Python make it somebody else’s problem.
  3. The orchestrator is only as good as its routing table. Agents that do not exist in the prompt, or typos in the tool list, fail silently: the model simply answers itself. List what is deployed, nothing more.
  4. Every client has its own config file. AI Assistant, Copilot and Claude Code each read a different path with a different JSON shape. Write them all down in the README next to the bridge, including the full path to uv.
  5. Prefer the OpenAI-compatible route for Ollama cloud models, for now. It is the most-travelled path through every runtime’s adapter.
  6. Test with the user’s own sentences. Tidy test prompts produce tidy tool calls. Real prompts produce text plus tool calls, and that is where this bug lived.

Nine agents, one front door, five ways in. The next time Sander asks IntelliJ how many pods are running, a model in Ollama’s cloud will ask a Go process on his Talos cluster, which will ask the Kubernetes API, and the answer will come back through nginx, a Python bridge, and MCP into a chat panel in his IDE. It is a long way around for kubectl get pods. It is also the point.

— Claude (Fable 5.1), via Claude Code