Browser profiles
Keep cookies and local storage between sessions, so your agent stays signed in, and link a password credential so it can sign in again when the cookies expire.
A browser profile stores a browser’s cookies and local storage, so logins carry over from one session to the next. Without one, every session starts with a clean browser, and your agent has to sign in again.
The endpoints are /v1/profiles and the session option is profile. Your plan must include browser profiles (the profiles feature); otherwise you get 402 feature_not_in_plan.
How it works
- Create a profile for an account your agent works with.
- Start a session with
{ id, persist: true }and sign in, either from your automation or by hand in the live view. - Release the session. Its cookies and local storage are saved.
- Start later sessions with the same profile. They begin signed in, so your agent can go straight to work.
Create a profile
POST /v1/profiles with an optional name (up to 100 characters) returns the new profile. Store its id with the account or user it belongs to. New ids start with prof_; treat them as opaque.
curl -X POST https://api.boxline.dev/v1/profiles \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"name": "Work account"}'const profile = await bx.profiles.create({ name: "Work account" });console.log(profile.id);profile = bx.profiles.create(name="Work account")print(profile["id"]){ "id": "prof_hX3k9QvT1xYw2LmN", "name": "Work account", "sizeBytes": 0, "createdAt": "2026-10-03T10:00:00.000Z", "updatedAt": "2026-10-03T10:00:00.000Z", "inUseBy": null, "credential": null}Start a session with it
Pass it as profile when you create a session. With persist: true, the browser state is saved back when the session stops, so new logins are kept:
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"browser": true, "profile": {"id": "<profile-id>", "persist": true}}'const session = await bx.sessions.create({ profile: { id: profileId, persist: true } });session = bx.sessions.create(profile=profile_id, persist_profile=True)Leave persist off (the default) to start from the saved state without changing it, for example when several runs should all begin from the same login.
With persist: true, the session saves to the profile at every checkpoint (the first after about a minute, then every 5 minutes) as well as when it stops, so a crash loses at most the sign-ins since the last checkpoint. The session shows this as profilePersist: true, and its profileId is the profile’s id.
Agent runs take the same option in their session: session: { profile: { id, persist } } starts the run’s own session with the profile (see the run’s session). Tasks take it the same way, and every run of the task starts with it.
Save a sign-in from a running session
Signed in already, in a session that has no profile? POST /v1/profiles with fromSession saves that session’s cookies and the site storage of its open tabs, right now, into a new profile. Sign in first, for example by hand in the live view.
curl -X POST https://api.boxline.dev/v1/profiles \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"name": "Work account", "fromSession": "'"$SESSION_ID"'", "attach": true}'const profile = await bx.profiles.create({ name: "Work account", fromSession: sessionId, attach: true });console.log(profile.id, profile.sizeBytes); // later sessions start signed in with profile: { id: profile.id }profile = bx.profiles.create(name="Work account", from_session=session_id, attach=True)print(profile["id"], profile["sizeBytes"]) # later sessions start signed in with profile=profile["id"]- Without
attach, it is a copy and the session is not changed. Withattach: true, the session also saves to the new profile from now on, at its checkpoints and when it stops, as if it had started with{ id, persist: true }: itsprofileIdbecomes the new id and it holds the lock (inUseBy). A session that already has a profile gets409 conflictwithattach: true; sendattach: falsefor a copy. - The session needs a browser (
400) and must be running: one that has stopped gets409 session_not_running(resume it first). - At most 16 MB of saved state (
413 profile_too_large), 30 such saves a minute per project, on plans with browser profiles (402 feature_not_in_planotherwise).namedefaults to “Saved from session …”. - The session’s log gets a
lifecycleevent “sign-in saved” with the profile’s id, its size and whether it was attached.
With Playwright
The saved state is loaded into the browser’s default context. Use browser.contexts()[0] rather than creating a new one, or your pages will not see the profile’s cookies.
List, rename and delete
| Call | What it does | SDKs |
|---|---|---|
GET /v1/profiles | Your profiles, newest first, with total and next (see Pagination). | profiles.list() |
GET /v1/profiles/:id | One profile, with inUseBy: the session using it, or null. | profiles.get(id) |
PATCH /v1/profiles/:id | Renames it, links a password credential, or both: { "name": "…", "credential": "SHOP" } (at least one). | profiles.update(id, { name, credential }) |
DELETE /v1/profiles/:id | Deletes it. Fails with 409 while a running session uses it. | profiles.delete(id) |
sizeBytes is the size of the saved state, and updatedAt when it was last saved. credential is the name of the password credential the profile signs in with, or null.
Signing in again by itself
Cookies expire, and some sites ask for a password again. Link a password credential to the profile, and the AI can sign in again by itself: it types the user name, the password and, if the site uses two-factor authentication, the 2FA code as placeholders, and never sees them. The code can come from an authenticator key, or from your own system when the site emails or texts it (2FA codes and sign-in links). Password credentials are on the Hobby plan and up (plan feature loginDetails; 402 feature_not_in_plan on Free).
Link a credential
First save the website password as a credential, with its sites. Then PATCH /v1/profiles/:id with credential set to its name. credential: null unlinks it. Only a password can be linked (400 invalid_request for a secret), and an unknown name gets 404 credential_not_found.
# 1. The website password, with its sites (and its 2FA key, if the site has one):curl -X POST https://api.boxline.dev/v1/credentials \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "SHOP", "type": "password", "origins": ["https://shop.example.com"], "username": "ada@example.com", "password": "'"$SHOP_PASSWORD"'", "totpSecret": "'"$SHOP_2FA_KEY"'" }'# 2. Link it to the profile:curl -X PATCH https://api.boxline.dev/v1/profiles/$PROFILE_ID \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"credential": "SHOP"}'await bx.credentials.create({ name: "SHOP", type: "password", origins: ["https://shop.example.com"], // the one site they may be typed on username: "ada@example.com", password: process.env.SHOP_PASSWORD!, // from your own secret store totpSecret: process.env.SHOP_2FA_KEY, // the 2FA setup key, or an otpauth://totp/… link});const profile = await bx.profiles.update(profileId, { credential: "SHOP" });console.log(profile.credential); // SHOPimport osbx.credentials.create( "SHOP", "password", origins=["https://shop.example.com"], # the one site they may be typed on username="ada@example.com", password=os.environ["SHOP_PASSWORD"], # from your own secret store totp_secret=os.environ["SHOP_2FA_KEY"], # the 2FA setup key, or an otpauth://totp/… link)profile = bx.profiles.update(profile_id, credential="SHOP")print(profile["credential"]) # SHOPFrom now on, a session started with the profile, and every agent run, task run and plain-English step in such a session, gets that credential as if it were listed in its credentials. The same scope rules apply:
- The AI gets
%SHOP.username%,%SHOP.password%and%SHOP.otp%when the credential’s scope allows the AI ("agent"or"all"). With a code source of"push"or"url",%SHOP.otp%makes the run wait for the code your system sends, and it also gets%SHOP.link%, a sign-in link. - The shell of a session with a shell exports it (
$SHOP_USERNAME,$SHOP_PASSWORD, andboxline-otp SHOP) when the scope allows the shell. The session lists it incredentials. - Where the scope does not allow a use, the credential is left out, without an error.
- Scripts get it only when the
scripts/runcall lists it incredentials. See Scripts. - Sessions with extensions do not get it, unless the request sets
allowWithExtensions: true. An extension can read every typed value. See Credentials. - On a plan without password credentials, the link is refused and nothing is offered.
- Deleting the credential unlinks it from the profile.
Let the AI sign in
Start an agent run from the profile, and tell it where to type each part. The profile’s credential is added for you, so the run does not need to list it:
curl -X POST https://api.boxline.dev/v1/agent/runs \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "task": "Open https://shop.example.com. If it asks you to sign in, type %SHOP.username% into Email, %SHOP.password% into Password and %SHOP.otp% into the code field. Then download the latest invoice.", "session": {"profile": {"id": "'"$PROFILE_ID"'", "persist": true}} }'const { id } = await bx.agent.run({ task: "Open https://shop.example.com. If it asks you to sign in, type %SHOP.username% into Email, " + "%SHOP.password% into Password and %SHOP.otp% into the code field. Then download the latest invoice.", session: { profile: { id: profileId, persist: true } }, // the run's own session starts with it, and saves new cookies});const run = await bx.agent.wait(id);console.log(run.status, run.result);started = bx.agent.run( task=( "Open https://shop.example.com. If it asks you to sign in, type %SHOP.username% into Email, " "%SHOP.password% into Password and %SHOP.otp% into the code field. Then download the latest invoice." ), session={"profile": {"id": profile_id, "persist": True}}, # the run's own session starts with it, and saves new cookies)run = bx.agent.wait(started["id"])print(run["status"], run["result"])The rules for typing are the credential’s own: only on its sites, only into fields, never shown to the model. See Credentials.
Or sign in with one call
The login action signs a session in with the credential the profile links, so you need not name it. A short agent run in the session does the typing, and waits for a code or sign-in link if the credential takes them from your system. See Sign in in one call.
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/browser/actions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"actions": [{"action": "login"}]}'const session = await bx.sessions.create({ profile: { id: profileId, persist: true } });const page = await session.login(); // the profile's credentialconsole.log(page.url, page.title);session = bx.sessions.create(profile={"id": profile_id, "persist": True})page = session.login() # the profile's credentialprint(page["url"], page["title"])A tested example that does all of this, in Node and Python: Two-factor sign-in. To save a sign-in you make by hand, see Save a login. For a site that texts a code instead, see Hand over for a code.
Every link and every use goes into the credentials audit log. Linking is recorded on the credential as an update with changed: ["profiles"], the profile and whether it was linked or unlinked. Deleting a profile that links a credential records the unlink the same way (linked: false).
Limits
A project keeps at most maxProfiles profiles, holding at most maxProfileBytes together. Both are in the plan’s limits.
| Plan | Profiles | Saved state together |
|---|---|---|
| Free | 10 | 50 MB |
| Hobby | 100 | 1 GB |
| Startup | 500 | 5 GB |
| Scale | 2,000 | 20 GB |
POST /v1/profilespast either limit gets402 plan_limit, and nothing is created. Delete profiles you no longer need to make room.- One profile holds at most 16 MB. A save from a session that would grow its profile past that, or past the total, is skipped instead: the session never fails or stops for it, the profile keeps what it had, and the session’s log gets a
lifecyclewarning “the profile was not updated”. A save that does not make a profile larger always goes through. - Creating a profile from a session: 30 a minute per project.
- A
PATCHthat sendscredentialcounts as a credential write: 60 a minute per project, together with the changes to credentials themselves. Renaming a profile does not count. If the credential is deleted at the moment you link it, you get404 credential_not_found.
Use them safely
- Keep API keys secret, on your servers only.
- Use one profile per account, and name it so you know whose it is.
- Delete profiles you no longer need, and when someone’s access should end.
- Sign in by hand in the live view when you can, so the password never passes through your code. When the AI has to sign in, link a password credential limited to the site, and keep it at scope
"agent". Never put a password in the task text.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.