{"slug": "the-hot-path-to-claude-is-a-shell-script", "title": "The Hot Path to Claude Is a Shell Script", "summary": "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.", "body_md": "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.\n\nThis 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.\n\nThe 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/).\n\nEvery client in the series has to pass the same seven points, and a harness checks them rather than me reading the code.\n\n`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.\nPoint 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.\n\nWith `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:\n\n```\nevent: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_...\",\"model\":\"claude-opus-5-5\",...}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_...\",\"name\":\"local_time\",\"input\":{}}}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"timezone\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\": \\\"Europe/London\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":56}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n```\n\nTwo 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.\n\nThe 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.\n\nThe whole thing is 93 lines. The interesting parts follow, and the rest is in the repo.\n\nJSON 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:\n\n```\njson_str() {\n  local s=$1\n  s=${s//\\\\/\\\\\\\\}; s=${s//\\\"/\\\\\\\"}\n  s=${s//$'\\n'/\\\\n}; s=${s//$'\\r'/\\\\r}; s=${s//$'\\t'/\\\\t}\n  printf '\"%s\"' \"$s\"\n}\n```\n\nDecoding 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:\n\n```\njson_unstr() {\n  local s=$1\n  s=${s//\\\\\\\"/\\\"}; s=${s//\\\\\\//\\/}\n  printf '%b' \"$s\"\n}\n```\n\nThat `\\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.\n\nThe stream loop is curl with `-N` to turn off its output buffering, read line by line, and a regex per event type:\n\n``` php\nwhile IFS= read -r line || [[ -n $line ]]; do\n  [[ $line == data:* ]] || { [[ $line == event:* || -z $line ]] || errbody+=\"$line\"; continue; }\n  data=${line#data: }\n  if [[ $data =~ \\\"type\\\":\\\"text_delta\\\",\\\"text\\\":\\\"((\\\\.|[^\\\"\\\\])*)\\\" ]]; then\n    text=$(json_unstr \"${BASH_REMATCH[1]}\"; printf x); text=${text%x}\n    printf '%s' \"$text\"; ASSISTANT_TEXT+=\"$text\"\n  elif [[ $data =~ \\\"type\\\":\\\"input_json_delta\\\",\\\"partial_json\\\":\\\"((\\\\.|[^\\\"\\\\])*)\\\" ]]; then\n    TOOL_INPUT+=$(json_unstr \"${BASH_REMATCH[1]}\")\n  elif [[ $data =~ \\\"type\\\":\\\"tool_use\\\",\\\"id\\\":\\\"([^\\\"]+)\\\",\\\"name\\\":\\\"([^\\\"]+)\\\" ]]; then\n    TOOL_ID=${BASH_REMATCH[1]}; TOOL_NAME=${BASH_REMATCH[2]}\n  elif [[ $data =~ \\\"stop_reason\\\":\\\"([a-z_]+)\\\" ]]; then\n    STOP_REASON=${BASH_REMATCH[1]}\n  fi\ndone < <(curl -sS -N -D \"$hdr\" \"$BASE/v1/messages\" \\\n    -H \"x-api-key: $ANTHROPIC_API_KEY\" \\\n    -H \"anthropic-version: 2023-06-01\" \\\n    -H \"content-type: application/json\" \\\n    --data-binary \"$body\")\n```\n\nThe `|| [[ -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`.\n\nThe 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.\n\nWhen 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.\n\nThe 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.\n\n|  | bash and curl | \n|---|---|\n| Artefact | 3.7 KB (the script) | \n| Runtime | 23.7 MB (bash, curl, and every shared library they link) | \n| Cold start, median of 100 | 7.0 ms | \n| Turnaround, median of 100 | 19.1 ms | \n| Client total, median of 100 | 35.3 ms | \n| Peak RSS | 11 MB | \n| Lines of code | 75 | \n\nApple M4 Max under Docker, linux/arm64, bash 5.2.37, curl 8.14.1 with OpenSSL 3.5.7.\n\nCold 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.\n\nThirty-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.\n\nEvery 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`.\n\n|  | bash and curl | Python, standard library | Python SDK | \n|---|---|---|---|\n| On the path, median of 100 | 35.3 ms | 140.4 ms | 592.5 ms | \n| Cold start | 7.0 ms | 123.8 ms | 464.6 ms | \n| Turnaround | 19.1 ms | 1.9 ms | 8.7 ms | \n| Tail after the last reply | 9.2 ms | 15.2 ms | 119.8 ms | \n| Peak RSS | 11 MB | 21 MB | 84 MB | \n| Runtime shipped | 23.7 MB | 35.5 MB | 71.9 MB | \n\nPython 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.\n\n```\ngit clone https://github.com/AndyCross/claude-hot-path\ncd claude-hot-path\n./bench/bench.sh bash\n```\n\nYou 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.\n\nAdding 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.", "url": "https://wpnews.pro/news/the-hot-path-to-claude-is-a-shell-script", "canonical_source": "https://dev.to/stepinto/the-hot-path-to-claude-is-a-shell-script-29h3", "published_at": "2026-10-22 07:35:54+00:00", "updated_at": "2026-10-22 07:40:40.041774+00:00", "lang": "en", "topics": ["large-language-models", "ai-tools", "developer-tools", "ai-agents"], "entities": ["Andy Cross", "Anthropic", "Claude", "GitHub"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-hot-path-to-claude-is-a-shell-script", "markdown": "https://wpnews.pro/news/the-hot-path-to-claude-is-a-shell-script.md", "text": "https://wpnews.pro/news/the-hot-path-to-claude-is-a-shell-script.txt", "jsonld": "https://wpnews.pro/news/the-hot-path-to-claude-is-a-shell-script.jsonld"}}