DEV Community

Taylor Wang
Taylor Wang

Posted on

Field Notes: The JSON Parsed on My Laptop Because a Shell Helper Ate the Fences

Have you ever watched a clean machine fail a JSON parse that your laptop handled without complaint? I just burned forty-eight hours on that exact split, and the model was never the broken piece. The payload looked perfect in my terminal, then jq died the moment the same bytes hit a stock shell. This is the field notebook I wish I had taped above the prompt box before the next rerun.

The Question I Should Have Asked First

Why did one machine treat the output as JSON while the other treated it as markdown? I assumed the remote run used a worse model, a truncated stream, or a different temperature I had not pinned. That assumption felt scientific, because I could see fences in a gist and no fences on my laptop screen. Did I capture the raw bytes, though, or only the bytes after my shell had already helped me?

Hours 0–8: I Kept Editing the Prompt

I asked for a tiny tool payload with three required keys: name, path, and dry_run. The local run printed a neat object, and python -m json.tool accepted it like a friendly librarian. On the next pass I added an enum for dry_run, because maybe the model needed a stricter contract to stay honest. Nothing changed except my confidence, which is a terrible metric and a great way to waste an afternoon.

Here is the ordered list of things I actually tried before I touched the shell.

  1. I shortened the system prompt until it barely described the three required keys at all.
  2. I added a blunt instruction to return JSON only, with no extra commentary around it.
  3. I pasted a miniature schema into the user message and asked for exact key names.
  4. I tweaked generation flags I could not verify, then treated the next pass as a controlled experiment.
  5. I pasted the laptop output into a browser linter, which is not a capture strategy.

Each of those steps kept the laptop green and left the clean environment looking uniquely cursed. Have you noticed how a local success makes you stop saving evidence the moment the pretty printer smiles? I stopped archiving raw transcripts after the third pretty print, and that is how this story gained extra hours.

Hours 8–16: Hashes, Hexdumps, and False Friends

I hashed the files I had saved, and of course they did not match, because I had saved processed text. Running sha256sum on laptop.json and server.json is a comforting ritual when you are hashing the wrong artifacts. A hexdump finally showed the laptop file starting with 7b 0a and the server file starting with 60 60 60 6a. That is an opening brace versus backticks, and it should have ended the mystery before dinner.

I still blamed the model, because the laptop transcript looked like the model had learned to drop fences overnight. Was the model adapting to my prompt, or was I adapting the bytes after the response had already landed? The honest answer is the second one, and it lived quietly in a function inside my bashrc file. Grep found process lists, Python versions, and locale settings, then skipped the one file that mutated output.

Hours 16–24: A Clean Shell Finally Disagreed

I needed a machine without my aliases, without my prompt theme, and without the helpers I forget I wrote.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

I reran the same extraction script with MonkeyCode's free model access on the free server option, which meant a stock shell. The remote transcript showed fenced markdown immediately, and jq failed on the first backtick just like the hexdump. The laptop was not smarter; it rewrote stdout through a function I had added during a previous debugging binge.

Here is the trap that lived on the laptop and nowhere else, copied from bashrc with the comments intact.

# ~/.bashrc — local convenience, missing on every clean host
pretty_model_json() {
  sed -n '/^```

/{n;:a; /^

```/q; p; n; ba}' | command jq .
}

# what I actually typed for two days
pretty_model_json < transcript.txt
Enter fullscreen mode Exit fullscreen mode

That helper made jq look brilliant locally and completely unavailable as a diagnostic on a clean host. Would you have grepped your aliases first, or would you have also argued with the model for another day? I grepped everything except the shell, which is a special kind of tunnel vision I would like to unlearn.

Hours 24–36: What Actually Broke

Three separate layers were stacked together, and I had treated them as one blob called the model.

  • The model often wraps JSON in markdown fences when the surrounding chat looks like documentation to a human.
  • My laptop helper stripped those fences before jq, so local tooling never saw the wrapper bytes at all.
  • The clean server ran real jq, which correctly rejected a leading backtick as invalid JSON input.

There was a fourth layer I almost missed, because python -m json.tool on the laptop read a file saved through the helper. I was validating a derivative, then comparing it to a raw remote transcript, which is an instrumentation bug with extra opinions. Once those layers were named, the remaining work was a script that would fail the same way everywhere.

A Reproducible Fence-and-Schema Check

I wanted one script that would fail the same way on every machine, including the next clean server I borrow. It reads raw bytes, optionally strips one markdown fence, then checks a tiny required-key schema with the standard library. I am labeling this as a helper I ran after hour twenty-four, not as a published benchmark harness with magic numbers. Copy it into a file named fence_json_check.py and run it against transcripts that never touched an alias.

#!/usr/bin/env python3
"""fence_json_check.py — compare raw model text to a tiny object schema."""

from __future__ import annotations

import json
import sys
from pathlib import Path
from typing import Any

TICKS = chr(96) * 3
REQUIRED = {
    "name": str,
    "path": str,
    "dry_run": bool,
}


def unwrap(text: str) -> tuple[str, str]:
    stripped = text.strip("\ufeff").strip()
    lines = stripped.splitlines()
    if (
        len(lines) >= 3
        and lines[0].startswith(TICKS)
        and lines[-1].strip() == TICKS
    ):
        return "\n".join(lines[1:-1]), "fenced"
    return stripped, "raw"


def type_ok(value: Any, expected: type) -> bool:
    # bool is a subclass of int; use exact type checks on purpose.
    return type(value) is expected


def validate(obj: Any) -> list[str]:
    errors: list[str] = []
    if not isinstance(obj, dict):
        return ["root is not an object"]
    for key, expected in REQUIRED.items():
        if key not in obj:
            errors.append(f"missing key: {key}")
            continue
        if not type_ok(obj[key], expected):
            errors.append(
                f"wrong type for {key}: {type(obj[key]).__name__}"
            )
    extras = sorted(set(obj) - set(REQUIRED))
    if extras:
        errors.append("unexpected keys: " + ", ".join(extras))
    return errors


def main(argv: list[str]) -> int:
    if len(argv) != 2:
        print("usage: fence_json_check.py FILE", file=sys.stderr)
        return 2
    text = Path(argv[1]).read_text(encoding="utf-8")
    body, shape = unwrap(text)
    print(
        f"shape={shape} bytes={len(text.encode())} "
        f"body_bytes={len(body.encode())}"
    )
    try:
        obj = json.loads(body)
    except json.JSONDecodeError as exc:
        print(f"json_error={exc}")
        print("head=" + body[:80].replace("\n", "\\n"))
        return 1
    errors = validate(obj)
    if errors:
        print("schema_error=" + "; ".join(errors))
        return 1
    print("ok")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
Enter fullscreen mode Exit fullscreen mode

Run it against both transcripts without touching aliases, pagers, or clipboard managers that like to rewrap text.

python3 fence_json_check.py laptop_raw.txt
python3 fence_json_check.py server_raw.txt
python3 fence_json_check.py /dev/stdin < transcript.txt
command -v jq
type pretty_model_json 2>/dev/null || echo "no local helper"
Enter fullscreen mode Exit fullscreen mode

If shape=fenced on one host and shape=raw on the other, stop arguing about models and inspect preprocessing. You are comparing two different pipelines, and only one of them is honest about the original bytes. The script prints byte counts so a truncated stream cannot hide behind a pretty error message from jq.

Test Plan I Would Repeat

This is the five-step loop I would repeat before changing any prompt text again.

  1. Save the model response as raw bytes before any pager, alias, or clipboard tool can rewrite them.
  2. Run fence_json_check.py on that file on the laptop and again inside a clean login shell.
  3. Compare the shape= line first, then the JSON decoder error, and only then the schema errors.
  4. Grep the shell startup files for alias, jq, sed, and any function that mentions fences or JSON.
  5. Change prompts, schemas, or servers only after both hosts report the same shape for the same file.

Decision Table

I kept this table in the notebook because it stopped me from looping on prompt edits.

Observation Likely layer Next move Do not do
Laptop parses, clean host shows backticks Local helper or editor rewrite Dump type pretty_model_json and command -v jq Tighten the prompt again
Both hosts show fences, schema fails Contract mismatch, not the shell Keep fences in the log, fix the required keys Strip fences only on one machine
JSONDecodeError at column 1 with { missing Truncated stream or bad join Log byte length before decode Assume the model forgot JSON
dry_run is the string "false" Type coercion, not fences Reject strings in the checker Call bool("false") in a hurry
Files hash differently after pretty print You saved derivatives Recapture stdin as the source of truth Compare jq . outputs as ground truth

What I Would Repeat

I would capture raw transcripts first, even when the local pretty printer looks innocent, fast, and slightly smug. I would keep a thirty-line schema checker in the repo so every host fails for the same boring reason. I would treat a clean server as a control group, not as a second opinion about mysterious model quality. Would I still draft inside a fenced chat UI, knowing the fences are documentation rather than an API boundary?

Yes, I would still draft there, but I would never pipe that UI through a custom jq helper again. The useful habit is boring: same script, same file, two shells, and one explicit schema for the object. If those four things disagree, the disagreement is your actual bug, and the prompt can wait until morning. If they agree and the object is still wrong, then you finally have permission to argue with the prompt.

Limitations, and Who Should Not Use This

This helper is not a parser for arbitrary markdown, nested fences, or concatenated JSON streams from server-sent events. The unwrap step assumes one optional fence around the entire file, which is enough for this failure and wrong for interleaved tool traces. It also uses a tiny required-key map instead of a real JSON Schema draft, so it will not catch formats or ranges. Those limits are acceptable for a field check and unacceptable for a production tool router sitting on the internet.

Do not send confidential prompts or customer payloads to any shared server you do not already trust, including a free one. Do not treat fence stripping as a security boundary, because a hostile payload can hide instructions outside the fenced block. If you need streaming tool calls, signed audit logs, or air-gapped secrets, this workflow is the wrong shape. Stop at a local validator in those cases, and do not use a chat transcript as the system of record.

I am also not claiming anything about model rankings, quotas, uptime, or hardware, because I did not measure those. The point is that a stock shell is a better instrument than a clever helper you forgot you wrote. Forty-eight hours is a long time to learn that lesson, and I would like the next bug to survive hexdump.

Top comments (0)