Skip to main content

JSON Output

ctx provides a machine-readable output mode for scripting and tool integration. Pass the global --json flag to any supported command to get a single, stable JSON document on stdout.

ctx query find parse --json | jq '.data.symbols[].file'

Contract

stdout / stderr rules

  • In JSON mode, stdout contains exactly one JSON document and nothing else.
  • Diagnostics (progress messages, warnings, hints) go to stderr.
  • "No results" is not an error: the command still emits a full envelope with empty arrays.

Exit codes

The whole ctx suite follows a three-way exit-code convention (like grep or most linters):

CodeMeaning
0Success, nothing to report
1Command ran successfully but produced findings (used by quality commands)
2Operational error (bad arguments, missing index, git failure, IO error, ...)

Breaking change: operational errors previously exited with code 1; they now exit with code 2. Exit code 1 is reserved for "ran fine, found issues".

The envelope

Every JSON document has the same top-level shape:

{
  "ctx_version": "0.3.0",
  "command": "query.find",
  "generated_at": "2026-07-09T13:30:46.623878Z",
  "data": { }
}
FieldDescription
ctx_versionVersion of the ctx binary that produced the output
commandDotted command identifier (e.g. search, query.callers)
generated_atRFC3339 UTC timestamp
dataCommand-specific payload (documented below)

All field names are snake_case.

SymbolRef

Wherever a symbol appears in a payload, it is a SymbolRef object (never a bare string):

{
  "name": "parse_file",
  "qualified_name": "Indexer::parse_file",
  "kind": "function",
  "file": "src/index/mod.rs",
  "line_start": 120,
  "line_end": 158
}

qualified_name is null when not known. Some commands attach extra fields alongside or inside a SymbolRef (e.g. visibility in query.find); additions are backwards-compatible, removals or renames are not.

Note: query.graph and query.impact nodes come from graph traversal, which does not track line numbers; their SymbolRefs have line_start/line_end of 0 and qualified_name of null.

Commands

ctx search <QUERY> --json (also --output json)

{
  "query": "parse",
  "limit": 20,
  "results": [
    {
      "symbol": { "name": "parse_file", "qualified_name": null, "kind": "function", "file": "src/index/mod.rs", "line_start": 120, "line_end": 158 },
      "score": 1.0,
      "match_type": "exact",
      "signature": "fn parse_file(&mut self, path: &Path) -> Result<()>",
      "brief": "Parse a single file into symbols and edges."
    }
  ]
}

match_type is "exact", "semantic", or "name". score is a relevance value in 0.0..=1.0.

semantic

ctx semantic <QUERY> --json (also --output json)

{
  "query": "token counting",
  "limit": 10,
  "results": [
    {
      "symbol": { "name": "count_tokens", "qualified_name": null, "kind": "function", "file": "src/tokens.rs", "line_start": 41, "line_end": 60 },
      "symbol_id": "src/tokens.rs::count_tokens",
      "score": 0.83
    }
  ]
}

If no embeddings have been generated yet, results is empty and a hint is printed to stderr.

query.find

ctx query find <PATTERN> [--kind K] [--file F] --json

{
  "pattern": "parse",
  "filters": { "kind": "function", "file": null },
  "symbols": [
    {
      "name": "parse_file",
      "qualified_name": "Indexer::parse_file",
      "kind": "function",
      "file": "src/index/mod.rs",
      "line_start": 120,
      "line_end": 158,
      "visibility": "public"
    }
  ]
}

query.callers

ctx query callers <FUNCTION> [--file F] --json

{
  "target": { "name": "open_database", "qualified_name": null, "kind": "function", "file": "src/index/mod.rs", "line_start": 637, "line_end": 652 },
  "callers": [
    {
      "symbol": { "name": "run_search", "qualified_name": null, "kind": "function", "file": "src/commands/search.rs", "line_start": 14, "line_end": 41 },
      "line": 16,
      "context": "index::open_database(&root)?"
    }
  ],
  "ambiguous": []
}

Disambiguation: when several symbols match the name and no --file filter is given, target is null, callers is empty, and ambiguous lists the candidate SymbolRefs. When the symbol is not found at all, all three are empty/null.

query.deps

ctx query deps <SYMBOL> [--file F] [--kind K] --json

{
  "target": { "name": "run_search", "qualified_name": null, "kind": "function", "file": "src/commands/search.rs", "line_start": 14, "line_end": 41 },
  "dependencies": [
    {
      "kind": "calls",
      "target_name": "open_database",
      "line": 16,
      "resolved": { "name": "open_database", "qualified_name": null, "kind": "function", "file": "src/index/mod.rs", "line_start": 637, "line_end": 652 }
    }
  ],
  "ambiguous": []
}

resolved is null for unresolved (e.g. external) references. Ambiguity is reported like query.callers.

query.graph

ctx query graph <START> [--depth N] --json (also --output json; --output dot and text are unchanged)

{
  "root": "main",
  "depth": 3,
  "nodes": [
    {
      "symbol": { "name": "run", "qualified_name": null, "kind": "function", "file": "src/main.rs", "line_start": 0, "line_end": 0 },
      "depth": 1
    }
  ]
}

query.impact

ctx query impact <SYMBOL> [--depth N] --json

{
  "target": "open_database",
  "depth": 5,
  "impacted": [
    {
      "symbol": { "name": "run_search", "qualified_name": null, "kind": "function", "file": "src/commands/search.rs", "line_start": 0, "line_end": 0 },
      "distance": 1
    }
  ],
  "total": 1
}

query.stats

ctx query stats --json

{
  "files": 42,
  "symbols": 815,
  "functions": 500,
  "structs": 90,
  "enums": 12,
  "traits": 8,
  "edges": 2300,
  "per_file": [
    { "file": "src/index/mod.rs", "symbols": 60, "functions": 41, "public": 18, "types": 6 }
  ],
  "most_connected": [
    { "name": "open_database", "file": "src/index/mod.rs", "calls_out": 3, "called_by": 12 }
  ]
}

per_file and most_connected are empty when the analytics engine is unavailable (e.g. builds without the duckdb feature).

query.files

ctx query files --json

{
  "files": ["src/cli.rs", "src/main.rs"]
}

explain

ctx explain <SYMBOL> [--file F] [--kind K] --json

{
  "symbol": { "name": "run", "qualified_name": "App::run", "kind": "method", "file": "src/app.rs", "line_start": 7, "line_end": 30 },
  "visibility": "public",
  "signature": "fn run(&self) -> Result<()>",
  "brief": "Run the app",
  "docstring": null,
  "callers_count": 3,
  "deps_count": 5,
  "ambiguous": []
}

When the symbol is not found, symbol is null and the counts are 0; when several symbols match without filters, ambiguous lists the candidates.

check

ctx check [--rules PATH] [--against REF] --json

{
  "rules_path": ".ctx/rules.toml",
  "against": "main",
  "summary": { "violations": 2, "rules_violated": 2 },
  "violations": [
    {
      "rule": "forbidden",
      "rule_id": "forbidden: domain -> infrastructure",
      "reason": "Domain layer must stay persistence-agnostic",
      "message": "src/domain/order.ts:2 -> src/infra/db.ts [calls query]",
      "file": "src/domain/order.ts",
      "line": 2,
      "from": { "name": "order", "qualified_name": null, "kind": "function", "file": "src/domain/order.ts", "line_start": 2, "line_end": 2 },
      "to": { "name": "query", "qualified_name": null, "kind": "function", "file": "src/infra/db.ts", "line_start": 1, "line_end": 1 }
    },
    {
      "rule": "limit",
      "rule_id": "limit: fan_in <= 25 (symbol)",
      "reason": "fan_in 30 exceeds max 25",
      "message": "src/app/hub.ts:10 (handle): fan_in 30 exceeds max 25",
      "file": "src/app/hub.ts",
      "line": 10,
      "subject": { "name": "handle", "qualified_name": null, "kind": "function", "file": "src/app/hub.ts", "line_start": 10, "line_end": 42 },
      "metric": "fan_in",
      "scope": "symbol",
      "value": 30,
      "max": 25
    }
  ]
}

One entry per violation. rule is one of forbidden, allowed_dependents, limit, or no_new_dependents; rule_id identifies the specific rule instance (violations with the same rule_id belong to the same rule).

Dependency violations (forbidden, allowed_dependents, no_new_dependents) carry from/to endpoints. Symbol-level endpoints (resolved call/implements/extends/uses edges) are full SymbolRefs; file-level endpoints (resolved imports) are {"file": ...} objects. limit violations carry a subject endpoint plus metric, scope, value, and max. Absent optional fields (line, from, to, subject, the metric fields) are omitted rather than null. against is null when --against was not given.

Exit codes follow the suite convention: 0 = no violations, 1 = at least one violation, 2 = operational error (missing/invalid rules file, unknown or overlapping layers, missing index, bad git ref).

check.list

ctx check --list --json

{
  "rules_path": ".ctx/rules.toml",
  "version": 1,
  "layers": [
    { "name": "domain", "patterns": ["src/domain/**"], "files": 12 }
  ],
  "rules": {
    "forbidden": [ { "from": "domain", "to": "infrastructure", "reason": "Domain layer must stay persistence-agnostic" } ],
    "allowed_dependents": [ { "layer": "infrastructure", "only": ["application"], "reason": null } ],
    "limit": [ { "metric": "fan_in", "scope": "symbol", "max": 25, "exclude": ["src/core/**"] } ],
    "no_new_dependents": [ { "paths": ["src/legacy/**"], "reason": "Legacy module is frozen; do not add new callers" } ]
  }
}

files is the number of indexed files matching the layer's globs. --list always exits 0.

duplicates

ctx duplicates [--threshold F] [--min-tokens N] [--against REF] [--fail-on-found] --json

{
  "threshold": 0.85,
  "min_tokens": 50,
  "against": null,
  "skipped_languages": ["solidity"],
  "pairs": [
    {
      "a": { "name": "process_orders", "qualified_name": null, "kind": "function", "file": "src/orders.rs", "line_start": 12, "line_end": 30 },
      "b": { "name": "sum_invoices", "qualified_name": null, "kind": "function", "file": "src/invoices.rs", "line_start": 4, "line_end": 22 },
      "similarity": 0.97,
      "token_count_a": 64,
      "token_count_b": 64
    }
  ]
}

similarity is the exact Jaccard similarity (0.0-1.0) of the two functions' normalized 5-token shingle sets. Pairs are sorted by similarity (descending), then by symbol id. skipped_languages lists languages that are never fingerprinted (Solidity has no tree-sitter grammar). With --fail-on-found, a non-empty pairs array exits with code 1.

score

ctx score [--against REF] [--fail-on EXPR] --json

{
  "against": "main",
  "files_changed": 1,
  "metrics": {
    "complexity_delta": 3,
    "fan_out_delta": 1,
    "new_duplication": 0,
    "check_violations": 0,
    "symbols_added": 1,
    "symbols_removed": 0,
    "files_changed": 1
  },
  "check_violations_note": "no rules file",
  "per_file": [
    {
      "path": "src/a.rs",
      "complexity_baseline": 3,
      "complexity_current": 6,
      "fan_out_baseline": 1,
      "fan_out_current": 2,
      "symbols_added": 1,
      "symbols_removed": 0
    }
  ],
  "failed_conditions": [],
  "notes": ["fan_in approximated as same-file callers for baseline comparability"]
}

metrics is flat and its keys are exactly the metric names accepted by --fail-on (complexity_delta, fan_out_delta, new_duplication, check_violations, symbols_added, symbols_removed, files_changed). per_file breaks the delta metrics down per changed file with both sides (*_baseline from parsing the file's content at the reference in memory, *_current from the index). failed_conditions lists every satisfied --fail-on condition in canonical metric OP value form (non-empty means exit code 1). check_violations_note is "no rules file" (and check_violations is 0) when .ctx/rules.toml does not exist, null otherwise. notes carries caveats, always including the fan-in approximation note: baselines are parsed in isolation, so fan-in counts same-file callers on both sides.

Exit codes follow the suite convention: 0 = clean or informational, 1 = at least one --fail-on condition met, 2 = operational error (not a git repo, bad reference, malformed --fail-on, invalid rules file).

Separately from --json, setting the CTX_GATE_LOG environment variable makes every ctx score run append one JSONL record (a bare JSON object per line — not this envelope) describing the gate evaluation to a local log, default .ctx/gate-log.jsonl. See ctx score — Gate logging.

snapshot.capture

ctx snapshot [--force] [--churn-window SPEC] --json

{
  "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316",
  "committed_at": "2026-07-10T21:25:27+02:00",
  "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316",
  "files": 2,
  "symbols": 3,
  "dup_pairs": 0,
  "violations": 0,
  "skipped_existing": false
}

One report per captured partition. commit_sha and committed_at (the committer date, strict ISO 8601) identify the snapshotted commit; partition_dir is where the four Parquet files were written. files, symbols, and dup_pairs are the row counts of the corresponding Parquet tables; violations is the total architecture-rule violation count (0 when .ctx/rules.toml is absent). When a partition for the commit already exists and --force was not given, skipped_existing is true and the counts are reported as zero — they are not re-read from the existing files.

Exit codes: 0 = snapshot written or partition already existed, 2 = operational error (not a git repo, build without the duckdb feature, IO failure).

snapshot.backfill

ctx snapshot backfill --since REF [--every N] [--churn-window SPEC] --json

{
  "since": "3019df548fc417c7b6b06bef7defb74a0c01ba78",
  "captured": 1,
  "skipped_existing": 1,
  "snapshots": [
    {
      "commit_sha": "3019df548fc417c7b6b06bef7defb74a0c01ba78",
      "committed_at": "2026-07-10T21:25:27+02:00",
      "partition_dir": ".ctx/snapshots/sha=3019df548fc417c7b6b06bef7defb74a0c01ba78",
      "files": 1,
      "symbols": 2,
      "dup_pairs": 0,
      "violations": 0,
      "skipped_existing": false
    },
    {
      "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316",
      "committed_at": "2026-07-10T21:25:27+02:00",
      "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316",
      "files": 0,
      "symbols": 0,
      "dup_pairs": 0,
      "violations": 0,
      "skipped_existing": true
    }
  ]
}

since is the --since argument as given. snapshots carries one snapshot.capture-shaped report per partition, oldest first; captured and skipped_existing are the counts of new vs. already-existing partitions among them. Commits that failed to capture are logged to stderr and do not appear in snapshots — the walk continues past them, and the exit code stays 0.

harness.init

ctx harness init [--mode local|plugin] [--force] --json

{
  "mode": "local",
  "target": "claude",
  "force": false,
  "files": [
    { "path": ".claude/hooks/ctx/session-start.sh", "action": "created" },
    { "path": ".claude/hooks/ctx/post-tool-use.sh", "action": "regenerated" },
    { "path": ".claude/hooks/ctx/stop.sh", "action": "skipped_modified" },
    { "path": ".ctx/rules.toml", "action": "skipped_policy" },
    { "path": ".ctx/harness.lock", "action": "regenerated" }
  ],
  "settings_snippet": "{ ... }",
  "claude_md_block": "## Code intelligence (ctx) ..."
}

One files entry per planned file. action is one of created (did not exist), regenerated (owned by ctx and unmodified), overwritten (--force replaced a modified or foreign file), skipped_modified (checksum no longer matches; use --force), skipped_foreign (exists but was not generated by ctx), or skipped_policy (.ctx/rules.toml, never overwritten). settings_snippet and claude_md_block are only present in local mode; they carry the exact text that non-JSON mode prints to stdout for manual inclusion.

harness.doctor

ctx harness doctor --json

{
  "healthy": false,
  "binary_version": "0.3.0",
  "mcp_compiled": false,
  "templates_version": "0.3.0",
  "summary": { "errors": 1, "warnings": 1, "info": 1 },
  "checks": [
    { "severity": "info", "code": "binary_version", "message": "ctx v0.3.0 (mcp feature: not compiled in)" },
    { "severity": "warning", "code": "index_missing", "message": "no code intelligence index (.ctx/codebase.sqlite)", "hint": "run 'ctx index' to build it" },
    { "severity": "error", "code": "rules_invalid", "message": ".ctx/rules.toml is invalid: ...", "hint": "fix the rules file; 'ctx check --help' documents the format" }
  ]
}

severity is error, warning, or info; hint is omitted when there is none. code is a stable machine-readable identifier: binary_version, harness_not_initialized, templates_stale, index_missing, index_schema, index_stale, rules_missing, rules_invalid, hooks_missing, hooks_modified, settings_not_wired, mcp_unavailable, mcp_not_wired. Checks are independent (a missing index and an invalid rules file are both reported in one run). healthy is true when no check is an error or a warning; exit codes: 0 = healthy, 1 = problems, 2 = operational error.

self_update

ctx self-update [--version X.Y.Z] --json

{
  "old_version": "0.2.1",
  "new_version": "0.3.0",
  "outcome": "updated"
}

outcome is updated (the binary was replaced) or up_to_date (nothing to do). Failures (network error, checksum mismatch, install location not writable, unsupported platform) exit 2 without emitting an envelope. Note that --json also suppresses the passive update notice — in JSON mode nothing but the envelope is ever printed to stdout, and update notices only ever go to stderr.

version.check

ctx --version --check --json

{
  "current_version": "0.2.1",
  "latest_version": "0.3.0",
  "update_available": true
}

Explicit release comparison (never installs anything). Exits 0 whether or not an update exists; network failures exit 2.

Legacy shapes

ctx complexity --output json, ctx graph --output json, and ctx audit --output json still emit their old, ad-hoc JSON shapes. They will be migrated to the envelope in a future release. The old ad-hoc shapes of search --output json and semantic --output json have already been replaced by the envelope described above.