Skip to content
3.0. Packaging

3.0. Packaging

In one glance

  • You will: Build the agent as one static binary, watch it refuse a composition that does not exist, and learn where each capability in this chapter lands.
  • You need: mise run install done. No model, key, container, or network. Assumed, not required: the composition from 2.1. First Agent.
  • Time: about 20 minutes, hands-on.

Why the agent ships as one binary, not one per surface

Packaging is the decision about how many executables an agent ships as, and which artifacts travel with them: seed data, runtime state, a container image. Every extra executable is a second configuration path and a second build pipeline, and the one that breaks in production is the one nobody wired into the test suite. Whoever debugs it cannot tell which configuration path the failing process read.

This chapter adds four ways to reach the agent — console, web, MCP, and A2A — each a plausible excuse for that second executable. There is one binary instead, and configuration selects the composition it runs as. This page builds it, watches it refuse a composition that does not exist, and maps where a capability lands. Build it and look:

cd agents/go
mise run build
./bin/agent version
{
  "build_timestamp": "2026-08-10T20:12:50+02:00",
  "mode": "development",
  "version": "development",
  "source_identity": "f72b98b025ae35f644c865cd097b66155b3b0d04",
  "revision": "f72b98b025ae35f644c865cd097b66155b3b0d04",
  "tree_digest": "sha256:7c103f338448acc491d90f73de4908d76cb1e16d45614842a97353c7dd66ebe4",
  "dirty": false
}

Your hashes will differ: they are this checkout’s revision and a digest of its whole tree, stamped in at link time so a running process can name its source. mode and version read development because AGENT_BUILD_MODE is unset; a release build reads the VERSION file instead.

The build sets CGO_ENABLED=0, an invariant the rest of the course depends on rather than an optimization. It lets the same file run inside the distroless image of Chapter 6 — the binary and its data, no shell, package manager, or compiler — and keeps one pure-Go SQLite implementation in the binary instead of a C library that must be present on every host that runs it.

You just built the whole agent — every composition, every transport, every tool — as a single self-contained file with no runtime dependencies.

How AGENT_ENTRYPOINT picks a composition, and what it refuses

That one file has to be told which agent to be. It parses its environment and refuses bad settings before constructing a runtime, so ask what it resolved:

cd agents/go
./bin/agent config:check
Agent configuration is valid. Resolved settings (secrets masked):
- AGENT_A2A_MAX_LLM_CALLS = 12
- AGENT_A2A_PORT = 8080
- AGENT_DATA_DIR = ../data
- AGENT_ENTRYPOINT = agent
- AGENT_MCP_URL = (unset)
- AGENT_MODEL = qwen3:4b-instruct
- AGENT_STATE_DIR = .state
- AGENT_WRITES_DISABLED = false
- OPENAI_API_KEY = **********

That is nine lines out of forty-eight settings; the real listing names every setting the runtime read, sorted, with secrets replaced by asterisks. It answers “which value is this process using” without printing a credential.

Configuration is parsed once, at startup, and every problem found is reported together: a five-second fix instead of a debugging session inside a half-built agent. Predict what that does to a composition that does not exist — a typo in AGENT_ENTRYPOINT could plausibly fall back to the default, or fail deep in the first turn:

cd agents/go
AGENT_ENTRYPOINT=supervisor ./bin/agent console
agent: loading the configuration (run `config:check` for the resolved settings): agent configuration is invalid (1 problems)
  - AGENT_ENTRYPOINT: must be one of agent, workflow, coordinator; got "supervisor"

Those are the first two of eighty-one lines: the binary exits non-zero before a model client exists, names the three legal values, and then — because the subcommand never parsed — ADK dumps its whole argument reference underneath. Read the top two lines and ignore the rest.

cmd/agent parses a subcommand, loads configuration once, and composes the requested surface. Every task below runs that same file:

TaskExact task expansionComposition or transport
mise run runAGENT_ENTRYPOINT=agent go run ./cmd/agent consoleConversational agent in the terminal
mise run workflowAGENT_ENTRYPOINT=workflow go run ./cmd/agent consoleBounded read-only workflow
mise run coordinatorAGENT_ENTRYPOINT=coordinator go run ./cmd/agent consoleLeast-privilege coordinator
mise run webAGENT_ENTRYPOINT=agent go run ./cmd/agent web -port 8002 webui -api_server_address http://localhost:8002/api api -webui_address localhost:8002 -sse-write-timeout 60mADK development web and REST surface
mise run mcpgo run ./cmd/agent mcpMCP over standard input/output
mise run mcp:httpMCP_HOST=127.0.0.1 MCP_PORT=8000 MCP_TRANSPORT=streamable-http go run ./cmd/agent mcpMCP over loopback HTTP
mise run a2aAGENT_ENTRYPOINT=agent go run ./cmd/agent a2aPersistent A2A server
mise run data:resetrm -rf .stateDelete disposable runtime state; the next A2A start republishes it

Each expansion shows that task’s own environment. A process that builds a model client needs credentials; a process that only reads a database must not hold any. So five tasks — run, workflow, coordinator, web, and a2a — additionally load the repository-root .env through mise’s redacted dotenv loader, and the two MCP tasks deliberately do not. Step outside mise and the loader goes with it, which is why 3.4. Memory writes set -a && . ../.env && set +a by hand before running the retrieval command from evals.

The first three rows differ by one environment variable: the composition is data, not a build target, so a behavior you see in mise run workflow reproduces in a container shipping one binary.

Two directories complete the picture, and confusing them is the packaging mistake that hurts most. agents/data is committed, immutable seed: incidents, service logs, runbooks, and skills. Runtime writes go to agents/go/.state on a host, or a shared volume in Kubernetes. Startup copies or migrates a runtime generation, a writable copy published from that seed, and only the write-owning boundary — the one process allowed to publish it — does so. Read tools and probes never touch the seed. Both locations are explicit settings, AGENT_DATA_DIR and AGENT_STATE_DIR, so nothing depends on which directory you are standing in.

The container follows the same rule: the multi-stage agents/go/Dockerfile compiles this exact package with CGO_ENABLED=0, then copies the binary and the seed into that distroless runtime. Chapter 6 verifies the non-root user, read-only filesystem, probes, scan, and artifact identity.

Deeper: the packaging mistakes that break the course contract

Each has a specific failure behind it, not a style preference:

  • Adding agent dependencies to the root theme module, which makes the documentation build resolve the agent’s graph.
  • Reading ambient credentials inside a package instead of passing validated configuration, which makes config:check a lie.
  • Hiding runtime state beside immutable seed data, which makes mise run data:reset destructive.
  • Building a second executable per composition, which lets two of them drift and only one get tested.
  • Importing agents/go from the black-box evals module, which turns protocol observation into unit tests of your own implementation.
  • Relying on cgo, which breaks the static distroless image contract and forces a second SQLite engine into the build.

Where each package lives, and what go.mod and go.sum own

The module is deliberately flat: packages are grouped by responsibility, not by framework layer, so a capability has one obvious home:

agents/go/
├── cmd/agent/       executable and subcommands
├── compose/         conversational, workflow, and coordinator wiring
├── config/          typed environment boundary
├── data/            immutable seed and the runtime generation published from it
├── domain/          normalized ids, enums, and shared records
├── tools/           incident reads and guarded actions
├── memory/          runbooks, notes, and semantic retrieval
├── policy/          budget, compaction, redaction, and injection controls
├── resilience/      deadlines, retries, and circuit breaking
├── a2aserver/       persistent A2A transport and task store
├── mcpserver/       least-privilege MCP server
├── telemetry/       logs, traces, and metrics
├── go.mod           direct, indirect, and tool dependencies
└── go.sum           resolved module checksums

go list ./... prints twenty-two packages, and the map stops at twelve because those twelve are where a capability lands. The others are buildinfo, kagentinterop, model, piiwebhook, platformdrill, principal, state, a second small command, and two internal/ helpers. Dependencies point inward toward typed domain values and outward only at explicit adapters, which is why compose can swap a local tool for a remote one without domain noticing.

go.mod gives the module its import path, language version, and direct requirements; go.sum authenticates what was downloaded. Here is the requirement block, quoted from the file the build reads:

require (
	github.com/a2aproject/a2a-go/v2 v2.4.0
	github.com/caarlos0/env/v11 v11.4.1
	// Three module names, one SQLite engine. github.com/glebarez/go-sqlite is the
	// database/sql driver (registered as "sqlite") that a2aserver, data, memory, and
	// state open directly; github.com/glebarez/sqlite is the GORM dialector ADK's
	// session store needs, and is a thin layer over that same driver; modernc.org/sqlite
	// is the transpiled-C engine under both and stays indirect. All three are pure Go,
	// which is what lets CGO_ENABLED=0 hold and keeps exactly one SQLite implementation
	// in the binary. Never add a cgo driver such as github.com/mattn/go-sqlite3 beside them.
	github.com/glebarez/go-sqlite v1.23.0
	github.com/glebarez/sqlite v1.11.0
	github.com/google/jsonschema-go v0.4.3
	// The second session backend, and the only non-SQLite database in the binary.
	// gorm.io/driver/postgres is the GORM dialector ADK's session store needs;
	// github.com/jackc/pgx/v5 is the pure-Go driver under it, imported for its
	// database/sql registration so cmd/agent can own and bound the pool itself.
	// Both stay cgo-free, so CGO_ENABLED=0 still holds. Sessions are the only
	// state that moves here — the incident, task, memory, and vector databases
	// remain SQLite files owned by one writer (Ch. 6.9).
	github.com/jackc/pgx/v5 v5.10.0
	github.com/modelcontextprotocol/go-sdk v1.7.0
	// ADK Go v2.2.0 owns this generated-client pair and requires openai-go v3.49.0.
	// Bump it only with ADK, so the adapter and the generated client stay in step.
	github.com/openai/openai-go/v3 v3.49.0 // compatibility hold: owner=google.golang.org/adk/v2@v2.2.0 constraint=v3.49.0 validator=agents/go mise run check and test
	// ADK Go v2.2.0 still uses the OTel log.Value and log.KeyValue APIs that the
	// 1.45/0.21 release set removed, so 1.44 with log 0.20 is the highest compiling family.
	go.opentelemetry.io/otel v1.44.0 // compatibility hold: owner=google.golang.org/adk/v2@v2.2.0 constraint=v1.44.0 validator=agents/go mise run check and test
	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0
	go.opentelemetry.io/otel/log v0.20.0 // compatibility hold: owner=google.golang.org/adk/v2@v2.2.0 constraint=v0.20.0 validator=agents/go mise run check and test
	go.opentelemetry.io/otel/metric v1.44.0
	go.opentelemetry.io/otel/sdk v1.44.0
	go.opentelemetry.io/otel/sdk/metric v1.44.0
	go.opentelemetry.io/otel/trace v1.44.0
	golang.org/x/text v0.40.0
	google.golang.org/adk/v2 v2.2.0
	google.golang.org/genai v1.66.0 // compatibility hold: owner=google.golang.org/adk/v2@v2.2.0 constraint=v1.66.0 validator=agents/go mise run check and test
	gorm.io/driver/postgres v1.6.2
)

Read it for two things, not the version numbers. Three of those module names — glebarez/go-sqlite, glebarez/sqlite, and the modernc.org/sqlite the comment names — are one pure-Go SQLite engine seen from three angles, which is the requirement behind the CGO_ENABLED=0 invariant above. Four entries carry a // compatibility hold: marker naming ADK as their owner: openai-go, otel, otel/log, and genai are pinned to whatever the installed ADK compiles against, so nobody bumps one alone and discovers at runtime that the adapter no longer matches.

Two commands confirm the graph resolves and matches its checksums:

cd agents/go
go mod verify
go list -deps ./... >/dev/null
all modules verified

That answer is narrow: the bytes you compiled are the bytes the module authors published. It says nothing about licences, known vulnerabilities, or whether the code is any good — separate checks own each of those claims, and 0.2. Evidence explains why the course keeps them apart.

go.mod also carries a tool directive listing goimports, govulncheck, and gofumpt. Each is then part of the module graph — versioned and checksummed like any dependency, runnable as go tool gofumpt — without importing a line of it into the production binary. Tools that span modules, such as golangci-lint and gotestsum, are pinned in the root mise.toml instead: one authority per tool, no hidden global install.

Deeper: when vendoring is worth its cost

The learner path uses the module cache and committed checksums. Vendoring writes a large derived tree into the repository, and earns it only when a release or restricted build environment forbids fetching modules at build time:

cd agents/go
go mod vendor
go build -mod=vendor ./cmd/agent

If you do it, review vendor/modules.txt and make the module mode explicit in CI and release builds: a vendored tree that silently disagrees with go.mod is worse than none. This course commits no vendor tree: reproducibility comes from versioned requirements, checksums, pinned build tools, and a digest-pinned container.

Your turn: prove the composition is configuration, not a build target

That claim is easy to state and easy to get wrong later, so check it by hand now.

  • Mode: inspect — every step is a read or a refusal, so there is nothing to revert.
  • Goal: show that the composition is configuration rather than a build target, and that an unknown composition never reaches a runtime.
  • Files to touch: none.
  • Preflight: cd agents/go && mise run build, then confirm ./bin/agent version reports "mode": "development".
  • Steps: record sha256sum bin/agent. Run ./bin/agent config:check, then AGENT_ENTRYPOINT=workflow ./bin/agent config:check, then AGENT_ENTRYPOINT=coordinator ./bin/agent config:check, and read the AGENT_ENTRYPOINT line each time. Finish with AGENT_ENTRYPOINT=supervisor ./bin/agent console, and read only its first two lines — the seventy-nine that follow are ADK’s argument reference, as above.
  • Gate that proves completion: the three valid runs report three different entrypoints, sha256sum bin/agent is identical after all of them, and the fourth exits non-zero naming agent, workflow, coordinator.
  • Final state: no files changed. Write down which of the eight tasks in the table above you would reach for to reproduce a colleague’s bug report about the workflow.

What you can do now

  • You can say why one static, cgo-free binary serves three compositions, selected by AGENT_ENTRYPOINT rather than a build target.
  • You can read the resolved configuration with secrets masked, and say why AGENT_ENTRYPOINT=supervisor exits before a runtime exists.
  • You can say what go.mod, go.sum, the tool directive, and vendoring each own, and what go mod verify does not claim.
  • You can name where a new capability belongs, and which directory is immutable seed rather than disposable state.

You now have somewhere to put the next seven pages’ worth of capability, and a build that stays one file when they are in it.

Continue to 3.1. Tools, which fills tools with the reads the agent answers from and the writes that change a live system.