n4nAI

Piping curl streaming output into jq for parsing

Step-by-step guide to piping curl streaming output into jq for parsing LLM API Server-Sent Events: strip data prefixes, filter deltas, and test locally.

n4n Team4 min read833 words

Audio narration

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

When you call a streaming LLM API from the command line, the raw bytes are a firehose of Server-Sent Events that are miserable to read. This guide shows how to build a robust curl jq parse streaming output pipeline that turns that firehose into clean, line-delimited JSON you can inspect, filter, or forward to another tool. We’ll work with the OpenAI streaming format because it’s the de facto standard for most gateways.

Step 1: Capture the raw stream with curl

The first mistake engineers make is letting curl buffer the response. Streaming endpoints send small chunks; if you omit -N, curl will wait for the connection to close before printing anything. Always use -N (or --no-buffer) and -sS to suppress the progress meter but keep errors.

curl -sN https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o-mini",
    "stream": true,
    "messages": [{"role": "user", "content": "Say hello in 5 words."}]
  }'

If you’re hitting an OpenAI-compatible gateway such as n4n.ai, which fronts 240+ models behind one endpoint, the request shape is identical—only the base URL and auth header change. The raw output looks like this:

data: {"id":"chatcmpl-123","choices":[{"delta":{"role":"assistant","content":""},"index":0}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"Hello"},"index":0}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" there"},"index":0}]}

data: [DONE]

Note the blank lines between events. That’s SSE framing, not JSON.

Step 2: Strip the SSE framing before jq sees it

jq expects one JSON value per line (or a stream of JSON values). The data: prefix and the occasional carriage return will break it. Use sed to remove the prefix and tr to drop \r. Also filter out the [DONE] sentinel.

curl -sN https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"Say hello in 5 words."}]}' \
  | tr -d '\r' \
  | sed 's/^data: //' \
  | grep -v '^\[DONE\]'

At this point your curl jq parse streaming output intermediate stage emits pure JSON lines:

{"id":"chatcmpl-123","choices":[{"delta":{"role":"assistant","content":""},"index":0}]}
{"id":"chatcmpl-123","choices":[{"delta":{"content":"Hello"},"index":0}]}
{"id":"chatcmpl-123","choices":[{"delta":{"content":" there"},"index":0}]}

Step 3: Pipe to jq for incremental parsing

Now invoke jq with -c (compact) and --unbuffered so it prints each result as soon as a line arrives. The delta objects have a content field only when there is text. Use select() to skip empty deltas and extract the string.

... | jq -c --unbuffered 'select(.choices[0].delta.content != null) | .choices[0].delta.content'

Output:

"Hello"
" there"

If you want the raw chunk JSON without extraction, just run jq -c '.'. The --unbuffered flag is critical: without it, jq may buffer multiple lines and defeat the streaming purpose. Your curl jq parse streaming output command should feel live—tokens appear as the model generates them.

Handling non-OpenAI streams

Some providers send NDJSON instead of SSE, or prefix with event:. Adjust the sed/grep accordingly. For NDJSON, skip Step 2 entirely and pipe directly to jq. For event: delta lines, strip those with sed '/^event:/d'.

Step 4: Reconstruct the full message or extract usage

Streaming is great for UX, but sometimes you need the assembled text or the final token count. Since each line is a separate JSON object, you can collect them with jq -s (slurp) at the end, but that waits for the stream to finish. Alternatively, use a small awk or paste to concatenate content strings.

To get the full reply text after the stream ends:

... | jq -c --unbuffered 'select(.choices[0].delta.content != null) | .choices[0].delta.content' \
  | jq -s -r 'join("")'

If the API includes a final usage chunk (OpenAI does not in the stream by default, but some gateways do), capture it:

... | jq -c --unbuffered 'select(.usage != null) | .usage'

For per-token metering—relevant when you run through a gateway that bills per token—you can sum choices[0].delta.content | length as a rough proxy, though proper counting requires a tokenizer.

Step 5: Verify the pipeline works without a network call

You don’t need a live API key to test the parsing logic. Fabricate an SSE stream with printf and run it through the exact same post-curl stages.

printf 'data: {"choices":[{"delta":{"content":"Hello"}}]}\r\n\r\ndata: {"choices":[{"delta":{"content":" world"}}]}\r\n\r\ndata: [DONE]\r\n' \
  | tr -d '\r' \
  | sed 's/^data: //' \
  | grep -v '^\[DONE\]' \
  | jq -c --unbuffered 'select(.choices[0].delta.content != null) | .choices[0].delta.content'

Expected output:

"Hello"
" world"

If you see that, the curl jq parse streaming output chain is correct; swap the printf for a real curl and you’re done. For a more thorough check, pipe the output to jq -e . to assert each line is valid JSON, or use jq empty which exits non-zero on parse error.

Step 6: Harden against errors and malformed chunks

Production streams drop. A provider may inject a non-JSON comment line or an error event. Wrap the jq filter in try to skip bad lines without killing the pipeline.

... | jq -c --unbuffered 'try (select(.choices[0].delta.content != null) | .choices[0].delta.content) catch empty'

Add curl -f (or --fail-with-body) so HTTP errors don’t produce a 200-style body that jq chokes on. If you’re behind a gateway with automatic fallback when a provider is degraded, you might still receive a stream from a secondary provider whose chunk shape differs slightly; the try guard keeps you resilient.

Also consider set -euo pipefail in scripts. A broken middle stage (e.g., sed receiving SIGPIPE) should propagate. With pipefail, a non-zero exit in curl surfaces instead of being masked by successful jq termination.

Step 7: Compose a reusable shell function

Don’t retype this every time. Put it in your .bashrc or a script.

stream_parse() {
  local url="$1"; local key="$2"; local body="$3"
  curl -sN -f "$url" \
    -H "Authorization: Bearer $key" \
    -H "Content-Type: application/json" \
    -d "$body" \
  | tr -d '\r' \
  | sed 's/^data: //' \
  | grep -v '^\[DONE\]' \
  | jq -c --unbuffered 'try (select(.choices[0].delta.content != null) | .choices[0].delta.content) catch empty'
}

Call it:

stream_parse https://api.openai.com/v1/chat/completions "$OPENAI_API_KEY" \
  '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"Ping"}]}'

The curl jq parse streaming output pattern is now a one-liner you can embed in log tailing, tests, or quick model comparisons.

Step 8: Inspect raw chunks for debugging

When something looks off—missing tokens, weird latency—drop the jq filter and just look at the cleaned JSON lines.

... | sed 's/^data: //' | grep -v '^\[DONE\]' | jq -c '.'

Watch for finish_reason in the last chunk, or error objects mid-stream. Because jq is streaming, you’ll see them as they arrive. This beats saving a 50 MB log file and parsing post-hoc.

Why this matters for LLM tooling

CLI streaming inspection is not a toy. It’s how you debug prompt caching hints, verify that cache_control is honored, or confirm that a routing directive sent you to the model you asked for. A clean curl jq parse streaming output workflow turns an opaque binary-ish stream into actionable signals. Once you have line-delimited JSON, you can pipe into grep, awk, or a custom Go consumer just as easily.

Keep the pipeline unbuffered end-to-end. Any stage that waits for EOF will hide the exact moment a provider stalls—defeating the entire point of streaming.

Tagscurljqstreamingcli

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 curl llm api cookbook posts →