Custom Actions
Custom actions extend the agent with domain-specific capabilities that go beyond browser automation. The agent will automatically call your actions when the task requires them.
Why Custom Actions?
Some tasks require capabilities outside the browser:
- Fetching OTP codes from email
- Making API calls to your backend
- Interacting with external services
- Generating test data
Custom actions let you integrate these capabilities seamlessly.
Registering an Action
import { createAgent, z } from "@shiplightai/sdk";
const agent = createAgent({ model: "claude-haiku-4-5" });
agent.registerAction({
name: "extract_email_code",
description: "Extract verification code from email inbox",
schema: z.object({
email_address: z.string().describe("The email address to check"),
code_type: z.enum(["verification", "reset"]).describe("Type of code"),
}),
async execute(args, ctx) {
const code = await myEmailService.getCode(args.email_address, args.code_type);
ctx.variableStore.set("verification_code", code);
return { success: true, message: `Found code: ${code}` };
},
});Action Interface
interface ICustomAction<TSchema extends z.ZodObject> {
name: string;
description: string;
schema: TSchema;
execute: (args: z.infer<TSchema>, ctx: ActionExecutionContext) => Promise<CustomActionResult>;
}Required Fields
| Field | Type | Description |
|---|---|---|
name | string | Unique action name (snake_case recommended) |
description | string | Description for the agent - explains when to use this action |
schema | ZodObject | Zod schema defining parameters |
execute | function | Async function that performs the action |
Schema Definition
Use Zod to define the parameters your action accepts. The schema tells the agent what arguments to pass.
import { z } from "@shiplightai/sdk";
const schema = z.object({
// Required string parameter
email: z.string().describe("User email address"),
// Optional parameter with default
timeout: z.number().optional().default(5000).describe("Timeout in ms"),
// Enum parameter
priority: z.enum(["low", "medium", "high"]).describe("Message priority"),
// Array parameter
tags: z.array(z.string()).describe("List of tags"),
});TIP
Always add .describe() to parameters. The agent uses these descriptions to understand how to use your action.
Execution Context
The execute function receives a context object:
interface ActionExecutionContext {
page: Page; // Playwright page instance
variableStore: VariableStore; // Variable store for reading/writing
}Accessing the Page
async execute(args, ctx) {
// Interact with the page
const url = ctx.page.url();
const title = await ctx.page.title();
// Perform browser actions
await ctx.page.click('#some-button');
return { success: true };
}Using Variables
async execute(args, ctx) {
// Read a variable
const email = ctx.variableStore.get('email');
// Set a variable
ctx.variableStore.set('result', 'some value');
// Set a sensitive variable (withheld from LLM context and masked in logs)
ctx.variableStore.set('token', secretToken, true);
return { success: true };
}Return Format
Actions must return a CustomActionResult:
interface CustomActionResult {
success: boolean;
message?: string; // Optional status message
}Success
return { success: true, message: "Email sent successfully" };Failure
return { success: false, message: "Email service unavailable" };You can also throw an exception, which will be caught and converted to a failure result.
registerAction() itself throws if name, description, schema, or execute is missing, or if the same agent registers the same name twice.
Example: Email OTP
agent.registerAction({
name: "get_otp_from_email",
description:
"Fetch the latest OTP code from an email inbox. Use this when you need to enter a verification code that was sent via email.",
schema: z.object({
email: z.string().describe("Email address to check"),
subject_contains: z.string().optional().describe("Filter by subject line"),
}),
async execute(args, ctx) {
// Connect to email service
const inbox = await connectToInbox(args.email);
// Find latest OTP email
const email = await inbox.findLatest({
subject: args.subject_contains,
});
if (!email) {
return { success: false, message: "No OTP email found" };
}
// Extract code using regex
const match = email.body.match(/\b(\d{6})\b/);
if (!match) {
return { success: false, message: "Could not extract OTP code" };
}
const code = match[1];
ctx.variableStore.set("otp_code", code);
return { success: true, message: `Found OTP: ${code}` };
},
});
// Usage
await agent.act(page, "Get the OTP from test@example.com");
await agent.act(page, "Enter {{ otp_code }} in the verification field");Example: API Call
agent.registerAction({
name: "reset_test_user",
description: "Reset a test user to initial state via API. Use before running tests that require a clean user.",
schema: z.object({
user_id: z.string().describe("User ID to reset"),
}),
async execute(args, ctx) {
const response = await fetch(`https://api.example.com/users/${args.user_id}/reset`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
});
if (!response.ok) {
return { success: false, message: `API error: ${response.status}` };
}
return { success: true, message: "User reset successfully" };
},
});Example: Generate Test Data
agent.registerAction({
name: "generate_user_data",
description: "Generate unique user data for a registration test.",
schema: z.object({
email_domain: z.string().default("example.com").describe("Domain for the generated email address"),
}),
async execute(args, ctx) {
const suffix = crypto.randomUUID().slice(0, 8);
const userData = {
firstName: `Test-${suffix}`,
lastName: "User",
email: `test-${suffix}@${args.email_domain}`,
};
// Store each field as a variable
for (const [key, value] of Object.entries(userData)) {
ctx.variableStore.set(key, value);
}
return { success: true, message: `Generated data for ${userData.firstName} ${userData.lastName}` };
},
});
// Usage
await agent.act(page, "Generate user data");
await agent.run(page, "Fill the registration form with the generated data");Best Practices
Naming
Use snake_case for action names:
get_otp_from_emailreset_test_usergenerate_random_data
Descriptions
Write descriptions that explain when to use the action:
// Good - explains when to use
description: "Fetch the latest OTP code from an email inbox. Use this when you need to enter a verification code that was sent via email.";
// Bad - just describes what it does
description: "Gets an OTP code from email";Error Handling
Return meaningful error messages:
async execute(args, ctx) {
try {
await riskyOperation(args);
return { success: true, message: 'Operation completed' };
} catch (error: unknown) {
return {
success: false,
message: `Operation failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
}Variable Naming
Use consistent, descriptive variable names:
ctx.variableStore.set("otp_code", code); // Clear
ctx.variableStore.set("user_email", email); // Clear
ctx.variableStore.set("x", value); // Unclear