← All posts

The 2FA Wall Automation Can't Climb

I wrapped Loom's undocumented GraphQL into one CLI command — until a two-factor prompt stopped my headless browser cold and taught me to build an escape hatch.

  • automation
  • playwright
  • cli
  • graphql
  • scraping

I kept doing the same dumb thing every week: open Loom — the tool where I record screen-share walkthroughs — click into the newest recording, wait for the transcript to load, copy the text, paste it somewhere an agent could read it. Loom has no public API for this. So I wanted one command: ctl loom latest, run on my dev box, and the freshest transcript lands as flat text.

ctl is just my personal command-line tool — the little internal Swiss Army knife every developer accumulates. I gave it a new loom latest subcommand.

The trick to pulling data from a SaaS app that never gave you an API: don’t scrape the pretty webpage. Let the app’s own front-end tell you where the data lives. I opened Loom in a browser with the network tab up, watched the page load my videos, and there it was — an internal GraphQL call named recentUserVideos. GraphQL is just the private back door the website itself uses to fetch its data. If I could call that endpoint the way the browser does, I’d skip the whole UI.

The “the way the browser does” part is the catch. That endpoint only answers if you’re logged in. So I drove a real browser with Playwright — the tool that automates Chrome — using a saved, already-logged-in session I’d stashed in a secrets manager. The session cookie is like a coat-check ticket: hand it over, skip the line, you’re in. The transcript came back as sentence-level phrases, each with timestamps, which I flattened into one clean block of text.

First real snag: the account had well over a thousand videos. My first version paginated from the top with no limit and it crawled forever, burning time and requests on recordings from two years ago I didn’t care about. I added a --since window, defaulting to the last 30 days. Bound the crawl before it bounds you.

Second snag was the one I didn’t see coming. An expired session was fine — the automation logs back in cleanly. But one day Loom threw a two-factor prompt: enter the code we just texted you. My headless browser — running with no screen, no human — had nowhere to type it. The command just errored out. There is no clever bypass; a 2FA wall is a wall by design.

So I built an escape hatch: detect the prompt, and instead of failing, fall back to a --headed run — a real visible browser on a real display where I type the code once, which refreshes the saved session for the next month of headless runs.

Detecting the 2FA wall and falling back to headed give me the detail

The pattern is: try headless, catch the auth wall by looking for its selector, and re-invoke headed instead of dying.

const ctx = await browser.newContext({ storageState: SESSION_PATH });
const page = await ctx.newPage();
await page.goto("https://www.loom.com/looms/videos");

if (await page.locator('[data-testid="2fa-code-input"]').isVisible()) {
  if (!opts.headed) {
    console.error("2FA required — rerun with --headed to refresh the session.");
    process.exit(2);          // distinct exit code, not a generic crash
  }
  await page.waitForURL("**/looms/videos", { timeout: 120_000 }); // human types code
  await ctx.storageState({ path: SESSION_PATH }); // persist the refreshed session
}

// only now call the internal endpoint
const res = await page.request.post("/graphql", {
  data: { operationName: "recentUserVideos", variables: { limit: 25 }, query: RECENT_VIDEOS },
});

Exit code 2 lets a wrapping script tell “needs a human” apart from “actually broke.”

The reusable shape is small: cache the authenticated session, call the app’s own GraphQL, and put a date window on the pagination. But automation that logs in for you will eventually hit an auth wall that exists specifically to stop automation. Design the failure mode on purpose — a clean fallback and a distinct exit code — instead of pretending the login always succeeds.