Plugins

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

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 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 with access to the repositories your agents work on, as the tenant secret GITHUB_TOKEN.

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:

Terminal
oz0 connection start github --param owner=acme --open
oz0 connection list

If it does not work: see 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. 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):

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, 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).

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.

FunctionDoesAnnotationsToken's permissions
get_repositoryA repository: description, default branch, visibilityread-onlymetadata: read
list_issues, get_issueIssues, without pull requests; one with its bodyread-onlyissues: read
list_pull_requests, get_pull_requestPull requests; one with its head and base (ref and SHA), draft, mergeableread-onlypull_requests: read
get_pull_request_diff, list_pull_request_filesThe unified diff (cut at 120 000 characters, or fitted to a budget), the changed filesread-onlypull_requests: read
get_fileA file's text at a ref (cut at 100 000 characters, or fitted to a budget)read-onlycontents: read
list_checksA ref's check runs and statuses, and their combined state: success, failure, pending or noneread-onlychecks: read, statuses: read
list_commentsComments on an issue or a pull requestread-onlyissues: read
searchIssues and pull requests, code or repositories, in GitHub's search syntaxread-onlyevery repository
download_archiveThe repository's code at one commit (sha, the full 40 characters), as a file for another function, such as pr-reviewer's run_tests: the bytes go to the edge, the answer names them oz0-file:sha256:<hex> (at most 32 MiB)read-onlycontents: read
resolve_refThe full SHA of the commit a branch, a tag (heads/<name> or tags/<name> to say which; an annotated tag is followed to its commit) or a commit names now, and which kind it is: the sha that download_archive and a reviewed changeset's base take; the default branch when ref is emptyread-onlycontents: read
check_installationThe repositories the GitHub App's installation may use; the connection runs it after connectingread-onlymetadata: read, every repository
create_issueOpens an issue; idempotency_key writes it oncewrites, asks by defaultissues: write
commentComments on an issue or a pull request; idempotency_key writes it oncewrites, asks by defaultissues, pull_requests: write
create_pull_requestOpens a pull request; again for the same head and base, answers the open onewrites, idempotent, asks by defaultpull_requests: write
review_pull_requestCOMMENT, APPROVE or REQUEST_CHANGES, with comments on lineswrites, asks by defaultpull_requests: write
add_labels, remove_labelLabelswrites, idempotent, asks by defaultissues, pull_requests: write
merge_pull_requestMerges at the head sha it names (GitHub refuses if the branch moved)destructive, always askscontents, pull_requests: write
closeCloses an issue or a pull request without mergingdestructive, asks by defaultissues, pull_requests: write
delete_branchDeletes a branch, such as a merged headdestructive, asks by defaultcontents: write
push_changesApplies an approved patch as one commit on its own branch and opens a draft pull request. See checks and provenance.writes, idempotent, always asks (a changes approval stands for it)contents, pull_requests: write

Applying a reviewed changeset

push_changes applies a reviewed changeset'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 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).

Every function reaches GitHub, so all are open-world for the 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, <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: 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.

Viewget_pull_request_diffget_file
fullThe whole diffThe whole file
sectionThe hunks of the files query names (a path, the end of one, or a part of one), or one hunk: src/app.py#2The part under a Markdown heading, a top-level definition by name (def, class, func, fn, type and the like), or a range of lines such as 120-180
summaryEach file with its added and deleted lines, and each hunk's @@ lineEach heading with its first sentence, or each top-level definition with its lines
stubHow many files and lines changed, and the filesThe file's size and outline

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 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, 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); 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).

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):

oz0-plugin.yaml
uses:
  github:
    version: ">=1.0, <2"
    source: hub:ai.oz0/github
    agents: [oz0-github]
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 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:

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

A routine that greets each new pull request, as its own identity and with only the tools it lists:

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, 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: 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):

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.

Copyright © 2026