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
description: "Watches accounts and flags renewal risk.",
summary: "Flags customer accounts at renewal risk.", // what humans see in the Apps view
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.
Ship it in full color. You supply one icon file — your real logo, in your real colors. Avi renders it two ways, depending on whether the app is the subject or one of many:
| Where | How it renders |
|---|---|
| The open app panel's title bar | Full color — your logo as you drew it |
| A rail tile, hovered | Full color, crossfading in |
| A rail tile at rest, the Apps list, the updates feed | Monochrome silhouette — Avi paints it to match the theme and the tile's state |
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. Color freely — it's your logo. - For a real brand mark, the Simple Icons set is a great source; recolor a mark to its official brand color and you're done.
- No logo? A clean one- or two-letter monogram or a minimal glyph is perfect. Draw it in
currentColorand it simply renders monochrome everywhere.
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", // full color, as above
iconMono: "./icon-mono.svg", // transparent, single-color version of the mark
});Avi uses iconMono anywhere it paints your app in one color, and icon everywhere else. Most apps never need it.
(A panel's own ui.panels[].icon still renders monochrome and 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, description, 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.
- 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",
description: "Watches accounts and flags renewal risk.",
icon: "./icon.svg",
capabilities: ["data:read", "data:write", "tasks: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, create tasks, publish events...
await context.tasks.create({ title: "Review at-risk accounts" });
// 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 and context.userId are null. 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"],
contactIds: [account.contactId], // entity associations this event is about
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 native curator reasons over the log to surface project updates. Read the log with events:read (context.events.list(...) / context.events.get(id)).
Calling tools from the handler
A handler with apps:invoke can call tools from any app enabled in the same project:
const result = await context.apps.invoke("google_gmail-inbox-review", {
access_token: "integration:google:user@example.com:access_token",
after: "2026-05-18T00:00:00Z",
});Rules:
- The app must declare
apps:invoke. - Target tools must come from apps enabled in the invoking project; the call runs with that project's permissions.
- 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 target app.
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.
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",
description: "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 @-mention typeahead — so they show up next to native Tasks, Updates, Contacts, and Notes. 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 1 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.
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",
description: "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" });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",
description: "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), 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.
integration fields — connect an external account
An integration field lets the admin pick one of the project's connected Integrations (Google, Slack, AWS, …). Its value is the synthesized secret key for the chosen account; you pass that key to a tool's secretInput and the backend resolves it to a live token at call time (you never handle refresh tokens or OAuth credentials). Restrict the picker to one provider with service:
settings: {
account: { type: "integration", service: "google", label: "Google account", required: true },
},
// context.settings.account → "integration:google:user@example.com:access_token"If the project has no matching Integration yet, the field shows an inline Connect Service button that runs the connect flow right there — the admin never has to leave the app's settings screen.
Declaring more than one integration
Two ways, depending on whether the accounts are interchangeable or distinct roles:
-
multiple: true— one field that accepts several Integrations of the same kind. The value is astring[], and the admin gets a multi-select plus a "Connect another" button:tssettings: { accounts: { type: "integration", service: "aws", multiple: true, label: "AWS accounts", required: true }, }, // context.settings.accounts → string[] (one key per chosen account) -
Separate fields — distinct roles, or different providers, each its own single picker:
tssettings: { source: { type: "integration", service: "google", label: "Source account", required: true }, dest: { type: "integration", service: "google", label: "Destination account", required: true }, notify: { type: "integration", service: "slack", label: "Notify in Slack" }, },
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",
description: "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/search | Read and search project tasks. | Invoking project |
tasks:write | context.tasks.create/update/delete | Create, edit, or delete project tasks. | Invoking project |
contacts:read | context.contacts.get/list | Read contacts in the invoking project's contact pool. | Invoking project |
contacts:write | context.contacts.create/update/delete | Create, edit, or delete contacts in the invoking project's contact pool. | 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 |
apps:invoke | context.apps.invoke | Invoke a tool on another app enabled in the same project. | Invoking project permissions |
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 Files panel 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.
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.