---
title: "Records and start-up text"
description: "Keep versioned records from your plugin's tools through the platform, under the tenant's retention, holds and erasure, and add text to the start-up context of the agents that use your plugin."
canonical_url: "https://docs.oz0.ai/build/parts/records-and-startup"
---
# Records and start-up text

> Keep versioned records from your plugin's tools through the platform, under the tenant's retention, holds and erasure, and add text to the start-up context of the agents that use your plugin.

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](https://docs.oz0.ai/build/plugins/verified/memory) uses both interfaces without special platform privileges.

## Declare them

```yaml [oz0-plugin.yaml]
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:

```json
{"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.

<table>
<thead>
  <tr>
    <th>
      <code>
        op
      </code>
    </th>
    
    <th>
      Fields
    </th>
    
    <th>
      Answer
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        put
      </code>
    </td>
    
    <td>
      <code>
        scope
      </code>
      
      , <code>
        record_key
      </code>
      
      , <code>
        body
      </code>
      
       (JSON, at most 16 KiB), <code>
        expect
      </code>
      
       (the version read, 0 for a new record), <code>
        kind
      </code>
      
      , <code>
        state
      </code>
      
      , <code>
        dedupe
      </code>
      
      , <code>
        supersedes
      </code>
      
      , <code>
        ends_at
      </code>
      
      , <code>
        subject_kind
      </code>
      
       (<code>
        work
      </code>
      
       or <code>
        person
      </code>
      
      ), <code>
        closed
      </code>
    </td>
    
    <td>
      <code>
        {"version": n, "scope": ..., "key": ...}
      </code>
      
      : what was written
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        get
      </code>
    </td>
    
    <td>
      <code>
        scope
      </code>
      
      , <code>
        record_key
      </code>
      
      , <code>
        version
      </code>
      
       (0 for the newest)
    </td>
    
    <td>
      <code>
        {"record": {...}}
      </code>
      
      , or <code>
        {}
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        history
      </code>
    </td>
    
    <td>
      <code>
        scope
      </code>
      
      , <code>
        record_key
      </code>
    </td>
    
    <td>
      <code>
        {"records": [...]}
      </code>
      
      , newest first
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        list
      </code>
    </td>
    
    <td>
      <code>
        list: {scopes, kinds, states, prefix, since, ended, deleted, after, limit, order, cursor, dedupe, newest_in}
      </code>
    </td>
    
    <td>
      <code>
        {"records": [...], "withheld": n, "next": "...", "more": true}
      </code>
      
      , the newest version of each
    </td>
  </tr>
</tbody>
</table>

- Scopes are `tenant`, `project:<name>` and `agent:<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 `put` adds a version and keeps the ones before. A `put` whose `expect` is not the record's newest
version is refused with `{"error": "stale", "current": n}`. A delete is a `put` with `state: deleted`.
- Dedupe. A `put` with `dedupe` text (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 `put` with `closed: true` closes its record: the record is replaced, not in force. A record's first
version cannot be closed (`{"error": "closed"}`). Lists leave it out
unless `ended: true`, and it raises no review. Use it, with a `supersedes` link 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 `put` the 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](https://docs.oz0.ai/govern/personal-data/plugin-records#what-plugins-keep) 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:

```json
{"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.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
