# Open a pull request Turn finished agent work into a reviewable draft pull request, then merge exactly the head that was reviewed, including when branch protection makes you wait. Source: https://docs.cloud.vampikez.fun/guides/open-a-pull-request/ After this page you can read what an agent changed, open a pull request from it, wait for review and branch policy, and merge exactly the commit that was reviewed — or fail loudly instead of merging something else. ## The shape of the flow Publishing is two separate durable commands, not one. Opening a pull request and integrating its reviewed head have different authority, different retry behavior and different failure modes, so they are different resources. ``` GET /v1/sessions/{sessionId}/repository/review read the diff, get a revision PUT /v1/sessions/{sessionId}/publications/{publicationId} open (or update) the pull request GET /v1/sessions/{sessionId}/publications/{publicationId} watch it reach `succeeded` PUT …/publications/{publicationId}/merges/{mergeId} merge that exact head GET …/publications/{publicationId}/merges/{mergeId} watch it reach `succeeded` GET …/publications/{publicationId}/merges page every merge, if you lost an id ``` Both `PUT`s take an identifier **you** mint, so a timeout or a retry addresses the same command instead of creating a second pull request. The prerequisites are a session whose `source` is a live GitHub grant, and an installation holding `wamp.cloud.publications:create` and `wamp.cloud.publications:merge`. If the work should land on the base branch without a pull request at all, that is a different delivery of the same command, on the same grant: see [Push directly to a branch](/guides/push-directly-to-a-branch/). ## Read the review first The review is not optional decoration. It produces the `revision` token that the publish call requires, and that token is how the server knows you published what you actually looked at. ```bash curl -sS "$WAMP_API/v1/sessions/$SESSION_ID/repository/review" \ -H "Authorization: Bearer $WAMP_TOKEN" ``` ```ts check export async function readReview(cloud: WampCloud, sessionId: string) { return cloud.reviewRepository(sessionId); } ``` ```json { "review": { "branch": "wamp/publications-v1/cloud-3f7d4f4c-2b6a-4a2e-9c1a-1f2b3c-6b031ca6", "baseRef": "origin/main", "revision": "4f9c0b2e7a1d6c3b8e5f0a2d4c6b8e1f3a5c7d9b0e2f4a6c8b0d2e4f6a8c0b2d", "files": [ { "path": "src/invoices/list.ts", "status": "modified", "additions": 42, "deletions": 6, "binary": false, "patch": "@@ -1,6 +1,42 @@\n…" } ], "additions": 42, "deletions": 6, "patchTruncated": false } } ``` The contract guarantees `revision` and `files`; the fields above are what the server sends today, and it may add more. | Field | Meaning | |---|---| | `revision` | 64 hex characters. A SHA-256 over the reviewed tree — **not** a git commit SHA. | | `branch` | The working branch inside the sandbox. | | `baseRef` | What the diff is against. | | `files[].status` | `added`, `modified`, `deleted` or `renamed`. `oldPath` is present on a rename. | | `files[].patch` | Omitted for binary files and when the patch budget is exhausted. | | `patchTruncated` | `true` when patches were dropped to stay inside the response budget. The file list is still complete. | Two different 64- and 40-character hex strings travel through this flow. `revision` (64 hex) identifies the reviewed tree and is what you send. `commitSha` and `mergedCommitSha` (40 hex) are git object ids and are what the server reports back. Sending one where the other belongs is a `400`. If the review is refused, this endpoint reports repository-level problems directly: `409 no_changes` when the agent changed nothing, `409 not_repository` when the session has no checkout, `413 review_too_large`, and `409 review_changed` when the tree moved while the review was being computed. Those responses use a `{ "success": false, "error": "" }` body rather than the standard error shape — see [Errors](/reference/errors/#repository-and-github-errors). Both the review and the publish need a live workspace, and a sandbox lease has its own deadline. Coming back to review or publish after that deadline is `409 cloud_workspace_expired`. That is recoverable rather than fatal: `PUT /v1/sessions/{sessionId}/workspace` takes no body, needs `wamp.cloud.turns:submit`, is idempotent on the postcondition, and answers `200` with the session once the workspace is back. See [Sandboxes](/concepts/sandboxes/). ## Open the pull request ```bash PUBLICATION_ID=$(uuidgen | tr 'A-Z' 'a-z') curl -sS -X PUT \ "$WAMP_API/v1/sessions/$SESSION_ID/publications/$PUBLICATION_ID" \ -H "Authorization: Bearer $WAMP_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "revision": "4f9c0b2e7a1d6c3b8e5f0a2d4c6b8e1f3a5c7d9b0e2f4a6c8b0d2e4f6a8c0b2d", "title": "Paginate the invoices endpoint", "body": "Adds cursor pagination.\n\nOpened by an agent run.", "draft": true }' ``` ```ts check export async function openPullRequest( cloud: WampCloud, sessionId: string, review: CloudRepositoryReview, ) { const publicationId = randomUUID(); const publication = await cloud.publishRepository(sessionId, publicationId, { revision: review.revision, // the token the review handed you title: 'Paginate the invoices endpoint', body: 'Adds cursor pagination.\n\nOpened by an agent run.', draft: true, }); return publication; } ``` | Field | Required | Constraint | |---|---|---| | `revision` | yes | Exactly `^[a-f0-9]{64}$`, from the review | | `title` | yes | 1–256 characters, no CR or LF | | `body` | yes | Up to 65 536 characters and 65 536 UTF-8 bytes. May be `""` | | `draft` | no | Boolean, **defaults to `true`**. Omit it entirely when `delivery` is `direct_branch` — direct delivery opens no pull request, so sending the field at all, with either value, is `400 invalid_request` | `draft: true` is the default deliberately: an agent's work arrives as a draft unless you decide otherwise. A draft pull request cannot be merged, so if you intend to merge in the same flow, publish with `draft: false`. If you already published a draft, you are not stuck. A session always publishes to the same branch, so a second publication updates the same pull request — and publishing again with `draft: false` takes it out of draft. That is the two-step route to a merge: republish as ready, wait for `succeeded`, then merge with a new merge id. Merging does not clear a draft on its own. The response is `201` the first time and `200` on replay, both with a `Location` header and the publication resource: ```json { "publication": { "id": "…", "sessionId": "…", "status": "admitted", "revision": "4f9c…", "title": "Paginate the invoices endpoint", "createdAt": "2026-08-11T09:12:00.000Z", "updatedAt": "2026-08-11T09:12:00.000Z" } } ``` `admitted` means the command is durable, not that a pull request exists. The server then commits, pushes and calls GitHub in the background: ``` admitted ──▶ prepared ──▶ attempted ──┬──▶ succeeded └──▶ failed ``` Only `succeeded` and `failed` are terminal, and `result` appears only on success: ```json { "publication": { "status": "succeeded", "result": { "delivery": "pull_request", "branch": "wamp/publications-v1/cloud-3f7d4f4c-2b6a-4a2e-9c1a-1f2b3c-6b031ca6", "commitSha": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d", "pullRequest": { "number": 412, "url": "https://github.com/acme/payments-api/pull/412", "draft": true, "reused": false } }, "completedAt": "2026-08-11T09:12:07.512Z" } } ``` `pullRequest.reused: true` means an existing pull request for that branch was updated rather than a new one opened. Treat it as normal — it is what makes a second publication from the same session idempotent at the GitHub level rather than spamming the repository. You can watch for the terminal state either by polling `GET …/publications/{publicationId}` or by following the session's event log, which carries `wamp.publication.created` on success and `wamp.publication.failed` (with `data.errorCode`) on failure. Following events is cheaper if you are already following the run — see [Follow a run live](/guides/follow-a-run/). A session may have at most one non-terminal publication. A second concurrent publish is `409 cloud_publication_in_progress`. Replaying the same publication id with a **different** body is `409 cloud_publication_conflict` — the server hashes the request, so retries must be byte-identical. ## Merge exactly what was reviewed ```bash MERGE_ID=$(uuidgen | tr 'A-Z' 'a-z') curl -sS -X PUT \ "$WAMP_API/v1/sessions/$SESSION_ID/publications/$PUBLICATION_ID/merges/$MERGE_ID" \ -H "Authorization: Bearer $WAMP_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "strategy": "squash", "mode": "when_ready" }' ``` ```ts check export async function mergeWhenReady( cloud: WampCloud, sessionId: string, publicationId: string, ) { const mergeId = randomUUID(); return cloud.mergePublication(sessionId, publicationId, mergeId, { strategy: 'squash', mode: 'when_ready', }); } ``` | Field | Default | Values | |---|---|---| | `strategy` | `squash` | `merge`, `squash`, `rebase` | | `mode` | `now` | `now`, `when_ready` | | `commitTitle` | — | 1–256 characters, no CR or LF | | `commitMessage` | — | Up to 65 536 characters and 65 536 UTF-8 bytes | Notice what is **not** in that body: the head to merge. The server takes `expectedHeadSha` from the publication and merges that exact commit. If someone pushed to the pull request after your review, the merge fails rather than integrating commits nobody looked at. This is the single most important property of the endpoint, and it is not configurable. ``` admitted ──▶ attempted ──┬──▶ succeeded └──▶ waiting ────┤ (mode: when_ready) └──▶ failed ``` ```json { "merge": { "id": "…", "publicationId": "…", "status": "succeeded", "strategy": "squash", "mode": "when_ready", "pullRequestNumber": 412, "expectedHeadSha": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d", "mergedCommitSha": "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432", "attempts": 2, "completedAt": "2026-08-11T09:31:44.108Z" } } ``` `mergedCommitSha` is present exactly when `status` is `succeeded` — a database constraint guarantees the equivalence, so its presence is a sound success test. At most one merge per publication can ever succeed, so a duplicate merge attempt cannot double-integrate. ## Waiting on branch policy `mode: "now"` means attempt once and fail if GitHub says the pull request is not mergeable. That is the right choice when a human is watching your UI. `mode: "when_ready"` is for the common real case: required status checks are still running, or a review is outstanding. The merge parks in `waiting`, and the server re-attempts roughly every 30 seconds until GitHub accepts it. While it waits: - `status` is `waiting` and `lastError.code` is `merge_not_ready`. - `attempts` increases. There is **no attempt cap** — a merge can wait as long as the pull request stays open and unchanged. - `completedAt` is absent. That means your side owns the timeout. Decide how long a merge may wait (your own SLA, not the server's), and if it elapses, tell the customer and stop polling. There is no endpoint that cancels a waiting merge; what ends it is the pull request itself changing — a push moves the head and the merge fails, and closing the pull request fails it too. Polling never answers `409 cloud_publication_merge_not_ready`: a `GET` on a waiting merge is a `200` whose `status` is `waiting`. That code comes from the `PUT` that opens a merge, and it means the publication you addressed is not a succeeded pull request yet — read the publication before minting a merge id. ## When a merge is rejected A `failed` merge carries a diagnostic code: ```json { "merge": { "status": "failed", "lastError": { "code": "pull_request_changed" }, "attempts": 1 } } ``` | `lastError.code` | What happened | What to do | |---|---|---| | `pull_request_changed` | The head moved after the publication recorded it. | Re-review, publish again to update the pull request, then merge the new head with a **new** merge id. | | `pull_request_closed` | Someone closed it. | Nothing to merge. Surface it and stop. | | `merge_not_ready` (with `mode: now`) | Checks pending, review missing, or the pull request is still a draft. | For a draft: republish with `draft: false`, then merge with a new merge id. Otherwise retry with `mode: when_ready`. | | `merge_not_allowed` | GitHub refused the merge for this actor or this strategy — commonly branch protection, or a strategy the repository disallows. | Change `strategy`, or ask the repository administrator. Retrying unchanged will not help. | | `repository_read_only` | The GitHub App installation can no longer write the repository — its `contents` permission was reduced, or the repository left the installation. | An administrator restores it on GitHub; see [Connect a repository](/guides/connect-a-repository/#when-a-grant-is-replaced). | | `repository_not_found` | The repository is gone or no longer reachable through this grant — GitHub answered `404` or `410`. | Permanent for this grant. Ask the administrator to re-grant. | | `github_rate_limited` | GitHub is throttling the server. | Retryable, and the server does it: it honors the wait GitHub asked for in `retryAfterMs` and re-attempts on its own. | | `github_request_refused` | GitHub refused permanently — organization policy, unsatisfied SAML SSO authorization, or a missing App permission. | Retrying is waste. Surface it to whoever administers the organization. | | `github_unavailable` | GitHub was unreachable or erroring. | Transient. The server retries this itself; it does not fail the merge. | Other codes can appear as the failure surface grows. Treat an unrecognized code as a permanent failure of that merge, report it verbatim to whoever can act on it, and do not silently retry — a merge that failed for an unknown reason is exactly the case where guessing is expensive. Because the merge id is yours, retrying the *same* command is always safe: the same id with the same body returns the existing merge rather than starting a second one. To merge a genuinely different head after a re-publish, mint a new merge id. The obvious one is replaying an existing merge id with a different body. The other is minting a **new** merge id while another merge for the same publication is `admitted`, `attempted`, `waiting` or `succeeded` — a publication admits at most one live merge, and a second id for it is that same `409`. Minting a new id after a failure works precisely because `failed` is not in that blocking set. If you lost a merge id — a crash between minting it and recording it, say — you are not stuck with an unaddressable command. `GET …/publications/{publicationId}/merges` pages every merge for the publication, failed ones included, newest first on a keyset over `(createdAt, id)`, with `cursor` and a `limit` of 1–100 that defaults to 50. That list is the way back to the id. ## Related - [Publications operations](/api/operations/tags/publications/) — the generated endpoint reference, including every documented response. - [Connect a repository](/guides/connect-a-repository/) — grants, and what holding one authorizes. - [Events reference](/reference/events/) — `wamp.publication.*` payloads. - [Errors](/reference/errors/) — the `409` conflict semantics behind both `PUT`s.