Skip to content

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.

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 PUTs 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.

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.

Terminal window
curl -sS "$WAMP_API/v1/sessions/$SESSION_ID/repository/review" \
-H "Authorization: Bearer $WAMP_TOKEN"
{
"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.

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": "<code>" } body rather than the standard error shape — see 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.

Terminal window
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
}'
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:

{
"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:

{
"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.

Terminal window
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" }'
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
{
"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.

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.

A failed merge carries a diagnostic code:

{ "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.
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.

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.