Credentials
Write-only, encrypted website passwords (with 2FA codes from an authenticator key or your own system) and secrets: the AI types them as placeholders on their own sites, one call signs a session in, and shells get them as environment variables, hidden in output and audited.
A credential is something you save once and then use by its name: a website password, or an API token. The AI types it as a placeholder such as %SHOP.password% without ever seeing it, and a session’s shell can get it as an environment variable such as $SHOP_PASSWORD.
Credentials are write-only. They are stored encrypted (AES-256-GCM, each field on its own, with a key used for nothing else), and no API call, log line or error ever returns a value. You can replace a value, but you cannot read it back.
Sites that send their 2FA code by email or SMS work too: your own system sends the code, and the AI types it without seeing it (2FA codes and sign-in links). One call, the login action, signs a session in (Sign in in one call).
Two types
| Password | Secret | |
|---|---|---|
| Holds | A user name, a password and, optionally, where its 2FA codes come from. | One value: an API token or a key. |
Sites (origins) | Required. A password is never typed on the wrong site. | Optional. Without them, the AI may type it anywhere. |
| The AI uses it as | %NAME.username%, %NAME.password%, %NAME.otp%, and %NAME.link% for a sign-in link | %NAME% |
| A shell gets | $NAME_USERNAME, $NAME_PASSWORD, and boxline-otp NAME for the 2FA code | $NAME |
| Lists show | The sites, the user name, and where its 2FA codes come from (codeSource, and hasTotp for an authenticator key). | preview: the last 4 characters of long values. |
| Plans | Hobby and up (the loginDetails feature). | Every plan. |
Everything else is the same for both: one name per project, one audit log, one limit, the same scopes, the same safety rules.
Save a credential
POST /v1/credentials stores one. Read the values from your own secret store or environment, never from code you commit. From a terminal, the CLI asks for them without showing what you type.
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": "ops@example.com", "password": "'"$SHOP_PASSWORD"'", "totpSecret": "'"$SHOP_2FA_KEY"'", "scope": "all" }'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst shop = await bx.credentials.create({ name: "SHOP", type: "password", origins: ["https://shop.example.com"], // the AI may type it only there username: "ops@example.com", password: process.env.SHOP_PASSWORD!, // from your own secret store totpSecret: process.env.SHOP_2FA_KEY, // optional: the 2FA setup key, or an otpauth://totp/… link (codeSource "totp") scope: "all", // the AI on the site, and $SHOP_USERNAME / $SHOP_PASSWORD in shells});console.log(shop.name, shop.username, shop.hasTotp); // SHOP ops@example.com trueimport osfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLshop = bx.credentials.create( "SHOP", "password", origins=["https://shop.example.com"], # the AI may type it only there username="ops@example.com", password=os.environ["SHOP_PASSWORD"], # from your own secret store totp_secret=os.environ["SHOP_2FA_KEY"], # optional: the 2FA setup key, or an otpauth://totp/… link (code_source "totp") scope="all", # the AI on the site, and $SHOP_USERNAME / $SHOP_PASSWORD in shells)print(shop["name"], shop["username"], shop["hasTotp"]) # SHOP ops@example.com True# Values are never arguments: type the password at the hidden prompt, or pipe it in.boxline credentials set SHOP --type password --username ops@example.com --origin https://shop.example.com# With a 2FA key: the password on the first line, the key on the second.printf '%s\n%s\n' "$SHOP_PASSWORD" "$SHOP_2FA_KEY" | boxline credentials set SHOP --type password \ --username ops@example.com --origin https://shop.example.com --2fa --scope all{ "name": "SHOP", "type": "password", "description": null, "origins": ["https://shop.example.com"], "username": "ops@example.com", "hasTotp": true, "codeSource": "totp", "codeUrl": null, "codeTimeoutSeconds": 300, "shell": false, "scope": "all", "createdAt": "2026-10-03T10:00:00.000Z", "updatedAt": "2026-10-03T10:00:00.000Z", "lastUsedAt": null}curl -X POST https://api.boxline.dev/v1/credentials \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{ "name": "GITHUB_TOKEN", "type": "secret", "value": "'"$GITHUB_TOKEN"'", "description": "CI token", "scope": "shell" }'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst token = await bx.credentials.create({ name: "GITHUB_TOKEN", type: "secret", value: process.env.GITHUB_TOKEN!, // from your own secret store description: "CI token", scope: "shell", // only as $GITHUB_TOKEN in shells, never the AI});console.log(token.name, token.scope, token.preview); // GITHUB_TOKEN shell ••••9f3aimport osfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLtoken = bx.credentials.create( "GITHUB_TOKEN", "secret", value=os.environ["GITHUB_TOKEN"], # from your own secret store description="CI token", scope="shell", # only as $GITHUB_TOKEN in shells, never the AI)print(token["name"], token["scope"], token["preview"]) # GITHUB_TOKEN shell ••••9f3a# A secret is the default type. Type the value at the hidden prompt, or pipe it in.printf %s "$GITHUB_TOKEN" | boxline credentials set GITHUB_TOKEN --scope shell --description "CI token"# Limit where the AI may type it:boxline credentials set SHOP_API_KEY --origin https://api.shop.example.com < key.txt{ "name": "GITHUB_TOKEN", "type": "secret", "description": "CI token", "origins": null, "preview": "••••9f3a", "shell": false, "scope": "shell", "createdAt": "2026-10-03T10:00:00.000Z", "updatedAt": "2026-10-03T10:00:00.000Z", "lastUsedAt": null}Every credential has these fields:
| Field | Default | Description |
|---|---|---|
name | required | An environment variable name in capitals: A to Z, digits and _, not starting with a digit, up to 64 characters. Not one the platform or bash sets (PATH, HOME, PWD, OLDPWD, SHLVL, IFS, ENV, SHELLOPTS, BASHOPTS, PS4, _, or a name starting with BOXLINE_, SANDBOXD_ or BASH_). One per project, whatever the type. It is also the placeholder and the shell variable name. |
type | required | "password" or "secret". It cannot change later: delete the credential and save it again. |
description | none | A note for you and your team, up to 500 characters. |
scope | "agent" | Where it may be used: "agent", "shell" or "all". See Where a credential works below. |
shell | false | true lets the AI put it into bash commands, which also lets sessions export it into their shell. |
A password also takes:
| Field | Default | Description |
|---|---|---|
origins | required | The sites where the AI may type it, such as ["https://shop.example.com"], or "https://*.example.com" for any subdomain. 1 to 20. |
username | required | 1 to 320 characters. It is shown in lists: it is not secret. |
password | required | 1 to 1,024 characters. Stored encrypted and never returned. |
codeSource | none | Where its 2FA codes come from: "totp" (an authenticator key), "push" (your system sends each code or sign-in link), "url" (the platform asks an address of yours), or null for no 2FA. A totpSecret on its own means "totp". See 2FA codes and sign-in links below. |
totpSecret | none | Only with codeSource "totp" (required then). The site's 2FA setup key (base32, any case, spaces allowed), or the otpauth://totp/… link from its QR code. Links may set the algorithm (SHA-1, SHA-256 or SHA-512), 6 to 8 digits and a period of 15 to 120 seconds; the defaults are SHA-1, 6 digits and 30 seconds. hotp links are refused. Stored encrypted and never returned. |
codeUrl | none | Only with codeSource "url" (required then). A public HTTPS address of yours that the platform asks for each code or link. Saving it answers with codeUrlSecret, shown once. |
codeTimeoutSeconds | 300 | How long a wait for a pushed or asked code or sign-in link lasts: 5 to 900 seconds. |
A password shows codeSource, codeUrl (null unless the source is "url") and codeTimeoutSeconds, and hasTotp is true when the source is "totp". It never shows the password, the 2FA key or the codeUrlSecret.
A secret takes:
| Field | Default | Description |
|---|---|---|
value | required | 1 to 8,000 characters, no NUL character. Stored encrypted and never returned. |
origins | any site | The sites where the AI may type it, such as ["https://api.example.com"]. 1 to 20. |
A password credential needs a plan with the loginDetails feature (Hobby and up): 402 feature_not_in_plan on Free. A credential never shows a password, a value or a 2FA key. A secret shows preview to help you tell two tokens apart: "••••1a2b", the last 4 characters, for values of 24 characters or more. It is null for shorter values and for secrets with origins.
Where a credential works
scope decides who may use a credential. The default keeps it away from every shell, where any program could read it.
| scope | Used by |
|---|---|
"agent" (default) | Only the AI, as placeholders in agent runs, plain-English steps, scripts' step() and the type action. Never in a shell. |
"shell" | Only the shell, as environment variables in sessions and commands that ask for it. Never the AI. |
"all" | Both. |
shell: true goes one step further for the AI: it may put the placeholders into bash commands, and sessions may export the credential into their shell too. Both types work in every scope.
Making a credential readable by shells later is a change that needs its values again: see List, change and delete.
The AI types it as a placeholder
Pass credentials: ["NAME"] to an agent run, a plain-English step, a script (for its step() calls) or a task (for every run, scheduled ones too), and write the placeholder where the value goes.
| Placeholder | For | Typed as |
|---|---|---|
%NAME% | A secret | Its value. |
%NAME.username% | A password | The user name. |
%NAME.password% | A password | The password. |
%NAME.otp% | A password with a code source | The 2FA code. With "totp" it is the current code (TOTP, RFC 6238), made at the moment it is typed. With "push" or "url" it is a fresh code the run waits for. |
%NAME.link% | A password with codeSource "push" or "url" | A sign-in link the site sends. The agent opens it with its browser_open_link tool and never sees it. Agent runs and the login action only. |
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/signin, type %SHOP.username% into Email, %SHOP.password% into Password and %SHOP.otp% into the code field, sign in, then download the latest invoice.", "credentials": ["SHOP"] }'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst { id } = await bx.agent.run({ task: "Open https://shop.example.com/signin, type %SHOP.username% into Email, %SHOP.password% into Password " + "and %SHOP.otp% into the code field, sign in, then download the latest invoice.", credentials: ["SHOP"],});const run = await bx.agent.wait(id);console.log(run.status, run.result); // the run shows the placeholders, never the valuesfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLstarted = bx.agent.run( task=( "Open https://shop.example.com/signin, type %SHOP.username% into Email, %SHOP.password% into Password " "and %SHOP.otp% into the code field, sign in, then download the latest invoice." ), credentials=["SHOP"],)run = bx.agent.wait(started["id"])print(run["status"], run["result"]) # the run shows the placeholders, never the valuesboxline run "Open https://shop.example.com/signin, type %SHOP.username% into Email, %SHOP.password% into Password and %SHOP.otp% into the code field, sign in, then download the latest invoice." --credential SHOP- Only on the credential’s sites. With
origins, a value is typed only when the field’s own frame is on one of those sites, checked right before it is written. On any other site the call fails with a message such as “%SHOP.password% can only be used on shop.example.com”. - Only into fields. The platform fills a value in where the model types text into a field or picks an option, and nowhere else: never into URLs, selectors or key presses. Into a bash command only with
shell: true. - Never shown to the model. The model is told the placeholders only. Everything the run keeps has the value replaced by the placeholder: page text, tool results, steps, the result and errors, and a 2FA code it caused to be made. The rules are the same as for agent variables; a variable with the same name wins.
- A credential with scope
"shell"gets400 credential_not_for_ai, and an unknown name404 credential_not_found. %SHOP.otp%works for every code source. When the site emails or texts the code, the run waits for the one your system sends: see 2FA codes and sign-in links.- A value typed into a field that is not a password field stays visible in the page. A later run or step in the same session that was not given the credential sees it there. Give such runs a session of their own (the default).
Tested examples, in Node and Python: Two-factor sign-in saves a password with a 2FA key and lets an agent run sign in with it. Hand over for a code signs in with a saved password and asks you for the code a site texts.
Type it yourself, without seeing it
You can also type a credential into a field from your own code, without the value passing through it. It is the type action with credential instead of text. For a password, name the part with field: username, password or otp.
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": "goto", "url": "https://shop.example.com/signin"}, {"action": "type", "credential": "SHOP", "field": "username", "selector": "#email"}, {"action": "type", "credential": "SHOP", "field": "password", "selector": "#password"}, {"action": "click", "selector": "button[type=submit]"} ]}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create();await session.goto("https://shop.example.com/signin");await session.typeCredential("SHOP", { field: "username", selector: "#email" });await session.typeCredential("SHOP", { field: "password", selector: "#password" });await session.click("button[type=submit]");from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create()session.goto("https://shop.example.com/signin")session.type_credential("SHOP", "username", "#email")session.type_credential("SHOP", "password", "#password")session.click("button[type=submit]")- A credential with
origins(every password) needs aselector, and goes only into a field whose own frame is on one of its sites. A secret withoutoriginsmay be typed where the focus is. - The reply never holds the value: its
textsays “Typed %SHOP.password% into #password”. The session is treated like one with an agent run while a value is typed and for 60 seconds after: no query strings, console text or recording frames are kept. - Afterwards the session hides the value (a step does the same).
elements,content,evaluateandtabsresults show%SHOP.password%where it is, also inside a nested result. A screenshot covers the fields that hold it. Errors, script output and shell command output in the session hide it too, and so does anextract, which also keeps it from the model. A 2FA code is hidden from then on, like a codeboxline-otpgave. - A refusal comes back as the action’s result (
ok: false) with acode, such ascredential_not_foundorcredential_not_for_ai, and the actions after it do not run. - The MCP server uses this action for its
browser_typetool, so an assistant can sign in without ever seeing a password.
2FA codes and sign-in links
A password can have 2FA. Its codeSource says where the codes come from.
| codeSource | The code comes from | Use it when |
|---|---|---|
"totp" | An authenticator key you save once (totpSecret). The platform works out the current code when it is typed. | The site offers an authenticator app. There is nothing to wait for. |
"push" | Your system. It sends each code or sign-in link to POST /v1/credentials/:name/codes. | The site emails or texts the code, and your system can read that message. |
"url" | Your endpoint, codeUrl. The platform asks it every 5 seconds while a run waits. | The same, and you would rather answer a request than call the API. |
null, or left out | Nowhere: the password has no 2FA. |
Whatever the source, the AI never sees a code. It types %SHOP.otp%, and the platform fills in the code. For a sign-in link, it opens %SHOP.link%, and the platform opens the link.
How a wait works
With "push" or "url", the platform does not make the code. It waits for yours. A wait begins when:
- the AI types
%SHOP.otp%in an agent run, a plain-English step or a script’sstep(); - your code runs the
typeaction withfield: "otp"; - the AI opens
%SHOP.link%(in an agent run, or in theloginaction); - a shell runs
boxline-otp SHOP.
Then, in this order:
- The platform checks the field’s site. It does this before it waits, so a code is never taken for a field it may not go into.
- It tells you. The run records a
codestep with the state"waiting", and the webhookcredential.code_neededgoes out, once per wait. Your system should now forward the site’s email or SMS. - It takes a fresh code or link. A fresh one is one delivered after the wait began. An older one is never used. Each is used once.
- It types the code, or opens the link. The step says
"received". If nothing arrives withincodeTimeoutSeconds, the step says"timeout"and the call fails withcredential_code_timeout(boxline-otpexits with 1).
{"type": "code", "state": "waiting", "credential": "SHOP", "kind": "code", "text": "Waiting up to 300 s for the 2FA code the site sent for SHOP", "at": "…"}{"type": "code", "state": "received", "credential": "SHOP", "kind": "code", "at": "…"}kind is "code" or "link". The steps also arrive live on the run’s event stream. They never hold the code or the link. See Agent runs for the other steps.
The webhook credential.code_needed has credential (the name), type ("code" or "link"), sessionId and runId (null for an action or a shell command). It never holds a code, a link or a value. Subscribe an endpoint to it, or to "*".
Send each code yourself: "push"
Save the password with codeSource: "push". When a run waits, read the site’s email or SMS and send what it holds.
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": "ops@example.com", "password": "'"$SHOP_PASSWORD"'", "codeSource": "push", "codeTimeoutSeconds": 300 }'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst shop = await bx.credentials.create({ name: "SHOP", type: "password", origins: ["https://shop.example.com"], username: "ops@example.com", password: process.env.SHOP_PASSWORD!, // from your own secret store codeSource: "push", // your system sends each code or sign-in link codeTimeoutSeconds: 300, // how long a run waits for one (5 to 900)});console.log(shop.codeSource, shop.codeTimeoutSeconds); // push 300import osfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLshop = bx.credentials.create( "SHOP", "password", origins=["https://shop.example.com"], username="ops@example.com", password=os.environ["SHOP_PASSWORD"], # from your own secret store code_source="push", # your system sends each code or sign-in link code_timeout_seconds=300, # how long a run waits for one (5 to 900))print(shop["codeSource"], shop["codeTimeoutSeconds"]) # push 300# The password is asked for at a hidden prompt.boxline credentials set SHOP --type password --username ops@example.com --origin https://shop.example.com \ --code-source push --code-timeout 300Then send each code, or sign-in link, to POST /v1/credentials/:name/codes while a run waits for it.
curl -X POST https://api.boxline.dev/v1/credentials/SHOP/codes \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"code": "'"$CODE"'"}'# A sign-in link instead of a code. It must be on one of the credential's sites.curl -X POST https://api.boxline.dev/v1/credentials/SHOP/codes \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"link": "'"$LINK"'"}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URL// The code the site just emailed or texted, read by your own code.const accepted = await bx.credentials.pushCode("SHOP", { code: "123456" });console.log(accepted.kind, accepted.expiresAt); // code 2026-10-03T10:12:00.000Z// A sign-in link instead of a code. It must be on one of the credential's sites.await bx.credentials.pushCode("SHOP", { link: "https://shop.example.com/magic?token=…" });from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URL# The code the site just emailed or texted, read by your own code.accepted = bx.credentials.push_code("SHOP", code="123456")print(accepted["kind"], accepted["expiresAt"]) # code 2026-10-03T10:12:00.000Z# A sign-in link instead of a code. It must be on one of the credential's sites.bx.credentials.push_code("SHOP", link="https://shop.example.com/magic?token=…")# The code or link is never an argument: pipe it in, or type it at the hidden prompt.# A value that starts with http:// or https:// is sent as a sign-in link.printf '%s\n' "$CODE" | boxline credentials push-code SHOPboxline credentials push-code SHOP{ "accepted": true, "kind": "code", "expiresAt": "2026-10-03T10:12:00.000Z" }- Send it after the wait began. A wait uses only a code or link that arrives after it began, so wait for the
credential.code_neededwebhook (or the run’scodestep) and then send. A code sent earlier is kept, and never used. - Used once, kept 10 minutes. The first wait that takes a code or link uses it. Unused ones are kept sealed for 10 minutes, and at most 10 per credential (the oldest go first).
- A code is 1 to 64 characters without spaces or control characters. A link is an
httporhttpsaddress of up to 4,096 characters on one of the credential’s sites, or the call fails with400 credential_link_wrong_site. - Only a credential with
codeSource "push"takes them (400 invalid_requestotherwise), and only one of your project (404 credential_not_found). - The audit log records each push as
action: "code"withkindand who sent it, never the value. It counts in the 60 credential changes a minute.
A receiver for the webhook that reads the message and sends the code, in a few lines:
import { Boxline, verifyWebhook, type WebhookEventPayload } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URL// body: the raw request body exactly as it arrived; header: its Boxline-Signature (see Webhooks).export async function receive(body: Buffer, header: string | undefined): Promise<number> { const event = verifyWebhook(body, header, process.env.BOXLINE_WEBHOOK_SECRET!) as WebhookEventPayload; if (event.type === "credential.code_needed") { const { credential, type } = event.data; // "SHOP", and "code" or "link" const value = await readNewestMessage(credential, type); // your own code: the email or SMS that just arrived if (value) await bx.credentials.pushCode(credential, type === "code" ? { code: value } : { link: value }); } return 200;}import osfrom boxline import Boxline, verify_webhookbx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLdef receive(body: bytes, header) -> int: """body: the raw request body exactly as it arrived; header: its Boxline-Signature (see Webhooks).""" event = verify_webhook(body, header, os.environ["BOXLINE_WEBHOOK_SECRET"]) if event["type"] == "credential.code_needed": data = event["data"] # credential, and type: "code" or "link" value = read_newest_message(data["credential"], data["type"]) # your own code: the email or SMS that just arrived if value: if data["type"] == "code": bx.credentials.push_code(data["credential"], code=value) else: bx.credentials.push_code(data["credential"], link=value) return 200readNewestMessage is yours: read the mailbox or the SMS service you use. The tested example Email-code sign-in does it end to end, in Node and Python.
Let the platform ask you: "url"
With codeSource: "url" you give an address, codeUrl. While a run waits, the platform sends it a signed POST every 5 seconds, and your endpoint answers with the code, or says “not yet”.
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": "ops@example.com", "password": "'"$SHOP_PASSWORD"'", "codeSource": "url", "codeUrl": "https://ops.example.com/boxline-codes" }'# The answer holds "codeUrlSecret": "whsec_…", shown only this once.import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst shop = await bx.credentials.create({ name: "SHOP", type: "password", origins: ["https://shop.example.com"], username: "ops@example.com", password: process.env.SHOP_PASSWORD!, // from your own secret store codeSource: "url", codeUrl: "https://ops.example.com/boxline-codes", // a public HTTPS address of yours});// shop.codeUrlSecret ("whsec_…") is shown only now: put it in your secret manager, never in code or logs.console.log(shop.codeSource, shop.codeUrl);import osfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLshop = bx.credentials.create( "SHOP", "password", origins=["https://shop.example.com"], username="ops@example.com", password=os.environ["SHOP_PASSWORD"], # from your own secret store code_source="url", code_url="https://ops.example.com/boxline-codes", # a public HTTPS address of yours)# shop["codeUrlSecret"] ("whsec_…") is shown only now: put it in your secret manager, never in code or logs.print(shop["codeSource"], shop["codeUrl"])# The password is asked for at a hidden prompt. The signing secret is printed once, at the end.boxline credentials set SHOP --type password --username ops@example.com --origin https://shop.example.com \ --code-source url --code-url https://ops.example.com/boxline-codes{ "name": "SHOP", "type": "password", "origins": ["https://shop.example.com"], "username": "ops@example.com", "hasTotp": false, "codeSource": "url", "codeUrl": "https://ops.example.com/boxline-codes", "codeTimeoutSeconds": 300, "codeUrlSecret": "whsec_…", "…": "…"}Setting or changing codeUrl answers with codeUrlSecret (no-store). It is the key that signs the platform’s requests. It is never shown again.
What the platform sends
| Part | Holds |
|---|---|
credential | The credential’s name. |
sessionId | The session that waits. |
runId | The agent run that waits, or null for an action or boxline-otp. |
requestedAt | When the wait began (an ISO time, the same in every request of one wait). |
kind | "code" or "link": what the platform wants. |
Header Boxline-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>, the HMAC of <t>.<raw body> keyed with the codeUrlSecret: exactly as for webhooks. |
Check the signature, then answer
Check every request before you answer, with verifyWebhook (Node) or verify_webhook (Python), given the codeUrlSecret. They refuse a request whose t is more than 5 minutes from your clock, and return the request’s body.
import { verifyWebhook, WebhookSignatureError } from "@boxline/sdk";interface CodeRequest { credential: string; sessionId: string; runId: string | null; requestedAt: string; // when the wait began kind: "code" | "link";}// body: the raw request body exactly as it arrived; header: its Boxline-Signature.// Returns the status to answer, and the JSON for a 200.export async function answer(body: Buffer, header: string | undefined): Promise<{ status: number; json?: object }> { let ask: CodeRequest; try { ask = verifyWebhook(body, header, process.env.BOXLINE_CODE_URL_SECRET!) as unknown as CodeRequest; } catch (err) { if (err instanceof WebhookSignatureError) return { status: 400 }; // not from Boxline: hand out nothing throw err; } // Your own code: the newest email or SMS for this credential that arrived after the wait began. const value = await readNewestMessage(ask.credential, ask.kind, new Date(ask.requestedAt)); if (!value) return { status: 204 }; // not yet: the platform asks again in 5 seconds return { status: 200, json: ask.kind === "code" ? { code: value } : { link: value } };}import osfrom datetime import datetimefrom boxline import WebhookSignatureError, verify_webhookdef answer(body: bytes, header): """body: the raw request body exactly as it arrived; header: its Boxline-Signature. Returns (status, json).""" try: ask = verify_webhook(body, header, os.environ["BOXLINE_CODE_URL_SECRET"]) except WebhookSignatureError: return 400, None # not from Boxline: hand out nothing # Your own code: the newest email or SMS for this credential that arrived after the wait began. since = datetime.fromisoformat(ask["requestedAt"].replace("Z", "+00:00")) value = read_newest_message(ask["credential"], ask["kind"], since) if not value: return 204, None # not yet: the platform asks again in 5 seconds return 200, ({"code": value} if ask["kind"] == "code" else {"link": value})| Your answer | What the platform does |
|---|---|
200 with {"code": "123456"} for kind "code", or {"link": "https://…"} for "link" | Types the code, or opens the link, and the wait ends. |
204 | Not yet. It asks again 5 seconds later, until codeTimeoutSeconds is over. |
Anything else: an error, a redirect, no answer in 10 seconds, other JSON, or a code when it asked for a link | Counts as not yet. |
- Freshness is yours. For
"push"the platform drops codes that arrived before the wait. For"url"it types whatever you answer, so answer only with a message that arrived afterrequestedAt. An old code is typed, and the site refuses it. - Public HTTPS only. The same rules as a webhook URL: never http, and never a private, loopback, link-local or internal address or name. They are checked when you save it (
400 code_url_not_allowed) and again at every request. Redirects are not followed, and the platform reads at most 4 KB of the answer. - Answer quickly: you have 10 seconds. All of a project’s waits together send at most 120 requests a minute from one API server; past that, a turn is skipped.
Rotate the signing secret
PATCH /v1/credentials/:name with { "rotateCodeUrlSecret": true } makes a new codeUrlSecret, shown only in the answer. The old one stops at once, so put the new one where your endpoint reads it right away: a request signed with the new secret fails your check until you do, and counts as “not yet”. Rotate it when it may have leaked, and when someone who knew it leaves.
curl -X PATCH https://api.boxline.dev/v1/credentials/SHOP \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"rotateCodeUrlSecret": true}'# The answer is the credential, with "codeUrlSecret": "whsec_…" shown only this once.import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst { codeUrlSecret } = await bx.credentials.update("SHOP", { rotateCodeUrlSecret: true });// Store it in your secret manager now. The old one has stopped working.from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsecret = bx.credentials.update("SHOP", rotate_code_url_secret=True)["codeUrlSecret"]# Store it in your secret manager now. The old one has stopped working.It works only for a credential with codeSource "url" (400 invalid_request otherwise), and counts in the 60 credential changes a minute. A new secret also comes with every change of codeUrl. See Update a credential.
Sign-in links: %NAME.link%
Some sites email a sign-in link (a “magic link”) instead of a code. Send it the same way: push { link }, or answer a kind: "link" request with {"link": "https://…"}.
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/signin, type %SHOP.username% into Email and ask the site to email a sign-in link. When it says it sent one, open %SHOP.link%. Then tell me the name on the account page.", "credentials": ["SHOP"] }'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst { id } = await bx.agent.run({ task: "Open https://shop.example.com/signin, type %SHOP.username% into Email and ask the site to email a sign-in link. " + "When it says it sent one, open %SHOP.link%. Then tell me the name on the account page.", credentials: ["SHOP"], // codeSource "push" or "url"});const run = await bx.agent.wait(id); // your system pushes the link when the webhook says the run waitsconsole.log(run.status, run.result);from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLstarted = bx.agent.run( task=( "Open https://shop.example.com/signin, type %SHOP.username% into Email and ask the site to email a sign-in link. " "When it says it sent one, open %SHOP.link%. Then tell me the name on the account page." ), credentials=["SHOP"], # code_source "push" or "url")run = bx.agent.wait(started["id"]) # your system pushes the link when the webhook says the run waitsprint(run["status"], run["result"])boxline run "Open https://shop.example.com/signin, type %SHOP.username% into Email and ask the site to email a sign-in link. When it says it sent one, open %SHOP.link%. Then tell me the name on the account page." --credential SHOP- Only on the credential’s sites. The agent opens the link with its
browser_open_linktool, which waits until the link arrives. A link whose site is not one of the credential’soriginsis never opened:credential_link_wrong_site. - Never shown to the model. A link carries a token, so it is hidden from the model, the run’s steps, events and logs, and the session’s recorded addresses. The tool reports the page it lands on by its title and site only.
%NAME.link%is for agent runs and theloginaction. It is never typed into a field or put into a URL.
In a shell
boxline-otp SHOP waits too. It holds until a fresh code arrives and prints it. If none arrives within codeTimeoutSeconds, it exits with 1 and says credential_code_timeout. Give the command a time limit longer than that: a command run through the API stops after its timeoutMs, 120 seconds by default.
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"command": "curl -s -d \"code=$(boxline-otp SHOP)\" https://shop.example.com/verify", "timeoutMs": 400000}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URL// The session was created with credentials: ["SHOP"] and a shell.const session = await bx.sessions.get("SESSION_ID");await session.exec('curl -s -d "code=$(boxline-otp SHOP)" https://shop.example.com/verify', { timeoutMs: 400_000 });from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URL# The session was created with credentials=["SHOP"] and a shell.session = bx.sessions.get("SESSION_ID")session.exec('curl -s -d "code=$(boxline-otp SHOP)" https://shop.example.com/verify', timeout_ms=400_000)Only a credential the session, or that one command, exports works with boxline-otp: see In the shell as variables.
A tested example, in Node and Python: Email-code sign-in saves a password whose codes you send, lets an agent run sign in, and sends the code when the run waits for it.
Sign in in one call
The login action signs a session’s browser in with a password credential. Give it the credential’s name, and the sign-in page if you want. It types the user name and the password, waits for a code or a sign-in link when the credential’s codeSource needs one, and answers with the page it ended on.
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", "credential": "SHOP", "url": "https://shop.example.com/signin"}]}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create();const page = await session.login("SHOP", { url: "https://shop.example.com/signin" });console.log(page.url, page.title, page.runId); // https://shop.example.com/account Your account run_…from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create()page = session.login("SHOP", url="https://shop.example.com/signin")print(page["url"], page["title"], page["runId"]) # https://shop.example.com/account Your account run_…boxline sessions login $SESSION_ID SHOP --url https://shop.example.com/signin{ "results": [ { "ok": true, "action": "login", "value": { "url": "https://shop.example.com/account", "title": "Your account", "runId": "run_…" }, "text": "Signed in with SHOP. Page: Your account", "ms": 21400 } ]}| Field | Default | Description |
|---|---|---|
credential | the profile’s | The password credential to sign in with. Without it, the one the session’s profile links. With neither, the call gets 400 invalid_request. A secret cannot sign in. |
url | its first site | The sign-in page. It must be on one of the credential’s sites (400 otherwise). The default is the first site without a *.; when every site allows any subdomain, send url. |
allowWithExtensions | false | Needed in a session with Chrome extensions, as for the other credential uses. |
In Node it is session.login(credential?, { url?, allowWithExtensions? }). In Python it is session.login(credential=None, url=None, allow_with_extensions=None). In MCP it is the browser_login tool, and on the command line boxline sessions login ID CREDENTIAL [--url PAGE].
What it does
- A short agent run does the work, in the session, with a fixed instruction and the project’s default model: at most 15 steps, browser tools only (no shell, no web search, no asking for help), only that credential, and only pages on its sites.
- It waits for codes and links like any run. With
"push"or"url"the run recordscodesteps and the webhookcredential.code_neededgoes out (see How a wait works). The run’s id isrunId, soGET /v1/agent/runs/:idshows its steps. - It runs alone: it is the only action of its request (
400otherwise). It needs a session with a browser (a shell-only session answers409 browser_disabled) and a plan with password credentials (402 feature_not_in_planon Free). - It costs like an agent run. It counts in the 30 agent runs a minute per project, and its model use counts like any run’s (
GET /v1/agent/runs/:idshows it).
What the answer shows
value.urlhas no query and no fragment. When the page is the one a sign-in link opened, or lies under that link’s path, it is the site only (https://shop.example.com), because a site may keep the link’s token in the path.url,title,textanderrornever hold anything the run learned: a code, a sign-in link or a credential value. The SDKs, the CLI and the MCP tool show only that.- A failure is an action result with
ok: false, therunIdand acode. The call itself still answers200.
| code | What happened |
|---|---|
credential_login_failed | The run could not sign in, for example the site did not accept the password. error is the run’s last step. |
credential_login_timeout | It ran out of time. See below. The run was canceled. |
credential_code_timeout | No code or sign-in link arrived within codeTimeoutSeconds. |
credential_link_wrong_site | A sign-in link was not on one of the credential’s sites, so it was not opened. |
The SDKs turn each into an error: CredentialLoginFailedError (with runId), CredentialLoginTimeoutError (status 408; a kind of CredentialLoginFailedError), CredentialCodeTimeoutError and CredentialLinkWrongSiteError.
The time limit
The run gets 15 steps of 30 seconds, plus the credential’s codeTimeoutSeconds when its source is "push" or "url": 450 seconds, or up to 1,350. Waiting for a code, for a person to solve a CAPTCHA and a slow model all count. Past the limit the run is canceled (it ends as canceled, and the session stays) and the result is credential_login_timeout.
If the caller goes away, the run stops. When the connection closes before the answer, because your client timed out or was stopped, the run is canceled at once. It is never left waiting for a code. The SDKs (1.3.0 and later), and with them the CLI and the MCP server, give login and code waits the server’s own time limit, so you need no option for that.
Use login when you want a session signed in and do not care how. To control each step, give an agent run the placeholders, or type the fields yourself with the type action.
Signing in again by itself
A browser profile keeps cookies, but cookies expire. Link a password credential to the profile, and sessions that start from it can sign in again on their own. See Browser profiles.
In the shell as variables
A credential with scope "shell" or "all" (or shell: true) can be exported into a session’s shell. Ask for it when you create the session, for every command, or on one command, for that command only.
A session’s credentials (and its env) are fixed when it is created. Update a session refuses them with 400 not_updatable. To give a running session one more credential, name it on a command, or start a new session.
| In the shell | For | It is |
|---|---|---|
$NAME | A secret | Its value. |
$NAME_USERNAME | A password | The user name. |
$NAME_PASSWORD | A password | The password. |
boxline-otp NAME | A password with a code source | A command that prints the current 2FA code. With "push" or "url" it waits for a fresh one. The authenticator key itself never enters the machine. |
# The whole session: exported in every command and terminal.curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"shell": true, "credentials": ["GITHUB_TOKEN", "SHOP"]}'curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"command": "curl -s -H \"Authorization: Bearer $GITHUB_TOKEN\" https://api.github.com/user | head -5"}'# Use the current 2FA code of a password with a 2FA key inside a command (its reply shows %SHOP.otp%, not the code):curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"command": "curl -s -d \"code=$(boxline-otp SHOP)\" https://shop.example.com/verify"}'# One command only: the shell does not keep DEPLOY_KEY afterwards.curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"command": "./deploy.sh", "credentials": ["DEPLOY_KEY"]}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create({ shell: true, credentials: ["GITHUB_TOKEN", "SHOP"] });// Use them by name in commands; never print them or write them to a file.const r = await session.exec('curl -s -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/user | head -5');console.log(r.stdout);// Use the current 2FA code inside a command. Its reply shows %SHOP.otp%, not the code.await session.exec('curl -s -d "code=$(boxline-otp SHOP)" https://shop.example.com/verify');// One command only: the shell does not keep DEPLOY_KEY afterwards.await session.exec("./deploy.sh", { credentials: ["DEPLOY_KEY"] });from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create(shell=True, credentials=["GITHUB_TOKEN", "SHOP"])# Use them by name in commands; never print them or write them to a file.r = session.exec('curl -s -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/user | head -5')print(r["stdout"])# Use the current 2FA code inside a command. Its reply shows %SHOP.otp%, not the code.session.exec('curl -s -d "code=$(boxline-otp SHOP)" https://shop.example.com/verify')# One command only: the shell does not keep DEPLOY_KEY afterwards.session.exec("./deploy.sh", credentials=["DEPLOY_KEY"])- Exported values live in the machine’s memory only. The platform never writes them to a file, and sets them again on every new machine: after a resume or a recovery. The session lists the credentials’ names in
credentials, never the values. boxline-otp NAMEasks the platform for the current code, so the 2FA key never enters the machine. It works only for credentials the session (or that one command) exports: for others the command exits with 1. At most 60 codes a minute per session. The first code per session is audited. With a code source of"push"or"url"it waits for a fresh code, up tocodeTimeoutSeconds, and then exits with 1.- The session’s own
boxline-otpgrant (BOXLINE_OTP_GRANT) is hidden in output on every API server. It ends when the session stops or when you rotate its URLs (rotateUrls); one command’s grant lasts until that command ends. A 2FA code is hidden in the reply of the command that asked, in an agent’s bash output and in a step’s result, but only on the API server that handed it out. A stream or terminal that was already open when the code was made, or that another API server handles, can still show it. A code lasts 30 seconds, and the session’s own shell can ask for more, so keep 2FA credentials out of shells you do not trust. - Output shows
%NAME%(%NAME_USERNAME%,%NAME_PASSWORD%for the parts of a password,%NAME.otp%for a 2FA code) where a value would appear: command replies, streamed output, script output, the terminal and theexecevents in the session’s log. Values are also found JSON-escaped, percent-encoded, with white space collapsed, or split across two pieces of output. - That hiding guards against showing a value by accident. It is not a wall: a command can print the value changed (base64, reversed) and it shows. Values shorter than 3 characters are not hidden.
- An agent run in the session is told the variable names (
$GITHUB_TOKEN,$SHOP_PASSWORD) andboxline-otp NAMEso it can use them in commands, and its model never sees the values. - A credential the scope does not allow gets
400 credential_not_for_shell. A variable name given twice (inenvand by a credential, or by two credentials, such as a secretSHOP_PASSWORDnext to a passwordSHOP) gets400.
The 256-value limit
A session keeps hiding values it was given earlier for as long as it runs: the old value of a changed credential, one command’s credentials, 2FA codes, and shell: true values an agent run or a step put into commands. It holds at most 256 such values, or 256 KB of them.
A request that would add more (a session create, a shell command, an agent run or a step) gets 409 too_many_credential_values. Start a new session. The credentials the session exports now are always hidden.
Sessions with Chrome extensions
A Chrome extension reads every page and every value typed into it, so none of the hiding above applies to it.
- Agent runs, steps, scripts and
typeactions withcredentialsin a session with extensions get400 variables_with_extensions, unless the request setsallowWithExtensions: true. - The credential a profile links is not offered there without it either.
- With
allowWithExtensions: truethe request runs, and the session’s log gets a warning event.
List, change and delete
GET /v1/credentials lists your credentials in name order (100 a page, up to 200), and GET /v1/credentials/:name returns one. Neither shows a value. PATCH changes any of a password’s origins, username, password, codeSource (null removes 2FA), totpSecret (null removes 2FA), codeUrl, codeTimeoutSeconds, description, shell and scope, or a secret’s value, origins (null for any site), description, shell and scope. DELETE removes it, and unlinks it from the profiles that use it.
curl https://api.boxline.dev/v1/credentials -H "x-api-key: $BOXLINE_API_KEY"# Rotate the value:curl -X PATCH https://api.boxline.dev/v1/credentials/GITHUB_TOKEN \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"value": "'"$NEW_GITHUB_TOKEN"'"}'# A new password for the same site:curl -X PATCH https://api.boxline.dev/v1/credentials/SHOP \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"password": "'"$NEW_SHOP_PASSWORD"'"}'# Take its 2FA codes from your own system: changing where they come from needs the password again.curl -X PATCH https://api.boxline.dev/v1/credentials/SHOP \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"codeSource": "push", "password": "'"$SHOP_PASSWORD"'"}'curl -X DELETE https://api.boxline.dev/v1/credentials/GITHUB_TOKEN -H "x-api-key: $BOXLINE_API_KEY"import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLfor await (const c of bx.credentials.list()) console.log(c.name, c.type, c.scope, c.lastUsedAt);await bx.credentials.update("GITHUB_TOKEN", { value: process.env.NEW_GITHUB_TOKEN! }); // rotate the valueawait bx.credentials.update("SHOP", { password: process.env.NEW_SHOP_PASSWORD! }); // same site and 2FA// Take its 2FA codes from your own system: changing where they come from needs the password again.await bx.credentials.update("SHOP", { codeSource: "push", password: process.env.SHOP_PASSWORD! });await bx.credentials.delete("GITHUB_TOKEN");import osfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLfor c in bx.credentials.list(): print(c["name"], c["type"], c["scope"], c["lastUsedAt"])bx.credentials.update("GITHUB_TOKEN", value=os.environ["NEW_GITHUB_TOKEN"]) # rotate the valuebx.credentials.update("SHOP", password=os.environ["NEW_SHOP_PASSWORD"]) # same site and 2FA# Take its 2FA codes from your own system: changing where they come from needs the password again.bx.credentials.update("SHOP", code_source="push", password=os.environ["SHOP_PASSWORD"])bx.credentials.delete("GITHUB_TOKEN")boxline credentials list # names, types, sites, user names; never the values# A credential that exists gets the new values; options you leave out stay as they were.printf %s "$NEW_GITHUB_TOKEN" | boxline credentials set GITHUB_TOKEN# Take a password's 2FA codes from your own system (the password is asked for again):boxline credentials set SHOP --code-source pushboxline credentials delete GITHUB_TOKENChanges apply to new uses:
- An agent run that is already working keeps the values it started with.
- A session that exports the credential gets the new values on its next machine (a resume or a recovery).
- A deleted credential is no longer set there; a
lifecycleevent “credentials not set in the shell” names it.
Each create, change and delete also sends a credential.changed webhook event, with the name, the type and who made it, never a value.
The audit log
GET /v1/credentials/audit lists every change to your credentials, and every use, newest first, 50 a page by default (limit up to 200). ?name= shows one credential. It never holds a value.
curl "https://api.boxline.dev/v1/credentials/audit?name=SHOP" -H "x-api-key: $BOXLINE_API_KEY"import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLfor await (const e of bx.credentials.audit({ name: "SHOP" })) { console.log(e.at, e.action, e.actor ?? "", e.usedBy?.type ?? "", e.usedBy?.id ?? "");}from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLfor e in bx.credentials.audit(name="SHOP"): print(e["at"], e["action"], e["actor"] or "", (e["usedBy"] or {}).get("type", "")){"at": "2026-10-03T10:05:12.000Z", "action": "use", "type": "password", "name": "SHOP", "actor": null, "usedBy": {"type": "agent_run", "id": "run_…"}, "details": {}}{"at": "2026-10-03T10:00:00.000Z", "action": "create", "type": "password", "name": "SHOP", "actor": "user:ada@example.com", "usedBy": null, "details": {"scope": "all", "shell": false, "origins": 1, "totp": true, "codeSource": "totp"}}actioniscreate,update,delete,useorcode(a code or sign-in link you pushed, never its value).typeispasswordorsecret.actorsays who changed it:user:<email>for a console user,key:<id>for an API key, orsupportwhen Boxline support made the change while acting for you. The support person is recorded in Boxline’s own admin log.- A use is recorded once per session, command, agent run, step session, script, task run, typed field session or 2FA-code session (the first time the value is filled in, exported or handed out), with
usedBysaying which. Changes haveusedBy: null.
| usedBy.type | The use | usedBy.id |
|---|---|---|
session | Exported into the shell of a session being created (credentials on POST /v1/sessions). | the session |
exec | One command's credentials. | the session |
agent_run | An agent run. | the run |
task_run | A task's run, by hand or on its schedule. | the task run |
step | Plain-English steps in browser actions. | the session |
script | browser/script and its step() calls. | the session |
action | The type action with a credential. | the session |
otp | 2FA codes that boxline-otp asked for. | the session |
details says what a change did, never a value:
| Change | details |
|---|---|
| A credential created | { scope, shell, origins } (origins: how many sites). A password adds totp (whether it has an authenticator key) and codeSource. |
| A credential updated | { changed }, the fields sent, such as ["password", "origins"]. A change of the sites adds previousOrigins. |
| A password linked to or unlinked from a profile | { changed: ["profiles"], profile, linked } |
| A new codeUrlSecret | { changed: ["codeUrlSecret"] }, an update |
| A code or link pushed | { kind }: "code" or "link". Never the value. The entry is an action code, and the actor says who sent it. |
| A credential deleted | {} |
Limits
| What | Limit |
|---|---|
| Credentials per project | 200 on every plan (project.limits.maxCredentials in GET /v1/auth/me). Beyond: 402 plan_limit. |
| Passwords | Hobby plan and up (the loginDetails feature). Secrets are on every plan. |
| Name | Capitals, digits and _, up to 64 characters, not a reserved name. |
| Password | 1 to 1,024 characters. User name: 1 to 320. Sites: 1 to 20. |
| Secret value | 1 to 8,000 characters. |
| description | Up to 500 characters. |
| Credentials named in one request | At most 50 in credentials: [...] on a session, a shell command, an agent run, a step, a script or a task. |
| Audit log | 50 entries a page by default, up to 200 with limit. |
| Changes | Creating, changing and deleting credentials, pushing a code or link, making a new codeUrlSecret, and linking a credential to a profile or unlinking it (PATCH /v1/profiles/:id with credential): 60 a minute per project. |
| 2FA codes in a shell | 60 a minute per session (boxline-otp). A long wait asks again about every 20 seconds, and each ask counts. |
| A pushed code | 1 to 64 characters, no spaces. A link: an http or https address up to 4,096 characters on one of the credential's sites. |
| Pushed codes kept | Each one 10 minutes, and at most 10 per credential (the oldest go first). Used once. |
| codeTimeoutSeconds | 5 to 900 seconds, 300 by default. |
| codeUrl requests | One every 5 seconds while a wait lasts. You have 10 seconds to answer, and the platform reads at most 4 KB. All of a project's waits together send at most 120 requests a minute from one API server; past that a turn is skipped. |
| The login action | At most 15 steps of 30 seconds, plus codeTimeoutSeconds when the codes come from you: 450 seconds, or up to 1,350. It counts as an agent run (30 a minute per project). |
| Hidden values per session | 256 values or 256 KB. Beyond: 409 too_many_credential_values. |
Use credentials safely
- Keep the default scope.
"agent"keeps a credential out of every shell. Choose"shell"or"all"only for values a command really needs. - Give passwords and long-lived tokens
origins, so they can only be typed on their own site, even if a page tries to steer the agent elsewhere. Passwords require it. - Prefer short-lived tokens with narrow rights, and rotate them with
PATCH. - Give a run only the credentials it needs. A scheduled task uses its credentials on every run, so list only what it must have.
- Read values from your own secret store, never from code you commit, and never type them as a command argument: the CLI asks for them at a hidden prompt or reads them from a pipe for that reason.
- Use values by name in commands (
$GITHUB_TOKEN). Never print them, and never write them to files: files stay in the workspace and move with the session. - Do not rely on the hiding. Hiding a typed value in page reads and output stops accidents, not code you run. Type passwords only in sessions whose pages and scripts you trust.
- Keep credentials out of sessions with extensions, unless you trust the extension with them.
- Treat codes and sign-in links like passwords. Send them from your server over HTTPS, never through a chat or a log, and verify
Boxline-Signatureon every request to acodeUrl. See 2FA codes and sign-in links. - Limit what
logincan reach. It signs in only on the credential’sorigins, so give them the narrowest sites, and give every call a time limit above its own. - Do not put passwords in a chat. The MCP server has no tool that creates credentials, on purpose: add them in the console, with the CLI or with an SDK, and let the assistant use them by name.
- Read the audit log now and then, and delete credentials you no longer use.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | A field is not valid: a name in small letters or a reserved name, an empty or too long value, origins that are not sites, or a password without origins. Also a change of sites, of where the codes come from (codeSource, codeUrl), or one that makes the credential readable by shells, without the password (or value) again. A code pushed to a credential whose codeSource is not "push", or a code with spaces. A codeUrl without codeSource "url", or the other way round. A login action that is not alone, has no browser or no credential to use, or names a secret. |
| 400 | credential_link_wrong_site | A sign-in link that is not on one of the credential's sites, so it was not used: pushed, answered by your codeUrl, or opened by an agent. |
| 400 | code_url_not_allowed | The codeUrl is not a public HTTPS address: the same rules as a webhook URL (no http, no private, loopback or internal address or name). |
| 400 | credential_not_for_ai | An agent run, step, script, task or type action asked for a credential with scope "shell". |
| 400 | credential_not_for_shell | A session or command asked to export a credential with scope "agent" (and shell: false). |
| 400 | variables_with_extensions | Credentials in a session with Chrome extensions, without allowWithExtensions: true. |
| 402 | feature_not_in_plan | A password credential on a plan without loginDetails (Free): creating it, linking it to a profile, using it, and the login action. |
| 402 | plan_limit | The project already keeps as many credentials as its plan allows (200). |
| 404 | credential_not_found | A run, step, script, task, session, command or action named a credential the project does not have. Also GET, PATCH or DELETE of one. |
| 409 | credential_exists | A credential with that name exists. Change it with PATCH. |
| 409 | browser_disabled | The type action or the login action in a session without a browser (a shell-only session). |
| 409 | shell_disabled | A shell command in a session without a shell (a browser-only session). |
| 408 | credential_code_timeout | No code or sign-in link arrived within codeTimeoutSeconds. boxline-otp exits with 1; a type action or a login action answers with ok: false and this code; in an agent run the tool call fails and a code step records timeout. |
| none | credential_login_failed | The login action could not sign in. The call itself answers 200: the action's result has ok: false, this code, the run's last step in error, and the runId. |
| none | credential_login_timeout | The login action ran out of time (15 steps plus codeTimeoutSeconds) and its run was canceled. An ok: false result, with the runId. The SDKs raise it with status 408. |
| 409 | conflict | A PATCH found the credential's sites, scope, shell, codeSource or codeUrl changed meanwhile. Send it again. |
| 409 | too_many_credential_values | The session already hides 256 earlier values (or 256 KB). Start a new session. |
| 409 | machine_too_old | During a platform update, a session on an older machine cannot take credentials. Start a new session. |
| 429 | rate_limited | More than 60 changes a minute (linking a credential to a profile, pushing a code or link, and a new codeUrlSecret count), or more than 30 agent runs a minute (a login action counts). |
| 500 | secret_unreadable | The platform can no longer open a stored value. Set the values again with PATCH. |
The SDKs raise CredentialExistsError, CredentialNotAllowedError (both credential_not_for_* codes), TooManyCredentialValuesError, MachineTooOldError, PlanLimitError, FeatureNotInPlanError and VariablesWithExtensionsError for these, and a NotFoundError for credential_not_found.
For codes and signing in they raise CredentialCodeTimeoutError, CredentialLinkWrongSiteError, CredentialLoginFailedError, CredentialLoginTimeoutError and CodeUrlNotAllowedError. CredentialLoginFailedError has the run’s id (runId in Node, run_id in Python), and CredentialLoginTimeoutError is a kind of it. See the API reference for every field.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.