Skip to content

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.

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

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:

  • match is 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.
  • where is one bounded range filter { path, op: lte | gte | eq, value }. The compare is numeric when value is a number, and text or ISO-date otherwise.
  • orderBy is a single dot-path.
// FSRS card state for the spaced-repetition plugin
await 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,
});

Collections reach both tool lanes, always scoped to the owning plugin:

  • Function tools get the store as collections.put / get / delete / list / query host methods. Access is opt-in and doubly gated: the method name must appear in the manifest capabilities.host allowlist and the tool’s snapshot must carry plugin provenance. A bare code/ 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.