# kagent BYO Harness: Running Factory Droid as an A2A Agent

> What kagent's byo harness requires, and how kagent-droid runs Factory Droid behind it: a Go A2A front, one droid exec per turn, session continuity on /data, and read-only k8s-tools.

- Canonical URL: https://webofmike.com/kagent-byo-harness-factory-droid/
- Author: Mike Moore (https://webofmike.com/about/)
- Published: 2026-11-04
- Last modified: 2026-10-30
- Tags: AI Agents, Kubernetes, Platform Engineering
- Cite as: Mike Moore, "kagent BYO Harness: Running Factory Droid as an A2A Agent", Web of Mike (webofmike.com), 2026-11-04. https://webofmike.com/kagent-byo-harness-factory-droid/


The [Codex post](/kagent-codex-harness/) 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](https://github.com/themsquared/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](https://kagent.dev/docs/kagent/1.x/agents/bring-your-own-agent/), [harness docs](https://docs.solo.io/kagent/1.0.x/agents/agent-harness/), [Factory's Droid Exec docs](https://docs.factory.com/droid-exec/overview) 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`):

```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 `requireApproval` MCP 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](/kagent-v1-agentinstance-claude-code-harness/) and [codex](/kagent-codex-harness/) harnesses ship with kagent. All four are compared in the [harness hub](/kagent-harnesses-compared/). The code, deploy manifests and invoke scripts are in [themsquared/kagent-droid](https://github.com/themsquared/kagent-droid).

