If you have ever wired an agent to a site without an official API, you have probably reached for Playwright or Puppeteer first. It works in the demo. Then production traffic starts, Chromium eats memory, modal dialogs block flows, a CSS selector changes, and your “API” starts failing because somebody moved a button.
Use the browser to learn the protocol, not to run the workflow
Most modern web apps are API clients. The frontend renders forms and tables, but the useful work usually happens through HTTP calls to private endpoints.
For example, submitting a shipment lookup form might produce a request like this in DevTools:
POST https://carrier.example.com/api/shipments/track
content-type: application/json
x-csrf-token: 4ddf9...
cookie: session=eyJ...
{
"trackingNumber": "1Z999AA10123456784"
}
If your automation clicks through the UI every time, your runtime path includes page load, JavaScript execution, layout, selectors, screenshots, retries, and whatever experiments the frontend team shipped that week.
If you call the underlying request directly, your runtime path becomes a normal HTTP integration:
import fetch from "node-fetch";
async function trackShipment({ trackingNumber, sessionCookie, csrfToken }) {
const res = await fetch("https://carrier.example.com/api/shipments/track", {
method: "POST",
headers: {
"content-type": "application/json",
"x-csrf-token": csrfToken,
"cookie": `session=${sessionCookie}`,
"user-agent": "Mozilla/5.0"
},
body: JSON.stringify({ trackingNumber })
});
if (!res.ok) {
const body = await res.text();
throw new Error(`tracking request failed: ${res.status} ${body}`);
}
return res.json();
}
That is easier to queue, retry, observe, and test than a headless browser. It is also much cheaper under load because you are not starting a browser process per action.
The tradeoff is that you now own an undocumented API integration.
What actually breaks
Network-level automation does not remove fragility. It moves the fragility to a layer developers can inspect.
Common failure modes look like this:
401 Unauthorized
Your session expired, the site rotated auth cookies, or login now requires a fresh 2FA challenge.
403 Forbidden
The request is missing a CSRF token, origin header, device fingerprint, or some other value the frontend normally supplies.
422 Unprocessable Entity
The endpoint still exists, but the frontend changed the payload shape. Maybe trackingNumber became tracking_number, or the request now needs a hidden accountId fetched from an earlier call.
200 OK with an empty result
This one is worse. Your code did not fail loudly, but the dependency chain changed and you are sending a syntactically valid request with the wrong context.
This is why the interesting work is not “copy request as cURL.” The interesting work is discovering the dependency graph behind the action.
A form submit often depends on earlier calls:
GET /login -> sets session cookie
GET /dashboard -> returns account id
GET /api/config -> returns region and feature flags
GET /api/csrf -> returns csrf token
POST /api/shipments/track -> performs the action
If you replay only the final request, it works until one of those upstream values expires or changes.
Capture the dependency chain explicitly
A practical network-level integration should model each upstream dependency as code, not as a magic blob copied from DevTools.
async function createCarrierClient(credentials) {
const session = await login(credentials);
const account = await getAccount(session);
const config = await getConfig(session);
const csrfToken = await getCsrfToken(session);
return {
async track(trackingNumber) {
return trackShipment({
trackingNumber,
sessionCookie: session.cookie,
csrfToken,
accountId: account.id,
region: config.region
});
}
};
}
This structure gives you places to add retries, cache short-lived values, refresh auth, and log which dependency failed.
It also gives you better tests. You can record fixtures for each dependency and detect when the final request shape changes.
test("track payload matches carrier contract", () => {
const payload = buildTrackPayload({
trackingNumber: "1Z999AA10123456784",
accountId: "acct_123",
region: "us-east"
});
expect(payload).toEqual({
trackingNumber: "1Z999AA10123456784",
accountId: "acct_123",
region: "us-east"
});
});
For catalog-based integrations, Wire is one example of exposing these network-level workflows as stable action IDs, so application code does not have to store captured request chains directly.
When a browser is still the right tool
Do not turn every website into private HTTP calls by default.
A browser is often the better choice when:
- The workflow depends heavily on client-side crypto or WebAuthn
- The site streams state through WebSockets with complex client behavior
- The request format changes constantly and volume is low
- You need visual confirmation, screenshots, or PDF rendering
- Legal or compliance rules require using the UI as presented
There is also a maintenance question. If you call undocumented endpoints, someone must watch for drift. That can be your team, a vendor, or a managed integration service, but it cannot be nobody.
Credential handling matters too. Passing raw passwords or session cookies through job payloads creates logs and queues full of secrets. Use a vault-backed identity reference instead of embedding credentials in each request. Wire handles this with named identities, which is the same pattern you would want in a homegrown system: the job receives an identity ID, and the execution layer resolves credentials server-side.
The decision rule I use
For production agent workflows, I usually split the problem like this:
- Use official APIs when they exist and cover the workflow
- Use direct HTTP replay when the web app has stable private endpoints and volume matters
- Use browser automation when the UI itself is part of the workflow or the protocol is too expensive to reverse engineer
- Avoid the integration if the site actively blocks automation and you do not have permission to proceed
The key point is that Playwright should not be your default runtime just because it is the fastest way to get a demo working.
Open DevTools, perform the workflow once, and inspect the Network tab. If the useful work is a small number of JSON requests, prototype the HTTP path before you build a browser farm. Then write down the dependency chain, failure modes, auth refresh behavior, and the owner for maintenance before calling it production.
Top comments (0)