Store blobs in a bucket
A provider that needs to keep files — generated reports, downloaded attachments, cached
renderings — declares its own bucket with defineBucket and reaches it through
BucketService. A bucket is an S3-shaped object store: blobs addressed by /-separated
keys, each with a size, a content type, and user metadata. This guide covers declaring a
bucket, using it from an action, and what the host has to supply.
Buckets are private implementation detail
Section titled “Buckets are private implementation detail”Like a database, a bucket is not one of the primitives. By default it is internal storage. Expose what is in it through actions, entities, and events — a caller asks for “the latest report”, not for a key in your bucket. That keeps you free to change the layout later.
Bytes are the exception the primitives handle badly. A client that needs to download a PDF or upload an image wants the blob itself, not a base64 field inside an action result — so a bucket can opt into being served directly. That is a deliberate choice per bucket, not the default, and it publishes the key layout you would otherwise be free to change.
Declare the bucket
Section titled “Declare the bucket”defineBucket takes an id and returns the definition unchanged. Call it once, at
module scope, and export the definition.
import { defineBucket } from '@grundlag/core';
const reportsBucket = defineBucket({ id: 'reports' });
export { reportsBucket };The definition object is the bucket’s identity, not just its id. Asking BucketService
for an id that another definition already claimed throws BucketIdConflictError, so two
providers cannot end up sharing storage by accident. Two providers that should share a
bucket share the definition.
Register it during setup
Section titled “Register it during setup”Register the definition on the provider instance so the bucket is known before anything touches it:
setup: async ({ services }) => { const instance = new ProviderInstance('reporting'); instance.buckets.register(reportsBucket); return instance;};BucketService only learns of a bucket when someone asks for an instance, so a bucket used
solely inside an action stays invisible until that action first runs — too late for a host
that mounts its HTTP routes at boot. Registering is also what makes the bucket visible to
anything that enumerates a provider’s storage.
Registering publishes nothing on its own: unlike actions and entities, a registered bucket
is still private until it is marked exposed.
Use the bucket from an action
Section titled “Use the bucket from an action”The storage backend is supplied by the host, not the provider — the host sets a
bucketCreator on the Services container (see
Services and dependency injection). Your provider asks
BucketService for the bucket and gets an object it can read and write.
import { BucketService, defineAction, z } from '@grundlag/core';
import { reportsBucket } from '../buckets/buckets.js';
const saveReport = defineAction({ id: 'saveReport', name: 'Save report', description: 'Renders a report and stores it for later download.', input: z.object({ month: z.string(), body: z.string() }), output: z.object({ key: z.string(), size: z.number() }), execute: async ({ input, services }) => { const bucket = services.get(BucketService).getInstance(reportsBucket);
const object = await bucket.putText(`monthly/${input.month}.md`, input.body, { contentType: 'text/markdown', metadata: { generatedBy: 'saveReport' }, });
return { key: object.key, size: object.size }; },});getInstance is cached by id and the backend is opened lazily on first read or
write, so it is cheap to call from every action.
The object API
Section titled “The object API”await bucket.put('a/b.bin', bytes, { contentType: 'application/octet-stream' });await bucket.putText('a/b.txt', 'hello'); // defaults to text/plain; charset=utf-8
const bytes = await bucket.get('a/b.bin'); // Uint8Arrayconst text = await bucket.getText('a/b.txt'); // string
const object = await bucket.stat('a/b.txt'); // BucketObject | undefinedconst there = await bucket.exists('a/b.txt'); // boolean
await bucket.delete('a/b.txt'); // idempotentconst objects = await bucket.list({ prefix: 'a/' }); // ordered by keyThe two shapes of “missing” are deliberate: get and getText throw
BucketObjectNotFoundError because a caller that asked for a blob by key expects one, while
stat and exists report absence by return value because asking is the point. delete on
a key that is not there is not an error.
put replaces whatever was at the key, metadata included — a write that passes no
metadata clears the previous write’s rather than merging into it.
Keys are relative, /-separated paths: reports/2026/06.md. A key that is empty, starts or
ends with /, contains an empty segment, a . or .. segment, a backslash, or a null byte
is rejected with InvalidBucketKeyError. The filesystem backend maps keys onto paths, so
these rules are what stops a key built from tool input from writing outside its bucket. Keys
are validated on every call, get and delete included.
Exposing a bucket over HTTP
Section titled “Exposing a bucket over HTTP”exposed decides one thing: whether the host serves the bucket over HTTP. It defaults to
false.
| Private (the default) | exposed: true |
|
|---|---|---|
| Provider code using it | Unchanged | Unchanged |
| HTTP routes | None — the bucket is unreachable from outside | The full object API below |
| Who can read and write | Only code holding the definition | Anyone who can reach the host |
| Reached by callers | Indirectly, through actions and entities | Directly, by key |
The flag changes nothing about the bucket itself. A private bucket is not a lesser bucket: same storage, same operations, same backend. It simply has no door from the outside, so the only way in is code that already has the definition. Flipping the flag adds that door — it does not migrate anything, and a bucket can be made private again by removing it.
Reach for it when a caller needs the bytes themselves — downloading a PDF, uploading an avatar — which is the case the primitives handle badly, since an action would have to base64 the payload into a JSON field. Everything else is better served by an action that happens to read from the bucket.
const reportsBucket = defineBucket({ id: 'reports', exposed: true });The routes are flat, keyed by bucket id rather than by owning provider, because bucket ids are already globally unique:
| Method | Route | Does |
|---|---|---|
GET |
/api/buckets |
Lists the exposed buckets |
GET |
/api/buckets/{id} |
Lists objects, optionally ?prefix= |
GET |
/api/buckets/{id}/{key} |
Reads the raw bytes |
HEAD |
/api/buckets/{id}/{key} |
Size, content type, and metadata as headers |
PUT |
/api/buckets/{id}/{key} |
Writes the request body verbatim |
DELETE |
/api/buckets/{id}/{key} |
Deletes the object |
The key is the whole trailing path, so /api/buckets/reports/monthly/june.md addresses the
key monthly/june.md. On a write, Content-Type becomes the object’s content type and
x-bucket-meta-<name> headers become its metadata, S3-style; both are replaced wholesale by
each write. A missing object is a 404 and a malformed key a 400. Request bodies are capped at
32 MiB — blobs are held whole in memory on both sides.
Only registered buckets can be exposed — the host enumerates what providers registered at
boot, so a definition that is never registered has no routes no matter what its exposed
flag says.
From the other side of the wire, @grundlag/client offers the same operations against an
exposed bucket — see
Call actions and search entities.
What the host supplies
Section titled “What the host supplies”A bucketCreator maps a bucket id to a BucketStorage. Hosts that pass nothing get the
in-memory backend, which is what makes buckets work in tests without touching disk. For a
real deployment, hand the container the filesystem creator:
import { Services } from '@grundlag/core';import { createFileSystemCreator } from '@grundlag/core/filesystem';
const services = new Services({ bucketCreator: createFileSystemCreator((id) => join(dataLocation, 'buckets', id)),});Each bucket becomes a directory: blobs under objects/<key>, and — only for objects that
have a content type or metadata — a sidecar at meta/<key>.json. Walking objects/ gives
exactly the keys in the bucket.
Implementing BucketStorage yourself is how another backend (S3, a blob service) gets
plugged in; nothing in a provider changes when the host swaps one in.
Guidelines
Section titled “Guidelines”- Keep the bucket private by default. Expose its contents through actions, entities, and
events; reach for
exposedwhen a client genuinely needs the bytes themselves. - Register during setup. It is what makes the bucket known before first use.
- Define once, share the definition. The definition object is the identity; a duplicate id from a different definition is an error.
- Blobs are whole values.
putandgetmove an entire object; buckets are for files you can hold in memory, not for streaming multi-gigabyte media. - Let the host own the creator. The provider asks
BucketService; the host decides where the bytes land.