Extensions
Upload a Manifest V3 Chrome extension once and load it into the sessions that need it.
Upload a Chrome extension once, then start sessions with it: it is loaded into the session’s browser before the session is handed to you. Use it for tools your pages or your agent need inside the browser. Extensions are on the Hobby plan and up.
Upload an extension
Send an unpacked Manifest V3 extension as a zip, at most 10 MB: zip the extension’s folder, with manifest.json at the root (or in the zip’s one top folder). Send it as the raw body (application/zip or application/octet-stream), or as multipart/form-data with the zip in a file field.
cd my-extension && zip -r ../my-extension.zip . && cd ..curl -X POST https://api.boxline.dev/v1/extensions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/zip" \ -H "Idempotency-Key: $(uuidgen)" \ --data-binary @my-extension.zipimport { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URL// A file path, or the zip's bytes (Uint8Array, Buffer, ArrayBuffer, Blob). An Idempotency-Key is sent for you.const ext = await bx.extensions.upload("./my-extension.zip");console.log(ext.id, ext.name, ext.version, ext.permissions);for await (const e of bx.extensions.list()) console.log(e.id, e.name, e.version);from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URL# A path, or the zip's bytes. An Idempotency-Key is sent for you.ext = bx.extensions.upload("my-extension.zip")print(ext["id"], ext["name"], ext["version"], ext["permissions"])for e in bx.extensions.list(): print(e["id"], e["name"], e["version"]){ "id": "ext_Q2x9L0aPz7NmK3Jb", "name": "My extension", "version": "1.2.0", "description": "…", "permissions": ["storage"], "hostPermissions": ["https://example.com/*"], "sizeBytes": 48213, "files": 12, "sha256": "…", "createdAt": "2026-09-30T10:00:00.000Z"}name comes from the manifest, from _locales when it uses __MSG_…__. GET /v1/extensions lists your extensions, newest first, and GET /v1/extensions/:id returns one. An Idempotency-Key makes a retried upload safe; uploads are compared by the bytes of the zip.
What is checked
An upload is refused with 400 invalid_extension, and a message saying why, when:
- it is not a zip (a
.crxis refused too: upload the zip of the extension’s folder), or it is damaged, encrypted, larger than 32 MB unpacked, or has more than 5,000 files; manifest.jsonis missing or not JSON, itsmanifest_versionis not 3, or itsname(1 to 75 characters) orversion(1 to 4 numbers with dots) is missing or malformed;- it asks for a refused permission (below), or its manifest has
nacl_modulesorplugins(native code); - a file is a native program or library (ELF, Mach-O or Windows executables,
.so,.dll,.dylib,.exe,.node,.nexe), a symbolic link, or has an unsafe path (absolute,.., backslashes, over 240 characters).
| Permission | Why it is refused |
|---|---|
nativeMessaging | It starts native programs on the machine. |
debugger | It opens a DevTools channel into every tab. |
proxy, vpnProvider | They take the browser off the session's own proxy, which ad blocking, session proxies and their accounting rely on. |
enterprise.* | Managed-device access, which a session has no use for. |
These apply to permissions and optional_permissions. Other permissions, and host permissions, are allowed; the extension’s permissions and hostPermissions show what it asked for.
Use it in a session
Start a session with extensions, a list of up to 10 extension ids. The session needs a browser.
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"extensions": ["ext_Q2x9L0aPz7NmK3Jb"]}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst s = await bx.sessions.create({ extensions: ["ext_Q2x9L0aPz7NmK3Jb"] }); // loaded before it is returnedconsole.log(s.data.extensions);from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLs = bx.sessions.create(extensions=["ext_Q2x9L0aPz7NmK3Jb"]) # loaded before it is returnedprint(s.extensions)- The extension is loaded before the session is returned, usually well under a second. The
lifecycleevent “extensions loaded” hasdata: { extensions: [{ id, name, sha256 }], ms }. - It runs in every tab, also in new browser contexts (Playwright’s
browser.newContext()). - After a resume, or when the platform replaces a lost machine, it is loaded again on the new machine. Its files live in the workspace under
.boxline/extensions/<id>/, and are installed fresh on each machine rather than carried along. - Extensions are set when the session starts. Updating a running session with
extensionsanswers400 not_updatable. An agent run takes them in itssession:session: { extensions: ["ext_…"] }. - An extension cannot drive the browser itself: the browser’s DevTools, which the platform, Playwright and Puppeteer use, refuse connections from pages and extensions.
| Status | When |
|---|---|
400 invalid_request | More than 10, a repeated id or a malformed id, or a session without a browser. |
400 not_updatable | extensions in an update of a running session. They are set when it starts. |
404 not_found | An id that is not in your project. |
402 feature_not_in_plan | Your plan does not include extensions. |
403 extension_denied | The platform does not allow this extension. |
Agent variables, credentials and extensions
Because an extension sees what is typed, an agent run with variables in a session with extensions is refused with 400 variables_with_extensions. If you trust the extensions with those values, send allowWithExtensions: true: the run then starts, and the session’s log gets a lifecycle warning “agent run with variables in a session with extensions”.
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": "Sign in to example.com with %email% and %password%", "sessionId": "'"$SESSION_ID"'", "variables": {"email": "ada@example.com", "password": {"value": "'"$SITE_PASSWORD"'", "origins": ["https://example.com"]}}, "allowWithExtensions": true }'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URL// Only when you trust the session's extensions with these values.const run = await bx.agent.run({ task: "Sign in to example.com with %email% and %password%", sessionId: "SESSION_ID", variables: { email: "ada@example.com", password: { value: process.env.SITE_PASSWORD!, origins: ["https://example.com"] }, }, allowWithExtensions: true, // without it: VariablesWithExtensionsError (400)});import osfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URL# Only when you trust the session's extensions with these values.run = bx.agent.run( "Sign in to example.com with %email% and %password%", session_id="SESSION_ID", variables={ "email": "ada@example.com", "password": {"value": os.environ["SITE_PASSWORD"], "origins": ["https://example.com"]}, }, allow_with_extensions=True, # without it: VariablesWithExtensionsError (400))Plain-English step variables and script env values carry the same risk in a session with extensions.
So do credentials: an agent run, a step, a script or a type action with credentials in a session with extensions gets the same 400 variables_with_extensions unless it sends allowWithExtensions: true, and the credential a browser profile links is not offered there without it. Keep passwords in sessions without extensions when you can.
Delete an extension
DELETE /v1/extensions/:id removes it. Sessions already running with it keep it in their current browser; when such a session resumes or recovers, it is skipped, with a lifecycle warning “extension missing” and a data.reason. The same happens to every extension when your plan no longer includes them, and to one that no longer passes the checks.
curl -X DELETE https://api.boxline.dev/v1/extensions/$EXTENSION_ID \ -H "x-api-key: $BOXLINE_API_KEY"import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLawait bx.extensions.delete("EXTENSION_ID");from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLbx.extensions.delete("EXTENSION_ID")Plan, limits and errors
- Extensions need the plan feature
extensions: Hobby and up, not Free. See Plans and features. - Up to 100 extensions per project, 10 per session, and 30 uploads an hour per project.
- Uploads from the console must come from the platform’s own sites; API keys are not affected.
The SDKs raise InvalidExtensionError, PayloadTooLargeError, LimitReachedError, ExtensionDeniedError, CrossSiteRequestError (a console upload from another site) and VariablesWithExtensionsError for these.
| Status | Code | When |
|---|---|---|
| 400 | invalid_extension | The zip failed a check; the message says which (see below). |
| 402 | feature_not_in_plan | Your plan does not include extensions (Free). |
| 403 | extension_denied | The platform does not allow this extension (by its zip's hash or its name). |
| 409 | limit_reached | Your project already keeps 100 extensions. Delete one first. |
| 413 | payload_too_large | The zip is larger than 10 MB. |
| 429 | rate_limited | More than 30 uploads an hour for your project. |
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.