Profiles, cookie sync, secrets and 2FA
Most jobs worth automating sit behind a login. There are three ways to get a hosted browser signed in without putting a password in your code: sign in once and keep the session in a profile, copy cookies you already have into one with cookie sync, or let an agent run type the login from secrets the model never sees, 2FA codes included.
Profiles
A profile keeps a session's cookies, localStorage and IndexedDB under a name. Start a session with profile: { name: "shop-account", persist: true } to load it and save it back when the session ends, or with profile: "shop-account" to load it read-only. Unless you pass identity or fingerprint, a profile also brings its own identity: the same device, and the same residential IP while that IP stays online. The full rules are under Profiles: sign in once; from the SDK, see a persistent context on a cloud profile.
To see what a profile holds, without its values:
curl -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/browsers/profiles/shop-account
# {
# "name": "shop-account",
# "cookies": 42,
# "domains": ["example.com", "shop.example.com"],
# "bytes": 18211,
# "storage": "Local Storage,IndexedDB",
# "updatedAt": "2026-10-02T09:20:11.000Z"
# }domains lists up to 50 sites, sorted, without a leading dot. bytes is the compressed size. storage names the site storage saved with it (Local Storage, IndexedDB); it is empty for a profile that only holds imported cookies. An unknown name is a 404 NOT_FOUND.
No endpoint returns a cookie value. A profile's contents are only opened on our servers: to merge an import, to count what it holds, and on the server running a session on it.
Cookie sync
If you are already signed in somewhere (your own browser, a local Clearcote, a Playwright script), copy those cookies into a cloud profile and every session on that profile starts signed in:
curl -X PUT https://www.clearcotelabs.com/api/v1/browsers/profiles/shop-account/cookies \
-H "authorization: Bearer cc_live_..." -H "content-type: application/json" \
-d '{
"mode": "merge",
"cookies": [
{ "name": "session_id", "value": "…", "domain": "shop.example.com", "path": "/",
"secure": true, "httpOnly": true, "sameSite": "Lax", "expires": 1798761600 }
]
}'
# { "name": "shop-account", "cookies": 42, "imported": 1, "domains": ["example.com", "shop.example.com"],
# "bytes": 18211, "updatedAt": "…" }- Any common cookie format. The CDP shape (
Network.getCookies,Network.Cookie/CookieParam) and the Playwright shape (context.cookies(), a storage-state file'scookies) both work as they are; aurlmay stand in fordomain. Noexpires,expires: -1(any value up to 0) orsession: truemeans a session cookie, and a cookie that has already expired is skipped.sameSiteisLax,StrictorNonein any case, andNoneneedssecure: true. - Checked like Chrome checks them. A cookie Chrome would refuse (a
;in a value,sameSite: "None"withoutsecure, a__Secure-or__Host-cookie that breaks its rules, a path that does not start with/) makes the whole import a400whose message names it. - Stored as
name,value,domain(lowercased),path(default/),secure,httpOnly,sameSite(only if you sent it) andexpires(absent for a session cookie). Other fields, such aspartitionKey, are dropped. mode: "merge"(the default) adds your cookies and replaces any with the same name, domain and path.mode: "replace"makes the profile's cookies exactly the list you send. Either way the profile's localStorage and IndexedDB are kept.- Limits: at most 5000 cookies per request, and 5000 in the profile after a merge (
400 TOO_MANY); names up to 256 characters, values up to 4096, paths up to 1024; the whole profile at most 3.5 MB compressed (413 TOO_LARGE); 60 imports per 10 minutes. - A profile that does not exist yet is created. While a session that saves to the profile (
persist: true) is starting or running, an import is refused with409 PROFILE_IN_USE: the session would overwrite it when it ends. - Imported session cookies (no expiry) are loaded into the next sessions like any other. A session that saves the profile drops them, as a real browser does on restart, so import them again if you need them after that.
The answer counts the profile's cookies after the import (cookies) and how many of yours it took (imported: expired cookies and duplicates are left out), and lists the first 50 of its domains, sorted.
From your machine, with the SDK
The clearcote command reads cookies from where you have them and uploads only the domains you name:
# a local Clearcote profile folder (the user_data_dir of launch_persistent_context)
clearcote cloud profile sync shop-account --from-profile ./profiles/shop --domain example.com
# a browser that is running with a CDP endpoint, for example clearcote serve
clearcote cloud profile sync shop-account --from-cdp http://127.0.0.1:9222 --domain example.com
# a Playwright storage-state file: context.storage_state(path="state.json")
clearcote cloud profile sync shop-account --from-file state.json --domain example.com --domain example-cdn.net
# sign in by hand: opens a local Clearcote window on a throwaway profile at the URL,
# you sign in and press Enter, and the cookies for the chosen domains are uploaded
clearcote cloud profile sync shop-account --login https://shop.example.com/login --domain example.com--domain(repeat it for several) picks the cookies a browser would send to that domain: its own, its subdomains', and those of its parent domains (--domain shop.example.comalso takes.example.comcookies, never ones on a bare suffix such as.com). Without--domainor--all-domainsthe command refuses to run, so nothing uploads every cookie in a profile by accident.--all-domainsuploads everything. Only use it for a profile made for this one job.--replacesendsmode: "replace"; the default merges.- From code,
profiles.sync(name, …)takes the same choices: exactly one offrom_profile,from_cdp,from_fileorlogin_url(Node:fromProfile,fromCdp,fromFile,loginUrl), thendomains=[…]orall_domains=True(allDomains: true), andreplace=True. It returns the import answer above, and raisesValueError(anErrorin Node) without uploading anything when no cookie matches.
from clearcote import launch_persistent_context
from clearcote.cloud import Cloud
cloud = Cloud()
# the same as the command line
cloud.profiles.sync("shop-account", from_file="state.json", domains=["example.com"])
# or upload cookies you have in hand, here from a local profile you signed in with
# (cloud=False: this folder is on your machine, whatever CLEARCOTE_CLOUD says)
ctx = launch_persistent_context("./profiles/shop", cloud=False)
cookies = ctx.cookies("https://shop.example.com") # only this site's cookies
ctx.close()
cloud.profiles.import_cookies("shop-account", cookies) # mode="merge"A cookie is as good as a password while it is valid: whoever holds a session cookie is signed in as you. Upload the domains the job needs and nothing else. Some sites tie a login to the device or IP it was made on and sign a copied cookie out; for those, sign in once inside a hosted session with persist: true instead.
Secrets and 2FA
An agent run can type values the model never sees: passwords, account numbers, and the current code from a TOTP authenticator. Put them in secrets and refer to them in the task as {{name}}:
import os
from clearcote.cloud import Cloud
run = Cloud().runs.create(
"Sign in with {{email}} and {{password}}. If a code is asked for, enter {{otp}}. "
"Then open Orders and read the number of the latest order.",
url="https://shop.example.com/login",
secrets={
"email": "me@example.com",
"password": {"value": os.environ["SHOP_PASSWORD"]},
"otp": {"totp": os.environ["SHOP_TOTP_SECRET"]},
},
schema={"type": "object", "properties": {"order": {"type": "string"}}, "required": ["order"]},
profile={"name": "shop-account", "persist": True}, # next time it starts signed in
)
print(run["status"], run["result"]["output"])| Field | Type | Meaning |
|---|---|---|
"name": "value" | string | A plain value, typed on the host of the run's url (and its subdomains). |
value | string | The value, 1 to 1024 characters. |
totp | base32 string | A 2FA secret instead of a value: the setup key shown next to the QR code (letters A–Z and digits 2–7, at most 128; spaces and lowercase are fine). The run types the current code. |
digits | 6 | 8 | TOTP only. Default 6. |
period | 30 | 60 | TOTP only, in seconds. Default 30. |
domains | string[] | The hosts it may be typed on: 1 to 10 host names such as example.com (at least two labels; no scheme, path, port or *), each including its subdomains. Default: the host of url. |
- A secret is a string,
{ value, domains? }or{ totp, domains?, digits?, period? }. At most 20 per run. Names start with a letter and use letters, digits and_, up to 32 characters. - A
{{name}}in the task that is not insecretsis a400(unknown secret "name"). So is a secret with nodomainson a run with nourl: there is no host to bind it to. Write the reference exactly as{{name}}, without spaces. - A secret the task never mentions is allowed; the create answer's
warningssays so. - Mind the host. The default binds a secret to the host of
urland its subdomains:https://shop.example.com/logincoversshop.example.com, notlogin.example.com. If the login form lives on another host (single sign-on, an identity provider), list it indomains. - TOTP codes follow RFC 6238 with SHA-1, which is what authenticator apps use. The code is worked out at the moment it is typed, so it is always the current one.
- Secrets belong to one run. They are not stored with a profile and not kept after the run: send them again with the next one (or run the next one on a signed-in profile and skip the login).
What the model sees
Before Jet starts, every {{name}} in the task is replaced with a random placeholder such as CCSECRET_password_k3x9qa, and the task gets one sentence saying placeholders are to be typed exactly as written. That is all the model ever gets. When Jet types a placeholder, the browser layer on the server swaps in the real value, or the current TOTP code, at that moment, and only if the page's host is one of the secret's domains or a subdomain of one (for a field in an iframe, the frame's host too). On any other host the step stops with needs_input (“secret password is not allowed on other.example”) and nothing is typed.
Everything that comes back is cleaned the same way: in detail, markdown, each step's text, the output JSON and the events, a secret's value, its current TOTP code and its placeholder all read {{name}}. One consequence: if the page shows a secret (say, the email address after sign-in) and your schema asks for it, you get {{email}}, not the address.
Security model
What protects what, and where the limits are:
- Your API key is your account. It can start browsers on your balance, use your profiles and read your runs. Keep it on a server, never in a browser or an app you ship.
- Profiles are private and encrypted. Each is stored encrypted (AES-256-GCM, with a key per customer) and opened only on our servers: to merge an import, to count what it holds, and on the server running a session on it. Another customer's profile with the same name is a different profile. No endpoint returns a cookie value (a session on the profile can, of course, read its own cookies over CDP).
- Cookie sync uploads what you choose. Only you can import into your profiles, within the limits above (5000 cookies, 3.5 MB), and never into one that a starting or running session is saving to. Picking the domains happens on your side:
clearcote cloud profile syncandprofiles.sync()refuse to upload a whole cookie jar unless you pass--all-domains/all_domains=True. - Secrets are sealed until the run starts. They are stored encrypted the same way, inside the run's settings, and opened only when a server picks up the run. They are handed to that server once, and the stored settings are erased at that point. On the server they stay in memory and go to the Jet process on its standard input: never in environment variables, command lines, logs or events. If they can no longer be opened when a server picks the run up, the run ends
failedwithsession.endReasonsecrets_unreadable. - The model never receives a secret. It works with placeholders, and the real value is only typed on the hosts you allowed. A page that tries to talk the agent into typing your password somewhere else (prompt injection) gets a refused step, not the password.
- Outputs are scrubbed. Values, TOTP codes and placeholders are replaced with
{{name}}in everything a run returns or stores. - Jet runs apart from the server's own credentials. Each run is a separate process with an explicit, minimal environment: the server's keys and the proxy credentials are never passed to it or to the browser. The browser keeps the hosted guards: no local files, no private networks.
And what this does not cover:
- The site you sign in to gets the value. That is the point; choose
domainswith care. - The page can show it. After a value is typed into an ordinary field, the page displays it. Jet swaps a typed value back to its placeholder before the model reads the page, but only where it appears exactly as typed (or URL-encoded): a page that shows it changed, for example reformatted or partly hidden, is not caught. Password fields are never read.
- Recordings show the screen. A value in an ordinary field is on the video. Leave
recordoff for logins you do not want on file. - The task is not secret. It is stored with the run and shown in the dashboard and the run list. Never write a value into the task itself; always use
{{name}}. - Links are keys. A hand-off or control link lets whoever has it drive the browser, signed in, until it expires. Send it only to the person who should act.