GitHub
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>.
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.
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:
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):
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.
| Function | Does | Annotations | Token's permissions |
|---|---|---|---|
get_repository | A repository: description, default branch, visibility | read-only | metadata: read |
list_issues, get_issue | Issues, without pull requests; one with its body | read-only | issues: read |
list_pull_requests, get_pull_request | Pull requests; one with its head and base (ref and SHA), draft, mergeable | read-only | pull_requests: read |
get_pull_request_diff, list_pull_request_files | The unified diff (cut at 120 000 characters, or fitted to a budget), the changed files | read-only | pull_requests: read |
get_file | A file's text at a ref (cut at 100 000 characters, or fitted to a budget) | read-only | contents: read |
list_checks | A ref's check runs and statuses, and their combined state: success, failure, pending or none | read-only | checks: read, statuses: read |
list_comments | Comments on an issue or a pull request | read-only | issues: read |
search | Issues and pull requests, code or repositories, in GitHub's search syntax | read-only | every repository |
download_archive | The 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-only | contents: read |
resolve_ref | The 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 empty | read-only | contents: read |
check_installation | The repositories the GitHub App's installation may use; the connection runs it after connecting | read-only | metadata: read, every repository |
create_issue | Opens an issue; idempotency_key writes it once | writes, asks by default | issues: write |
comment | Comments on an issue or a pull request; idempotency_key writes it once | writes, asks by default | issues, pull_requests: write |
create_pull_request | Opens a pull request; again for the same head and base, answers the open one | writes, idempotent, asks by default | pull_requests: write |
review_pull_request | COMMENT, APPROVE or REQUEST_CHANGES, with comments on lines | writes, asks by default | pull_requests: write |
add_labels, remove_label | Labels | writes, idempotent, asks by default | issues, pull_requests: write |
merge_pull_request | Merges at the head sha it names (GitHub refuses if the branch moved) | destructive, always asks | contents, pull_requests: write |
close | Closes an issue or a pull request without merging | destructive, asks by default | issues, pull_requests: write |
delete_branch | Deletes a branch, such as a merged head | destructive, asks by default | contents: write |
push_changes | Applies 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.
| View | get_pull_request_diff | get_file |
|---|---|---|
full | The whole diff | The whole file |
section | The hunks of the files query names (a path, the end of one, or a part of one), or one hunk: src/app.py#2 | The 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 |
summary | Each file with its added and deleted lines, and each hunk's @@ line | Each heading with its first sentence, or each top-level definition with its lines |
stub | How many files and lines changed, and the files | The 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 settingGITHUB_PROTECTED_BRANCHES) are never deleted from here; - with
GITHUB_ALLOWED_REPOSset (such asacme/*), 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):
uses:
github:
version: ">=1.0, <2"
source: hub:ai.oz0/github
agents: [oz0-github]
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:
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:
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):
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.