The Codex post
covered a runtime kagent ships for you. byo is the opposite: kagent runs your image and treats whatever’s inside as opaque. To see what that takes for a real agent, I put Factory Droid on it. The result is kagent-droid
, a Droid-as-BYO-agent for DevOps and platform work. This post is the contract plus what that repo had to do to meet it.
Disclosure: I work at Solo.io, which created kagent (now an open-source project) and sells Solo Enterprise for kagent. I built kagent-droid. I have no relationship with Factory. Sources: the public kagent BYO docs , harness docs , Factory’s Droid Exec docs and the kagent-droid README. Lab results are from a kind cluster running kagent-enterprise 1.0.0-alpha4 and Substrate 0.2.0-beta5 on 2026-10-16. kagent 1.0 is alpha.
What byo actually requires
The docs call it an A2A contract. The details are stricter than “speaks A2A”:
| Requirement | When it’s checked | Failure |
|---|---|---|
spec.workload.command set | On apply | BYO harnesses must specify workload.command |
spec.workload.image pinned by sha256 | On apply | Rejected, a bare tag fails |
gRPC lf.a2a.v1.A2AService on port 80 | First invoke | Connect: tunnel failed |
GET /readyz on port 8081 | Actor start (30s timeout) | Actor never ready |
| gRPC, not JSON-RPC | First invoke | Never reached |
The gotcha that will cost you an afternoon: /readyz passing says nothing about port 80. The agent shows READY, and the first call dies with a tunnel error that doesn’t name a port. Read runtime.port from the config kagent delivers, or bind 80 yourself.
Two other rules: config comes in through KAGENT_CONFIG_JSON, and cross-turn state goes under /data so it survives suspend and resume. You also lose structured output and UI file attachments on byo. kagent ships Go and Python helpers for the server side.
Harness shape (trimmed from the repo’s deploy/harness.yaml):
apiVersion: api.kagent.dev/v1alpha3
kind: Harness
metadata:
name: droid-harness
namespace: kagent
spec:
byo: {}
workload:
image: <your-registry>/kagent-droid@sha256:<digest>
command: ["/usr/local/bin/droid-harness"]
substrate:
workerPoolRef:
name: kagent-default
snapshotPolicy:
location: s3://ate-snapshots/kagent/
---
apiVersion: api.kagent.dev/v1alpha3
kind: AgentTemplate
metadata:
name: droid-template
namespace: kagent
spec:
description: Factory Droid as a DevOps agent (see deploy/ in kagent-droid for the full template).
---
apiVersion: api.kagent.dev/v1alpha3
kind: Agent
metadata:
name: droid-agent
namespace: kagent
spec:
template:
ref:
name: droid-template
harnessRef:
name: droid-harness
Droid doesn’t speak A2A, so wrap it
Factory documents MCP for tools and a headless droid exec mode with its own streaming output, not A2A or gRPC. So kagent-droid puts a small Go front in the image. It serves lf.a2a.v1.A2AService on :80 and /readyz on :8081. Each A2A turn runs one droid exec -o stream-json. A kagent Session maps to one droid session ID, and later turns continue it with -s. The AgentTemplate supplies the system prompt, model and MCP tools, so the image doesn’t change per agent.
| File | What |
|---|---|
front/config.go | Reads KAGENT_CONFIG_JSON, writes ~/.factory/mcp.json, maps model IDs, wires the Factory credential |
front/executor.go | One droid exec per turn. stream-json events become A2A status updates (function_call / function_response DataParts), an artifact and a final state |
front/tasks.go | Adopts the task IDs kagent creates before it sends the message |
front/main.go | gRPC + readyz servers, agent card |
Dockerfile | Static Go front, droid 0.242.0 (checksum verified), kubectl, helm, git/jq/rg on debian:trixie-slim |
Why Go, and why droid runs per turn
This is the most useful lesson in the repo. Substrate checkpoints the Actor right after /readyz passes and again after every turn, then restores from that snapshot for the next request. On the lab cluster, a Python process didn’t survive the restore. readyz was refused, and the router’s 5s ResumeActor deadline cancelled every attempt. Probe images on the same base image isolated it:
| Probe | Restore |
|---|---|
| Static Go readyz server | 2.0s, heartbeat resumes |
| Python asyncio readyz, no grpcio | Fails |
| Python blocking readyz on a thread | Fails |
Python asyncio + GLIBC_TUNABLES=glibc.pthread.rseq=0 | Fails |
So the Go front is the only process alive at checkpoint time. droid runs only during a turn, and its process group is killed when the turn ends. The Actor checkpoints in ~0.26s and restores in 0.4–0.6s. If you build a byo image, test restore early. “It passes readyz” isn’t enough.
Mapping
| AgentTemplate / Harness | Droid |
|---|---|
systemPrompt | --append-system-prompt |
modelConfig.model | -m, mapped to Factory IDs (e.g. claude-haiku-4-5 → claude-haiku-4-5-20251001), or DROID_MODEL on the Harness |
tools[].mcp (HTTP/SSE) | ~/.factory/mcp.json. requireApproval servers are left out (no HITL path yet) |
Harness DROID_AUTONOMY | --auto (default high, because the gVisor sandbox is the boundary) |
Credentials and egress
Droid talks to two Factory hosts, and each one needs an exact-hostname entry for credential injection:
| Host | Declared by | Credential |
|---|---|---|
api.factory.ai | OpenAI-type ModelConfig with baseUrl: https://api.factory.ai | FACTORY_API_KEY Secret |
prem.factory.ai | A RemoteMCPServer used only for egress | Authorization: Bearer ... Secret |
The Actor never holds the real key. It sees the placeholder kagent-credential-injected in OPENAI_API_KEY, the front passes that to droid as FACTORY_API_KEY, and the gateway swaps in the real header on each declared host. Things that didn’t work: Secret-backed Harness env (rejected), wildcards like https://*.factory.ai (“credential injection requires an exact DNS hostname”), and an egress allowlist field (there isn’t one). telemetry.factory.ai stays blocked, and droid tolerates that.
Cluster access without a kubeconfig
The sandbox has no kubeconfig and can only reach declared hosts, so kubectl inside Droid can’t reach a cluster. Cluster access comes from kagent-tools 0.3.0 installed read-only instead: a get/list/watch ClusterRole without Secrets, plus --read-only, attached as the k8s-tools RemoteMCPServer. One footgun: the chart’s default metrics port collides with the MCP port, so the values file moves metrics to 8085.
State that must outlive a turn lives on /data: droid’s HOME (session store, mcp.json), /data/workspace, and the context-to-session map in /data/droid-sessions.json. Process memory does not carry over.
Status from the README
| Check | Result |
|---|---|
| Local: two turns on one context, pre-assigned task IDs | Pass, turn 2 remembers turn 1 |
| Golden snapshot | Pass, checkpoint 0.26s |
| Turn 1 through kagent, real Factory key | Pass, droid created /data/workspace/notes.txt |
| Turn 2 on the same Actor | Pass, same droid session, read the file back |
| “List the namespaces you can see” | Pass, 8 namespaces via k8s-tools, matching kubectl get ns |
Open items
- Human-in-the-loop for
requireApprovalMCP servers (A2A input-required). - A2A task history lives in the front’s in-memory store, not the kagent controller, so the kagent UI may not show past turns.
- Write access. k8s-tools is read-only on purpose. Flip it only with a human approval step in the loop.
When byo is the right call
If the agent already exists and works, byo beats rewriting it as a declarative kagent agent. You keep Substrate sessions, digest-pinned revisions and gateway-held credentials. The trade-off is that the front is now code you own, and restore behavior is part of your contract. If you just want a strong coding loop on Kubernetes, the claude and codex harnesses ship with kagent. All four are compared in the harness hub . The code, deploy manifests and invoke scripts are in themsquared/kagent-droid .