Custom Tools & Persistence Plugins
Extend Donobu with custom tools the AI can invoke during flows, and replace the default file-based persistence with a custom backend.
Donobu's architecture is extensible. You can add custom tools that the AI can invoke during a flow, and you can replace the default file-based persistence with a custom backend.
Scaffolding a plugin
The fastest way to get started is with the official scaffolding CLI, create-donobu-plugin:
npx create-donobu-plugin my-support-tools
cd my-support-tools && npm install
This generates a TypeScript project pre-wired to Donobu's plugin API. The entry point is src/index.ts, which must export a single async function loadCustomTools(deps) that returns an array of Tool instances:
import type { PluginDependencies, Tool } from 'donobu';
import { z } from 'zod/v4';
export async function loadCustomTools(
deps: PluginDependencies,
): Promise<Tool<any, any>[]> {
return [
deps.donobu.createTool(deps, {
name: 'myCustomTool',
description:
'Description the AI sees when deciding whether to call this tool.',
schema: z.object({
input: z.string().describe('The value to process'),
}),
call: async (context, parameters) => {
return {
isSuccessful: true,
forLlm: `Processed: ${parameters.input}`,
metadata: null,
};
},
}),
];
}
After implementing your tools, build and install the plugin:
npm run build
npm exec install-donobu-plugin
Then restart Donobu Studio so the new bundle is loaded. Donobu watches a plugins directory inside its working data folder (macOS: ~/Library/Application Support/Donobu Studio/plugins; Windows: %APPDATA%/Donobu Studio/plugins; Linux: ~/.config/Donobu Studio/plugins) and imports each plugin's dist/index.mjs on startup.
Custom tools
A custom tool is a class that the AI can call by name during a page.ai() flow, just like the built-in tools (click, inputText, etc.). Use custom tools to:
- Interact with internal APIs or databases during a test flow
- Trigger external webhooks or notifications as part of a test step
- Perform computations that are difficult to express as a browser interaction
Tool interface
A custom tool must extend the base Tool class and implement its call method. The Tool class takes two schema type parameters: one for direct invocation via page.run() and one for AI-invoked calls (which typically adds a rationale field the AI uses to explain its reasoning):
import { Tool } from 'donobu';
import { z } from 'zod';
// Schema for direct invocation via page.run()
const SyncSsoSessionSchema = z.object({
tenant: z.string().describe('The tenant slug to sync the SSO session for'),
});
// Schema for AI-invoked calls — extends the core schema with a rationale field
const SyncSsoSessionGptSchema = z.object({
...SyncSsoSessionSchema.shape,
rationale: z.string().optional().describe('Why the AI is invoking this tool'),
});
export class SyncSsoSessionTool extends Tool<
typeof SyncSsoSessionSchema,
typeof SyncSsoSessionGptSchema
> {
public static readonly NAME = 'syncSsoSession';
public constructor() {
super(
SyncSsoSessionTool.NAME,
'Trigger an SSO session sync for the specified tenant via the internal API.',
SyncSsoSessionSchema, // used when called via page.run()
SyncSsoSessionGptSchema, // used when the AI calls this tool
);
}
public override async call(context, parameters) {
const response = await fetch(
`https://internal.example.com/sso/sync/${parameters.tenant}`,
{
method: 'POST',
headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
},
);
if (!response.ok) {
return {
isSuccessful: false,
forLlm: `SSO sync failed for tenant ${parameters.tenant}: ${response.statusText}`,
metadata: null,
};
}
return {
isSuccessful: true,
forLlm: `SSO session synced for tenant ${parameters.tenant}`,
metadata: { tenantId: parameters.tenant },
};
}
}
Registering custom tools
Register custom tools when calling page.run() directly (see page.run) or by adding them to the Donobu stack before your tests run. Custom tools become available by name in allowedTools:
await page.ai(
'Sync the SSO session for the acme tenant, then verify login works',
{
allowedTools: ['syncSsoSession', 'click', 'inputText', 'assert'],
},
);
You can also invoke a custom tool directly without AI involvement:
const result = await page.run('syncSsoSession', { tenant: 'acme' });
expect(result.metadata.tenantId).toBe('acme');
Custom persistence backends
By default, Donobu stores flow metadata, tool calls, and screenshots in a local SQLite database. You can replace this with a custom persistence backend — useful for:
- Storing flow data in a central database shared across your team
- Streaming results to an observability or APM platform in real time
- Multi-tenant environments where each tenant's data must be isolated
Built-in backends and PERSISTENCE_PRIORITY
The PERSISTENCE_PRIORITY environment variable controls which backends are active and their priority order. The first entry is the primary layer used for reads and single-layer operations. Built-in values are:
| Key | Backend |
|---|---|
DONOBU | Donobu Cloud API (requires DONOBU_API_KEY) |
LOCAL | SQLite database in the Donobu Studio data directory |
RAM | In-memory, non-durable |
The default priority is ["DONOBU","S3","GCS","LOCAL","RAM"]. Backends not listed are excluded entirely. If a built-in backend is listed but not configured (e.g. DONOBU without a DONOBU_API_KEY), it is silently skipped. S3 and GCS are available as separate persistence plugins for paid accounts — visit the Pricing page to upgrade.
Implementing FlowsPersistence
Your backend must implement FlowsPersistence, which covers all flow run data — metadata, screenshots, tool calls, video segments, generic file storage, and browser state:
interface FlowsPersistence {
// Flow metadata
setFlowMetadata(metadata: FlowMetadata): Promise<void>;
getFlowMetadataById(flowId: string): Promise<FlowMetadata>; // throw FlowNotFoundException if not found
getFlowMetadataByName(flowName: string): Promise<FlowMetadata>; // throw FlowNotFoundException if not found
getFlowsMetadata(query: FlowsQuery): Promise<PaginatedResult<FlowMetadata>>;
// Screenshots
saveScreenShot(flowId: string, bytes: Buffer): Promise<string>; // returns a screenshot ID
getScreenShot(flowId: string, screenShotId: string): Promise<Buffer | null>;
// Tool calls
setToolCall(flowId: string, toolCall: ToolCall): Promise<void>;
getToolCalls(flowId: string): Promise<ToolCall[]>;
deleteToolCall(flowId: string, toolCallId: string): Promise<void>;
// Video
setVideo(flowId: string, bytes: Buffer): Promise<void>;
getVideoSegment(
flowId: string,
startOffset: number,
length: number,
): Promise<VideoSegment | null>;
// Generic file storage
setFlowFile(flowId: string, fileId: string, fileBytes: Buffer): Promise<void>;
getFlowFile(flowId: string, fileId: string): Promise<Buffer | null>;
// Browser state
setBrowserState(flowId: string, state: BrowserStorageState): Promise<void>;
getBrowserState(flowId: string): Promise<BrowserStorageState | null>;
// Deletion
deleteFlow(flowId: string): Promise<void>;
}
Implementing EnvPersistence
EnvPersistence is the key-value store for the {{$.env.VAR_NAME}} interpolation system. Return null from createEnvPersistence() to fall back to the next backend in PERSISTENCE_PRIORITY:
interface EnvPersistence {
setEnvironmentDatum(key: string, value: string): Promise<void>;
deleteEnvironmentDatum(key: string): Promise<void>;
getEnvironmentDatum(key: string): Promise<string | undefined>;
getEnvironmentData(): Promise<Record<string, string>>;
}
Packaging and registering the plugin
A persistence plugin uses the same packaging as a tool plugin. Add a loadPersistencePlugin export to your plugin's src/index.ts alongside (or instead of) loadCustomTools. Donobu calls each exported function independently on startup:
import type {
EnvPersistence,
FlowsPersistence,
PersistencePlugin,
PluginDependencies,
} from 'donobu';
class MyDbPersistencePlugin implements PersistencePlugin {
async createFlowsPersistence(): Promise<FlowsPersistence | null> {
// Return null if required configuration is absent — Donobu will skip this backend
if (!process.env.MYDB_CONNECTION_STRING) return null;
return new MyDbFlowsPersistence(process.env.MYDB_CONNECTION_STRING);
}
async createEnvPersistence(): Promise<EnvPersistence | null> {
return null; // fall back to the next backend in PERSISTENCE_PRIORITY
}
}
export async function loadPersistencePlugin(
_deps: PluginDependencies,
): Promise<{ key: string; plugin: PersistencePlugin } | null> {
return {
key: 'MYDB', // must match the key you add to PERSISTENCE_PRIORITY
plugin: new MyDbPersistencePlugin(),
};
}
Build and install using the same steps as a tool plugin:
npm run build
npm exec install-donobu-plugin
Restart Donobu Studio, then activate the backend by adding its key to PERSISTENCE_PRIORITY. List it first to make it the primary layer, with LOCAL as a fallback:
PERSISTENCE_PRIORITY='["MYDB","LOCAL"]' npx donobu test
Notes
- Custom tools and persistence plugins are intended for advanced integration scenarios. For most test automation use cases, the built-in tools and default persistence are sufficient.
- The tool parameter schema (Zod) is used both for input validation and to generate the tool description that the AI sees. Write clear
.describe()annotations on each field to help the AI use the tool correctly.