Everything in Hypermedia is made of small immutable pieces of data called blobs. A blob is named by the hash of its own bytes, so it can never be altered without changing its name, and it carries the signature of the account that wrote it, so anyone holding the bytes can check who made them without asking a server. Documents, comments, permissions and profiles are all built from blobs.
This page describes the blob layer. Documents explains how Change and Ref blobs combine into a mutable document. Integrity explains what the signatures prove and what they do not.
Encoding and naming
A blob is a DAG-CBOR value: a map with text keys, encoded in the canonical form (sorted keys, shortest integers, no duplicates), where links to other blobs are native CID values (CBOR tag 42). Unset optional fields are left out. They are never written as null. Because the encoding is canonical, the same logical value always gives the same bytes, and the same bytes always give the same name.
The name of a blob is its CID, a self-describing hash. Hypermedia uses CIDv1 with the dag-cbor codec (0x71) for structured blobs. Files and images are stored as ordinary IPFS UnixFS data: dag-pb (0x70) nodes with raw (0x55) leaves. Those are not signed. Files covers them.
Two hash functions, one rule
The Seed daemon hashes the blobs it creates with BLAKE2b-256. The SDK, the CLI and the Seed web and desktop apps hash the same bytes with SHA-256. Both are valid CIDs for the same bytes, and the daemon accepts either on ingest. When you upload a blob with an explicit CID, it verifies the bytes against whatever hash function that CID names. When you upload a blob without a CID, the daemon assumes DAG-CBOR and computes a BLAKE2b CID.
So every publisher must follow one rule: a blob that another blob references by CID must be uploaded with the exact CID the referrer used. If a Ref names a Change by its SHA-256 CID and you let the daemon compute a BLAKE2b CID for that Change, the Ref points at a name nobody stored. The daemon stores blocks by multihash, so the two names never collide in storage. For links they are two different identities: a version made of the SHA-256 heads and a version made of the BLAKE2b heads of the same bytes are different versions. The home-document genesis (see Documents) is pinned under both CIDs by tests in the daemon and the SDK for this reason.
The signed envelope
Every structured blob embeds the same four fields, defined by the blob schema:
key | CBOR type | meaning |
|---|---|---|
| text |
|
| bytes | the principal of the signing key: a multicodec prefix plus the raw public key |
| bytes | the signature, 64 bytes for Ed25519 and for P-256 |
| integer | the timestamp in Unix milliseconds |
The signer field holds bytes. URLs and the apps show its string form: multibase base58btc, which starts with z6Mk… for Ed25519. Identity explains how keys become principals.
The signing rule
Signing fills the signature field with zeros first:
Set sig to 64 zero bytes.
Encode the whole map as canonical DAG-CBOR.
Sign those bytes with the signer's private key.
Put the real signature into sig and encode again. Those final bytes are the blob, and their hash is its CID.
Verification runs the same steps backwards: check the signature length, copy the signature out, zero the field in place, re-encode, verify against the re-encoded bytes. The signed message is the blob with a zeroed signature. A third-party implementation that drops the field instead of zeroing it produces signatures the daemon rejects. The SDK and the daemon agree on this rule. The PrepareDocumentChange API (the daemon's PrepareChange) returns an unsigned Change with null signer and sig that a client completes with the same steps.
One legacy case: the daemon verifies Comment signatures over the blob decoded as an opaque map instead of the typed struct, because early comments were encoded slightly differently. Every other type is verified after a strict typed decode.
Timestamps
ts is an integer of Unix milliseconds, and the daemon refuses to encode or decode a timestamp that is not rounded to a millisecond. Producers use a causal clock: each new timestamp is strictly greater than any timestamp the node has seen, and a node whose wall clock is more than 40 seconds behind the newest timestamp it has tracked refuses to issue one at all. Nothing checks ts against real time when a blob arrives. The only constraints are on relative order inside a document's history, described in Documents. Integrity lists this as a deliberate limit. The one clock check happens later. When a node replays a document, a Change stamped 40 seconds or more ahead of that node's clock fails to apply. A timestamp of exactly zero is a sentinel used by the deterministic home-document genesis.
The blob types
| what it is | resource it builds | detail |
|---|---|---|---|
| a signed delta on a document: operations plus links to the changes it depends on | a document, together with Refs | |
| a signed claim that a path in a space points at a set of head Changes, or is deleted, or redirects | the address of a document | |
| a whole comment on a document, replaced entirely when edited | a comment thread | |
| a grant from a space owner to another key, with a role and a path scope | permissions | |
| one account's public note about another, replaced entirely when edited | contacts and following | |
| an account's name, avatar and description, or an alias to another account | the profile | |
| UnixFS file nodes and chunks; not signed | files and images |
Change is the only delta type. The other four signed types are snapshot blobs: an edit publishes a new blob carrying the full value, and the newest one wins. Comments and contacts are addressed by a TSID (timestamped id), described below, instead of a path. A profile has one current value per account: the newest. A capability is never edited.
The proto schema for every blob type is in this library: the root pages blob, change, ref, comment, capability, contact and profile each carry their formal schema, and Network blobs explains how they were schematised.
What the daemon does with a blob
The daemon indexes only dag-cbor and dag-pb blocks. It recognises a structured blob by scanning the raw bytes for the CBOR text "type" immediately followed by one of the six type names. This is a byte match with no parse. Any DAG-CBOR blob containing "type":"Ref" at any depth is treated as a Ref. Give your own blob types a distinct type tag, as the Hypermedia Schemas tooling does.
outcome | when |
|---|---|
stored, not indexed | codec is neither dag-cbor nor dag-pb, or the bytes contain no known |
rejected, and the whole upload batch fails | the marker matches but the typed decode fails, the signature fails, an operation contains an unknown field, the Change or Ref invariants are violated, an AGENT capability carries a path, a label is too long, a profile breaks its rules, or a comment reply has no thread root |
stashed: bytes kept, meaning withheld, retried later | a dependency, head, thread root or target has not arrived yet; or the Ref's signer is not an authorised writer of the path; or a profile alias is not yet backed by a capability |
indexed | everything else |
Stashing makes arrival order irrelevant. Blobs can reach a node in any order over the network. The node interprets each one as soon as everything it points at is present and its signer is allowed to say what it says. The authorisation part of that rule is in Permissions.
Two limits apply. A single blob may be at most 2 MiB, the Bitswap block-size limit. Each upload batch is atomic.
TSIDs
Snapshot blobs need a stable identity that survives edits. A TSID is 10 bytes: a 48-bit big-endian Unix-millisecond timestamp followed by the first 4 bytes of the SHA-256 of the blob's bytes, written as base58btc multibase, which comes out at 14 or 15 characters. The first version of a comment or contact derives its TSID from its own bytes. An edit or a tombstone carries the original TSID in its id field so the daemon knows which record it replaces. The full identity of such a record is <signer principal>/<tsid>, which is also what appears in URLs. See URLs.
A worked example
This is a Version Ref as the CLI prints it with seed-cli blob get, in DAG-JSON, where a CID is spelled {"/": "…"} and bytes are spelled {"/": {"bytes": "…"}}:
{
"type": "Ref",
"signer": {"/": {"bytes": "7QGqK1Pk1vXBhgBk6nbpAy3Q7vLg5AfTt0wLc5Bn8tYb1dw"}},
"sig": {"/": {"bytes": "…64 bytes…"}},
"ts": 1757980800000,
"path": "/notes/sushi",
"genesisBlob": {"/": "bafyreiao4v2f6a7x3xkfdeb3pkvpj7lmwl2vn2zl4awqk3jx5zkd6nwmqa"},
"heads": [{"/": "bafyreid3q7zgcqhbebutwvpqmlq5ml5bbp5j3g6o3ry7w4m3wyxghrq2we"}],
"generation": 1757980800000
}There is no space field because the signer is the space. The daemon omits the field when they are equal. There is no visibility because the empty value means public (see Privacy). The blob says: "the key signer claims that, as of ts, the path /notes/sushi in its own space points at the document whose first change is genesisBlob and whose current state is the single head in heads."
Working with blobs
In the Seed app
You rarely see a blob. With the Developer Tools experiment enabled, a document's options menu gains "Inspect Document", which opens the inspector (an hm://inspect/… address): the Refs and Changes behind the document, each openable by CID.
CLI
seed-cli blob get <cid> # print any blob as DAG-JSON
seed-cli blob verify <cid> # check the signature (and a schema, with -s)
seed-cli blob sign -f value.json -t MyType # add signer/ts/sig to a value and publish it
seed-cli blob create -f value.json # publish an unsigned DAG-CBOR value
seed-cli document changes <hm-url> # the Change blobs behind a documentblob sign is how you publish your own blob types with the same envelope; pair it with a schema as described in Hypermedia Schemas. The full command list is in the CLI reference.
SDK
The SDK, @seed-hypermedia/client, implements the envelope once, and every higher-level builder uses it. The @seed-hypermedia/client/blobs module has sign(signer, blob), which zeroes and signs, encode(blob), which produces the DAG-CBOR bytes and the SHA-256 CID, decodeBlob(bytes, cid), which checks the bytes against the CID, and verify(blob), which checks an Ed25519 signature. For arbitrary values the @seed-hypermedia/client/signed-blob module has signBlob, verifySignedBlob and encodeDagCbor. Publishing is client.publish({blobs: [{cid, data}]}), which is the PublishBlobs request below. See the SDK guide for the builders (createChange, createVersionRef, createComment, createCapability, …).
Web API
POST /api/PublishBlobs with {blobs: [{cid?, data}]} stores a batch atomically and returns the CIDs in order. Supply cid for every blob another blob references.
GET /api/GetCID?cid=<cid> returns the decoded value of any stored blob as JSON.
On the daemon's own HTTP port, GET /ipfs/<cid> serves the raw block and GET /ipfs/<cid>.dagjson the decoded form.
The request catalogue is in the Seed API guide.
Agents
Seed Agents read and write blobs through two address verbs. read of ipfs://<cid> returns a file, or for a DAG-CBOR blob the decoded object together with a signature check and, when the blob names a schema, a validation result. write to ipfs://<cid> publishes a memory file, an attachment or a JSON object as blobs. Writes to hm:// addresses sign Change, Ref, Comment, Capability, Contact and Profile blobs for the agent with the key its grant allows. An external agent such as Claude Code with the seed-cli skill uses the CLI commands above; see Agents.
See also
Documents: how Changes and Refs become a document.
Identity: keys, principals and who may sign.
Integrity: what a signature proves, and what it does not.
Files: the unsigned UnixFS side.
Network: how blobs move between peers.
Schema pages: blob, cid, principal, signature, timestamp, DAG-CBOR, canonical encoding, Network blobs.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime