Build your first agent-ready CLI
In this lesson you will build a small command-line tool, greeter, from an
empty Go module. By the end it will do everything an LLM agent needs from a
well-behaved CLI: emit a deterministic JSON payload on stdout, keep logs on
stderr, return a structured error with the right exit code, and describe
itself when asked. You will run every command yourself and see the output at
each step.
This is a lesson, not a reference. Follow the steps in order and you will reach a working tool. Once you understand the shape, the How-to Guides show how to go further, and Why Agentic Experience? explains the reasoning behind the contracts you are about to use.
What you will build
Section titled “What you will build”A single command that greets someone and prints a machine-readable envelope:
$ greeter --format=json --name Ada{"data":{"message":"hello","name":"Ada","times":1,"mode":"json"},"meta":{"trace_id":"...","span_id":"...","idempotency_key":"..."}}The same binary will also reject bad input with a structured error and a
deterministic exit code, and answer greeter __schema with a full description
of itself — without you writing any of that plumbing.
Prerequisites
Section titled “Prerequisites”- Go 1.27 or newer installed (
go versionto check). - A terminal.
- Basic familiarity with Cobra commands is helpful but not required — you will copy the small amount you need.
Step 1 — Create the module
Section titled “Step 1 — Create the module”Make a new directory and initialize a Go module:
mkdir greeter && cd greetergo mod init example.com/greeterYou now have an empty module with a go.mod file. You will add the ax-go
dependency in Step 3, after there is code that imports it.
Step 2 — Wire ax.Execute
Section titled “Step 2 — Wire ax.Execute”Create a file named main.go with the following contents. Read the comments —
they point out the two or three lines that matter.
package main
import ( "context" "os"
"github.com/spf13/cobra"
// The import PATH ends in "ax-go", but the package is named "ax". // Refer to everything as ax.Something. "github.com/rshade/ax-go")
// greeting is the payload that will appear under "data" in the envelope.// Use a struct, never a map: structs give you a stable, deterministic field// order that agents can rely on across runs.type greeting struct { Message string `json:"message"` Name string `json:"name"` Times int `json:"times"` Mode string `json:"mode"`}
func main() { var name string var times int var logger ax.Logger
root := &cobra.Command{ Use: "greeter", Short: "Greet someone the agent-friendly way", RunE: func(cmd *cobra.Command, _ []string) error { mode, _ := ax.ModeFromContext(cmd.Context()) payload := greeting{ Message: "hello", Name: name, Times: times, Mode: mode.String(), } // ax.NewEnvelope attaches the standard metadata (trace_id, // idempotency_key, and so on); ax.WriteJSON writes strict, // minified JSON to stdout. return ax.WriteJSON(cmd.OutOrStdout(), ax.NewEnvelope(cmd.Context(), payload)) }, }
root.Flags().StringVar(&name, "name", "world", "who to greet") root.Flags().IntVar(×, "times", 1, "how many greetings the caller intends to send")
// ax.Execute wraps Cobra: it resolves the output mode, adds the // --format/--dry-run/--idempotency-key flags, injects the __schema // command, owns bounded buffered-output and telemetry shutdown, and returns // a deterministic exit code. The logger is assigned later inside RunE; // Flush is nil-safe if parsing fails before that happens. os.Exit(ax.Execute( context.Background(), root, ax.WithVersion("0.1.0"), ax.WithFlushFunc(func(shutdownCtx context.Context) error { return ax.Flush(shutdownCtx, logger) }), ))}Step 3 — Fetch the dependencies and run
Section titled “Step 3 — Fetch the dependencies and run”Let Go resolve ax-go (and Cobra) from the imports you just wrote:
go mod tidyNow run your command, asking for the machine format:
go run . --format=json --name AdaYou will see a single line of JSON on stdout:
{"data":{"message":"hello","name":"Ada","times":1,"mode":"json"},"meta":{"trace_id":"...","span_id":"...","idempotency_key":"..."}}That is the envelope. data is your payload; meta is the standard metadata
ax-go added for you. The idempotency_key was auto-generated because you did
not pass one — you will use that in Step 7. If you ever need that same meta
block without building an envelope — for a custom payload shape or a log
field, say — call ax.MetadataFromContext(cmd.Context()) directly; it
resolves the same live trace_id/span_id ax.NewEnvelope just did.
Step 4 — Prove stream separation
Section titled “Step 4 — Prove stream separation”The core promise of ax-go is that stdout carries only the machine
payload. Everything else — logs, diagnostics, errors — goes to stderr. Prove
it by sending each stream to its own file:
go run . --format=json --name Ada > out.json 2> err.logcat out.jsonout.json contains exactly one clean line of JSON — nothing else. Any
telemetry or diagnostic noise landed in err.log. This is why an agent can
pipe your stdout straight into a JSON parser without a sanitizing step.
To make the split visible, add a log line to your command. Insert this just
before the payload := line in RunE:
logger = ax.NewLogger( cmd.Context(), ax.WithLoggerWriter(cmd.ErrOrStderr()), ax.WithLoggerLabels(ax.Labels{Application: "greeter", Version: "0.1.0"}),)
logger.Info(cmd.Context()).Str("event", "greeted").Str("name", name).Msg("handled greet")The WithFlushFunc you registered in Step 2 now drains this late-bound logger
after every normal command return path. Execute supplies a fresh bounded
shutdown context, so you do not need a command-local timeout or defer. If a
future sink reports a flush error, ax-go writes one control-character-sanitized
diagnostic to stderr; it never changes the payload or command exit code.
Sanitization is not redaction, so a callback must not return an error containing
PII, secrets, tokens, or credentials.
Run the split again:
go run . --format=json --name Ada > out.json 2> err.logout.json is still just the envelope. The structured log line — correlated
with the same trace_id — is now in err.log. You never have to choose
between observability and clean machine output; the streams keep them apart.
Step 5 — Return a real error
Section titled “Step 5 — Return a real error”Agents need failures to be as structured as successes. Add a validation check
at the very top of RunE, before the logger:
if times < 1 { return ax.NewError( cmd.Context(), "validation_error", "times must be at least 1", ax.WithActionableFix("pass --times with a positive integer"), ax.WithErrorExitCode(ax.ExitValidation), )}Now build a real binary and trigger the error. Build it rather than using
go run here: go run reports its own exit code (1) for any failure and
prints the program’s real code only as text (exit status 2), which would hide
the very contract you are trying to see.
go build -o greeter ../greeter --format=json --times=0echo "exit code: $?"You will see a structured error envelope on stderr and a deterministic exit
code:
{"error_code":"validation_error","message":"times must be at least 1","trace_id":"...","tool":"greeter","version":"0.1.0","schema_version":"1.0.0","actionable_fix":"pass --times with a positive integer"}exit code: 2Exit code 2 always means “validation / bad input” in ax-go. The
actionable_fix field tells the caller — human or agent — exactly how to
recover. You return an *ax.Error; the framework serializes it, routes it to
stderr, and maps it to the right exit code.
Step 6 — Discover it like an agent would
Section titled “Step 6 — Discover it like an agent would”You never wrote a __schema command, but you have one. ax.Execute injected
it. Ask the binary you just built to describe itself:
./greeter __schemaThe JSON on stdout describes the command tree, every flag (including the
--format, --dry-run, and --idempotency-key flags ax-go added), types, and
your 0.1.0 version. This is how an agent learns to drive your CLI without a
human writing an integration.
There is an MCP-flavored adapter too:
./greeter __schema --as=mcpThat emits an MCP-tool-compatible description — the groundwork for wrapping your CLI as an MCP server with no per-command effort.
Step 7 — Flip the agent-safety flags
Section titled “Step 7 — Flip the agent-safety flags”Two more flags came for free. First, idempotency. Pass your own key and watch it surface in the envelope’s metadata:
./greeter --format=json --name Ada --idempotency-key=demo-key-123{"data":{"message":"hello","name":"Ada","times":1,"mode":"json"},"meta":{"trace_id":"...","span_id":"...","idempotency_key":"demo-key-123"}}The key you passed is echoed back under meta.idempotency_key, so an agent
retrying a call can prove it is the same logical request. (When you omit it,
ax-go generates one — that is the auto-generated key you saw in Step 3.)
Second, dry-run. Add --dry-run and the envelope reports it:
./greeter --format=json --name Ada --dry-runThe metadata now includes "dry_run":true. Your greeter has no side effects,
so nothing changes — but for a command that does write, --dry-run produces
the same envelope shape while suppressing the side effect, so an agent can
preview an action safely.
What you learned
Section titled “What you learned”You built a CLI that, on top of ordinary Cobra, now:
- writes a deterministic JSON envelope to
stdoutand nothing else; - keeps logs and errors on
stderr, correlated bytrace_id; - returns a structured
ax.Errorwith an actionable fix and a deterministic exit code; - describes itself via
__schemaand__schema --as=mcp; - honors
--format,--dry-run, and--idempotency-keywithout extra code.
Where to go next
Section titled “Where to go next”- How-to: Expose your command tree with
__schema— go deeper on self-description and MCP. - Explanation: Why Agentic Experience? — the reasoning behind determinism, stream separation, and self-description.
- Reference: Sources — provenance of the AX discipline ax-go implements.