GitHub
Run sessions on a GitHub repository, push approved results to a branch, and open a PR from that branch
Werft does three things with GitHub. It clones the repository when a session starts with one, or when the agent attaches it otherwise. It pushes the commits a person approved to the agent/<session-id> branch. It opens a PR from that branch and adds comments to it. The worker clones through the API's proxy, while the platform (API) pushes and handles the PR itself. In either case, the repository credentials never reach the worker.
What the operator does
The credential is a GitHub App, which an owner or admin handles in the console under Settings > Workspace > Integrations > GitHub.
- Add App: create a new App on GitHub (the console receives and stores its App ID and key), or connect an existing one with its App ID and private key (the PEM file). A workspace can hold several author Apps and reviewer Apps; a role badge tells them apart in the list. The key is stored encrypted and never shown again on the screen or in an API response. To change a key, use Register a new key in the App row's ⋯ menu.
- Add organization: install an App on an organization or a personal account. The installation table shows, for each account, its type, repository scope, App, who connected it and its status; Manage on GitHub in the row's ⋯ menu opens that installation's settings.
- Manage repositories: see the repositories agents picked, by installation, and change their default branch and the App that handles each. A repository appears once it is picked in an agent's GitHub tab; there is nothing to register.
- Features agents may use: the workspace's ceiling. A switch saves at once. When an App lacks a permission a feature that is on needs, that App's row says which and offers Add permissions on GitHub. Once added, GitHub asks the owner of each account it is installed on to approve; no reinstall is needed.
- Merging its own pull requests: off by default. When on, an agent squash-merges a pull request its session opened when a person tells it to. GitHub still decides on required checks and reviews, and the merge request is sent with ruleset bypass off. Only owners and admins change it, through the workspace GitHub settings API (
ceiling.mergeofPUT /v1/workspaces/{id}/github-settings). Turning it on needs no new release and applies from the next call.
To create a new App in the console, the API needs CONSOLE_ORIGIN, the origin where browsers open the console (for example https://werft.internal.example), because GitHub sends the browser back to that address after it creates the App. Without it the Create on GitHub button on the Create a new App screen, which opens from Create a GitHub App, is disabled and the console says CONSOLE_ORIGIN is not set. Ask the operator to set it, or use Connect an existing App. On a single server, put it in the env file and re-apply with upgrade (Single server). If GitHub asks for a passkey check (sudo mode), the App may not be created on that screen and you land on an empty App form. Finish the check, return to the console and click Create on GitHub again.
| Method | Required permissions |
|---|---|
| GitHub App | Repository permissions: Contents Read and write, Pull requests Read and write |
Add the Workflows permission to the App only when the "Change workflow files" feature is on. The commits that get pushed are written by the agent, so with this permission a .github/workflows/ file the agent edited would also be pushed and run in CI. Also protect the default branch with a repository ruleset. The operations docs describe how to set this up.
The App's Setup URL
An App made in the console gets its Setup URL filled in as <CONSOLE_ORIGIN>/settings/workspace/integrations/github. If you connected an App that already existed, set Setup URL to this address in the App's settings on GitHub (Developer settings > GitHub Apps > the App > General). After you add an organization, GitHub sends the browser back to this address, and the console records the installation from what comes back. An App whose Setup URL still points at the old /settings/workspace/github needs the new address; that path no longer exists. For an account that already has the App installed, GitHub returns to the console after you save the installation's settings only when the App's Redirect on update is on.
What the user does
- Create a session with that repository's
repository_id(Start). - Request and approve the result. When the request becomes
pushed, theagent/<session-id>branch exists in the repository. - Open the PR. This needs
sessions:approve. The examples below use thesessionIdandpushfrom Request and Approve (SIDandPUSH_IDin the HTTP API).
const opened = await werft.sessions.pushRequests.openPullRequest(sessionId, push.id, {
title: "Add release notes",
body: "Written by the agent, approved by me.",
});
console.log(opened.pull_request.number, opened.pull_request.url, opened.created);This call has no Idempotency-Key, so the SDK does not resend it when it gets no response (Retries and Idempotency-Key). The head is agent/<session-id> and the base is the branch listed in the catalog. Each request gets one PR. If a PR from that branch is already open, no new one is opened and that PR is returned (created: false). This call does not approve or merge PRs, and it does not choose reviewers or labels.
- Add a comment. If a comment with the same
marker(defaultstatus) already exists, it is edited instead of adding a new one.
await werft.sessions.pushRequests.commentPullRequest(sessionId, push.id, {
body: "Tests pass on the agent branch.",
});Titles, bodies, and comments are sanitized before they are sent to GitHub. Lines that look like secrets are masked, and @mentions and closing keywords such as Closes #N are rewritten so they trigger no notification or automatic close.
The platform adds a tail to the end of the PR body: the session id and commit, the session's address, the agent and release, who started the session and how, and who requested the push (Requested by, the login without @ so nobody is notified). Push to the branch again and ask for the PR again, and only the tail changes to the new commit; the text a person wrote above it stays.
The agent in a session does the same work with built-in MCP tools: open_pull_request opens the PR, update_pull_request changes its title and body, and comment_pull_request comments on it. close_pull_request and reopen_pull_request close a PR that a push request of this session opened, without merging it, and reopen it. No person approves these. The agent cannot close a PR from another session, or one in a repository it is not allowed. Closing a closed PR, or reopening an open one, changes nothing and answers changed: false. A merged PR is refused with PULL_REQUEST_MERGED. Closing does not delete the agent/<session-id> branch, so the PR can be reopened.
With the workspace's permission, merge_pull_request squash-merges that PR. Without it, the call is refused with GITHUB_REFUSED (merge_off) before GitHub is called. The merge is pinned to the commit the push request pushed: a PR whose head is another commit is refused with head_moved, and a PR GitHub will not merge now (a required check pending, a review needed, an open conversation, a conflict) is refused with merge_blocked. That refusal's details state what GitHub reported (mergeable_state, mergeable, draft) and a reason sentence saying which condition failed; the reason names the state, it does not name which required check or review. So is a PR moved to another base than the one it was opened against, or one whose base is another open PR's head (a stacked PR), because GitHub's asynchronous merge also merges the pull requests it is stacked on. When GitHub accepted the merge but it has not finished within the wait, the call answers forge_failed; the merge may still complete, so check the PR and call again. A PR already merged on the same commit is answered with changed: false. A PR that is already merged on the same commit answers changed: false. There is no admin bypass, no auto-merge and no other merge method. In a mode with no permission check (bypassPermissions), a session can merge without being told to while the switch is on. The merge request is sent with rule bypass off (bypass_rules: false) to GitHub's asynchronous merge API, so even if the author App is on a ruleset's bypass list it cannot skip required checks and reviews with that permission. Keeping the merging App off bypass lists is still the safer setup. A workflow that merges PRs, put on an agent's Actions allow list, is outside this switch.
How the pull request opens after a push
When a push request carries a pull request request, the action-broker inside the control-host opens the pull request once the push ends as pushed. The agent does not have to call open_pull_request in a follow-up turn. This work starts with the control-host, and a stage that was already started is finished even if you restart it. Over REST, calling POST /v1/sessions/{id}/push-requests/{push_request_id}/pull-request?schedule=true before the push ends returns 202 with status: "scheduled" instead of 409 PUSH_NOT_PUSHED, and the pull request opens as soon as the push ends. Without the schedule query, or on a push request made on the legacy flow (flow = legacy), the call answers 409 as before.
There is one pull request stage per push request, and its state moves like this.
| State | Meaning |
|---|---|
due | The push is done and it is time to open the pull request. A failed attempt is tried again after a delay (up to 3 times) |
opening | One action-broker is opening it. If that worker stops, another replica takes over |
opened | The pull request is open and recorded. Its number and link are on the pull request row |
closed_unrecorded | This push request's pull request was closed or merged before the platform recorded it. No new one is created |
uncertain | The platform could not confirm whether a pull request exists (the lookup failed three times, or the first lookup returned a full page of 10 and more may be on the next), or more than one fits. The platform does not choose |
failed | The pull request could not be opened. The reason is in pr_error |
cancelled | The push failed, so there is no pull request to open. The reason is in pr_error |
Before opening, the platform looks the branch up in every state (open, closed, merged). That is why a retry after a lost answer does not create a second pull request. If a pull request is already open it is reused, and only its tail changes to the new commit. If the head of a pull request it found or created is not the pushed commit, nothing is recorded and the stage ends as head_moved. GitHub can show the old head for a moment after a push, so when that head is an ancestor of the pushed commit the stage waits for its next attempt instead (before the last attempt, one compare request). A head that is not an ancestor, or a compare that fails, ends it at once. The pull request that was created is not closed, so a person checks it. A marker in a pull request body can be written by anyone, so only pull requests with the same repository, head and base count as candidates.
The result arrives as a notice in the session thread (opened, could not open, already closed, could not confirm). The text follows the language of the person who started the session (not the requester) and is created once per result. A notice is created only when the session is tied to a Slack thread that is not muted or restricted; otherwise the state changes and no notice row is written. When one is created it is written in the same transaction as the state change, so a state never changes without its notice.
There is a known limit. If a process stops for a long time just before the call and wakes up so that an already sent request arrives late, one more pull request can appear on the same branch, and the platform does not look for it after opening. GitHub has no conditional create, so the platform only checks again right before the call and looks the branch up before opening. If more than one pull request fits at open time, it creates none, leaves the stage uncertain and notifies. It never closes one automatically; a person chooses on GitHub.
Attaching a repository mid-session
A session of an agent with GitHub settings starts without a repository (an older agent with no settings starts on its release's repository). While it works, the agent attaches only the repositories the task reads or changes, with the built-in MCP tool attach_repository({ repository_id }). It can attach only repositories listed in the agent release's GitHub settings (github.repositories) that the profile allows. No person approves it; writing to that repository still goes through push request approval, and a push to an attached repository goes without a person's approval only for an agent with push approval off and the attached-repository push policy on allow (one rule for every repository). The agent sees which repositories it can attach with list_repositories (attached ones attached, ones being fetched or that failed pending or failed, and ones not attached yet not_attached; a repository named by repository_id when the session was created is listed as attached). A session keeps the release it started with, so a repository added only to a later active release is listed apart as allowed_in_newer_release, and attach_repository refuses it with allowed_in_newer_release instead of repository_not_allowed. It does not need to be allowed again: a new session (in Slack, a new thread) can attach it. A session that runs on the agent's settings and keeps its own attached-repository list is different. A repository the agent turns on after the session started can be attached right away; if the agent takes one off the list, or turns GitHub off, the repositories it had attached (one named when the session was created included) are listed as revoked, and the platform refuses the agent's GitHub actions on them and a new attach of another repository. An attach of the repository named when the session was created does nothing and is answered as already attached, and the platform's own reading of recorded pull requests continues. The working copy is not deleted, and turning the repository back on makes it usable again.
A session created without attached_repository_ids starts with no repository attached (a session created with repository_id has only that one). Put the repositories it needs from the start in that list; the agent attaches the rest when it needs them. A push request sends one repository, so with several repositories you make one push request per repository.
If an attach fails on a connection error (an upstream connection failed answer, or HTTP 502 or 503), the failure is transient and says nothing about the repository. Trying again with the same arguments is safe. repository_not_allowed is a refusal by the agent's settings, not a connection failure, so trying again gets the same answer.
- The attached repository becomes a git remote named
repository_idin the workspace, with its default branch fetched to<repository_id>/<branch>. The agent never sees a token. - Attaching the same repository again changes nothing and succeeds. While the fetch is running the tool answers
pendingwithfinal: falseat once. Withwait_seconds(0 to 7, default 0) it holds the answer until the fetch ends or the seconds have passed;finalis true once the fetch has ended. - The fetch is limited to 300 seconds and 2 GiB. Past either, the attachment fails with the reason in
failure(fetch_timeout,fetch_too_large). - Each attachment leaves an
attach_repositoryreceipt: who asked and from which attempt, the repository, the release, the commit fetched, and the refusal reason (repository_not_allowedorallowed_in_newer_release). - When the session restarts on another worker (a restore), the attached repositories' remotes are added again. A CLAUDE.md in an attached repository is not read as instructions.
Link your GitHub account
The GitHub App's bot account (<App slug>[bot]) is always the commit author. Link your GitHub account and the commits of turns you request also carry your name in a Co-authored-by: trailer, so GitHub shows the commit as your contribution too.
- On the "Member GitHub accounts" line under Settings > Workspace > Integrations > GitHub, an owner or admin picks the author App to link with (when there are several), puts the callback URL from the ⋯ menu in that App's Callback URL, and enters a client secret made in that App. Until then the link button is off.
- In the GitHub row of Settings > Linked accounts, choose "Link" and approve on GitHub's consent screen. The platform reads only the GitHub account's id and login and keeps no GitHub token.
- The linked row shows the line commits get (
Co-authored-by: <login> <<id>+<login>@users.noreply.github.com>). After you unlink, turns requested from then on leave it out.
Turns requested from the web console, a personal API key, or Slack follow the link of the person who requested that turn. When several people ask in one session, each commit carries the requester of its own turn. A turn requested with an agent key or an operator key has no requesting person, so no co-author is added. Either way, commits carry an Agent-Session: trailer: the session's address where the operator has set the console's address, else the session's id. The last line of the Outputs tab in session details shows which case a session is.
A GitHub account links to one person only. Linking an account someone else already linked is refused.
On the local stack
The local sample-app is registered with a GitHub App on the stack's own Git server, so an approved push becomes pushed and a PR can be opened. A PR request on a request that was never pushed, such as a rejected one, returns 409 PUSH_NOT_PUSHED. Check this with a request that ended as rejected in Approve.
import { isApiError } from "@werft-ai/sdk";
const requests = await werft.sessions.pushRequests.list(sessionId);
const rejected = requests.find((request) => request.state === "rejected");
if (rejected) {
try {
await werft.sessions.pushRequests.openPullRequest(sessionId, rejected.id, {
title: "Add release notes",
});
} catch (error) {
if (!isApiError(error)) throw error;
console.log(error.status, error.code);
}
}409 PUSH_NOT_PUSHED| Response | Meaning |
|---|---|
409 PUSH_NOT_PUSHED | The request is not pushed yet |
409 PULL_REQUEST_MISSING | Open the PR before commenting |
409 CATALOG_MISMATCH | The operator changed the repository settings after the request |
403 FORBIDDEN | sessions:approve is missing, the caller only sees the session and is not one who approves, or the repository is not on GitHub (no_forge) |
503 BACKEND_UNAVAILABLE | The GitHub call still failed after retries |