Collections
Plugins are barred from DDL, so they never create tables. Collections is the data API
they get instead: a namespaced jsonb key-value store with put, get, delete, list,
and a bounded query, all scoped to the owning plugin. It is sized for exactly the kind of
per-plugin state a domain feature accumulates, FSRS card scheduling state for
spaced repetition, coding-agent session state for
Devin, a plugin’s own settings, and it is deliberately not
a general-purpose database. When a plugin needs a place to keep structured data that is not
a knowledge-base page, this is that place.
The store
Section titled “The store”Every collection value lives in one row of ops.plugin_collections (plugin, collection, key, value jsonb). The plugin column is set by the host from the caller’s provenance, never by
the plugin, which is what makes isolation structural: a plugin physically cannot read or
write another plugin’s rows, because it cannot address them. The rows cascade-delete with
the plugin, so removing a plugin removes its data.
| Constraint | Limit |
|---|---|
| Value size | 128 KiB per value |
| Key length | 512 characters |
| Collection name | kebab-case, 64 characters |
list page size |
500 rows |
Operations
Section titled “Operations”put(collection, key, value) write
Upserts a jsonb value at (collection, key).
get(collection, key) read
Reads one value by key.
delete(collection, key) write
Removes one row.
list(collection, { prefix, after, limit }) read
Lists keys with optional key-prefix filtering and keyset pagination (after a
key, limit up to 500). LIKE metacharacters in a prefix are treated literally.
query(collection, { match, where, orderBy, limit }) read
Structured lookup. See below.
query is the one place collections do more than key lookup, and it stays bounded on
purpose:
matchis jsonb containment, backed by a GIN index: equality on any path, for example{ status: "learning" }. You declare one or two indexed value paths per collection so containment stays fast.whereis one bounded range filter{ path, op: lte | gte | eq, value }. The compare is numeric whenvalueis a number, and text or ISO-date otherwise.orderByis a single dot-path.
// FSRS card state for the spaced-repetition pluginawait collections.put("cards", cardId, { due: "2026-07-20", stability: 4.2, status: "review" });
const dueToday = await collections.query("cards", { match: { status: "review" }, where: { path: "due", op: "lte", value: "2026-07-16" }, orderBy: "due", limit: 100,});Access from tools
Section titled “Access from tools”Collections reach both tool lanes, always scoped to the owning plugin:
- Function tools get the store as
collections.put/get/delete/list/queryhost methods. Access is opt-in and doubly gated: the method name must appear in the manifestcapabilities.hostallowlist and the tool’s snapshot must carry plugin provenance. A barecode/function you write by hand has no collections at all, whatever its host list claims, because it has no plugin provenance to scope to. - Native tool modules call the same server-side
makeCollections(sql, plugin)store directly.