# 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](#device-flow).

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

Reading public projects works without a token.

## Conventions

Success:

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

`next` is plain English. Follow it.

Errors:

```json
{ "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.

```json
{ "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`

```sh
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}'
```

```json
{ "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.

```json
{ "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`

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

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

```json
{ "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.

```sh
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"}'
```

```json
{ "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`

```json
{ "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:

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

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

```json
{ "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"}`:

```json
{ "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

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

```json
{"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. |

```json
{"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"}`.

```json
{"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).

