If you have seen a demo of a no-code agent builder, you know the moment. Someone types one sentence, waits a minute, and there it is: a new agent, running, with tools. It is impressive. For about ten seconds.
Then the questions start. Where does it run? What can it touch? Who approved it?
This post is about the first part, the ten impressive seconds. I want to show that it is not magic and that you can build it yourself, in an afternoon, with open source and spending a minimum on tokens. The tool is kagent, a CNCF Sandbox project that runs AI agents as Kubernetes resources. The questions come back at the end, because they are the real work.
Why kagent makes this almost boring
In kagent, an agent is not a process you start by hand. It is a custom resource. You write an Agent with a system prompt, a model, and a list of tools. You apply it, and the controller turns it into a running pod that speaks A2A.
Once agents are just YAML, an agent that builds agents is simply an agent that can write one kind of YAML. No new framework, no plugin. One Agent, three tools, and a prompt that knows what an Agent looks like.

Figure 1: An agent that builds agents, on kagent.
Before you start: What this lab is not
The goal here is the builder. Security is out of scope, on purpose, and I want to be upfront about what that means:
- kagent runs in its default
unsecureauth mode, so every request acts as the same user. - The chart’s bundled PostgreSQL uses its built-in password.
- The UI and API are reachable only through
kubectl port-forward on 127.0.0.1.
That is fine for a laptop. It is not fine for a shared cluster, and I will come back to why that gap matters.
You need Docker, kind, kubectl, helm, jq and Ollama (alternatively, you can use your API Key for Claude, OpenAI, or any other provider, but we wanted to make it as cheap as possible). There is nothing to clone: you create every file as you go. Start in an empty folder:
mkdir kagent-agent-builder && cd kagent-agent-builder
Step 1: Pick a local model that can actually do it
This is the part that surprised me. Writing a valid Kubernetes manifest through a tool call is harder than chatting, and small models show it. In my runs, qwen2.5:7b-instruct invented its own tool block format, and qwen2.5:14b-instruct sometimes answered politely without ever calling the apply tool. qwen3-coder:30b-a3b got it right on every run, so that is the model this post uses:
ollama pull qwen3-coder:30b-a3b-q4_K_M # about 18 GB on disk
The cluster reaches Ollama at host.docker.internal:11434, which works out of the box with Docker Desktop. On Linux you have to change that host in the values file below to an address the kind node can reach. I have not tested that path.
If you prefer a hosted model, kagent’s Helm chart also has provider blocks for OpenAI, Anthropic, Azure OpenAI and Gemini. Then it is no longer free, of course.
Step 2: Install kagent with a tool server that can only write Agents
Create the cluster:
kind create cluster --name kagent-builder
kagent ships a tool server with Kubernetes tools, and by default the chart gives its ServiceAccount cluster-admin. A builder backed by cluster-admin is a bad idea even on a laptop, so the values file changes that. Create it:
providers:
default: ollama
ollama:
provider: Ollama
model: "qwen3-coder:30b-a3b-q4_K_M"
config:
host: host.docker.internal:11434
options:
num_ctx: "32768"
kagent-tools:
tools:
enabledTools:
- k8s
rbac:
readOnly: true
additionalRules:
- apiGroups: ["kagent.dev"]
resources: ["agents"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
grafana-mcp: {enabled: false}
k8s-agent: {enabled: false}
kgateway-agent: {enabled: false}
istio-agent: {enabled: false}
promql-agent: {enabled: false}
observability-agent: {enabled: false}
argo-rollouts-agent: {enabled: false}
helm-agent: {enabled: false}
cilium-policy-agent: {enabled: false}
cilium-manager-agent: {enabled: false}
cilium-debug-agent: {enabled: false}
The model becomes the default ModelConfig. The tool server loads only its Kubernetes tools (22 of them). And it gets a read-only role plus one extra rule: it may write agents.kagent.dev, nothing else. The last block turns off the sample agents the chart installs by default, so the UI only shows yours.
Install the CRDs and kagent 0.10.2 with that file, and wait for the pods:
helm upgrade --install kagent-crds oci://ghcr.io/kagent-dev/kagent/helm/kagent-crds \
--kube-context kind-kagent-builder \
--version 0.10.2 --namespace kagent --create-namespace
helm upgrade --install kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent \
--kube-context kind-kagent-builder \
--version 0.10.2 --namespace kagent --values kagent-values.yaml
for d in kagent-controller kagent-ui kagent-tools; do
kubectl --context=kind-kagent-builder -n kagent rollout status deploy/$d --timeout=300s
done
Step 3: The builder is just an agent
This is the whole builder. Create the file:
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: agent-builder
namespace: kagent
spec:
description: Builds new kagent agents from a plain-language description and deploys them.
type: Declarative
declarative:
modelConfig: default-model-config
stream: false
systemMessage: |
You build kagent agents. The user describes, in plain language, an agent
they want. You turn that into ONE Kubernetes manifest and apply it. Do not
ask follow-up questions: pick sensible defaults and go.
How you work, in this order:
1. List the existing agents with k8s_get_resources (resource_type
agents.kagent.dev, namespace kagent). Pick a name that is not taken;
never overwrite an existing agent.
2. Write the manifest in exactly the shape below and apply it with
k8s_apply_manifest. The user confirms the apply in the UI; that is
expected.
3. Read it back with k8s_get_resource_yaml (resource_type
agents.kagent.dev, namespace kagent) and look at the Accepted and
Ready conditions in its status. Ready can take a few seconds.
The manifest must be exactly this shape, and nothing else:
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: <short-kebab-case-name>
namespace: kagent
labels:
app.kubernetes.io/managed-by: agent-builder
spec:
description: |-
<one sentence>
type: Declarative
declarative:
modelConfig: default-model-config
stream: false
systemMessage: |
<a clear prompt for the new agent, written for its task>
tools:
- type: McpServer
mcpServer:
apiGroup: kagent.dev
kind: RemoteMCPServer
name: kagent-tool-server
toolNames: [<pick from the read-only list below>]
Read-only tools you may give it: k8s_get_resources, k8s_get_resource_yaml,
k8s_describe_resource, k8s_get_events, k8s_get_pod_logs.
Never another kind, never another namespace, never write or delete tools,
never more than one document.
YAML that does not break: free text always goes in a block scalar, as in
the shape above (description with |-, systemMessage with |). In a plain
scalar, a colon followed by a space makes the YAML invalid, and a space
followed by # silently cuts the value short.
When something goes wrong:
- If the user rejects the apply, do not retry it and do not read the
agent back. Say it was not applied, and stop.
- Never create a second resource with another name (-test, -check, -v2)
to find out what failed. It tells you nothing and leaves clutter.
- Do not name a cause you have not checked. An opaque error is not proof
of a name clash, a permission problem or a network issue.
- Report the error exactly as the tool returned it, and stop.
When it is done, answer in three short lines: the agent's name, what it
does, and which tools it can use. Then one more line with its real state
from step 3 (Accepted, Ready). Never claim it is ready if it is not.
tools:
- type: McpServer
mcpServer:
apiGroup: kagent.dev
kind: RemoteMCPServer
name: kagent-tool-server
toolNames:
- k8s_apply_manifest
- k8s_get_resources
- k8s_get_resource_yaml
requireApproval:
- k8s_apply_manifest
a2aConfig:
skills:
- id: build-agent
name: Build an agent
description: Turns a plain-language description into a deployed kagent agent.
tags: [builder, agents]
examples:
- "Create an agent for the support team that answers questions about the state of the services in the cluster"
Apply it and wait until kagent reports it Ready:
kubectl --context=kind-kagent-builder apply -f agent-builder.yaml
kubectl --context=kind-kagent-builder -n kagent wait --for=condition=Ready \
agent/agent-builder --timeout=300s
The tools block at the end is where the builder gets its limits. toolNames is an allowlist: of the 22 tools on the server, the model sees three. requireApproval is kagent’s human-in-the-loop switch. When the agent calls a tool on that list, the run pauses and the user has to approve or reject the call.
The system prompt does the rest. It tells the model to list existing agents first, so it never overwrites one, and then gives it the exact manifest shape it may produce: one Agent, in the kagent namespace, with a label that marks it as built by the builder, and only read-only tools. It also carries a few lessons I learned the hard way. Free text goes in YAML block scalars, because a colon followed by a space inside a plain string breaks the manifest. And when something fails, the model must report the error and stop, not create my-agent-v2 to see what happens.
Keep in mind that the prompt is a request, not a guarantee. The guarantees are the allowlist, the approval and RBAC.
Step 4: Ask for an agent
Open the UI:
kubectl --context=kind-kagent-builder -n kagent port-forward \
--address 127.0.0.1 svc/kagent-ui 8080:8080
Go to http://localhost:8080, pick agent-builder and ask for something:
Create an agent for the support team that answers questions
about the state of the services in the cluster
After half a minute or so the run stops. The UI shows the manifest the model wants to apply and two buttons.

Figure 2: The UI showing the manifest requiring user approval before applying.
Read it before you click. This is the one moment where a human sees exactly what is about to land in the cluster. Approve, and the builder applies it, reads it back and reports the real state:

Figure 3: The UI showing the result and real state after applying the manifest.
The new agent shows up in the agent list and you can chat with it straight away. Ask it which services exist in the kagent namespace. It answers with read-only tools, because those are the only ones the builder was allowed to give it.
Step 5: The same thing without the UI
Every kagent agent is an A2A endpoint on the controller, so you can script the whole flow. Forward the controller port and send the request:
kubectl --context=kind-kagent-builder -n kagent port-forward \
--address 127.0.0.1 svc/kagent-controller 8083:8083 &
sleep 3 # give the port-forward a moment to listen
curl -s http://127.0.0.1:8083/api/a2a/kagent/agent-builder/ \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"1","method":"message/send","params":{"message":{"role":"user","messageId":"1","parts":[{"kind":"text","text":"Create an agent that lists the pods that are not running in the kagent namespace"}]}}}' \
> build.json
jq -r '.result.status.state' build.json # input-required
input-required means the apply is waiting for you. The pending manifest is in a data part of the status message. To answer, send a message on the same task and context with a decision:
TASK=$(jq -r .result.id build.json); CTX=$(jq -r .result.contextId build.json)
curl -s http://127.0.0.1:8083/api/a2a/kagent/agent-builder/ \
-H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"method\":\"message/send\",\"params\":{\"message\":{\"role\":\"user\",\"messageId\":\"2\",\"taskId\":\"$TASK\",\"contextId\":\"$CTX\",\"parts\":[{\"kind\":\"data\",\"data\":{\"decision_type\":\"approve\"}}]}}}" \
| jq -r '.result.status.state' # completed
Use “reject” instead of “approve” and nothing is created. Either way, the cluster tells you what really happened:
kubectl --context=kind-kagent-builder -n kagent get agents
Why three guardrails and not one

Figure 4: Three guardrails that do not depend on the prompt.
It is tempting to think the prompt is enough. It is not, and not because the model is malicious. Models get YAML wrong, retry in creative ways and misunderstand requests.
- It happened to me while checking the commands for this post. The prompt spells out the tool server name,
kagent-tool-server, and one run still produced an Agent pointing atkagent-tools. My curl approved it blindly, as scripts do. The controller refused it:Accepted=False, RemoteMCPServer "kagent-tools" not found. Nothing broke, but it is a good reminder of where the real checks live. Each layer catches what the previous one lets through: - The allowlist. If the model reaches for
k8s_delete_resource, the tool simply is not there. - The approval. If it writes a manifest you did not expect, you see it before it exists.
- RBAC. If you approve something you should not have, the API server still refuses anything that is not an
Agent.
You can check that last one yourself:
SA=system:serviceaccount:kagent:kagent-tools
kubectl --context=kind-kagent-builder auth can-i create
agents.kagent.dev --as=$SA -n kagent # yes
kubectl --context=kind-kagent-builder auth can-i create
deployments --as=$SA -n kagent # no
kubectl --context=kind-kagent-builder auth can-i get secrets
--as=$SA -n kagent # no
The part this lab leaves open
So building the agent took one YAML file and a couple of Helm installs. That was the easy part, and it is exactly why I do not think it is the interesting part anymore.
Look again at the box at the bottom of Figure 4. On this laptop nobody knows who asked for the agent, because everyone is the same user. Nobody knows what its model calls cost, or for which team. And nobody decided whether the agent should exist, beyond one click by whoever happened to be in the chat. Now imagine a company with a builder like this, a few frameworks, a hosted agent platform and a couple of assistants on developers’ machines. Each one answers those questions differently, if it answers them at all.
That is the argument for a governance layer that sits in front of the agents, wherever they come from, instead of inside each one. In the open source world one option is agentgateway, a Linux Foundation project. It proxies LLM, MCP and A2A traffic, with authentication (JWT, API keys), fine-grained authorization with CEL, and OpenTelemetry metrics, logs and traces for every call. Wiring it in front of this lab is a good next exercise, and probably my next post.
Clean up
Everything lives in the kind cluster, so one command removes it:
kind delete cluster --name kagent-builder
Where to go from here
The kagent docs cover more tool servers, other model providers and agents written in code, and the project lives at github.com/kagent-dev/kagent. If you build something on top of this, or you find a smaller local model that handles the builder well, I would really like to hear about it.