MuseHub API Reference
MuseHub exposes a JSON REST API for every platform operation — repo management, issues, proposals, releases, and webhooks. Authentication uses MSign, the same Ed25519-based scheme that signs commits. There are no passwords, no JWTs, no OAuth flows. Every write is signed; every read of a public resource is anonymous.
| Property | Value |
|---|---|
| Base URL | http://staging.musehub.ai/api/v1 |
| Auth scheme | MSign (Ed25519, no session tokens) |
| Content-Type | application/json |
| Pagination style | Cursor-based (opaque token, forward-only) |
| Rate limiting | Per-handle, sliding 60-second window |
| OpenAPI spec | /api/v1/openapi.json |
MSign Authentication
Every mutating request must carry an Authorization header signed with
the caller's Ed25519 private key. Read requests on public resources succeed without
any header. The server verifies the signature, checks the timestamp for replay
protection (±30 seconds), and rejects requests with a missing or expired
ts.
Header format
Authorization: MSign handle="gabriel" alg="ed25519" ts=1745280000 sig="<base64url>"
| Field | Type | Description |
|---|---|---|
handle | string | Registered MuseHub username — used to look up the public key |
alg | string | Always "ed25519" |
ts | integer | Unix timestamp (seconds). Request is rejected if |now - ts| > 30 |
sig | base64url | Ed25519 signature over the canonical message (no padding) |
Canonical message
The signed payload is a newline-delimited string:
{algo}\n{METHOD}\n{host}\n{path_with_query}\n{ts}\n{body_sha256_hex}
algo is always ed25519.
body_sha256_hex is the hex-encoded SHA-256 of the raw request body,
or the empty string for requests with no body (GET, DELETE).
path_with_query includes the leading / and any query
string — e.g. /api/v1/repos?cursor=abc&limit=20.
Signing with the CLI
# Generate the Authorization header
muse sign header \
--method POST \
--path /api/v1/repos/gabriel/muse/issues \
--hub http://staging.musehub.ai \
--json
# Sign and execute an HTTP request in one step
muse sign request \
--method POST \
--url http://staging.musehub.ai/api/v1/repos/gabriel/muse/issues \
--body-file payload.json \
--json
muse auth register --hub http://staging.musehub.ai --handle <you>.
The public key is stored on the hub and looked up by handle on every request.Pagination
All list endpoints use cursor-based pagination. Cursors are opaque strings —
do not attempt to decode or construct them. The forward-only model means there
is no page parameter and no reverse iteration.
Request parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | — | Opaque continuation token from a previous response. Omit for first page. |
limit | integer | 20 | Items per page. Range: 1–100. |
Response envelope
{
"total": 312,
"nextCursor": "eyJvZmZzZXQiOjIwfQ",
"repos": [ ... ]
}
nextCursor is null when no further pages exist.
The total field reflects the full result set at query time.
The response also includes a Link header with rel="next"
for clients that prefer header-based discovery:
Link: <http://staging.musehub.ai/api/v1/repos?cursor=eyJvZmZzZXQiOjIwfQ&limit=20>; rel="next"
Rate Limits
Limits are keyed on the authenticated handle (or IP for anonymous reads).
All windows are sliding 60-second buckets. Exceeding a limit returns
429 Too Many Requests with a Retry-After header
(seconds until the oldest request in the window expires).
| Endpoint group | Limit (per 60 s) |
|---|---|
| All endpoints (global ceiling) | 300 |
Wire push (POST /{owner}/{repo}/push) | 30 |
Wire fetch (POST /{owner}/{repo}/fetch) | 120 |
| Issue create | 20 |
| Issue comment | 60 |
| Anonymous reads | 60 (IP-keyed) |
Rate-limit state is returned on every response:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1745280041
Repos
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /repos | optional | List repos for the authenticated user (or public repos for anonymous) |
POST | /repos | required | Create a new repo |
GET | /repos/{owner}/{repo} | optional | Get repo metadata |
PATCH | /repos/{owner}/{repo} | required | Update repo metadata (description, visibility, domain) |
DELETE | /repos/{owner}/{repo} | required | Delete repo and all objects permanently |
POST | /repos/{owner}/{repo}/transfer | required | Transfer ownership to another handle |
Create repo — request body
POST /api/v1/repos
Content-Type: application/json
Authorization: MSign handle="gabriel" alg="ed25519" ts=1745280000 sig="..."
{
"name": "muse",
"description": "Content-addressed VCS engine",
"visibility": "public",
"domain": "code",
"default_branch": "main"
}
Repo object
{
"repo_id": "sha256:abc...",
"owner": "gabriel",
"name": "muse",
"slug": "gabriel/muse",
"description": "Content-addressed VCS engine",
"visibility": "public",
"domain": "code",
"default_branch": "main",
"created_at": "2025-01-15T09:00:00Z",
"updated_at": "2026-04-21T12:00:00Z",
"size_bytes": 4194304,
"commit_count": 1240,
"star_count": 88,
"fork_count": 3
}
Transfer ownership
POST /api/v1/repos/gabriel/muse/transfer
{"to": "carol"}
The repo immediately becomes carol/muse. The old slug returns
301 Moved Permanently for 30 days, then 404.
Issues
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /repos/{owner}/{repo}/issues | optional | List issues. Filter: ?status=open|closed&label=bug&assignee=handle |
POST | /repos/{owner}/{repo}/issues | required | Create issue |
GET | /repos/{owner}/{repo}/issues/{number} | optional | Get a single issue |
PATCH | /repos/{owner}/{repo}/issues/{number} | required | Update title, body, status, labels, assignees |
POST | /repos/{owner}/{repo}/issues/{number}/comments | required | Add a comment |
GET | /repos/{owner}/{repo}/issues/{number}/comments | optional | List comments (paginated) |
DELETE | /repos/{owner}/{repo}/issues/{number}/comments/{id} | required | Delete a comment (author or repo admin only) |
Create issue — request body
POST /api/v1/repos/gabriel/muse/issues
{
"title": "coord: claim file left behind after crash",
"body": "If the agent process is killed mid-claim...",
"labels": ["bug", "coord"],
"assignees": ["gabriel"]
}
Issue object
{
"number": 42,
"issue_id": "sha256:def...",
"title": "coord: claim file left behind after crash",
"body": "If the agent process is killed mid-claim...",
"status": "open",
"labels": ["bug", "coord"],
"assignees": ["gabriel"],
"author": "gabriel",
"created_at": "2026-04-01T08:00:00Z",
"updated_at": "2026-04-21T11:30:00Z",
"closed_at": null,
"comment_count": 3
}
Close / reopen
# Close
PATCH /api/v1/repos/gabriel/muse/issues/42
{"status": "closed"}
# Reopen
PATCH /api/v1/repos/gabriel/muse/issues/42
{"status": "open"}
Labels
# Replace label set atomically
PATCH /api/v1/repos/gabriel/muse/issues/42
{"labels": ["bug", "harmony", "needs-repro"]}
# Create a label on the repo
POST /api/v1/repos/gabriel/muse/labels
{"name": "harmony", "color": "#7c3aed", "description": "Conflict resolution layer"}
Proposals
Proposals are Muse's equivalent of pull requests — they describe a request to
merge one branch into another. Unlike pull requests, proposals are content-addressed:
the proposal object includes the head_commit_id at creation time so
reviewers always know exactly which snapshot they approved.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /repos/{owner}/{repo}/proposals | required | Create proposal |
GET | /repos/{owner}/{repo}/proposals | optional | List proposals. Filter: ?state=open|closed|merged |
GET | /repos/{owner}/{repo}/proposals/{id} | optional | Get proposal (includes review state) |
GET | /repos/{owner}/{repo}/proposals/{id}/diff | optional | Symbol-level diff between head and base |
POST | /repos/{owner}/{repo}/proposals/{id}/merge | required | Merge proposal (requester or repo admin) |
POST | /repos/{owner}/{repo}/proposals/{id}/comments | required | Add comment |
GET | /repos/{owner}/{repo}/proposals/{id}/comments | optional | List comments |
POST | /repos/{owner}/{repo}/proposals/{id}/reviews | required | Submit a review (approve / request-changes / comment) |
GET | /repos/{owner}/{repo}/proposals/{id}/reviews | optional | List reviews |
POST | /repos/{owner}/{repo}/proposals/{id}/reviewers | required | Request reviewer by handle |
DELETE | /repos/{owner}/{repo}/proposals/{id}/reviewers/{handle} | required | Remove reviewer request |
Create proposal — request body
POST /api/v1/repos/gabriel/muse/proposals
{
"title": "feat: harmony semantic tier",
"body": "Adds HarmonyPlugin protocol and Tanimoto similarity scoring.",
"from_branch": "feat/harmony-semantic",
"into_branch": "dev"
}
Proposal object
{
"proposal_id": "af54753d-...",
"number": 17,
"title": "feat: harmony semantic tier",
"body": "...",
"state": "open",
"from_branch": "feat/harmony-semantic",
"into_branch": "dev",
"head_commit_id": "sha256:abc...",
"base_commit_id": "sha256:def...",
"author": "gabriel",
"created_at": "2026-04-20T10:00:00Z",
"merged_at": null,
"closed_at": null,
"merge_strategy": null,
"reviewers": ["carol"],
"review_state": "pending",
"comment_count": 2,
"additions": 312,
"deletions": 47
}
Merge
POST /api/v1/repos/gabriel/muse/proposals/af54753d/merge
{"strategy": "merge"} // "merge" | "squash" | "rebase"
Merging creates a merge commit on the into_branch. If Harmony
detects conflicts, the merge is blocked and the response body lists the conflict
paths. Resolve them locally, push the resolution commit, then retry.
Reviews
POST /api/v1/repos/gabriel/muse/proposals/af54753d/reviews
{
"verdict": "approve", // "approve" | "request-changes" | "comment"
"body": "LGTM — harmony tests all pass."
}
Releases
Releases bind a semantic version tag to a commit, a changelog, and optional
binary assets. Versions follow SemVer 2.0. The channel field
supports multi-track release management (stable, beta, nightly).
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /repos/{owner}/{repo}/releases | required | Create release |
GET | /repos/{owner}/{repo}/releases | optional | List releases. Filter: ?channel=stable&draft=false |
GET | /repos/{owner}/{repo}/releases/{tag} | optional | Get release by semver tag |
DELETE | /repos/{owner}/{repo}/releases/{tag} | required | Delete release (tag + metadata; objects retained) |
POST | /repos/{owner}/{repo}/releases/{tag}/assets | required | Attach an asset (URL reference) |
GET | /repos/{owner}/{repo}/releases/{tag}/assets | optional | List assets |
DELETE | /repos/{owner}/{repo}/releases/{tag}/assets/{id} | required | Remove asset |
Create release — request body
POST /api/v1/repos/gabriel/muse/releases
{
"tag": "v2.1.0",
"title": "Harmony semantic tier + MCP elicitation",
"body": "## What's new\n- Harmony Tier 3: semantic match...",
"channel": "stable",
"commit_id": "sha256:abc...",
"is_draft": false,
"prerelease": false
}
Release object
{
"release_id": "sha256:ghi...",
"tag": "v2.1.0",
"title": "Harmony semantic tier + MCP elicitation",
"body": "...",
"channel": "stable",
"commit_id": "sha256:abc...",
"is_draft": false,
"prerelease": false,
"created_at": "2026-04-21T12:00:00Z",
"published_at": "2026-04-21T12:05:00Z",
"assets": []
}
Collaborators
Collaborators have write or admin access to a private or public repo. The owner always has admin. Collaborator permissions are independent of the repo's visibility — a public repo can still restrict who may push.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /repos/{owner}/{repo}/collaborators | required | List collaborators and their permission levels |
POST | /repos/{owner}/{repo}/collaborators | required | Invite a collaborator (owner or admin only) |
PATCH | /repos/{owner}/{repo}/collaborators/{handle} | required | Update permission level |
DELETE | /repos/{owner}/{repo}/collaborators/{handle} | required | Remove a collaborator |
Invite collaborator
POST /api/v1/repos/gabriel/muse/collaborators
{
"handle": "carol",
"permission": "write" // "read" | "write" | "admin"
}
Collaborator object
{
"handle": "carol",
"permission": "write",
"added_at": "2026-04-01T09:00:00Z",
"added_by": "gabriel"
}
Permission levels
| Level | Can read | Can push | Can manage repo |
|---|---|---|---|
read | ✓ | — | — |
write | ✓ | ✓ | — |
admin | ✓ | ✓ | ✓ (cannot delete repo or transfer ownership) |
Labels
Labels are repo-scoped tags applied to issues. They have a name, a hex color for UI rendering, and an optional description. Label names must be unique within a repo and are case-insensitive on lookup.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /repos/{owner}/{repo}/labels | optional | List all labels |
POST | /repos/{owner}/{repo}/labels | required | Create a label |
PATCH | /repos/{owner}/{repo}/labels/{name} | required | Update name, color, or description |
DELETE | /repos/{owner}/{repo}/labels/{name} | required | Delete label (removed from all issues atomically) |
Create label
POST /api/v1/repos/gabriel/muse/labels
{
"name": "harmony",
"color": "#7c3aed",
"description": "Conflict resolution layer"
}
Label object
{
"name": "harmony",
"color": "#7c3aed",
"description": "Conflict resolution layer",
"issue_count": 4
}
Forks
Forking creates an independent copy of a repo under the caller's handle,
with the full object history preserved. The fork tracks its origin via
the fork_of field; diverged forks may open proposals back to
the upstream via the standard proposal flow.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /repos/{owner}/{repo}/forks | required | Fork the repo into the caller's namespace |
GET | /repos/{owner}/{repo}/forks | optional | List all forks of this repo (paginated) |
Fork a repo
POST /api/v1/repos/gabriel/muse/forks
// No body required — the fork owner is the authenticated handle.
// Optional:
{
"name": "muse-fork" // override name; defaults to upstream repo name
}
Fork response
{
"repo_id": "sha256:xyz...",
"owner": "carol",
"name": "muse",
"slug": "carol/muse",
"fork_of": "gabriel/muse",
"visibility": "public",
"domain": "code",
"default_branch": "main",
"created_at": "2026-04-25T14:00:00Z",
"commit_count": 1240,
"fork_count": 0
}
fork_of field is set at creation and never changes even if the
upstream repo is renamed or transferred. Use the upstream's repo_id
(a content-addressed sha256: ID) for stable cross-reference.Users
User endpoints expose public profile reads and authenticated self-updates. There is no admin impersonation API — every mutation is scoped to the authenticated caller's own handle.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /users/{handle} | optional | Get public profile for any handle |
GET | /user | required | Get the authenticated caller's full profile (includes email) |
PATCH | /user | required | Update bio, display name, avatar URL, or public key |
User object (public)
{
"handle": "gabriel",
"display_name": "Carlos Gabriel Cardona",
"bio": "Building Muse — content-addressed VCS for code and music.",
"avatar_url": "http://staging.musehub.ai/avatars/gabriel.png",
"public_key": "ed25519:AAAA...",
"repo_count": 7,
"created_at": "2025-01-10T08:00:00Z"
}
Update profile
PATCH /api/v1/user
{
"bio": "Building the semantic web for code and music.",
"display_name": "Carlos Gabriel Cardona",
"avatar_url": "http://staging.musehub.ai/avatars/gabriel-v2.png"
}
All fields are optional; omitted fields are unchanged. Updating
public_key rotates the key used for MSign — subsequent signed
requests must use the new private key immediately.
Topics
Topics are short freeform tags applied to a repo for discovery. They appear on the repo page and power the search index. A repo may have up to 20 topics; the response is always sorted lexicographically.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /repos/{owner}/{repo}/topics | optional | List topics on a repo |
PUT | /repos/{owner}/{repo}/topics | required | Replace the full topic set atomically |
Replace topics
PUT /api/v1/repos/gabriel/muse/topics
{
"topics": ["vcs", "content-addressed", "music", "midi", "code-intelligence"]
}
Response
{
"topics": [
"code-intelligence",
"content-addressed",
"midi",
"music",
"vcs"
]
}
PUT /topics is a full replacement — any topic not in the new list
is removed. There is no PATCH for topics; always send the complete
desired set. Maximum 20 topics; topics exceeding that limit return
400 validation_error.Webhooks
Webhooks deliver JSON payloads to your endpoint for repo events. Deliveries
are signed with an HMAC-SHA256 of the payload using a shared secret you provide
at registration — verify the X-MuseHub-Signature header before
processing.
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /repos/{owner}/{repo}/webhooks | Register a webhook |
GET | /repos/{owner}/{repo}/webhooks | List webhooks |
DELETE | /repos/{owner}/{repo}/webhooks/{id} | Delete webhook |
GET | /repos/{owner}/{repo}/webhooks/{id}/deliveries | Delivery history (last 100) |
POST | /repos/{owner}/{repo}/webhooks/{id}/deliveries/{did}/redeliver | Retry a delivery |
Register webhook
POST /api/v1/repos/gabriel/muse/webhooks
{
"url": "https://ci.example.com/musehub",
"secret": "s3cr3t",
"events": ["push", "proposal", "issue"],
"active": true
}
Event types
| Event | Triggered when |
|---|---|
push | A branch is updated via wire push |
proposal | Proposal created, updated, merged, or closed |
issue | Issue created, updated, commented, or closed |
release | Release published or deleted |
branch | Branch created or deleted |
tag | Tag added or removed |
session | MCP session started or expired |
analysis | Code intelligence index rebuilt |
Payload envelope
{
"event": "push",
"delivery_id": "a3f2c9...",
"repo": "gabriel/muse",
"sender": "gabriel",
"timestamp": "2026-04-21T12:00:00Z",
"payload": {
"branch": "dev",
"before": "sha256:old...",
"after": "sha256:new...",
"commits": [
{
"commit_id": "sha256:abc...",
"message": "feat: harmony semantic tier",
"author": "gabriel",
"agent_id": "claude-code",
"model_id": "claude-sonnet-4-6"
}
]
}
}
Signature verification
import hmac, hashlib
def verify(secret: str, body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)
# In your handler:
sig = request.headers["X-MuseHub-Signature"]
if not verify(WEBHOOK_SECRET, request.body, sig):
return 401
Errors
All errors return a JSON body with a machine-readable code
and a human-readable message. The HTTP status code is the
primary signal; the code field lets you branch without
parsing message strings.
{
"error": {
"code": "repo_not_found",
"message": "Repository 'gabriel/nope' does not exist or is private.",
"docs": "http://staging.musehub.ai/muse/api#repos"
}
}
| HTTP status | code | Meaning |
|---|---|---|
| 400 | validation_error | Request body failed schema validation — details lists each field error |
| 401 | auth_required | No Authorization header on a route that requires one |
| 401 | invalid_signature | Signature verification failed |
| 401 | timestamp_expired | |now - ts| > 30 s — replay protection triggered |
| 403 | forbidden | Authenticated but lacks permission for this action |
| 404 | repo_not_found | Repo does not exist or caller lacks read access |
| 404 | not_found | Generic resource not found (issue, proposal, release, …) |
| 409 | merge_conflict | Proposal merge failed — conflicts lists the blocking paths |
| 409 | ref_conflict | Push rejected — remote branch has diverged, pull first |
| 410 | gone | Resource permanently deleted; slug redirects have expired |
| 422 | invalid_semver | Release tag is not a valid SemVer 2.0 string |
| 429 | rate_limited | Rate limit exceeded — check Retry-After header |
| 500 | internal_error | Unhandled server error — include request_id when reporting |