Docs / Automation Plugins / Build a Plugin

Build a Plugin

Build a production-ready provider plugin from manifest to tests.

Developer Preview: Use this as the build path after the quickstart. Detailed type shapes live in Plugin Reference.

Reading path

For a lead skimming the work:

  1. Overview for the model.
  2. Plugin Quickstart for the smallest working plugin.
  3. This page for production shape and effort.
  4. Reference for exact types.

Runtime contract

  • Plugin code runs in an Atomical-managed runtime.
  • Do not assume local filesystem access or a long-lived process.
  • Network access uses declared http.egressDomains.
  • Browser access uses Atomical’s browser abstraction, not a raw driver, protocol connection, or hosting vendor API.

Default implementation order

Do not build a CUA-heavy plugin by default. Use the browser to authenticate the agent, then drive provider work through the provider’s own web APIs from that authenticated session.

  1. Use http.fetch for public provider APIs that need no browser session.
  2. Use browser.sessionFetch for authenticated provider APIs. This is the normal path for read and write skills after setup.
  3. Use browser.act only for UI-only steps: login, OTP entry, captcha, consent screens, or click-throughs with no stable API.
  4. Use browser.navigate to put the managed profile on the page needed for login, recovery, or API discovery.

If you find yourself writing a skill as a chain of browser.act("click ...") calls, stop and look for the provider request behind the UI. See Provider API Discovery.

Package shape

atomic-plugin-provider/
|-- package.json
|-- src/
|   |-- index.ts
|   |-- manifest.ts
|   |-- setup.ts
|   |-- recover.ts
|   `-- skills/
|       |-- check-order.ts
|       `-- submit-order.ts
`-- test/
    `-- plugin.test.ts

src/index.ts exports the public surface:

export { manifest } from './manifest';
export { setup } from './setup';
export { recover } from './recover';
export { checkOrder } from './skills/check-order';
export { submitOrder } from './skills/submit-order';

Manifest tour

export const manifest = {
  id: 'sample-market',
  name: 'Sample Market',
  version: '0.1.0',
  runtimeRange: '^0.1',
  capabilities: {
    browser: true,
    otp: { email: true, sms: true },
    resources: ['payment_method'],
    state: true,
    logs: true,
    http: {
      egressDomains: ['sample.example', 'api.sample.example'],
    },
  },
  email: {
    allowedSenderDomains: ['sample.example', 'mail.sample.example'],
  },
  setup: { required: true },
  recover: { enabled: true },
  skills: [
    {
      name: 'check_order',
      description: 'Check the current status of an order.',
      mutating: false,
      timeoutMs: 30_000,
      maxConcurrency: 4,
      inputSchema: {
        type: 'object',
        properties: {
          orderRef: { type: 'string', minLength: 3 },
        },
        required: ['orderRef'],
        additionalProperties: false,
      },
    },
    {
      name: 'submit_order',
      description: 'Submit a reviewed order.',
      mutating: true,
      requiresConfirmation: true,
      timeoutMs: 120_000,
      maxConcurrency: 1,
      inputSchema: {
        type: 'object',
        properties: {
          cartId: { type: 'string' },
          confirmationToken: { type: 'string' },
        },
        required: ['cartId', 'confirmationToken'],
        additionalProperties: false,
      },
    },
  ],
} as const;

Focus on four parts first:

PartWhy it matters
runtimeRangeCurrent preview range is ^0.1; update it when the runtime contract changes.
capabilitiesControls which runtime services the plugin can use.
email.allowedSenderDomainsRoutes provider email and OTPs into this plugin’s inbox.
skillsDefines each action’s input, timeout, concurrency, and mutation rules.

Declare only what the plugin uses. Calls outside http.egressDomains should fail at runtime.

1. Map the provider

Map these states before writing handlers:

  • fresh signup
  • existing account with an active session
  • existing account but logged out
  • OTP challenge during signup
  • OTP challenge during recovery
  • provider risk check or captcha loop
  • provider rate limit
  • account active and ready for skills

Write the checkpoint after each step the provider can observe. Those checkpoints become plugin state.

2. Implement setup

Setup should leave the account ready for skills.

export const setup: SetupHandler = async ({ agent, browser, otp, state, log }) => {
  const existing = await state.get<{ stage?: string }>('setup');
  if (existing?.stage === 'active') {
    return { status: 'active', data: existing };
  }

  await browser.navigate('https://sample.example/signup');
  await browser.act(`Create an account with email ${agent.email} and phone ${agent.phone}.`);

  const code = await otp.waitForEmailCode({
    service: 'sample-market',
    timeoutMs: 120_000,
  });

  await browser.act(`Enter verification code ${code}.`);
  const account = await provider.readAccount({ browser });

  await state.merge('setup', {
    stage: 'active',
    providerAccountId: account.id,
    updatedAt: new Date().toISOString(),
  });

  log.info('Prepared Sample Market account', { providerAccountId: account.id });

  return { status: 'active', data: { providerAccountId: account.id } };
};

If the provider says the account already exists and the agent is logged out, return failed with errorCode: 'login_required' and retryable: true.

3. Implement recover

Recovery restores the existing provider account. Starting signup again creates the wrong account.

export const recover: RecoverHandler = async ({ agent, browser, otp, state }) => {
  const checkpoint = await state.getWithVersion<{ providerAccountId?: string }>('setup');

  await browser.navigate('https://sample.example/login');
  await browser.act(`Log in with ${agent.email}.`);

  const code = await otp.waitForEmailCode({
    service: 'sample-market',
    timeoutMs: 120_000,
  });

  await browser.act(`Enter verification code ${code}.`);

  const saved = await state.compareAndSet('setup', checkpoint.version, {
    ...checkpoint.value,
    stage: 'active',
    recoveredAt: new Date().toISOString(),
  });

  if (!saved.ok) {
    return {
      status: 'failed',
      errorCode: 'provider_unavailable',
      message: 'Recovery checkpoint changed during login.',
      retryable: true,
    };
  }

  return {
    status: 'active',
    data: { providerAccountId: checkpoint.value?.providerAccountId },
  };
};

Common recovery triggers are expired sessions, repeated OTP challenges, profile re-verification, and interrupted setup after the provider account was created.

4. Add read-only skills first

export const checkOrder: SkillHandler<{ orderRef: string }> = async ({
  input,
  browser,
  state,
  log,
}) => {
  const setup = await state.get<{ stage?: string }>('setup');
  if (setup?.stage !== 'active') {
    return {
      ok: false,
      errorCode: 'setup_required',
      message: 'Setup has not completed for this provider.',
      retryable: false,
    };
  }

  const response = await browser.sessionFetch('https://api.sample.example/orders/status', {
    method: 'POST',
    json: { orderRef: input.orderRef },
  });

  if (response.status === 429) {
    return {
      ok: false,
      errorCode: 'rate_limited',
      message: 'The provider asked us to slow down.',
      retryable: true,
    };
  }

  log.info('Checked order status', { orderRef: input.orderRef });
  return { ok: true, data: await response.json() };
};

Read-only skills prove setup, session handling, API replay, and state before the plugin changes provider state.

5. Add mutating skills carefully

For a skill that changes provider state, set mutating: true, require confirmation when the action has cost or user-visible effects, and use context.idempotencyKey.

Before submitting, re-check the target, amount, address, or selected record. Use state.compareAndSet if more than one invocation can update the same checkpoint. After submitting, log one short event and put provider IDs in structured fields.

timeoutMs is a strict runtime budget. On timeout, Atomical stops the invocation and returns provider_unavailable with retryable: true, so write checkpoints before and after steps the provider can observe.

What browser.act does

browser.act is the slow path for visible UI work that has no stable provider request:

await browser.act('Open the account menu and choose Payment methods.');
await browser.act('Select the card ending in 4242 and save it as the default.');

The runtime resolves the instruction against the current page. Use it for login, OTP entry, captcha, and provider risk screens. For carts, checkout, account reads, order status, and other repeatable work, find the underlying request and call it with sessionFetch.

If the page is ambiguous, inspect it first:

const snapshot = await browser.snapshot({ includeText: true });
log.info('Inspected payment page', { url: snapshot.url });

In unit tests, mock act results and assert the instruction text. Browser integration tests can cover one or two real flows later.

Real-provider effort check

For DoorDash signup, the provider map looks like this:

  1. Start on the identity page.
  2. Choose sign up.
  3. Enter the Atomical agent email and phone.
  4. Wait for email or SMS OTP.
  5. Submit the OTP.
  6. Save the profile.
  7. Add the managed payment method if required.
  8. Mark setup active only after skills can run in the same browser profile.

Typical checkpoints:

{
  "setup": {
    "stage": "active",
    "signup": "otp_verified",
    "profile": "saved",
    "payment": "saved"
  }
}

After authentication, most DoorDash skills should run against provider GraphQL or web API calls through browser.sessionFetch. The browser gets the agent into a valid session; API replay does the repeated work. That is the difference between a week of fragile UI automation and a few stable checkpoints plus request-shape maintenance.

Test failure paths

Test fresh setup, setup after partial success, logged-out recovery, email OTP timeout, SMS OTP timeout if used, stale OTP after resend, provider API shape changes, read-only skills after setup, duplicate mutating calls, and state conflicts under concurrency.

Expect OTP drift, session expiry, provider API changes, risk checks, captcha loops, and rate limits. The plugin is ready when these return stable error codes such as otp_timeout, login_required, rate_limited, or provider_unavailable, preserve useful state, and do not create duplicate provider accounts.