Agent-First Data v0.23: The Shell Speaks Protocol Too
AFDATA's structured log/result/error protocol was available to anything that linked an SDK or called the CLI — except the layer where agent tooling actually runs: the Bash script that invokes cargo, npm, and wrangler. v0.23 fixes that with `afdata emit` (one protocol event from shell-safe scalar arguments) and `afdata shell bash`, a sourceable Bash 3.2+ authoring kit embedded in the binary. It gives a plain script AFDATA-style argument parsing, config reads, and structured events — under one firm rule: structure your own words, and pass everything else through untouched. `afdata_run` wraps a child without touching its stdout, stderr, TTY, colors, or exit status; `afdata_call` keeps one script the sole owner of the terminal result even when scripts orchestrate scripts.
AFDATA has always been about one thing: output an agent understands the first
time, with no schema to ship on the side. Results on stdout, errors on stderr,
a stable error.code, _secret fields redacted. Any program that links the
Rust, Go, Python, or TypeScript SDK gets it; so does anything that calls the
afdata CLI.
Except the layer where agent tooling actually runs. The release script, the
deploy wrapper, the “build then publish then tag” glue — that’s a Bash script
calling cargo, npm, wrangler. And Bash was the one place that couldn’t
speak the protocol without hand-rolling JSON with printf or staying entirely
unstructured. The most agent-facing layer was the least agent-legible.
v0.23 closes that gap.
One event from the shell
The smallest piece is afdata emit. It writes exactly one protocol-v1 event
from plain scalar arguments — nothing to quote-escape, nothing to assemble:
afdata emit log info "Starting build"
afdata emit result "Build complete"
afdata emit error build_failed "Build failed" --hint "Inspect the compiler output"
The same routing every AFDATA CLI uses applies: the log lands on stderr, the
result on stdout, the error on stderr with exit code 1. --output yaml and
--output-to stdout work here too, so a consumer that wants one unified stream
gets it. This is the whole protocol, reachable from a line of shell.
A kit, embedded in the binary
Emitting events by hand still leaves a script rebuilding the boring parts —
option parsing, --help, reading a value out of a config file. So the CLI now
carries a sourceable Bash authoring kit and prints it on demand:
#!/usr/bin/env bash
set -euo pipefail
_AFDATA_BASH_SOURCE="$("${AFDATA_BIN:-afdata}" shell bash)"
source /dev/stdin <<<"$_AFDATA_BASH_SOURCE"
unset _AFDATA_BASH_SOURCE
afdata_args_begin "deploy.sh [OPTIONS] PROJECT [-- WRANGLER_ARG ...]"
afdata_args_option config_path --config PATH "Configuration file" config.toml
afdata_args_flag dry_run --dry-run "Prepare without deploying"
afdata_args_positional project PROJECT "Project to deploy"
afdata_args_rest WRANGLER_ARG "Arguments forwarded to wrangler"
afdata_args_parse "$@"
account_id="$(afdata_config_get "$config_path" cloudflare.account_id)"
afdata_log info "Preparing ${project} for account ${account_id}"
[ "$dry_run" = true ] || afdata_run wrangler deploy "${AFDATA_ARGS_REST[@]}"
afdata_result "Deployment complete"
The declarations assign straight into the named Bash variables — flags become
true/false, options and positionals are strings, trailing arguments survive
byte-for-byte in AFDATA_ARGS_REST. Every parser gets -h/--help,
--output, and --output-to for free, and malformed arguments come back as a
structured cli_error, not a stack of echos. afdata_config_get delegates to
afdata value, so the same format detection, dot-path grammar, and secret gate
apply.
It’s embedded, not installed. cargo install, Homebrew, Scoop, and the release
archives all expose the exact same afdata shell bash with no separate
shared-data directory to keep in sync — and the kit is Bash 3.2 compatible, so
it runs on a stock macOS shell. Sourcing it changes no shell options and prints
nothing.
The firm rule: structure your own words, pass everything else through
The interesting design decision isn’t the parser. It’s what happens when your
script calls cargo.
The tempting thing would be to capture the child’s output and relabel each line
as an AFDATA log event. It’s also wrong. cargo’s colored diagnostics, npm’s
progress bar, wrangler login’s interactive prompt — those are the child’s
interface, and rewrapping them breaks the TTY, mangles colors, and turns a
readable error into JSON noise.
So afdata_run refuses to. It emits a start log and a completion log — the
script’s own words — and otherwise gets out of the way:
afdata_run cargo test --all-features # colors, progress, exit status: untouched
afdata_run wrangler login # stays fully interactive
The child’s stdin, stdout, stderr, signals, and exit status pass through
exactly. Only the script’s lifecycle is structured; a child failure becomes a
terminal child_process_failed error carrying the child’s real exit code.
Command arguments are deliberately left out of the wrapper’s logs, because they
may carry secrets.
For noninteractive steps whose output is only noise until something breaks,
--quiet is an explicit policy on top:
afdata_run --quiet cargo test --all-features
It buffers the child’s combined output in memory, discards it on success to save an agent’s tokens, and replays it on stderr only if the child fails — before the terminal error. Buffering in memory rather than a temp file means an interrupted script leaves nothing behind to clean up.
One owner of the result
The last piece shows up once scripts orchestrate scripts. A protocol stream has
exactly one terminal event — one result, or one error. But if release.sh calls
build.sh and deploy.sh, and each ends with its own afdata_result, you’d
get three “done“s in one stream. Which one is the result?
afdata_call answers it. It runs a cooperating AFDATA-Bash child with its logs
and errors fully live, but demotes the child’s successful afdata_result to an
info log:
afdata_call "$SCRIPT_DIR/build-assets.sh"
afdata_call "$SCRIPT_DIR/deploy.sh"
afdata_result "Release complete" # the only terminal result in the stream
The child still owns its failures — an error stays terminal and its exit status propagates — but only the outermost script owns success. The stream validates as one finite protocol run, every time.
Where it lands
AFDATA now reaches from the SDK down to the shell script that ties a release
together, with the same envelopes, the same routing, the same secret rules. And
it does it without pretending a Bash script is something it isn’t: the kit
structures the words the script owns and passes the rest through untouched. Run
afdata shell bash to read the whole thing — it’s the library that gets printed,
not hidden.