HTTP API v1¶
The shardhive daemon serves HTTP/1.1 over a Unix domain socket (mode
0600 — the socket permission is the access boundary; there is no TCP
listener). JSON in, JSON out. The canonical contract is
spec 005;
this page documents the implemented surface of internal/api/server.go,
with every example executed for real against a running daemon.
All examples assume the socket in a variable (see Daemon operation for path resolution):
S=${SHARDR_SOCKET:-${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}/shardhive-$(id -u)}/shardhive.sock}
curl -s --unix-socket "$S" http://localhost/v1/models
Versioning¶
The /v1 path prefix is the client's version declaration — there is no
negotiation header. A request aimed at another major version is
rejected loudly (executed):
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 such as an unsatisfiable range — is one JSON shape:
{"error": {"code": "E_UNKNOWN_REF", "message": "…", "candidates": ["…"]}}
candidates is optional and lists known alternatives (existing
same-namespace repos, matching index members) so callers can
self-correct. The full class inventory with sources is in the
error inventory.
References at the API¶
The API accepts only the canonical URI form (spec 000 §2):
shardr:///ns/name:quant, shardr:///ns/name:tag+quant, or
shardr:///ns/name@sha256:<hex>. A parseable CLI short form is
rejected with the canonical spelling in the message (executed):
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=gold/smollm2-135m-instruct:q4_k_m'
# {"error":{"code":"E_PARSE",
# "message":"references at the API must be canonical URIs; use shardr:///gold/smollm2-135m-instruct:q4_k_m"}} # 400
URL-encoding note (executed): in a query string + decodes to a
space, so the tag separator in tag+quant must be sent as %2B.
Unencoded, the daemon receives snap raw and answers
E_PARSE: selector must be a quant or tag+quant; bare tags are not
selectors. Encoded correctly:
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=shardr:///minicpmv/mmproj:snap%2Braw'
# {"error":{"code":"E_UNKNOWN_REF",
# "message":"no tag snap for minicpmv/mmproj; tags are scoped per repository (000 §3.4)"}} # 404
GET /v1/resolve¶
Pure name → digest lookup against local state only; never touches
the network. Query parameter ref (canonical reference, required).
Executed (200):
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=shardr:///gold/smollm2-135m-instruct:q4_k_m'
# {"ref":"shardr:///gold/smollm2-135m-instruct:q4_k_m","ns":"gold","name":"smollm2-135m-instruct",
# "quant":"q4_k_m",
# "manifestDigest":"sha256:696ff8cb8d6695de89ce9a76bd07eb82941e1fea48612d3446801a1f48039825",
# "indexDigest":"sha256:1631afafd611129f9caf6a4b33aaf7cb843e37da35ffff4e2ff4d2e765ab300f",
# "plan":"pending",
# "planReason":"manifest document parsing lands with imports (001 §8)"}
plan is always "pending" in this build — a stale constant from an
earlier slice (known legacy defect, see the code comment on
resolveResult); treat it as unimplemented.
The @sha256:<hex> form addresses a manifest directly, without index
lookup, and returns no indexDigest (executed):
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=shardr:///minicpmv/mmproj@sha256:3db690a6d2d18d85eff7206bcf65911321bf582a58b4194d33d0b78e5a5a8171'
# {"ref":"shardr:///minicpmv/mmproj@sha256:3db690a6…","ns":"minicpmv","name":"mmproj",
# "manifestDigest":"sha256:3db690a6d2d18d85eff7206bcf65911321bf582a58b4194d33d0b78e5a5a8171",
# "plan":"pending","planReason":"manifest document parsing lands with imports (001 §8)"} # 200
Errors (all executed):
E_BAD_REQUEST— missingrefquery parameter (400)E_PARSE(+ canonical-form hint) — non-canonical or malformed reference (400)E_UNKNOWN_REF— no local index, with same-namespacecandidates(404):
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=shardr:///minicpmv/nope:raw'
# {"error":{"code":"E_UNKNOWN_REF",
# "message":"no local index for minicpmv/nope; network resolver fetch is not implemented in this shardhive build",
# "candidates":["minicpmv/mmproj"]}} # 404
E_UNKNOWN_REF— unknown tag, with the per-repository scoping message and existing tags ascandidates(404)E_AMBIGUOUS_SELECTOR— prefix matches more than one index member,candidateslists them (400):
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=shardr:///gold/smollm2-135m-instruct:q4'
# {"error":{"code":"E_AMBIGUOUS_SELECTOR","message":"ambiguous prefix q4",
# "candidates":["q4_k_m","q4km"]}} # 400
E_NO_INDEX— index blob absent from the CAS (404)E_INVALID_INDEX— index blob fails validation (500)E_NO_MEMBER— no index member matches the selector (400):
curl -s --unix-socket "$S" 'http://localhost/v1/resolve?ref=shardr:///gold/smollm2-135m-instruct:q8_0'
# {"error":{"code":"E_NO_MEMBER","message":"no index member matches q8_0"}} # 400
GET /v1/open¶
/resolve plus the local blob inventory: which files are present with
CAS paths, which digests are missing. Never auto-fills anything.
Executed (200, one weights file, one config, index and manifest blobs):
curl -s --unix-socket "$S" 'http://localhost/v1/open?ref=shardr:///minicpmv/mmproj:raw'
# {"ref":"shardr:///minicpmv/mmproj:raw","ns":"minicpmv","name":"mmproj","quant":"raw",
# "manifestDigest":"sha256:3db690a6…","indexDigest":"sha256:fb6ae5be…",
# "plan":"pending","planReason":"…",
# "files":[
# {"digest":"sha256:fb6ae5be…","path":"<cas>/blobs/sha256/fb/6ae5be…","size":198},
# {"digest":"sha256:3db690a6…","path":"<cas>/blobs/sha256/3d/b690a6…","size":620},
# {"digest":"sha256:e576867a…","path":"<cas>/blobs/sha256/e5/76867a…","size":169,
# "name":"modelconfig.json","kind":"config"},
# {"digest":"sha256:f8a805e9…","path":"<cas>/blobs/sha256/f8/a805e9…","size":1044425152,
# "name":"MiniCPM-V-2.6-mmproj.gguf","kind":"weights.gguf"}]}
File records carry the manifest metadata (name, kind, role,
variant, part, runtime) when the manifest is locally present.
missing appears only when digests are absent (omitempty).
Errors: identical to /resolve — the same resolution path runs first.
POST /v1/ensure¶
Make a resolved model locally complete. Body {"ref": "…"} (canonical
reference).
- All files present → terminal
donejob immediately (executed, 201):
curl -s --unix-socket "$S" -X POST http://localhost/v1/ensure \
-d '{"ref":"shardr:///minicpmv/mmproj:raw"}'
# {"id":"b47b97e5e25bc355","ref":"shardr:///minicpmv/mmproj:raw","state":"done",
# "createdAt":"2026-10-02T12:16:31.184735Z",
# "manifest":"sha256:3db690a6…","kind":"ensure","filesDone":2,"filesTotal":2}
- Files missing + swarm enabled → async fill job (
waiting→fetchingwith progress → terminal). - Files missing + swarm disabled → terminal
failedjob,E_SOURCE_UNAVAILABLEnaming the config knob. - Manifest not local → terminal
failedjob,E_SOURCE_UNAVAILABLEnaming the import endpoints that can bring manifests in. - Unresolvable ref → still 201 with a terminal
failedjob (executed):
curl -s --unix-socket "$S" -X POST http://localhost/v1/ensure \
-d '{"ref":"shardr:///minicpmv/nope:raw"}'
# {"id":"23334dca74f81f51","ref":"shardr:///minicpmv/nope:raw","state":"failed",
# "createdAt":"2026-10-02T12:16:31.193289Z",
# "error":{"code":"E_UNKNOWN_REF","message":"no local index for minicpmv/nope; …"},
# "kind":"ensure"} # 201
E_BAD_REQUEST— missingreffield or bad JSON (executed, 400):
curl -s --unix-socket "$S" -X POST http://localhost/v1/ensure -d '{}'
# {"error":{"code":"E_BAD_REQUEST","message":"missing required field: ref"}}
Jobs are immutable once published — every poll returns a consistent snapshot; terminal states are final, never silently retried. Jobs live in daemon memory; a restart empties the job list (CAS and state are unaffected).
GET /v1/jobs/{id} · GET /v1/jobs¶
One job by id; the list endpoint returns all jobs newest-first
(shardr status without an argument uses it). Executed terminal
import job (200):
curl -s --unix-socket "$S" http://localhost/v1/jobs/cfcba1cbaa111de5
# {"id":"cfcba1cbaa111de5","ref":"gold/smollm2-135m-instruct","state":"done",
# "createdAt":"2026-10-02T12:17:05.744655Z",
# "manifest":"sha256:7d2ef480…","kind":"import-local","as":"gold/smollm2-135m-instruct",
# "filesDone":1,"filesTotal":1,
# "result":{"manifests":["sha256:7d2ef480…"],"indexDigest":"sha256:2a98f6cb…",
# "quants":["q4km"],"skipped":0}}
Unknown id (executed, 404):
curl -s --unix-socket "$S" http://localhost/v1/jobs/deadbeef
# {"error":{"code":"E_NOT_FOUND","message":"no such job: deadbeef"}}
GET /v1/blob/{digest}¶
Read-only blob access from the CAS — zero-copy, Range support
mandatory. Digest is sha256:<hex> or bare hex; only validated blobs
(blobs/sha256/) are addressable — partial downloads
(incoming/*.part) never are.
Valid range → 206 (executed):
curl -s --unix-socket "$S" -H 'Range: bytes=0-15' \
http://localhost/v1/blob/sha256:fb6ae5be9c12290aa6b5fbe85966b19032005db6a47dfc0eb27fad864e7d8549
# {"artifactType" # HTTP 206
Non-overlapping range → 416 in the error envelope (executed):
curl -s --unix-socket "$S" -H 'Range: bytes=99999-' \
http://localhost/v1/blob/sha256:fb6ae5be9c12290aa6b5fbe85966b19032005db6a47dfc0eb27fad864e7d8549
# {"error":{"code":"E_RANGE_INVALID","message":"invalid range: failed to overlap"}} # 416
Unknown blob → E_NOT_FOUND (404); malformed digest →
E_BAD_REQUEST (400). Both executed against a running daemon.
POST /v1/import/local¶
Ingest local files. Body:
{"paths": ["/abs/path", "/abs/model-dir"], "as": "ns/name"}
as is required (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. Every source must be a
regular file — symlinks, FIFOs, and devices fail the whole request
with E_SOURCE_NOT_REGULAR before any byte is read.
Executed (201, then terminal done):
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/local \
-d '{"paths":["/tmp/toy-model"],"as":"gold/smollm2-135m-instruct"}'
# {"id":"…","ref":"gold/smollm2-135m-instruct","state":"waiting",
# "kind":"import-local","as":"gold/smollm2-135m-instruct","filesTotal":1}
Executed refusal cases (both 400):
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/local \
-d '{"paths":["/tmp/smollm2-q4km.gguf"]}'
# {"error":{"code":"E_BAD_REQUEST",
# "message":"missing required field: as (namespace, 001 §8.6 — never optional)"}}
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/local \
-d '{"paths":["/tmp/evil-link.gguf"],"as":"gold/toy"}'
# {"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"}}
Terminal result: manifests, indexDigest, quants, warnings?,
skipped. Further terminal errors: E_NOT_IMPORTABLE,
E_INVALID_INDEX, E_INTERNAL.
POST /v1/import/hf¶
Import a Hugging Face repo. Body {"repo": "org/name", "revision":
"…"} — revision optional (default branch). The revision is pinned:
the listing resolves to a commit SHA and every byte is fetched at that
SHA (001 §8.8). Anonymous access works for public repos;
SHARDR_HF_TOKEN in the daemon's environment enables gated repos.
The namespace derives from the lowercased repo id; original case and
pinned revision land in the manifest annotations. An HF import fetches
the whole repo listing — mind multi-quant repo sizes.
Executed error (repo does not exist / gated without token, 502):
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)"}}
Further error classes: E_BAD_REQUEST (missing repo),
E_UNKNOWN_REF, E_RATE_LIMITED, E_SOURCE_UNAVAILABLE,
E_NOT_IMPORTABLE. Success answers 201 with an import-hf job; the
terminal result shape is the same as local imports.
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. Executed (400):
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/bt \
-d '{"infohash":"btmh:1220a707dead"}'
# {"error":{"code":"E_BAD_REQUEST",
# "message":"missing required field: manifestDigest — the pin is mandatory; a magnet alone is never trusted (005 §5)"}}
Executed (swarm-disabled daemon, 501):
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/bt \
-d '{"infohash":"btmh:1220a707dead","manifestDigest":"sha256:bbbb…"}'
# {"error":{"code":"E_NOT_IMPLEMENTED",
# "message":"swarm client disabled — set [swarm] enabled = true in ~/.config/shardr/config.toml (004 §7)"}}
magnet and infohash together are E_BAD_REQUEST (mutually
exclusive, executed). A BT import brings in the pinned artifact's
blobs only — it does not create a namespace index; resolve by
name:quant needs a local or HF import, while @sha256:<manifest>
resolves immediately. Terminal job errors: E_NOT_IMPORTABLE
(verification/binding failures), E_SOURCE_UNAVAILABLE.
POST /v1/import/catalog¶
Anchored pull of a community-listed model torrent (provider: pirateface.co). Body:
{
"repo": "owner/name", // REQUIRED — as listed by the provider
"quant": "q4_k_m", // optional 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), derives the
anchor from the Hugging Face tree at the pinned revision, runs the
foreign-swarm import, and keeps seeding the swarm it came from
(good-citizen mode, [catalog] upload_limit budget — see
Catalog pulls).
Executed (repo not listed, 400):
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/catalog \
-d '{"repo":"some-org/does-not-exist"}'
# {"error":{"code":"E_UNKNOWN_REF",
# "message":"catalog: repo not listed by the provider: some-org/does-not-exist"}}
Executed (swarm disabled, 503):
curl -s --unix-socket "$S" -X POST http://localhost/v1/import/catalog -d '{"repo":"x/y"}'
# {"error":{"code":"E_SOURCE_UNAVAILABLE",
# "message":"swarm client disabled ([swarm] enabled) — catalog pulls need the swarm"}}
A rescued model (HF source gone) refuses without trustCatalog
with E_NOT_ANCHORED (422); with it, the anchor becomes the catalog's
own recorded checksums and the job result carries the trust-shift
warning. Terminal job errors include the executed metadata-timeout
case — a listing whose torrent has no seeders online (executed, job
payload after the CLI pull of the same repo):
curl -s --unix-socket "$S" http://localhost/v1/jobs/e51d505f1104bc0f
# {"id":"e51d505f1104bc0f","ref":"HuggingFaceTB/SmolLM2-135M-Instruct","state":"failed",
# "error":{"code":"E_INTERNAL",
# "message":"swarm: catalog: no torrent metadata within 2 minutes — the swarm is unreachable (seeders online? tracker up?); a magnet alone never carries the file tree"},
# "kind":"import-catalog","as":"huggingfacetb/smollm2-135m-instruct",
# "filesDone":24,"filesTotal":24}
POST /v1/verify¶
Integrity re-hash (003 §4) as an async job. Body {"target": "…"}
where target is a canonical ref, a digest, or "all" (the CLI spells
it --all; the API accepts both). A ref target resolves first (loud
on unknown refs); a manifest target verifies the manifest blob and
every file blob it lists; "all" re-hashes the entire CAS.
Executed by-ref (201, terminal done):
curl -s --unix-socket "$S" -X POST http://localhost/v1/verify \
-d '{"target":"shardr:///gold/smollm2-135m-instruct:q4_k_m"}'
# {"id":"e6b5726d17ec83b9", …, "kind":"verify",
# "target":"sha256:696ff8cb…","filesDone":3,"filesTotal":3,"state":"fetching"}
# → terminal: state "done", filesDone 3/3
Executed "all" (terminal done, 20/20 blobs; a failure would carry
E_VERIFY_FAILED and a verify payload with mismatched/missing/
stateErrors lists):
curl -s --unix-socket "$S" -X POST http://localhost/v1/verify -d '{"target":"all"}'
# → {"state":"done","filesDone":20,"filesTotal":20,"verify":{}}
GET /v1/models¶
Local inventory from state + CAS presence (executed, 200):
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":"smollm2-135m-instruct",
# "indexDigest":"sha256:1631afaf…","indexPresent":true,
# "quants":["q4_k_m","q4km","raw"]},
# {"ns":"minicpmv","name":"mmproj",
# "indexDigest":"sha256:fb6ae5be…","indexPresent":true,"quants":["raw"]}],
# "tags":[]}
namespaces[].quants comes from the current index when present and
valid; tags[] entries carry their scoping repo (repo, tag,
digest, blobPresent). The response is explicit about being a
skeleton (skeleton: true) — seed state and sizes are follow-ups, not
silently missing fields. Error: E_INTERNAL (state unreadable).