One JSON Line on Stdout

This week I wired up weekly autopublish to four sites. Every Wednesday morning, a scheduled job reads a site config, drafts an article in that site's voice, generates a featured image, and pushes the result into the CMS. By the time I make coffee, four posts are live or queued.

The interesting engineering this week was not the drafting. It was the script the drafting agent calls at the very last step.

That script is publish-draft.ts. It takes a path to a draft markdown file, parses out the post meta block, uploads the featured image, inserts a row in Postgres, and returns the preview URL. Six steps, maybe two hundred lines of code, nothing fancy. The interesting part is how it talks back to whatever called it.

The first version was nice

The first version of publish-draft.ts printed exactly what I would have wanted to see if I were running it from a terminal. A line for each step. Green checkmarks. A friendly final summary.

✓ Parsed draft
✓ Uploaded image to /featured/abc123.png
✓ Inserted post: One JSON Line on Stdout
Published! Preview at https://alexvwilson.com/p/one-json-line-on-stdout

When I ran it by hand it was lovely. When the agent ran it, the agent reported back to me that the post had published successfully to a URL ending in featured/abc123.png.

You can see the failure mode. The script printed several URL-looking strings to stdout. The agent's job was to scan the output, find a URL, and report it. The agent did. It picked the wrong one.

This was not the agent being dumb. This was me writing a script whose output looked machine-readable because it had structure, but was actually formatted for a human to glance at. The script had a vibe. The caller could not parse vibes.

The rewrite

The current version of publish-draft.ts does one thing on stdout. It prints exactly one JSON object on a single line, then exits.

On success it prints something like:

{"status":"ok","title":"One JSON Line on Stdout","slug":"one-json-line-on-stdout","previewUrl":"https://alexvwilson.com/p/one-json-line-on-stdout"}

On failure it prints:

{"status":"error","message":"Featured image missing for draft at /path/to/draft.md"}

That is the entire contract. Everything else the script wants to say goes to stderr. Progress logs go to stderr. Sub-process warnings go to stderr. The Postgres connection string echo go to stderr. Stdout is one line, and that line is JSON, and that JSON has a status field.

The agent's prompt is now equally short: "the script prints exactly one JSON line on stdout. Parse it. On status: ok, report the title and previewUrl. On status: error, report the message and stop."

I have not had a misparse since.

What I was actually relearning

This is a thirty-year-old idea. Unix exit codes. Separate streams for output and diagnostics. The --json flag every modern CLI has bolted on by version 2.0. I have used scripts that emit machine-readable output literally every day of my data engineering career.

The reason I did not write publish-draft.ts that way to begin with is that I thought it was overkill for a six-step helper script. I was being precious. I was treating the script like a personal tool instead of treating it like a node in a system.

The thing that snapped me into the discipline was realizing that the caller could not see the room. A human running my script in a terminal would notice if it stalled, if it printed an error, if the URL at the end looked wrong. An automated caller does not notice anything. It sees text. If the text is ambiguous, the caller will hallucinate the structure it expects to find. That is true of every automated caller I have ever written, AI or not. A shell pipeline grepping for Published is just as gullible. The agent is just the most expensive grep I have ever paid for.

The discipline is the same: if your caller can't read the room, put the room in a structured form on stdout.

The shape of the practice

Two rules I now apply to every script with a non-human caller.

Stdout is reserved. One status object per invocation. If you want logs, you have stderr. If you want progress, you have stderr. If you want to debug, you have stderr. Stdout is the contract. Touching it for anything other than the contract is a bug.

The status object is a state machine, not a vibe. The status field is ok or error. Not success, not published, not the celebratory thing you wanted to type. If your script does not know whether it succeeded, you cannot emit a clean status, and that means you have a real bug under the cosmetic one. The discipline of writing this output forces the script to actually know what state it is in. Several times I have started writing the status object and discovered the script was lying to itself about whether step three had committed.

The second rule is the one that surprised me. I expected the JSON-line protocol to be a calling convention. It turned out to also be a debugging tool. The act of having to assert success in a structured way exposed cases where my script was claiming success after a half-failed transaction. Designing the output forced me to design the failure modes.

What I am taking forward

Every script I write now that anything else might call gets the same treatment. One line of structured output on stdout, one explicit state machine in the code, everything else on stderr, exit code that matches the status field. I am old enough to have known this. I had to rebuild a content pipeline to actually internalize it.

The article you are reading was published by exactly that script. If you are seeing it, the agent parsed one JSON line, found status: ok, found the previewUrl, and reported home. That is the entire engineering story for the week.