The Hot Path to Claude Is a Shell Script Developer Andy Cross built a 93-line bash and curl client for Anthropic's Claude Messages API that streams responses, completes a tool-call round trip, and surfaces API errors, with no jq dependency. The client is the baseline entry in a multi-language series that measures per-language overhead against the official SDKs, and it must pass seven harness-checked requirements including first-byte streaming and assembling fragmented input_json_delta tool arguments. Cross notes the model can jump straight to a tool call with no preamble text and that a ping event appears mid-stream, so clients must ignore unknown event types. The hot path to Claude is one HTTPS POST and a stream of six event types. Every SDK wraps that in thousands of lines, and the wrapping costs you: in cold start, in memory, and in the milliseconds between the model finishing a thought and your code acting on it. For a chat window none of that matters. For a CLI that should feel instant, a Lambda that is billed from the first millisecond, or an agent loop making ten thousand calls a day, it is the only part of the latency you control. This series writes the path by hand, one language a week, one file each, and measures what each one adds. This post is the floor: a complete client in bash and curl, in one file, with no jq, that streams the reply as it arrives, completes a tool call round trip, and reports API errors properly. Everything that follows is measured against it, and against the official SDKs. The code and the harness are at github.com/AndyCross/claude-hot-path https://github.com/AndyCross/claude-hot-path , and the leaderboard lives at /claude-hot-path/ https://stepinto.dev/claude-hot-path/ . Every client in the series has to pass the same seven points, and a harness checks them rather than me reading the code. ANTHROPIC API KEY and the base URL from ANTHROPIC BASE URL . Nothing else from the environment. 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 . text delta as it arrives. The harness checks that the first visible byte reached stdout before the mock finished sending the stream, so buffering the reply, or flushing only on newlines, fails. 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. error.message to stderr and exit non-zero. 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"} 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" } 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" } 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: php 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" 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. | | 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.0 ms | | Turnaround, median of 100 | 19.1 ms | | Client total, median of 100 | 35.3 ms | | Peak RSS | 11 MB | | Lines of code | 75 | 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. Turnaround is the first reply finishing to the second request arriving, which is the client parsing the stream, running the tool and sending again. Client total adds the tail after the last reply, so it is every millisecond the mock was not streaming. All three are differences between timestamps the mock recorded and the runner's spawn and exit, with nothing subtracted. 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. Thirty-five milliseconds of client time for two requests is two curl process spawns, a date process, and a few hundred regex matches in a shell loop. Nineteen of those milliseconds are the turnaround alone. The Go and C clients that follow do the same work in under a quarter 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. Every row on the leaderboard is measured against the official SDK as well as against the other hand-written clients. The SDK rows sit below a line on the board and are not ranked, because they are the reference rather than entries. The first is the Python SDK, anthropic 1.15.0 on Python 3.14, written the way its documentation says: messages.stream , text stream , get final message , and the manual tool loop from its own tool-use page. Fifty-five lines, which is the shortest client on the board, and the slowest by a wide margin. Next to it, so the cost of Python and the cost of the SDK can be told apart, is a client in one Python file on http.client and json . | | bash and curl | Python, standard library | Python SDK | |---|---|---|---| | On the path, median of 100 | 35.3 ms | 140.4 ms | 592.5 ms | | Cold start | 7.0 ms | 123.8 ms | 464.6 ms | | Turnaround | 19.1 ms | 1.9 ms | 8.7 ms | | Tail after the last reply | 9.2 ms | 15.2 ms | 119.8 ms | | Peak RSS | 11 MB | 21 MB | 84 MB | | Runtime shipped | 23.7 MB | 35.5 MB | 71.9 MB | Python itself costs 140 ms per process, almost all of it the interpreter starting and importing four standard modules. The SDK costs 450 ms on top: pydantic, httpx, anyio and the generated types on the way in, and the HTTP client's pool and the interpreter tearing all of that down on the way out, after the answer has already been printed. In between, while the stream is actually flowing, the SDK is quick. If your process lives for an hour, none of this is a number you would notice. If it lives for one call, it is half a second every time, and that is what this series is about. The Python post https://stepinto.dev/posts/claude-hot-path-python/ has both clients in full. git clone https://github.com/AndyCross/claude-hot-path cd claude-hot-path ./bench/bench.sh bash 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.