Primitives
Storage
Object storage with metadata, ETags, range reads and signed URLs for token-free uploads and downloads.
Object storage for the files an agent produces and consumes: build artifacts, reports, screenshots, user uploads, datasets. Each object is a blob of bytes under a key, with a content type, your own metadata and an ETag. Read part of an object with a range, write only if it hasn't changed, and hand anyone a signed URL to download or upload one object without a token.
Creating an object store
A storage primitive is an object store inside a group with an immutable id and a name. A group can hold as many as you need, for example artifacts for what the agent produces and uploads for what users send it:
const artifacts = await group.storage.create('artifacts'); // 409 already_exists if taken; artifacts.id is its id
const uploads = group.storage('uploads'); // by name in the group, resolved on first use
const same = p.storage(artifacts.id); // the same store, by idartifacts = await group.storage.create("artifacts")
same = p.storage(artifacts.id)From the CLI: primitives primitive create artifacts --type storage --group agent-42 (prints the id).
Writing and reading
await artifacts.put('reports/2026/q3.pdf', pdfBytes, {
contentType: 'application/pdf',
metadata: { run: 'r-1234', author: 'agent-42' },
});
await artifacts.put('notes/summary.md', '# Summary\n…'); // strings are UTF-8
const info = await artifacts.head('reports/2026/q3.pdf');
// { key, size, etag, content_type: 'application/pdf', metadata: { run: 'r-1234', … }, created_at, updated_at }
const text = await artifacts.getText('notes/summary.md');
const obj = await artifacts.get('reports/2026/q3.pdf'); // StorageObject, or null if missing
// { key, body: Uint8Array, size, etag, contentType, metadata, lastModified, range }
await artifacts.copy('reports/2026/q3.pdf', 'reports/latest.pdf');
await artifacts.delete('notes/summary.md');await artifacts.put(
"reports/2026/q3.pdf",
pdf_bytes,
content_type="application/pdf",
metadata={"run": "r-1234"},
)
info = await artifacts.head("reports/2026/q3.pdf")
text = await artifacts.get_text("notes/summary.md")
obj = await artifacts.get("reports/2026/q3.pdf") # StorageObject (body: bytes), or None
head = await artifacts.get("logs/run.jsonl", range=(0, 1023))
tail = await artifacts.get("logs/run.jsonl", suffix=4096)
await artifacts.copy("reports/2026/q3.pdf", "reports/latest.pdf")| Method | Description |
|---|---|
put(key, body, { contentType?, metadata?, ifMatch?, ifNoneMatch? }) | Create or replace an object. body is a Uint8Array, a string or a Blob. |
get(key, { range?, ifNoneMatch? }) | A StorageObject, or null if it doesn't exist (or is unchanged, with ifNoneMatch). |
getText(key) / head(key) | The object as UTF-8 text, or just its info. |
copy(from, to, { ifNoneMatch? }) | Copy an object, with its content type and metadata, inside the store. |
delete(key, { ifMatch? }) | Delete an object. Deleting one that doesn't exist succeeds. delete() with no key deletes the whole store. |
list({ prefix?, delimiter?, cursor?, limit? }) | One page of objects in key order. objects(…) iterates every page. |
presign(key, { method?, expiresIn?, contentType?, maxSize?, ifNoneMatch? }) | A signed URL for one object. See Signed URLs. |
revokeSignedUrls() | Invalidate every signed URL issued for this store so far. Returns { epoch }. |
export() | The whole store as a SQLite file. |
Object info is { key, size, etag, content_type, metadata, created_at, updated_at }, with times in milliseconds. A StorageObject from get has the bytes in body, plus key, size (of the whole object, even for a range), etag, contentType, metadata, lastModified and range (the inclusive { start, end } returned, or null). In Python the fields are snake_case, body is bytes and range is a (start, end) tuple; get takes range=(start, end) (end may be None) or suffix=n, copy is copy(source, dest), and revoke_signed_urls() returns the new epoch as an int. A put always writes a whole object: there are no appends or partial writes, and a put replaces the previous content type and metadata too.
Keys and folders
Keys are UTF-8 strings of 1 to 1,024 bytes. They can't contain control characters, start with /, or have . or .. as a /-separated segment. The store is flat: reports/2026/q3.pdf is one key, and there are no directories to create or remove.
A delimiter rolls keys up into folders when you list:
const page = await artifacts.list({ prefix: 'reports/', delimiter: '/' });
// { data: [ObjectInfo for keys directly under reports/], prefixes: ['reports/2025/', 'reports/2026/'], next_cursor: null }
for await (const obj of artifacts.objects({ prefix: 'reports/2026/' })) {
console.log(obj.key, obj.size);
}async for obj in artifacts.iter(prefix="reports/2026/"):
print(obj.key, obj.size)Without a delimiter, every key under the prefix is listed. With one, keys that have the delimiter after the prefix are grouped into prefixes (each listed once, however many objects it holds), and only the rest appear in data. Pages hold 100 entries by default and up to 1,000; pass next_cursor back until it's null.
Metadata
Metadata is up to 32 string pairs you attach on put, such as the run that produced an object or its source URL. Names are 1 to 64 characters of a-z, 0-9, _ and -, and all names and values together are at most 8 KiB. Over HTTP each pair is an x-object-meta-<name> header, on both PUT and GET:
curl -X PUT http://localhost:7777/v1/primitives/$ID/objects/reports/q3.pdf \
-H "Content-Type: application/pdf" \
-H "x-object-meta-run: r-1234" \
--data-binary @q3.pdfContent types are up to 256 bytes and default to application/octet-stream.
ETags and conditional writes
Every object has an etag, a hash of its content, so it changes whenever the bytes do. Use it to avoid overwriting someone else's change:
await uploads.put('locks/run-1234', '', { ifNoneMatch: '*' }); // create only
const info = await artifacts.head('state.json');
await artifacts.put('state.json', next, { ifMatch: info.etag }); // only if unchanged since you read it
await artifacts.delete('state.json', { ifMatch: info.etag });
await artifacts.copy('reports/latest.pdf', 'reports/q3-final.pdf', { ifNoneMatch: '*' });A failed condition returns 412 precondition_failed and changes nothing. Deleting an object that doesn't exist succeeds (204), unless you pass If-Match, which then fails with 412. On reads, If-None-Match with the current ETag returns 304 Not Modified with no body, so caches and browsers can revalidate cheaply.
Range reads
Read part of an object with a range, for example to page through a large log or let a video player seek:
const head = await artifacts.get('logs/run.jsonl', { range: { start: 0, end: 1023 } }); // the first KiB
const tail = await artifacts.get('logs/run.jsonl', { range: { suffix: 4096 } }); // the last 4 KiBOver HTTP this is the standard Range header: bytes=0-1023, bytes=1024- or bytes=-4096, one range per request. The response is 206 Partial Content with Content-Range, and every read advertises Accept-Ranges: bytes. A range that starts past the end of the object fails with 416 range_not_satisfiable and Content-Range: bytes */<size>. A malformed Range header, or one asking for several ranges, is ignored and the whole object comes back with 200.
Object reads are sent with X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox, so an uploaded HTML or SVG file can't run scripts as your origin when someone opens it.
Signed URLs
A signed URL lets someone without a token, such as a browser, a user, or another service, make one kind of request on one object until it expires. Use it to share a report, or to let a user upload a file straight into the store without it passing through your server or the agent.
// A download link, valid for an hour
const { url, expires_at } = await artifacts.presign('reports/2026/q3.pdf', { expiresIn: '1h' });
// An upload link for one file, at most 5 MB of PNG
const upload = await uploads.presign('avatars/user-42.png', {
method: 'PUT',
contentType: 'image/png',
maxSize: 5_000_000,
expiresIn: '10m',
ifNoneMatch: '*', // refuse to overwrite an existing object
});
// In the browser:
await fetch(upload.url, { method: upload.method, headers: upload.headers, body: file });link = await artifacts.presign("reports/2026/q3.pdf", expires_in="1h")
upload = await uploads.presign("avatars/user-42.png", method="PUT", content_type="image/png", max_size=5_000_000)presign returns { url, method, headers, expires_at }. Send the request with exactly that method and those headers. URLs look like https://api.useprimitives.com/v1/signed/v1.… in the cloud and http://localhost:7777/v1/signed/v1.… locally; a self-hosted engine sets the base with --signed-url-base or PRIMITIVES_SIGNED_URL_BASE.
- One object, one method. A
GETURL downloads the object it was signed for (HEAD, ranges andIf-None-Matchwork too). APUTURL uploads to that key, and nothing else.methoddefaults toGET. APUTURL signed withcontentTypeonly accepts that content type, one signed withmaxSizerefuses larger bodies, and one signed withifNoneMatch: '*'only creates the object, never overwrites it. - Lifetime.
expiresInis 1 second to 7 days, 15 minutes by default.expires_atis in milliseconds since the epoch. - Failures don't leak. A bad signature, an expired or revoked URL, the wrong method, or a store that no longer exists all answer
403 forbidden, so a URL never reveals whether an object or store exists. In the cloud, a malformed URL is400 invalid_request. - Revocation. URLs can't be revoked one by one.
revokeSignedUrls()invalidates every URL issued for the store so far, and new ones work as usual. A snapshot restore doesn't bring revoked URLs back. - Forks and moves. A URL is bound to the store's id and its group. A fork is a new store with a new id, so it doesn't accept the source's URLs, and moving a store to another group invalidates all of its URLs.
- Paused and deleted objects. A paused store refuses signed requests with
423 paused. AGETURL for an object that has since been deleted returns404 not_found. - CORS. In the cloud, signed URLs accept requests from any origin for
GET,HEADandPUT, with theContent-Type,If-Match,If-None-Match,Rangeandx-object-meta-*headers, so a web page can upload or download directly. - Audit and usage. Uploads through a signed URL are audited as
storage.putwith the actorsigned_url. Each signed request counts as one operation of the store's group, like any other call.
Treat a signed URL like a short-lived password for that object: anyone who has it can use it. Giving an agent storage_sign lets it hand out that capability for any object in the store. See Auth & scoping.
Storage or filesystem?
Both hold files. Pick by how they're used:
| Storage | Filesystem | |
|---|---|---|
| Shape | Flat keys; folders only when listing with a delimiter | A real tree of directories |
| Writes | Whole objects, with If-Match / If-None-Match | Write, move, rename, mkdir, recursive delete |
| Reads | Byte ranges, ETags and 304 revalidation | Whole files |
| Extras | Content types, metadata, signed URLs for people and browsers | Glob, full-text search, directory forks for sub-agents |
| Best for | Artifacts, uploads and datasets you share outside the agent | The agent's working directory |
Limits
| Limit | Value |
|---|---|
| Key | 1–1,024 bytes of UTF-8 |
| Object | 32 MiB |
| Content type | 256 bytes |
| Metadata | 32 pairs, names [a-z0-9_-]{1,64}, 8 KiB in total |
| Page size | 100 by default, up to 1,000 |
| Signed URL lifetime | 1 second to 7 days, 15 minutes by default |
Snapshots, forks and pause
A storage primitive has its own lifecycle, like every primitive:
await artifacts.snapshot('before-cleanup');
await artifacts.restore('before-cleanup');
const copy = await artifacts.fork({ name: 'artifacts-try' }); // a new store in the same group, new id
await artifacts.pause(); // reads, writes and signed URLs fail with 423 paused
await artifacts.resume();
const { size_bytes, items } = await artifacts.stats(); // items = objects
const file = await artifacts.export(); // Uint8Array, the store as a SQLite filePuts, deletes and copies are audited as storage.put, storage.delete and storage.copy, and revoking signed URLs as storage.presign_revoke. Object contents never are.
Over REST
curl -X PUT http://localhost:7777/v1/primitives/$ID/objects/notes/summary.md \
-H "Content-Type: text/markdown" --data-binary @summary.md
curl -H "Range: bytes=0-99" http://localhost:7777/v1/primitives/$ID/objects/notes/summary.md
curl "http://localhost:7777/v1/primitives/$ID/objects?prefix=notes/&delimiter=/"All endpoints are in the REST API reference.
MCP tools
| Tool | Read-only | Arguments |
|---|---|---|
storage_list | yes | primitive?, prefix?, delimiter?, cursor?, limit? |
storage_read | yes | primitive?, key, head? (metadata only), range? (such as 0-1023 or bytes=-4096) |
storage_write | no | primitive?; action: put (default), copy or delete; key; content with encoding? (utf8 or base64), content_type?, metadata?; to for copy; if_match?, if_none_match? |
storage_sign | no | primitive?, key, method? (GET or PUT), expires_in?, content_type?, max_size?, if_none_match? |
primitive is the store to use: its id or its name from primitive_list. It can be left out when the group has exactly one. storage_read returns { key, content_type, etag, metadata, size, range, encoding, content, truncated }: text as UTF-8 and anything else as base64, at most 256 KiB per call so a large object can't flood the agent's context. Without range it reads the first 256 KiB and sets truncated when there's more; read further with range. Agents create a store with primitive_create { "type": "storage", "name": "artifacts" }. See the tools reference.