n4nAI

Piping curl and jq to extract chat completion text in bash

Learn how to use bash curl jq extract chat completion text from OpenAI-compatible LLM APIs with a reproducible shell pipeline and verification steps.

n4n Team3 min read719 words

Audio narration

Coming soon — every post will get a voice note here.

Shell automation around LLMs often collapses into fragile one-liners that break on the first quoted newline. This guide shows how to build a robust bash curl jq extract chat completion text pipeline that talks to any OpenAI-compatible endpoint, so you can drop model responses into logs, tests, or downstream scripts without writing a full client.

Step 1: Export credentials and choose an endpoint

Never hardcode API keys in scripts. Export them into the environment and reference them from curl.

export LLM_API_KEY="sk-your-key-here"
export LLM_BASE="https://api.openai.com/v1"

If you would rather hit one OpenAI-compatible endpoint that addresses 240+ models with automatic fallback when a provider is degraded, point LLM_BASE at a gateway like https://api.n4n.ai/v1 instead. The request shape stays identical.

Verify the variables are set:

[[ -n "$LLM_API_KEY" && -n "$LLM_BASE" ]] || { echo "missing env"; exit 1; }

Step 2: Construct the request JSON safely

Hand-rolling JSON with -d '{"messages":...}' breaks the moment your prompt contains a double quote or a newline. Build the payload with jq -n so the shell never touches JSON escaping.

prompt="Say hello in one word."
payload=$(jq -n \
  --arg model "gpt-4o-mini" \
  --arg content "$prompt" \
  '{model: $model, messages: [{role: "user", content: $content}]}')

jq -n reads no input and emits the object. --arg injects shell strings as JSON strings. This eliminates a whole class of injection bugs.

Send it with curl. Use -sS to silence progress but keep errors, and -f to fail on HTTP errors.

resp=$(curl -sS -f "$LLM_BASE/chat/completions" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$payload")

If the endpoint returns a non-2xx, -f makes curl exit non-zero and resp stays empty. Wrap this in set -euo pipefail for strict failure.

Step 3: Pipe through jq to extract the chat completion text

A successful response looks like this:

{
  "id": "chatcmpl-123",
  "choices": [
    { "index": 0, "message": { "role": "assistant", "content": "Hello" }, "finish_reason": "stop" }
  ],
  "usage": { "prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11 }
}

The text lives at .choices[0].message.content. Use -r for raw output (no surrounding quotes):

text=$(printf '%s' "$resp" | jq -r '.choices[0].message.content')
echo "$text"

That is the core of bash curl jq extract chat completion text. The printf '%s' avoids appending a trailing newline to the JSON before jq parses it.

Step 4: Wrap it in a reusable function

A one-shot variable assignment is fine for a REPL, but automation needs a function. Put this in a script or .bashrc:

chat() {
  local model="${1:-gpt-4o-mini}"
  local prompt="$2"
  local payload
  payload=$(jq -n --arg m "$model" --arg c "$prompt" \
    '{model: $m, messages: [{role: "user", content: $c}]}')
  curl -sS -f "$LLM_BASE/chat/completions" \
    -H "Authorization: Bearer $LLM_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$payload" | jq -r '.choices[0].message.content'
}

Call it as chat gpt-4o-mini "Explain TCP in one line.". The function returns the extracted text on stdout and propagates curl failures via -f and the pipefail flag.

Step 5: Handle multiple choices and empty content

Some requests set n: 2 to get alternatives. Others may return content: null if the model called a tool instead of replying. Naive .choices[0] will mis-handle these.

Extract all non-null texts:

printf '%s' "$resp" | jq -r '.choices[] | .message.content // empty'

If you need to detect tool calls, inspect .choices[0].message.tool_calls instead. For a single deterministic answer, force n: 1 in the payload and check finish reason:

jq -r 'if .choices[0].finish_reason == "stop"
       then .choices[0].message.content
       else error("unexpected finish: \(.choices[0].finish_reason)") end' <<<"$resp"

jq will exit 5 on error(), which set -e catches.

Step 6: Verify the pipeline end to end

Automation is worthless if you can’t prove it worked. Write a small test that checks both shape and content.

set -euo pipefail
out=$(chat gpt-4o-mini "Reply with the single word: pong")
if [[ "$out" == "pong" ]]; then
  echo "PASS: extracted text matches expected"
else
  echo "FAIL: got '$out'" >&2
  exit 1
fi

Run it. A pass means your bash curl jq extract chat completion text flow is wired correctly: request built, auth sent, response parsed. For CI, add a fake endpoint with LLM_BASE pointing at a local stub that returns canned JSON.

To confirm jq is actually parsing (not just echoing), corrupt the response and expect failure:

echo '{"choices":[]}' | jq -r '.choices[0].message.content'
# jq: error: Cannot index array with string "message" -> actually null index; returns null

Guard with a check that output is non-empty before use.

Step 7: Capture usage and route intentionally

Production scripts usually need token accounting. Extract usage without disturbing the text path:

usage=$(printf '%s' "$resp" | jq '.usage')
echo "tokens: $(jq -r '.total_tokens' <<<"$usage")"

If you use a gateway that provides per-token usage metering, this field arrives automatically. You can also forward provider cache-control hints by adding "extra_body": {"cache_control": {...}} to the payload—but only if your endpoint honors client routing directives. Keep such extensions out of the minimal path unless required.

For deterministic routing across many models, set the model field to the exact provider slug your gateway expects. With a single OpenAI-compatible endpoint, you avoid maintaining per-provider base URLs in bash.

Step 8: Streaming and why we avoid it here

Chat completions support stream: true, which returns newline-delimited JSON chunks. Parsing that in bash requires jq -c in a while-read loop:

curl ... -d '{"stream":true,...}' | while IFS= read -r line; do
  [[ "$line" == data:* ]] && jq -r '.choices[0].delta.content // empty' <<<"${line#data: }"
done

That works, but it complicates extraction and error handling. For batch jobs, cron, and tests, non-streaming with a single jq call is simpler and easier to verify. Reach for streaming only when latency matters more than script simplicity.

Common pitfalls

  • Missing -r: jq emits JSON strings with quotes. Downstream grep or file writes will include them.
  • Unquoted $resp: always printf '%s' or <<<"$resp"; unquoted expansion splits on whitespace and breaks JSON.
  • Ignoring HTTP status: without -f, a 429 returns a JSON error body that jq may parse as success if you only look at .choices.
  • Shell history leakage: export LLM_API_KEY writes to environment, not ~/.bash_history, but be careful with set -x in shared logs.

The bash curl jq extract chat completion text pattern is boring on purpose. Boring means it runs at 3 a.m. in a cron job and still works. Build the payload with jq, send with curl -sf, parse with jq -r, and verify with a strict test. Everything else is an optimization.

Tagsbashcurljqcookbook

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All bash/shell scripting llm automation posts →