Data Types
Avi is built on a small set of native data types that every project has from day one. The agent reads and writes them, apps extend them, and you can query them — they're the shared substrate the whole platform sits on.
The native types
- Events — an append-only log of everything that happens in a project: messages received, background-agent runs, deploys, app activity. Events are immutable and carry a structured payload. Apps publish them, and Avi's curation engine reads the log to build the Updates feed.
- Files — documents, images, PDFs, and data stored in the project. The agent reads them (PDFs and images natively), writes and edits them, and searches across them. See Files.
Three types that used to be native now live in apps — the records, panels and tools are the same, they just belong to an app that owns the domain:
- Tasks — to-dos you and the agent track together: a name, notes, an optional due date, and subtasks. In the always-on Tasks app; see Tasks. (For work that runs on a schedule, see Background Agents.)
- Contacts — the people this project deals with: names, emails, phones, companies, tags, and notes, standalone or synced with Google Contacts. In the always-on Contacts app; see Contacts.
- Notes — free-form documents like meeting notes, runbooks, plans, and drafts. In the Drive app, alongside your documents and connected cloud storage.
Everything that existed before each move was imported automatically, and old links open in the owning app.
Custom metadata
Files also carry a metadata field: a freeform key-value bag where apps, integrations, and your own code can store arbitrary data alongside each record. (App records — a Task, a Contact, a CRM deal — carry their own metadata bag too, governed by the app that owns them; the semantics below are the platform's.)
This is how Avi stays the most extensible agent harness: every record can be tagged, joined, and queried by the systems your team already uses — Stripe, HubSpot, Linear, Intercom, anything — without modifying the schema.
What metadata is
A JSON object on every File. The shape is open — you decide what goes in it.
{
"stripe.invoice_id": "in_NffrFeUfNV2Hib",
"hubspot.deal_id": "1234567",
"team_owner": "alex@acme.com"
}There is no schema. There's no registry. You just write the keys you need.
Convention: prefix your keys
The platform doesn't enforce anything, but the universal convention is to prefix your keys with a namespace so two systems writing to the same record don't accidentally collide:
stripe.customer_idhubspot.deal_idinternal.priority_score
Pick something obvious (your tool name, your team) and stick with it.
Writing metadata
Every create and update accepts an optional metadata field.
On create, you supply the initial object:
await files.put("invoices/2026-q1.pdf", bytes, {
metadata: { "stripe.invoice_id": "in_NffrFe…" },
});On update, metadata is a shallow merge patch, not a replace:
- Each top-level key you supply REPLACES the value at that key.
- A
nullvalue DELETES that key. - Keys you don't mention are left untouched.
nullANYWHERE in the patch is stripped — including nested.{ stripe: { token: null } }stores as{ stripe: {} }. If you need to keep anullsomewhere nested, just don't write it; store an explicit sentinel value instead.
// Adds stripe.invoice_id without disturbing anything else
await files.updateMetadata(path, { "stripe.invoice_id": "in_NffrFe…" });
// Removes a key
await files.updateMetadata(path, { "stripe.invoice_id": null });This is the important property: two apps writing different top-level keys never clobber each other. Stripe's tool can write stripe.* while HubSpot's tool writes hubspot.*, and neither has to know the other exists.
Files store metadata on the row, not in the file's bytes — so renames, moves, and re-uploads all preserve it. Update a file's metadata with a PATCH …/files/metadata (body: { path, metadata }), from an app's file write, or from the agent (drive_write sets metadata on a file write or patches it alone). On re-upload of the same path, the new metadata is merged into whatever was already on the row.
Querying metadata
Every list method on these types accepts a metadata filter. It matches records whose metadata contains all the key/value pairs you supply (JSON containment).
// All files a review app has signed off on
await files.list({ metadata: { "review.approved": true } });The matching is exact at each key — pass the value you stored. Nested objects compare by structural containment, so { stripe: { customer_id: "cus_…" } } matches a row that has at least that nested shape.
When to use it
- Cross-system identifiers. The single most common use: storing the foreign key another system uses for the same record, so you can round-trip between Avi and that system.
- App-defined fields. An app wants to attach a
priority_scoreortriage_statuswithout needing a new column. - Ad-hoc tags or hints the agent or your apps will later filter on.
When not to use it
- Real first-class fields. If a value belongs on every record of a type, ask for a real column. Metadata is for the long tail, not the spine.
- Anything secret. Metadata is plaintext and visible to anyone with read access on the record. Secrets belong in Secrets, not metadata.
Where it works
Files behave exactly as described above, and app record collections follow the same merge semantics and query shape. Events and Updates aren't part of this surface: events are immutable log entries that carry their own structured payload rather than an editable metadata bag.