Search
Search documentation
JSON mode
Updated Oct 21, 2026
--json is a built-in flag on every fireargs command. It lets the same
program serve human users at the shell, scripts in a pipeline, and LLM
agents — all backed by one pydantic-validated handler.
Enabled by default. Disable per command with json_option=False, or
customize the flag and its help with
json_option={"flags": ..., "description": ...} — any flag name works, e.g.
{"flags": "-j, --as-json"}.
Three modes
greet world # CLI in, rendered output (if any)
greet world --json # CLI in, JSON out
greet --json '{"name":"world"}' # JSON in, JSON outThe flag takes an optional value (--json [value]):
- Absent — normal CLI parsing, the handler runs, and the validated output goes to the command's render if it has one; otherwise nothing is printed.
- Present without a value — CLI parsing for input, but the handler's return is serialized as JSON to stdout.
- Present with a JSON value — input comes from the JSON value; CLI positionals and options are skipped, so required positionals may be left off; output is JSON.
What happens on --json '<value>'
- Checks the value is JSON.
- Validates it against the input model in pydantic's strict mode
(
model_validate_json(value, strict=True)). - Calls the handler with the validated input and
context.json is True. - Validates the handler's return against the output type.
- Dumps it in pydantic's JSON mode, by alias, leaving out
Noneoptionals, and writes it to stdout as JSON indented by two spaces, with a newline.
When --json is passed alone (no value), step 1 is skipped and input is
collected from the regular CLI parse instead. Steps 3–5 are identical.
JSON input bypasses click, so env vars, choices checks, a field's parse and
number parsing do not apply — the model's defaults and validation still do.
Send each value in its real type: ["a","b"], not "a,b"; 3, not "3" —
strict mode rejects a string for an int, like zod does. Strings still
carry the types JSON has no literal for: an ISO 8601 datetime, a UUID,
an Enum value. Keys the model does not declare are ignored, as pydantic
does by default.
Keeping stdout clean
The handler's optional second parameter tells it which mode it runs in. In
JSON mode, anything else written to stdout corrupts the result, so spinners,
colors and progress output should check context.json:
import click
from pydantic import BaseModel
from fireargs import Context, command
class SyncInput(BaseModel):
source: str
@command()
def sync(input: SyncInput, context: Context) -> list[str]:
if not context.json:
click.echo(f"Syncing {input.source}…", err=True)
return [input.source]Errors
Without a hook, a value that is not JSON and input that fails the model are reported like a commander parse error, written to stderr with exit code 1:
error: --json value is not valid JSON: Expecting value: line 1 column 1 (char 0)
error: invalid --json input at 'items[0][1]': Input should be a valid integerThe path reads like TypeScript's (a.b[0]); the message after the colon is
pydantic's. Output validation failures and
handler exceptions propagate out of the click command: in standalone mode
click prints the traceback and exits 1, and with standalone_mode=False or
CliRunner the exception is raised or captured.
Set render_error in the command's or program's config to turn all of them
into output instead — the hook receives the original json.JSONDecodeError
or pydantic ValidationError for input failures, and the exception itself
for output, handler and render failures:
from fireargs import Context, Program
def render_error(error: Exception, context: Context) -> object:
return {"error": str(error)} if context.json else f"error: {error}"
cli = Program(name="myapp", render_error=render_error, router={"greet": greet})- In JSON mode a returned value is written as JSON to stdout; otherwise a
returned string is written to stderr. Return
Noneto write it yourself; raise to propagate as before. - The run then exits with code 1, through click's own exit.
- It also catches render failures. The nearest hook wins: the leaf's own,
then each enclosing program's. A hook may be
async def.
Click's parse errors (unknown option, bad choice, extra argument) happen
before the run starts: click prints its usage line and Error: ... to
stderr and exits with code 2. A missing required positional, a
conflicts violation and a CLI value that number parsing or a field's
parse rejects are reported in commander's wording, with exit code 1 — in
TypeScript commander rejects them while parsing. None of these reach
render_error.
Composes with subcommands
Every leaf in a program tree gets its own --json:
myapp greet --json '{"name":"world"}'
myapp api deploy --json '{"env":"prod"}'The program itself doesn't have a --json — programs dispatch to
subcommands; only leaves take input. See Programs.
Pairs with --llms
Together with LLMs mode, --json is the call-time
half of fireargs's agentic interface: an LLM reads the --llms manifest to
learn the schemas, then calls back through --json with a value matching
the documented inputSchema.
Created with ❤ and Livemark