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:

bash
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:

bash
#!/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_request reads 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 for head -n -1, which doesn't work with BSD head on macOS. For any code other than 200 the function writes the status and error.message to 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/stdin flag 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:

bash
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:

  • -R reads input as raw lines, select(startswith("data: ")) | .[6:] keeps the useful part, select(. != "[DONE]") drops the end marker, fromjson parses the JSON;
  • .choices[0]?.delta.content // empty guards against the last chunk: Zukko sends usage and an empty choices array in it, and without ? and // empty the output would contain null;
  • -j prints fragments without line breaks between them, and --unbuffered emits 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

CodeCauseWhat to do
401invalid keycheck ZUKKO_API_KEY
402insufficient balancetop up in the dashboard
403model not allowed for the keychange the key's allowed_models or the model
413request too largeshorten the input
429too many requestsreduce parallelism, retry later
503provider unavailableretry 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:

bash
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:

bash
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:

bash
prompt=$(cat <<'EOF'
You review shell scripts.
Find bugs in the script below and suggest fixes.
EOF
)
ask_pipe "$prompt" < deploy.sh

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

Get API keyDocumentation

api.zukko.pro · fetch_*

Bash, curl and jq: calling the Claude AI model from scripts · Zukko