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
| Query | Meaning |
|---|---|
owner | Only this user's projects |
q | Search slug and description |
limit | 1–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:
| Field | Meaning |
|---|---|
id | Patch ID |
project | Project slug |
title, summary | Written at publish time |
status | draft, published or merged |
author | Username |
safety_level, safety_notes | ok, caution or unknown, plus why |
votes, users | Upvotes and people who downloaded it |
files_changed, additions, deletions | Size of the change |
running_job_id | Set while the AI works on it |
url | Web page |
git_branch | patches/<id> for published patches (or your own drafts) |
created_at | RFC 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.
| Field | Meaning |
|---|---|
kind | turn, publish, merge or build |
status | queued, running, done or failed |
done | True when finished |
error | Why it failed |
progress | Recent steps, e.g. "▶ go build ./..." |
patch_id, build_id | What 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
| Field | Meaning |
|---|---|
version | main (default) or a version version like r3 |
variant | source (default) or a variant id from the project |
patches | Published 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. Erroraccess_denied: the person said no; ask them.
Errors
| Code | Status | What to do |
|---|---|---|
unauthorized | 401 | Send a valid token. Get one at https://patchhub.sh/settings or via the device flow. |
forbidden | 403 | You aren't allowed (e.g. not the maintainer). Tell the person. |
username_required | 403 | The person must claim a username once at https://patchhub.sh/welcome. |
not_found | 404 | Check the slug or ID. Private projects look like this too. |
route_not_found | 404 | Check the path against GET /api/v1. |
invalid_json | 400 | Send a JSON object. |
unknown_field | 400 | Fix the field name; the hint suggests one. |
invalid_field | 400 | A field has the wrong type. |
invalid_message | 400 | Describe the change in 1–8000 characters. |
invalid_name | 400 | Use a lowercase name like habit-tracker. |
invalid_version | 400 | Use main or a version from the versions list. |
invalid_variant | 400 | Use a variant id listed in the hint. |
invalid_patch | 400 | Only published patches (or your own drafts) of this project can be downloaded. |
empty_patch | 400 | Send a message first; the patch changes nothing yet. |
not_published | 400 | Only published patches (or your own drafts) can be voted on. |
already_exists | 409 | Pick another name or use the existing one. |
patch_busy | 409 | Wait for the running job, then retry. |
merge_in_progress | 409 | Wait a minute, then retry once. |
too_many_jobs | 429 | Wait for your running jobs to finish. Don't loop. |
daily_budget_exceeded | 429 | Tell the person; the limit resets within 24 hours. |
internal | 500 | Retry 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.
| Request | What it does |
|---|---|
PUT /projects/{owner}/{name}/star | Star the project. Idempotent. |
DELETE /projects/{owner}/{name}/star | Remove your star. |
GET /me/starred | Projects 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:
POST /projects/{owner}/{name}/uploadsreturnsupload_idandput_url(valid 30 minutes, up to 100 MB).PUTthe zip toput_url, e.g.curl -T app.zip "$put_url".POST /projects/{owner}/{name}/uploadwith{"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).