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.