Records and start-up text
For: plugin authors.
Store data that must survive a job in plugin records. The platform keeps every version, rejects writes based on an old version, and can deduplicate identical text within a scope. It seals records written by a data subject's job and applies tenant retention, legal holds and erasure.
Records travel with tenant exports. Each read records the returned versions in the audit log. A plugin can also supply text at the start of jobs that use it. The memory plugin uses both interfaces without special platform privileges.
Declare them
records:
scopes: [agent, project, tenant] # any of them; all three when left out
startup:
call: acme_notes.startup_context # one of the plugin's own functions
plugin lint checks the scopes, that the plugin has tools, and that startup.call is one of its own local functions
that needs no approval.
Records
Each call of one of the plugin's functions gets, in its _meta under oz0/records, the address of the node's
runtime on loopback, a ticket good for that call only, and the calling agent's name:
{"oz0/records": {"url": "http://127.0.0.1:41873/v1/records", "ticket": "…", "agent": "planner"}}
The function posts JSON there with Authorization: Bearer <ticket>. The runtime adds what the plugin may not choose,
the plugin's own namespace, the job, the call's step and the job's labels, and hands the request to the edge. The
ticket dies with the call; a plugin process never holds a credential that reaches the edge. Tools in containers,
microVMs or remote servers get no ticket: they cannot reach the runtime's loopback.
op | Fields | Answer |
|---|---|---|
put | scope, record_key, body (JSON, at most 16 KiB), expect (the version read, 0 for a new record), kind, state, dedupe, supersedes, ends_at, subject_kind (work or person), closed | {"version": n, "scope": ..., "key": ...}: what was written |
get | scope, record_key, version (0 for the newest) | {"record": {...}}, or {} |
history | scope, record_key | {"records": [...]}, newest first |
list | list: {scopes, kinds, states, prefix, since, ended, deleted, after, limit, order, cursor, dedupe, newest_in} | {"records": [...], "withheld": n, "next": "...", "more": true}, the newest version of each |
- Scopes are
tenant,project:<name>andagent:<name>. An agent's scope is read and written only by jobs of that agent: the edge takes the agent from the job, not from the request. - Versions. Every
putadds a version and keeps the ones before. Aputwhoseexpectis not the record's newest version is refused with{"error": "stale", "current": n}. A delete is aputwithstate: deleted. - Dedupe. A
putwithdedupetext (normalise it yourself) is refused with{"error": "duplicate", "key": ..., "version": ...}when another live record of the scope, written for the job's purpose, has the same. The edge keeps only a keyed digest of it.
Ends
Every record ends. A put without ends_at ends after the tenant's default (90 days unless the tenant changed it); one past the tenant's longest end (365 days by default), or in the past, is refused with {"error": "ends_at", "max_days": n}, except a version that carries its record's end forward (no later than the end the record has), which is brought down to the tenant's longest end when the tenant lowered it.
A record past its end is not live: lists leave it out unless ended: true, and its dedupe text is free again. What an end means depends on the record's kind (below).
- Closing. A
putwithclosed: truecloses its record: the record is replaced, not in force. A record's first version cannot be closed ({"error": "closed"}). Lists leave it out unlessended: true, and it raises no review. Use it, with asupersedeslink on the successor, for "replaced"; an end of now would delete a record about people.
Work or people
Each put says whether the record is memory about work (subject_kind: work, the default) or about people (person: a preference, what someone asked or decided). The platform decides in the end: whatever a job with a data subject writes is person, and a record stays person once a version of it was. A person write is refused with {"error": "person_off"} unless the tenant turned memory about people on for the calling agent and the job's purpose, and with {"error": "person_scope"} outside the agent's own scope unless the tenant shares it there.
A person record is deleted at its end; a work record's end is a review date: it leaves lists without ended and its owner is asked to renew it, and it stays until retention. Pass both errors' reason on to the model: it says what the tenant must do.
Purposes
Each record carries the purpose its job tree was started for, which the platform takes from the job, never from the request. Reads return records written for the job's purpose, or for one the tenant maps as compatible, and records about work written without a purpose; a job without a purpose reads only records written without one. A job writes only records of its own purpose ({"error": "purpose"}), whatever it may read: a record about work written without a purpose is written again only by jobs without one.
Each record shows its subject_kind, its purpose, whether it is closed, and writable: whether the calling job may write its next version. Every answer names the calling job's purpose in job_purpose ("" for none). A function that changes two records checks that both are writable before it writes either, and keys its own bookkeeping records (a bookmark, a list of what it used) by job_purpose, since a record one purpose wrote is not another's to write.
- Caps. The tenant caps each scope of each plugin in records (5,000 by default) and in bytes of their newest
versions' bodies (8 MiB by default). Closed records and records past their end count until retention (or, for
memory about people, their end) removes them; a deleted record no longer counts, until it is written again (which takes a place under the cap like a new one). A write past a cap is refused with
{"error": "full", "cap": "records" | "bytes", "limit": n}and writes nothing. - A retried step writes once: each request of a call has its own key, the same on a retry, and a
putthe first try made already answers with the scope, key and version it wrote, even when the retry named another key. Pick new keys from the call's_meta["oz0/idempotency_key"](the same on every attempt), not at random, so that the retry makes the same requests.
Lists
List records by scope and key, or by time with order: created (oldest first) or -created (newest first). A page holds at most limit records; when more is true, pass the answer's next as cursor for the next page. The cursor counts withheld records too, so a page cut short by a wall is not the end. dedupe keeps the records whose dedupe text is the one given (in one scope): a way to find a record by a name without putting the name in its key.
newest_in: true with states picks, of each live record, the newest version in one of those states: the version in force while a newer one waits in another. Each record has created_ms, its time as a number.
Labels
What a call reads joins its result's labels with the source memory: a record written by a tainted job, or by an agent of another plugin than the reader's, taints the job that reads it. The runtime sends the labels of what a call read with the call's later requests: a record the call writes keeps them. When the tenant's label policies keep compartments apart, a record on the far side of a wall from the reading job, or from a record the same call was shown, is withheld and counted in withheld; without such a policy nothing is.
- Bounds: a key is 1 to 200 characters of letters, digits and
._:@/+=-; a scope holds at most 20,000 live records whatever the tenant's cap; a list returns at most 200; a call makes at most 64 requests. - Start-up calls never see ended, closed or deleted records, whatever they ask, so what a job starts with is in force for its purpose.
What happens to records under retention, holds and erasure is on the personal data page.
Start-up text
When a job of an agent that uses the plugin starts (the plugin's own agents, and an agent whose tools names one of
its functions, another plugin's under its grant), the platform calls startup.call on the job's home node with
{"budget": <tokens>}: what the agent's limits.startup_tokens leaves after its skills index. The function answers
with entries in its order of priority:
{"entries": [{"text": "Decision (tenant): Deploy on Wednesdays. [tenant/d-3f2a91c0 v2]"}, {"text": "…"}]}
The platform puts them under ## Context from plugin <name> after the agent's instructions and its skills index,
keeps the entries that fit in order, leaves out the rest with a log line, and never puts them in the per-request
status block. A plain text answer is one entry. At most four plugins add start-up text to one agent. The call's
records reads are audited as a start-up read, with the records and versions it showed; the job's trace shows the
step as startup <plugin>.
plugin lint counts the heading in the agent's start-up budget; the text itself is counted when the job runs. A
start-up call that fails or is refused (a grant revoked, the tenant's tool policy) leaves the text out, and the job
goes on.
Bring your own agent
Run code you already have, a script, a LangGraph graph or a CrewAI crew, as an agent on the fleet, with its model calls metered and its tool calls asked first.
Addresses
Every part has one address, <plugin>/<part>. Where you write one, how the server checks it against the part's owner, and why a part is never renamed in place.