Agent-First HTTP: One Request, One JSON Line
A structured HTTP client for agents that turns requests, streaming bodies, and transport failures into stable JSON events.
You’re building an agent that calls an API. You run curl. It writes raw bytes to stdout — no status code, no headers, no timing. If the server returns a 404, curl exits with code 22 and writes “The requested URL returned error: 404” to stderr. A connection refused is exit code 7 and a different English sentence. Your agent now has to drain two output channels, map exit codes to meanings, and parse human-readable prose to figure out what happened.
afhttp is a persistent HTTP client built for agents. The contract: stdout is always structured JSON, and every failure is a structured {"code":"error",...} event with a stable error_code the agent branches on. No human-only text parsing, no mixed output channels, no ad-hoc error shapes.
CLI mode: one request, one JSON line
One request, one JSON line, exit:
afhttp GET https://api.example.com/users
# {"code":"response","status":200,"body":[...],"trace":{"duration_ms":120,"http_version":"h2"}}
afhttp POST https://api.example.com/users --body '{"name":"Alice","email":"alice@example.com"}'
# {"code":"response","status":201,"body":{"id":42},...}
afhttp GET https://api.example.com/data --header "Authorization: Bearer sk-xxx"
# {"code":"response","status":200,...}
status is the HTTP status code. body is the parsed response body — JSON object if the server sent JSON, string otherwise. trace carries duration_ms, http_version, and connection metadata.
Transport failures come back as structured error events, not exit codes or stderr text:
{"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}}
error_code is stable and machine-readable. retryable tells the agent whether retrying may succeed. HTTP 4xx and 5xx responses are not errors — they arrive as code: "response" with a status field. A 404 is data; the agent reads status and decides what to do.
Pipe mode: persistent connection, concurrent requests
For long-lived sessions — connection reuse, concurrent requests, streaming, WebSocket — use --mode pipe. afhttp reads JSONL from stdin and writes JSONL to stdout until stdin closes.
afhttp --mode pipe <<'EOF'
{"code":"config","host_defaults":{"api.example.com":{"headers":{"x-api-key":"sk-ant-xxx","api-version":"2023-06-01"}}}}
{"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"}
{"code":"request","id":"chat","method":"POST","url":"https://api.example.com/v1/messages","body":{"model":"general-chat-model","max_tokens":256,"messages":[{"role":"user","content":"Hello"}],"stream":true},"options":{"chunked":true,"chunked_delimiter":"\n\n"}}
EOF
Output:
{"code":"config","host_defaults":{"api.example.com":{"headers":{"x-api-key":"[redacted]","api-version":"2023-06-01"}}},...}
{"code":"response","id":"models","status":200,"body":{"data":[{"id":"general-chat-model",...}]},"trace":{"duration_ms":92,"http_version":"h2"}}
{"code":"response","id":"usage","status":403,"body":{"error":{"type":"permission_error","message":"Your API key does not have permission"}},"trace":{"duration_ms":87}}
{"code":"chunk_start","id":"chat","status":200,"headers":{"content-type":"text/event-stream"}}
{"code":"chunk_data","id":"chat","data":"event: content_block_delta\ndata: {\"delta\":{\"text\":\"Hello\"}}"}
{"code":"chunk_end","id":"chat","trace":{"duration_ms":834,"chunks":8}}
Several things worth noting.
Auth was set once in host_defaults, scoped to api.example.com. Requests to any other host will not receive these headers — credentials don’t leak across domains. For non-sensitive public headers like User-Agent or Accept that should apply to every request regardless of host, use defaults.headers_for_any_hosts instead.
All three requests were sent without waiting for earlier responses. They were in-flight simultaneously. Responses arrived in completion order — models and usage finished before chat started streaming. The agent matches responses to requests using id.
Connection reuse is automatic. models and usage both go to api.example.com. After the first request establishes a connection (including TLS), the second request reuses it. No second handshake.
HTTP 403 is not an error at the protocol level. The usage response came back as code: "response" with status: 403 and the error body intact. The agent reads status, decides what to do, and moves on. There’s no exception to catch, no stderr to drain.
The chat request used options.chunked because the API streams server-sent events. afhttp reads chunks as they arrive and emits chunk_data lines for each one, then a chunk_end with timing. The agent can process partial results as they stream in without buffering the whole response.
Input commands
Pipe mode accepts five input commands: config sets connection defaults (base URL, headers, timeouts) that apply to subsequent requests. request sends an HTTP request. cancel stops an in-flight request by id. ping checks that afhttp is still running. close signals end of input and flushes pending responses.
Response types
Responses are response for completed non-streaming requests, chunk_start / chunk_data / chunk_end for streaming responses, error for transport failures (not HTTP errors), and pong for ping replies.
MCP mode
--mode mcp runs afhttp as an MCP server over stdio. Add it to Claude Desktop’s config:
{
"mcpServers": {
"afhttp": { "command": "afhttp", "args": ["--mode", "mcp"] }
}
}
Two tools are available: http_request makes a single HTTP call and returns structured JSON. http_config sets connection defaults that persist for the session.
curl compatibility
--mode curl accepts a subset of curl flags and returns structured JSON instead of the raw response body:
afhttp --mode curl -X POST https://api.example.com/users \
-H "Authorization: Bearer sk-xxx" \
-d '{"name":"Alice"}'
Agents with existing tool definitions written for curl can switch to afhttp without changing their tool calls. The flags translate; the output becomes machine-readable.
Install
brew install cmnspore/tap/afhttp # macOS/Linux
scoop bucket add cmnspore https://github.com/cmnspore/scoop-bucket && scoop install afhttp # Windows
cargo install agent-first-http # any platform