Docs
Dashboard
Guides / SessionsBeta

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

PasswordSecret
HoldsA 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 showThe 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.
PlansHobby 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"  }'
Response 201
{  "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"  }'
Response 201
{  "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:

FieldDefaultDescription
namerequiredAn 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.
typerequired"password" or "secret". It cannot change later: delete the credential and save it again.
descriptionnoneA 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.
shellfalsetrue lets the AI put it into bash commands, which also lets sessions export it into their shell.

A password also takes:

FieldDefaultDescription
originsrequiredThe sites where the AI may type it, such as ["https://shop.example.com"], or "https://*.example.com" for any subdomain. 1 to 20.
usernamerequired1 to 320 characters. It is shown in lists: it is not secret.
passwordrequired1 to 1,024 characters. Stored encrypted and never returned.
codeSourcenoneWhere 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.
totpSecretnoneOnly 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.
codeUrlnoneOnly 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.
codeTimeoutSeconds300How 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:

FieldDefaultDescription
valuerequired1 to 8,000 characters, no NUL character. Stored encrypted and never returned.
originsany siteThe 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.

scopeUsed 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.

PlaceholderForTyped as
%NAME%A secretIts value.
%NAME.username%A passwordThe user name.
%NAME.password%A passwordThe password.
%NAME.otp%A password with a code sourceThe 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"]  }'
  • 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" gets 400 credential_not_for_ai, and an unknown name 404 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]"}  ]}'
  • A credential with origins (every password) needs a selector, and goes only into a field whose own frame is on one of its sites. A secret without origins may be typed where the focus is.
  • The reply never holds the value: its text says “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, evaluate and tabs results 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 an extract, which also keeps it from the model. A 2FA code is hidden from then on, like a code boxline-otp gave.
  • A refusal comes back as the action’s result (ok: false) with a code, such as credential_not_found or credential_not_for_ai, and the actions after it do not run.
  • The MCP server uses this action for its browser_type tool, 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.

codeSourceThe code comes fromUse 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 outNowhere: 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’s step();
  • your code runs the type action with field: "otp";
  • the AI opens %SHOP.link% (in an agent run, or in the login action);
  • a shell runs boxline-otp SHOP.

Then, in this order:

  1. 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.
  2. It tells you. The run records a code step with the state "waiting", and the webhook credential.code_needed goes out, once per wait. Your system should now forward the site’s email or SMS.
  3. 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.
  4. It types the code, or opens the link. The step says "received". If nothing arrives within codeTimeoutSeconds, the step says "timeout" and the call fails with credential_code_timeout (boxline-otp exits with 1).
The code steps of a run (shape)
{"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  }'

Then 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"'"}'
Response 202
{ "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_needed webhook (or the run’s code step) 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 http or https address of up to 4,096 characters on one of the credential’s sites, or the call fails with 400 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" with kind and 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;}

readNewestMessage 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.
Response 201 (shape)
{  "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

PartHolds
credentialThe credential’s name.
sessionIdThe session that waits.
runIdThe agent run that waits, or null for an action or boxline-otp.
requestedAtWhen the wait began (an ISO time, the same in every request of one wait).
kind"code" or "link": what the platform wants.
Header Boxline-Signaturet=<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 } };}
Your answerWhat 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.
204Not 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 linkCounts 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 after requestedAt. 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.

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.

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"]  }'
  • Only on the credential’s sites. The agent opens the link with its browser_open_link tool, which waits until the link arrives. A link whose site is not one of the credential’s origins is 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 the login action. 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}'

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"}]}'
Answer
{  "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    }  ]}
FieldDefaultDescription
credentialthe profile’sThe 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.
urlits first siteThe 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.
allowWithExtensionsfalseNeeded 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 records code steps and the webhook credential.code_needed goes out (see How a wait works). The run’s id is runId, so GET /v1/agent/runs/:id shows its steps.
  • It runs alone: it is the only action of its request (400 otherwise). It needs a session with a browser (a shell-only session answers 409 browser_disabled) and a plan with password credentials (402 feature_not_in_plan on 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/:id shows it).

What the answer shows

  • value.url has 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, text and error never 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, the runId and a code. The call itself still answers 200.
codeWhat happened
credential_login_failedThe run could not sign in, for example the site did not accept the password. error is the run’s last step.
credential_login_timeoutIt ran out of time. See below. The run was canceled.
credential_code_timeoutNo code or sign-in link arrived within codeTimeoutSeconds.
credential_link_wrong_siteA 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 shellForIt is
$NAMEA secretIts value.
$NAME_USERNAMEA passwordThe user name.
$NAME_PASSWORDA passwordThe password.
boxline-otp NAMEA password with a code sourceA 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"]}'
  • 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 NAME asks 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 to codeTimeoutSeconds, and then exits with 1.
  • The session’s own boxline-otp grant (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 the exec events 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) and boxline-otp NAME so 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 (in env and by a credential, or by two credentials, such as a secret SHOP_PASSWORD next to a password SHOP) gets 400.

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 type actions with credentials in a session with extensions get 400 variables_with_extensions, unless the request sets allowWithExtensions: true.
  • The credential a profile links is not offered there without it either.
  • With allowWithExtensions: true the 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"

Changes 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 lifecycle event “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"
Audit entries (shape)
{"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"}}
  • action is create, update, delete, use or code (a code or sign-in link you pushed, never its value). type is password or secret.
  • actor says who changed it: user:<email> for a console user, key:<id> for an API key, or support when 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 usedBy saying which. Changes have usedBy: null.
usedBy.typeThe useusedBy.id
sessionExported into the shell of a session being created (credentials on POST /v1/sessions).the session
execOne command's credentials.the session
agent_runAn agent run.the run
task_runA task's run, by hand or on its schedule.the task run
stepPlain-English steps in browser actions.the session
scriptbrowser/script and its step() calls.the session
actionThe type action with a credential.the session
otp2FA codes that boxline-otp asked for.the session

details says what a change did, never a value:

Changedetails
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

WhatLimit
Credentials per project200 on every plan (project.limits.maxCredentials in GET /v1/auth/me). Beyond: 402 plan_limit.
PasswordsHobby plan and up (the loginDetails feature). Secrets are on every plan.
NameCapitals, digits and _, up to 64 characters, not a reserved name.
Password1 to 1,024 characters. User name: 1 to 320. Sites: 1 to 20.
Secret value1 to 8,000 characters.
descriptionUp to 500 characters.
Credentials named in one requestAt most 50 in credentials: [...] on a session, a shell command, an agent run, a step, a script or a task.
Audit log50 entries a page by default, up to 200 with limit.
ChangesCreating, 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 shell60 a minute per session (boxline-otp). A long wait asks again about every 20 seconds, and each ask counts.
A pushed code1 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 keptEach one 10 minutes, and at most 10 per credential (the oldest go first). Used once.
codeTimeoutSeconds5 to 900 seconds, 300 by default.
codeUrl requestsOne 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 actionAt 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 session256 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-Signature on every request to a codeUrl. See 2FA codes and sign-in links.
  • Limit what login can reach. It signs in only on the credential’s origins, 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

StatusCodeWhen
400invalid_requestA 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.
400credential_link_wrong_siteA 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.
400code_url_not_allowedThe codeUrl is not a public HTTPS address: the same rules as a webhook URL (no http, no private, loopback or internal address or name).
400credential_not_for_aiAn agent run, step, script, task or type action asked for a credential with scope "shell".
400credential_not_for_shellA session or command asked to export a credential with scope "agent" (and shell: false).
400variables_with_extensionsCredentials in a session with Chrome extensions, without allowWithExtensions: true.
402feature_not_in_planA password credential on a plan without loginDetails (Free): creating it, linking it to a profile, using it, and the login action.
402plan_limitThe project already keeps as many credentials as its plan allows (200).
404credential_not_foundA run, step, script, task, session, command or action named a credential the project does not have. Also GET, PATCH or DELETE of one.
409credential_existsA credential with that name exists. Change it with PATCH.
409browser_disabledThe type action or the login action in a session without a browser (a shell-only session).
409shell_disabledA shell command in a session without a shell (a browser-only session).
408credential_code_timeoutNo 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.
nonecredential_login_failedThe 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.
nonecredential_login_timeoutThe 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.
409conflictA PATCH found the credential's sites, scope, shell, codeSource or codeUrl changed meanwhile. Send it again.
409too_many_credential_valuesThe session already hides 256 earlier values (or 256 KB). Start a new session.
409machine_too_oldDuring a platform update, a session on an older machine cannot take credentials. Start a new session.
429rate_limitedMore 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).
500secret_unreadableThe 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.