Custom Modules

If none of the built-in modules cover what you need, write your own. Avi's CLI turns a small TypeScript file into a deployable module that your agent can use in any project.

The idea

A custom module can provide tools, Radar agents, and optional React panels. Tools are functions the agent or a panel can call. Radar agents are scheduled background workers users enable from Project Settings → Radar. 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 module belongs to.

Scaffold a module

avi init my-module

Creates a starter project with:

  • module.ts — the entry point where you declare module metadata plus tools, Radar agents, and panels.
  • @avihq/custom-modules-sdk as the authoring SDK for types like ModuleToolContext.
    • 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 Radar agents.
  • A package.json wired up with the right scripts.

Write your tools

Edit module.ts. Import authoring types such as ModuleToolContext from @avihq/custom-modules-sdk, then declare the module's name, description, and the list of tools. Radar agents and React panels live beside tools in the same defineModule(...) 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) — agent, ui, or both. Defaults to both.
  • Handler — the function that runs.
  • Secret inputs (optional) — per-project secret references the backend resolves before your handler runs.

Use callableFrom when a tool is meant only for a React panel or only for the agent:

tools: {
  "refresh-cache": {
    description: "Refresh cached customer data from the panel.",
    inputSchema: { type: "object", additionalProperties: false },
    callableFrom: "ui",
    async handler() {
      return { ok: true };
    },
  },
}

Scheduled Radar agents

Custom modules can also expose scheduled background agents. In the UI these appear as Radar. V1 Radar agents are not chat-callable specialists; they run on a schedule, with project-specific instructions, config, and a selected allowlist of module tools.

Define Radar agents with an agents object inside defineModule:

import {
  defineModule,
  type ModuleAgentContextForCapabilities,
  type ModuleAgentRequiredToolNames,
} from "@avihq/custom-modules-sdk";
import { generateText, stepCountIs, tool } from "ai";
import { z } from "zod";

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

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

type RadarContext = ModuleAgentContextForCapabilities<
  typeof capabilities,
  ModuleAgentRequiredToolNames<"customer-success", typeof requiredTools>
>;

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

  agents: {
    "renewal-risk-radar": {
      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: RadarContext) {
        const moduleTools = await context.modules.toolSet();
        let finished = false;

        const result = await generateText({
          model: context.ai.model(modelId),
          system: "You are a renewal-risk radar. End by calling finish_run.",
          prompt: `${instructions}\nLook back ${config.lookbackDays} days.`,
          tools: {
            ...moduleTools,
            finish_run: tool({
              description: "Finish this radar 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 radar 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 Radar setup view. List the module tools the agent actually needs; deploy stores them on the agent definition so users only see the allowlist required for that Radar. Entries can be full runtime names (customer-success_list-accounts) or same-module tool names (list-accounts), which the deploy step normalizes. For SDK type-safety, keep the list as a const tuple and pass ModuleAgentRequiredToolNames<"module-name", typeof requiredTools> as the second generic to ModuleAgentContextForCapabilities.

Radar agent definitions support:

FieldRequiredNotes
descriptionYesShown in Project Settings → Radar.
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 Radar instance in Project Settings → Radar.
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 Radar settings form.
requiredToolsNoModule tools this Radar needs. Same-module tool names are normalized to runtime names during deploy.
capabilitiesNoPer-agent narrowing. Defaults to module capabilities. Use llm:invoke for context.ai.model(...) and modules:invoke for module-tool access.
handlerYesRuns inside the custom-module Lambda. Return { status, stats, result } or void.

The handler receives:

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

Radar 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

Radar 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 Radar 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 Radar agents

Radar agents can call tools from enabled custom modules in the same project:

const allSelectedTools = await context.modules.toolSet();
const oneTool = await context.modules.tool("google_gmail-review-inbox");
const result = await context.modules.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 modules:invoke.
  • Users choose which tools the Radar may call when they enable it.
  • The backend enforces the selected-tool allowlist.
  • Target tools must be available in the project and callableFrom: "agent" or default-callable from agents.
  • 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 module.

Runtime behavior

When a user enables a Radar, 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 custom-module Lambda with action: "invoke-agent", and records last_run_started_at, last_run_finished_at, last_error, and run stats on the instance.

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

React panels

Custom modules 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 defineModule({
  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"],
      },
    },
  },
  ui: {
    panels: {
      dashboard: {
        title: "Customers",
        icon: "users",
        entry: "./ui/Dashboard.tsx",
      },
    },
  },
  tools: {
    // ...
  },
});

Panel code is normal React. Use @avihq/custom-modules-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/custom-modules-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 modules owned by the current org.
  • A module 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 module capabilities as tools.

Initial component library exports: Button, Input, Textarea, Select, Switch, Tabs, Table, Badge, Toolbar, PanelHeader, EmptyState, Spinner, plus useAvi, useTheme, useModuleData, useModuleRecords, useProjectUpdates, useProjectTasks, useProjectContacts, and useModuleTool.

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

Capabilities and scopes

Custom modules declare the capabilities they need in defineModule({ capabilities }). Avi asks an admin to approve those capabilities when the module is enabled for a project or for an org.

export default defineModule({
  name: "crm-sync",
  description: "Sync CRM data across shared projects.",
  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 module 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 module's JSON state.Invoking project + module
data:writecontext.set, context.deleteWrite this module's JSON state.Invoking project + module
records:readcontext.records.collection(name).get/list/searchRead declared queryable records, including full-text and vector search.Project tree + module + collection
records:writecontext.records.collection(name).create/update/deleteWrite declared queryable records and update search indexes.Project tree + module + collection
updates:readcontext.updates.listRead project timeline updates.Invoking project
updates:writecontext.updates.createPublish project timeline updates.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 project tree's root contact pool.Project tree root
contacts:writecontext.contacts.create/update/deleteCreate, edit, or delete contacts in the project tree's root contact pool.Project tree root
files:readcontext.files.getRead files in the module's file namespace.Invoking project + module
files:writecontext.files.putWrite files in the module's file namespace.Invoking project + module
org:files:readcontext.org.files.get({ projectId, key })Read module files from an explicitly selected shared project.Shared projects in the org + module
org:files:writecontext.org.files.put({ projectId, key, blob })Write module files to an explicitly selected shared project.Shared projects in the org + module
logs:writecontext.log.info, context.log.warn, context.log.errorEmit structured module logs.Current invocation
metrics:writecontext.metricEmit module 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.listList shared projects in the org. Personal projects are excluded.Shared projects in the org
modules:invokecontext.modules.invokeInvoke another enabled module tool.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. Org-scoped helpers are explicit: org data lives separately from project data, and cross-project file helpers require a target projectId.

Org-enabled modules are available to shared projects in the org, but their tools are not automatically granted. Each project still controls tool permissions. Personal projects do not inherit org-enabled modules.

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 module. The deployment package contains your bundled module.mjs plus a tiny bootstrap that delegates to Avi's shared custom-module runtime layer, so runtime/context-helper improvements can roll out without every author rebuilding their bundle.

You can pass module-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 module lives at packages/tools/google in the Avi repo. It's a full-size custom module — handlers for ~26 tools — and a good reference if you want to see how a larger one is structured.