cd /news/ai-tools/show-hn-sjq-jq-but-for-ai-streaming · home › topics › ai-tools › article
[ARTICLE · art-151048] src=github.com ↗ pub= topic=ai-tools verified=true sentiment=↑ positive

Show HN: Sjq – Jq, but for AI Streaming

A developer released sjq, a command-line tool that parses streaming LLM responses the way jq parses whole JSON documents, extracting only the tokens each Server-Sent Events chunk adds. The tool's author measured that envelope data accounts for 99.1% of a 3,590-byte short chat-completion stream (31 bytes of generated text), 98.6% of a 10,446-byte reasoning-plus-answer stream (142 bytes), and 98.8% of a 24,726-byte Responses API stream (300 bytes), and sjq also reports timing such as connect, ttfb, ttft, first_content, total and tokens-per-second. sjq is positioned as a drop-in replacement for jq, which fails on streaming responses with errors like 'Invalid numeric literal at line 1, column 5' for /v1/chat/completions chunks.

read5 min views3 publishedOct 16, 2026
Show HN: Sjq – Jq, but for AI Streaming
Image: Michielbdejong (auto-discovered)

jq, but for AI streaming.

Pipe an LLM stream in. Get the tokens out, one per line, and a stopwatch.

Why · jq parse error? · Install · Usage · Reading the output · Responses API · Timing

A streaming LLM response is mostly envelope. Every data: line repeats the id, the model, the object type and a row of null s, just to deliver a few characters of new text:

stream on the wire generated text envelope
chat completion, short answer 3,590 B 31 B 99.1%
chat completion, reasoning + answer 10,446 B 142 B 98.6%
Responses API, reasoning + answer 24,726 B 300 B 98.8%

jq wants whole documents, and grep can't tell a reasoning token from an answer token. sjq reads the stream as it arrives and keeps only what each event adds:

- data: {"id":"021792034294732770d3492782dd10cc0a489fec0a72ead7e3e0d","created":1792034296,"model":"viv-fast-2609","object":"chat.completion.chunk","system_fingerprint":null,"obfuscation":null,"choices":[{"delta":{"role":"assistant","reasoning_content":"The","refusal":null},"index":0,"logprobs":null}]}
+ meta: id=021792034294732770d3492782dd10cc0a489fec0a72ead7e3e0d created=1792034296 model=viv-fast-2609
+ data: role=assistant, reasoning_content='The'
- data: {"id":"021792034294732770d3492782dd10cc0a489fec0a72ead7e3e0d","created":1792034296,"model":"viv-fast-2609","object":"chat.completion.chunk","system_fingerprint":null,"obfuscation":null,"choices":[{"delta":{"reasoning_content":" user","refusal":null},"index":0,"logprobs":null}]}
+ data: reasoning_content=' user'
- data: {"id":"021792034294732770d3492782dd10cc0a489fec0a72ead7e3e0d","created":1792034296,"model":"viv-fast-2609","object":"chat.completion.chunk","system_fingerprint":null,"obfuscation":null,"choices":[{"delta":{"content":"Hola","refusal":null},"index":0,"logprobs":null}]}
+ data: 'Hola'
- data: {"id":"021792034294732770d3492782dd10cc0a489fec0a72ead7e3e0d","created":1792034296,"model":"viv-fast-2609","object":"chat.completion.chunk","system_fingerprint":null,"obfuscation":null,"choices":[{"delta":{"refusal":null},"finish_reason":"stop","index":0,"logprobs":null}],"usage":{"prompt_tokens":232,"completion_tokens":44,"total_tokens":276,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":24,"rejected_prediction_tokens":0}}}
+ data: finish_reason=stop, usage: {"prompt_tokens":232,"completion_tokens":44,"total_tokens":276,"completion_tokens_details":{"reasoning_tokens":24}}
  data: [DONE]
+ time: connect=0.3ms ttfb=308.7ms ttft=531.8ms first_content=1.119s total=1.416s speed=49.8tok/s

Searched for this error? You piped a streaming response into jq:

$ curl -i "$BASE_URL/v1/chat/completions" -d '{"stream":true, …}' | jq
jq: parse error: Invalid numeric literal at line 1, column 9

With "stream": true the response isn't one JSON document. It's Server-Sent Events: data: {…} lines (plus event: … lines on /v1/responses), separated by blank lines and ended by data: [DONE]. jq gives up at the first word that isn't JSON, and the column tells you which word it was:

the input starts with jq says
HTTP/1.1 200 OK , the status line fromcurl -i jq: parse error: Invalid numeric literal at line 1, column 9
data: {"id":… , a/v1/chat/completions chunk jq: parse error: Invalid numeric literal at line 1, column 5
event: response.created , a/v1/responses event jq: parse error: Invalid numeric literal at line 1, column 6

The fix is one word: put sjq where jq was. Headers from -i pass through untouched.

curl -iN "$BASE_URL/v1/chat/completions" … | sjq

Rather stay in jq? Read the lines raw, strip data: and skip whatever isn't JSON:

curl -sN "$BASE_URL/v1/chat/completions" … | jq -cR 'ltrimstr("data: ") | fromjson? // empty'

That gets you valid JSON again, but still a few hundred bytes of envelope per token.

brew tap vivgrid/tap && brew trust vivgrid/tap  # Homebrew 6+ asks you to trust third-party taps; once is enough
brew install sjq

Or build from source:

cargo install --git https://github.com/vivgrid/sjq

One binary, one dependency (serde_json). Needs a recent stable Rust (edition 2024).

run what you get
sjq curl … sjq runs curl for you, so the time: line also gets connect, TLS and TTFB
curl -sN … | sjq pipe in any stream ( -N stops curl from buffering it)
sjq capture.txt replay a saved stream, or several
sjq curl -s "$BASE_URL/v1/chat/completions" \
  -H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
  -d '{"model":"'"$MODEL"'","stream":true,"messages":[{"role":"user","content":"hi"}]}'

-t prefixes each line with the seconds since start, handy for spotting stalls. --color auto|always|never sets coloring, and NO_COLOR is honored.

  • Text is quoted and escaped , so leading spaces, empty deltas ('' ) and newlines are visible, and every chunk stays on one line.
  • Reasoning keeps the provider's field name :reasoning_content ,reasoning ,reasoning_details , whatever the server sends.
  • meta: appears once per stream , and again only whenid ,model or anything else in the envelope changes.
  • usage drops zero counters inside*_details .
  • Nothing unusual is hidden. Unrecognized non-null fields print askey=value , and non-data: lines (event: ,: ping , a plain-text error body) pass through. The only lines dropped are blank lines andevent: lines that repeat the next payload'stype .

/v1/responses streams typed events instead of chunks. sjq drops the response. prefix from event names and prints answer text the same way as chat content:

meta: created_at=1792038227 id=resp_0217… model=… reasoning={"effort":"medium"} service_tier=auto
data: created status=in_progress
data: output_item.added reasoning id=rs_0217… status=in_progress
data: reasoning_summary_text='Simple'
data: reasoning_summary_text.done
data: output_item.added message role=assistant status=in_progress id=msg_0217…
data: content_part.added output_text
data: 'Here'
data: output_text.done
data: output_item.done message status=completed
data: completed, usage: {"input_tokens":18,"output_tokens":87,"output_tokens_details":{"reasoning_tokens":3},"total_tokens":105}

The Responses API repeats itself a lot. Every .done event echoes the full text again, at every level (text, part, item, response), and every event carries a sequence_number. sjq checks instead of printing: an echo that matches the deltas is hidden, and so is a sequence_number that is one more than the last. You only see them when something is off:

item_id, output_index, content_index and summary_index work the same way: they only show when they change, for example when two function calls stream at once.

When the stream ends, sjq prints one more line:

field meaning
connect`` tls``ttfb from curl, only with sjq curl …
ttft first generated token: reasoning, content or tool call
first_content first answer token, shown when reasoning or a tool call came first
total last line received
speed completion_tokens (oroutput_tokens )/ (total − ttft)

A pipe only carries the response body. With curl … | sjq, sjq counts from its own start, which is when the shell started curl, but it can't see the TCP connect. sjq curl … adds -N -sS -w … to your curl arguments (replacing any -w of yours) to get connection timings, and exits with curl's status. Replayed files get no time: line.

data: [DONE]

── more in #ai-tools 4 stories · sorted by recency
── more on @sjq 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/show-hn-sjq-jq-but-f…] indexed:0 read:5min 2026-10-16 · —