---
title: "GitHub"
description: "The verified ai.oz0/github plugin - repositories, issues, pull requests, checks and reviews for agents, GitHub's webhooks as events, and a GitHub App of your own."
canonical_url: "https://docs.oz0.ai/build/plugins/github"
---
# GitHub

> The verified ai.oz0/github plugin - repositories, issues, pull requests, checks and reviews for agents, GitHub's webhooks as events, and a GitHub App of your own.

For: plugin authors.

Use `ai.oz0/github` to give agents access to GitHub repositories, issues and pull requests. It is the project's first verified first-party plugin, maintained with the engine in `plugins/github`. Its manifest name is `github`. It provides:

- one tool, `oz0_github`, with GitHub's reads and writes, each with input and output schemas and honest annotations;
- an agent, `oz0-github`, that other plugins hand their GitHub work to, so the token, the approvals and the rules live in one place;
- an event source, `github`, that turns GitHub's webhooks into events routines and flows fire on and jobs wait for;
- a connection, `github`: a GitHub App of your own on your organization, which an admin connects in two clicks, after which every call gets a token minted for that call alone. Until then, calls use a token you set.

## Install it

Reading the plugin's source or using the Git install below currently requires access to the private repository. Public source downloads are coming soon.

Its agent `oz0-github` asks for the tenant's `default` [model alias](https://docs.oz0.ai/spend/models-and-keys/aliases#the-standard-aliases) with tools, never a vendor's model, so it runs on whichever provider the tenant set up first; map it elsewhere with `oz0 model alias set default <provider>/<model>`.

```bash [Terminal]
oz0 plugin install https://github.com/orchestrator-zero/orchestrator-zero --path plugins/github
```

It ships no eval, so the install has no [quality gate](https://docs.oz0.ai/improve/evals/quality-gates#quality-gates) and calls neither GitHub nor a model: its agent is a delegate that does what the agent handing it work asks, and the plugin's tests check its functions against a fake GitHub. Then give it a way into GitHub, in either of two ways.

### With a token

The quickest start: a [fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with access to the repositories your agents work on, as the tenant secret `GITHUB_TOKEN`.

```bash [Terminal]
printf %s "$GITHUB_TOKEN" | oz0 secret set GITHUB_TOKEN
```

Only the edge reads it: each call gets it from the edge, in the call itself, and the plugin's processes never hold it in their environment. It is one person's rights for every call, though, so move to the app when you can.

### With a GitHub App (Connect)

Create your own app, about two minutes. An admin opens Credentials, Connections in the console, picks `github`, fills in the organization, and clicks Connect. The app's name defaults to your tenant's name plus `-oz0` (the tenant's name shortened to fit GitHub's 34 characters); app names are unique across GitHub, so give another one (`app_name`) or change it on GitHub's page if it is taken. GitHub asks twice: to create the app from the manifest the edge made, and where to install it.

The manifest already has everything else: the webhook URL (your edge's event source for the plugin), the permissions and the events the plugin declares, and the edge's callback as the redirect and setup URL, so you only confirm. The browser comes back to the Connections tab, and the connection says connected. From the CLI:

```bash [Terminal]
oz0 connection start github --param owner=acme --open
oz0 connection list
```

If it does not work: see [troubleshooting](https://docs.oz0.ai/admin/connections/troubleshooting#troubleshooting) (a personal account instead of an organization, a name that is taken, webhooks GitHub cannot deliver).

What happens, and where each part ends up, is on [Connections](https://docs.oz0.ai/admin/connections). In short: the app's private key and its webhook secret go to the edge and are sealed as tenant secrets that management cannot open; each call then gets an installation token minted on the edge, for the one repository the call names and only the permissions its function needs, and good for an hour at most. The app's webhooks go to the plugin's event source, signed with the secret the connection saved, so nothing more is needed for events.

For GitHub Enterprise Server, set the addresses first, then connect (the app's key and tokens go only to the API address they were made for, so moving a connected connection to another server means connecting it again from its first step with `--replace`; see [where credentials go](https://docs.oz0.ai/admin/connections/destinations#where-credentials-go)):

```bash [Terminal]
oz0 connection set github --param web_url=https://github.acme.se --param api_url=https://github.acme.se/api/v3
```

A fallback `GITHUB_TOKEN` goes to a server other than github.com only once it is bound to its host, as someone who may set secrets sets it: `oz0 secret set GITHUB_TOKEN --host github.acme.se`.

and add the host to the tenant's [egress list](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes), since the plugin's own list names only `api.github.com` and `codeload.github.com`. A server with subdomain isolation serves archives from a host of its own, `codeload.<host>` (`codeload.github.acme.se` here): add that one too, or `download_archive` fails in jobs an outside event started. A server on a private network also needs a cluster admin to list that network for the edge's own requests (`server start --connect-allow-net 10.20.0.0/16`): the edge reaches only public addresses for a connection otherwise.

A personal account. To register the app on your own GitHub account instead of an organization, choose account: user (`--param account=user`, no `owner`; the organization's page answers 404 for a personal account): GitHub registers it on the account you are signed in with, at `/settings/apps/new`, and the install step is the same. A tenant has one `github` connection, on an organization or on a user: moving from one to the other registers a new app and needs `--replace`.

Once the app is installed, the connection runs `oz0_github.check_installation` as a tool job of its own: it lists the repositories the installation may use, with the new token, so the Connections tab shows at once whether the app sees what you meant it to ([after connecting](https://docs.oz0.ai/admin/connections#after-connecting)).

To install the app on another organization, connect again from step `install` (`connection start github --from-step install`, the console's default for a connected app): the app keeps working until the new installation is done. Registering a new app replaces the old one at once and needs `--replace`.

## The functions

Every function names a repository as `repo: owner/name`, and an issue or a pull request by `number`.

<table>
<thead>
  <tr>
    <th>
      Function
    </th>
    
    <th>
      Does
    </th>
    
    <th>
      Annotations
    </th>
    
    <th>
      Token's permissions
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        get_repository
      </code>
    </td>
    
    <td>
      A repository: description, default branch, visibility
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      metadata: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        list_issues
      </code>
      
      , <code>
        get_issue
      </code>
    </td>
    
    <td>
      Issues, without pull requests; one with its body
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      issues: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        list_pull_requests
      </code>
      
      , <code>
        get_pull_request
      </code>
    </td>
    
    <td>
      Pull requests; one with its head and base (ref and SHA), draft, mergeable
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      pull_requests: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        get_pull_request_diff
      </code>
      
      , <code>
        list_pull_request_files
      </code>
    </td>
    
    <td>
      The unified diff (cut at 120 000 characters, or <a href="https://docs.oz0.ai/build/plugins/github#long-files-and-diffs">
        fitted to a budget
      </a>
      
      ), the changed files
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      pull_requests: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        get_file
      </code>
    </td>
    
    <td>
      A file's text at a ref (cut at 100 000 characters, or <a href="https://docs.oz0.ai/build/plugins/github#long-files-and-diffs">
        fitted to a budget
      </a>
      
      )
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      contents: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        list_checks
      </code>
    </td>
    
    <td>
      A ref's check runs and statuses, and their combined state: <code>
        success
      </code>
      
      , <code>
        failure
      </code>
      
      , <code>
        pending
      </code>
      
       or <code>
        none
      </code>
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      checks: read, statuses: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        list_comments
      </code>
    </td>
    
    <td>
      Comments on an issue or a pull request
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      issues: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        search
      </code>
    </td>
    
    <td>
      Issues and pull requests, code or repositories, in GitHub's search syntax
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      every repository
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        download_archive
      </code>
    </td>
    
    <td>
      The repository's code at one commit (<code>
        sha
      </code>
      
      , the full 40 characters), as a file for another function, such as <a href="https://docs.oz0.ai/build/plugins/verified/pr-reviewer">
        pr-reviewer
      </a>
      
      's <code>
        run_tests
      </code>
      
      : the bytes go to the edge, the answer names them <code>
        oz0-file:sha256:&lt;hex>
      </code>
      
       (at most 32 MiB)
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      contents: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        resolve_ref
      </code>
    </td>
    
    <td>
      The full SHA of the commit a branch, a tag (<code>
        heads/&lt;name>
      </code>
      
       or <code>
        tags/&lt;name>
      </code>
      
       to say which; an annotated tag is followed to its commit) or a commit names now, and which kind it is: the <code>
        sha
      </code>
      
       that <code>
        download_archive
      </code>
      
       and a <a href="https://docs.oz0.ai/govern/reviewed-changes/changesets#changesets">
        reviewed changeset
      </a>
      
      's base take; the default branch when <code>
        ref
      </code>
      
       is empty
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      contents: read
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        check_installation
      </code>
    </td>
    
    <td>
      The repositories the GitHub App's installation may use; the connection runs it after connecting
    </td>
    
    <td>
      read-only
    </td>
    
    <td>
      metadata: read, every repository
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        create_issue
      </code>
    </td>
    
    <td>
      Opens an issue; <code>
        idempotency_key
      </code>
      
       writes it once
    </td>
    
    <td>
      writes, asks by default
    </td>
    
    <td>
      issues: write
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        comment
      </code>
    </td>
    
    <td>
      Comments on an issue or a pull request; <code>
        idempotency_key
      </code>
      
       writes it once
    </td>
    
    <td>
      writes, asks by default
    </td>
    
    <td>
      issues, pull_requests: write
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        create_pull_request
      </code>
    </td>
    
    <td>
      Opens a pull request; again for the same head and base, answers the open one
    </td>
    
    <td>
      writes, idempotent, asks by default
    </td>
    
    <td>
      pull_requests: write
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        review_pull_request
      </code>
    </td>
    
    <td>
      <code>
        COMMENT
      </code>
      
      , <code>
        APPROVE
      </code>
      
       or <code>
        REQUEST_CHANGES
      </code>
      
      , with comments on lines
    </td>
    
    <td>
      writes, asks by default
    </td>
    
    <td>
      pull_requests: write
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        add_labels
      </code>
      
      , <code>
        remove_label
      </code>
    </td>
    
    <td>
      Labels
    </td>
    
    <td>
      writes, idempotent, asks by default
    </td>
    
    <td>
      issues, pull_requests: write
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        merge_pull_request
      </code>
    </td>
    
    <td>
      Merges at the head <code>
        sha
      </code>
      
       it names (GitHub refuses if the branch moved)
    </td>
    
    <td>
      destructive, always asks
    </td>
    
    <td>
      contents, pull_requests: write
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        close
      </code>
    </td>
    
    <td>
      Closes an issue or a pull request without merging
    </td>
    
    <td>
      destructive, asks by default
    </td>
    
    <td>
      issues, pull_requests: write
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        delete_branch
      </code>
    </td>
    
    <td>
      Deletes a branch, such as a merged head
    </td>
    
    <td>
      destructive, asks by default
    </td>
    
    <td>
      contents: write
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        push_changes
      </code>
    </td>
    
    <td>
      Applies an approved patch as one commit on its own branch and opens a draft pull request. See <a href="#applying-a-reviewed-changeset">
        checks and provenance
      </a>
      
      .
    </td>
    
    <td>
      writes, idempotent, always asks (a changes approval stands for it)
    </td>
    
    <td>
      contents, pull_requests: write
    </td>
  </tr>
</tbody>
</table>

### Applying a reviewed changeset

`push_changes` applies a [reviewed changeset](https://docs.oz0.ai/govern/reviewed-changes)'s patch (`repository`, `base`, `patch` as an `oz0-file`) onto its base commit as one commit on a branch of its own, then opens a draft pull request. It refuses a patch that does not fit the base or whose bytes differ from its name. A `base` of `default` means the default branch's tip, which a rollback uses.

For an agent's changeset, it writes [where the change came from](https://docs.oz0.ai/govern/reviewed-changes/changesets#where-a-change-came-from) as the commit's `OZ-` trailers and a section of the pull request's body. These come from the platform's records, never the function's arguments.

`download_archive` sends the token on the API's request only: GitHub answers with a redirect to the archive's own host (`codeload.github.com`, which the plugin's egress names; on GitHub Enterprise Server `<host>/_codeload`, or `codeload.<host>` with subdomain isolation), whose link carries a short-lived token of its own. A pull request from a fork downloads from the base repository at its head commit, which GitHub keeps there too, so it needs no access to the fork.

The bytes never reach a model or a job's history: the runtime keeps them on the edge, by their digest, and hands them to the function that names the file as a file of its own on the node ([files between tools](https://docs.oz0.ai/reference/plugin-manifest/tools#files-between-tools)).

Every function reaches GitHub, so all are open-world for the [tool policy](https://docs.oz0.ai/govern/tool-policy). What a function answers from GitHub, titles, bodies, comments, diffs and files, was written by someone outside: the agents that read it are told it is data, not instructions.

What an agent writes for people, an issue's body, a comment, a pull request's description, a review and each of its line comments, starts with a small line that carries the tenant's [AI label](https://docs.oz0.ai/govern/ai-labels), `<sub>[AI agent]</sub>` unless you word it otherwise; with the label turned off it has none.

### Long files and diffs

From version 1.2.0 `get_file` and `get_pull_request_diff` follow the convention of
[results that fit a budget](https://docs.oz0.ai/build/parts/tools/results-and-files#results-that-fit-a-budget): with `budget` (tokens), `view` or `query`
they answer with the biggest view that fits, and a `fit` field (also in the result's `_meta` under `oz0/fit`) that
names the view, the sizes and what was left out.

<table>
<thead>
  <tr>
    <th>
      View
    </th>
    
    <th>
      <code>
        get_pull_request_diff
      </code>
    </th>
    
    <th>
      <code>
        get_file
      </code>
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        full
      </code>
    </td>
    
    <td>
      The whole diff
    </td>
    
    <td>
      The whole file
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        section
      </code>
    </td>
    
    <td>
      The hunks of the files <code>
        query
      </code>
      
       names (a path, the end of one, or a part of one), or one hunk: <code>
        src/app.py#2
      </code>
    </td>
    
    <td>
      The part under a Markdown heading, a top-level definition by name (<code>
        def
      </code>
      
      , <code>
        class
      </code>
      
      , <code>
        func
      </code>
      
      , <code>
        fn
      </code>
      
      , <code>
        type
      </code>
      
       and the like), or a range of lines such as <code>
        120-180
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        summary
      </code>
    </td>
    
    <td>
      Each file with its added and deleted lines, and each hunk's <code>
        @@
      </code>
      
       line
    </td>
    
    <td>
      Each heading with its first sentence, or each top-level definition with its lines
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        stub
      </code>
    </td>
    
    <td>
      How many files and lines changed, and the files
    </td>
    
    <td>
      The file's size and outline
    </td>
  </tr>
</tbody>
</table>

Sections hold whole hunks, whole parts and whole lines only: a hunk too big for the budget is not cut, and the answer says how big it is. The runtime gives an agent's call a budget when its model leaves it out (at most 8,000 tokens, less when the agent's context is filling up), so a large pull request reaches the model as its file list and hunks first.

A fitted answer holds at most 50,000 tokens whatever the budget, at most 8 characters for each token of the budget, and no more characters than the answer without the arguments (100,000 for a file, 120,000 for a diff); past a bound, `truncated` is set and the answer is a smaller view. Without `view`, `budget` and `query` the two functions answer as before.

### Approvals and the plugin's rules

Every write asks a person in the [approvals inbox](https://docs.oz0.ai/agents/approvals) by default, whichever agent calls it:

#### `merge_pull_request` and `push_changes` always ask

Both functions set `requires_approval`. A merge lands on a branch others build from, and `push_changes` runs after a [changes approval](https://docs.oz0.ai/govern/reviewed-changes), which stands for it.

`push_changes` refuses, before it writes anything, a patch that touches a plugin's tests or rules without the second approval they need, a patch that touches CODEOWNERS or a CI workflow unless that approval names exactly those files, and a patch of a plugin's files the platform did not check ([protected paths](https://docs.oz0.ai/govern/reviewed-changes/protected-paths#protected-paths)); a person's own change, such as a rollback a person asked for, passes.

The install screen shows them as "Ask, set by the plugin", with no switch; only a tenant's refusal stops them sooner.

#### The other writes ask through the plugin's suggested policy

The suggested policy uses the reason "the plugin asks before it writes" for `create_issue`, `comment`, `create_pull_request`, `review_pull_request`, `add_labels`, `remove_label`, `close` and `delete_branch`. The install writes them as rules of the tenant's tool policy, marked as the plugin's, after the tenant's own rules.

An admin who trusts an agent's GitHub writes switches them, with a reason: `oz0 plugin policy github --set write=allow --reason "the triage agent labels and comments"` (`destructive=allow` for `close` and `delete_branch`).

From 1.4.0; up to 1.3.0, `close` and `delete_branch` were under `requires_approval`, and an update from one of those keeps asking about them until an admin switches them ([Upgrades](https://docs.oz0.ai/fleet/upgrades/policy-changes#verified-plugins-writes-ask-by-default-through-the-plugins-suggested-policy)).

The writes that stay in the repository say so in their listing (`_meta` `oz0/class: write`), so the install screen
shows them as Write rather than Sends out once installed from the official index. Before any write, the plugin's own
hook, `oz0_github.policy`, checks what no person should have to:

- a merge names the head commit it merges, so what was approved is what lands;
- protected branches (`main`, `master`, `develop`, `trunk`, `release/*`, `releases/*`, `gh-pages`, and the patterns in the tool's setting `GITHUB_PROTECTED_BRANCHES`) are never deleted from here;
- with `GITHUB_ALLOWED_REPOS` set (such as `acme/*`), writes stay in those repositories.

A denied call is not made, and the agent reads why. Hooks fail closed: one that does not answer in 2 seconds denies.

## The agent other plugins use

`oz0-github` reads before it writes, merges only the head it read, and answers with the links GitHub gave back. Another plugin's agent hands it a task instead of calling GitHub itself, and declares it in its plugin's `uses` (see [Install and update](https://docs.oz0.ai/build/plugins/install)):

```yaml [oz0-plugin.yaml]
uses:
  github:
    version: ">=1.0, <2"
    source: hub:ai.oz0/github
    agents: [oz0-github]
```

```yaml [agents/release-notes.yaml]
name: acme-release-notes
description: Writes release notes from the merged pull requests.
runtime: pydantic-ai
model:
  primary: anthropic/claude-sonnet-5-5
instructions: prompts/release-notes.md
input: text
output: text
tools: [oz.delegate]
delegation:
  allow: [github/oz0-github]   # github's agent, by its address
```

The token, the approvals and the hook then apply to that work too, and the other plugin needs no GitHub secret at all.

## Events

The source `github` takes GitHub's webhook deliveries at `https://<edge>/events/<tenant>/github`, checks each one's `X-Hub-Signature-256` with the tenant secret `GITHUB_WEBHOOK_SECRET`, and turns it into events of the types `pull_request`, `pull_request_review`, `check_suite`, `workflow_run`, `issue_comment` and `push`, with subjects such as `octo/demo#12` (a pull request or an issue) and `octo/demo@main` (a push). See [Event sources](https://docs.oz0.ai/build/parts/event-sources) for what an event carries.

With the GitHub App, the app's webhooks already go there with the right secret, which its connection saved. With a token, add a webhook to the repository (content type `application/json`) and set the same secret on both sides, in the tenant as a signing key bound to the edge's address GitHub posts to:

```bash [Terminal]
openssl rand -hex 32 | tee /dev/stderr | oz0 secret set GITHUB_WEBHOOK_SECRET --signing --host <edge>:7443
```

A [routine](https://docs.oz0.ai/build/parts/routines#events) that greets each new pull request, as its own identity and with only the tools it lists:

```yaml [greet-prs.yaml]
name: greet-prs
description: Greets each new pull request
agent: oz0-github
input: Greet the pull request in the trigger data.
owner_team: dev
tools: [oz0_github.get_pull_request, oz0_github.comment]
triggers:
  - name: opened
    event:
      source: github
      types: [pull_request]
      filter: event.action == 'opened'
limits: { budget_usd: 0.5, daily_budget_usd: 5 }
```

A job an outside event started runs its tool calls in the node's [sandbox](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes), whose egress allows `api.github.com` and `codeload.github.com` (where GitHub sends `download_archive`'s archive): the token such a call gets is the one minted for it, not a key that mints more.

## The merge-when-green flow

`ai.oz0/github` is a [connector](https://docs.oz0.ai/build/plugins/structure#four-plugin-kinds): GitHub's tools, its connection, its events and
one delegate agent, and no business logic. The flow `oz0-github-merge-when-green`, which up to 1.2.0 came with it, is
its own plugin since 1.3.0, the capability `ai.oz0/merge-when-green`, which reaches GitHub through the four
`oz0_github` functions its `uses:` names. It reads a pull request and its head's checks, and merges exactly that head
when the checks passed and it is not a draft (a person approves, as for every merge), or comments why it waits.

To keep the flow, upgrade `github` to 1.3.0 first, then install `merge-when-green` (routines that run the flow by
name keep working):

```bash [Terminal]
oz0 plugin install https://github.com/orchestrator-zero/orchestrator-zero --path plugins/merge-when-green
oz0 run oz0-github-merge-when-green '{"repo": "acme/api", "number": 42}'
```

Its eval cases replay recorded step outputs (`evals/steps/merge-when-green/`), so its quality gate checks the flow's logic at every install and update with no call to GitHub or a model, and `oz0 plugin test` runs them the same way.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
