Skip to content

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

typescript
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

typescript
interface ICustomAction<TSchema extends z.ZodObject> {
  name: string;
  description: string;
  schema: TSchema;
  execute: (args: z.infer<TSchema>, ctx: ActionExecutionContext) => Promise<CustomActionResult>;
}

Required Fields

FieldTypeDescription
namestringUnique action name (snake_case recommended)
descriptionstringDescription for the agent - explains when to use this action
schemaZodObjectZod schema defining parameters
executefunctionAsync 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.

typescript
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:

typescript
interface ActionExecutionContext {
  page: Page; // Playwright page instance
  variableStore: VariableStore; // Variable store for reading/writing
}

Accessing the Page

typescript
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

typescript
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:

typescript
interface CustomActionResult {
  success: boolean;
  message?: string; // Optional status message
}

Success

typescript
return { success: true, message: "Email sent successfully" };

Failure

typescript
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

typescript
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

typescript
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

typescript
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_email
  • reset_test_user
  • generate_random_data

Descriptions

Write descriptions that explain when to use the action:

typescript
// 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:

typescript
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:

typescript
ctx.variableStore.set("otp_code", code); // Clear
ctx.variableStore.set("user_email", email); // Clear
ctx.variableStore.set("x", value); // Unclear

Released under the MIT License.