stepinto.devThe fastest way from your code to Claude is one POST and one stream, and this is it in bash and curl, no jq. It launches a series: one language a week, one file each, measured for what it adds on top of the API.
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, and the leaderboard lives at /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:
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 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.