Skip to content

JSON output and exit codes

The sync commands of ctx have a machine mode for scripts, CI and the VS Code extension. This page is the reference for it: what --json prints, how a failure is reported, and what each exit code means. All of it is version 1 of the ctx contract, which ctx version --json announces.

Command stdout document
ctx version --json ctx.version.v1
ctx status --json (also with --offline) ctx.status.v1
ctx push --json ctx.push.v1
ctx pull --json ctx.pull.v1
ctx resolve <relpath> --ours or --theirs, with --json ctx.resolve.v1
ctx repair --json (ctx only; the extension never calls it) ctx.repair.v1
ctx show <relpath> --at ... the raw page bytes, no document

ctx show is the exception: it always runs in machine mode and writes the page’s raw bytes, not JSON.

With --json, a successful run writes exactly one JSON document to stdout and nothing else anywhere: stderr stays empty, there is no spinner, no prompt, no update nudge and no warning: line. Warnings go into the document’s own warnings array. ctx push --json never prompts or previews; --yes is accepted there and does nothing.

The document is written the same way every time:

  • 2-space indent, a trailing newline, keys in a fixed order (the contract’s order, never alphabetical), all keys snake_case.
  • Arrays are always present, never null; an empty list is [].
  • Optional values are left out when unknown or not applicable, never null. One field is the exception: project.dashboard_url in ctx.status.v1 is always present and is "" for a self-hosted server with no dashboard configured (then project.dashboard_page_url_template is left out).
  • Page and commit shas are full 40-hex. The build commit and date in ctx.version.v1 are build metadata (a short commit and a date), not page shas.
  • Times are UTC RFC 3339 in whole seconds, like 2026-07-28T09:14:07Z.
  • Content hashes are sha256: followed by 64 hex digits.
  • Page paths are relative to .contexo/ and use /. Only root and contexo_dir in the status document are absolute paths in your operating system’s form.

Callers must ignore fields they do not know. Additive changes keep contract_version at 1: new optional fields, new commands or flags (announced by a new capability), new error, warning, note or skip-reason codes, new server capabilities. Breaking changes raise it to 2: removing, renaming or retyping a field, changing what a field means, a new page state or conflict kind, moving an error code to another exit code, or giving up the one-document rule.

When a command fails in machine mode, stdout is empty and the last non-empty line of stderr is one line of JSON:

{"schema":"ctx.error.v1","exit_code":5,"error":{"code":"not_initialized","message":"status: no .contexo/ directory in /e2e/alice","advice":["ctx init"],"retryable":false}}
  • exit_code always equals the process’s exit code.
  • code is one of the 24 stable codes below. It is the authoritative field; the exit code is derived from it.
  • message is a human sentence with no colour codes.
  • advice lists shell commands that fix the problem. It is [] when there are none, never left out.
  • retryable is true only for codes worth retrying unchanged.
  • http_status appears only when an HTTP response caused the error.
  • details appears only for the codes that have a details payload, listed below.

There is no other key.

The envelope is written only in machine mode, which means: the command was run with --json (or --json=true), or the raw arguments contain --json before any --, so even a flag-parsing failure is reported as an envelope; or the command is ctx show. ctx mcp, ctx capture turn and shell completion own their output streams and never write one.

Without --json, failures stay human: a rendered message, usually with a try line naming the fix, and exit code 1, whatever the cause. For example, ctx resolve <relpath> --ours --theirs exits 1 with a message, while the same command with --json exits 3 with a usage envelope.

There is one case with both a document and an envelope: ctx push --json where some pages hit a conflict. The pages that went through are in the stdout document, and the envelope says conflict, exit 6. See the example below.

To read a failure from a program: if the exit code is not 0, take the last non-empty stderr line and parse it. If it does not parse (an older ctx, or a crash), fall back on the exit code alone. Treat an unknown code by its exit code’s category.

Every error code maps to exactly one exit code:

Code Exit Retryable Raised when
internal 1 no Anything unclassified: a local file that cannot be read or written, or an HTTP answer ctx does not expect (such as a 409 outside a push, or a redirect it cannot follow).
usage 3 no Bad flags or arguments: an unknown flag, both --ours and --theirs, a malformed --at or --if-server-sha, ctx show --json=false; also an HTTP 400 other than invalid_path.
invalid_path 3 no A page path that breaks the path rules (show, resolve, push), or a 400 from the server saying so.
not_authenticated 4 no No credentials, or an empty token, for a command that talks to the server. Advice: ctx auth login.
auth_rejected 4 no HTTP 401: the token was revoked or expired.
forbidden 4 no HTTP 403: you are not a member of that repo.
not_initialized 5 no No .contexo/ directory under the root. The sync commands (status, push, pull, resolve, show, repair) all need one; ctx version does not. Advice: ctx init.
repo_not_configured 5 no No repo_id in .contexo/config.json. Advice: ctx remote set-repo <id>.
server_unresolved 5 no Your global credentials have no server_url, so ctx will not guess where to send your token. Advice: ctx auth login --server <url>.
conflict 6 no ctx push: the server refused one or more pages because someone pushed them since your base (HTTP 409). The stdout document is written too.
stale 6 no ctx resolve --if-server-sha: the server is no longer at the sha you passed.
network 7 yes The request failed in transit or timed out.
server_error 7 yes HTTP 5xx, or an answer whose body does not decode.
rate_limited 7 yes HTTP 429.
repo_not_found 8 no The server has no repo with that id. (ctx status reports remote.repo_exists: false instead of failing.)
page_not_found 8 no ctx show --at server or ctx resolve on a page the server does not have.
base_unavailable 8 no ctx show --at base and the page has no valid base snapshot. Advice: ctx repair.
sha_not_found 8 no ctx show --at <sha>: the server does not know that commit, or the page did not exist at it.
distill_required 9 no ctx push was refused by the distill guard: a pending agent session must be shared through your agent first. See ctx push.
not_resolvable 9 no ctx resolve was asked for a strategy that does not apply to the page’s state; nothing was changed.
server_unsupported 10 no The server lacks something the command needs, for example ctx show --at <sha> on a server that cannot read history.
not_implemented 10 no Only a hidden stub command in a pre-release build returns this. Advice: ctx update. No release returns it for a capability it advertises.
quota_exceeded 11 no HTTP 402: a plan limit on the hosted service.
busy 12 yes Another ctx process (often an agent’s push or pull) held .contexo/.sync/lock for longer than CONTEXO_LOCK_TIMEOUT (default 10 seconds).

The exit numbers on their own:

  • 0: success. For ctx pull, skipped pages are still success; for ctx status, a server it could not reach is reported inside the document and is still success.
  • 1: internal error, and also every failure in human mode.
  • 2: never used on purpose. It is what the Go runtime exits with on a crash, and callers treat it as internal.
  • 3: usage. 4: authentication. 5: configuration or not initialized. 6: conflict or stale. 7: network or server, retryable. 8: not found. 9: refused by a precondition, nothing changed. 10: server unsupported. 11: quota. 12: busy, retryable.
  • 130 or 143: the process was killed (SIGINT or SIGTERM), for example by Ctrl+C or by a caller’s timeout.

These codes carry a details object in the envelope:

  • conflict: paths, the pages the server refused.
  • stale: path, expected_sha, server_sha.
  • not_resolvable: path, state (the page’s state), strategy (ours or theirs).
  • server_unsupported: missing, the server capabilities that were needed, for example ["pages.at"].
  • busy: pid, host, command, started_at, describing the process that holds the lock.
  • quota_exceeded: quota_code, limit, and upgrade_url when the server sent one.
  • distill_required: pages (the concept and analysis pages in the push), sessions (the pending agent sessions: session_id, turns, updated_at, fresh), and agent_prompt, a sentence you can paste into your agent so it pushes the pages for you.

A program that drives ctx should start here. The ctx.version.v1 document tells it which contract this ctx speaks and what it can do:

Terminal window
ctx version --json
ctx version --json
{
  "schema": "ctx.version.v1",
  "contract_version": 1,
  "version": "0.7.0",
  "commit": "9f31c07",
  "date": "2026-07-28",
  "dev_build": false,
  "os": "darwin",
  "arch": "arm64",
  "capabilities": [
    "status.json",
    "status.offline",
    "show.base",
    "show.server",
    "show.sha",
    "pull.json",
    "push.json",
    "push.paths",
    "push.distill_guard",
    "resolve",
    "errors.envelope",
    "repair"
  ]
}

contract_version is an integer, 1 today. A caller built for version 1 should refuse a higher number and ask to be updated. dev_build is true for an unstamped go build.

capabilities lists what this build can do, in the contract’s order, never sorted. A ctx 0.7.0 release lists all of these:

Capability Means Required by the VS Code extension
status.json ctx status --json yes
status.offline ctx status --json --offline yes
show.base ctx show --at base yes
show.server ctx show --at server yes
show.sha ctx show --at <sha> no, optional
pull.json ctx pull --json, the safe pull yes
push.json ctx push --json yes
push.paths ctx push with named paths yes
push.distill_guard push refuses with distill_required yes
resolve ctx resolve yes
errors.envelope the ctx.error.v1 envelope yes
repair ctx repair no, optional and ctx-only

The first eleven are the version 1 list; repair comes after them. A caller should check for the capabilities it needs rather than compare version numbers, and treat a missing required capability as “this ctx is too old: run ctx update”.

The server has capabilities of its own: pages.at (reading a page at a past commit), push.expect_absent (refusing a create when the page already exists) and write.path_sha (an unchanged write reports the page’s own last commit). ctx learns them from the headers of responses it already gets, and never makes a request just to ask. ctx status --json reports them under server.capabilities and adds a server_outdated warning when one is missing.

Every request ctx makes to the server carries an X-Contexo-Client header, which the server records in the repo’s activity so the team can see what synced. The value is, in order of precedence:

  1. the CONTEXO_CLIENT environment variable, when set;
  2. the name the running program gave itself: under ctx mcp, the agent’s MCP client name, or ctx-mcp when the agent sent none;
  3. ctx-cli.

The VS Code extension runs ctx with CONTEXO_CLIENT=contexo-vscode/<version>. A value that is empty or contains control characters is ignored. Set it in your own scripts to tell their syncs apart:

Terminal window
CONTEXO_CLIENT=nightly-ci/1 ctx pull --json

A command run outside a project. With --json, the failure is the envelope on stderr and exit 5:

Terminal window
ctx status --json --root ~/scratch
ctx status --json --root ~/scratch
{"schema":"ctx.error.v1","exit_code":5,"error":{"code":"not_initialized","message":"status: no .contexo/ directory in ~/scratch","advice":["ctx init"],"retryable":false}}
(exit 5)

A push that lost a race: a teammate’s push landed between this push’s listing and its send. One page was created; two came back as conflicts. stdout holds the push document, stderr ends with the conflict envelope, and the advice lists the ctx resolve commands:

Terminal window
ctx push --json
ctx push --json (two conflicts)
{
  "schema": "ctx.push.v1",
  "dry_run": false,
  "message": "ctx push (3 pages)",
  "server_url": "https://contexo.example.com",
  "repo_id": "acme-api",
  "new_head": "e5a3b9c807162534435261708f9e0d1c2b3a4958",
  "pushed": [
    {
      "path": "wiki/entities/invoice-service.md",
      "kind": "create",
      "sha": "e5a3b9c807162534435261708f9e0d1c2b3a4958"
    }
  ],
  "conflicts": [
    {
      "path": "wiki/concepts/stripe-subscription.md",
      "kind": "both_modified",
      "base_sha": "d2f0e617a3b5c7d9e1f2031425364758697a8b9c",
      "server_sha": "f0c47d2196385a7b4c3d2e1f0a9b8c7d6e5f4a3b"
    },
    {
      "path": "wiki/entities/billing-worker.md",
      "kind": "both_modified",
      "base_sha": "c1d8a3f50e2b4c6d8f90a1b2c3d4e5f607182930",
      "server_sha": "3b8e1a0947c2f6d85e90a1b2c3d4e5f60718293a"
    }
  ],
  "skipped": [],
  "distill": {
    "required": false,
    "bypassed": false
  },
  "warnings": []
}
{"schema":"ctx.error.v1","exit_code":6,"error":{"code":"conflict","message":"push: 2 conflict(s) remain; other pages were pushed","advice":["ctx resolve wiki/concepts/stripe-subscription.md --ours","ctx resolve wiki/concepts/stripe-subscription.md --theirs","ctx resolve wiki/entities/billing-worker.md --ours","ctx resolve wiki/entities/billing-worker.md --theirs"],"retryable":false,"http_status":409,"details":{"paths":["wiki/concepts/stripe-subscription.md","wiki/entities/billing-worker.md"]}}}
(exit 6)

The distill guard refusing a push. The envelope’s details.agent_prompt is the sentence to hand to your agent:

Terminal window
ctx push --json
ctx push --json (refused by the distill guard)
{"schema":"ctx.error.v1","exit_code":9,"error":{"code":"distill_required","message":"push: 1 un-distilled agent session is pending; share these pages through your agent so the decision record is captured","advice":[],"retryable":false,"details":{"pages":["wiki/concepts/stripe-subscription.md"],"sessions":[{"session_id":"docs-demo-session","turns":1,"updated_at":"2026-07-28T09:14:07Z","fresh":true}],"agent_prompt":"Push my pending Contexo changes with the ctx_push tool (wiki/concepts/stripe-subscription.md). Distill the pending session into a source page first when ctx_push asks you to."}}}
(exit 9)

A successful status: one document, nothing else.

Terminal window
ctx status --json
ctx status --json
{
  "schema": "ctx.status.v1",
  "generated_at": "2026-07-28T09:14:07Z",
  "offline": false,
  "root": "~/projects/acme-api",
  "contexo_dir": "~/projects/acme-api/.contexo",
  "project": {
    "repo_id": "acme-api",
    "config_server_url": "https://contexo.example.com",
    "dashboard_url": "",
    "last_pull_sha": "a4b2c1d3e5f60718293a4b5c6d7e8f9012345678"
  },
  "server": {
    "url": "https://contexo.example.com",
    "url_source": "credentials",
    "version": "dev",
    "capabilities_known": true,
    "capabilities": [
      "pages.at",
      "push.expect_absent",
      "write.path_sha"
    ]
  },
  "auth": {
    "state": "authenticated",
    "credentials_source": "project",
    "token_kind": "legacy",
    "user_name": "dev",
    "user_email": "dev@example.com"
  },
  "remote": {
    "source": "live",
    "fetched_at": "2026-07-28T09:14:07Z",
    "repo_exists": true,
    "head": "b7e9f204c8a1d35e6f70819a2b3c4d5e6f708192",
    "page_count": 1
  },
  "counts": {
    "unchanged": 0,
    "modified": 1,
    "new": 1,
    "incoming": 0,
    "incoming_new": 0,
    "conflict": 0,
    "deleted_local": 0,
    "invalid": 0,
    "unverified": 0,
    "ahead": 2,
    "behind": 0
  },
  "pages": [
    {
      "path": "wiki/concepts/stripe-subscription.md",
      "state": "modified",
      "type": "concept",
      "slug": "stripe-subscription",
      "tags": [
        "stripe",
        "billing",
        "proration"
      ],
      "author": "dev",
      "agent": "claude",
      "updated": "2026-07-24T16:31:00Z",
      "reasoning_summary": "How trials, proration and webhooks fit together in acme-api.",
      "local": {
        "file": "wiki/concepts/stripe-subscription.md",
        "hash": "sha256:d2d5406cf1ff46eee2577caf20c3509270c93ea29c4a765fe3227446cbe55cde"
      },
      "base": {
        "sha": "b7e9f204c8a1d35e6f70819a2b3c4d5e6f708192",
        "hash": "sha256:460dd05a3b5e41a66eff13f4af9ff5e88390e98b55645cd9ba093768d22423e5",
        "synced_at": "2026-07-28T09:14:07Z",
        "stale": false
      },
      "remote": {
        "sha": "b7e9f204c8a1d35e6f70819a2b3c4d5e6f708192",
        "author": "dev",
        "email": "dev@example.com",
        "time": "2026-07-28T09:14:07Z",
        "message": "ctx push (1 pages)"
      },
      "notes": []
    },
    {
      "path": "wiki/entities/billing-worker.md",
      "state": "new",
      "type": "entity",
      "slug": "billing-worker",
      "tags": [
        "billing"
      ],
      "author": "dev",
      "agent": "claude",
      "updated": "2026-07-25T11:20:00Z",
      "local": {
        "file": "wiki/entities/billing-worker.md",
        "hash": "sha256:8bae15f3fa732a04b87f2c50d5e25834e1f0009bce68dde191409c51a947b828"
      },
      "notes": []
    }
  ],
  "captures": {
    "pending": 0,
    "fresh": 0,
    "blocks_push": false,
    "sessions": []
  },
  "warnings": []
}

Global credentials with no server_url fail the same way in every command that talks to the server. This envelope comes from the real binary:

{"schema":"ctx.error.v1","exit_code":5,"error":{"code":"server_unresolved","message":"pull: server URL unresolved: the credentials have no server_url","advice":["ctx auth login --server <url>"],"retryable":false}}

ctx status --json does not fail in that case: it reports server_unresolved under remote.error and exits 0.