DEV Community

Sam Rivera
Sam Rivera

Posted on

Build a 50-Line Validation Guard for Free Model JSON Before It Reaches Your CLI

Why this is worth reading: as soon as you point a side project at a free model endpoint, every model response becomes untrusted input. A truncated JSON object, a missing field, or a 70 KB payload can break your CLI in ways that look like a model outage and send you into a rabbit hole. This guide gives you a small validation guard that runs in Node, checks the response shape, trims unknown fields, enforces a size cap, and exits with a clear code. You can run it locally or in a free server process; no web framework required.

What you get

You will build a single validate-response.mjs file around a hardcoded contract: a summary string, a steps array, and a confidence number. The guard reads one JSON payload from stdin, emits either a sanitized JSON object on stdout or an error on stderr, and exits with 0, 2, or 3. You will also create three fixtures: one valid, one missing a field, and one oversized. The whole thing is about 50 lines of plain Node with no npm dependencies beyond the runtime.

Prerequisites

You need Node 18 or newer and a terminal. Create a directory and open it:

mkdir model-guard
cd model-guard
npm init -y
npm pkg set type=module
Enter fullscreen mode Exit fullscreen mode

The type=module setting lets the file use ES module imports without a build step.

Step 1: Lock the contract before you parse

You want the guard to fail closed when the model returns something your downstream code does not expect. For a small task like turning an error into a retry plan, the contract can be deliberately narrow:

{"summary":"Add retry when endpoint returns 429","steps":["catch 429","back off for 2s","retry once"],"confidence":0.82}
Enter fullscreen mode Exit fullscreen mode

You keep the schema in the guard file itself. Hardcoding is intentional here: it makes the contract reviewable next to the code that enforces it, and it removes the need to manage a schema file across local and hosted runs.

Step 2: Write the guard

Create validate-response.mjs:

#!/usr/bin/env node
import { readFileSync } from 'node:fs';

const schema = {
  summary: 'string',
  steps: 'string[]',
  confidence: 'number'
};

const limits = {
  maxBytes: 64 * 1024,
  maxSummary: 240,
  maxSteps: 6,
  maxStepLength: 160
};

const raw = readFileSync(0, 'utf8');

if (!raw.trim()) {
  console.error('guard: empty input');
  process.exit(2);
}

if (Buffer.byteLength(raw, 'utf8') > limits.maxBytes) {
  console.error('guard: response exceeded maxBytes');
  process.exit(3);
}

let payload;
try {
  payload = JSON.parse(raw);
} catch {
  console.error('guard: response is not valid JSON');
  process.exit(2);
}

const out = {};

for (const [key, expectedType] of Object.entries(schema)) {
  const value = payload[key];

  if (value === undefined) {
    console.error(`guard: missing field ${key}`);
    process.exit(2);
  }

  if (expectedType === 'string') {
    if (typeof value !== 'string') {
      console.error(`guard: field ${key} must be a string`);
      process.exit(2);
    }
    out[key] = value.slice(0, limits.maxSummary);
  } else if (expectedType === 'string[]') {
    if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
      console.error(`guard: field ${key} must be an array of strings`);
      process.exit(2);
    }
    if (value.length > limits.maxSteps) {
      console.error(`guard: field ${key} has too many entries`);
      process.exit(2);
    }
    out[key] = value.map((item) => item.slice(0, limits.maxStepLength));
  } else if (expectedType === 'number') {
    if (typeof value !== 'number' || Number.isNaN(value)) {
      console.error(`guard: field ${key} must be a number`);
      process.exit(2);
    }
    out[key] = value;
  }
}

console.log(JSON.stringify(out, null, 2));
Enter fullscreen mode Exit fullscreen mode

You read from file descriptor 0 so the same process can be used in a shell pipeline or as a child process. The guard only copies fields declared in the schema, so extra fields in the model output are trimmed. It also truncates summary and step strings instead of rejecting them outright, which gives you a usable payload when the model adds noise around a mostly correct answer.

Step 3: Test with three fixtures

Create a valid fixture:

mkdir fixtures
cat > fixtures/valid.json <<'JSON'
{"summary":"Add retry when endpoint returns 429","steps":["catch 429","back off for 2s","retry once"],"confidence":0.82,"extra":"drop me"}
JSON
node validate-response.mjs < fixtures/valid.json
Enter fullscreen mode Exit fullscreen mode

The extra field disappears from stdout, while the three valid fields survive.

Create a missing-field fixture:

cat > fixtures/missing-field.json <<'JSON'
{"summary":"Add retry","confidence":0.5}
JSON
node validate-response.mjs < fixtures/missing-field.json; echo exit=$?
Enter fullscreen mode Exit fullscreen mode

The guard prints guard: missing field steps and exits with 2.

Create an oversized fixture:

node -e 'const o={summary:"x".repeat(70000),steps:["y"],confidence:0.5}; console.log(JSON.stringify(o))' > fixtures/oversized.json
node validate-response.mjs < fixtures/oversized.json; echo exit=$?
Enter fullscreen mode Exit fullscreen mode

The guard prints guard: response exceeded maxBytes and exits with 3 before it tries to parse the payload.

These three fixtures give you a tiny regression suite. Run them after any change to the schema or limits:

for fixture in valid missing-field oversized; do
  node validate-response.mjs < fixtures/$fixture.json >/dev/null 2>&1
  code=$?
  echo $fixture: exit=$code
done
Enter fullscreen mode Exit fullscreen mode

Expected output: valid: exit=0, missing-field: exit=2, oversized: exit=3.

Exit code Meaning What you do
0 Shape and size are valid Write the sanitized payload to the next step
2 Invalid JSON or contract violation Drop the response, retry, or fail closed
3 Payload exceeded the size cap Log the size, then raise the limit only on purpose

Step 4: Put the guard between the model response and your CLI

Here is where MonkeyCode's free model access and free server option become relevant. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I am treating the free model access as the source of the response and the free server option as the place the guard can run, because both match the low-cost, single-process constraint this workflow targets. I am not assuming specific model names, quotas, or hardware; the guard only cares about the contract you define.

Your current flow is likely:

model response -> your CLI parses JSON -> your CLI acts on it
Enter fullscreen mode Exit fullscreen mode

You move the guard into the middle:

model response -> validate-response.mjs -> sanitized payload -> your CLI acts on it
Enter fullscreen mode Exit fullscreen mode

In a shell, the pipeline looks like this with a placeholder runner:

your-model-runner --prompt turn this error into a retry plan | node validate-response.mjs > safe.json
Enter fullscreen mode Exit fullscreen mode

If the guard exits with 0, safe.json contains only the fields you declared. If it exits nonzero, your script can stop and avoid passing malformed data deeper. The exit code is the clean exit condition: a downstream script can test it before it ever touches the payload.

If you use MonkeyCode's free server option, run the same file in the server process that receives the model response. Because the guard is frameworkless and reads stdin, you can call it as a child process or place it in the same pipeline as the server command. That avoids a second framework or a separate validation service until you actually need one.

Step 5: Decide what the guard should not do

The guard is a shape and size check, not a safety boundary. It does not know whether the steps are correct for your codebase. It does not stop prompt injection or shell expansion if you later execute a step string. It does not stream; it expects one complete JSON payload, so it is wrong for an endpoint that returns token streams or newline-delimited JSON. It uses one hardcoded schema, so if you have several response shapes you will need to pass the schema in rather than duplicate the file.

You should not use this approach if:

  • the model streams partial output,
  • the response uses nested optional fields or enums that need a real schema validator,
  • you need to process many responses per second and cannot pay process startup for each one,
  • the model output will be executed directly in a shell.

For executable model output, use a disposable sandbox and treat the guard only as the first filter.

What changes in the next iteration

The guard would be more useful if it could report why a response was dropped in a structured way. The current version prints a human-readable line to stderr, which is fine for a solo debugging session but not great for an automated log. The exit code is stable, but the reason string is not part of stdout, so it cannot easily feed a retry decision.

The next version I would build adds a --strict flag that returns verbose failure details as JSON on stdout while keeping the same exit codes. But before I add it, there is one question worth asking: does the free server option expose the model response as a single buffered payload or as a stream? That single detail determines whether the guard runs before or after buffering, and it is the main thing I want to confirm before deploying the next iteration.

Top comments (0)