Coding in the Cloud
Let Cohort route a build while your own GitHub Actions runner does the coding work.
How It Works
Builds start two ways: triage routes a feedback item into the build lane, or — where you’ve enabled it — your agents dispatch a build directly from a task with the cohort_build tool. Either way, Cohort dispatches a workflow in your repository. Your GitHub Actions runner authenticates back to Cohort with GitHub-issued OIDC tokens, runs the coding engine, reports changed files, and Cohort opens the pull request through the same deterministic gates used by the rest of the build spine.
- Cohort triage decides a feedback item should enter the build lane.
- Cohort sends
workflow_dispatchto your repository with the build id and callback URLs. - Your GitHub Actions runner fetches the brief, runs the coding engine, and reports changed files to Cohort.
- Cohort verifies provenance, applies the denylist and size envelope, then opens the pull request.
- You review and merge the pull request in your repository.
Setup
- Install the Cohort Builds GitHub App .
- In Cohort, go to Settings > Integrations, then choose the repository that should receive builds.
- Run the one-command setup below (requires the GitHub CLI , authenticated to your repo). It commits the workflow file and then prompts for your OpenAI API key — the key goes straight to GitHub as a repository secret, never through Cohort. Replace
OWNER/REPOwith your repository:
gh api -X PUT repos/OWNER/REPO/contents/.github/workflows/cohort-build.yml -f message="Add Cohort Build workflow" -f content="$(gh api repos/Creative-Foresight/cohort-starter/contents/.github/workflows/cohort-build.yml --jq '.content|gsub("\n";"")')" && gh secret set COHORT_BUILD_OPENAI_API_KEY -R OWNER/REPOCreated your repo from the cohort-starter template ? The workflow is already there — run only the last part: gh secret set COHORT_BUILD_OPENAI_API_KEY -R OWNER/REPO.
- Click Verify setup in Cohort. (The Connect GitHub card shows this same command with your repository pre-filled, plus the raw workflow file if you prefer to add it manually.)
name: Cohort Build
on:
workflow_dispatch:
inputs:
build_id:
required: true
type: string
brief_url:
required: true
type: string
# legacy, ignored — runner authenticates via OIDC
brief_token:
required: false
type: string
cohort_base_url:
required: true
type: string
# The runner never pushes code — Cohort opens the PR after deterministic
# checks. Do NOT widen these permissions; the build lane depends on them.
# Only the brief and report jobs may mint cohort-builds OIDC tokens (each
# grants itself id-token: write). The coding engine runs in its own job
# without it, so it can never authenticate to Cohort as this build.
permissions:
contents: read
concurrency: cohort-build-${{ inputs.build_id }}
jobs:
brief:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
id-token: write
steps:
- name: Fetch build brief
env:
BRIEF_URL: ${{ inputs.brief_url }}
run: |
# Audit H8: fail fast on non-https brief URLs before any network call.
if [[ "${BRIEF_URL,,}" != https://* ]]; then
echo "::error::brief_url must start with https://"
exit 1
fi
mkdir -p "$RUNNER_TEMP/cohort-brief"
OIDC_TOKEN=$(curl -sf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=cohort-builds" | python3 -c "import json,sys; print(json.load(sys.stdin)['value'])")
curl -sf -H "Authorization: Bearer $OIDC_TOKEN" "$BRIEF_URL" -o "$RUNNER_TEMP/brief.json"
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['brief'])" "$RUNNER_TEMP/brief.json" > "$RUNNER_TEMP/cohort-brief/brief.md"
echo "Brief fetched ($(wc -c < "$RUNNER_TEMP/cohort-brief/brief.md") bytes)"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cohort-brief
path: ${{ runner.temp }}/cohort-brief/brief.md
retention-days: 1
if-no-files-found: error
build:
needs: brief
runs-on: ubuntu-latest
timeout-minutes: 45
# No OIDC permission here (see the workflow-level note): nothing in this job can
# authenticate to Cohort.
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: cohort-brief
path: /tmp/cohort-brief
- name: Run coding engine
env:
OPENAI_API_KEY: ${{ secrets.COHORT_BUILD_OPENAI_API_KEY }}
# Repo-configurable build command (audit H8: passed via env, never
# interpolated into the script body).
BUILD_COMMAND: ${{ vars.COHORT_BUILD_COMMAND || 'codex exec --dangerously-bypass-approvals-and-sandbox --output-last-message /tmp/engine-summary.md "$(cat /tmp/brief.md)"' }}
run: |
cp /tmp/cohort-brief/brief.md /tmp/brief.md
if [ -z "$OPENAI_API_KEY" ]; then
echo "::error::COHORT_BUILD_OPENAI_API_KEY repo secret is missing or empty — add it in Settings > Secrets and variables > Actions"
exit 1
fi
# Exact version, no install scripts (audit S-67).
npm install -g --ignore-scripts @openai/codex@0.155.1
# Codex CLI ignores the OPENAI_API_KEY env var — it requires an explicit login.
printenv OPENAI_API_KEY | codex login --with-api-key
# Codex's own bubblewrap sandbox cannot start inside Actions runners
# (bwrap loopback needs caps the runner doesn't grant). The ephemeral
# runner VM with a read-only GITHUB_TOKEN is the sandbox here.
# Audit H8 (2026-08-22): the --dangerously-bypass-approvals-and-sandbox
# default stays deliberately — the runner itself is the confinement
# boundary (ephemeral VM, read-only token, no repo write access, no
# OIDC, secrets limited to the build API key). Replacing this flag
# needs runner-level sandboxing work; revisit before syncing this
# template into long-lived or self-hosted runner contexts.
# Execute the configured command verbatim (audit H8: env-mediated).
eval "$BUILD_COMMAND"
- name: Collect changed files
run: |
mkdir -p /tmp/cohort-report
python3 - <<'PYEOF'
import json, os, subprocess
status = subprocess.run(
["git", "status", "--porcelain"], capture_output=True, text=True, check=True
).stdout.splitlines()
files = []
for line in status:
code, path = line[:2], line[3:].strip()
if path.startswith('"') and path.endswith('"'):
path = json.loads(path)
if "D" in code:
print(f"skip (deletion unsupported in v1): {path}")
continue
if not os.path.isfile(path):
continue
try:
with open(path, encoding="utf-8") as f:
files.append({"path": path, "content": f.read()})
except UnicodeDecodeError:
print(f"skip (binary): {path}")
summary = open("/tmp/brief.md", encoding="utf-8").read().strip().splitlines()[0][:200] or "Cohort build"
if not files:
# No change: send the engine's own explanation so Cohort records
# WHY on the build instead of timing it out.
try:
explanation = open("/tmp/engine-summary.md", encoding="utf-8").read().strip()
except OSError:
explanation = ""
summary = explanation or "The coding engine finished without changing any files."
print("No changed files produced by the engine; reporting that to Cohort.")
with open("/tmp/cohort-report/report.json", "w", encoding="utf-8") as f:
json.dump({"summary": summary, "files": files}, f)
print(f"Collected {len(files)} changed file(s).")
PYEOF
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cohort-report
path: /tmp/cohort-report/report.json
retention-days: 1
if-no-files-found: error
report:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: cohort-report
path: ${{ runner.temp }}/cohort-report
- name: Report changed files to Cohort
env:
COHORT_BASE_URL: ${{ inputs.cohort_base_url }}
BUILD_ID: ${{ inputs.build_id }}
REPORT_PATH: ${{ runner.temp }}/cohort-report/report.json
run: |
# Audit H8: build_id is spliced into a URL path below — constrain it.
if ! printf '%s' "$BUILD_ID" | grep -qE '^[A-Za-z0-9-]+$'; then
echo "::error::build_id contains characters outside [A-Za-z0-9-]"
exit 1
fi
export COHORT_BASE_URL BUILD_ID REPORT_PATH
OIDC_TOKEN=$(curl -sf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=cohort-builds" | python3 -c "import json,sys; print(json.load(sys.stdin)['value'])")
export OIDC_TOKEN
python3 - <<'PYEOF'
import json, os, sys, urllib.error, urllib.request
with open(os.environ["REPORT_PATH"], "rb") as f:
body = f.read()
changed = len(json.loads(body).get("files") or [])
report_url = (
os.environ["COHORT_BASE_URL"].rstrip("/")
+ "/api/v1/builds/"
+ os.environ["BUILD_ID"]
+ "/report"
)
req = urllib.request.Request(
report_url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {os.environ['OIDC_TOKEN']}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as res:
print(f"report accepted: HTTP {res.status}")
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", "replace")[:2000]
if changed == 0 and error.code == 400:
print("::error::The coding engine changed no files; Cohort recorded its explanation on the build.")
else:
print(f"::error::Cohort did not accept this build's report (HTTP {error.code}): {detail}")
sys.exit(1)
except urllib.error.URLError as error:
print(f"::error::Could not reach Cohort to report this build: {error.reason}")
sys.exit(1)
PYEOFDon’t Have a Repo Yet?
Use the Cohort starter template .
- Create the repository from the template, then install the Cohort Builds GitHub App for that repository.
- Pick the repository in Cohort Settings > Integrations, add
COHORT_BUILD_OPENAI_API_KEY, and verify setup.
The starter already includes .github/workflows/cohort-build.yml; the only easy step to miss is including the new repository in the GitHub App installation.
Agent-Dispatched Builds
Your Cohort agents can turn a tracked task into a build without waiting for triage. An agent calls the cohort_build tool with the task number and a precise instruction; Cohort runs the same pipeline described above and posts the whole lifecycle back onto the task — a dispatch confirmation, the pull-request link when it opens, and the merged or failed-CI outcome.
Every dispatched build is anchored to a task: the task number is required, the audit trail lives on the task, and the pull request links back to it.
Enabling It
Agent dispatch is off by default for every workspace. Your workspace has a dispatch policy with three settings:
| Policy | Behavior |
|---|---|
| (off — the default) | Agents get a clear “not enabled for this workspace” refusal |
approval_required | Agents are told to ask a workspace owner to dispatch |
agents_direct | Agents with the build:dispatch scope dispatch directly, within the daily cap |
Enablement is currently handled by Cohort — contact us to turn on agent dispatch for your workspace and choose a policy.
The Daily Cap
Workspaces on agents_direct have a daily dispatch cap (default 5 builds per day). A cap-blocked dispatch is never silently dropped: the agent receives an explicit error, and the task gets a comment recording that the build was not started — so discovered work stays visible and a human can raise the cap or dispatch manually.
What Agents Are Told
The agent guide every workspace ships with explains the routes: read repository code with cohort_repo_read, dispatch code changes with cohort_build (task number + precise instruction), and never claim a checkout that doesn’t exist. Agents pass the plain task number — the tool also accepts common references like #1258.
Security Model
- Your runner is read-only. The workflow uses
contents: read, so it can check out code but cannot push commits. - Cohort opens the pull request. Reported files pass through the denylist, size envelope, and provenance verification before Cohort writes a branch.
- Runner auth is scoped. The runner authenticates with GitHub-issued OIDC tokens scoped to your repo and this workflow; no secret material passes through workflow inputs.
- The coding engine can’t talk to Cohort. Only the short
briefandreportjobs can request those tokens. The engine runs in a separatebuildjob without that permission, and its changes reach thereportjob only as a workflow artifact. - Your engine key stays in your repo. Cohort never stores or proxies
COHORT_BUILD_OPENAI_API_KEY; the workflow reads it from your repository secret.
FAQ
Who Pays for the Build?
You pay for your GitHub Actions minutes and the LLM tokens used by your coding engine.
Can I Change the Coding Engine?
Yes. Set the repository variable COHORT_BUILD_COMMAND to override the default command. The workflow still passes the same brief (at /tmp/brief.md) and reports changed files back to Cohort. If your engine can change nothing, have it write a short explanation to /tmp/engine-summary.md; Cohort records it on the build.
What If the Report Back to Cohort Fails?
The report job fails, and its log shows Cohort’s response and the reason. For example, a change to a protected path is refused with the path named.
Why Must Permissions Stay contents: read?
The runner should never be able to push. Keeping contents: read means the runner can only produce a report; Cohort’s spine is the only path that can open the pull request, and it applies deterministic gates first.
Can Agents Spend My Actions Minutes Without Limit?
No. Dispatch requires the workspace policy to be enabled, the agent key to carry the build:dispatch scope, and the daily cap to have headroom — and every dispatch is anchored to a task you can see. The same brief/report/PR gates apply regardless of who started the build.
Why Aren’t Pull Requests Auto-Merged?
Cloud builds run in customer repositories, so Cohort does not arm auto-merge. The build lane opens the pull request; you review and merge it in your own repository.