claude -p in Cron: Isolation, JSON Schema, Fail Fast

Lessons from 161 scheduled runs and 671 headless Claude Code calls: the flags that isolate it, schema-validated output, and the failure that retries cannot fix.

Claude Code's -p (print) mode turns an interactive coding agent into a command you can call from a script. We use it in a scheduled job: four generation tasks, three times a day. Each task produces short structured texts in Japanese, Korean and English, which are validated and written to a MySQL table. Between 2026-09-17 and 2026-11-08 the job ran 161 times and made 671 model calls.

This post covers what we learned about calling claude -p safely from code. It is about the plumbing, not the prompt: isolating the CLI from its surroundings, getting structured output, and deciding which failures to retry.

The call

Every flag below exists in Claude Code 2.1.274's --help:

cmd = [
    claude_bin, "-p", user_message,
    "--model", model,
    "--effort", effort,
    "--system-prompt", system_prompt,
    "--json-schema", json.dumps(schema, ensure_ascii=False),
    "--output-format", "json",
    "--tools", "",                     # no tools: text in, JSON out
    "--no-session-persistence",        # nothing saved between runs
    "--setting-sources", "",           # ignore user/project settings
    "--strict-mcp-config",             # no MCP servers unless given here
    "--disable-slash-commands",
]
with tempfile.TemporaryDirectory(prefix="gen_") as cwd:   # no CLAUDE.md to pick up
    proc = subprocess.run(cmd, cwd=cwd, env=minimal_env(), capture_output=True,
                          text=True, timeout=600)

Isolate it from its surroundings

An interactive agent is designed to pick up context: the project's CLAUDE.md, user settings, configured MCP servers, the tools it may run. In a batch job, every one of those is a way for yesterday's unrelated change to alter today's output.

  • Empty working directory. A fresh temporary directory per call means no project instructions are read, whatever directory the scheduler starts in.
  • No tools, no settings, no MCP. The model's only job is to return JSON. It should not be able to run a command or call a server, even by accident.
  • No session persistence. Each call is independent, so a strange output cannot leak into the next one.
  • Minimal environment. We pass only HOME, USER, LOGNAME, TMPDIR, LANG, LC_ALL, SHELL and a fixed PATH. If the job starts from inside another Claude Code session or a shell that exports API credentials, those variables can change which credentials and settings the CLI uses. Starting from a clean environment removes that whole class of surprise.

Structured output: schema first, then distrust it

--json-schema with --output-format json returns a result object whose structured_output field follows the schema. We build the schema in code and keep it strict:

  • enum wherever the set of values is known, including computed values such as the two target dates of the coming weekend.
  • additionalProperties: false everywhere.
  • Exact array lengths where the count matters.

Then every rule is checked again in Python before anything is written: counts, ranges, no duplicates within an item or across items, non-empty text. There is also a language check, a regex that rejects Hangul in the Japanese and English fields and kana in the Korean and English fields. The schema makes the output parseable. It does not make it correct. Four of our failed attempts were schema-valid JSON whose Japanese field contained Korean text. The validator rejected them, and the retry succeeded.

We also log the metadata the JSON result carries: models used, input and output tokens, and duration. That is how we know the numbers below.

Which failures to retry

Each task gets three attempts. Over 161 runs, 54 attempts failed:

Cause Failed attempts Retry helps?
Expired CLI credentials 48 No. Every retry fails the same way until a human signs in again
Validation rejected the output (language mixed) 4 Yes. The next attempt passed
Timeout (600 s) 2 Yes

The first row is the lesson. The CLI's saved sign-in expired twice, on 2026-10-06 and 2026-11-04, four weeks apart. Each time, two scheduled slots failed for all four tasks, and each failed slot made 12 useless calls (4 tasks × 3 attempts). A human found out from the failure alert, signed in again, and re-ran the missed slot by hand.

The fix is a separate exception that skips the retry loop and aborts the remaining tasks:

class AuthExpiredError(GenerationError):
    """Credentials expired; retrying cannot succeed until someone signs in again."""

if result.get("is_error") or result.get("subtype") != "success":
    detail = str(result.get("result"))[:500]
    if "authenticate" in detail or "OAuth" in detail:
        raise AuthExpiredError(detail)
    raise GenerationError(detail)

for attempt in range(1, MAX_ATTEMPTS + 1):
    try:
        return validate(call_claude(...))
    except AuthExpiredError:
        raise                      # do not burn the other attempts or tasks
    except (GenerationError, ValueError, KeyError) as e:
        log.warning("attempt %d failed: %s", attempt, e)

The alert now says exactly what to do: sign in, then re-run with --force.

One more trap: the CLI's status command reported "logged in" while the credentials were already expired. Our health check (--check) therefore makes a tiny real generation call instead of asking for status. A check that does not exercise the real path only proves that the check runs.

Scheduling around it

  • One run at a time. An fcntl lock file. A run can take a while (median about 6.5 minutes for all four tasks, at most about 25.6 minutes), so overlap is a real risk.
  • Idempotent slots. A small state file records the last completed slot per task. A re-run skips finished work unless forced, so "re-run after fixing the sign-in" is always safe.
  • launchd needs local log paths. On macOS, launchd could not open log files on an external volume, and the job failed to start with exit code 78. launchd's own stdout and stderr go to ~/Library/Logs/, and the application log can live anywhere.

Numbers

Item Value
Scheduled runs 161 (157 complete, 4 partially failed: all credential expiry)
Model calls logged 671
Duration per call median about 68 s (2–593 s)
Output per call median about 4.1k tokens (max 26.7k)
Full run (4 tasks) median about 6.5 min, max about 25.6 min

Checklist

  • [ ] Run in an empty temp directory with --tools "", --setting-sources "", --strict-mcp-config, --disable-slash-commands, --no-session-persistence
  • [ ] Pass a minimal, explicit environment
  • [ ] --json-schema + --output-format json; read structured_output; re-validate every rule in code
  • [ ] Treat credential errors as fatal for the whole run; alert with the exact fix
  • [ ] Health checks must make a real call
  • [ ] Lock against overlap; make slots idempotent; log tokens and duration from the result

FAQ

How do you stop claude -p from reading project files, tools or MCP servers in a script?

Run it in an empty temporary directory and pass --tools "", --setting-sources "", --strict-mcp-config, --disable-slash-commands and --no-session-persistence, with a minimal environment. Then the only inputs are your system prompt and your message.

How do you get reliable JSON out of claude -p?

Pass --json-schema with a strict schema (enums, additionalProperties false) and --output-format json, read structured_output from the result, and still validate every rule in your own code before writing anything.

What failure should not be retried?

Expired CLI credentials. In our logs, 48 of 54 failed attempts were the same authentication error retried pointlessly. Detect it, stop the whole run, and alert a human.

  • #claude-code
  • #llm
  • #automation
  • #json-schema
  • #python
  • #cron

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑