TL;DR
The @mastra/browser-firecrawl package gives a Mastra agent a hosted browser for pages behind a login. It keeps the same AgentBrowser tools and refs, gives each thread its own session, uses named profiles to stay signed in, and needs no Chromium on your machine.
Search and scrape work when the page is public. The vendor portal your ops team logs into every morning, or the report that only exists after you pick a date range and click Export, needs something that can type and click, like a browser agent. Open the inventory page of Swag Labs, Sauce Labs' public demo store, without logging in and you get this:
Epic sadface: You can only access '/inventory.html' when you are logged in.The inventory is there, behind a form, and nothing comes back until something has typed, clicked, and waited. Mastra agents have a browser field for that job. FirecrawlBrowser, from @mastra/browser-firecrawl, runs the browser in a Firecrawl Interact session instead of a Chromium install on your machine, so there is nothing local to keep alive.
Swag Labs stands in for whatever internal tool you need the agent to use.
How to add a browser to a Mastra agent
A browser attaches to a Mastra agent the way tools and memory do:
// src/mastra/agents/portal-agent.ts
import { Agent } from "@mastra/core/agent";
import { Memory } from "@mastra/memory";
import { FirecrawlBrowser } from "@mastra/browser-firecrawl";
const browser = new FirecrawlBrowser({
apiKey: process.env.FIRECRAWL_API_KEY,
});
export const portalAgent = new Agent({
id: "portal-agent",
name: "Portal Agent",
model: "openai/gpt-5.6-sol",
browser,
memory: new Memory(),
defaultOptions: { maxSteps: 40 },
instructions: `You operate a web portal on the user's behalf.
Always call browser_snapshot before acting so you have current element refs.
Use the refs (@e1, @e2, ...) to type and click.
After every action, snapshot again and confirm the page changed the way you expected.
Never guess a ref from a previous page.
Snapshots list interactive elements only. Pass interactiveOnly: false when you need to read page text.`,
});The agent now has the 16 browser tools AgentBrowser exposes by default: browser_goto, browser_snapshot, browser_click, browser_type, and the rest. FirecrawlBrowser extends AgentBrowser, so the model drives the page the same way. The page itself runs remotely, in a session Mastra requests from the Firecrawl API and drives over CDP.
Memory holds the conversation, so "now check out" on turn two resolves against turn one. The browser session holds the cart. Both key on the thread you pass in memory: { thread, resource }, so they line up without extra wiring.
Mastra stops an agent after five steps by default, and the login-to-checkout run below took around twenty-five because every action gets its own snapshot. Setting maxSteps on the agent means callers don't have to pass it.
How the agent logs in and clicks: snapshots and refs
The prompt:
Log in to https://www.saucedemo.com with the username
standard_userand passwordsecret_sauce. Add the Sauce Labs Backpack to the cart, check out as Jane Doe, postal code 94105, and tell me the order confirmation message.
Those are the demo logins Sauce Labs prints on its own login page. For a real portal, keep credentials out of the prompt and use a saved profile instead.
The model's first move is browser_goto, then browser_snapshot. Instead of HTML, it gets the interactive elements of the page with refs. On the login page that is the whole snapshot:
- textbox "Username" @e1
- textbox "Password" @e2
- button "Login" @e3The model calls browser_type on @e1 and @e2, browser_click on @e3, and snapshots again. Now the tree is the inventory page and the refs are new, because the page is new. Every product there has its own "Add to cart" button, so the model picks the one that follows the Backpack row. From there it's the same loop through cart, checkout form, Continue and Finish.
A ref is only valid for the snapshot that produced it. If the agent gets bounced from /inventory.html back to the login page, that page has a "Dismiss error" button at @e3 and Login moves to @e4. That is why the instructions tell the model never to guess a ref from a previous page.
On the confirmation page the default snapshot lists the buttons and footer links and leaves out the message, because the message is plain text. Following the last line of its instructions, the model snapshots again with interactiveOnly: false and gets the line the user asked for:
- heading "Thank you for your order!" @e3 [level=2]
- text: Your order has been dispatched, and will arrive just as fast as the pony can get there!The agent returns that sentence without any hand-written selectors or page-specific code.
One browser session per thread, so users stay separate
FirecrawlBrowser defaults to scope: 'thread', the same option AgentBrowser has:
- The first time a Mastra thread touches a browser tool,
FirecrawlBrowsercreates a Firecrawl browser session for that thread. - Later browser calls in that thread reuse the session while it is alive, so the login from turn one still holds on turn three.
- When the thread's browser closes, the remote session is deleted.
The thread is whatever you pass at the call site:
await portalAgent.generate(prompt, {
memory: { thread: "ops-alice", resource: "alice" },
});Two users running the portal agent under different thread ids get two isolated browsers with two separate logins, and neither can see the other's cart. Leave out the thread id and every caller shares one default session and one login. If you want one browser for everything on purpose, pass scope: 'shared' and Mastra creates one session at launch and shares it until it closes.
Firecrawl closes a session after ttl seconds of total lifetime (default 600, max 3600) or activityTtl seconds idle (default 300). A thread that comes back after lunch gets a fresh session with no cookies, and the agent is back at the login form.
How to keep a Mastra agent logged in between runs
Threads keep users apart within a run. To stay logged in across runs, use a named Firecrawl profile, passed under the firecrawl key:
const browser = new FirecrawlBrowser({
firecrawl: {
profile: { name: "swag-labs-ops", saveChanges: true },
ttl: 600,
activityTtl: 120,
},
});saveChanges: true writes cookies and login state back to the profile when the session closes. The next session opened with swag-labs-ops starts already logged in, so the agent can go straight to /inventory.html. Firecrawl writes state when the session closes, so end the login run by closing the browser, either the agent calling browser_close or your code calling browser.closeThreadSession(threadId).
Two details before you ship this:
- The nested
firecrawl.profileis not the top-levelprofile. The top-level one comes fromAgentBrowserand points at a local Playwright user-data directory. The nested one is stored on Firecrawl's side, and it's the one you want for a hosted browser. - Only one session can save to a profile at a time. A second session opening the same profile with
saveChanges: trueis refused with a 409:Browser operation conflicts with the current session or profile state.Withscope: 'thread'and several threads in flight, that will bite. Log in once withsaveChanges: true. Run normal traffic withsaveChanges: false. Every thread reads the saved login and none of them fight over the write.
// After a one-time login run has saved the profile:
const browser = new FirecrawlBrowser({
firecrawl: {
profile: { name: "swag-labs-ops", saveChanges: false },
},
});Every thread that opens swag-labs-ops is signed in as the same account, which is what you want for a shared ops login. If each user has their own account on the portal, give each user their own profile, for example portal-${userId}, and log each one in once the same way.
Profiles also keep credentials out of the prompt. A person or a setup script types the password once, into the session that saves the profile, and the agent only ever opens the profile, so the password never reaches the model, the traces, or memory. The content of the logged-in pages the agent reads still goes to the model and the traces, like any other tool output.
How to install FirecrawlBrowser
The Firecrawl team built the provider and it lives in the Mastra monorepo. In a new or existing Mastra project:
npm install @mastra/core @mastra/memory @mastra/libsql @mastra/browser-firecrawl agent-browser@0.19.0Set FIRECRAWL_API_KEY, plus OPENAI_API_KEY for the openai/... model string above (or the key for whichever provider you pick). Memory needs a storage provider on your Mastra instance. The create-mastra scaffold already has one; otherwise register the agent with LibSQL:
// src/mastra/index.ts
import { Mastra } from "@mastra/core/mastra";
import { LibSQLStore } from "@mastra/libsql";
import { portalAgent } from "./agents/portal-agent";
export const mastra = new Mastra({
agents: { portalAgent },
storage: new LibSQLStore({ id: "mastra-storage", url: "file:./mastra.db" }),
});Then run the prompt from earlier, and close the thread's browser when you're done so the Firecrawl session doesn't sit open until its TTL:
// run.ts
import { mastra } from "./src/mastra";
const agent = mastra.getAgent("portalAgent");
const thread = "ops-alice";
try {
const result = await agent.generate(
"Log in to https://www.saucedemo.com with the username `standard_user` and password `secret_sauce`. Add the Sauce Labs Backpack to the cart, check out as Jane Doe, postal code 94105, and tell me the order confirmation message.",
{ memory: { thread, resource: "alice" } },
);
console.log(result.text);
} finally {
await agent.browser?.closeThreadSession(thread);
}Send one request at a time per thread. The same Firecrawl key covers Search and Scrape if the agent needs public pages too.
The FirecrawlBrowser reference has every option, the browser overview covers providers and screencast, and the Firecrawl browser docs cover sessions and profiles. For the short version of the setup, see the Firecrawl + Mastra integration page.
Frequently Asked Questions
What is FirecrawlBrowser?
FirecrawlBrowser is a Mastra browser provider from the @mastra/browser-firecrawl package. It extends Mastra's AgentBrowser, so the agent gets the same browser tools and element refs, but the browser runs in a Firecrawl session instead of a local Chromium.
When should a Mastra agent use a browser instead of Scrape?
Use Scrape when the page is public and the content loads on its own. Use a browser when the content sits behind a login, a form, or a few clicks, like a vendor portal or a report you have to export.
How do I keep the agent logged in between runs?
Pass a Firecrawl profile under the firecrawl option. Log in once with saveChanges set to true and close the browser so the state is saved. Run later sessions with saveChanges set to false, so every thread starts signed in and none of them fight over the write.
Do two users of the same agent share a browser?
Not if you pass a thread id. FirecrawlBrowser defaults to scope thread, so each Mastra thread gets its own Firecrawl session with its own cookies. Without a thread id, every caller shares one default session.
How long does a browser session stay open?
A session closes after ttl seconds of total lifetime (default 600, max 3600) or activityTtl seconds idle (default 300). After that the next call opens a fresh session, so use a saved profile if the agent needs to stay logged in.

