Specification

Self-describing structured data for AI agents and humans.

Field names encode units and semantics. Agents read latency_ms and know milliseconds, api_key_secret and know to redact — no external schema needed.

Overview

Agent-First Data has three parts:

  1. Naming Convention (required) — encode units and semantics in field names
  2. Output Processing (required) — suffix-driven formatting and automatic secret protection
  3. Protocol Template (optional) — structured format with code (required) and trace (recommended)

Parts 1 and 2 are the core. Part 3 is optional — a recommended structure that works well with Parts 1 and 2, but you can use AFDATA naming with any JSON structure (REST APIs, GraphQL, databases, etc.).

Jump to:

Quick Reference: All Suffixes

CategorySuffixesYAML/Plain example
Duration_ns, _us, _ms, _s, _minutes, _hours, _dayslatency_ms: 1280latency: 1.28s
Timestamps_epoch_ns, _epoch_ms, _epoch_s, _rfc3339created_at_epoch_ms: 1707868800000created_at: 2024-02-14T...
Size_bytes (output), _size (config input)file_size_bytes: 5242880file_size: 5.0MB
Currency_msats, _sats, _btc, _usd_cents, _eur_cents, _jpy, _{code}_centsprice_usd_cents: 999price: $9.99
String formats_bcp47, _utc_offset, _rfc3339_date, _rfc3339_timelanguage_bcp47: "zh-CN", invoice_due_rfc3339_date: "2026-06-13"
Other_percent, _secret, _urlcpu_percent: 85cpu: 85%

In default YAML and Plain: formatting suffixes are stripped from keys (value already encodes the unit) and values are formatted for readability. JSON preserves original keys and raw values. (_url, _bcp47, _utc_offset, _rfc3339_date, and _rfc3339_time are not stripped.)

Secret protection: All three formats automatically redact _secret fields and scrub secret components (userinfo password, secret-named query params) inside _url field values.

Boundary: AFDATA names communicate local field semantics. They do not replace schemas for required fields, enum values, numeric ranges, object shapes, or cross-field validation. Use JSON Schema, OpenAPI, database constraints, or typed APIs for those guarantees.


Part 1: Naming Convention

Applies to all structured data: JSON, YAML, TOML, CLI arguments, environment variables, config files, database columns, HTTP payload fields, log fields.

Design rules

  1. Name conveys meaning. A reader should understand the field’s purpose from the name alone, without seeing surrounding context or documentation. data could be anything — request_body, search_results, cached_response say exactly what it contains.
  2. Unit in suffix. If a numeric value has a unit, encode the unit in the field name suffix.
  3. Secrets marked. If a value is sensitive, end the field name with _secret.
  4. Obvious needs no suffix. If the meaning is obvious from the name alone, no suffix is needed.
  5. Self-contained. Never rely on external metadata, companion fields, or documentation to convey what a field contains.

Suffixes

Duration

SuffixUnitExample
_nsnanosecondsgc_pause_ns: 450000
_usmicrosecondsquery_us: 830
_msmillisecondslatency_ms: 142
_ssecondsdns_ttl_s: 3600
_minutesminutessession_timeout_minutes: 30
_hourshourstoken_validity_hours: 24
_daysdayscert_validity_days: 365

Timestamps

SuffixFormatExample
_epoch_nsnanoseconds since Unix epochcreated_epoch_ns: 1707868800000000000
_epoch_msmilliseconds since Unix epochcreated_at_epoch_ms: 1707868800000
_epoch_sseconds since Unix epochcached_epoch_s: 1707868800
_rfc3339RFC 3339 date-time stringexpires_rfc3339: "2026-02-14T10:30:00Z"

Precision note: this is a property of the host’s JSON number parsing, not of AFDATA. Any integer beyond 2⁵³ (≈ 9×10¹⁵) — most commonly _epoch_ns (~1.7×10¹⁸ near the current era), but also large _msats/_sats balances — loses precision wherever JSON numbers are parsed as IEEE-754 doubles. This affects JavaScript (JSON.parse always yields a double; use BigInt or a custom parser) and Go with the default json.Unmarshal into any (yields float64; decode with json.Decoder + UseNumber() to preserve exact integers — the library formats json.Number losslessly). Rust (serde_json i64/u64) and Python (arbitrary-precision int) preserve such integers exactly. When exact large integers must survive every language boundary, transport them as strings.

Strict string formats

These suffixes identify strings with a strict external format. They are semantic field-name conventions, not YAML/Plain formatting suffixes: readable output keeps the full key and raw string value.

SuffixFormatExample
_bcp47BCP-47 language tag stringlanguage_bcp47: "zh-CN"
_utc_offsetfixed UTC offset stringtimezone_utc_offset: "+08:00"
_rfc3339_dateRFC 3339 full-date stringinvoice_due_rfc3339_date: "2026-06-13"
_rfc3339_timeRFC 3339 partial-time stringmarket_open_rfc3339_time: "09:30:00"

*_bcp47 names a field whose string value is a BCP-47 language tag, such as language_bcp47: "zh-CN" or content_language_bcp47: "en-US". AFDATA does not implement the full BCP-47 registry; tools may validate tags when they need stronger guarantees.

*_utc_offset names a fixed offset from UTC. Canonical persisted and structured output values are "UTC" or ±HH:MM, with HH in 00..23 and MM in 00..59; zero offsets normalize to "UTC". Examples: timezone_utc_offset: "+08:00", report_utc_offset: "-05:00". This is intentionally not an IANA timezone name: do not use Asia/Shanghai, America/Los_Angeles, DST rules, or timezone databases in this field.

*_rfc3339_date names an RFC 3339 full-date string: exactly YYYY-MM-DD, such as invoice_due_rfc3339_date: "2026-06-13". It is a calendar date, not an instant, and it does not imply any time, offset, or timezone.

*_rfc3339_time names an RFC 3339 partial-time string: exactly HH:MM:SS with optional fractional seconds, such as market_open_rfc3339_time: "09:30:00" or "09:30:00.123". It is a time-of-day, not an instant. It MUST NOT include Z, ±HH:MM, an IANA timezone, or any other timezone annotation; a time without a date cannot be resolved through timezone/DST rules. Use _rfc3339 or _epoch_* for instants.

AFDATA core does not define a companion timezone-name field. If a future tool needs to preserve IANA timezone semantics with a timestamp, prefer a self-contained standard value such as RFC 9557 rather than pairing a date/time field with a separate timezone-name field.

Tools should avoid magic string sentinels such as "auto" inside strict-format fields. If a tool needs auto/default behavior, define that in the tool’s own config semantics rather than as an AFDATA-wide rule.

Size

SuffixValue typeUsageExample
_bytesnumericOutput, APIspayload_bytes: 456789
_sizestring with unitConfig inputbuffer_size: "10M"

Simple rule:

Programs parse _size at load time using parse_size() and convert to bytes for internal use.

Parsing rules for _size (binary units):

UnitMultiplierExample
B or bare number1"512" → 512
K1024"10K" → 10240
M1024²"10M" → 10485760
G1024³"2G" → 2147483648
T1024⁴"1T" → 1099511627776

Case-insensitive. Supports decimals ("1.5M"). Returns null for invalid, negative, or overflow/unrepresentable input. To keep the helper byte-identical across all four ports, parsed sizes above JSON’s safe integer ceiling (2^53 - 1) are rejected.

Example config file:

{
  "shared_buffers_size": "128M",
  "max_wal_size": "1G",
  "archive_retention_size": "2T"
}

In YAML and Plain output, _bytes values auto-scale to human-readable format (5.0MB, 2.0GB).

Percentage

SuffixUnitExample
_percentpercentagecpu_percent: 85

Currency

Bitcoin:

SuffixUnitExample
_msatsmillisatoshisbalance_msats: 97900
_satssatoshiswithdrawn_sats: 1234
_btcbitcoinreserve_btc: 0.5

Fiat — _{iso4217}_cents for currencies with 1/100 subdivision, _{iso4217} for currencies without (JPY). Always integers:

SuffixUnitExample
_usd_centsUS dollar centsprice_usd_cents: 999
_eur_centseuro centsprice_eur_cents: 850
_thb_centsThai baht 1/100fare_thb_cents: 15050
_jpyJapanese yen (no minor unit)price_jpy: 1500

Stablecoins follow the same _{code}_cents pattern: deposit_usdt_cents: 1000, payout_usdc_cents: 500.

Sensitive

SuffixHandlingExample
_secretredact the entire value/subtree to ***api_key_secret: "sk-or-v1-abc..."
_urlredact secret components inside the URL value (userinfo password, secret-named query params); the rest of the URL is preservedcallback_url: "https://h/cb?code_secret=..."

All CLI output formats (JSON, YAML, Plain) automatically redact _secret fields. Any _secret value — scalar, object, or array — becomes the scalar string ***, so a secret-marked container never leaks through JSON, YAML, Plain, or collision fallback. Matching recognizes _secret and _SECRET only. Config files always store the real value. For legacy payloads that cannot rename fields to _secret, use OutputOptions.redaction.secret_names (or RedactionOptions.secret_names in redaction helpers) at serialization time; names match exact field names at any nesting level, with no trim, case folding, hyphen/underscore normalization, globs, regex, or substring matching. Secret-name lists only affect redaction; formatting suffix stripping is still controlled by AFDATA suffixes in the default readable style. Callers that need schema-preserving YAML/plain rendering can use OutputOptions with the Raw output style. For cases that require partial/no redaction on specific payload sections, choose an explicit output policy at serialization time.

Secrets inside URLs

Key-based redaction cannot reach a secret embedded inside a URL string — token in wss://host/cdp?token=abc is not its own field, and the URL often lives in a free-form error or log message that must stay readable. Implementations expose a URL-aware helper for this:

The same secret decision as everywhere else applies, to the URL’s query-parameter names: a parameter is redacted iff its (form-decoded) name ends in _secret/_SECRET, or matches an exact entry in secret_names. No built-in list of “sensitive” parameter names exists — a legacy parameter such as ?token= is redacted only when the caller passes secret_names: ["token"], exactly as for legacy field names. Consumers that own the URL should instead rename the parameter to follow the suffix convention (?token_secret=).

⚠️ Common credential-bearing parameters are NOT redacted by default. The userinfo password (user:pass@host) is always scrubbed structurally, but query parameters are matched by name only. Conventionally-named secret parameters such as ?access_token=, ?api_key=, ?code=, ?id_token=, ?sig=, or ?sessionid= pass through unchanged unless their name ends in _secret or is listed in secret_names. A _url field does not make an arbitrary URL safe to log — it scrubs the userinfo password and suffix-named/listed parameters, nothing more. When you own the URL, rename sensitive parameters to the _secret suffix (?access_token_secret=); when you do not, pass the parameter names via secret_names.

Independently of the parameter convention, the userinfo password component is always redacted as a structural rule: scheme://user:pass@hostscheme://user:***@host (the username is preserved; a userinfo with no : is left untouched).

Input must be a single URL. The standalone helper processes a string iff it begins with a scheme (^[A-Za-z][A-Za-z0-9+.-]*://) and contains no whitespace; any other string — including a URL embedded in surrounding prose — is returned unchanged. Callers that build messages around a URL redact the URL before interpolating it: format("connect {}: {}", redact_url_secrets(url), err).

Surgical replacement. Only the secret spans (a secret parameter’s value bytes after = up to the next &/#/end; the password bytes after the first : in userinfo up to the authority’s last @) are replaced with the literal ***. Every other byte — scheme, host, path, fragment, benign parameters, percent-encoding, ordering — is preserved exactly. Implementations parse with their URL library but must not re-serialize the whole URL (normalization differs across libraries and would break cross-language parity); output equals input outside the redacted spans.

Automatic application via the _url suffix. Redaction applies redact_url_secrets to the string value of any field whose name ends in _url/_URL — and only those fields. No payload string is scanned: the trigger is the field name, exactly like _secret. So final_url and callback_url are scrubbed automatically, while a free-form error or message field is never touched even if it contains a URL (redact such a URL with the helper before interpolating it). RedactionNone disables it along with all other redaction; RedactionTraceOnly scopes it to the trace subtree. A _url value with surrounding whitespace is trimmed before URL redaction. A _url value that cannot be parsed as a clean scheme-prefixed URL is replaced with *** rather than silently passing through a likely malformed secret-bearing value when it carries either internal whitespace or an @ credential sigil — for example a schemeless connection string user:pass@host:5432/db, which has no scheme anchor for the surgical span logic. A schemeless, @-free, whitespace-free value (e.g. a relative URL /cb?page=2) still passes through unchanged. The secret_names list applies to query-parameter names inside _url values as well. (A field carrying both meanings, e.g. token_url_secret, ends in _secret and so its whole value is redacted to ***.)

No suffix needed

Fields whose meaning is obvious from the name alone:

(URL-valued fields are the exception: end them in _url so secrets inside the URL are scrubbed — see the _url suffix above.)

CLI arguments

Same suffixes, kebab-case. An agent reading --help output understands units and sensitivity without documentation:

--timeout-ms 5000          # milliseconds
--cache-ttl-s 3600         # seconds
--max-size-bytes 1048576   # bytes
--api-key-secret sk-xxx    # redact from logs and process listings
--buffer-size 10M          # human-readable config input (parse_size)
--port 8080                # no suffix needed — meaning obvious
--verbose                  # boolean flag — no suffix needed

Long flags only. Do not define single-letter short flags (-s, -d, -l). Short flags are ambiguous — -s could be --synapse, --synopsis, or --source. Agents parsing --help output cannot reliably interpret single-letter aliases. Always use the full --kebab-case form. The only exception is -o for --output and built-in flags like -h/-V from the argument parser.

Kebab → snake mapping. CLI flags map 1:1 to JSON field names by replacing hyphens with underscores. When a CLI tool emits a startup log event (Part 3), the args field uses the snake_case form:

myapp --cache-ttl-s 3600 --api-key-secret sk-xxx --max-size-bytes 1048576
{"code": "log", "event": "startup", "args": {"cache_ttl_s": 3600, "api_key_secret": "***", "max_size_bytes": 1048576}}
---
args:
  api_key: "***"
  cache_ttl: "3600s"
  max_size: "1.0MB"
code: "log"
event: "startup"

The flag name, the JSON field name, and the formatted output all tell the same story. No mapping table, no --help prose explaining “timeout is in milliseconds” — the suffix is the documentation.

Secret flags (--api-key-secret, --database-url-secret) are automatically redacted in startup messages, logs, and YAML/Plain output. Tools should also consider redacting them from /proc process listings where possible.

Human help vs export surface. Help scope and help format are orthogonal. Scope is controlled by --recursive: --help is one-level (and myapp sub --help is one-level for that subcommand), while --help --recursive expands the selected command subtree. Format is controlled by --output: plain by default, or json|yaml|markdown. So human-facing CLIs use plain one-level --help; agent/doc flows use --help --recursive (recursive plain), --help --recursive --output json|yaml (recursive export), or --help --recursive --output markdown (recursive docs). A bare --recursive without --help is a no-op for help and MUST NOT be consumed by the help layer — it falls through to the application’s own parser. Help markdown is help-only and SHOULD NOT become a general business output format.

Version output. Agent-first CLIs should handle --version before the argument parser’s built-in plain-text exit. A bare --version should keep conventional human text, while --version --output json|yaml|plain MUST honor the requested AFDATA renderer. JSON version output uses {"code":"version","version":"<semver>"}. Compatibility wrappers may keep conventional bare text (for example tool 1.2.3) as long as an explicit structured --output is honored.

Environment variables

Same suffixes, UPPER_SNAKE_CASE:

DATABASE_URL_SECRET=postgres://user:pass@host/db
CACHE_TTL_S=3600
TOKEN_VALIDITY_HOURS=24
RUST_LOG=info

Config files

Config files follow the same naming suffixes. Agents reading a config file can determine units, formats, and sensitivity without a separate schema.

YAML

openrouter:
  api_key_secret: "sk-or-v1-actual-key"
  model: "google/gemini-3-flash-preview"

storage:
  backend: redb
  postgres_url_secret: "postgres://user:pass@host/db"
  redb_path: "data.redb"

cache:
  dns_ttl_s: 3600
  manifest_ttl_s: 300

pricing:
  input_msats: 2
  output_msats: 12

TOML

[cache]
dns_ttl_s = 3600
manifest_ttl_s = 300

[openrouter]
api_key_secret = "sk-or-v1-actual-key"
model = "google/gemini-3-flash-preview"

Database schemas

Same suffixes in column names. Agents reading a table schema can determine units, formats, and sensitivity without external documentation.

When the database type already carries semantics, no suffix is needed. TIMESTAMPTZ says “timestamp with timezone” — adding _epoch_ms is redundant. Suffixes are for generic types (BIGINT, INTEGER, TEXT) where the type alone is ambiguous.

CREATE TABLE events (
    id TEXT PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL,   -- type says timestamp, no suffix needed
    duration_ms INTEGER,               -- INTEGER is ambiguous, suffix needed
    payload_bytes INTEGER,
    api_key_secret TEXT,
    retry_count INTEGER,               -- no suffix needed, meaning is obvious
    domain TEXT NOT NULL
);
ColumnTypeSuffix needed?Why
created_atTIMESTAMPTZnotype encodes semantics
duration_msINTEGERyes142 what? ms vs s vs μs
payload_bytesINTEGERyesbytes vs KB vs count
api_key_secretTEXTyesenables auto-redaction
retry_countINTEGERnomeaning obvious from name
expires_atTIMESTAMPTZnotype encodes semantics
cached_epoch_msBIGINTyesbare integer needs unit

ORM / struct mapping: Keep the suffix in the struct field name. The suffix is part of the semantic name, not a display concern:

struct Event {
    created_at: DateTime<Utc>,   // native type — no suffix
    duration_ms: i64,            // integer — suffix preserves semantics
    // duration: i64,            // bad — 64-bit what? seconds? ms?
}

Queries: Column aliases in views or query results should also follow AFDATA naming:

SELECT
    duration_ms,
    payload_bytes,
    (cost_input_msats + cost_output_msats) AS total_cost_msats
FROM requests;

Part 2: Output Processing

Transform JSON values for CLI/log output with suffix-driven formatting and automatic secret protection. This applies to any JSON data, regardless of structure.

Two Output Paths

Path 1: Raw JSON Serialization

Return JSON values directly (for example via framework serializer or serde_json::to_string).

No output processing. Values are serialized as-is:

{"user_id": 123, "api_key_secret": "sk-1234567890abcdef", "balance_msats": 50000}

Path 2: CLI / Logs

Format JSON values for terminal/log display.

Automatic processing: Suffix formatting + secret redaction.

Input:

{"user_id": 123, "api_key_secret": "sk-1234567890abcdef", "balance_msats": 50000}

JSON: {"api_key_secret":"***","balance_msats":50000,"user_id":123}

YAML:

---
api_key: "***"
balance: "50000msats"
user_id: 123

Plain: api_key=*** balance=50000msats user_id=123

Output Formats

CLI tools should support multiple output formats:

--output json|yaml|plain
--log startup,request,progress,retry,redirect
--verbose

Default is tool-defined. Interactive CLIs default to yaml, scripting/logging contexts to json.

JSON is the canonical format. YAML and plain are derived from it.

All CLI output formats automatically redact _secret fields. Matching recognizes _secret and _SECRET only. Any _secret value — scalar, object, or array — is replaced with ***. Legacy field names can be protected by passing OutputOptions.redaction.secret_names at serialization time; this opt-in list is exact field-name equality. The Raw output style disables YAML/plain formatting suffix stripping while keeping the selected redaction policy.

Format characteristics:

yaml

Each JSON line becomes a YAML document, separated by ---. Strings always quoted to avoid YAML pitfalls (nofalse, 3.0 → float). In the default readable style, formatting suffixes are stripped from keys (value already encodes the unit). Secrets automatically redacted.

---
args:
  config_path: "config.yml"
code: "log"
config:
  api_key: "***"
  dns_ttl: "3600s"
event: "startup"
---
code: "ok"
result:
  hash: "abc123"
  size: "446.1KB"
trace:
  duration: "1.28s"
  cost: "2056msats"

plain

Single-line logfmt style. In the default readable style, formatting suffixes are stripped from keys. Secrets automatically redacted.

args.config_path=config.yml code=log config.api_key=*** config.dns_ttl=3600s event=startup
code=ok result.hash=abc123 result.size=446.1KB trace.cost=2056msats trace.duration=1.28s

Suffix processing (yaml and plain)

YAML and plain apply two transformations:

1. Key stripping — remove the recognized formatting suffix from the key name. The formatted value already encodes the unit, so the suffix is redundant for human readers.

Algorithm: match the longest known suffix from the list below. Each suffix is recognized in two forms: lowercase (_secret) and uppercase (_SECRET). No other casing is matched. Remove the matched suffix from the key. If no suffix matches, keep the key unchanged. Match order (longest first):

  1. _epoch_ms, _epoch_s, _epoch_ns (compound timestamp suffixes)
  2. _usd_cents, _eur_cents, _{code}_cents (compound currency suffixes; code is 3-4 ASCII letters)
  3. _rfc3339, _minutes, _hours, _days (multi-char suffixes)
  4. _msats, _sats, _bytes, _percent, _secret (single-unit suffixes)
  5. _btc, _jpy, _ns, _us, _ms, _s (short suffixes, matched last to avoid false positives)

Strict string suffixes (_bcp47, _utc_offset, _rfc3339_date, _rfc3339_time) are not key-stripping suffixes. They keep the field’s format contract visible in readable output.

Collision: if two keys in the same object produce the same stripped key (e.g., response_ms and response_bytes both → response), revert both to their original key AND raw value (no formatting). Redaction happens before this step, so collision fallback can never restore a secret value.

JSON keyYAML/Plain keyWhy
duration_msdurationvalue shows 1.28s
size_bytessizevalue shows 446.1KB
created_at_epoch_mscreated_atvalue shows 2025-02-07T...
expires_rfc3339expiresvalue passes through
api_key_secretapi_keyvalue shows ***
cpu_percentcpuvalue shows 85%
balance_msatsbalancevalue shows 50000msats
price_usd_centspricevalue shows $9.99
DATABASE_URL_SECRETDATABASE_URLuppercase _SECRET matched
CACHE_TTL_SCACHE_TTLuppercase _S matched
buffer_sizebuffer_size_size passes through, key unchanged
language_bcp47language_bcp47strict string format, key unchanged
timezone_utc_offsettimezone_utc_offsetfixed-offset string, key unchanged
invoice_due_rfc3339_dateinvoice_due_rfc3339_dateRFC 3339 full-date string, key unchanged
market_open_rfc3339_timemarket_open_rfc3339_timeRFC 3339 partial-time string, key unchanged
config_pathconfig_pathno suffix, unchanged
user_iduser_idno suffix, unchanged

2. Value formatting — transform the value for human readability. Same suffix matching as key stripping (lowercase or uppercase only):

Strict string fields such as _bcp47, _utc_offset, _rfc3339_date, and _rfc3339_time are not value-formatting suffixes; their string values pass through unchanged.

A _url field value is preserved byte-for-byte in YAML and plain except for the redacted secret spans (userinfo password, _secret-suffixed/secret_names query parameters): the _url key is not stripped, and formatting suffixes that appear inside the URL — ?timeout_ms=5000, ?size_bytes=1048576 — are not reformatted (5s, 1.0MB) or stripped, because the URL must round-trip to its server exactly. URL key-stripping/value-formatting applies to JSON object keys, never to query parameters inside a string value. This is pinned by the url_params_redacted_not_reformatted case in spec/fixtures/output_formats.json.

Type constraints: _bytes and _epoch_* require integer values. _usd_cents, _eur_cents, _jpy, and _{code}_cents require non-negative integers. Duration, Bitcoin, and _percent suffixes accept any number. When the value type doesn’t match, formatting falls through to the raw value with the original key preserved. An integral-valued float counts as an integer for the integer-required suffixes (3.0 is treated as 3): a JSON number’s value, not its lexical form, decides, because JavaScript cannot distinguish 3 from 3.0 after parsing.

Number rendering: a number is rendered for YAML/plain by the shared fixture-defined decimal form: integral-valued floats drop their trailing .0 (3.03), exponent markers use lowercase e, and exponent signs/leading zeroes are normalized (1e-071e-7). Integers beyond 2⁵³ are preserved exactly by Rust, Go, and Python; JavaScript loses precision on them (see the _epoch_ns precision note above).

Key ordering

YAML and plain output sort keys (after stripping) by UTF-16 code unit order (JCS, RFC 8785 §3.2.3). For ASCII keys — the common case — this equals simple byte-order sorting.

In plain logfmt, nested keys are flattened to dot notation before sorting. Sort by the full dot path: args.input_path < code < config.api_key < trace.duration.

JSON output is unordered per the JSON specification. YAML and plain sort for deterministic, cross-language-consistent output.

Using AFDATA Without Part 3

Parts 1 and 2 (naming + output processing) work with any JSON structure — no protocol template needed:

{"user_id": 123, "created_at_epoch_ms": 1738886400000, "balance_msats": 50000000, "api_key_secret": "sk-..."}

Plain: api_key=*** balance=50000000msats created_at=2025-02-07T00:00:00.000Z user_id=123

This works with REST APIs, GraphQL, database results, config files — anywhere you have structured data. Just use AFDATA naming and let output processing handle the rest.


Part 3: Protocol Template (Recommended, Optional)

A recommended structure for program output. This part is optional — adopt it when you want consistent structure across CLI tools, streaming output, or internal protocols.

Core Fields

Required:

Recommended:

Everything else is flexible. Fields can be flat or nested. Both styles are valid. Examples below show both approaches.

JSONL Stream

Programs emit JSONL to stdout — one JSON object per line. Every line has a code field identifying its type:

Channel policy:

Optional stream redirection:

Recommended enforcement:

codeMeaning
"log"Diagnostic event (event field identifies startup/request/progress/retry/redirect)
"ok"Success result
"error"Generic error (prefer specific codes)
tool-definedStatus / errors / progress

Minimum logging envelope across language integrations:

Log fields are redacted by field name at emit time — the same _secret/_url rule as all other output, applied by the formatter, not by scanning rendered values. Emit secrets as named fields (api_key_secret) so the rule can see them. Logging a whole object pre-rendered to a single string (e.g. a language’s debug/inspect form) defeats redaction, because the inner field names are no longer visible: build a structured value and redact it before logging instead.

Three values are reserved: log, ok, error. All other values are tool-defined.

Error codes: Use specific codes instead of generic "error":

Status codes: Progress, requests, custom events:

Not all phases are required. A simple CLI tool may emit only a result line. A long-running service may never emit a result.

Startup Diagnostic Event

code: "log", event: "startup". Optional. Emitted once at the beginning if diagnostic logging is enabled.

{"code": "log", "event": "startup", "version": "0.1.0", "argv": ["tool", "--log", "startup"], "config": {"api_key_secret": "***", "dns_ttl_s": 3600}, "args": {"config_path": "config.yml"}, "env": {"RUST_LOG": null, "DATABASE_URL_SECRET": "***"}}

Startup payload fields are tool-defined. Common fields:

Status

code is tool-defined. Content is tool-defined. Include trace for execution context.

{"code": "progress", "current": 3, "total": 10, "message": "indexing spores", "trace": {"duration_ms": 500}}
{"code": "request", "method": "POST", "path": "/v1/chat", "http_status": 200, "trace": {"latency_ms": 42}}

Result

code: "ok" on success, code: "error" or specific error code on failure. An agent watching a stream can treat any result code as the signal that the operation is complete.

Always include trace for execution context — duration, data sources, resource usage, query details.

Success - both styles valid:

Nested (structured):

{"code": "ok", "result": {"hash": "abc123", "size_bytes": 456789}, "trace": {"duration_ms": 1280, "tokens_input": 512}}

Flat:

{"code": "ok", "hash": "abc123", "size_bytes": 456789, "trace": {"duration_ms": 1280, "tokens_input": 512}}

Error - both styles valid:

Simple message:

{"code": "error", "error": "config file not found", "trace": {"duration_ms": 3}}

With actionable hint:

{"code": "error", "error": "connection refused", "hint": "check --host/--port or PGHOST/PGPORT environment variables", "trace": {"duration_ms": 3}}

The hint field is optional. When present, it provides an actionable suggestion for the user or agent to resolve the error. Omit hint when no specific remediation is available.

Nested error details:

{"code": "not_found", "error": {"resource": "user", "id": 123}, "trace": {"duration_ms": 8}}

Flat error details:

{"code": "not_found", "resource": "user", "id": 123, "trace": {"duration_ms": 8}}

More examples (flat style):

{"code": "validation_error", "fields": ["email", "age"], "trace": {"duration_ms": 2}}
{"code": "unauthorized", "message": "invalid token", "trace": {"duration_ms": 5}}
{"code": "rate_limit", "retry_after_s": 60, "quota_remaining": 0, "trace": {"duration_ms": 1}}

Best Practices

Always include trace field. Even simple operations should report execution context:

Good (with trace):

{"code": "ok", "count": 42, "trace": {"duration_ms": 150, "source": "db"}}
{"code": "error", "error": "not found", "trace": {"duration_ms": 5}}

Also good (structured):

{"code": "ok", "result": {"count": 42}, "trace": {"duration_ms": 150, "source": "db"}}
{"code": "validation_error", "error": {"fields": [...]}, "trace": {"duration_ms": 2}}

Avoid (missing trace):

{"code": "ok", "count": 42}
{"code": "error", "error": "not found"}

Missing trace makes debugging harder. Agents can’t analyze performance, cost, or data flow without execution context.

Agent consumption

  1. Read code on every line.
  2. {"code":"log","event":"startup",...} → understand configuration.
  3. "ok" or "error" → operation complete.
  4. Anything else → status/progress, tool-specific.

Usage in HTTP Services

The protocol structure can be used in REST APIs. Choose output path explicitly:

REST API Examples

Response body follows the protocol structure:

HTTP 200:

{"code": "ok", "result": {"balance_msats": 97900}, "trace": {"source": "redb", "duration_ms": 3}}

HTTP 404:

{"code": "not_found", "error": {"resource": "user", "id": 123}, "trace": {"duration_ms": 5}}

HTTP 402:

{"code": "insufficient_balance", "error": {"balance_msats": 0, "required_msats": 2056}, "trace": {"source": "redb", "duration_ms": 2}}

MCP Tool Response

Same structure, raw JSON:

{"code": "ok", "result": {"files": ["src/main.rs"]}, "trace": {"source": "glob", "matched": 1, "duration_ms": 12}}

Streaming (SSE)

JSONL stream, raw JSON per line:

{"code": "log", "event": "startup", "config": {"model": "gpt-4", "max_tokens": 1024}, "args": {}, "env": {}}
{"code": "progress", "current": 1, "total": 5, "message": "processing", "trace": {"duration_ms": 500}}
{"code": "ok", "result": {"answer": "..."}, "trace": {"tokens_input": 512, "duration_ms": 1280}}

One Protocol, Multiple Contexts

ContextOutputSecret Protection
CLI / LogsJSONL (json/yaml/plain formats)✅ Automatic
HTTP body (raw path)JSON body (raw Value)Use redacted_value before framework serialization
MCP tool (raw path)JSON (raw Value)Use redacted_value before SDK serialization
SSE stream (raw path)JSONL (raw JSON)Use redacted_value before emitting events

All contexts can use the protocol structure from Part 3. Only code (required) and trace (recommended) are standardized. Other fields can be flat or nested — both styles work. CLI/logs apply output formatting and secret protection from Part 2. Raw-path serializers return JSON values unchanged unless the program explicitly calls redacted_value. For CLI/log protocol transport, use stdout only; do not split protocol events across stdout and stderr.


Complete Example: CLI Tool

A complete example showing all three parts working together. A backup tool that uploads files to cloud storage.

CLI Invocation

cloudback --api-key-secret sk-1234567890abcdef --timeout-s 30 --max-file-size-bytes 10737418240 /data/backup.tar.gz

Flag names use AFDATA suffixes in kebab-case. An agent reading --help knows --timeout-s is seconds and --api-key-secret should be redacted — no documentation needed.

Raw JSON (before output processing)

The tool converts CLI flags from kebab-case to snake_case and emits a startup diagnostic event when enabled:

{
  "code": "log",
  "event": "startup",
  "config": {
    "api_key_secret": "sk-1234567890abcdef",
    "endpoint": "https://storage.example.com",
    "timeout_s": 30,
    "max_file_size_bytes": 10737418240
  },
  "args": {
    "input_path": "/data/backup.tar.gz",
    "compression_level": 9
  }
}

Field names encode semantics:

Output Formats (Part 2: Output Processing)

JSON (raw, for machines):

{"code":"log","event":"startup","config":{"api_key_secret":"***","endpoint":"https://storage.example.com","timeout_s":30,"max_file_size_bytes":10737418240},"args":{"input_path":"/data/backup.tar.gz","compression_level":9}}

YAML (structured, formatting suffixes stripped, for human inspection):

---
args:
  compression_level: 9
  input_path: "/data/backup.tar.gz"
code: "log"
config:
  api_key: "***"
  endpoint: "https://storage.example.com"
  max_file_size: "10.0GB"
  timeout: "30s"
event: "startup"

Plain (single-line logfmt, formatting suffixes stripped, for compact scanning):

args.compression_level=9 args.input_path=/data/backup.tar.gz code=log config.api_key=*** config.endpoint=https://storage.example.com config.max_file_size=10.0GB config.timeout=30s event=startup

Note:

Progress Update (Part 3: Protocol Template)

{"code": "progress", "current": 3, "total": 10, "message": "uploading chunks", "trace": {"duration_ms": 5420, "uploaded_bytes": 3221225472}}

YAML:

---
code: "progress"
current: 3
message: "uploading chunks"
total: 10
trace:
  duration: "5.42s"
  uploaded: "3.0GB"

Plain:

code=progress current=3 message="uploading chunks" total=10 trace.duration=5.42s trace.uploaded=3.0GB

Final Result

{"code": "ok", "result": {"backup_url": "https://storage.example.com/backup.tar.gz", "size_bytes": 10485760, "checksum": "sha256:abc123...", "uploaded_at_epoch_ms": 1738886400000}, "trace": {"duration_ms": 15300, "chunks": 10, "retries": 2}}

YAML:

---
code: "ok"
result:
  backup_url: "https://storage.example.com/backup.tar.gz"
  checksum: "sha256:abc123..."
  size: "10.0MB"
  uploaded_at: "2025-02-07T00:00:00.000Z"
trace:
  chunks: 10
  duration: "15.3s"
  retries: 2

Plain:

code=ok result.backup_url=https://storage.example.com/backup.tar.gz result.checksum=sha256:abc123... result.size=10.0MB result.uploaded_at=2025-02-07T00:00:00.000Z trace.chunks=10 trace.duration=15.3s trace.retries=2

What This Demonstrates

  1. Part 1 (Naming): Every field is self-describing — from CLI flags (--timeout-s, --api-key-secret) to JSON fields (timeout_s, uploaded_at_epoch_ms). Same suffixes, same semantics, kebab↔snake mapping

  2. Part 2 (Output Processing): Three formats for different needs

    • JSON: single-line, original keys, raw values, for programs and logs
    • YAML: multi-line, formatting suffixes stripped, values formatted, for human inspection
    • Plain: single-line logfmt, formatting suffixes stripped, values formatted, for compact scanning
    • All formats protect secrets automatically
  3. Part 3 (Protocol): Consistent structure across all output — code identifies message type, trace provides execution context, other fields flexible

Key insight: The same naming convention flows from CLI flag (--timeout-s 30) to JSON field (timeout_s: 30) to formatted output (timeout: 30s). An agent reading --help, JSON output, or YAML all gets the same self-describing semantics — no documentation needed at any layer.