Bash, curl and jq: calling the Claude AI model straight from scripts
A ready three-function library for calling Claude from the shell: JSON built with jq, reading from a pipe, streaming without extra tools, HTTP error handling and batch file processing.
5 min · · development · автоматизация
Bash, curl and jq are the shortest way to plug an AI model into existing automation: analysing logs in cron, describing a diff before a commit, batch-processing text files. Zukko accepts OpenAI-format requests at https://api.zukko.pro/v1, so curl is all you need. Below, from a one-liner to a small function library; every example was tested in bash 3.2 (the macOS default) with jq 1.6.
A one-liner
Create a fetch_... key in the Zukko dashboard and set it as an environment variable:
export ZUKKO_API_KEY=fetch_...
curl -sS https://api.zukko.pro/v1/chat/completions \
-H "Authorization: Bearer $ZUKKO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "claude-sonnet-5", "max_tokens": 300,
"messages": [{"role": "user", "content": "Explain what set -euo pipefail does"}]}' \
| jq -r '.choices[0].message.content'-sS hides the progress meter but keeps curl's error messages. jq -r prints the string without quotes and with real line breaks; without -r you get a JSON string with \n.
Why JSON should be built with jq
Don't substitute a variable straight into -d "{...$prompt...}": the first quote, backslash or line break in the text breaks the JSON, and text from an external source can change the structure of the request. The right way is to pass values to jq -n with --arg (string), --argjson (number, JSON) or --rawfile (a whole file's contents), and jq escapes everything correctly.
The second point is size. Large text in a command-line argument hits the system limit on argument length. So the request body goes to curl via stdin: --data-binary @-.
A function library
Save it as zukko.sh and load it with source zukko.sh:
#!/usr/bin/env bash
set -o pipefail
: "${ZUKKO_API_KEY:?Set the ZUKKO_API_KEY variable}"
ZUKKO_URL="https://api.zukko.pro/v1/chat/completions"
ZUKKO_MODEL="${ZUKKO_MODEL:-claude-sonnet-5}"
zukko_request() {
local tmp code
tmp=$(mktemp)
if ! code=$(curl -sS -o "$tmp" -w '%{http_code}' "$ZUKKO_URL" \
-H "Authorization: Bearer $ZUKKO_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @-); then
rm -f "$tmp"What each function does:
zukko_requestreads the request JSON from stdin, sends it and prints the response. The status code comes from-w '%{http_code}', and the body goes to a temporary file, so there's no need forhead -n -1, which doesn't work with BSDheadon macOS. For any code other than 200 the function writes the status anderror.messageto stderr and returns 1.ask "question" [max_tokens]is a short question from an argument.ask_pipe "task"is a task from an argument plus data from stdin. The--rawfile data /dev/stdinflag reads the whole input, line breaks included.ask_stream "question"prints the answer as it is generated.
set -o pipefail is needed so an error in zukko_request in the middle of a pipeline isn't lost: without it the exit code of the whole chain is that of the last jq, which is almost always 0.
Examples:
source ./zukko.sh
ask "Suggest 5 names for a backup CLI tool"
git diff --staged | ask_pipe "Write a commit message in Conventional Commits format"
tail -n 300 /var/log/nginx/error.log | ask_pipe "Group the errors and suggest causes"
ZUKKO_MODEL=glm-5.3-flash ask "Translate into French: good morning"Streaming without extra tools
With "stream": true the server answers with data: {...} lines in SSE format and ends the stream with data: [DONE]. A common mistake is to pipe that stream straight into jq: the lines start with data: , and jq fails to parse them. ask_stream solves this with a single jq call:
-Rreads input as raw lines,select(startswith("data: ")) | .[6:]keeps the useful part,select(. != "[DONE]")drops the end marker,fromjsonparses the JSON;.choices[0]?.delta.content // emptyguards against the last chunk: Zukko sendsusageand an emptychoicesarray in it, and without?and// emptythe output would containnull;-jprints fragments without line breaks between them, and--unbufferedemits them immediately.
curl -N disables buffering on curl's side, and --fail makes the function return a non-zero code on an HTTP error. The error body isn't printed in this mode; if streaming stays silent, repeat the same request with ask to see the server's message.
Error codes
| Code | Cause | What to do |
|---|---|---|
| 401 | invalid key | check ZUKKO_API_KEY |
| 402 | insufficient balance | top up in the dashboard |
| 403 | model not allowed for the key | change the key's allowed_models or the model |
| 413 | request too large | shorten the input |
| 429 | too many requests | reduce parallelism, retry later |
| 503 | provider unavailable | retry later or pick another model from /v1/models |
For 429 and 503 in cron jobs it's convenient to retry after a pause, for example ask "..." || { sleep 10; ask "..."; }. For 401–403 a retry is pointless.
Batch processing files
xargs -P runs several requests in parallel. The functions must be exported, otherwise the child bash -c won't see them:
set -euo pipefail
source ./zukko.sh
export -f zukko_request ask_pipe
export ZUKKO_URL ZUKKO_MODEL ZUKKO_API_KEY
mkdir -p out
find docs -name '*.md' -print0 \
| xargs -0 -n 1 -P 4 bash -c 'set -o pipefail; ask_pipe "Summarize in 5 bullet points:" < "$1" > "out/${1##*/}"' _The file name is passed to bash -c as the positional argument $1 rather than substituted into the command string, so spaces and quotes in names break nothing. Each answer goes to its own file, so the output of parallel requests doesn't get mixed. -P 4 keeps at most four requests in flight; if you hit 429, lower the number.
What it costs
Zukko prices per 1M tokens (input/output): claude-sonnet-5 $0.30 / $0.30, gpt-5.6-luna $0.10 / $0.10, glm-5.3-flash $0.04 / $0.04. The current model list:
curl -sS https://api.zukko.pro/v1/models -H "Authorization: Bearer $ZUKKO_API_KEY" | jq -r '.data[].id'or the "Models" page in the dashboard. Example: summarizing 200 files at 3,000 input and 300 output tokens each. On claude-sonnet-5: 200 × (3,000 + 300) × $0.30 / 1,000,000 = $0.198. On glm-5.3-flash: 200 × 3,300 × $0.04 / 1,000,000 = $0.0264. Check whether the cheap model is good enough for your task on your own data: run 10 files through both models and compare the results.
FAQ
How do I pass a multi-line prompt?
With a here-document inside command substitution:
prompt=$(cat <<'EOF'
You review shell scripts.
Find bugs in the script below and suggest fixes.
EOF
)
ask_pipe "$prompt" < deploy.shThe quotes around 'EOF' disable variable expansion inside the text. read -d '' is worse for this: it returns a non-zero code and kills a script running with set -e.
Why does the answer show \n and quotes?
jq -r is missing. Without it jq prints the value as a JSON string.
How do I save the result to a variable?
summary=$(ask_pipe "Briefly:" < report.txt). If the request fails, the function returns 1, and with set -e the script stops, which beats silently storing an empty string.
Can I call Claude in the Anthropic format with curl?
Yes: POST https://api.zukko.pro/v1/messages with the anthropic-version: 2023-06-01 header; the answer is in .content[0].text. The request format is described in the Messages API reference, and the OpenAI format in the chat completions reference.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Translating subtitles with a neural network: SRT without broken timecodesThe main risk when translating SRT with a neural network is a broken file structure. A scheme where timecodes never reach the model, and a Python script with validation.
- Editing texts with a neural network: proofreading, style and cuttingDifferent kinds of editing need different prompts. Wording for proofreading, style and cutting, how to get edits with explanations and how not to lose the author's voice.
- A neural network for writing scripts: Bash, PowerShell and PythonA model writes scripts for routine fast, but running them blindly is risky. How to state the task, demand a safe dry-run mode and check the result before launch.
- Dify in Docker: installation, first-run setup and connecting modelsInstall Dify with Docker Compose, create the admin account and connect Claude, GPT and DeepSeek models on one key. Plus what to do when containers will not start.