DEV Community

stepinto.dev
stepinto.dev

Posted on Originally published at stepinto.dev

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 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/.

The spec

Every client in the series has to pass the same seven points, and a harness checks them rather than me reading the code.

  1. Read the key from ANTHROPIC_API_KEY and the base URL from ANTHROPIC_BASE_URL. Nothing else from the environment.
  2. Read the prompt from stdin.
  3. POST /v1/messages with the x-api-key, anthropic-version: 2023-06-01 and content-type: application/json headers, and a body with model, max_tokens, stream: true, messages and tools.
  4. Print each text_delta as it arrives. Buffering the whole reply and printing it at the end fails.
  5. Declare one tool, local_time, which takes an IANA timezone. When the stream ends with stop_reason: "tool_use", assemble the input_json_delta fragments, run the tool, send the tool_result back in a second request with the assistant turn echoed, and stream that reply too.
  6. On a non-2xx response, print the body's error.message to stderr and exit non-zero.
  7. One source file. HTTP and TLS from the platform where it has them. Where it does not (C, Zig), one named library, which counts towards size.

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.

What comes down the wire

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 client

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[1]}"; 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[1]}")
  elif [[ $data =~ \"type\":\"tool_use\",\"id\":\"([^\"]+)\",\"name\":\"([^\"]+)\" ]]; then
    TOOL_ID=${BASH_REMATCH[1]}; TOOL_NAME=${BASH_REMATCH[2]}
  elif [[ $data =~ \"stop_reason\":\"([a-z_]+)\" ]]; then
    STOP_REASON=${BASH_REMATCH[1]}
  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.

What it measures as

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.

bash and curl
Artefact 3.7 KB (the script)
Runtime 23.7 MB (bash, curl, and every shared library they link)
Cold start, median of 100 7.1 ms
Overhead, median of 100 56.6 ms
Peak RSS 11 MB

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.

How to run it

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.

Top comments (0)