Plugins are the primary way to extend Co-Assistant with new capabilities. Each plugin exposes one or more tools that the AI model can invoke during a conversation — searching emails, creating calendar events, fetching weather data, or anything else you can build with an API.
This guide walks you through building a plugin from scratch. You'll learn the interfaces, the lifecycle, and the patterns that make a plugin production-ready.
Prerequisites: Familiarity with TypeScript and Node.js. No prior knowledge of the Co-Assistant codebase is required.
- Quick Start
- Plugin Structure
- Plugin Manifest (plugin.json)
- The CoAssistantPlugin Interface
- PluginContext — What's Available at Runtime
- Defining Tools
- Credential Management
- State Management
- Error Handling
- Health Checks
- Testing Your Plugin
- Complete Example — The Gmail Plugin
- Best Practices
The fastest way to create a new plugin is with the built-in scaffold command:
co-assistant plugin create my-pluginThis generates a ready-to-edit directory under plugins/:
plugins/my-plugin/
├── plugin.json # Plugin manifest (metadata + credential requirements)
├── index.ts # Plugin entry point (factory function)
├── tools.ts # AI tool definitions
└── README.md # Plugin documentation
Enable it:
co-assistant plugin enable my-pluginList all discovered plugins to verify:
co-assistant plugin listGet detailed info about a specific plugin:
co-assistant plugin info my-pluginThat's it — you now have a working plugin. The rest of this guide explains how to customise every part of it.
Every plugin lives in its own subdirectory under plugins/. The directory name should match the plugin's id.
plugins/my-plugin/
├── plugin.json # Required — manifest with metadata and credential declarations
├── index.ts # Required — default export is a factory that returns a CoAssistantPlugin
├── tools.ts # Recommended — tool definitions, kept separate for clarity
├── auth.ts # Optional — authentication helpers (OAuth, API key management)
└── README.md # Optional — human-readable documentation
| File | Purpose |
|---|---|
plugin.json |
Declarative metadata validated at load time. Defines the plugin's identity, version, credential requirements, and dependencies. |
index.ts |
Must default-export (or named-export createPlugin) a factory function that returns a CoAssistantPlugin object. |
| File | Purpose |
|---|---|
tools.ts |
Keep tool definitions in a separate file so index.ts stays focused on lifecycle. |
auth.ts |
Encapsulate API authentication logic (token refresh, header generation). |
README.md |
Explain what the plugin does, what credentials are needed, and how to set it up. |
The manifest is validated against a Zod schema (PluginManifestSchema) when the registry discovers your plugin. If validation fails, the plugin is skipped with a warning.
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "A short description of what this plugin does",
"author": "your-name",
"requiredCredentials": [
{
"key": "MY_API_KEY",
"description": "API key for the My Service API",
"type": "apikey"
}
],
"dependencies": []
}| Field | Type | Required | Description |
|---|---|---|---|
id |
string |
✅ | Unique identifier. Must be kebab-case (a-z, 0-9, - only). Must match the directory name. |
name |
string |
✅ | Human-readable display name. |
version |
string |
✅ | Semantic version in strict MAJOR.MINOR.PATCH format (e.g. "1.2.3"). |
description |
string |
✅ | Brief description shown in plugin list and plugin info. |
author |
string |
— | Author or organisation name. |
requiredCredentials |
array |
— | Credentials the plugin needs. Defaults to []. See Credential Management. |
dependencies |
array |
— | IDs of other plugins this plugin depends on. Dependencies are loaded first. Defaults to []. |
Each entry in requiredCredentials has the following shape:
| Field | Type | Default | Description |
|---|---|---|---|
key |
string |
— | The credential key used to look up the value at runtime. |
description |
string |
— | Human-readable explanation shown during setup. |
type |
"text" | "oauth" | "apikey" |
"text" |
Hint for the setup wizard about the kind of credential. |
{
"id": "gmail",
"name": "Gmail Plugin",
"version": "1.0.0",
"description": "Send, read, and search Gmail messages via the Gmail API",
"author": "co-assistant",
"requiredCredentials": [
{ "key": "GMAIL_CLIENT_ID", "description": "Google OAuth2 Client ID", "type": "oauth" },
{ "key": "GMAIL_CLIENT_SECRET", "description": "Google OAuth2 Client Secret", "type": "oauth" },
{ "key": "GMAIL_REFRESH_TOKEN", "description": "Google OAuth2 Refresh Token", "type": "oauth" }
],
"dependencies": []
}{
"id": "hello-world",
"name": "Hello World",
"version": "1.0.0",
"description": "A minimal example plugin that needs no credentials",
"author": "co-assistant",
"requiredCredentials": [],
"dependencies": []
}Every plugin is a plain object that implements the CoAssistantPlugin interface. Your entry point (index.ts) must export a factory function — a zero-argument function that returns a fresh plugin instance.
// plugins/types.ts — simplified
export interface CoAssistantPlugin {
id: string; // Unique identifier (kebab-case)
name: string; // Display name
version: string; // Semantic version
description: string; // Short description
requiredCredentials: string[]; // Credential keys this plugin needs
initialize(context: PluginContext): Promise<void>;
getTools(): ToolDefinition[];
destroy(): Promise<void>;
healthCheck(): Promise<boolean>;
}
export type PluginFactory = () => CoAssistantPlugin;Called exactly once after the plugin is loaded and credentials are verified. Use this to:
- Set up API clients
- Open connections
- Create tool definitions that depend on runtime context
async initialize(context: PluginContext) {
this.apiClient = new MyApiClient(context.credentials.MY_API_KEY);
this.tools = createMyTools(this.apiClient, context.logger);
context.logger.info("Plugin initialized");
}Called after initialize(). Returns the array of tool definitions that this plugin exposes to the AI model. The plugin manager prefixes each tool's name with the plugin ID automatically (e.g. my-plugin__search).
getTools(): ToolDefinition[] {
return this.tools;
}Called during graceful shutdown. Clean up resources — close HTTP connections, flush buffers, release file handles.
async destroy() {
await this.apiClient.disconnect();
}Called periodically by the sandbox. Return true if the plugin is fully operational.
async healthCheck(): Promise<boolean> {
return this.apiClient.isConnected();
}// plugins/hello-world/index.ts
import type {
CoAssistantPlugin,
PluginContext,
ToolDefinition,
} from "../../src/plugins/types.js";
export default function createPlugin(): CoAssistantPlugin {
let ctx: PluginContext;
return {
id: "hello-world",
name: "Hello World",
version: "1.0.0",
description: "A minimal example plugin",
requiredCredentials: [],
async initialize(context) {
ctx = context;
ctx.logger.info("Hello World plugin initialized");
},
getTools(): ToolDefinition[] {
return [
{
name: "greet",
description: "Say hello to someone",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Name to greet" },
},
required: ["name"],
},
handler: async (args) => {
const name = args.name as string;
return `Hello, ${name}! 👋`;
},
},
];
},
async destroy() {},
async healthCheck() {
return true;
},
};
}The plugin manager accepts either of these export styles:
// Option 1: default export (preferred)
export default function createPlugin(): CoAssistantPlugin { … }
// Option 2: named export
export function createPlugin(): CoAssistantPlugin { … }When initialize() is called, the plugin receives a PluginContext object with everything it needs:
export interface PluginContext {
pluginId: string; // Your plugin's ID
credentials: Record<string, string>; // Pre-validated credential values
state: PluginStateStore; // Namespaced persistent storage
logger: Logger; // Pino child logger tagged with your plugin ID
}A key-value map of all credentials declared in your manifest. All keys listed in requiredCredentials are guaranteed to be present and non-empty by the time initialize() is called.
async initialize(context: PluginContext) {
const apiKey = context.credentials.MY_API_KEY;
// Safe to use — validated before initialize() was called
}A namespaced key-value store for persistent data. Keys are automatically scoped to your plugin — you can't accidentally read or write another plugin's data.
export interface PluginStateStore {
get(key: string): string | null; // Read a value (null if missing)
set(key: string, value: string): void; // Write a value
delete(key: string): void; // Remove a key
getAll(): Record<string, string>; // Snapshot of all your data
}See State Management for usage patterns.
A Pino child logger. All log entries are automatically tagged with your plugin ID, so you never need to add it manually.
context.logger.info("Plugin initialized");
context.logger.debug({ query }, "Searching...");
context.logger.error({ error: err.message }, "API call failed");Tools are the core of a plugin — they're what the AI model actually calls. Each tool is a ToolDefinition object:
export interface ToolDefinition {
name: string; // Tool name (without plugin prefix)
description: string; // Tells the AI when to use this tool
parameters: Record<string, unknown> | ZodType; // Parameter schema
handler: (args: Record<string, unknown>) => Promise<string | Record<string, unknown>>;
}You only provide the short name. The plugin manager automatically prefixes it with your plugin ID using a double underscore separator:
- You define:
name: "search_emails" - AI model sees:
gmail__search_emails
This guarantees uniqueness across all plugins.
You have two options for declaring parameters:
const tool: ToolDefinition = {
name: "lookup",
description: "Look up a record by ID",
parameters: {
type: "object",
properties: {
id: { type: "string", description: "Record ID to look up" },
includeDetails: { type: "boolean", description: "Include full details" },
},
required: ["id"],
},
handler: async (args) => { /* ... */ },
};import { z } from "zod";
const tool: ToolDefinition = {
name: "search_emails",
description: "Search for emails using a query string",
parameters: z.object({
query: z.string().describe("Search query"),
maxResults: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe("Maximum number of results"),
}),
handler: async (args) => { /* ... */ },
};Zod schemas are automatically converted to JSON Schema at registration time. Use .describe() on each field — these descriptions help the AI model understand what to pass.
Handlers receive parsed arguments and must return either a string or a Record<string, unknown> (JSON-serializable object).
Return a string for simple text responses:
handler: async (args) => {
const name = args.name as string;
return `Hello, ${name}!`;
}Return an object for structured data the AI can reason about:
handler: async (args) => {
const results = await searchApi(args.query as string);
return {
resultCount: results.length,
items: results.map(r => ({ id: r.id, title: r.title })),
};
}Return an error string on failure (never throw — see Error Handling):
handler: async (args) => {
try {
const data = await fetchData(args.id as string);
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return `Error fetching data: ${message}`;
}
}Keep index.ts focused on lifecycle and put tool definitions in tools.ts:
// plugins/my-plugin/tools.ts
import { z } from "zod";
import type { ToolDefinition } from "../../src/plugins/types.js";
export function createTools(apiClient: MyApiClient, logger: Logger): ToolDefinition[] {
return [
{
name: "search",
description: "Search for items",
parameters: z.object({
query: z.string().describe("Search query"),
}),
handler: async (args) => {
try {
const results = await apiClient.search(args.query as string);
logger.debug({ count: results.length }, "Search completed");
return { results };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error({ error: message }, "Search failed");
return `Error: ${message}`;
}
},
},
];
}// plugins/my-plugin/index.ts
import type { CoAssistantPlugin, PluginContext, ToolDefinition } from "../../src/plugins/types.js";
import { createTools } from "./tools.js";
export default function createPlugin(): CoAssistantPlugin {
let tools: ToolDefinition[];
return {
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
description: "Does something useful",
requiredCredentials: ["MY_API_KEY"],
async initialize(context: PluginContext) {
const client = new MyApiClient(context.credentials.MY_API_KEY);
tools = createTools(client, context.logger);
},
getTools: () => tools,
async destroy() {},
async healthCheck() { return true; },
};
}Credentials live in config.json under the plugins.<pluginId>.credentials key:
{
"plugins": {
"my-plugin": {
"enabled": true,
"credentials": {
"MY_API_KEY": "sk-abc123..."
}
}
}
}In your plugin.json, list every credential key your plugin needs:
{
"requiredCredentials": [
{
"key": "MY_API_KEY",
"description": "API key for the My Service API",
"type": "apikey"
},
{
"key": "MY_WEBHOOK_SECRET",
"description": "Webhook signing secret",
"type": "text"
}
]
}Before initialize() is called, the CredentialManager verifies that every key declared in requiredCredentials exists in config and has a non-empty value. If any are missing:
- A warning is logged with the list of missing keys.
- The plugin is loaded with empty credentials (it can still start, but API calls will likely fail).
| Type | Meaning |
|---|---|
"text" |
Generic text secret (default). |
"apikey" |
An API key — hints to the setup wizard to treat it as a secret. |
"oauth" |
OAuth token or related credential. |
The type field is a hint for the setup wizard and CLI display. At runtime, all credentials are plain strings regardless of type.
Inside initialize(), credentials are available on the context:
async initialize(context: PluginContext) {
const apiKey = context.credentials.MY_API_KEY;
const secret = context.credentials.MY_WEBHOOK_SECRET;
}# See which credentials are configured vs missing
co-assistant plugin info my-pluginOutput:
📋 Plugin: My Plugin
──────────────────────
ID: my-plugin
Version: 1.0.0
Description: Does something useful
Status: Enabled
Required Credentials:
MY_API_KEY - API key for the My Service API [configured]
MY_WEBHOOK_SECRET - Webhook signing secret [missing]
Each plugin gets a namespaced key-value store that persists across restarts. Use it for caching, user preferences, pagination cursors, or any data your plugin needs to remember.
async initialize(context: PluginContext) {
const { state } = context;
// Store a value
state.set("last-sync", new Date().toISOString());
// Read a value (returns null if not found)
const lastSync = state.get("last-sync");
// Delete a value
state.delete("temp-data");
// Get everything
const allData = state.getAll();
// => { "last-sync": "2024-01-15T10:30:00.000Z" }
}Capture the state store in a closure so tool handlers can access it:
export default function createPlugin(): CoAssistantPlugin {
let state: PluginStateStore;
return {
// ...
async initialize(context) {
state = context.state;
},
getTools() {
return [{
name: "get_preference",
description: "Get a saved preference",
parameters: {
type: "object",
properties: {
key: { type: "string", description: "Preference key" },
},
required: ["key"],
},
handler: async (args) => {
const value = state.get(args.key as string);
return value ?? "No preference set for that key.";
},
}];
},
// ...
};
}- Keys are automatically namespaced —
state.set("foo", "bar")only affects your plugin. Another plugin setting"foo"is completely independent. - Values are strings — to store complex data,
JSON.stringify()on write andJSON.parse()on read. - Storage is synchronous —
get,set, anddeleteare synchronous calls backed by SQLite.
The plugin system is designed to be resilient. A single broken plugin should never crash the assistant or affect other plugins.
Tool handlers are called by the AI model. If a handler throws, the sandbox catches it and returns a generic error message. You lose the opportunity to provide helpful context. Instead, catch errors yourself and return a descriptive string:
// ✅ Good — return an error string
handler: async (args) => {
try {
const data = await apiClient.fetch(args.id as string);
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error({ error: message }, "API call failed");
return `Error fetching data: ${message}`;
}
}
// ❌ Bad — throwing propagates to the sandbox
handler: async (args) => {
const data = await apiClient.fetch(args.id as string); // might throw!
return { success: true, data };
}The PluginSandbox wraps every plugin method call (initialize, destroy, tool handlers) in a try/catch boundary:
- Errors never propagate into the host process.
- Consecutive failures are counted per plugin.
- Auto-disable kicks in after 5 consecutive failures (configurable via
pluginHealth.maxFailuresinconfig.json). When a plugin is auto-disabled, all its tool calls return an error message instead of executing. - A successful call resets the counter — proving the plugin has recovered.
When a tool handler throws, the AI model receives:
Error: Tool search_emails failed: unexpected error during execution
When a plugin is auto-disabled:
Error: Tool search_emails failed: plugin has been disabled due to repeated failures
- Always wrap external API calls in try/catch.
- Return error strings that include enough context for the AI to explain the failure to the user.
- Log errors with the plugin logger so they appear in the application logs.
- Never let credential values leak into error messages or logs.
The healthCheck() method is called periodically to verify your plugin is operational.
A health check should verify the core dependency of your plugin:
// ✅ Good — verifies the essential resource
async healthCheck(): Promise<boolean> {
return this.apiClient.isConnected();
}
// ✅ Good — verifies credentials are still valid
async healthCheck(): Promise<boolean> {
try {
await this.auth.getAccessToken();
return true;
} catch {
return false;
}
}
// ❌ Bad — always returns true (not useful)
async healthCheck(): Promise<boolean> {
return true;
}Failed health checks increment the sandbox's failure counter. After maxFailures consecutive failures (default: 5), the plugin is auto-disabled. The counter resets on any successful operation.
co-assistant plugin create my-plugin
co-assistant plugin enable my-pluginco-assistant plugin listYou should see your plugin listed as enabled.
co-assistant plugin info my-pluginEnsure all required credentials show [configured].
Start Co-Assistant and interact with it via Telegram (or your configured interface). Ask the AI to use your plugin's tools:
"Use the my-plugin example tool with input 'test'"
Review the application logs for entries tagged with your plugin ID. The namespaced logger makes it easy to filter:
# Look for your plugin's log entries
cat logs/app.log | grep '"pluginId":"my-plugin"'- Edit
tools.tsto refine your tool definitions. - Restart the assistant to pick up changes.
- Use
plugin disable/plugin enableto toggle without removing files.
The Gmail plugin is the reference implementation. Let's walk through every file.
{
"id": "gmail",
"name": "Gmail Plugin",
"version": "1.0.0",
"description": "Send, read, and search Gmail messages via the Gmail API",
"author": "co-assistant",
"requiredCredentials": [
{ "key": "GMAIL_CLIENT_ID", "description": "Google OAuth2 Client ID", "type": "oauth" },
{ "key": "GMAIL_CLIENT_SECRET", "description": "Google OAuth2 Client Secret", "type": "oauth" },
{ "key": "GMAIL_REFRESH_TOKEN", "description": "Google OAuth2 Refresh Token", "type": "oauth" }
],
"dependencies": []
}Three OAuth credentials are declared. The "type": "oauth" hints to the setup wizard that these are part of an OAuth flow.
import type {
CoAssistantPlugin,
PluginContext,
ToolDefinition,
} from "../../src/plugins/types.js";
import { GmailAuth } from "./auth.js";
import { createGmailTools } from "./tools.js";
export default function createPlugin(): CoAssistantPlugin {
let auth: GmailAuth;
let toolDefs: ToolDefinition[];
return {
id: "gmail",
name: "Gmail Plugin",
version: "1.0.0",
description: "Send, read, and search Gmail messages",
requiredCredentials: [
"GMAIL_CLIENT_ID",
"GMAIL_CLIENT_SECRET",
"GMAIL_REFRESH_TOKEN",
],
async initialize(context: PluginContext) {
// Create the auth helper using validated credentials
auth = new GmailAuth(
context.credentials.GMAIL_CLIENT_ID,
context.credentials.GMAIL_CLIENT_SECRET,
context.credentials.GMAIL_REFRESH_TOKEN,
);
// Build tool definitions that close over the auth helper
toolDefs = createGmailTools(auth, context.logger);
context.logger.info("Gmail plugin initialized");
},
getTools(): ToolDefinition[] {
return toolDefs;
},
async destroy() {
// No persistent connections to close
},
async healthCheck(): Promise<boolean> {
// Verify credentials are present
return auth.isConfigured();
},
};
}Key patterns to note:
- Factory function —
createPlugin()returns a fresh plugin instance with private state captured in a closure (auth,toolDefs). - Credentials are used in
initialize()— theGmailAuthhelper is constructed with the pre-validated credential values. - Tools are built during initialization — they close over the
authinstance and thelogger. - Health check is meaningful — it verifies the auth helper has non-empty credentials.
export class GmailAuth {
private readonly clientId: string;
private readonly clientSecret: string;
private readonly refreshToken: string;
private accessToken: string | null = null;
private tokenExpiresAt = 0;
constructor(clientId: string, clientSecret: string, refreshToken: string) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.refreshToken = refreshToken;
}
isConfigured(): boolean {
return Boolean(this.clientId && this.clientSecret && this.refreshToken);
}
async getAccessToken(): Promise<string> {
// Return cached token if still valid (with 60s buffer)
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
return this.accessToken;
}
// Refresh the token using the Google OAuth2 endpoint
const body = new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
refresh_token: this.refreshToken,
grant_type: "refresh_token",
});
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to refresh token (${response.status}): ${errorText}`);
}
const data = await response.json() as { access_token: string; expires_in: number };
this.accessToken = data.access_token;
this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
return this.accessToken;
}
}This pattern — a dedicated auth class that caches tokens and refreshes transparently — is reusable for any OAuth-based plugin.
The Gmail plugin defines five tools:
| Tool | Description | Parameters |
|---|---|---|
search_threads |
Search Gmail threads with full message history | query (string), maxThreads (number, optional), includeLatestBody (boolean, optional) |
get_thread |
Get a full thread by ID with all messages | threadId (string) |
search_emails |
Search Gmail with a query string | query (string), maxResults (number, optional), includeBody (boolean, optional) |
read_email |
Read the full content of an email | messageId (string) |
send_email |
Send an email | to (string), subject (string), body (string) |
Here's the search_emails tool as an example of the pattern:
import { z } from "zod";
import type { ToolDefinition } from "../../src/plugins/types.js";
const searchEmails: ToolDefinition = {
name: "search_emails",
description:
"Search for emails in Gmail using a query string (same syntax as the Gmail search bar)",
parameters: z.object({
query: z.string().describe("Gmail search query"),
maxResults: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe("Maximum number of results to return"),
}),
handler: async (args) => {
try {
const query = args.query as string;
const maxResults = (args.maxResults as number | undefined) ?? 10;
// 1. List message IDs
const listRes = await fetch(`${GMAIL_API}/messages?q=${query}&maxResults=${maxResults}`, {
headers: await authHeaders(auth),
});
if (!listRes.ok) {
return `Error searching emails (${listRes.status}): ${await listRes.text()}`;
}
const listData = await listRes.json();
if (!listData.messages?.length) {
return "No emails found matching that query.";
}
// 2. Fetch metadata for each message
const results = await Promise.all(
listData.messages.map(async (msg) => {
// ... fetch and format each message
}),
);
// 3. Return structured data
return {
resultCount: results.length,
estimatedTotal: listData.resultSizeEstimate ?? results.length,
messages: results,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return `Error searching emails: ${message}`;
}
},
};Notice the pattern every tool follows:
- Extract and validate arguments from the
argsobject. - Call the external API with proper authentication.
- Handle HTTP errors by returning descriptive error strings.
- Return structured data for successful results.
- Wrap everything in try/catch — never let exceptions escape.
{
"plugins": {
"gmail": {
"enabled": true,
"credentials": {
"GMAIL_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
"GMAIL_CLIENT_SECRET": "your-client-secret",
"GMAIL_REFRESH_TOKEN": "your-refresh-token"
}
}
}
}- Each plugin runs in its own logical sandbox. Errors are caught and counted — they never crash other plugins or the host process.
- State is namespaced — your
state.set("key", "value")can never collide with another plugin's keys. - Tool names are prefixed —
my-plugin__tool-name— so naming conflicts are impossible.
| Thing | Convention | Example |
|---|---|---|
| Plugin ID | kebab-case | google-calendar |
| Plugin directory | Matches ID | plugins/google-calendar/ |
| Tool names | snake_case | search_emails, send_email |
| Credential keys | UPPER_SNAKE_CASE | GMAIL_CLIENT_ID |
- Never log credential values. The logger is for debugging — use it for keys, not values.
- Store credentials in
config.jsononly — never hard-code them in plugin source. - Keep
config.jsonout of version control — it's in.gitignorefor a reason. Commitconfig.json.examplewith empty placeholder values instead.
- Keep
index.tssmall — it should only handle the lifecycle (initialize,getTools,destroy,healthCheck). - Put tool definitions in
tools.ts. - Put authentication logic in
auth.ts. - Each file should have a single responsibility.
- Write clear descriptions — the AI model reads them to decide when to invoke your tool. Be specific about what the tool does and what the parameters mean.
- Use Zod schemas when you want parameter validation and rich
.describe()annotations. - Return structured data (objects) rather than formatted strings when the result has multiple fields. The AI can format the data for the user more naturally.
- Handle partial failures gracefully — if one item in a batch fails, return the successful results alongside error information rather than failing the entire call.
- Cache tokens and expensive computations — the Gmail plugin caches access tokens and only refreshes when they expire.
- Use
Promise.allfor independent requests — the Gmail search tool fetches message metadata in parallel. - Set reasonable defaults — limit result counts (e.g.
maxResultsdefaults to 10) to avoid slow responses.