How to run a command-line tool from an automation platform as a stateless JSONL batch — one job per line in, one result per line out, no daemon and no open port.
Last tested: May 2026. See Changelog at the bottom.
If this saves you setup time, follow @renezander030 — production notes on wiring CLIs and LLM agents into real automation pipelines.
Reference implementation (zero-dep Node CLI with a
servesubcommand): github.com/renezander030/capcut-cli
| You want to… | Do this |
|---|---|
| Run a CLI from n8n | Execute Command node, pipe JSONL to the tool's stdin |
| Run many jobs in one shot | one JSON object per line (JSONL), tool drains the stream and exits |
| Avoid running a service | no HTTP server — the process starts, drains the queue, exits |
| Trigger from a cloud tool (Make/Coze) | cloud writes a queue.jsonl file (or POSTs it), a host-side cron drains it |
| Get machine-parseable results | one {ok, status, stdout, stderr} JSON object per line on stdout |
| Stop on first failure | a --fail-fast flag your runner checks |
This is NOT n8n queue mode. n8n's queue mode is Redis-backed worker scaling for n8n itself. This pattern is the opposite end: a single external tool that accepts a batch over stdin and exits. No Redis, no workers, no broker.
- If the tool runs and finishes (a render, an edit, a lint, a fetch), it does not need to be a server.
- A long-lived HTTP server adds a port to secure, state to reset, and a process to babysit — for work that is fundamentally batch.
- Make the JSONL file the boundary. Whatever produces the jobs (n8n, a cloud scenario, cron) never needs to know the tool's internals.
Give the CLI a serve subcommand that reads JSONL from stdin (or a --queue file), dispatches each line to its own normal commands, and writes one result line per job:
cat jobs.jsonl | mytool serve > results.jsonl
# or from a file an upstream step wrote:
mytool serve --queue jobs.jsonl > results.jsonlOne JSON object per line. A cmd field plus whatever arguments that command takes:
{"cmd":"info","project":"/work/draft.json"}
{"cmd":"add-text","project":"/work/draft.json","args":["8s","2s","Subscribe"]}
{"cmd":"lint","project":"/work/draft.json"}One JSON object per line, in the same order:
{"ok":true,"cmd":"info","status":0,"stdout":{"tracks":3,"duration_us":10000000}}
{"ok":true,"cmd":"add-text","status":0,"stdout":{"ok":true}}
{"ok":false,"cmd":"lint","status":2,"stdout":null,"stderr":"2 caption overlaps"}ok is status === 0. Non-zero exit codes flow straight through, so a downstream step can gate on them.
This is the whole pattern in Node — no framework, no dependencies. It shells each job out to the same CLI binary, so serve and the interactive commands never drift:
#!/usr/bin/env node
// serve.js — read JSONL jobs from stdin, run each, emit JSONL results.
import { spawnSync } from "node:child_process";
import { createInterface } from "node:readline";
const CLI = process.argv[2] || "mytool"; // the binary to dispatch to
const failFast = process.argv.includes("--fail-fast");
const rl = createInterface({ input: process.stdin });
for await (const line of rl) {
if (!line.trim()) continue; // skip blank lines
let job;
try {
job = JSON.parse(line);
} catch (e) {
process.stdout.write(JSON.stringify({ ok: false, status: null, stderr: `bad JSON: ${e.message}` }) + "\n");
if (failFast) process.exit(1);
continue;
}
const args = [job.cmd, ...(job.project ? [job.project] : []), ...(job.args || []).map(String)];
const r = spawnSync(CLI, args, { encoding: "utf-8", timeout: 60_000 });
let stdout = null;
try { stdout = r.stdout?.trim() ? JSON.parse(r.stdout) : null; } catch { stdout = r.stdout; }
const ok = r.status === 0;
process.stdout.write(JSON.stringify({ ok, cmd: job.cmd, status: r.status, stdout, stderr: r.stderr || undefined }) + "\n");
if (failFast && !ok) process.exit(1);
}Run it:
cat jobs.jsonl | node serve.js mytool > results.jsonlThe same idea ships as a real subcommand in capcut-cli's serve — it adds --queue <file> and a timeout per job, but the shape is identical.
n8n's Execute Command node runs on the n8n host, so the tool just needs to be on its PATH. Build the JSONL in a Function node, pipe it in, parse the results back out.
Function node — turn n8n items into a JSONL string:
// one n8n item per job → a single JSONL string for stdin
return [{ json: { jobs: items.map(i => JSON.stringify(i.json)).join("\n") } }];Execute Command node:
Command: printf '%s' "{{ $json.jobs }}" | mytool serve
Function node — parse results back into items:
// split stdout on newlines, JSON.parse each result line
return $input.first().json.stdout
.trim().split("\n")
.map(line => ({ json: JSON.parse(line) }));That is the entire integration: no custom node, no webhook, no n8n queue mode.
A cloud scenario can't exec a binary, but the stateless model still fits: have the cloud side write the JSONL (to a file on a host you control, or POST it to a tiny endpoint), then drain it host-side from cron.
# host crontab: drain whatever the cloud scenario dropped, once a minute
* * * * * test -s /srv/queue/inbox.jsonl && \
mytool serve --queue /srv/queue/inbox.jsonl > /srv/queue/outbox.jsonl && \
: > /srv/queue/inbox.jsonlThe cloud platform owns "what to do"; your host owns "run it." The JSONL file is the contract between them, and it survives retries because draining is idempotent per line.
If the tool ships an image, pipe JSONL straight into the container:
cat jobs.jsonl | docker run --rm -i -v "$PWD:/work" mytool serve > results.jsonl-i keeps stdin open; -v mounts the working files. The container starts, drains, exits — nothing left running.
| Stateless JSONL runner | Long-lived HTTP server | |
|---|---|---|
| Port to secure | none | yes |
| State between calls | none (each line is independent) | yes (must reset / leak risk) |
| Process to babysit | none (starts, drains, exits) | yes (supervisor, health checks) |
| Retry model | re-send the line; idempotent | depends on server semantics |
Fits cron / CI / exec |
natively | needs a client |
| Streaming progress | no | yes |
The one thing you give up is live progress events. If the work is batch (edits, lints, renders, fetches), you never needed them. If you genuinely do, put serve behind a one-line handler that pipes the request body to it — you still keep the stateless core.
- Building a Flask/Express server for batch work. You inherit a port, auth surface, and a process to keep alive for something that should start and exit.
- Reaching for n8n queue mode to "run my CLI in parallel." Queue mode scales n8n's own executions via Redis workers; it does nothing for an external binary. Different layer.
/dev/stdinto read piped input. On Linux, opening the/dev/stdindevice node fails withENXIO: no such device or addresswhen the process was spawned with a piped stdin (e.g. fromchild_process.spawn). Read file descriptor 0 instead:readFileSync(0, "utf-8").- One process per job. Spawning the tool once per line pays startup cost N times. Stream the whole JSONL into one
serveinvocation.
printf '%s\n' \
'{"cmd":"info","project":"/work/draft.json"}' \
'{"cmd":"lint","project":"/work/draft.json"}' \
| mytool servePass criteria — one result line per input line, in order, each valid JSON:
{"ok":true,"cmd":"info","status":0,"stdout":{...}}
{"ok":false,"cmd":"lint","status":2,"stdout":null,"stderr":"..."}
If you get fewer result lines than input lines, a job is crashing the runner instead of being caught — wrap the dispatch in try/catch (the runner above does).
- No output at all? Confirm the tool is on
PATHin the execution context (n8n host, container, cron's minimal env — cron does not load your shell profile). Use an absolute path if unsure. ENXIO/ stdin errors? You're reading/dev/stdin; switch to fd0.- Results truncated / merged? You're not splitting on newlines, or the tool is pretty-printing JSON (multi-line). Force compact JSON output.
- Job N kills the batch? An uncaught throw in dispatch. Catch per-line and emit an error result instead of exiting.
- Cloud queue never drains? Cron
PATHis minimal; the file is empty (test -s); or two cron ticks overlap — add a lockfile (flock).
This is Production AI Automation Notes #8. Related entries:
- #1: Agent Approval Gates — the same "agent emits JSON, deterministic layer dispatches" boundary, with a human in the loop.
- #4: Driving CapCut / JianYing from an LLM agent — the agent-facing side of this CLI;
serveis how you batch those commands. - #6: Prompt-injection defense — why a deterministic boundary (not the model) should decide what actually runs.
Reference implementations: capcut-cli (Node, serve subcommand) and draftcat (Go pipeline engine). Both MIT.
Follow @renezander030 for the next entry.
Wiring this into your own stack? Drop a comment with: the automation platform (n8n / Make / Coze / Temporal / cron), how you produce the JSONL, the tool you're dispatching to, and anything that bit you (PATH, stdin, cron env). I fold the good ones back in.
- Initial publish. Covers the runner, n8n Execute Command, cloud-via-queue-file, Docker, the HTTP-server comparison, avoid-list, smoke test, and debug flow.
- Deliberately skipped the hardware×model matrix gate (not hardware-bound) and a new companion repo (the reference implementations — capcut-cli and draftcat — already carry the runnable code).