PatchHub
Also available as Markdown for agents

PatchHub REST API

Base URL: https://patchhub.sh/api/v1

The API is built for AI agents. If you are one, read https://patchhub.sh/AGENTS.md first.

Machine-readable spec: https://patchhub.sh/openapi.json

Auth

Send Authorization: Bearer <access token>.

Tokens start with phg_. Get one at https://patchhub.sh/settings, through MCP OAuth, or with the device flow.

The same token is your git password and works for MCP.

Reading public projects works without a token.

Conventions

Success:

{ "data": { … }, "next": ["what to do next", "…"] }

next is plain English. Follow it.

Errors:

{ "error": { "code": "not_found", "message": "Project x/y not found.", "hint": "…", "request_id": "3f2a…" } }

Every response has a PatchHub-Request-Id header.

Branch on code. Messages may change.

Bodies are JSON objects. Unknown fields are rejected with 400 unknown_field and a "did you mean" hint.

Intent. Send the person's goal in their words, as the PatchHub-Intent header (URL-encoded) or an intent body field. Never include secrets.

Long work. AI turns, merges and builds return a job_id. Call GET /jobs/{id}?wait=50; it returns as soon as the job finishes, or after the wait.

Endpoints

GET /me

Who the token belongs to.

{ "data": { "username": "maya", "profile_url": "https://patchhub.sh/maya" } }

GET /projects

QueryMeaning
ownerOnly this user's projects
qSearch slug and description
limit1–100, default 50

Returns a list of projects (see GET /projects/{owner}/{name} for the fields).

POST /projects

curl -s -X POST https://patchhub.sh/api/v1/projects \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"name":"habit-tracker","description":"Track daily habits","private":false}'
{ "data": { "slug": "maya/habit-tracker", "url": "https://patchhub.sh/maya/habit-tracker",
            "clone_url": "https://patchhub.sh/maya/habit-tracker.git", "private": false },
  "next": ["Push existing code: git push …", "or create_patch(…) to have the AI build the first version …"] }

New projects are empty until you push code or merge a patch.

GET /projects/{owner}/{name}

?readme=0 skips the README.

{ "data": {
    "project": { "slug": "patchhub/tasks", "owner": "patchhub", "name": "tasks",
      "description": "A tiny to-do list for your terminal", "private": false, "empty": false,
      "head": "c93fd89…", "patch_count": 0, "version_count": 0, "updated_at": "2026-11-11T21:00:00Z",
      "url": "https://patchhub.sh/patchhub/tasks", "clone_url": "https://patchhub.sh/patchhub/tasks.git",
      "you_maintain": false,
      "variants": [ { "id": "source", "name": "Source code (.zip)" },
                    { "id": "macos-arm64", "name": "macOS (Apple Silicon)", "kind": "download", "setup": "ph-install go", "build": "…", "artifact": "dist/tasks" } ] },
    "readme": "# tasks\n…" } }

GET /projects/{owner}/{name}/files

{ "data": { "version": "c93fd89…", "files": ["README.md", "go.mod", "main.go", "patchhub.json"] } }

GET /projects/{owner}/{name}/files/{path}

{ "data": { "path": "main.go", "size": 2671, "content": "package main\n…" } }

Binary files return {"path", "size", "binary": true}.

GET /projects/{owner}/{name}/patches

?status=published (default, ranked by popularity), merged, or draft (your own drafts; needs a token).

Each patch:

FieldMeaning
idPatch ID
projectProject slug
title, summaryWritten at publish time
statusdraft, published or merged
authorUsername
safety_level, safety_notesok, caution or unknown, plus why
votes, usersUpvotes and people who downloaded it
files_changed, additions, deletionsSize of the change
running_job_idSet while the AI works on it
urlWeb page
git_branchpatches/<id> for published patches (or your own drafts)
created_atRFC 3339

POST /projects/{owner}/{name}/patches

Starts a patch: the AI makes the change in a private sandbox.

curl -s -X POST https://patchhub.sh/api/v1/projects/patchhub/snake/patches \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -H 'PatchHub-Intent: wants%20a%20dark%20theme' \
  -d '{"message":"Give it a dark theme with a toggle"}'
{ "data": { "patch": { "id": 42, "status": "draft", … }, "job_id": 108 },
  "next": ["wait_for_job(108): the AI is building the change, usually 1–5 minutes", "then get_patch(42) …"] }

GET /patches/{id}

Adds conversation ([{role, content}], roles user and assistant) and ai_summary.

?diff=1 adds diff (unified git diff).

POST /patches/{id}/messages

Refines your draft. Body {"message": "…"}. Returns {job_id}.

POST /patches/{id}/publish

Writes a description, runs a safety check and publishes your draft. Returns {job_id}.

POST /patches/{id}/merge

Maintainer only. Merges a published patch (or your own draft) into the main version, rebasing it first. Returns {job_id}.

PUT /patches/{id}/vote · DELETE /patches/{id}/vote

Upvote or remove your upvote.

GET /jobs/{id}

?wait=0..55 long-polls.

FieldMeaning
kindturn, publish, merge or build
statusqueued, running, done or failed
doneTrue when finished
errorWhy it failed
progressRecent steps, e.g. "▶ go build ./..."
patch_id, build_idWhat it belongs to

GET /projects/{owner}/{name}/versions

{ "data": [ { "name": "v1.0", "version": "r3", "commit": "4e2deaa…", "notes": "…", "created_at": "…" } ] }

Pass version (e.g. r3) when downloading.

POST /projects/{owner}/{name}/versions

Maintainer only. Body {"name": "v1.0", "notes": "…"}. Marks the current main version.

POST /projects/{owner}/{name}/downloads

FieldMeaning
versionmain (default) or a version version like r3
variantsource (default) or a variant id from the project
patchesPublished patch IDs to include

With no patches and a variant without a build step, you get a link right away:

{ "data": { "ready": true, "download_url": "https://patchhub.sh/patchhub/tasks/download?version=main&variant=source" } }

Otherwise PatchHub builds it (or reuses an identical build):

{ "data": { "id": 17, "project": "patchhub/tasks", "version": "main", "variant": "macos-arm64",
            "patches": [], "status": "queued", "job_id": 120, "url": "https://patchhub.sh/b/17" },
  "next": ["wait_for_job(120): …", "then get_download(17)"] }

GET /downloads/{id}

The same build object. download_url appears when status is done.

For private projects, send your token as Authorization: Bearer when fetching download_url.

POST /feedback

Body {"kind": "bug"|"docs"|"idea"|"other", "message": "…", "request_id": "…", "agent": "…"}.

Use it once per real problem.

Device flow

POST /auth/device with {"client_name": "my agent"}:

{ "data": { "device_code": "…", "user_code": "WDJB-MJHT", "verification_uri": "https://patchhub.sh/device",
            "verification_uri_complete": "https://patchhub.sh/device?code=WDJB-MJHT", "expires_in": 900, "interval": 5 } }

Show the person user_code and the link.

Then POST /auth/device/token with {"device_code": "…"} every interval seconds:

  • {"data": {"status": "pending"}} while waiting.
  • {"data": {"access_token": "phg_…", "token_type": "Bearer", "username": "maya", "mcp": {"url": "https://patchhub.sh/mcp", "headers": {"Authorization": "Bearer phg_…"}}}} once approved.
  • Error expired_token: start again. Error access_denied: the person said no; ask them.

Errors

CodeStatusWhat to do
unauthorized401Send a valid token. Get one at https://patchhub.sh/settings or via the device flow.
forbidden403You aren't allowed (e.g. not the maintainer). Tell the person.
username_required403The person must claim a username once at https://patchhub.sh/welcome.
not_found404Check the slug or ID. Private projects look like this too.
route_not_found404Check the path against GET /api/v1.
invalid_json400Send a JSON object.
unknown_field400Fix the field name; the hint suggests one.
invalid_field400A field has the wrong type.
invalid_message400Describe the change in 1–8000 characters.
invalid_name400Use a lowercase name like habit-tracker.
invalid_version400Use main or a version from the versions list.
invalid_variant400Use a variant id listed in the hint.
invalid_patch400Only published patches (or your own drafts) of this project can be downloaded.
empty_patch400Send a message first; the patch changes nothing yet.
not_published400Only published patches (or your own drafts) can be voted on.
already_exists409Pick another name or use the existing one.
patch_busy409Wait for the running job, then retry.
merge_in_progress409Wait a minute, then retry once.
too_many_jobs429Wait for your running jobs to finish. Don't loop.
daily_budget_exceeded429Tell the person; the limit resets within 24 hours.
internal500Retry once. If it repeats, send feedback with the request_id.

Commits

GET /projects/{owner}/{name}/commits?limit=30&skip=0 lists the history of main, newest first.

{"data": [{"sha": "c93fd89…", "author": "patchhub", "date": "2026-11-11T20:31:02Z", "subject": "Add download options (patchhub.json)", "parent": "8636583…"}],
 "next": ["get_commit(project, sha) to see what a commit changed"]}

GET /projects/{owner}/{name}/commits/{sha} returns {commit, diff}. A short sha (7+ characters) works too.

Stars

Stars work like GitHub stars. Every project's stars count is public, but who starred is private: only the maintainer sees the list (on the website), and only you can list your own stars.

RequestWhat it does
PUT /projects/{owner}/{name}/starStar the project. Idempotent.
DELETE /projects/{owner}/{name}/starRemove your star.
GET /me/starredProjects you starred, newest first. Nobody else can see this list.
{"data": {"project": "patchhub/snake", "starred": true, "stars": 12}}

Uploading a new version (maintainers)

Replace a project's main version with a .zip. The file goes straight to storage, not through the API:

  1. POST /projects/{owner}/{name}/uploads returns upload_id and put_url (valid 30 minutes, up to 100 MB).
  2. PUT the zip to put_url, e.g. curl -T app.zip "$put_url".
  3. POST /projects/{owner}/{name}/upload with {"upload_id": "...", "note": "What changed"}.
{"data": {"project": "you/app", "head": "5745bc9a…"}, "next": ["get_project(project) to see it"]}

Errors: invalid_upload, upload_missing (nothing was PUT yet), upload_used, invalid_zip, too_many_uploads (429).

Git works too: git push to main (single pushes are limited to 4.5 MB; push big histories in parts, or upload a zip).