Apps

If none of the built-in 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 can provide tools, Subagent agents, and optional React panels. Tools are functions the agent or a panel can call. Subagent agents are scheduled background workers users enable from Project Settings → Subagent. You author them locally, bundle them with the CLI, and deploy. The CLI hosts the code for you; you don't need to run any infrastructure.

Great candidates:

  • An internal API your team uses that Avi doesn't have a built-in 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 build

Sign in

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

avi init my-app

Creates a starter project with:

  • app.ts — the entry point where you declare app metadata plus tools, Subagent agents, 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 Subagent agents.
  • A package.json wired up with the right scripts.

Write your tools

Edit app.ts. Import authoring types such as AppToolContext from @avihq/apps-sdk, then declare the app's name, description, and the list of tools. Subagent agents 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).
  • Input schema — JSON Schema or Zod schema of the arguments.
  • Callable from (optional) — any of agent (chat assistant), ui (React panels), subagent (scheduled subagents), as a single value or an array. Defaults to all three. This controls the normal project-level callers. A scheduled Subagent can also invoke tools that its definition lists in requiredTools; that grant is scoped only to that Subagent instance.
  • Handler — the function that runs.
  • Secret inputs (optional) — per-project secret references the backend resolves before your handler runs.

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 };
    },
  },
}

Scheduled Subagent agents

Apps can also expose scheduled background agents. In the UI these appear as Subagent. V1 Subagent agents are not chat-callable specialists; they run on a schedule, with project-specific instructions and config. A Subagent declares exactly the app tools it needs in requiredTools; users approve those tools for the specific Subagent instance during setup.

Define Subagent agents with an agents object inside defineApp:

import {
  defineApp,
  type AppAgentContextForCapabilities,
  type AppAgentRequiredToolNames,
} from "@avihq/apps-sdk";
import { generateText, stepCountIs, tool } from "ai";
import { z } from "zod";

const capabilities = [
  "llm:invoke",
  "apps:invoke",
  "data:read",
  "data:write",
  "logs:write",
] as const;

const requiredTools = ["customer-success_list-accounts", "customer-success_update-account"] as const;

type SubagentContext = AppAgentContextForCapabilities<
  typeof capabilities,
  AppAgentRequiredToolNames<"customer-success", typeof requiredTools>
>;

export default defineApp({
  name: "customer-success",
  description: "Customer success automations",
  capabilities,

  agents: {
    "renewal-risk-subagent": {
      description: "Checks customer activity and flags renewal risk.",

      schedule: {
        defaultIntervalSeconds: 3600,
      },

      defaultModel: "claude-sonnet-4-6",

      requiredTools,

      configSchema: z.object({
        lookbackDays: z.number().int().positive().default(14),
        minRiskScore: z.number().min(0).max(1).default(0.7),
      }),

      defaultConfig: {
        lookbackDays: 14,
        minRiskScore: 0.7,
      },

      async handler({ instructions, config, modelId, runId }, context: SubagentContext) {
        const appTools = await context.apps.toolSet();
        let finished = false;

        const result = await generateText({
          model: context.ai.model(modelId),
          system: "You are a renewal-risk subagent. End by calling finish_run.",
          prompt: `${instructions}\nLook back ${config.lookbackDays} days.`,
          tools: {
            ...appTools,
            finish_run: tool({
              description: "Finish this subagent run.",
              inputSchema: z.object({
                summary: z.string(),
                riskyAccounts: z.array(z.object({
                  accountName: z.string(),
                  riskScore: z.number(),
                  reason: z.string(),
                })),
              }),
              execute: async (input) => {
                finished = true;
                await context.set("last-run", {
                  runId,
                  finishedAt: new Date().toISOString(),
                  ...input,
                });
                await context.log.info("renewal risk subagent finished", {
                  runId,
                  riskyAccounts: input.riskyAccounts.length,
                });
                return { ok: true };
              },
            }),
          },
          toolChoice: "required",
          stopWhen: [() => finished, stepCountIs(12)],
        });

        return {
          status: finished ? "succeeded" : "failed",
          stats: { steps: result.steps.length },
        };
      },
    },
  },
});

requiredTools drives the Subagent setup view. List the app tools the agent actually needs; deploy stores them on the agent definition so Avi can show the dependency apps and the tools the user must approve for that Subagent. Entries can be full runtime names (customer-success_list-accounts) or same-app tool names (list-accounts), which the deploy step normalizes. For SDK type-safety, keep the list as a const tuple and pass AppAgentRequiredToolNames<"app-name", typeof requiredTools> as the second generic to AppAgentContextForCapabilities.

Subagent agent definitions support:

FieldRequiredNotes
descriptionYesShown in Project Settings → Subagent.
singletonNoDefaults to false. Set true when a project may only configure one instance of this Subagent.
schedule.defaultIntervalSecondsYesDeveloper-declared default cadence. Must be 60 seconds to 7 days. Users can change the interval in the UI after enabling.
defaultModelNoDeveloper-declared default model for context.ai.model(). Users can change the model per Subagent instance in Project Settings → Subagent.
configSchemaNoJSON Schema or Zod schema for project-specific config. The CLI converts schema-library objects with toJSONSchema() before deploy. Object schemas render as settings inputs for string, number, integer, boolean, and enum fields.
defaultConfigNoJSON-serializable defaults used to prefill the Subagent settings form.
requiredToolsNoApp tools this Subagent needs. Same-app tool names are normalized to runtime names during deploy.
capabilitiesNoPer-agent narrowing. Defaults to app capabilities. Use llm:invoke for context.ai.model(...) and apps:invoke for app-tool access.
handlerYesRuns inside the app Lambda. Return { status, stats, result } or void.

The handler receives:

{
  instanceId,
  runId,
  rootRunId,
  orgId,
  projectId,
  modelId,
  instructions,
  config,
  selectedTools,
  schedule,
  startedAt,
}

runId identifies this single invocation; rootRunId is the stable id shared by every Subagent in the run tree (see Run identity and the run tree).

Subagent agents run as system actors: context.user and context.userId are null. Use instructions for user-authored steering and config for structured settings.

AI SDK model adapter

Subagent agents are written like normal Vercel AI SDK loops:

const result = await generateText({
  model: context.ai.model("claude-sonnet-4-6"),
  tools,
  stopWhen: [stepCountIs(12)],
});

context.ai.model(modelId?) returns an AI SDK-compatible language model. When you omit modelId, Avi uses the model selected for that Subagent instance; new instances start from the author's defaultModel. Calls are proxied through Avi so routing, billing, and usage tracking stay centralized. The agent must declare llm:invoke.

Calling tools from Subagent agents

Subagent agents can call tools from enabled apps in the same project:

const allSelectedTools = await context.apps.toolSet();
const oneTool = await context.apps.tool("google_gmail-review-inbox");
const result = await context.apps.invoke("google_gmail-review-inbox", {
  access_token: "integration:google:user@example.com:access_token",
  after: "2026-05-18T00:00:00Z",
});

Important rules:

  • The agent must declare apps:invoke.
  • Users enable the apps a Subagent depends on, then approve the required tools for that Subagent instance.
  • The backend derives the runtime allowlist from requiredTools.
  • Target tools must come from enabled project apps. Creating the Subagent stores the approved requiredTools grant only on that Subagent and does not enable those tools for the project chat agent.
  • 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.

Calling other Subagents

A Subagent can run other Subagents defined in the same app and get their run result back:

const child = await context.subagents.invoke("enrich-account", {
  instructions: "Enrich the flagged accounts",
  config: { limit: 25 },
});
// child is the invoked Subagent's run result: { status, stats, result } | null

Rules:

  • The agent must declare apps:invoke.
  • Only Subagents defined in the same app are addressable, by their bare name.
  • The invoked Subagent runs with its own declared capabilities and requiredTools, not the caller's.
  • Subagent-to-subagent calls share the same nesting-depth budget as tool calls; calls past the maximum depth are rejected.
  • The whole fan-out is reported to the user as a single run — the initial Subagent's — because every nested call automatically inherits the same rootRunId (see below). You never thread the id yourself.

Finishing a run: success() and fail()

Every Subagent's context exposes terminal run-control methods:

if (nothingToDo) context.success();            // whole run tree → succeeded
if (cannotProceed) context.fail("no access");  // whole run tree → failed
  • Both throw to stop execution immediately, so code after them never runs (their return type is never).
  • They set the outcome of the whole run tree — the initial Subagent's run, which is the one reported in the dashboard. A fail() called inside a nested Subagent propagates up and fails the initial run.
  • Prefer them when you want a definitive verdict. A plain return { status } only reports the current Subagent's own outcome and leaves it to the caller.

Note: success(), fail(), and context.subagents.invoke propagate the verdict by throwing a control-flow signal. Don't wrap them in a broad try/catch that swallows the error (re-throw it if you must catch), otherwise the whole-tree verdict is silently lost.

Run identity and the run tree

runId identifies a single Subagent invocation. rootRunId is the stable id shared by every Subagent in one run tree: for a scheduled run it equals runId, and when a Subagent calls context.subagents.invoke, the nested run inherits the same rootRunId. This is what groups a fan-out under the initial Subagent in the Avi dashboard. It propagates automatically through the run context — authors don't pass it.

Runtime behavior

When a user enables a Subagent, Avi creates an EventBridge schedule using the configured interval. Existing enabled instances keep their effective interval until the user changes it. On each tick, Avi claims the run, invokes the app Lambda with action: "invoke-agent", and records last_run_started_at, last_run_finished_at, last_error, and run stats on the instance.

V1 Subagent runs are scheduled/background only. They are allowed to run for up to 10 minutes.

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.",
  capabilities: ["records:read", "records:write"] as const,
  data: {
    collections: {
      customers: {
        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.set("last-click", new Date().toISOString())}>
        Save
      </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, useAppRecords, useProjectUpdates, useProjectTasks, useProjectContacts, and useAppTool.

For the full authoring guide, including the manifest contract, bridge APIs, theming, limits, security model, and troubleshooting, see App React UI.

Data scope

By default an app's data — its KV state (context.get/set) and every record collection (context.records) — is partitioned per project: each project that enables the app gets its own isolated copy. Declare dataScope: "org" in defineApp(...) to store one shared partition per org instead. Every project under the org that enables the app then reads and writes the same data.

export default defineApp({
  name: "customer-directory",
  description: "One shared customer list for the whole org.",
  dataScope: "org",
  data: {
    collections: {
      customers: {
        fields: { name: { type: "string", required: true } },
        globalSearch: { kind: "customer", titleField: "name" },
      },
    },
  },
  tools: {
    // ...
  },
});

Rules and behavior:

  • Default is "project". Omitting dataScope keeps the historical per-project isolation.
  • Immutable after the first deploy. Changing dataScope on a redeploy is rejected (APP_DATA_SCOPE_IMMUTABLE) — flipping it would strand all existing data in the old partition. Publish a new app if you need a different scope.
  • App-wide. The scope applies to the app's KV state and all of its record collections together.
  • Orthogonal to install scope. Whether an app is enabled per project or org-wide controls availability; dataScope controls where its data lives. A project-installed app with dataScope: "org" still shares org data.
  • Global search merges both. Searching from a project covers its project-scoped app records and the org-scoped records of any enabled dataScope: "org" app, in one result set.
  • Lifecycle. Deleting a project removes only that project's app data; org-scoped data survives until the org itself (or the app) is deleted.
  • Runtime. Handlers can read the active scope from context.dataScope ("project" unless the app declared "org").

Files (context.files) and the project primitives (tasks, contacts, notes, updates) always stay bound to the invoking project regardless of dataScope.

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 or for an org. If an org-enabled app is redeployed with new capabilities later, the org install stays enabled; each project that uses the app approves the new project-scoped capabilities from Project Settings.

export default defineApp({
  name: "crm-sync",
  description: "Sync CRM data for a project.",
  capabilities: [
    "data:read",
    "data:write",
    "records:read",
    "records:write",
    "org:projects:read",
  ] 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.records.collection("customers").create(...) is only available when it declares records:write.

ScopeContext helperWhat it allowsBoundary
data:readcontext.get, context.has, context.listKeysRead this app's JSON state.Data partition (project, or org when dataScope: "org") + app
data:writecontext.set, context.deleteWrite this app's JSON state.Data partition (project, or org when dataScope: "org") + app
records:readcontext.records.collection(name).get/list/searchRead declared queryable records, including full-text and vector search.Data partition + app + collection
records:writecontext.records.collection(name).create/update/deleteWrite declared queryable records and update search indexes.Data partition + app + collection
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.create/editCreate a feed update, or edit one in place (edit with material to resurface it).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
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
org:files:readcontext.org.files.get({ projectId, key })Legacy shared-project helper. Shared projects have been removed.Not available
org:files:writecontext.org.files.put({ projectId, key, blob })Legacy shared-project helper. Shared projects have been removed.Not available
logs:writecontext.log.info, context.log.warn, context.log.errorEmit structured app logs.Current invocation
metrics:writecontext.metricEmit app metrics.Current invocation
chat:postcontext.chat.postPost text into the invoking chat.Invoking project/chat
notifications:sendcontext.notifySend external notifications through Avi.Invoking org/project
tasks:schedulecontext.tasks.scheduleSchedule a project task.Invoking project
tasks:cancelcontext.tasks.cancelCancel a scheduled project task.Invoking project
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
org:projects:readcontext.org.projects.listLegacy shared-project helper. Returns an empty list.Not available
apps:invokecontext.apps.invoke, context.subagents.invokeInvoke another enabled app tool, or (from a Subagent) run another same-app Subagent.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. User-owned projects are private to their owner; cross-project shared-project helpers are retained only for backward compatibility and do not expose projects.

User-owned projects install apps individually, and each project controls its own tool permissions.

Build and deploy

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:

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

Iterating

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

avi status

First-party example

The Gmail / Calendar / Contacts / Drive app lives at packages/tools/google in the Avi repo. It's a full-size app — handlers for ~21 tools — and a good reference if you want to see how a larger one is structured.