The Smallest Claude Client Is a Shell Script
Every SDK for a language model API is thousands of lines, and that size teaches people that a client is a big thing. It is not. Underneath, the Claude Messages API is one HTTPS POST with a JSON body, and the reply is a stream of Server-Sent Events of which you need to understand six. This post is a

Posted on Oct 17 • Originally published at stepinto.dev
Every SDK for a language model API is thousands of lines, and that size teaches people that a client is a big thing. It is not. Underneath, the Claude Messages API is one HTTPS POST with a JSON body, and the reply is a stream of Server-Sent Events of which you need to understand six. This post is a complete client in bash and curl, in one file, with no jq. It streams the reply as it arrives, completes a tool call round trip, and reports API errors properly. It is the floor for a series that does the same thing in a different language every Thursday and measures each one against the others.
The code and the harness are at github.com/AndyCross/smallest-client, and the leaderboard lives at /smallest-client/.
Every client in the series has to pass the same seven points, and a harness checks them rather than me reading the code.
Point 5 is the one that separates a client from a curl wrapper. Tool use is where the protocol stops being request and response and becomes a conversation you have to maintain, and the tool's arguments arrive as fragments of JSON that you assemble yourself.
With stream: true the response is text/event-stream. Each event is an event: line, a data: line carrying JSON, and a blank line. Here is the shape of the first turn, from a real recording, trimmed:
event: message_start data: { "type": "message_start", "message":{ "id": "msg_...", "model": "claude-opus-5-5",... }} event: content_block_start data: { "type": "content_block_start", "index": 0, "content_block":{ "type": "tool_use", "id": "toolu_...", "name": "local_time", "input":{}}} event: ping data: { "type": "ping" } event: content_block_delta data: { "type": "content_block_delta", "index": 0, "delta":{ "type": "input_json_delta", "partial_json": "{ \" timezone \" " }} event: content_block_delta data: { "type": "content_block_delta", "index": 0, "delta":{ "type": "input_json_delta", "partial_json": ": \" Europe/London \" }" }} event: content_block_stop data: { "type": "content_block_stop", "index": 0 } event: message_delta data: { "type": "message_delta", "delta":{ "stop_reason": "tool_use" }, "usage":{ "output_tokens": 56 }} event: message_stop data: { "type": "message_stop" } Enter fullscreen mode Exit fullscreen mode Two things I had wrong before I recorded it. The model went straight to the tool call with no preamble text, so a client that assumes a text block always comes first is wrong. And there is a ping event in the middle that the documentation mentions and nobody's mental model includes.
A client has to ignore event types it does not know.
The six you act on are message_start, content_block_start, content_block_delta, content_block_stop, message_delta and message_stop. Deltas come in two kinds, text_delta with a text field and input_json_delta with a partial_json field, and the index says which content block they belong to.
The whole thing is 93 lines. The interesting parts follow, and the rest is in the repo.
JSON without jq means encoding strings yourself. The prompt comes from stdin and could contain anything, so backslash, quote and the control characters have to be escaped:
json_str () { local s = $1 s = ${ s //\\/\\\\ }; s = ${ s //\ "/ \\\" } s= ${ s // $' \n ' /\\n }; s= ${ s // $' \r ' /\\r }; s= ${ s // $' \t ' /\\t } printf '" %s "' " $s " } Enter fullscreen mode Exit fullscreen mode Decoding goes the other way. The text deltas arrive as JSON strings, escapes and all, and bash's printf '%b' happens to understand \n, \t, \\ and \uXXXX, so after swapping \" for " you can hand it the string:
json_unstr () { local s = $1 s = ${ s //\\\ "/ \" }; s= ${ s //\\\//\/ } printf '%b' " $s " } Enter fullscreen mode Exit fullscreen mode That \uXXXX support needs a UTF-8 locale. In a slim Debian image there is none set, printf prints the escape literally, and "café" comes out as caf\u00E9. The Dockerfile sets LC_ALL=C.UTF-8 and that is the kind of thing this series is for.
The stream loop is curl with -N to turn off its output buffering, read line by line, and a regex per event type:
while IFS = read -r line || [[ -n $line ]]; do [[ $line == data: * ]] || { [[ $line == event: * || -z $line ]] || errbody+ = " $line "; continue; } data = ${ line #data: } if [[ $data = ~ \" type \": \" text_delta \", \" text \": \" (( \\.|[^ \"\\ ]) * ) \" ]]; then text = $( json_unstr " ${ BASH_REMATCH } "; printf x ); text = ${ text %x } printf '%s' " $text "; ASSISTANT_TEXT+ = " $text " elif [[ $data = ~ \" type \": \" input_json_delta \", \" partial_json \": \" (( \\.|[^ \"\\ ]) * ) \" ]]; then TOOL_INPUT+ = $( json_unstr " ${ BASH_REMATCH } " ) elif [[ $data = ~ \" type \": \" tool_use \", \" id \": \" ([ ^ \" ] + ) \", \" name \": \" ([ ^ \" ] + ) \" ]]; then TOOL_ID = ${ BASH_REMATCH }; TOOL_NAME = ${ BASH_REMATCH } elif [[ $data = ~ \" stop_reason \": \" ([ a-z_]+ ) \" ]]; then STOP_REASON = ${ BASH_REMATCH } fi done < < ( curl -sS -N -D " $hdr " " $BASE /v1/messages" \ -H "x-api-key: $ANTHROPIC_API_KEY " \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ --data-binary " $body " ) Enter fullscreen mode Exit fullscreen mode The || [[ -n $line ]] on the read is there because an error response is a single JSON line with no trailing newline, and read returns false on the last line of a file that lacks one, so without it the loop body never ran for the only line that mattered. Lines that are not data:, event: or blank are collected as the error body, and after the loop the status code comes out of the headers file curl wrote with -D.
The regex is doing the job a JSON parser would. It works because the API emits compact JSON with the keys in a known order, and that is the honest caveat: a bash client is parsing a wire format with pattern matching, and the first time the server reorders two keys it breaks. Every other client in the series uses a real parser. This one is the floor, and it is meant to be.
When the first stream ends with tool_use, the client runs the tool with TZ="$timezone" date, builds the assistant turn back out of what it collected (any text, then the tool_use block with its assembled input) and sends the three-message conversation as a second request. The reply to that is the answer.
The harness runs every client in its own container against a mock that replays the recorded exchange with the original gaps between chunks. Nothing is measured over the network. The one real call per client is for conformance, and no timing from it is published.
Apple M4 Max under Docker, linux/arm64, bash 5.2.37, curl 8.14.1 with OpenSSL 3.5.7.
Cold start is process spawn to the mock receiving the first request. Overhead is spawn to exit minus the time the mock spent replaying, which is what the client itself cost. The runtime figure counts the shared libraries because a static binary pays for its TLS and HTTP inside itself, and a dynamically linked curl should pay for libcurl and OpenSSL the same way. Without them bash would show 1.7 MB and win on a technicality.
Fifty-seven milliseconds of overhead for two requests is two curl process spawns plus a few hundred regex matches. The Go and C# clients that follow do the same work in under half of that, and the interesting question for the rest of the series is whether anything gets much below what Go does, or much smaller than what a Native AOT.NET binary does. I have an opinion about both and the leaderboard will tell me whether it was right.
git clone https://github.com/AndyCross/smallest-client cd smallest-client./bench/bench.sh bash Enter fullscreen mode Exit fullscreen mode You need Docker and Python 3 and you do not need a key. The fixtures in the repo are real recordings. If you want to re-record them,./bench/bench.sh record bash runs one exchange through a proxy that reads your key from the shell and never writes it anywhere, and the client under test is handed a placeholder.
Adding a language is a directory with the source file, a Dockerfile and a four-line meta.env, and the CI runs the bench on every pull request. If you want to see your language on the leaderboard before I get to it, that is the way.