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

bash
npm install -g @avi-hq/cli
# or build it from source: npm -w @avi-hq/cli run build

Sign in

bash
avi login

Opens a browser window to link the CLI to your Avi account and lets you pick which org the app belongs to.

Scaffold an app

bash
avi init my-app

Creates a starter project with:

  • app.ts — the entry point where you declare app metadata plus tools, an optional background handler with its triggers, data stores, settings, and panels.
  • @avihq/apps-sdk as the authoring SDK for types like AppToolContext.
    • 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.json wired 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:

ts
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:

WhereHow it renders
The open app panel's title barFull color — your logo as you drew it
A rail tile, hoveredFull color, crossfading in
A rail tile at rest, the Apps list, the updates feedMonochrome 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 (or 0 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 currentColor and 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:

ts
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/or ui (React panels), as a single value or an array. Defaults to both. This controls who may call the tool. The app's own background handler reaches its tools directly in code (and via context.apps.invoke for other enabled apps), so it isn't gated by callableFrom.
  • 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: true on a field to let callers omit it (your handler then sees undefined) — useful when only some of a tool's actions need the credential. Declare list: true to 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 declare allowedFromSetting: "<settingName>", naming one of your app's secret-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 with resolve: false on 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:

ts
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.

ts
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:

TriggerShapeThe handler is called with
schedulea cron / rate string, or an array of them (e.g. "rate(10 minutes)", ["cron(0 9 * * ? *)"])a schedule.ticked event on each tick
eventsan array of dotted event-type filters, * wildcard allowed (e.g. ["gmail.message.*"])the matching AppEvent from the project event log
webhookderived 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:

ts
{
  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:

ts
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:

ts
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.

ts
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:

ts
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 on request.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, Meta hub.challenge) for you without waking your handler — so most webhooks stay respond: "ack".
  • ack vs sync. ack (default) returns 202 immediately and runs your handler in the background — right for normal event delivery. sync runs your handler inline (bounded by timeoutSeconds) 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) then webhookBodyBytes / 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.

ts
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.

tsx
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 .js files.
  • Browser panel bundles cannot import Node built-ins such as fs, path, or node:*.
  • 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:

ts
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 typecontext.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)
ts
// 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):

ts
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: {
    // ...
  },
});
ts
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:

ts
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 a string[], and the admin gets a multi-select plus a "Connect another" button:

    ts
    settings: {
      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:

    ts
    settings: {
      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.

ts
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).

ScopeContext helperWhat it allowsBoundary
data:readcontext.data.<kvStore>.get/has/listKeys, context.data.<collection>.get/list/searchRead 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:writecontext.data.<kvStore>.set/delete, context.data.<collection>.create/update/deleteWrite 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:readcontext.updates.list/get/searchRead project feed updates (mutable, one living update per concern); search to find an existing one.Invoking project
updates:writecontext.updates.publishPublish 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:readcontext.events.list/getRead the project event log (filter by type, tag, or associated entity).Invoking project
events:writecontext.events.publishAppend an event to the project event log.Invoking project
tasks:readcontext.tasks.get/list/searchRead and search project tasks.Invoking project
tasks:writecontext.tasks.create/update/deleteCreate, edit, or delete project tasks.Invoking project
contacts:readcontext.contacts.get/listRead contacts in the invoking project's contact pool.Invoking project
contacts:writecontext.contacts.create/update/deleteCreate, edit, or delete contacts in the invoking project's contact pool.Invoking project
notes:readcontext.notes.get/listRead notes in the invoking project.Invoking project
notes:writecontext.notes.create/update/deleteCreate, edit, or delete notes in the invoking project.Invoking project
files:readcontext.files.getRead files in the app's file namespace.Invoking project + app
files:writecontext.files.putWrite files in the app's file namespace.Invoking project + app
project:files:readcontext.projectFiles.list/get/searchRead and search the project's shared file pool (the Files panel).Invoking project
project:files:writecontext.projectFiles.put/delete/move/updateMetadataCreate, edit, move, or delete files in the project's shared file pool.Invoking project
logs:writecontext.log.info, context.log.warn, context.log.errorEmit structured app logs.Current invocation
secrets:readcontext.secrets.getRead a project secret by name. Prefer secretInputs for per-caller credentials.Invoking project
project:readcontext.project.infoRead metadata for the invoking project.Invoking project
apps:invokecontext.apps.invokeInvoke a tool on another app enabled in the same project.Invoking project permissions
llm:invokecontext.llm.completeCall an LLM billed to the org.Invoking org/project
user:readcontext.userRead 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.
ts
// 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

bash
avi build
avi deploy

The 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:

bash
avi deploy --env BASE_URL=https://api.internal.example

Iterating

Make changes locally, run avi deploy again. Check state any time with:

bash
avi status

First-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.