stdout vs stderr
The rule is simple: data on stdout, everything else on stderr. The reason it matters is composability.
Why it matters
When you run tabstack extract markdown https://example.com | wc -w, the shell connects the stdout of tabstack to the stdin of wc. If tabstack writes its progress messages (“fetching…”, “done in 1.2s”) to stdout, they land in wc’s input and corrupt the word count. The pipe breaks.
Every progress indicator, spinner, status message, citation list, or error that goes to stdout is a bug waiting to trigger the moment someone pipes your tool into another.
# This works because progress goes to stderr
tabstack research "tabs vs spaces" > report.md
# report.md contains only the markdown report
# progress messages were on stderr, invisible to the redirect
# This also works
tabstack extract markdown https://example.com
| jq -r .content
| pbcopy
# jq sees only clean JSON, clipboard gets only text The implementation
In practice this means: every console.log or process.stdout.write in the codebase that isn’t the final data payload is a mistake. The convention has to be intentional — the path of least resistance in Node/Bun is console.log, which goes to stdout.
// Progress — always stderr
process.stderr.write(`→ fetching ${url}...\n`);
// Data — stdout only
process.stdout.write(JSON.stringify(result) + '\n'); Color codes are also stderr-only. tabstack checks process.stderr.isTTY for color decisions, not process.stdout.isTTY. This means color can be on even when stdout is piped to jq, because the user is still watching stderr interactively.
Errors go to stderr too
Including the full error message and any diagnostic context. The only thing on stdout after an error is silence — exit code carries the signal.
tabstack extract markdown https://does-not-exist.example.com
# stderr: Error: fetch failed — ENOTFOUND does-not-exist.example.com
# stdout: (empty)
# exit code: 1 If you’re building a pipeline and want to capture errors separately:
tabstack extract markdown https://example.com 2>errors.log | process-data Why agents care about this more than humans
A human reads the terminal holistically — they see both streams interleaved. An agent reading your tool’s output over a subprocess pipe sees only what it asked for. If you put errors on stdout, the agent’s JSON parser throws. If you put progress on stdout, the agent gets garbage data.
The separation isn’t just good practice — for agent-driven CLIs it’s load-bearing. tabstack was designed with agent pipelines as a first-class use case, so this rule is enforced throughout: the test suite checks that piped output is always valid JSON, never contains progress text.