Developer Docs MuseHub API Reference
PHASE 10

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.

PropertyValue
Base URLhttp://staging.musehub.ai/api/v1
Auth schemeMSign (Ed25519, no session tokens)
Content-Typeapplication/json
Pagination styleCursor-based (opaque token, forward-only)
Rate limitingPer-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>"
FieldTypeDescription
handlestringRegistered MuseHub username — used to look up the public key
algstringAlways "ed25519"
tsintegerUnix timestamp (seconds). Request is rejected if |now - ts| > 30
sigbase64urlEd25519 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
Key registration: before any signed request succeeds, your Ed25519 public key must be registered with the hub — once, at account creation. Use 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

ParameterTypeDefaultDescription
cursorstringOpaque continuation token from a previous response. Omit for first page.
limitinteger20Items 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 groupLimit (per 60 s)
All endpoints (global ceiling)300
Wire push (POST /{owner}/{repo}/push)30
Wire fetch (POST /{owner}/{repo}/fetch)120
Issue create20
Issue comment60
Anonymous reads60 (IP-keyed)

Rate-limit state is returned on every response:

X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1745280041

Repos

Endpoints

MethodPathAuthDescription
GET/reposoptionalList repos for the authenticated user (or public repos for anonymous)
POST/reposrequiredCreate a new repo
GET/repos/{owner}/{repo}optionalGet repo metadata
PATCH/repos/{owner}/{repo}requiredUpdate repo metadata (description, visibility, domain)
DELETE/repos/{owner}/{repo}requiredDelete repo and all objects permanently
POST/repos/{owner}/{repo}/transferrequiredTransfer 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

MethodPathAuthDescription
GET/repos/{owner}/{repo}/issuesoptionalList issues. Filter: ?status=open|closed&label=bug&assignee=handle
POST/repos/{owner}/{repo}/issuesrequiredCreate issue
GET/repos/{owner}/{repo}/issues/{number}optionalGet a single issue
PATCH/repos/{owner}/{repo}/issues/{number}requiredUpdate title, body, status, labels, assignees
POST/repos/{owner}/{repo}/issues/{number}/commentsrequiredAdd a comment
GET/repos/{owner}/{repo}/issues/{number}/commentsoptionalList comments (paginated)
DELETE/repos/{owner}/{repo}/issues/{number}/comments/{id}requiredDelete 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"}
Comment-then-close pattern: always post a comment with context before closing. Editing the original description is reserved for correcting errors; closure rationale belongs in a comment so the timeline stays accurate.

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

MethodPathAuthDescription
POST/repos/{owner}/{repo}/proposalsrequiredCreate proposal
GET/repos/{owner}/{repo}/proposalsoptionalList proposals. Filter: ?state=open|closed|merged
GET/repos/{owner}/{repo}/proposals/{id}optionalGet proposal (includes review state)
GET/repos/{owner}/{repo}/proposals/{id}/diffoptionalSymbol-level diff between head and base
POST/repos/{owner}/{repo}/proposals/{id}/mergerequiredMerge proposal (requester or repo admin)
POST/repos/{owner}/{repo}/proposals/{id}/commentsrequiredAdd comment
GET/repos/{owner}/{repo}/proposals/{id}/commentsoptionalList comments
POST/repos/{owner}/{repo}/proposals/{id}/reviewsrequiredSubmit a review (approve / request-changes / comment)
GET/repos/{owner}/{repo}/proposals/{id}/reviewsoptionalList reviews
POST/repos/{owner}/{repo}/proposals/{id}/reviewersrequiredRequest reviewer by handle
DELETE/repos/{owner}/{repo}/proposals/{id}/reviewers/{handle}requiredRemove 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

MethodPathAuthDescription
POST/repos/{owner}/{repo}/releasesrequiredCreate release
GET/repos/{owner}/{repo}/releasesoptionalList releases. Filter: ?channel=stable&draft=false
GET/repos/{owner}/{repo}/releases/{tag}optionalGet release by semver tag
DELETE/repos/{owner}/{repo}/releases/{tag}requiredDelete release (tag + metadata; objects retained)
POST/repos/{owner}/{repo}/releases/{tag}/assetsrequiredAttach an asset (URL reference)
GET/repos/{owner}/{repo}/releases/{tag}/assetsoptionalList assets
DELETE/repos/{owner}/{repo}/releases/{tag}/assets/{id}requiredRemove 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

MethodPathAuthDescription
GET/repos/{owner}/{repo}/collaboratorsrequiredList collaborators and their permission levels
POST/repos/{owner}/{repo}/collaboratorsrequiredInvite a collaborator (owner or admin only)
PATCH/repos/{owner}/{repo}/collaborators/{handle}requiredUpdate permission level
DELETE/repos/{owner}/{repo}/collaborators/{handle}requiredRemove 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

LevelCan readCan pushCan 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

MethodPathAuthDescription
GET/repos/{owner}/{repo}/labelsoptionalList all labels
POST/repos/{owner}/{repo}/labelsrequiredCreate a label
PATCH/repos/{owner}/{repo}/labels/{name}requiredUpdate name, color, or description
DELETE/repos/{owner}/{repo}/labels/{name}requiredDelete 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

MethodPathAuthDescription
POST/repos/{owner}/{repo}/forksrequiredFork the repo into the caller's namespace
GET/repos/{owner}/{repo}/forksoptionalList 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
}
The 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

MethodPathAuthDescription
GET/users/{handle}optionalGet public profile for any handle
GET/userrequiredGet the authenticated caller's full profile (includes email)
PATCH/userrequiredUpdate 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

MethodPathAuthDescription
GET/repos/{owner}/{repo}/topicsoptionalList topics on a repo
PUT/repos/{owner}/{repo}/topicsrequiredReplace 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

MethodPathDescription
POST/repos/{owner}/{repo}/webhooksRegister a webhook
GET/repos/{owner}/{repo}/webhooksList webhooks
DELETE/repos/{owner}/{repo}/webhooks/{id}Delete webhook
GET/repos/{owner}/{repo}/webhooks/{id}/deliveriesDelivery history (last 100)
POST/repos/{owner}/{repo}/webhooks/{id}/deliveries/{did}/redeliverRetry 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

EventTriggered when
pushA branch is updated via wire push
proposalProposal created, updated, merged, or closed
issueIssue created, updated, commented, or closed
releaseRelease published or deleted
branchBranch created or deleted
tagTag added or removed
sessionMCP session started or expired
analysisCode 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
MuseHub retries failed deliveries with exponential backoff: 10 s, 1 min, 5 min, 30 min, 2 h. A delivery is considered failed if your endpoint returns a non-2xx status or does not respond within 10 seconds. After 5 failures the webhook is automatically deactivated.

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 statuscodeMeaning
400validation_errorRequest body failed schema validation — details lists each field error
401auth_requiredNo Authorization header on a route that requires one
401invalid_signatureSignature verification failed
401timestamp_expired|now - ts| > 30 s — replay protection triggered
403forbiddenAuthenticated but lacks permission for this action
404repo_not_foundRepo does not exist or caller lacks read access
404not_foundGeneric resource not found (issue, proposal, release, …)
409merge_conflictProposal merge failed — conflicts lists the blocking paths
409ref_conflictPush rejected — remote branch has diverged, pull first
410goneResource permanently deleted; slug redirects have expired
422invalid_semverRelease tag is not a valid SemVer 2.0 string
429rate_limitedRate limit exceeded — check Retry-After header
500internal_errorUnhandled server error — include request_id when reporting