Agent-First HTTP: An HTTP Client Designed from the Agent Side

by Agent-First Kit Contributors

What an HTTP client should look like when the primary caller is an agent: structured responses, previewable requests, persistent sessions, body-preserving formats, and typed transport failures.

A human running curl gets a screen. The response body lands on stdout, the status code is the exit code mapped through curl’s table, headers are off by default, timing is off unless you ask. If the request fails before a response, curl prints Could not resolve host: api.example.com to stderr and exits 6. Two channels, a numeric code that means a particular sentence, and an English message the human reads and acts on.

An agent calling curl has to handle the same shape, but it cannot read the sentence and decide. It has to drain two streams, look up what exit code 6 meant this version, and grep the prose to figure out whether to retry or give up.

afhttp asks the same question this kit asks about every tool it builds:

What should an HTTP client look like when the primary caller is an autonomous coding agent?

The premise: an HTTP response is state, not text

A response from an HTTP server is not a body. It is a status code, a set of headers, a body whose type is described by those headers, timing, the HTTP version actually negotiated, and a record of any redirects taken to get there. A human can reconstruct most of that from curl’s output and context. An agent should not have to.

The shape an agent wants is one object that carries all of it:

{"code":"response","status":200,"headers":{"content-type":"application/json"},"body":{"id":42},"trace":{"duration_ms":120,"http_version":"h2"}}

One request in, one structured event out. The agent never needs to look elsewhere.

The output rule: one request, one event

afhttp GET https://api.example.com/users/42
# {"code":"response","status":200,"body":{"id":42,"name":"Ada"},"trace":{"duration_ms":120,"http_version":"h2"}}

afhttp POST https://api.example.com/users \
  --body '{"name":"Ada","email":"ada@example.com"}'
# {"code":"response","status":201,"body":{"id":43},"trace":{"duration_ms":180,"http_version":"h2"}}

status is the HTTP status code. body is the parsed response — a JSON object if the server returned JSON, a string otherwise. headers is a map. trace carries timing, HTTP version, and any redirect chain.

The discipline that matters here: HTTP 4xx and 5xx are not errors at the protocol level. A 404 is data; an agent reads status and decides what to do. There is no exception to catch, no exit-code table to consult. The event shape is the same for 200, 404, and 500.

This is the first afhttp design rule: a network exchange yields one structured event, and the agent reads its fields without parsing prose.

The preview rule: agents should see the request before it ships

A typo in --body, a header set to the wrong host, an Authorization line that picked up the development key — these are real bugs. A human catches them by squinting at a curl command. An agent should not have to squint; the tool should describe the exact wire request it is about to send before it sends it.

afhttp POST https://api.example.com/orders \
  --header "Authorization: Bearer $TOKEN" \
  --body '{"amount":100}' \
  --dry-run
# {"code":"preview","method":"POST","url":"https://api.example.com/orders","headers":{"authorization":"[redacted]","content-type":"application/json"},"body":{"amount":100}}

The preview event has the same shape conventions as the response — code, structured fields, secrets redacted by the afdata rules. The agent can compare what it intended to send against what afhttp would actually send, and only then commit.

For the dry-run design in detail, see the v0.3.2 previewable post.

The rule: a tool that touches the network should be able to describe its next request without touching the network.

The pipe rule: a session is a session

A real agent workflow makes more than one request. It authenticates, lists resources, follows a link, posts a result. Those calls share auth, share cookies, share a TCP connection, share rate-limit state. A tool that re-establishes everything per call is throwing that away.

Pipe mode reads JSONL from stdin and writes JSONL to stdout for the lifetime of the process:

afhttp --mode pipe <<'EOF'
{"code":"config","host_defaults":{"api.example.com":{"headers":{"authorization":"Bearer $TOKEN"}}}}
{"code":"request","id":"models","method":"GET","url":"https://api.example.com/v1/models"}
{"code":"request","id":"usage","method":"GET","url":"https://api.example.com/v1/usage"}
EOF

Several invariants come together here. Auth headers set in config are scoped to a host — credentials cannot leak to another domain in the same session. Requests are issued without waiting for earlier responses to finish; responses arrive in completion order, matched to requests by id. The TCP connection (including TLS) established by the first request to api.example.com is reused by the second.

Pipe mode is not an optimization. It is the only place where a session has a meaningful identity. Per-call invocation forces every request to start from zero — which is exactly what an agent doing a multi-step workflow cannot afford.

The rule: connection reuse, auth scope, and request concurrency are part of the session contract, not side effects of how fast the program happens to start.

The body rule: responses preserve their bytes

A response body has a type, declared by the server’s Content-Type. JSON should arrive as a parsed object. Plain text should arrive as a string. A PNG, a tarball, a protobuf payload should arrive as bytes — not a “display string” guess, not a UTF-8 lossy decode that silently corrupts binary.

afhttp routes the body by the response’s declared type. JSON parses into the structured event’s body field. Text is preserved as a string. Binary content is delivered as base64 with an explicit body_encoding marker, or streamed to a file if the request asked for that. The agent always knows what kind of value is in body because the event says so.

For the formatter behavior in pipe mode, see the v0.3.4 output-formats post.

The rule: the formatter never coerces a body into a shape the server did not declare.

The failure rule: transport errors are events too

A network call has two failure surfaces. The HTTP layer can return any status the server cares to send — already handled by the output rule above. The transport layer can fail before any HTTP exchange exists: DNS did not resolve, TLS did not handshake, the TCP connection refused, the read timed out, the peer dropped mid-stream.

Curl reports those as a stderr sentence and an exit code. afhttp reports them as structured events:

{"code":"error","error_code":"connect_timeout","error":"request timed out after 30s","retryable":true,"trace":{"duration_ms":30012}}
{"code":"error","error_code":"dns_failed","error":"failed to resolve 'api.example.com'","retryable":true,"trace":{"duration_ms":5001}}
{"code":"error","error_code":"tls_handshake_failed","error":"hostname mismatch","retryable":false,"trace":{"duration_ms":820}}

error_code is the agent’s branch point. retryable is the tool’s opinion on whether a retry could plausibly succeed. The same JSONL stream that delivers responses delivers failures — the agent reads one line at a time and never needs a second channel.

The rule: a transport failure is data the agent can handle, not prose it has to translate.

The shape of this release: afhttp encodes the contract

The current afhttp line carries each rule into a concrete primitive:

The change to internalize is not any one flag. It is that an HTTP client for an agent stops pretending the screen is the target.

The next direction: more of the agent’s questions answered in-band

The HTTP design space is large, and afhttp is intentionally narrow today. Some next steps are clear:

The direction is not “make curl prettier.” It is an HTTP client that hands the agent the same structured world every other tool in the kit hands it: events, codes, traces, and policies it can act on.