Apps
If none of the existing apps cover what you need, write your own. Avi's CLI turns a small TypeScript file into a deployable app that your agent can use in any project.
The idea
An app is one installable unit. It can provide tools the agent or a panel calls, a background handler woken by triggers (a schedule, project events, or a webhook), its own data stores, project settings, and optional React panels. You author it locally, bundle it with the CLI, and deploy. The CLI hosts the code for you; you don't need to run any infrastructure.
There is one app shape: defineApp({ ... }). Tools are on-demand functions. The optional handler(event, context) is the app's autonomous worker — Avi wakes it whenever a declared trigger fires. Everything (tools, handler, panels) shares the same app data, settings, and capabilities.
Great candidates:
- An internal API your team uses that Avi doesn't have an app for.
- A proprietary workflow only your company does.
- A bespoke integration — Slack automations, internal dashboards, anything.
Install the CLI
npm install -g @avi-hq/cli
# or build it from source: npm -w @avi-hq/cli run buildSign in
avi loginOpens a browser window to link the CLI to your Avi account and lets you pick which org the app belongs to.
Scaffold an app
avi init my-appCreates a starter project with:
app.ts— the entry point where you declare app metadata plus tools, an optional backgroundhandlerwith itstriggers,datastores,settings, and panels.@avihq/apps-sdkas the authoring SDK for types likeAppToolContext.- This SDK is type-only; runtime functionality is supplied by Avi's shared Lambda layer.
- An optional
src/area you can add later for supporting code. - A skill file for authoring agents (e.g. Claude Code) so they can help you write tools and the background handler.
- A
package.jsonwired up with the right scripts.
App icon
Every app must declare a top-level icon — it can't deploy without one. The icon is an image (no built-in glyph names): an https:// image URL, a data:image/... URI, or — most common — a relative path to an image you bundle next to app.ts. The CLI inlines a bundled path at deploy, so you just commit the file:
export default defineApp({
name: "customer-success",
title: "Customer Success", // display name — free casing, shown wherever the UI names the app
summary: "Flags customer accounts at renewal risk.", // the app's one-liner on every card and list
icon: "./icon.svg", // bundled next to app.ts — .svg/.png/.jpg/.gif/.webp, up to 96 KB
// ...
});Your icon is what users see for the app across Avi — in the Apps list, the app's rail tile, and beside the updates it posts to the project feed.
Icons render monochrome, everywhere. You supply one icon file, and Avi paints it as a flat silhouette in the current theme color — light and dark, active and dimmed, every surface as one set. The color in your file is never shown; only its shape matters.
The silhouette is derived from your icon's alpha channel, so a mark on a transparent background works with no extra effort. That's the only real requirement:
- Use an SVG with a transparent background. A
0 0 64 64(or0 0 24 24) viewBox works well. Drawing incurrentColoror any single color is ideal — the shape is what ships. - For a real brand mark, the Simple Icons set is a great source — their marks are single-color with transparent backgrounds, exactly the right shape.
- No logo? A clean one- or two-letter monogram or a minimal glyph is perfect.
When your logo needs an iconMono
If your mark sits on an opaque background tile — a colored square with a knocked-out white glyph — its alpha channel is the whole square, so the silhouette would come out as a solid block. Only in that case, add a second file:
export default defineApp({
icon: "./icon.svg", // your mark; its alpha channel is the shape
iconMono: "./icon-mono.svg", // transparent, single-color version of the mark
});When iconMono is present its alpha is used for the silhouette instead of icon's. Most apps never need it.
(A panel's own ui.panels[].icon may also use a built-in glyph name — panels are Avi's chrome, not your brand. The app's icon is the identity shown on the rail and the panel title bar.)
Write your tools
Edit app.ts. Import authoring types such as AppToolContext from @avihq/apps-sdk, then declare the app's name, title, summary, icon (required — see below), and the list of tools. The name is the app's identity — kebab-case only (it becomes the tool prefix and the app's public subdomain), so it can never contain capital letters. The optional title is the human-facing display name with free casing ("HubSpot", "CRM", up to 60 characters) shown wherever the UI names the app; when omitted, the UI shows the name. The background handler, data, settings, and React panels live beside tools in the same defineApp(...) declaration. The SDK describes the handler contract; the deployed runtime layer provides the actual context implementation. Each tool has:
- Name — what the agent calls it.
- Description — what it does (the agent reads this to decide whether to use it). Write it for the model: it can be long, technical, and full of parameter guidance.
- Summary (optional but recommended) — one short plain sentence shown to people in the Apps and Tools views instead of the description. Keep it under ~80 characters; when omitted, the UI shows the description. The app itself takes only a required
summary— its one-liner on every card and list. There is no app-leveldescription: long-form prose about what the app does and how it works belongs indocs/README.md(see Documentation). - Input schema — JSON Schema or Zod schema of the arguments.
- Callable from (optional) —
agent(chat assistant) and/orui(React panels), as a single value or an array. Defaults to both. This controls who may call the tool. The app's own backgroundhandlerreaches its tools directly in code (and viacontext.apps.invokefor other enabled apps), so it isn't gated bycallableFrom. - Handler — the function that runs.
- Secret inputs (optional) — per-project secret references the backend resolves before your handler runs. Each field is required by default; declare
optional: trueon a field to let callers omit it (your handler then seesundefined) — useful when only some of a tool's actions need the credential. Declarelist: trueto accept an array of secret keys (your handler receives the array of resolved values in the same order, up to 20 per call) — useful when one call needs several credentials at once. A list field can also declareallowedFromSetting: "<settingName>", naming one of your app'ssecret-type settings: the backend then rejects any key a project admin hasn't selected in that setting, fail-closed — with nothing selected, no key resolves. Pair it withresolve: falseon the setting (see below) when the setting is purely an allowlist.
Use callableFrom to narrow normal project-level callers, such as a panel-only helper:
tools: {
"refresh-cache": {
description: "Refresh cached customer data from the panel.",
inputSchema: { type: "object", additionalProperties: false },
callableFrom: "ui",
async handler() {
return { ok: true };
},
},
}The background handler
Beyond on-demand tools, an app can run autonomously. Declare what wakes it under triggers, then write one handler(event, context) — Avi calls that single function for every wake-up. There are no separate scheduled agents and no per-instance config to approve; the handler runs under the app's one declared capability set, the same data, and the same settings as its tools.
import { defineApp, type AppEvent } from "@avihq/apps-sdk";
export default defineApp({
name: "customer-success",
summary: "Flags customer accounts at renewal risk.",
icon: "./icon.svg",
capabilities: ["data:read", "data:write", "updates:write", "logs:write"] as const,
data: {
state: { type: "kv", scope: "project" },
},
settings: {
lookbackDays: { type: "number", label: "Look-back window (days)", default: 14 },
},
// What wakes the handler. All three are independent and optional.
triggers: {
schedule: "rate(1 hour)", // a cron / rate expression, or an array of them
events: ["billing.invoice.*"], // event types from the project event log
webhook: false, // true to expose a public inbound URL
},
async handler(event: AppEvent, context) {
if (event.type === "schedule.ticked") {
const since = Date.now() - context.settings.lookbackDays * 86_400_000;
const last = (await context.data.state.get("last-sweep")) ?? null;
// ...do the work: read data, call tools, publish updates and events...
await context.updates.publish({
subject: "Renewal risk: Acme",
beat: { subject: "Usage down 40% since the last invoice" },
});
// Durable history (schedule/webhook wake-ups are NOT auto-logged):
await context.events.publish({
type: "customer-success.sweep.completed",
data: { since, previous: last },
});
await context.data.state.set("last-sweep", new Date().toISOString());
}
},
tools: {
// ...on-demand tools the chat agent and panels call...
},
});Triggers
triggers declares what wakes the handler. All three fields are independent and optional — set any combination:
| Trigger | Shape | The handler is called with |
|---|---|---|
schedule | a cron / rate string, or an array of them (e.g. "rate(10 minutes)", ["cron(0 9 * * ? *)"]) | a schedule.ticked event on each tick |
events | an array of dotted event-type filters, * wildcard allowed (e.g. ["gmail.message.*"]) | the matching AppEvent from the project event log |
webhook | derived automatically when you declare top-level webhooks (see Webhooks) | a <app>.webhook.<name> event per inbound request |
Schedule wake-ups are ephemeral handler inputs — not written to the event log; call context.events.publish(...) to record durable history. A webhook wake-up writes a lean audit event automatically. An events-triggered handler reacts to events that already live in the log.
The event argument
Every wake-up passes an AppEvent:
{
id, orgId, projectId,
appId, // the app that published it, or null for system sources
type, // dotted, e.g. "schedule.ticked", "gmail.message.received"
tags, // free-form labels for filtering
contactIds, taskIds, noteIds, // first-class entity associations this event is about
occurredAt, // when the underlying thing actually happened
data, // arbitrary structured payload
createdAt,
}Branch on event.type to handle each trigger. For a scheduled app, that's just schedule.ticked; for an event-driven one, it's whatever types you subscribed to.
Runtime behavior
The handler runs as a system actor: context.user, context.userId, and context.timezone are null (nobody is reading a handler run as it happens — in tool calls, context.timezone is the IANA zone of the person the call is for, so tools can show times on their clock). It runs inside the app's own Lambda package — there is no backend-side worker, type registry, or preflight; the app ships its handler code in its own bundle.
- Schedule — Avi creates one EventBridge schedule per (project that enabled the app) × (declared cron). AWS fans the ticks across installs, so a slow project never blocks another's tick. Changing the cron set and redeploying reconciles every install's schedules.
- Events — when a matching event is appended to the project event log, Avi dispatches it to the handler.
- Webhook — an inbound request to the app's per-install public URL wakes the handler with a
<app>.webhook.<name>event (see Webhooks).
A handler invocation may run for up to 10 minutes. Keep per-tick work bounded and resume across ticks using a "kv" data store (persist a cursor / last-run marker, as the example does with last-sweep).
Publishing events
A handler (or a tool) with events:write can append to the project event log:
await context.events.publish({
type: "customer-success.sweep.completed",
occurredAt: new Date(), // when it actually happened; omit for the server clock
tags: ["renewal"],
data: { riskScore: 0.82 },
dedupeKey: "sweep:2026-06-16", // optional — collapses duplicate publishes of the same source item
});Other apps can subscribe to these types via their own triggers.events, and Avi's curation engine reasons over the log to surface project updates. Read the log with events:read (context.events.list(...) / context.events.get(id)).
Two optional envelope fields tune how the curation engine treats an event:
embedText— a short, pre-trimmed projection (headline + leading content) that Avi embeds and matches on instead of the wholedatapayload, so long bodies and id/link metadata never dilute placement.curatable: false— keeps the event off the curation engine entirely (right for high-churn activity your other events already summarize). This only narrows:trueis ignored — an app can't force itself into the feed.
For events you want the engine to be great at, shape data with the curatableEventFields helper from the SDK. It assembles the standard field names the engine understands — subject, a full body with honest truncation flags, thread_id / record_id (stable identities that group later events about the same thread or record onto the same update), conversation_id, participants, mentions_user / from_user, provider / direction, connection, and a deep link — and returns { data, embedText } ready to publish:
import { curatableEventFields } from "@avihq/apps-sdk";
const { data, embedText } = curatableEventFields({
subject: ticket.title,
body: ticket.description, // full text — capped + flagged for you
recordId: ticket.id, // later events about this ticket group onto its update
provider: "acme-desk",
direction: "inbound",
aviLink: panelUrl,
extra: { ticket_id: ticket.id, severity: ticket.severity },
});
await context.events.publish({ type: "support.ticket.opened", data, embedText });Every field is optional — a plain data payload still flows through the engine unchanged.
Calling your own tools from the handler
A handler can call any of its own app's tools through context.apps.invoke — no capability needed:
const result = await context.apps.invoke("support_tickets-poll", { since: cursor });Rules:
- The tool must belong to the same app (runtime name
<yourApp>_<tool>). Apps cannot invoke other apps' tools — that was removed. If your app needs data another app owns, publish and subscribe to events; sending email or messages as the project is a first-party feature, so a third-party app that needs to notify someone publishes an update/event for the project's own surfaces to act on, or sends through its own provider with its own credentials. - For tools with
secretInputs, pass the secret key exactly as a normal tool caller would — the backend resolves the real secret value before invoking the tool.
Webhooks
A webhook lets an external service push events to your app in real time, instead of you polling for them. Declare them with the top-level webhooks array. Avi mints one public URL per install (per project that enables your app) on your app's own subdomain of the public user-content domain — https://<app-name>.avi.tools/api/v1/webhooks/apps/<token> (the same avi.tools subdomain that serves the app's pages, never the product/API domain); the project admin pastes it into the vendor's webhook settings.
export default defineApp({
name: "my-app",
capabilities: ["events:write"],
webhooks: [
{
name: "events", // kebab-case; the URL segment + event suffix
description: "Vendor event deliveries.",
respond: "ack", // "ack" (default) or "sync" — see below
secrets: [
{ name: "signing_secret", description: "Vendor signing secret, for verifying each request." },
],
},
],
handler,
});An inbound request wakes your handler with a <app>.webhook.<name> event — route on event.data.hook:
import { readWebhookRequest, webhookBodyText } from "@avihq/apps-sdk";
import crypto from "node:crypto";
export async function handler(event, context) {
if (event.data?.hook !== "events") return;
const request = readWebhookRequest(event);
if (!request) return;
// Verify the request is really from the vendor — you have the verbatim bytes.
const body = webhookBodyText(request);
const ok = verifyHmac(request.secrets.signing_secret, request.headers, body);
if (!ok) return { status: "skipped" };
const payload = JSON.parse(body);
await context.events.publish({ type: "vendor.thing.happened", data: payload });
}How it works:
- The URL is the credential. It carries a secret token; treat it like a password. Rotating it (in the app's settings) revokes the old URL immediately; disabling it is a kill switch.
- You verify authenticity. Avi passes the verbatim request bytes + headers through untouched. Declare the vendor's signing secret under
secrets; the project admin binds it to a project secret key in settings, and it arrives onrequest.secrets.<name>(never written to the event log). Use it to check the vendor's signature (HMAC) yourself. - Verification handshakes are automatic. Avi answers generic subscription challenges (Slack
url_verification, Metahub.challenge) for you without waking your handler — so most webhooks stayrespond: "ack". ackvssync.ack(default) returns202immediately and runs your handler in the background — right for normal event delivery.syncruns your handler inline (bounded bytimeoutSeconds) and relays its returned{ status?, headers?, body? }to the sender — use it only when the vendor needs a specific response body.- Read the body with
readWebhookRequest(event)thenwebhookBodyBytes/webhookBodyText/webhookBodyJson.
Configuring the URL and binding secrets is done per project in the app's settings — see Apps → Webhooks.
Reading your own webhook URL from code. Sometimes the app itself needs its ingress URL — for example to embed it as a contact form's action in a site it publishes. Declare the webhooks:read capability and call context.webhooks.url(name) with a declared webhook name: it returns this install's public URL, creating the endpoint on first use (the same URL the project admin sees in settings). An undeclared name rejects.
React panels
Apps owned by your org can also expose React panels in Avi's project sidebar. Panels run in a sandboxed iframe, so your React code never executes inside Avi's main app tree and cannot access Avi auth tokens, local storage, cookies, or the parent DOM.
export default defineApp({
name: "customers",
summary: "Customer dashboard.",
icon: "./icon.svg",
capabilities: ["data:read", "data:write"] as const,
data: {
// A KV store for the app's own state.
state: { type: "kv" },
// A queryable record collection.
customers: {
type: "collection",
fields: {
name: { type: "string", required: true },
status: { type: "enum", enum: ["lead", "active", "at-risk", "churned"] },
notes: { type: "string" },
},
indexes: [{ fields: ["status"] }],
fullText: ["name", "status", "notes"],
vector: ["name", "notes"],
globalSearch: { kind: "customer", titleField: "name", snippetField: "notes" },
},
},
ui: {
panels: {
dashboard: {
title: "Customers",
icon: "users",
entry: "./ui/Dashboard.tsx",
},
},
},
tools: {
// ...
},
});A collection's globalSearch opts its records into Avi's global search — the chat agent's one search tool and the chat composer's +-mention picker — so your records show up next to native Tasks and can be referenced directly in a message (the composer inlines the record for the agent, along with a pointer to your app's read tool when you expose one). Set titleField (which field is the result title) and, optionally, snippetField (the subtitle), kind (a human label like "customer"), and searchFields (which fields to text-match; defaults to fullText, else the title field). Every field you name must exist on the collection, or the deploy is rejected. Omit globalSearch entirely and the collection stays private to your app.
Panel code is normal React. Use @avihq/apps-sdk/react for Avi-themed components and bridge APIs:
Panel icons can use Avi's built-in names, relative SVG/PNG/JPEG/GIF/WebP paths that the CLI bundles at deploy time, compact data:image/... URIs, or HTTPS image URLs.
import { Button, PanelHeader, useAvi } from "@avihq/apps-sdk/react";
export default function Dashboard() {
const avi = useAvi();
return (
<div>
<PanelHeader title="Customers" />
<Button onClick={() => avi.data.customers.create({ name: "New customer", status: "lead" })}>
Add
</Button>
</div>
);
}V1 restrictions:
- Panels are shown only for apps owned by the current org.
- An app can define up to five panels.
- Each panel bundle must be 3 MB or smaller.
- Panel entries must be relative
.tsx,.ts,.jsx, or.jsfiles. - Browser panel bundles cannot import Node built-ins such as
fs,path, ornode:*. - Panel data access goes through the Avi bridge and is checked against the same approved app capabilities as tools.
Initial component library exports: Button, Input, Textarea, Select, Switch, Tabs, Table, Badge, Toolbar, PanelHeader, EmptyState, Spinner, plus useAvi, useTheme, useAppData, useProjectUpdates, useProjectTasks, useProjectContacts, and useAppTool. (useAppRecords is a deprecated alias of useAppData — record collections are now declared data stores reached through avi.data.<storeName>.)
For the full authoring guide, including the manifest contract, bridge APIs, theming, limits, security model, and troubleshooting, see App React UI.
Public pages and sites
Apps can publish two kinds of public surfaces, and they solve different problems:
Pages (ui.pages) are React bundles served inside Avi's hardened shell on your app's own subdomain (<app-name>.avi.tools/<token>), rendering data from your app — booking pages, shared views, per-thing links. Pages are unlisted by default; set indexable: true on a page's manifest entry when it's public content that should appear in search engines.
Sites (context.sites) are real static websites on a dedicated sites domain, each on its own subdomain — full HTML documents and assets you author, served verbatim and fully SEO-capable (your own <title>, meta tags, everything). Requires the sites:read / sites:write capabilities.
// First touch mints a random host like https://falcon-harbor-8214.avi.site/
const url = await context.sites.url("main");
// Let the user pick a custom subdomain — check availability first
const { available, reason } = await context.sites.checkHost("acme");
if (available) await context.sites.claimHost("main", "acme"); // → https://acme.avi.site/
// Publish files. Small text files go inline via `body`; larger files
// (images, video, anything) declare `sizeBytes` and come back with an
// upload URL.
const result = await context.sites.publish("main", [
{ path: "/", body: "<!doctype html><html>…</html>" },
{ path: "/styles.css", body: "body { … }" },
{ path: "/hero.jpg", sizeBytes: heroBytes.byteLength },
], { replace: true });
// PUT each large file's bytes to its upload URL (send the file's content
// type as the Content-Type header), then confirm to take them live.
for (const upload of result.uploads ?? []) {
await fetch(upload.url, { method: "PUT", headers: { "content-type": "image/jpeg" }, body: heroBytes });
}
await context.sites.confirm("main", ["/hero.jpg"]);
// Take files (or the whole site) down
await context.sites.remove("main", ["/old-page.html"]);Hosts are 3–40 lowercase letters, digits, or hyphens, globally unique, first-come-first-served. Content types are inferred from the file extension when you don't declare one (extensionless paths serve as HTML); almost anything is allowed — only executable file types are refused. Inline bodies are UTF-8 text up to 256 KB; anything bigger goes through the upload flow. Limits: 25 MB per file, 10,000 files and 1 GB per site. Upload URLs expire after 15 minutes; confirm is safe to call again if some uploads finish late.
Data stores and settings
An app declares two separate things under defineApp:
data— the app's own persistence: a map of named stores the tools, handler, and panels read and write.settings— a project-scoped field schema a project admin fills in once per project; the app reads the resolved values (read-only).
data — named stores
data is a map keyed by store name. Each store is either a KV store (type: "kv") or a record collection (type: "collection"), and each declares its own scope:
export default defineApp({
name: "customers",
summary: "Customer records.",
icon: "./icon.svg",
capabilities: ["data:read", "data:write"] as const,
data: {
// KV store, isolated per enabling project (the default).
state: { type: "kv", scope: "project" },
// Record collection, shared across the org's enabled projects.
customers: {
type: "collection",
scope: "org",
fields: { name: { type: "string", required: true } },
fullText: ["name"],
globalSearch: { kind: "customer", titleField: "name" },
},
},
tools: {
// ...
},
});Each declared store becomes a typed handle on context.data.<storeName> (and, in panels, avi.data.<storeName>):
Store type | context.data.<store> methods |
|---|---|
"kv" | get(key), set(key, value), has(key), delete(key), listKeys(options) |
"collection" | create(record, { id? }), get(id), update(id, patch), delete(id), list(options), search(options) |
// KV store
const config = (await context.data.state.get("config")) ?? defaults;
await context.data.state.set("config", { ...config, lookbackDays: 30 });
// Record collection
await context.data.customers.create({ name: "Northstar Labs" });
const hits = await context.data.customers.search({ text: "northstar" });For complete text-search pagination, supply an explicit record-field order and keep the text, filters, and order unchanged between pages:
const page = await context.data.customers.search({
text: "northstar",
orderBy: { field: "name", direction: "asc" },
limit: 25,
// cursor: previousPage.nextCursor,
});
// page.records; page.nextCursor is null when there are no more matches.Text-only search with orderBy matches full-text prefixes or substrings and uses the same stable field/id cursor ordering as list. It searches the complete matching set without the relevance engine's candidate cap. It returns searchSignals: ["text"] but no relevance score. Without explicit ordering, or when a vector query is supplied, search remains relevance-ranked and bounded, with no continuation cursor. Blank text falls back to list; punctuation-only text is not a listing.
Record update(id, patch) merges a partial patch under a database row lock. It does not support conditional updates: a separate read followed by an update is not atomic, so concurrent read-modify-write operations can lose increments or overwrite changes to the same field. The row lock protects the patch operation itself, not a transaction across calls, collections, or an external provider.
Scope is per store, not app-wide:
scope: "project"(default) — isolated per enabling project; each project that enables the app gets its own copy.scope: "org"— one shared partition across every enabled project under the org; all of them read and write the same data.- Fixed at deploy. A store's scope is set when it is first deployed; changing it would orphan the old partition.
- Independent of install scope. A project-installed app can still declare
scope: "org"stores. - Global search merges both. Searching from a project covers its project-scoped app records and the org-scoped records of any enabled app.
There is no app-wide dataScope and no top-level context.get/context.set/context.records.collection(...) — always reach data through a declared store: context.data.<storeName>. Files (context.files) and the project primitives (tasks, contacts, notes, updates) always stay bound to the invoking project.
settings — project-scoped admin fields
settings declares fields a project admin fills in for the app in that project. The app never writes them — it reads the resolved values from context.settings.<field> (typed, read-only):
export default defineApp({
name: "crm-sync",
summary: "Sync CRM data for a project.",
capabilities: ["data:read", "data:write"] as const,
settings: {
baseUrl: { type: "string", label: "API base URL", required: true },
region: { type: "enum", label: "Region", options: ["us", "eu"], default: "us" },
pageSize: { type: "number", label: "Page size", default: 50 },
verbose: { type: "boolean", label: "Verbose logging", default: false },
},
tools: {
// ...
},
});const res = await fetch(`${context.settings.baseUrl}/accounts?region=${context.settings.region}`);Field types are string, number, boolean, enum (with options — value strings, or { value, label } objects when the stored value and the display text differ, e.g. value avimail shown as “Avi Mail”), timezone (a searchable timezone picker — value is the IANA zone name), instructions (a rich text editor — value is a string), list (a tag input — value is a string[]), integration (a picker over the project's connected Integrations), and secret (a picker over the project's Secrets — the setting stores only the secret's key, and the resolved value is delivered to your code per invocation on context.settingSecrets.<field>; supports multiple). Use secret for app-level credentials an admin binds once — your app never reads the secret store itself and needs no extra capability; the resolved value is handed to each invocation and never persisted. A secret field may also declare resolve: false, making it a key-only reference: the platform stores the selected key(s) but never delivers the values to your app (the field is absent from context.settingSecrets). Use this for allowlist-style settings — typically the target of a secret input's allowedFromSetting — where the admin's selection authorizes keys rather than binding a credential; key-only pickers also offer integration-derived secrets (e.g. AWS short-term credentials), not just user-created ones. A list field can also declare optionsSource: { type: "integration-resource", service, resource, setting } to offer live options fetched from a connected integration (for example google / calendars scoped to a sibling integration field) — admins pick from a dropdown instead of typing values, and can still add manual entries. required and default control whether a field is guaranteed present at runtime — required or defaulted fields are always set; an optional unset field reads as undefined. Use settings for per-project configuration an admin should own; use a "kv" store for state the app itself manages.
Vendor credentials — bring your own
An app that talks to an outside service (Google, Slack, HubSpot…) connects to it with its own OAuth client or API key, stored as a secret setting. The accounts a user connects to Avi's own apps (Email, Calendar, Drive, CRM, Messages, Code…) are never shared with other apps — those credentials only ever reach code Avi authors, which is what keeps every connected account inside the vendors' terms.
Capabilities and scopes
Apps declare the capabilities they need in defineApp({ capabilities }). Avi asks an admin to approve those capabilities when the app is enabled for a project. Apps install per-project (there is no org-wide install). If an app is redeployed with new capabilities later, each project that uses it re-approves the new capabilities from Project Settings before the app runs again.
export default defineApp({
name: "crm-sync",
summary: "Sync CRM data for a project.",
capabilities: [
"data:read",
"data:write",
] as const,
tools: {
// ...
},
});The SDK uses that list for type-safety. For example, context.files.put(...) is only available when the app declares files:write, and context.data.<collection>.create(...) (a collection store) is only available when it declares data:write (declared data stores are covered by the data:* scopes).
| Scope | Context helper | What it allows | Boundary |
|---|---|---|---|
data:read | context.data.<kvStore>.get/has/listKeys, context.data.<collection>.get/list/search | Read this app's declared data stores — KV stores AND record collections (incl. full-text + vector search). One scope covers both store types. | Per-store data partition (project, or org when the store declares scope: "org") + app |
data:write | context.data.<kvStore>.set/delete, context.data.<collection>.create/update/delete | Write this app's declared data stores — KV stores AND record collections (and their search indexes). One scope covers both store types. | Per-store data partition (project, or org when the store declares scope: "org") + app |
updates:read | context.updates.list/get/search | Read project feed updates (mutable, one living update per concern); search to find an existing one. | Invoking project |
updates:write | context.updates.publish | Publish an Update to the project feed — appends to an existing one when a dedupeKey collides. A new Update push-notifies project members by default; pass notify: false for digests/rollups that shouldn't buzz anyone. | Invoking project |
events:read | context.events.list/get | Read the project event log (filter by type, tag, or associated entity). | Invoking project |
events:write | context.events.publish | Append an event to the project event log. | Invoking project |
tasks:read | context.tasks.get/list | Read the FROZEN native task table — the archive the Tasks app imported. Tasks live in that app now; work with them through its tools. | Invoking project |
contacts:read | context.contacts.get/list | Read the FROZEN native contact table — the archive the Contacts app imported. Contacts live in that app now; work with them through its tools. | Invoking project |
notes:read | context.notes.get/list | Read notes in the invoking project. | Invoking project |
notes:write | context.notes.create/update/delete | Create, edit, or delete notes in the invoking project. | Invoking project |
files:read | context.files.get | Read files in the app's file namespace. | Invoking project + app |
files:write | context.files.put | Write files in the app's file namespace. | Invoking project + app |
project:files:read | context.projectFiles.list/get/search | Read and search the project's shared file pool (the Files panel). | Invoking project |
project:files:write | context.projectFiles.put/delete/move/updateMetadata | Create, edit, move, or delete files in the project's shared file pool. | Invoking project |
logs:write | context.log.info, context.log.warn, context.log.error | Emit structured app logs. | Current invocation |
secrets:read | context.secrets.get | Read a project secret by name. Prefer secretInputs for per-caller credentials. | Invoking project |
project:read | context.project.info | Read metadata for the invoking project. | Invoking project |
llm:invoke | context.llm.complete | Call an LLM billed to the org. | Invoking org/project |
user:read | context.user | Read the invoking user's id when the invocation is user-initiated. | Current invocation |
Project-scoped helpers always use the project that invoked the tool. The only cross-project data surface is a store declared scope: "org" (one shared partition across the org's enabled projects); every other helper — files, tasks, contacts, notes, updates, events — stays bound to the invoking project.
Project files vs. app files
There are two file surfaces, and they don't overlap:
context.files(files:read/files:write) is the app's private namespace — a scratch area keyed by the app, invisible in the Files panel. Good for caches, cursors, and internal artifacts.context.projectFiles(project:files:read/project:files:write) is the project's shared file pool — the same files the user sees in the Files panel, chat attachments land in, and the Avi agent reads and writes.
// list a folder (or everything), filter by metadata
const { data, total } = await context.projectFiles.list({ dir: "reports/", limit: 50 });
// read a file — text comes back utf-8, binary comes back base64 (check `encoding`)
const file = await context.projectFiles.get("reports/q2-summary.md");
// full-text + semantic search across the pool
const { data: hits } = await context.projectFiles.search({ query: "quarterly revenue", limit: 10 });
// write (creates folders as needed; 10 MB max per file)
await context.projectFiles.put({
path: "reports/q3-summary.md",
data: "# Q3\n...",
contentType: "text/markdown",
metadata: { source: "crm-sync" },
});
// single-file move/rename, delete, metadata patch
await context.projectFiles.move({ from: "reports/q3-summary.md", to: "archive/q3-summary.md" });
await context.projectFiles.delete("archive/q3-summary.md");
await context.projectFiles.updateMetadata({ path: "reports/q2-summary.md", patch: { reviewed: true } });Writes show up in the Drive app immediately and are searchable by the agent. move handles single files only — it refuses directory paths.
User-owned projects install apps individually, and each project controls its own tool permissions.
Documentation
Every app documents itself in a docs/ directory next to app.ts — flat, kebab-case markdown files, uploaded automatically with every avi deploy so they can never go stale. docs/README.md is the entry page and is required whenever the directory exists; publishing an app publicly requires it.
my-app/
app.ts
docs/
README.md ← what the app is and how it works, end to end
integrations.md ← optional deep dives, any kebab-case name
troubleshooting.mdAvi composes each app's intro page — its landing page in the app directory, its Docs tab, and what agents read — from three parts: a generated header (title, summary, version, last deployed, capabilities), your README body, and generated sections listing every tool, agent, setting, and webhook straight from your manifest.
Because those sections are generated, never restate manifest content in docs: no hand-written tool or settings listings, and no app-level description in defineApp. Docs carry what the manifest can't say — how integrations authenticate, what runs automatically and when, human-in-the-loop moments, failure modes. Tool descriptions remain the complete model-facing contract for calling each tool.
Rules the CLI enforces at deploy: README exists when docs/ does; slugs are kebab-case and unique; relative links between pages resolve; at most 50 pages, 200 KB per page, 1 MB total. The README takes no frontmatter (its title is the app's title, its one-liner is the manifest summary) and starts directly at prose with ## sections. Deep-dive pages may set title, summary, and order in frontmatter. Images must be https: URLs.
Once deployed, docs are searchable by project agents and readable on demand in every project that enables the app — treat stale docs as live bugs, not cosmetic debt.
Build and deploy
avi build
avi deployThe CLI bundles your code, uploads it, and makes it available to any project in your org that enables the app. The deployment package contains your bundled app.mjs plus a tiny bootstrap that delegates to Avi's shared app runtime layer, so runtime/context-helper improvements can roll out without every author rebuilding their bundle.
You can pass app-level env vars during deploy:
avi deploy --env BASE_URL=https://api.internal.exampleIterating
Make changes locally, run avi deploy again. Check state any time with:
avi statusFirst-party example
The Gmail / Calendar / Contacts / Drive / Slides / Sheets app lives at packages/apps/google in the Avi repo. It's a full-size app — handlers for ~28 tools — and a good reference if you want to see how a larger one is structured.