Skip to content

ctx push

ctx push sends the pages you changed or created since the last sync to your project’s repo on the Contexo server: all of them, or only the ones you name. A page a teammate pushed in the meantime is never overwritten; it is reported as a conflict for you to settle.

At the end of a working session, once pages have been written or edited and you want the rest of the team’s agents to start from them. Most pushes in practice come from the agent calling the ctx_push MCP tool. The CLI is what you use when you want to look at a batch before it leaves your machine, push just one page, or push from a script.

A few things are worth knowing up front:

  • Only changed pages are sent. Push sends pages in the modified and new states (see ctx status) and nothing else. Pages you have not touched are not re-uploaded.
  • It never deletes anything on the server. A page you delete locally is deleted_local and is skipped.
  • It never overwrites a newer server version. If someone pushed a page since your base, the server refuses that page and push records a conflict. The other pages still go through.
  • It may refuse to push at all when an agent session that has not been distilled is still pending. See The distill guard.
Terminal window
ctx push
ctx push --dry-run
ctx push --yes -m "stripe webhook decisions"
ctx push wiki/concepts/stripe-subscription.md
ctx push --type concept --show-diff
ctx push --json -m "nightly" -- wiki/concepts/stripe-subscription.md
FlagTypeDefaultDescription
--dry-runboolfalseshow what would be pushed without sending or writing anything
--fallback-serverboolfalse(planned) route decision-record distillation to the Contexo server; currently errors
--featurestringpush only pages tagged with this feature/tag
--jsonboolfalseemit the ctx.push.v1 JSON document; never prompts or previews (implies --yes and --no-preview)
--message, -mstringcommit message (default: 'ctx push (N pages)')
--no-distillboolfalsepush even when an un-distilled agent session is pending (bypasses the decision-record guard; use deliberately)
--no-previewboolfalseskip the pre-push preview entirely (faster; loses the heads-up about what changes)
--show-diffboolfalseprint the full per-section diff in the preview, not just a one-line summary
--tagstringpush only pages with this tag (alias of --feature)
--typestringpush only pages of this type (concept|entity|source|analysis)
--yes, -yboolfalseskip the pre-push confirmation prompt (required for non-interactive use when editing existing pages)

Paths. Name pages to push only those: paths relative to .contexo/, such as wiki/concepts/stripe-subscription.md (a leading .contexo/, or an absolute path inside .contexo/, also works). You may put -- before them; the VS Code extension always does. A named page that is not modified or new is not sent: it is listed as skipped with its state (unchanged, incoming, conflict, …), and a path ctx has never heard of is skipped as not_found. Named paths cannot be combined with --feature, --tag or --type (a usage error).

Filters. --feature and --tag are the same filter under two names, matched case-insensitively against each page’s tags: list; if you pass both, --feature wins. --type matches concept, entity, source or analysis, also case-insensitively. A filter that matches nothing simply pushes nothing.

--dry-run goes through everything up to the send: it lists the server, classifies every page, runs the distill guard and shows what would be pushed. It sends nothing and writes nothing, not even the automatic base repair. In human mode it still shows the preview.

--yes matters only in human mode, when the preview finds at least one [EDIT] row. Without it, push asks Proceed? [y/N] at a terminal, and refuses when stdin is not a terminal. A batch of only new pages never asks.

--no-preview skips the preview, and with it the server reads and the prompt. Combined with --dry-run it prints a bare Would push N page(s): list with each page’s kind (create or update).

--show-diff prints the full per-section diff under each [EDIT] row, in the format of ctx diff.

--json prints the ctx.push.v1 document instead of the human report, and never previews or prompts. --yes is accepted alongside it and does nothing. See JSON output and exit codes.

--no-distill pushes even when the distill guard would refuse. See The distill guard.

-m, --message sets the commit message; the default is ctx push (N pages).

--fallback-server is a placeholder for server-side distillation. It is not implemented and fails immediately, before any network call.

ctx push also accepts the global flag:

FlagTypeDefaultDescription
--rootstringproject root directory (default: current directory)

In human mode, before anything is sent, push shows the batch, one row per page:

Row Meaning
[NEW] The page does not exist on the server yet.
[EDIT] The page exists on the server and your version differs. The summary in brackets lists what changed.
[SAME] The page exists and the section diff finds no difference.
[?] The server copy could not be read. The push itself still goes ahead.

The [EDIT] summary is section-aware: frontmatter when any frontmatter key changed, then + Heading for an added section, - Heading for a removed one, ~ Heading for a modified one and ~> for a renamed one, capped at four entries with a +N more tail.

Building the preview reads the server copy of each page the batch would update, at most 4 at a time; new pages need no read. The preview runs after the distill guard and before the send, and if you answer anything but y or yes at the prompt, push prints Aborted., exits 0 and has sent nothing.

Each page goes to the server with the sha of its base, the version you last synced. If someone else pushed that page since, the server refuses it (HTTP 409) and commits the rest of the batch.

For every refused page, push records a conflict under .contexo/.sync/ (with the server’s current content), and leaves your base and recorded sha alone, so the page shows as conflict in ctx status, online or offline, until you settle it. A later push skips it with the reason conflict; it does not try again. The way forward is ctx resolve: --ours to publish your version on top of theirs (then push again), or --theirs to take theirs.

A page you created that has never been synced has no base. Push sends it as a create that the server must refuse if the path already exists. If a teammate created the same page first, you get a conflict of kind both_added instead of silently overwriting their page.

In human mode the conflicts are listed as <path>: server=<sha> base=<sha> (base=none for both_added), followed by the ctx resolve commands to run, and push exits 1. With --json, stdout still holds the push document, the envelope says conflict and the exit code is 6. See the example below.

Push refuses to send your changes when both of these are true:

  1. the pages it would send include a concept or analysis page, and
  2. an agent session captured in this project has not been distilled yet: a buffer under .contexo/raw/sessions/_pending/ holds at least one recorded turn and was written to within the last 6 hours.

Concept and analysis pages record decisions. When the agent session behind them is still pending, the reasons for those decisions, the options considered and why they were rejected, exist only in that session. Pushing through your agent lets it first write a source page, the decision record, from the session and link it to the pages. So push stops and tells you to share the pages through your agent instead.

In human mode the refusal prints the sentence to paste into your agent, the bypass hint, and exits 1:

ctx push --yes (refused by the distill guard)
Share via your agent: 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.
ctx push --no-distill bypasses this guard; use it only deliberately.
✖ push: 1 un-distilled agent session is pending; share these pages through your agent so the decision record is captured
(exit 1)

With --json it is a distill_required envelope, exit 9. Its details list the pages, the pending sessions and the agent_prompt sentence:

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)

The guard runs on every push, before the preview and before anything is sent. A pending buffer with no turns in it, or one older than 6 hours, does not trigger it, and neither does a batch of only entity and source pages.

To push anyway, pass --no-distill or set CONTEXO_DISTILL_DISABLE=1. Either way the push document records distill.bypassed: true. Use it deliberately. The VS Code extension never bypasses the guard: it offers Share via your agent, which copies details.agent_prompt for you to paste into your agent’s chat, and it removes CONTEXO_DISTILL_DISABLE from the environment it runs ctx with.

Look before you leap. --dry-run shows the preview and stops:

Terminal window
ctx push --dry-run
ctx push --dry-run
About to push 1 page(s) to acme-api:
  [NEW]   wiki/concepts/stripe-subscription.md
(dry-run; no changes sent)

A first push of a brand-new page. Nothing exists on the server, so there is no prompt:

Terminal window
ctx push --yes
ctx push --yes
About to push 1 page(s) to acme-api:
  [NEW]   wiki/concepts/stripe-subscription.md
Pushed 1 page(s); HEAD=a4b2c1d3

The same kind of push as JSON, with one edited and one new page:

Terminal window
ctx push --json -m "Add the billing worker"
ctx push --json -m "Add the billing worker"
{
  "schema": "ctx.push.v1",
  "dry_run": false,
  "message": "Add the billing worker",
  "server_url": "https://contexo.example.com",
  "repo_id": "acme-api",
  "new_head": "c1d8a3f50e2b4c6d8f90a1b2c3d4e5f607182930",
  "pushed": [
    {
      "path": "wiki/concepts/stripe-subscription.md",
      "kind": "update",
      "sha": "d2f0e617a3b5c7d9e1f2031425364758697a8b9c"
    },
    {
      "path": "wiki/entities/billing-worker.md",
      "kind": "create",
      "sha": "c1d8a3f50e2b4c6d8f90a1b2c3d4e5f607182930"
    }
  ],
  "conflicts": [],
  "skipped": [],
  "distill": {
    "required": false,
    "bypassed": false
  },
  "warnings": []
}

A push that lost a race: a teammate’s push landed between this push’s listing and its send. The new page was created; the two edited pages came back as both_modified conflicts. stdout holds the document; stderr ends with the conflict envelope, exit 6:

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)
  1. Checks that .contexo/ exists, then resolves the server, token and repo id. The server is the one your credentials belong to: their server_url wins; project credentials without one use server_url from .contexo/config.json; global credentials without one fail with server_unresolved (exit 5 with --json) rather than send your token to a URL taken from the project. If config.json names a different server, push still uses the credentials’ server; ctx status shows the mismatch.
  2. Takes .contexo/.sync/lock, waiting up to CONTEXO_LOCK_TIMEOUT (default 10 seconds) if another ctx process, often an agent’s push or pull, holds it. After that it fails with busy (exit 12).
  3. Lists the repo’s pages once. A repo the server does not have yet counts as empty.
  4. Unless --dry-run, repairs missing or stale base snapshots over that listing, like ctx repair.
  5. Classifies every page against its base and the listing, taking recorded conflicts into account, and builds the batch: modified and new pages, filtered by your paths or filters. conflict, invalid, deleted_local and unverified pages are listed as skipped with their reason.
  6. Runs the distill guard over the batch.
  7. In human mode, shows the preview and asks if needed. A dry run stops here.
  8. Sends the batch in one push request. Each page is sent in normalized form with its base sha as parent; a page with no base is sent as create-only.
  9. Records the answer. For each page the server accepted, the exact bytes sent become its new base and its recorded sha moves to the new commit; for each refused page, a conflict is recorded instead. Then it prints Pushed N page(s); HEAD=<sha>.

Push writes only under .contexo/.sync/ (base snapshots, bases.json, conflicts.json and state.json). It never changes your pages, does not regenerate index.md and tags.md (that is ctx pull), and touches nothing in your project’s own git repo.

push: not authenticated, with try ctx auth login: no credentials. Run ctx login. Exit 4 with --json.

push: server URL unresolved: the credentials have no server_url, with try ctx auth login --server <url>: your global credentials do not say which server they belong to. Exit 5 with --json.

push: repo not configured, with try ctx remote set-repo <id>: no repo_id in .contexo/config.json. See ctx remote. Exit 5 with --json.

no .contexo/ directory in <root>: you are not in a project. cd to its root or pass --root. Exit 5 with --json.

push: refusing to alter existing pages in non-interactive mode; pass --yes to confirm: the preview found [EDIT] rows and stdin is not a terminal. Add --yes once you are sure, or use --json, which never prompts.

push: N conflict(s) remain; other pages were pushed: see Conflicts. Exit 6 with --json.

The distill guard refusal: see The distill guard. Exit 9 with --json.

busy: another ctx process held the lock past CONTEXO_LOCK_TIMEOUT. Exit 12 with --json; retry when it finishes.

quota_exceeded: the hosted server refused because of a plan limit (HTTP 402). Exit 11 with --json; the envelope’s details.upgrade_url says where to upgrade.

push: --fallback-server: server-side distillation not implemented yet ...: drop the flag.

Without --json, every failure exits 1.