Skip to content

How to author a browser-automation skill

Build a browser-driving skill with persistent sessions, robust authentication handoff, durable probes, and maintainable selectors.

Use this when: You are writing a skill that drives a web browser — to read, interact with, or extract data from a site that has no usable API. Prerequisites: playwright-cli installed; a skill directory under packs/<pack>/.apm/skills/<name>/ with a SKILL.md; familiarity with how to author a skill. Result: A skill whose browser sessions survive across invocations, whose auth is robust to SSO and device-certificate policies, and whose data layer decouples expensive browser scans from reasoning steps.


Playwright’s storageState() serialises cookies and localStorage to a JSON file — it works for cookie-based auth (most public-facing SaaS). It does not work when:

  • The site uses Microsoft Entra SSO with Conditional Access (device compliance is verified against a certificate stored in the OS keychain — it cannot be exported to JSON).
  • Auth state lives in the browser’s encrypted credential store rather than plain cookies or localStorage.
  • You need the signed system Chrome binary to access a macOS keychain entry (bundled Playwright Chromium is unsigned and cannot reach it).

In those cases, use a persistent profile directory instead.

MethodWhen to use
storageStateCookie/token auth; CI-friendly; fully headless
Persistent profileEntra PRT, Conditional Access, encrypted credential store, keychain-bound auth

The first-run pattern: open the system Chrome browser headed, let the user log in manually, then close. The profile directory (including PRT, cookies, device-compliance state) persists to disk. Every subsequent run opens the same profile headless.

Why system Chrome: Only /Applications/Google Chrome.app/Contents/MacOS/Google Chrome is signed with the identity that macOS Keychain trusts for credential-store access. Playwright’s bundled Chromium is unsigned.

import { chromium } from 'playwright';
import * as path from 'path';
import * as os from 'os';
const PROFILE_DIR = path.join(os.homedir(), '.agent-commander', '<pack>', 'chrome-profile');
const CHROME_PATHS = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
];
export function findSystemChrome(): string {
for (const p of CHROME_PATHS) {
if (require('fs').existsSync(p)) return p;
}
throw new Error('Google Chrome not found. Install it from https://www.google.com/chrome/');
}
// First-run: open headed for the user to sign in
export async function authenticateInteractive(): Promise<void> {
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
executablePath: findSystemChrome(),
headless: false,
args: ['--no-first-run', '--no-default-browser-check'],
});
const page = await context.newPage();
await page.goto('https://example.yourorg.com/');
// Prompt: wait for the user to complete login
const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout });
await new Promise<void>(resolve => readline.question(
'Log in, wait until the main screen is fully loaded, then press Enter…\n', () => { readline.close(); resolve(); }
));
await context.close();
}
// Subsequent runs: reuse profile
export async function playwrightRun<T>(
fn: (page: import('playwright').Page) => Promise<T>,
opts: { headed?: boolean } = {}
): Promise<T> {
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
executablePath: findSystemChrome(),
headless: !opts.headed,
args: ['--no-first-run'],
});
const page = await context.newPage();
try { return await fn(page); }
finally { await context.close(); }
}

Profile directory convention: always ~/.agent-commander/<pack>/chrome-profile/. Never inside the repo.

Before performing work, verify the session is still valid. On a page that requires auth, a failed session redirects to a login URL:

export async function checkSession(): Promise<'ok' | 'needs-login'> {
return playwrightRun(async page => {
await page.goto('https://example.yourorg.com/inbox', { waitUntil: 'domcontentloaded' });
try {
await page.waitForSelector('[data-app-section="main"]', { timeout: 8000 });
return 'ok';
} catch {
const url = page.url();
return url.includes('login') || url.includes('microsoftonline') ? 'needs-login' : 'ok';
}
});
}

Include a session check step in the skill body. On needs-login, run the office365-setup skill (or your pack’s equivalent setup skill) rather than silently failing.

Many internal portals issue a Bearer JWT on page load that you can capture to call their REST API directly — without requiring an OAuth app registration.

Critical: register the handler before page.goto(). The token arrives in the first authenticated request on page load, so a handler installed after goto will miss it.

export function captureToken(page: import('playwright').Page, hostname: string): Promise<string> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Token capture timeout (${hostname})`)), 20000);
const handler = (req: import('playwright').Request): void => {
const auth = req.headers()['authorization'] ?? '';
if (auth.startsWith('Bearer ') && req.url().includes(hostname)) {
clearTimeout(timer);
page.off('requestfinished', handler);
resolve(auth.slice(7));
}
};
page.on('requestfinished', handler);
});
}
// Usage:
const tokenPromise = captureToken(page, 'example.yourorg.com');
await page.goto('https://example.yourorg.com/inbox');
const token = await tokenPromise;

Vendor caveat — Teams: Teams uses a skypetoken_asm cookie on .asm.skype.com rather than a standard Authorization: Bearer header. The cookie is set asynchronously after page load; use polling with a deadline:

async function resolveTeamsSkypeToken(page: import('playwright').Page, timeoutMs = 10000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// do NOT filter by URL — the cookie domain is .asm.skype.com, not the Teams host
const cookies = await page.context().cookies();
const token = cookies.find(c => c.name === 'skypetoken_asm')?.value;
if (token) return token;
await page.waitForTimeout(500);
}
throw new Error('Teams skypetoken_asm not found within timeout');
}

The playwright-cli tool has two interaction modes. Pick based on what you need to do.

CLI commands — for single actions where one command produces one result:

Terminal window
playwright-cli -s=<session> open "https://…"
playwright-cli -s=<session> snapshot # returns ARIA accessibility tree with element refs
playwright-cli -s=<session> click "e42" # ref from snapshot — valid until next page change
playwright-cli -s=<session> fill "getByLabel('Search')" "query" # locator — stable across page changes
playwright-cli -s=<session> close

Use refs (e42) when you want to act on exactly the element the snapshot showed. Use locators (getByLabel(…), getByRole(…)) when you want a selector that survives navigation and re-renders.

run-code — for loops, conditional logic, or structured data extraction:

Terminal window
playwright-cli --raw -s=<session> run-code "async page => {
const items = await page.locator('[role=\"option\"]').evaluateAll(opts =>
opts.slice(0, 25).map(o => ({ id: o.id, label: o.getAttribute('aria-label') || '' }))
);
return JSON.stringify(items);
}"

--raw returns only the return value of the async function — no metadata wrapper. Use it whenever the skill needs to parse the output as JSON.

Rule of thumb: if you’d write a for loop to drive the browser, use run-code. If you’d write a single line, use a CLI command.

Browser UIs change. Selectors that work today may not work after an app update. Keep a references/ui-patterns.md file in the skill directory and treat it as a living document:

## <App name> UI selectors
Last confirmed: YYYY-MM-DD against <version/build if known>
| Selector | Status | Notes |
|----------|--------|-------|
| `[role="listbox"]` | ✓ | Inbox message list |
| `[aria-label="Reply"]` | ✓ | Reply button in thread view |
| `[data-app-section="compose"]` | ✗ | Not present in current build |
| `[data-testid="send-btn"]` | [?] | Seen in dev builds; unconfirmed in prod |
## Selector update log
| Date | Change |
|------|--------|
| YYYY-MM-DD | Initial live probe (47/62 confirmed). Core inbox/toolbar confirmed. |
| YYYY-MM-DD | Post-update probe (55/62). Compose selectors changed — updated above. |

Status annotations:

  • — confirmed working in a recent live probe
  • — not present in the current build (may be org-specific or version-gated)
  • [?] — seen in some environments but not confirmed in the canonical environment

Run a live probe (a quick snapshot pass through the main surfaces) whenever the app has a major update. Document the ceiling: 55/62 — practical ceiling; remaining 7 failures are org-build-absent.

Browser scans are expensive — they drive a browser, navigate pages, and extract data. Decouple the scan from the reasoning step by writing scan results to timestamped JSON files:

~/.agent-commander/<pack>/probes/
<timestamp>-senders.json
<timestamp>-threads.json
<timestamp>-queue.json

The skill checks probe freshness before deciding whether to re-scan:

Terminal window
LATEST=$(ls -t ~/.agent-commander/<pack>/probes/*-threads.json 2>/dev/null | head -1)
if [ -z "$LATEST" ]; then echo "no-probe"; exit 0; fi
AGE=$(( ($(date +%s) - $(stat -f %m "$LATEST")) / 3600 ))
[ "$AGE" -ge 4 ] && echo "stale" || echo "fresh"

Rules:

  • One scan writes one file with a timestamp prefix — never overwrite the previous file.
  • The skill reads the most recent file that meets the freshness threshold.
  • The agent reasons from the structured JSON, never from raw HTML.
  • Probe files live outside the repo (~/.agent-commander/…), never inside it.

All user-specific data lives outside the repo:

~/.agent-commander/<pack>/
config.toml # user config (API base URLs, display prefs)
chrome-profile/ # persistent browser profile
probes/ # timestamped scan outputs

Scripts resolve these paths from os.homedir() or os.homedir() equivalents — never from __dirname or a repo-relative path. This keeps the repo clean and the data portable.