Skip to main content
A deterministic, in-process digital twin of the GitHub REST API. It covers issues, pull requests, comments, labels, repositories, combined commit status, and the parts of the search API that agents tend to reach for. Each instance starts from a task seed and resets between runs, so the same task produces the same state every time.
Never driven one? Get started starts a sandbox, triages the bug in it from your own coding agent, and reads back the recorded tape. Nothing graded, no GitHub account. This page is the reference for what that twin serves.

By use case

These are the workflows agents actually run against this twin, each naming the MCP tools and REST surfaces involved. Anything named here that is not fully modelled says so in place.

Triage an incoming issue

Read the issue, apply the right label, assign an owner, and leave a comment explaining the call. MCP: issue_read, issue_write, add_issue_comment. REST: GET /repos/:owner/:repo/issues/:number, GET /repos/:owner/:repo/issues/:number/labels, POST /repos/:owner/:repo/issues/:number/labels, DELETE /repos/:owner/:repo/issues/:number/labels/:name, POST /repos/:owner/:repo/issues/:number/assignees, POST /repos/:owner/:repo/issues/:number/comments.

Review a pull request

Pull the diff and file list, read existing review threads, and leave a review or inline comment. MCP: pull_request_read, create_pull_request_review, pull_request_review_write, add_reply_to_pull_request_comment. REST: GET /repos/:owner/:repo/pulls/:number, GET /repos/:owner/:repo/pulls/:number/files, GET /repos/:owner/:repo/pulls/:number/commits, GET /repos/:owner/:repo/pulls/:number/diff, GET /repos/:owner/:repo/pulls/:number/reviews, POST /repos/:owner/:repo/pulls/:number/comments. GET /repos/:owner/:repo/pulls/:number/diff is shape only — see What you can rely on before you grade on it.

Merge a PR and check CI status

Read the combined commit status or check runs before merging, and post a status back. MCP: merge_pull_request, update_pull_request_branch. REST: POST /repos/:owner/:repo/statuses/:sha, GET /repos/:owner/:repo/commits/:ref/status, POST /repos/:owner/:repo/check-runs, GET /repos/:owner/:repo/commits/:ref/check-runs, PUT /repos/:owner/:repo/pulls/:number/merge, PUT /repos/:owner/:repo/pulls/:number/update-branch.

Manage labels and milestones

Create the taxonomy a repo needs, then move issues through it. REST: GET /repos/:owner/:repo/labels, POST /repos/:owner/:repo/labels, GET /repos/:owner/:repo/milestones, POST /repos/:owner/:repo/milestones, PATCH /repos/:owner/:repo/milestones/:number, DELETE /repos/:owner/:repo/milestones/:number.

Search across a repo

Find the issue, PR, commit, or file a task is actually about instead of walking every list. MCP: search_code, search_issues, search_commits, search_users, search_repositories. REST: GET /search/code, GET /search/issues, GET /search/commits, GET /search/repositories, GET /search/users.

Work with branches and commits

Cut a branch, push a fix, and compare it against a base. MCP: create_branch, list_branches, push_files, create_or_update_file, delete_file, list_commits, get_commit. REST: POST /repos/:owner/:repo/git/refs, GET /repos/:owner/:repo/contents/*, PUT /repos/:owner/:repo/contents/*, DELETE /repos/:owner/:repo/contents/*, GET /repos/:owner/:repo/commits/:ref, GET /repos/:owner/:repo/compare/:basehead. GET /repos/:owner/:repo/compare/:basehead is shape only — see What you can rely on before you grade on it.

Publish releases and tags

Cut a release once a milestone’s work lands. MCP: list_releases, get_latest_release, get_release_by_tag, list_tags, get_tag. REST: GET /repos/:owner/:repo/releases, POST /repos/:owner/:repo/releases, GET /repos/:owner/:repo/releases/latest, GET /repos/:owner/:repo/releases/tags/:tag, GET /repos/:owner/:repo/tags.

What you can rely on

Each surface below carries two rulings that are deliberately kept apart: heat is how deep it should be, ruled per milestone; state is how deep it is today, measured by the twin’s own tests. Neither moves because the other did. A surface is in exactly one state, and the six below are different answers to “can I write a task against this”: @pome-sh/twin-github declares 36 MCP tools and 71 REST surfaces, read from the twin’s own fidelity.inventory.json (last updated 2026-08-09):

Shape only

Read these; do not grade on them. The response shape is right and the behaviour behind it is not asserted, so a criterion that checks a value here can pass for the wrong reason.

Out of scope

GitHub Actions execution, billing, Copilot APIs, and real OAuth flows are not modelled at all — the twin hands out a static token, and an agent that needs real OAuth should run against GitHub directly. These surfaces are named rather than left to the catch-all, so the 501 is documented and test-backed:

Known divergence, ruled

29 divergences are on the ledger for GitHub: measured against the real API, found to differ, reviewed, and accepted. Each is registered and reverse-tested, so one that upstream heals becomes a signal rather than a surprise. The numbers are stable identifiers, not positions — a retired divergence leaves its number behind, so a gap in this list is a divergence that closed.

Twin-only tool

The twin serves it and the vendor’s published tool list does not declare it. An agent that learns to use it here would be refused in production.

Vendor-only tool

The vendor declares it and the twin answers unknown_tool. An agent doing the right thing is marked down for it. Where it runs. Both doors serve the same twin: pome twin start github boots it as a local process, and pome sandbox create --twin github starts a hosted sandbox running the same image. The states above are properties of the twin, so they hold on either. Everything above is what the twin declares. What it was measured to do is a different number: the comparison against real GitHub runs daily and publishes, surface by surface, how many matched, which drifted, and which are ruled exceptions with the reason written out in full — at status.pome.sh/twin/github, which also states when it last ran and how old the captured baseline it compares against is. No count is copied onto this page, because copying one is how it goes stale. Read from FIDELITY.md and fidelity.inventory.json in pome-sh/digital-twins, at the commit .github/twins-ref pins (2e41939).

Quickstart

That task boots the GitHub twin, seeds a world from the task, hands your agent a base URL and a token, and scores the run once the agent exits.

Point your agent at it

For interactive development without a full task run, start the standalone twin:
The command prints env-var lines you can paste into your agent’s environment:
POME_GITHUB_REST_URL is the REST root. POME_GITHUB_MCP_URL is the MCP transport endpoint. POME_AUTH_TOKEN is a short-lived JWT that authenticates every request on the sandbox. The twin speaks the GitHub REST shape. Point any existing GitHub client at POME_GITHUB_REST_URL and the same code path runs. The session path /s/standalone/healthz requires the JWT and returns 401 without it. To check whether the twin is alive, use the unauthenticated root health endpoint:
The MCP transport itself (POST /mcp/call, POST /mcp/tools/:name) and Pome’s own introspection routes are engine surface rather than GitHub’s, so they carry no heat ruling and are not in the tables above.

Task seed shape

GitHub task seeds are flat, with users and repositories. A repository carries its own labels, files, issues and pull_requests. Generate it; do not copy it. The block below is what the twin starts with, printed by the CLI from the twin’s own declared state — so it parses against the twin you are about to seed, today and after the twin changes:
A seed replaces the twin’s starting state. It does not merge into it. Seed your own world and everything below is gone — drop a repository and GET /repos/acme/api answers 404.
Set twins: ["github"] in the task’s ## Config block and name the file <task>.seed.json, beside the task’s .md. That ## Config is the only place the twin gets named: the file above is flat, so it carries no twin id of its own.

Seed file

A task is not the only way in. pome twin start and pome sandbox create take that very same file — no wrapper, no second shape, nothing to convert — and each takes the twin’s name beside it, since the file cannot supply one:
Omit the name and neither door guesses; both stop and say the seed is flat. Add a second twin and the file becomes a per-twin envelope instead, one key each — pome twin new-seed github slack writes it:
One twin, one flat file: the twin’s own world with no wrapper, which means it says nothing about which twin it is for. That is why twin start and sandbox create want the name as well, and why a task’s sidecar does not — the ## Config beside it already named the twin. From two twins up the file is a per-twin envelope { <twin>: <seed> }, which does name its twins, though twin start still has to be told which one of them to boot. Replace, not merge, applies wherever the file lands. Build your own world is that job end to end — generating the file, editing it down, both doors, and how to tell whether every field you wrote actually landed.

Example tasks

Ready-made examples you can run or copy to see the twin in action:

Catalog

Run one

Or let the coach pick and run a matching task for you — the pome-suggest-tasks and pome-run-task skills, installed by the graded capstone.