Skip to content

Instantly share code, notes, and snippets.

@renezander030
Last active June 12, 2026 15:39
Show Gist options
  • Select an option

  • Save renezander030/807559488f523892fc25870bf9501d29 to your computer and use it in GitHub Desktop.

Select an option

Save renezander030/807559488f523892fc25870bf9501d29 to your computer and use it in GitHub Desktop.
Production AI Automation Notes #8: Stateless JSONL queue runner — wiring a CLI into n8n, Make, and Coze without an HTTP server

Stateless JSONL queue runner: wiring a CLI into n8n, Make, and Coze without an HTTP server

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 serve subcommand): github.com/renezander030/capcut-cli

TL;DR cheat sheet

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.

The rule of thumb

  • 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.

Recommended setup

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.jsonl

The job format

One 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"}

The result format

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.

1. The runner (drop-in, ~40 lines, zero deps)

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.jsonl

The 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.

2. n8n — the Execute Command node

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.

3. Make and Coze — cloud platforms that can't run a binary

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.jsonl

The 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.

4. Docker — no global install

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.

Why stateless JSONL instead of an HTTP server?

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.

Setups I'd avoid

  • 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/stdin to read piped input. On Linux, opening the /dev/stdin device node fails with ENXIO: no such device or address when the process was spawned with a piped stdin (e.g. from child_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 serve invocation.

Smoke test

printf '%s\n' \
  '{"cmd":"info","project":"/work/draft.json"}' \
  '{"cmd":"lint","project":"/work/draft.json"}' \
  | mytool serve

Pass 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).

Debug flow

  1. No output at all? Confirm the tool is on PATH in the execution context (n8n host, container, cron's minimal env — cron does not load your shell profile). Use an absolute path if unsure.
  2. ENXIO / stdin errors? You're reading /dev/stdin; switch to fd 0.
  3. Results truncated / merged? You're not splitting on newlines, or the tool is pretty-printing JSON (multi-line). Force compact JSON output.
  4. Job N kills the batch? An uncaught throw in dispatch. Catch per-line and emit an error result instead of exiting.
  5. Cloud queue never drains? Cron PATH is minimal; the file is empty (test -s); or two cron ticks overlap — add a lockfile (flock).

Series

This is Production AI Automation Notes #8. Related entries:

Reference implementations: capcut-cli (Node, serve subcommand) and draftcat (Go pipeline engine). Both MIT.

Follow @renezander030 for the next entry.

Sources

Reader contributions

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.

Changelog

2026-05-29

  • 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment