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.
- Tasks — to-dos you and the agent track together: a name, notes, an optional due date, and subtasks. (For work that runs on a schedule, see Background Agents.) See Tasks.
- Notes — free-form documents that live in a project: meeting notes, runbooks, plans, drafts. Native and always-on, with full-text and meaning-based search. The agent can read, write, and search them.
- Contacts — the people and organizations you work with, as a lightweight CRM shared across your org: names, emails, companies, tags, and relationship context, enriched automatically from your connected accounts. Only real people make it in: automated senders — newsletters, receipts,
noreply@addresses, support queues — are filtered out before a contact is ever created. - 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.
Custom metadata
Four of these types — Tasks, Notes, Contacts, and 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.
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 Task, Note, Contact, and File. The shape is open — you decide what goes in it.
{
"stripe.customer_id": "cus_NffrFeUfNV2Hib",
"hubspot.contact_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 contacts.create({
name: "Pat",
email: "pat@acme.com",
metadata: { "stripe.customer_id": "cus_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.customer_id without disturbing anything else
await contacts.update(contactId, {
metadata: { "stripe.customer_id": "cus_NffrFe…" },
});
// Removes a key
await contacts.update(contactId, {
metadata: { "stripe.customer_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 }) or pass metadata to a cloud_files-write call (alone, or alongside a content / edit). 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 contacts with a Stripe customer id
await contacts.list({ metadata: { "stripe.customer_id": "cus_NffrFe…" } });
// All tasks tagged by your priority app
await tasks.list({ metadata: { "internal.priority_score": 1 } });
// 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 that another system uses for the same person/task/file, 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
Tasks, Notes, Contacts, and Files all behave the same way — same merge semantics, same 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.