---
name: replit-publishing
description: Publish and diagnose a Replit deployment with the full browser cookie set, persisted-query GraphQL, terminal-state polling, and honest release gates. Covers the open-source replit-deploy CLI, its currently verified limitations, exactly what to ask the user for, and where to store it safely.
version: 1.0.1
updated: 2026-08-21
---

# Replit Publishing

**Publishers are autonomous.** If a human has to click Republish, the loop is broken. This skill is
how an agent ships a Repl to production by itself, and how to diagnose it when that fails.

Source: **https://github.com/stevemoraco/replit-deploy** (MIT). At source commit `de3d069`, install
from a checkout because the pipe installer incorrectly depends on `BASH_SOURCE[0]`:

```bash
git clone https://github.com/stevemoraco/replit-deploy.git
bash replit-deploy/scripts/install.sh
```

Do not advertise the `curl .../scripts/install.sh | sh` form until that repository fixes it.

Everything below was learned by publishing a real production Repl after five consecutive failures.
The failure modes are specific and they stack — each one only becomes visible after you fix the one
in front of it.

---

## 1. What to ask the user for — get this exactly right

You need **the entire `cookie:` request header** from a logged-in Replit browser session. Ask for it
like this:

> Open replit.com while logged in → DevTools → **Network** tab → click any request to
> `replit.com/graphql` → **Headers** panel → *Request Headers* → find the line starting `cookie:` →
> copy **the whole value**, all of it → paste it here.

### The mistake that costs a day

**`connect.sid` alone does not work.** It looks like the session token — it is a JWT, it decodes, it
has an expiry — and every instinct says it is the credential. Send only that and every request
bounces to `/login`, including in a real headless browser with the cookie injected.

A working header is **~60 cookies, ~7,000 characters**. Replit's edge checks more than `connect.sid`.
So: if you extracted one cookie out of a paste, go back and take the whole line.

### Do NOT ask for the Payload tab

DevTools splits one request across sub-tabs. People often paste the **Payload** tab (the JSON body)
when asked for "the request". You want **Headers → Request Headers → cookie**. If what you received
contains `operationName` / `variables` / `extensions`, that is the Payload — ask again, naming the
Headers panel explicitly.

### Expiry

The `connect.sid` JWT carries an `exp` claim — decode it and tell the user the real date rather than
discovering expiry mid-deploy. Typical life is ~2 weeks. `replit-deploy check` does this for you.
Sessions can also be invalidated server-side early (a logout elsewhere), so a non-expired JWT is not
proof of a live session — the only proof is a request that succeeds.

---

## 2. Where to store it

| Location | Purpose |
|---|---|
| `~/.config/replit-deploy/session-full.cookie` | laptop, **mode 600** |
| `<workspace>/.replit-session.cookie` | on a box, **mode 600**, and **gitignored** |
| `$REPLIT_COOKIE` / `$REPLIT_COOKIE_FILE` | env override for CI-ish contexts |

Rules that matter:

- **Never `/tmp`.** Many boxes wipe `/tmp` on a cycle (Replit does, roughly every 35 minutes). A
  credential stored there vanishes and the next agent re-asks the user for something they already
  gave. Store it in a durable, gitignored path.
- **Never commit it.** Add the filename to `.gitignore` in the same action that writes it.
- **Write the storage path into `CLAUDE.md` / `AGENTS.md`.** The failure isn't losing the file, it's
  the *next* agent not knowing where to look and asking the user to paste it again. Users hate this,
  correctly.
- **Never echo it** into logs, tables, or chat.

---

## 3. Replit's GraphQL is persisted-query only

`https://replit.com/graphql` enforces Automatic Persisted Queries. This surprises everyone:

| What you send | What you get |
|---|---|
| A plain `query { ... }` string | `{"errors":[{"message":"Persisted query hash required"}]}` |
| A query + your own computed sha256 | `PersistedQueryNotFound` |
| **Only** a *registered* hash + variables | ✅ works |

You cannot register a new query. You must use hashes Replit's own client already registered, which
means **harvesting them from a real session**.

A known-good read (status/build history), verified working:

```jsonc
// POST https://replit.com/graphql   (send the FULL cookie header)
[{"operationName":"HostingDeploymentConfigRedeploy",
  "variables":{"replId":"<REPL_ID>"},
  "extensions":{"persistedQuery":{"version":1,
    "sha256Hash":"4e9a16f8eb0432bac6e687189ab7fc8c018b3efa9cc53aced67a64092d9a9e21"}}}]
```

Returns the deployment id, subdomain, and the full build activity log with statuses and timestamps.
**This is your polling primitive** — use it to watch a build to terminal `success`/`failed`.

### Harvesting hashes when you need a new operation

Drive a real browser (Playwright works) with the full cookie set, attach a request listener, perform
the action in the UI, and read `extensions.persistedQuery.sha256Hash` off the requests it fires.
Caveat learned the hard way: **the deploy *trigger* may not travel over HTTP at all** — much of the
Replit workspace talks over WebSocket, so a click can produce zero matching HTTP mutations. Reads are
easy to harvest; the trigger may not be.

Also: scraping Replit's JS bundles for hashes generally fails (cross-origin fetch restrictions).
Harvest from live requests, not from bundle text.

---

## 4. Publishing

### Current CLI limitation (verified 2026-08-21)

At source commit `de3d069`, `replit-deploy deploy` and `replit-deploy status` send plain GraphQL
query text. Current Replit rejects both with `Persisted query hash required`. `check` remains useful
for expiry validation, but do **not** claim the deploy/status/watch path is operational until the
open-source repository is updated to registered persisted queries.

The commands below are the intended interface, not a currently verified autonomous publish path:

```bash
replit-deploy check                 # decode cookie expiry, confirm session
replit-deploy deploy --repl <ID>    # trigger a build
replit-deploy status --repl <ID>    # latest build id + status
replit-deploy watch  --repl <ID>    # poll to terminal state
```

Until the CLI is repaired, trigger Republish through an authenticated browser using the complete
cookie set, capture the registered operation/hash from that real client, then poll with
`HostingDeploymentConfigRedeploy` above. If browser automation cannot reliably trigger it, stop and
report the limitation; a human click is not an autonomous success.

The Repl ID is the UUID in the workspace URL / SSH host (`b7b4a1ad-…`), not the slug.

**Always poll to a terminal state.** "Triggered" is not "published". A build that starts and fails
looks identical to a success for the first thirty seconds.

---

## 5. The failure modes — in the order they actually bite

These are real, each cost hours, and they **stack**: fixing one reveals the next.

### 5.1 A payment hold silently disables publishing
The Republish control is disabled **server-side** and the API refuses. Symptom: a "Payment failed /
delinquent payment" banner, and a button that renders but never fires. **No amount of retrying fixes
this** — the user must settle the invoice. Detect it and say so plainly rather than looping.

### 5.2 The deploy artifact omits a file the build needs
Symptom that names this one exactly: the log shows
`Running Security Scan` → `Security Scan Complete` → **failure, with no build output at all.**
The build died before your build commands ever ran. In our case `.replit` itself was excluded from
the artifact, so the builder had no config → `ENOENT`.
**Check what the artifact actually contains**, not what the workspace contains.

### 5.3 A file that changes *while* the artifact is being packed
Symptom: compilation succeeds, then the **archive step** fails. Cause: a live log (`.wave/…log`, a
running server's output) mutated mid-tar. Exclude live scratch/log paths from the deploy set.

### 5.4 The bundle is over 8 GiB
Measure the **deployed set** — walk the tree honoring `.replitignore` and sum it — not `du` on the
workspace. A 35 GB workspace was **1.73 GiB** deployed once ignore rules applied. Report the method
with the number, and never guess.

### 5.5 Your own release gate blocks the ship
A repo-side gate (ours: a "Goldfish Bowl" requiring N clean rounds across a named model panel) can
fail for reasons unrelated to the build — including being **unsatisfiable** with current capacity.
This is where judgement is required, so read §6 carefully.

---

## 6. Gates, waivers, and honesty

**Never weaken a gate to make a publish succeed.** Do not lower a threshold, do not substitute a
cheaper model into a named panel, do not mark a failing trial as passing. Fixing the ruler to fit the
result destroys the only thing the gate was for.

When a gate is genuinely unsatisfiable *and the owner has directed that publishing is autonomous*,
the defensible pattern is a **one-build owner waiver**:

1. Run every check that *can* run. Record the real result (ours: 28 PASS, 1 FAIL, named).
2. Scope the waiver to **exactly one build**, bound to that artifact — never a standing bypass.
3. **Disclose precisely** what was verified and what was not, in the committed report.
4. **Retire the waiver from source immediately after the build**, so the next build calls the
   unchanged gate.
5. Have **independent twins verify the waiver is gone and the gate is unmodified.** Ours did, at a
   named commit.

A waiver left in place is indistinguishable from a weakened gate a week later. Retirement is the
whole safety property, and it must be verified by someone other than the agent that used it.

---

## 7. Acceptance — how you know it published

**HTTP 200 proves nothing.** Error pages return 200. SPAs return 200 for every path. A "this site
does not exist" page returns 200.

Define acceptance as **a specific URL returning specific real content**, and assert the body:

```bash
curl -s https://<prod>/index.json | head -c 200   # real catalog? correct version field?
curl -s https://<prod>/index.json | wc -c         # plausible size, not an error stub
```

Ours: `data.sh/index.json` went 404 → **200 with a 17,057-byte catalog** whose `version` field
matched the shipped build. That is a publish. A 200 alone is not.

Then let **clean-context twins** confirm it against the plan — the publishing agent does not certify
its own success.

---

## 8. Checklist

- [ ] Full cookie header (~60 cookies) obtained from **Headers → Request Headers → cookie**
- [ ] Stored 600 in a durable, gitignored path; location written into `CLAUDE.md`/`AGENTS.md`
- [ ] Expiry decoded and reported
- [ ] Session proven live by a real request, not by a non-expired JWT
- [ ] Build passes; tests pass
- [ ] Deployed-set size measured under limit, with the method stated
- [ ] Deploy artifact contains every file the build needs (esp. `.replit`)
- [ ] Live logs/scratch excluded so the archive step can't race
- [ ] Publish **triggered by CLI**, polled to terminal state
- [ ] Acceptance asserts real body content, not a status code
- [ ] Any waiver scoped to one build, disclosed, retired, and twin-verified as retired

See also: [[honest-grading]], [[ask-the-twins]], [[show-real-errors]], [[safe-data-ops]].
