GitHub Actions
Call a Console agent from a GitHub Actions workflow with nothing but curl and jq, both preinstalled on GitHub-hosted runners. The workflow creates a run, long-polls it, reads the result, and cancels the run if the job is cancelled. The same pattern as Azure DevOps, in YAML.
When to use it: review a pull request with an agent, draft release notes at tag time, triage a failing test run, or gate a deployment on an agent's verdict.
Secrets
In the repository (or organisation) go to Settings → Secrets and variables → Actions and add:
| Name | Kind | Value |
|---|---|---|
CONSOLE_URL | Variable | https://console.example.com |
CONSOLE_TOKEN | Secret | A cpeer_… token of a dedicated service user with the agents service |
The Console URL must be reachable from the runner. Self-hosted Console behind a firewall needs a self-hosted runner in the same network. No inbound access is needed, because the workflow polls.
Reusable pattern
Put the create-and-wait logic in one composite step so each workflow stays short. This script takes the agent key, the input text and an optional JSON response_format, and writes the run to run.json.
#!/usr/bin/env bash
# .github/scripts/console-run.sh <agent-key> <input-file> [response_format.json]
set -euo pipefail
BASE="$CONSOLE_URL/api/client/v1"
AUTH="Authorization: Bearer $CONSOLE_TOKEN"
KEY="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${3:-plain}-$1" # run id + attempt + step
BODY=$(jq -n --rawfile input "$2" \
--arg repo "$GITHUB_REPOSITORY" --arg run "$GITHUB_RUN_ID" \
'{input: $input, metadata: {repo: $repo, github_run: $run}}')
if [ -n "${3:-}" ]; then
BODY=$(jq --slurpfile rf "$3" '. + {response_format: $rf[0]}' <<<"$BODY")
fi
RUN=$(curl -fsS -X POST "$BASE/agents/$1/runs" -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d "$BODY")
RUN_ID=$(jq -r .id <<<"$RUN")
echo "CONSOLE_RUN_ID=$RUN_ID" >> "$GITHUB_ENV" # for the cancel step
echo "Run $RUN_ID"
# Long-poll: each request is held up to 50 s by the server and returns as soon as the run settles.
for _ in $(seq 1 40); do # about 30+ minutes at most
STATUS=$(jq -r .status <<<"$RUN")
case "$STATUS" in scheduled|queued|running) ;; *) break ;; esac
RUN=$(curl -fsS --max-time 70 "$BASE/agents/runs/$RUN_ID?wait=50" -H "$AUTH")
done
echo "$RUN" > run.json
STATUS=$(jq -r .status run.json)
if [ "$STATUS" != "succeeded" ]; then
echo "::error::Agent run $RUN_ID is $STATUS: $(jq -r '.error.message // "no error message"' run.json)"
exit 1
fiExample: review a pull request and comment on it
# .github/workflows/agent-review.yml
name: Agent PR review
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
concurrency:
group: agent-review-${{ github.event.pull_request.number }}
cancel-in-progress: true # a new push cancels the job, and the cancel step stops the run
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 35
env:
CONSOLE_URL: ${{ vars.CONSOLE_URL }}
CONSOLE_TOKEN: ${{ secrets.CONSOLE_TOKEN }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Build the review request
env:
PR_TITLE: ${{ github.event.pull_request.title }} # via env, never interpolated into the script
BASE_REF: ${{ github.base_ref }}
run: |
{
echo "Review this pull request. Title: $PR_TITLE"
echo
git diff "origin/$BASE_REF...HEAD" -- . ':!*.lock' | head -c 60000
} > request.txt
cat > schema.json <<'JSON'
{ "type": "json_schema",
"json_schema": { "name": "review", "strict": true, "schema": {
"type": "object", "additionalProperties": false,
"required": ["verdict", "summary", "findings"],
"properties": {
"verdict": { "type": "string", "enum": ["approve", "comment", "request_changes"] },
"summary": { "type": "string" },
"findings": { "type": "array", "items": { "type": "object", "additionalProperties": false,
"required": ["file", "severity", "note"],
"properties": { "file": {"type":"string"}, "severity": {"type":"string","enum":["low","medium","high"]}, "note": {"type":"string"} } } }
} } } }
JSON
- name: Run the agent
run: bash .github/scripts/console-run.sh code-reviewer request.txt schema.json
- name: Post the review as a comment
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
VERDICT=$(jq -r '.output_parsed.verdict' run.json)
{
echo "### Agent review: **$VERDICT**"
echo
jq -r '.output_parsed.summary' run.json
echo
jq -r '.output_parsed.findings[] | "- **\(.severity)** `\(.file)`: \(.note)"' run.json
echo
echo "<sub>Console run \`$(jq -r .id run.json)\`</sub>"
} > comment.md
gh pr comment "$PR_NUMBER" --body-file comment.md
- name: Job summary
if: always()
run: |
[ -f run.json ] || exit 0
{
echo "## Agent review"
echo "- Run: \`$(jq -r .id run.json)\` ($(jq -r .status run.json))"
echo "- Verdict: $(jq -r '.output_parsed.verdict // "n/a"' run.json)"
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail on a blocking verdict
run: test "$(jq -r '.output_parsed.verdict' run.json)" != "request_changes"
- name: Cancel the run if the job was cancelled
if: cancelled()
run: |
[ -n "${CONSOLE_RUN_ID:-}" ] || exit 0
curl -sS -X POST "$CONSOLE_URL/api/client/v1/agents/runs/$CONSOLE_RUN_ID/cancel" \
-H "Authorization: Bearer $CONSOLE_TOKEN" >/dev/null || trueNotes:
if: cancelled()runs when the workflow is cancelled (by a person, byconcurrency, or by a timeout) and stops the Console run, so a superseded review does not keep working. A run that already finished answers409 agent_run_already_terminal; the|| trueignores it.- The idempotency key has the run id and the attempt, so "Re-run failed jobs" repeats the step without double-starting a turn within an attempt, and a new attempt starts a fresh run.
- Keep the diff inside the agent's context. The example truncates at 60 000 characters; for large changes, put the diff in a file and send it as an
input_filepart, or summarise per directory. - Fork pull requests do not receive secrets. Use
pull_request_targetcarefully (never check out untrusted code with secrets), or run the review from a trusted workflow triggered after the PR workflow. - Cost control. Console applies the agent's session budget and the tenant spend limit; a run that hits them ends with a clear stop reason. Tag runs with
metadataas above to find them in Console.
Example: release notes on tag
name: Release notes
on:
push:
tags: ['v*']
jobs:
notes:
runs-on: ubuntu-latest
env:
CONSOLE_URL: ${{ vars.CONSOLE_URL }}
CONSOLE_TOKEN: ${{ secrets.CONSOLE_TOKEN }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: |
PREV=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || git rev-list --max-parents=0 HEAD)
git log --pretty='- %s (%h)' "$PREV..$GITHUB_REF_NAME" > request.txt
- run: bash .github/scripts/console-run.sh release-notes request.txt
- run: jq -r .output_text run.json > NOTES.md
- uses: softprops/action-gh-release@v2
with: { body_path: NOTES.md }Without a workflow: a webhook trigger
If the agent only has to react to an event, such as a pull request being opened, you do not need a workflow at all: add a webhook trigger to the agent and set its URL as a repository (or organization) webhook. GitHub posts the event, Console starts the run and the agent comments with its own GitHub tool. Use a workflow when the job should wait for the verdict and fail the check, a webhook trigger when it should not; the recipe shows a PR review without any workflow.
Without polling: a callback
GitHub-hosted runners cannot receive inbound requests, so polling is the right default. If you want the pipeline to end and a separate workflow to react to the result, send a callback to a small relay that calls GitHub's repository_dispatch API, or subscribe a webhook to agent_run.* and have the relay forward it. Most pipelines should simply wait; see the use cases.

