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
Section titled “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 revisionPUT /v1/sessions/{sessionId}/publications/{publicationId} open (or update) the pull requestGET /v1/sessions/{sessionId}/publications/{publicationId} watch it reach `succeeded`PUT …/publications/{publicationId}/merges/{mergeId} merge that exact headGET …/publications/{publicationId}/merges/{mergeId} watch it reach `succeeded`GET …/publications/{publicationId}/merges page every merge, if you lost an idBoth 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.
Read the review first
Section titled “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.
curl -sS "$WAMP_API/v1/sessions/$SESSION_ID/repository/review" \ -H "Authorization: Bearer $WAMP_TOKEN"import type { WampCloud } from '@wamp/app-sdk';
export async function readReview(cloud: WampCloud, sessionId: string) { return cloud.reviewRepository(sessionId);}{ "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.
Open the pull request
Section titled “Open the pull request”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 }'import { randomUUID } from 'node:crypto';import type { CloudRepositoryReview, WampCloud } from '@wamp/app-sdk';
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:
{ "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 └──▶ failedOnly 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.
Merge exactly what was reviewed
Section titled “Merge exactly what was reviewed”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" }'import { randomUUID } from 'node:crypto';import type { WampCloud } from '@wamp/app-sdk';
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{ "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
Section titled “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:
statusiswaitingandlastError.codeismerge_not_ready.attemptsincreases. There is no attempt cap — a merge can wait as long as the pull request stays open and unchanged.completedAtis 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
Section titled “When a merge is rejected”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.
Related
Section titled “Related”- Publications operations — the generated endpoint reference, including every documented response.
- Connect a repository — grants, and what holding one authorizes.
- Events reference —
wamp.publication.*payloads. - Errors — the
409conflict semantics behind bothPUTs.