Skip to Content
GuidesCoding in the Cloud

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.

  1. Cohort triage decides a feedback item should enter the build lane.
  2. Cohort sends workflow_dispatch to your repository with the build id and callback URLs.
  3. Your GitHub Actions runner fetches the brief, runs the coding engine, and reports changed files to Cohort.
  4. Cohort verifies provenance, applies the denylist and size envelope, then opens the pull request.
  5. You review and merge the pull request in your repository.

Setup

  1. Install the Cohort Builds GitHub App .
  2. In Cohort, go to Settings > Integrations, then choose the repository that should receive builds.
  3. 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/REPO with 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/REPO

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

  1. 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) PYEOF

Don’t Have a Repo Yet?

Use the Cohort starter template .

  1. Create the repository from the template, then install the Cohort Builds GitHub App for that repository.
  2. 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:

PolicyBehavior
(off — the default)Agents get a clear “not enabled for this workspace” refusal
approval_requiredAgents are told to ask a workspace owner to dispatch
agents_directAgents 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 brief and report jobs can request those tokens. The engine runs in a separate build job without that permission, and its changes reach the report job 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.