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-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 Subagent agents.
- A
package.jsonwired 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 inrequiredTools; 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:
| Field | Required | Notes |
|---|---|---|
description | Yes | Shown in Project Settings → Subagent. |
singleton | No | Defaults to false. Set true when a project may only configure one instance of this Subagent. |
schedule.defaultIntervalSeconds | Yes | Developer-declared default cadence. Must be 60 seconds to 7 days. Users can change the interval in the UI after enabling. |
defaultModel | No | Developer-declared default model for context.ai.model(). Users can change the model per Subagent instance in Project Settings → Subagent. |
configSchema | No | JSON 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. |
defaultConfig | No | JSON-serializable defaults used to prefill the Subagent settings form. |
requiredTools | No | App tools this Subagent needs. Same-app tool names are normalized to runtime names during deploy. |
capabilities | No | Per-agent narrowing. Defaults to app capabilities. Use llm:invoke for context.ai.model(...) and apps:invoke for app-tool access. |
handler | Yes | Runs 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
requiredToolsgrant 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(), andcontext.subagents.invokepropagate the verdict by throwing a control-flow signal. Don't wrap them in a broadtry/catchthat 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.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, 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". OmittingdataScopekeeps the historical per-project isolation. - Immutable after the first deploy. Changing
dataScopeon 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;
dataScopecontrols where its data lives. A project-installed app withdataScope: "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.
| Scope | Context helper | What it allows | Boundary |
|---|---|---|---|
data:read | context.get, context.has, context.listKeys | Read this app's JSON state. | Data partition (project, or org when dataScope: "org") + app |
data:write | context.set, context.delete | Write this app's JSON state. | Data partition (project, or org when dataScope: "org") + app |
records:read | context.records.collection(name).get/list/search | Read declared queryable records, including full-text and vector search. | Data partition + app + collection |
records:write | context.records.collection(name).create/update/delete | Write declared queryable records and update search indexes. | Data partition + app + collection |
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.create/edit | Create a feed update, or edit one in place (edit with material to resurface it). | 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 |
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 |
org:files:read | context.org.files.get({ projectId, key }) | Legacy shared-project helper. Shared projects have been removed. | Not available |
org:files:write | context.org.files.put({ projectId, key, blob }) | Legacy shared-project helper. Shared projects have been removed. | Not available |
logs:write | context.log.info, context.log.warn, context.log.error | Emit structured app logs. | Current invocation |
metrics:write | context.metric | Emit app metrics. | Current invocation |
chat:post | context.chat.post | Post text into the invoking chat. | Invoking project/chat |
notifications:send | context.notify | Send external notifications through Avi. | Invoking org/project |
tasks:schedule | context.tasks.schedule | Schedule a project task. | Invoking project |
tasks:cancel | context.tasks.cancel | Cancel a scheduled project task. | Invoking project |
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 |
org:projects:read | context.org.projects.list | Legacy shared-project helper. Returns an empty list. | Not available |
apps:invoke | context.apps.invoke, context.subagents.invoke | Invoke another enabled app tool, or (from a Subagent) run another same-app Subagent. | 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. 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.