Custom Data

Avi's four native record types — Contacts, Tasks, Notes, Interactions — each carry a metadata field. It's a freeform key-value bag where custom modules, integrations, and your own code can store arbitrary data alongside each record.

This is how Avi stays the most extensible AI assistant: every native primitive 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 Contact, Task, Note, and Interaction. 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_id
  • hubspot.deal_id
  • internal.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 null value DELETES that key.
  • Keys you don't mention are left untouched.
  • null ANYWHERE in the patch is stripped — including nested. { stripe: { token: null } } stores as { stripe: {} }. If you need to keep a null somewhere 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 modules 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.

Interactions are otherwise immutable, but their metadata can be patched the same way via interactions.updateMetadata({ id, patch }).

Querying metadata

Every list method on these record 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 module
await tasks.list({ metadata: { "internal.priority_score": 1 } });

// All interactions linked to a specific deal
await interactions.list({ metadata: { "hubspot.deal_id": "8888" } });

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/event, so you can round-trip between Avi and that system.
  • Module-defined fields. A custom module wants to attach a priority_score or triage_status without needing a new column.
  • Ad-hoc tags or hints the agent or your modules 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

Contacts, Tasks, Notes, and Interactions all behave the same way — same merge semantics, same query shape. Files and Updates are not currently in this surface.