Blob Attachments
Attach binary files to LLMtxt documents — content-addressed storage, Last-Write-Wins merge semantics, lazy sync, S3/R2 or PG large objects backend, and hash verification on every read.
Blob Attachments
LLMtxt documents are text and JSON. Binary artifacts — rendered diagrams, screenshots, compiled outputs, PDFs — travel alongside documents via blob attachments. Blobs are content-addressed (SHA-256 hash = storage key), scoped per document, and merged via Last-Write-Wins when two agents upload the same attachment name concurrently.
Blobs are not CRDT-merged. A PNG from two concurrent editors cannot be meaningfully merged. The correct primitive is LWW per attachment name.
Concepts
Content Addressing
Every blob is identified by its SHA-256 hash. The hash is the storage key. Two attachments with identical bytes share one storage object. The hash is computed by hash_blob in crates/llmtxt-core.
storage path (LocalBackend): .llmtxt/blobs/<sha256-64-char-hex>
object key (S3/R2): blobs/<sha256-64-char-hex>The hash is verified on every read that returns bytes. If the stored bytes produce a different hash than the recorded hash field, BlobCorruptError is returned and the corrupt file is quarantined.
Attachment Names
An attachment name is the user-visible label for a blob within a document (e.g., diagram.png, report.pdf). Names are scoped per document. The same name on two different documents refers to two independent records.
Name validation (enforced in Rust blob_name_validate):
- Length: 1–255 bytes (UTF-8)
- Must not contain
..(path traversal prefix) - Must not contain
/or\(path separators) - Must not contain null bytes (
\0) - Must not start or end with whitespace
Invalid names are rejected before any storage operation.
LWW Merge Rule
When two agents independently upload an attachment with the same name on the same document, the conflict resolves by Last Write Wins per attachment name:
winner = agent with the larger uploadedAt timestamp
tie-break = larger uploadedBy string (lexicographic — deterministic)This rule applies to the manifest record only. Blob bytes are not merged. The winning record's hash determines which bytes are canonical.
API Reference
attachBlob(params)
Attach a binary blob to a document.
import { createBackend } from 'llmtxt';
import { readFileSync } from 'node:fs';
const backend = createBackend({ topology: 'standalone', storagePath: '.llmtxt' });
await backend.open();
const imageBytes = readFileSync('./diagram.png');
const attachment = await backend.attachBlob({
docSlug: 'api-spec',
name: 'diagram.png',
contentType: 'image/png',
data: imageBytes,
uploadedBy: 'agent-alice',
});
console.log(attachment.hash); // SHA-256 hex (64 chars)
console.log(attachment.size); // byte count
console.log(attachment.uploadedAt); // unix ms timestampBehavior:
- Computes SHA-256 of
dataand uses it as the storage key - Validates the attachment name before any storage operation
- Enforces
maxBlobSizeBytes(default 100 MB); throwsBlobTooLargeErrorif exceeded - If a blob with the same name already exists on the document, soft-deletes it and inserts the new record (LWW)
- Does NOT store duplicate bytes when the hash already exists in the store (content-addressed dedup within the same backend)
getBlob(docSlug, blobName, opts?)
Retrieve a blob attachment record, optionally including raw bytes.
// Metadata only (no bytes transferred — fast)
const meta = await backend.getBlob('api-spec', 'diagram.png');
if (meta) {
console.log(meta.hash, meta.size, meta.contentType);
}
// With bytes — hash verified on read
const blobData = await backend.getBlob('api-spec', 'diagram.png', { includeData: true });
if (blobData?.data) {
// blobData.data is a Buffer containing the raw bytes
await fs.writeFile('./local-diagram.png', blobData.data);
}Returns null (not a thrown error) when no blob with blobName is attached to the document.
When includeData: true, the hash is verified after reading. A mismatch throws BlobCorruptError.
listBlobs(docSlug)
List all active (non-deleted) blob attachments for a document.
const attachments = await backend.listBlobs('api-spec');
for (const a of attachments) {
console.log(`${a.blobName} ${a.size} bytes ${a.contentType} by ${a.uploadedBy}`);
}
// diagram.png 43210 bytes image/png by agent-alice
// report.pdf 1245678 bytes application/pdf by agent-bobReturns an empty array (not an error) when no blobs are attached.
detachBlob(docSlug, blobName, detachedBy)
Soft-delete a named blob attachment from a document.
const removed = await backend.detachBlob('api-spec', 'diagram.png', 'agent-alice');
console.log(removed); // true if found and soft-deleted; false if not foundReturns false (not an error) when no active attachment with that name exists.
The blob bytes are NOT deleted from storage — V1 has no automatic garbage collection. The manifest record is soft-deleted (deleted_at set); the bytes remain in the store until a future llmtxt gc-blobs command runs.
fetchBlobByHash(hash)
Fetch blob bytes directly by hash, bypassing the document manifest. Used by the sync layer for lazy blob pull.
const bytes = await backend.fetchBlobByHash(
'e3b0c44298fc1c149afbf4c8996fb924...'
);
// Returns null if the hash is not present in the local storeThis method requires the caller to hold read access to at least one document that references the requested hash.
BlobAttachment Type
interface BlobAttachment {
id: string; // nanoid record ID
docSlug: string; // document slug
blobName: string; // user-visible attachment name
hash: string; // SHA-256 hex (64 chars)
size: number; // byte count (original, uncompressed)
contentType: string; // MIME type
uploadedBy: string; // agentId of uploader
uploadedAt: number; // unix timestamp ms
}CLI Commands
llmtxt attach
# Attach a file to a document
llmtxt attach api-spec ./diagram.png
# Attached diagram.png to api-spec
# Hash: e3b0c44298fc1c149afbf4c8996fb924...
# Size: 42.2 KB
# Override attachment name
llmtxt attach api-spec ./diagram-v2.png --name diagram.png
# Override MIME type
llmtxt attach api-spec ./data.bin --name data.bin --content-type application/octet-streamMIME type is auto-detected from the file extension when --content-type is not supplied.
llmtxt detach
llmtxt detach api-spec diagram.png
# Detached diagram.png from api-specllmtxt blobs
llmtxt blobs api-spec
# NAME SIZE TYPE UPLOADED BY UPLOADED AT
# diagram.png 42.2 KB image/png agent-alice 2026-04-17T19:00:00Z
# report.pdf 1.2 MB application/pdf agent-bob 2026-04-17T18:55:00ZBackend Configuration
LocalBackend (Filesystem)
Blob bytes are stored at .llmtxt/blobs/<sha256>. No additional configuration is required. The directory is created on first use.
const backend = createBackend({
topology: 'standalone',
storagePath: '.llmtxt',
// maxBlobSizeBytes: 100 * 1024 * 1024, // default 100 MB
});File write is atomic: bytes are written to <hash>.tmp and then renamed to <hash>. This prevents a partially written blob from being visible to other processes.
PostgresBackend — S3/R2 (Default)
For production deployments, blobs are stored in S3-compatible object storage:
const backend = createBackend({
topology: 'hub-spoke',
hubUrl: 'https://api.llmtxt.my',
apiKey: process.env.LLMTXT_API_KEY,
// Blob storage config on the hub side (BackendConfig)
blobStorageMode: 's3',
s3Endpoint: 'https://s3.us-east-1.amazonaws.com',
s3Bucket: 'my-llmtxt-blobs',
s3Region: 'us-east-1',
s3AccessKeyId: process.env.S3_ACCESS_KEY_ID,
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
maxBlobSizeBytes: 50 * 1024 * 1024, // 50 MB limit
});For Cloudflare R2, set s3Endpoint to your R2 endpoint (https://<account-id>.r2.cloudflarestorage.com). R2 uses the S3 API.
Upload uses server-side SHA-256 integrity check (x-amz-checksum-sha256) where the provider supports it.
PostgresBackend — PG Large Objects (Fallback)
When S3 is not configured, blobs are stored in PostgreSQL large objects:
// In BackendConfig (server-side)
{
blobStorageMode: 'pg-lo', // uses pg_largeobject catalog
maxBlobSizeBytes: 10 * 1024 * 1024, // 10 MB recommended for PG-LO
}Large object creation and the blob_attachments insert happen in the same transaction. Bytes are written in 64 KB chunks to avoid memory pressure. Hash is verified after reading all chunks.
PG large objects do not scale as well as S3 for large files. Use S3/R2 for production.
Changeset Sync (Lazy Pull)
In mesh or hub-spoke topologies with cr-sqlite, blob references travel in sync changesets but bytes are NOT pushed eagerly:
- Agent A attaches a blob;
attachBlobwrites bytes to local store. - Next sync: the changeset includes a
BlobRef(name, hash, size, contentType, uploadedBy, uploadedAt). - Agent B receives the changeset, applies the LWW rule to its manifest, and records the
BlobRef. - Agent B's manifest now shows the attachment, but bytes are not yet local.
- When Agent B calls
getBlob('api-spec', 'diagram.png', { includeData: true }), bytes are pulled lazily viafetchBlobByHash(hash)from Agent A (or the hub).
This lazy-pull protocol keeps changeset sizes small. Manifest metadata (a few hundred bytes) travels with the changeset; megabytes of bytes are only fetched on demand.
Security Model
Hash Verification
Every read that returns bytes verifies the SHA-256 hash. If the stored bytes produce a different hash than the recorded hash field:
BlobCorruptErroris thrown- The corrupt file is quarantined (renamed to
<hash>.corrupton LocalBackend) - The error is propagated — corrupt bytes are NEVER returned to the caller
Path Traversal Prevention
Blob names are validated by blob_name_validate in Rust before any storage operation. The filesystem storage path for LocalBackend is derived solely from the content hash (never the attachment name), so even if name validation were bypassed, no path traversal is possible in the storage layer.
Access Control
Blob access inherits the document's access control policy:
| Caller capability | Permitted blob operations |
|---|---|
| Read document | listBlobs, getBlob |
| Write document | attachBlob, detachBlob |
| Sync layer | fetchBlobByHash (requires read access to at least one referencing document) |
HTTP API endpoints enforce requireAuth and document-level RBAC before delegating to blob operations. HTTP responses for blob downloads include Content-Disposition: attachment to prevent browser execution.
Size Limits
The default maximum blob size is 100 MB (maxBlobSizeBytes: 100 * 1024 * 1024). The check occurs before any storage allocation. Violations throw BlobTooLargeError with the configured limit in the message.
Limitations and Future Work
| Limitation | V1 behavior | Future |
|---|---|---|
| No CRDT merge | Blobs use LWW only — no byte-level merging | Not planned (binary files are not meaningfully CRDT-mergeable) |
| No automatic garbage collection | Soft-deleted and orphaned blobs remain in store | Future llmtxt gc-blobs command |
| Memory-buffered | Full blob bytes held in memory during hash verification | Future streaming with progressive hash computation |
| No blob versioning | One canonical record per name (LWW) | Future llmtxt blob-history command |
| No cross-document dedup | Dedup within a single backend instance only | Future shared hash registry |
Error Reference
| Error class | Condition |
|---|---|
BlobTooLargeError | data.byteLength > maxBlobSizeBytes |
BlobNameInvalidError | Name fails blob_name_validate |
BlobCorruptError | Hash mismatch on read |
BlobNotFoundError | Hash not in store (sync pull failure) |
BlobAccessDeniedError | Caller lacks read/write permission on the document |
Related Docs
- Export and Import — exporting document content (text only; blobs are separate)
- cr-sqlite Sync — changeset exchange that carries
BlobRefentries - Session Lifecycle — managing blob operations within an AgentSession
- SDK Reference — full Backend interface
Document Export and Import
Export LLMtxt documents to disk in 4 formats (markdown, JSON, plain text, native .llmtxt), import files as new documents or new versions, and use signed exports for audit trails.
API Reference
REST API at api.llmtxt.my for document management, progressive disclosure, and collaboration