The @mastra/browser-firecrawl package gives a Mastra agent a hosted browser for pages behind a login. Same AgentBrowser tools and refs, one session per thread, named profiles that keep it signed in, and no Chromium on your machine.
Most of what gets written about agents and the web assumes the page is public and just sitting there. Search it, fetch it, read it. That covers a lot.
It doesn't cover 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. Open the inventory page of Swag Labs, Sauce Labs' public demo store, without logging in:
Epic sadface: You can only access '/inventory.html' when you are logged in.The content exists. It's on the other side of a form, and nothing comes back until something has typed, clicked, and waited. That's a browser job, and Mastra agents have a browser field for it. FirecrawlBrowser, from @mastra/browser-firecrawl, runs that browser in a Firecrawl Interact session instead of a Chromium install on your machine. The browser lives on Firecrawl's side. Same tools, same refs, nothing local to keep alive.
Swag Labs stands in for whatever internal tool you actually care about.
One field on the 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 difference is where the page runs. Mastra asks the Firecrawl API for a session, then talks to it over CDP.
Memory holds the conversation, so "now check out" on turn two resolves against turn one. The browser session holds the actual cart. Both key on the thread you pass in memory: { thread, resource }, so they line up without extra wiring.
maxSteps is the line people forget. Mastra stops an agent after five steps by default, and a login-to-checkout run takes around twenty-five, because every action gets its own snapshot. Set it on the agent so callers don't have to remember.
What the agent sees
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. The last section covers how.
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" @e3Three refs, three actions. browser_type on @e1 and @e2, browser_click on @e3, snapshot 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.
Refs hold within a snapshot, not across navigations, which is why the instructions say "never guess a ref from a previous page". Land on the same login page by getting bounced from /inventory.html and there is a "Dismiss error" button at @e3, with Login pushed to @e4. Same page, different refs.
On the confirmation page the default snapshot would show the buttons and footer links and skip the message, because the answer is text, not a control. That is what the last instruction is for. The model snapshots 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. No selectors written by hand, no page-specific code.
Sessions follow threads
One default in the config above is doing more than it looks like. FirecrawlBrowser uses 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. You didn't write any of that. Skip the thread id and every caller shares one default session, which is the failure this section exists to prevent. 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.
Sessions don't live forever. Firecrawl closes one 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.
Staying logged in between runs
Threads solve isolation inside a run. They don't solve tomorrow. Firecrawl browser sessions have named profiles for this. Pass one 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 },
},
});One profile is one login. 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.
This is also the answer to the credentials question. A person or a setup script types the password once, into the session that saves the profile. The agent only ever opens the profile, so nothing sensitive reaches the model, the traces, or memory.
Get started
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);
}Give each user their own thread id, and 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.

