shardhive API v1¶
HTTP/1.1 over a Unix domain socket (mode 0600 — the socket permission is the access boundary; the management API has no TCP surface — the swarm client's peer/webseed listeners are separate, see swarm.md). JSON in, JSON out.
S=${SHARDR_SOCKET:-${XDG_RUNTIME_DIR:-/tmp/shardhive-$(id -u)}/shardhive.sock}
curl -s --unix-socket "$S" http://localhost/v1/models
Versioning¶
The major version is a path prefix: /v1/…. Requests aimed at another
major version are rejected loudly so clients can detect mismatches:
curl -s --unix-socket "$S" http://localhost/v2/resolve
# {"error":{"code":"E_UNSUPPORTED_VERSION",
# "message":"unsupported API major version /v2/ (/v2/resolve); this shardhive supports v1",
# "candidates":["v1"]}} # HTTP 400
Unknown paths under /v1/ are a plain 404 (E_NOT_FOUND).
Error envelope¶
Every error — including HTTP-level failures like an unsatisfiable range — is one JSON shape:
{"error": {"code": "E_UNKNOWN_REF", "message": "…", "candidates": ["…"]}}
candidates is optional and lists known alternatives (e.g. existing
tags for a repo, same-namespace models) so callers can self-correct.
See errors.md for the full class inventory.
References at the API¶
The API accepts only the canonical URI form (000 §2):
shardr:///ns/name:quant # namespace, name, quant selector
shardr:///ns/name:tag+quant # tag-pinned (tags are per-repository)
shardr:///ns/name@sha256:<hex> # manifest-addressing: digest pin, no selector
ns/name are lowercased by the parser; tags are case-sensitive and kept
verbatim. A parseable CLI short form (ns/name:quant) is rejected with
E_PARSE and the message names the canonical spelling to use:
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=gold/toy-qwen:q8_0'
# {"error":{"code":"E_PARSE",
# "message":"references at the API must be canonical URIs; use shardr:///gold/toy-qwen:q8_0"}} # 400
GET /v1/resolve¶
Pure name → digests lookup against local state only. Never touches the network.
| Query | Meaning |
|---|---|
ref |
canonical reference (required) |
Success (200):
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=shardr:///gold/toy-qwen:q8_0'
# {"ref":"shardr:///gold/toy-qwen:q8_0","ns":"gold","name":"toy-qwen",
# "quant":"q8_0",
# "manifestDigest":"sha256:bca235eb…",
# "indexDigest":"sha256:206ada42…",
# "plan":"pending",
# "planReason":"manifest document parsing lands with imports (001 §8)"}
plan/planReason: plan is always "pending" in this build, and
the planReason string ("manifest document parsing lands with
imports") is a stale message from an earlier slice — imports and the
runner have landed; the server still emits the old constant. This is a
known legacy defect in the API surface, tracked in the code comment on
resolveResult in internal/api/server.go — treat plan as
unimplemented, not as a statement about
runner capability. The @sha256:<hex> form resolves without any
state lookup (the digest is the answer) and returns no
indexDigest.
Errors: E_BAD_REQUEST (missing ref), E_PARSE/E_LENGTH/…
(reference grammar, with canonical-form hints), E_UNKNOWN_REF (no
local index; message says network resolver fetch is not implemented in
this build, candidates lists same-namespace repos), E_UNKNOWN_REF with
tag-scoped message + candidates (an unknown tag is an unknown ref:
"no tag E_NO_INDEX (index blob not in CAS), E_INVALID_INDEX (index fails
validation), E_NO_MEMBER (no index member matches the selector).
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=shardr:///gold/nope:q8_0'
# {"error":{"code":"E_UNKNOWN_REF",
# "message":"no local index for gold/nope; network resolver fetch is not implemented in this shardhive build",
# "candidates":["gold/toy-qwen"]}} # 404
GET /v1/open¶
/resolve plus local blob paths: which files are present and where.
Never auto-fills anything.
Success (200) — same fields as /resolve, plus files (present) and
missing (digests):
curl -s --unix-socket "$S" 'http://localhost/v1/open?ref=shardr:///gold/toy-qwen:q8_0'
# { … "files":[
# {"digest":"sha256:206ada42…","path":"~/.local/share/shardr/cas/blobs/sha256/20/6ada42…","size":485},
# {"digest":"sha256:bca235eb…","path":"…/blobs/sha256/bc/a235eb…","size":602}]}
missing appears only when digests are absent (omitempty — an empty
missing key is not emitted).
Errors: identical to /resolve — the same resolution path runs first:
curl -s --unix-socket "$S" 'http://localhost/v1/open?ref=shardr:///gold/none:q8_0'
# {"error":{"code":"E_UNKNOWN_REF",
# "message":"no local index for gold/none; network resolver fetch is not implemented in this shardhive build"}} # 404
POST /v1/ensure¶
Make a resolved model locally complete. Body: {"ref": "…"}.
- All files present → terminal
donejob immediately. - Files missing and the swarm client is enabled → asynchronous swarm fill job (fetch from peers/webseeds, digest-verified on write).
- Files missing and the swarm client is disabled → terminal
failedjob withE_SOURCE_UNAVAILABLEnaming the config knob.
Returns 201 with the initial job; poll GET /v1/jobs/{id}:
curl -s --unix-socket "$S" -X POST http://localhost/v1/ensure \
-d '{"ref":"shardr:///gold/toy-qwen:q8_0"}'
# {"id":"84a0207d94abdbed","ref":"shardr:///gold/toy-qwen:q8_0",
# "state":"done","manifest":"sha256:bca235eb…","kind":"ensure",
# "filesDone":2,"filesTotal":2} # 201
A pre-resolved failure (unknown ref) still answers 201 — with a
terminal failed job:
curl -s --unix-socket "$S" -X POST http://localhost/v1/ensure \
-d '{"ref":"shardr:///gold/none:q8_0"}'
# {"id":"61fe0aff9f21cb27","ref":"shardr:///gold/none:q8_0","state":"failed",
# "error":{"code":"E_UNKNOWN_REF","message":"no local index for gold/none; …"},
# "kind":"ensure"} # 201
Job states: waiting → fetching (with filesDone/filesTotal
progress) → done | failed (with an error envelope). Jobs are
immutable once published — every poll returns a consistent snapshot,
and a terminal state is final, never silently retried.
Errors (as terminal job payload or, for pre-job failures, as the
response): E_BAD_REQUEST, ref-grammar classes, E_UNKNOWN_REF,
E_SOURCE_UNAVAILABLE (manifest not local — the message names the
import endpoints that can bring manifests in), swarm-fill outcomes
(E_NOT_IMPORTABLE for verification failures, E_SOURCE_UNAVAILABLE
for unreachable sources).
GET /v1/jobs/{id}¶
Fetch one job by id (the id from an import/ensure response):
curl -s --unix-socket "$S" http://localhost/v1/jobs/f4af8094937a22bd
# {"id":"f4af8094937a22bd","ref":"gold/toy-qwen","state":"done",
# "manifest":"sha256:91471361…","kind":"import-local","as":"gold/toy-qwen",
# "filesDone":5,"filesTotal":5,
# "result":{"manifests":["sha256:91471361…"],"indexDigest":"sha256:88edcdb0…",
# "quants":["raw"],"skipped":0}}
Errors: E_NOT_FOUND (404) for an unknown id:
curl -s --unix-socket "$S" http://localhost/v1/jobs/deadbeef
# {"error":{"code":"E_NOT_FOUND","message":"no such job: deadbeef"}} # 404
Note: jobs live in daemon memory — a restart empties the job list (the CAS and state are unaffected).
GET /v1/blob/{digest}¶
Read-only blob access from the CAS — zero-copy, Range support
mandatory. Digest is sha256:<hex> or bare hex.
curl -s --unix-socket "$S" -H 'Range: bytes=0-4' \
http://localhost/v1/blob/sha256:206ada42…
# {"art # HTTP 206 (partial)
200full body,206valid range,416+E_RANGE_INVALIDfor a range that does not overlap the blob.- Only validated blobs (
blobs/sha256/) are addressable — partial downloads (incoming/*.part) never are.
curl -s --unix-socket "$S" -H 'Range: bytes=999999-' \
http://localhost/v1/blob/sha256:206ada42…
# {"error":{"code":"E_RANGE_INVALID","message":"invalid range: failed to overlap"}} # 416
Errors: E_BAD_REQUEST (invalid digest form), E_NOT_FOUND (no such
blob), E_RANGE_INVALID.
POST /v1/import/local¶
Import local files. Body:
{"paths": ["/abs/path", "/abs/model-dir"], "as": "ns/name"}
as is mandatory (001 §8.6 — never optional) and must be a
well-formed ns/name. Paths may be files or directories; a directory
contributes every regular file beneath it. Everything must be a
regular file — symlinks, FIFOs, devices fail the whole request with
E_SOURCE_NOT_REGULAR (400) before any byte is read.
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/local \
-d '{"paths":["/tmp/toy-model"],"as":"gold/toy-qwen"}'
# {"id":"c04e88d49859eabe","ref":"gold/toy-qwen","state":"waiting",
# "kind":"import-local","as":"gold/toy-qwen","filesTotal":5} # 201
Terminal result (see /v1/jobs/{id} above): manifests,
indexDigest, quants, warnings?, skipped.
Errors: E_BAD_REQUEST (bad body, missing paths/as, malformed
as), E_SOURCE_NOT_REGULAR (non-regular source), then importer
outcomes as terminal job errors (E_NOT_IMPORTABLE,
E_INVALID_INDEX, E_INTERNAL).
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/local \
-d '{"paths":["/tmp/evil-link.gguf"],"as":"gold/bad"}'
# {"error":{"code":"E_SOURCE_NOT_REGULAR",
# "message":"importer: import root is a hard boundary — source is not a regular file (symlink/fifo/device); refusing fail-open (E_SOURCE_NOT_REGULAR): /tmp/evil-link.gguf"}} # 400
POST /v1/import/hf¶
Import a Hugging Face repo. Body: {"repo": "org/name", "revision":
"…"} — revision optional (default branch).
The revision is pinned: the repo listing resolves to a commit SHA and every file byte is fetched at that SHA, never at the mutable branch — a branch move between listing and fetch cannot publish bytes from commit Y under the provenance of commit X (001 §8.8).
Public repos work anonymously; set SHARDR_HF_TOKEN in the daemon's
environment for gated repos (sent as a Bearer token).
The namespace derives from the repo id (lowercased org/name); the
original-case repo id and the pinned revision are recorded in the
manifest annotations.
Note: an HF import fetches the whole repo (every file the listing returns), so mind the size for multi-quant repos.
Real run (public repo, anonymous access):
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/hf \
-d '{"repo":"klosax/tinyllamas-stories-gguf"}'
# {"id":"00c4a5efa2c02d17","ref":"klosax/tinyllamas-stories-gguf",
# "state":"waiting","kind":"import-hf",
# "as":"klosax/tinyllamas-stories-gguf","filesTotal":6} # 201
# … poll /v1/jobs/{id}: state "fetching" → "done"; the terminal
# result shape is the same as local imports (manifests, indexDigest,
# quants, skipped).
Errors: E_BAD_REQUEST (missing repo), then HF outcomes as 502
response-body errors or terminal job errors: E_UNKNOWN_REF (repo not
found), E_SOURCE_FORBIDDEN (gated or — when anonymous — nonexistent
repo), E_RATE_LIMITED, E_SOURCE_UNAVAILABLE (unreachable),
E_NOT_IMPORTABLE, E_INTERNAL.
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/hf \
-d '{"repo":"some-org/does-not-exist"}'
# {"error":{"code":"E_SOURCE_FORBIDDEN",
# "message":"importer: HF access denied (auth required or private repo)"}} # 502
POST /v1/import/bt¶
Pinned BitTorrent v2 import. Body:
{
"magnet": "magnet:?", // XOR infohash
"infohash": "btmh:1220<hex>", // XOR magnet
"manifestDigest": "sha256:<hex>", // REQUIRED — the pin
"trackers": ["…"], // optional hints
"webseeds": ["http://…"], // optional hints
"peers": ["host:port"] // optional hints
}
The pin is mandatory: a magnet alone is never accepted. The fetch
is bound to the pinned manifest digest; every fetched byte is verified
against it on write. An infohash that delivers bytes not matching the
pin fails loudly (E_NOT_IMPORTABLE).
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/bt \
-d '{"infohash":"btmh:1220a707…"}'
# {"error":{"code":"E_BAD_REQUEST",
# "message":"missing required field: manifestDigest — the pin is mandatory; a magnet alone is never trusted (005 §5)"}} # 400
A real pinned import (node B pulling from seeding node A's webseed — both are shardhive daemons on this machine):
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/bt \
-d '{"infohash":"btmh:1220a707…","manifestDigest":"sha256:1d5491c2…","webseeds":["http://127.0.0.1:59718"]}'
# {"id":"facb9adcab3fa66d", … ,"kind":"import-bt"} # 201
# … poll /v1/jobs/{id} →
# {"state":"done","result":{"manifests":["sha256:1d5491c2…"],
# "infohash":"btmh:1220a707…","skipped":0}}
Note what a BT import brings in: the pinned artifact's blobs
(manifest + files). It does not create a namespace index — resolve by
name:quant on the importing node needs a local (or HF) import; the
@sha256:<manifest> form resolves immediately.
The infohash for a pinned import comes from the publisher's
distribution record (the torrent.infohash field of the record blob in
the CAS) — a user-facing surface for it lands with the shardrbay/CLI
slice. Until then, the seeding node's record blob carries it.
Errors: E_BAD_REQUEST (bad body, missing/ambiguous magnet vs
infohash, non-canonical pin), E_NOT_IMPLEMENTED (501 — swarm client
disabled; the message names the [swarm] enabled config knob), then
swarm outcomes as terminal job errors (E_NOT_IMPORTABLE,
E_SOURCE_UNAVAILABLE).
POST /v1/import/catalog¶
Anchored pull of a community-listed model torrent (pirateface, Epic
65). Body:¶
{
"repo": "owner/name", // REQUIRED — the listed repo
"quant": "q4_k_m", // optional GGUF family selector
"trustCatalog": true, // REQUIRED for rescued models
"as": "ns/name", // optional namespace override
"peers": ["host:port"] // optional direct-peer hints
}
The daemon owns the whole trust path: it resolves the listing from the
provider itself (never trusting caller-supplied magnets), builds the
anchor — Hugging Face tree at the revision pinned by the listing's
webseed — and runs the foreign-swarm (BitTorrent v1) import, then keeps
seeding that swarm from CAS bytes (good-citizen mode, [catalog]
upload_limit budget).
A rescued model (HF repo or revision gone) refuses loudly without
trustCatalog:
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/catalog \
-d '{"repo":"owner/gone-model"}'
# {"error":{"code":"E_NOT_ANCHORED",
# "message":"catalog: no anchor — the model is rescued … re-run
# with --trust-catalog …"}} # 422
With trustCatalog: true the anchor becomes the catalog's own recorded
SHA-256s; the job result carries the trust-shift warning.
Errors: E_BAD_REQUEST (400 — bad body, missing repo),
E_UNKNOWN_REF (400 — repo not listed by the provider),
E_NOT_ANCHORED (422 — rescued without the trust shift), then
provider/HF unreachability as E_SOURCE_UNAVAILABLE (502). Swarm and
anchor failures surface as terminal job errors (E_NOT_IMPORTABLE,
E_SOURCE_UNAVAILABLE). See catalog pulls for the full
flow.
GET /v1/models¶
Local inventory from state + CAS presence:
curl -s --unix-socket "$S" http://localhost/v1/models
# {"skeleton":true,
# "note":"skeleton inventory: seed state and sizes land with later slices (005 §3)",
# "namespaces":[
# {"ns":"gold","name":"toy-qwen","indexDigest":"sha256:88edcdb0…",
# "indexPresent":true,"quants":["q8_0"]}],
# "tags":[]}
tags entries carry the repo they are scoped to (repo, tag,
digest, blobPresent). The response is honest about being a skeleton
(skeleton: true, note): seed state and sizes are explicit
follow-ups, not silently missing fields.
Errors: E_INTERNAL (state unreadable).