Install, remote backends, ports, pool mode and deployment. This is the material an
operator reads once per install - it is deliberately NOT in cuttle skill, which
every agent loads on every session and should carry only what changes how it drives
a page.
cuttle needs Docker (or OrbStack) for the local backend; the ssh/k8s backends need
only their own client (ssh / kubectl+helm). The CLI is a single static Go
binary named cuttle:
brew install tenequm/tap/cuttle # homebrew cask (macOS/Linux)
go -C packages/cuttle install ./cmd/cuttle # from a clone of this repo (Go 1.26+)The container image is ghcr.io/glim-sh/cuttle and cuttle up pulls it on first
run. Then, from any directory:
cuttle up # start the container + VNC viewer (pulls the image if needed)up is idempotent and profile-preserving: a stopped container is restarted
(logins persist), not recreated.
Apple Silicon: the image is multi-arch, so on an arm64 Mac the local backend runs the native arm64 build (a macOS persona) - no emulation. Remote
ssh/k8sbackends run the amd64 build (a Windows persona).
Keep the CLI and the image in step. A release build pins its own version, so a
stale CLI drives a newer daemon while printing its own older guide. If cuttle skill and observed behaviour disagree, check cuttle status for the image tag and
cuttle --version for the CLI.
A context names where the browser runs, selected by --context >
CUTTLE_CONTEXT > the config default_context > built-in local. --context
and --name are global flags on cuttle itself, so every verb that reaches an
instance takes them - cuttle pw and cuttle jev-browse included:
- local - Docker on this host (the zero-config default).
- ssh - a container on a remote host, reached over
ssh -L. Inherits~/.ssh/config(keys, jump hosts). - k8s - a Deployment reached via
kubectl port-forward. Inherits your kube config. - direct - an already-running CDP endpoint, used as-is.
For the tunneled backends every CDP/VNC op still targets the stable local
127.0.0.1:9222/:6080 - the backend owns the standing tunnel, established by
up, re-established by status, torn down by down. cuttle context ls lists
contexts and marks the active one; cuttle context --help covers both verbs.
cuttle context add box --backend ssh --host user@box.example --default
cuttle context add cluster --backend k8s --namespace browser --release cuttle
cuttle context add tailnet --backend direct --cdp-url http://cuttle.example:9222Contexts live in $XDG_CONFIG_HOME/cuttle/config.toml (default
~/.config/cuttle/config.toml) and can be hand-edited - the only way to set
advanced k8s knobs (node_selector, tolerations, resources):
default_context = "box"
[context.box] # ssh: docker on a remote amd64 host
backend = "ssh"
host = "user@box.example"
name = "scraper" # optional: the container this context stands for (see below)
screen = "1366x768" # optional: the screen the browser claims (see below)
[context.cluster] # k8s: a Deployment via kubectl port-forward
backend = "k8s"
namespace = "browser"
release = "cuttle"A cuttle up container runs the daemon in session mode (the default): exactly
one Chrome, with one persisted identity, that every attaching agent shares and the
viewer shows. The daemon refuses a ?fingerprint= seed on any connect or discovery
URL (HTTP 400 naming the mode), so a driver cannot fork a second browser with a
second cookie jar next to the one the human is looking at, and resource use is
bounded by construction. For many identities on one endpoint see pool mode below.
Nobody can take it away from the others. The browser is shared, so no single
party gets to end it. A driver's Browser.close (or Browser.crash) is answered
with success and detaches only that client; a driver that closes every tab is let
through one tab at a time, the daemon opening a replacement before the last one
goes, so Chrome always keeps a page and stays up. In the
viewer, the window has no close button, Alt+F4 is unbound and the titlebar menu
is gone. Closing the last tab by hand does exit Chrome - and the daemon relaunches
it on the spot, so what you get is a fresh blank tab with the same logins, never
an empty desktop. A crash is healed the same way (with backoff if it keeps
crashing). cuttle status reads the daemon's health rather than poking the
browser, so it never starts one as a side effect; if none is running at that
instant it says:
note: no browser is running right now - `cuttle open` starts it (the profile is kept)
The viewer fills the window. The framebuffer is sized to the browser window, which is itself sized to the screen the browser claims rather than to the display
- a browser claiming a 1440x900 screen while filling a 1920x1080 window is an
obvious lie. The entrypoint asks the daemon for that geometry (
cuttle viewer-geometry) before starting the X server; when the size is not knowable ahead of the launch it falls back to 1920x1080.
Which screen. The browser may only claim a screen its persona ships with: the
amd64 image is a Windows desktop (1920x1080, 1366x768, 1440x900), the
arm64 image a MacBook Air (1440x900, 1470x956, 1710x1107 - its default scaled
resolution, and the GPU and cores follow the model); the window is that screen
minus the OS taskbar. A session browser
claims the largest by default - one human-facing window wants room. Pick another
with cuttle up --screen 1366x768, or durably per context with screen = "..."
in config.toml (the flag wins); anything off the table is refused with the list.
Changing it on an existing profile changes only the screen and window, not the
logins or the rest of the fingerprint. Pool mode keeps one screen per seed so a
fleet of identities does not all report the same monitor.
The image bundles playwright-cli 0.1.20, pinned by PLAYWRIGHT_CLI_VERSION in
packages/browser/versions.env and the Dockerfile ARG it feeds, so the driver is
version-locked to the image the way the browser is. cuttle pw <args> (long form
cuttle playwright-cli) hands the args to it verbatim inside the container -
stdin, stdout, stderr and the exit code pass straight back:
cuttle pw goto https://example.com # any verb; it connects on its own
cuttle pw screenshot --filename=shot.png
cuttle downloads shot.png # pull the file to this host
cuttle pw detach # optional; the browser stays up- It execs into the container, so it works on every backend that has one:
docker execforlocal, the same through the tunnel forssh,kubectl exec deploy/<release>fork8s. Thedirectbackend has no container to enter and says so - run a driver of your own against that CDP endpoint instead. - It can only attach. The image sets
PLAYWRIGHT_MCP_CDP_ENDPOINTto the daemon's in-container endpoint, which routes every browser acquisition throughconnectOverCDP-openincluded - and the wrapper refuses the--endpoint/--extensionflags that could name another one. A driver that spawns its own browser - the failure that reads as a logged-out page - is not reachable from here. - No attach step. When a verb finds no live session the wrapper runs
attach --cdp=<in-container endpoint>for it and retries once, so the first command of a session is whatever you actually wanted to do. - State lives in the container and persists across invocations. That attach
spawns a session daemon beside the browser; every later
cuttle pwis a thin client of it, which is why tabs, refs and page state survive between commands.detachandclosestop that daemon only, leaving the browser running. - The session daemon does not survive a container restart. The browser's
profile is durable; the driver session deliberately is not - it cannot outlive
the browser it attached to. Nothing to do about it: the first verb after
cuttle uporcuttle up --recreatereconnects transparently. - If the browser dies mid-session, the next verb reconnects - to cuttle's own
replacement. Killing the browser takes the session daemon with it, so the
next
cuttle pwverb sees the same "is not open" the on-demand attach already answers: it re-attaches to the browser cuttle has brought back and runs what you asked for. The tabs and page state of the dead session are gone; the profile is not. Nothing drifts onto a browser of its own here - there is no playwright-managed browser in the image, and with the endpoint unset the driver fails loudly instead of launching one. The smoke harness kills the browser under a live session and asserts the driver lands back on cuttle's, UA and all. The replacement reuses the same profile dir, so--filenameoutput written before the death is still there afterwards (--ephemeralis the exception: its profile is a scratch dir that goes with the browser it belonged to). --filenameoutputs land in the downloads dir. The exec workdir is the session's download directory, so a screenshot, PDF or saved snapshot comes back out withcuttle downloads <name>like a page download. The driver's own auto-named output goes to a.playwright-cli/dotdir the listing hides.- A snapshot lands on this host. playwright-cli writes the page snapshot
after
goto,click,pressand the like to a file in the container and prints only a link to it;cuttle pw snapshotis given a--filenamein that same dir, so it links a file too instead of printing the whole tree (the first 40 lines follow the link). In the same exec,cuttle pwfetches the file through the daemon's loopbackGET /snapshotroute into$XDG_STATE_HOME/cuttle/<instance>/snapshots/(default~/.local/state; dir 0700, files 0600, the newest 50 kept) and rewrites the link to that path.<instance>is the--name(the context name on k8s), so the default instance's copies sit undercuttle/cuttle/beside the other instances'; discarding the profile (down --purge,purge-profile,up --purge-profile) removes that instance's dir with it. The daemon replaces every value its secret store holds for the session with its{{cuttle:NAME}}sentinel first, and the current value of every<input type=password>on the session's open pages (same-origin iframes included, read over CDP at fetch time) with{{cuttle:password-field}}- exact matches only (plain or as the snapshot quotes them), values under 4 characters (6 if all digits) left alone. A page the daemon cannot read in time is served masked by held values alone. The verb's own stdout, and asnapshotwith--raw,--jsonor its own--filename, are not masked. If the fetch fails the link is left pointing into the container, and a file-backedsnapshotsays so on stderr, naming--raw snapshot. The driver's console log (.playwright-cli/console-<ts>.log) is not copied: the printed line points atcuttle pw consoleinstead. - Driver help is per verb. A leading
cuttle pw --help(or-h) prints cuttle's wrapper help;cuttle pw <verb> --helppasses through and prints the driver's own help for that verb. playwright-cli 0.1.20 has nohelpordocsverb. - Never re-run
attachoropenmid-session. Either one stops the running driver session and starts a new one, dropping every ref it minted (the browser, its tabs and logins stay);openwith no URL also navigates the current tab toabout:blank. The wrapper attaches on its own when there is no session, so neither is ever needed -gotois how to load a page.
Experimental. Its flags, output and exit codes may still change.
cuttle jev-browse drives that same bundled driver on its own: it reads the
page's accessibility snapshot, asks a System-One decision model (TypeSafe's Jev)
which element to act on next, and has playwright-cli perform the pick - then
snapshots again. There is no LLM in the loop and no generated text: a decision is
one typed choice with a calibrated confidence, and a page where nothing makes
progress - or an answer naming an element the page never offered - ends the run
instead of being guessed at.
cuttle jev-browse --task 'download the latest invoice' \
--url https://example.com/billing --max-steps 25
cuttle jev-browse --task 'sign in' --text user=qa@example.com \
--text pass='{{cuttle:QA_PASS}}' # only the NAMES are sent
cuttle jev-browse --task 'list the open tickets' \
--extract 'one ticket, with its id and title' --json
cuttle jev-browse --task '...' --mock # no key, no model callExit codes are the result: 0 the task is done, 1 an error, 3 blocked (a
person is needed), 4 the step budget ran out. The task can also be given as
the first argument (cuttle jev-browse 'sign in'), as sugar for --task.
-
--urlis required on a fresh session. Without it the run starts wherever the browser already is, and a blank tab is refused up front ("the session has no page") rather than spending a step on it. -
The model judges the page; the loop acts. A run ends as done only when the model rates the current page as finishing the task (0.8 or more), from the page's address, title, headings, first ~120 lines of text and every control - so a task framed as finding or reading something can end done on the page that holds the answer; read it with
--extractorcuttle pw snapshot. Every control is offered, links to a section of the same page included; what was already tried is in the history the model sees, with whether it changed the page, and it is asked not to repeat it. Anone- the model finding no useful action - ends the run with 3 at the confidence it gave; it never implies done. -
Writes are refused on the chosen action, not pruned from the offer. The loop runs on real signed-in accounts. A control whose own name carries an irreversible verb (pay, buy, purchase, checkout, place order, transfer, donate, delete, remove, send, post, publish, sign out, unsubscribe; for a link or tab only when its name leads with it) is refused outright; so is Enter when a button of the form or dialog holding the focus carries one. Typing into a box is never refused - a "Post code" field is named by its content, and the submit is where a write happens. Every other pick, Enter included, costs one more model call: "does taking this change something on the site rather than navigate, open, sort or filter?", refused at 0.2 or more. A refusal goes into the history as
refusedand the model picks again; the same pick on the same page is refused without asking, and that second refusal is printed and ends the run with 3, naming the write the task needs, for a person to take withcuttle pw. The word list errs toward refusing and is not a guarantee. -
Options carry their section and state. Each control is offered as
[<landmark>] <role>: <name>(e.g.[banner: Site] searchbox: Search) with its filled/checked/expanded state, so a site-wide search box and a form's own box are different choices. While a dialog is open only its controls are offered. -
One persistent driver client per run. Verbs go to the playwright-cli session daemon through a single long-lived client inside the container instead of one exec each - same session, same humanized input as
cuttle pw. Anything it does not handle (attach, open, a client that fails to start) falls back to an exec. The lease renew before each driving verb goes through it too, so a takeover still stops the run before its next action.--jsonreports per-step timings (model_ms,driver_ms,api_calls,spawns,verbs,input_tokens,output_tokens); the outcome adds the run totals,elapsed_msandmodel. -
The key is an environment variable, and only that.
export CUTTLE_TYPESAFE_API_KEY=...in the shell that runscuttle- there is no flag and no config key for it, so it never reaches argv orconfig.toml. It is read on this host; the container never sees it. A value that must come from a vault goes in the same way as anything else here, e.g.export CUTTLE_TYPESAFE_API_KEY=$(op read op://vault/typesafe/api-key). -
An OpenRouter key works in the same variable. OpenRouter resells the same model, and a key beginning
sk-or-routes itself there - same variable, no flag and nothing to configure. The first-party API is the default for every other key. The OpenRouter model is pinned to an exact version rather than thelatestalias, so an upgrade is a release of cuttle and never a silent change of judgement mid-run. -
--mockneeds no key. It exercises the whole loop - snapshot parsing, element filtering, the driver calls, the exit paths - with a local decider that picks the first plausible element. Only the judgement is mocked: it clicks and types on the live page for real, so point it at a page you are willing to have it press the first button on. -
It shares the driver session with
cuttle pw. Same session daemon, same tabs, same refs. When the run ends blocked (exit3: an escalation, a login wall, a native dialog) or runs out of steps (exit4), the browser is left exactly where it stopped, so the next step is a plaincuttle pw snapshot(with the same--name/--contextas the run) and manual verbs from there - nothing to re-navigate, nothing to re-authenticate. -
Humanized input stays on. Every action the loop takes goes through the same humanization as any other driver action; there is no fast path for it, by design (see
docs/knowledge/decisions/humanize-over-speed.md). -
What is fast is the decision, not the action. A choice comes back in a few hundred milliseconds where an LLM round trip is seconds, and that is where the saving is. The action is unchanged: a humanized click is about half a second, typing about an eighth of a second per character, plus whatever the page takes to load. Over a 20-step run the wall clock is dominated by the browser, not by the model, so expect a modest end-to-end speedup - the large factor is in tokens and per-decision latency, not in how fast the page gets driven.
-
Secrets work as everywhere else.
--textsupplies what may be typed and only the NAMES are sent to the model - the value is looked up here afterwards and handed to the driver verbatim, so a{{cuttle:NAME}}sentinel fromcuttle secret setpasses through untouched and is substituted inside the CDP frame (see "Secrets the session types for you" below). Element labels and page text are data throughout: quoted into the log, offered as choices, never followed as instructions. A--textvalue is argv, visible in the host'spswhile the run lasts; pass a secret as a{{cuttle:NAME}}sentinel to keep it out. One residual either way: a value typed into a box whose form submits with GET ends up in the page URL, and the URL is what the step log prints and what the next request names the page by - so a search box is not a place to put a credential. -
--extractpicks items, it does not answer. On the page the run ended on, whatever the ending (done, blocked or out of steps), the model judges each line against the description and the matching lines are printed verbatim; headings, labels and descriptive prose are deliberately never picked. It works best for list-shaped answers (tickets, rows, results) and returns nothing useful for a question whose answer is a sentence. It needs the model, so it is refused with--mock.
Two drivers on one page interleave clicks and navigations into nonsense, so the
daemon hands out a driving lease per browser. cuttle jev-browse takes it for
the whole run, renews it every 40 seconds and before every action it takes, and
releases it on the way out, Ctrl-C included.
- A second driver is refused, with who and how long. Another
jev-browseexits1without touching the page, andcuttle pwrefuses verbs that drive it (click, fill, goto, eval, anything unrecognized); both name the holder and how long it has held the browser. Read verbs -snapshot,console,tab-list,screenshot, the cookie/storage/request listings - still run, so a person can watch a run without stopping it. - Takeover is explicit.
cuttle jev-browse --takeover ...orcuttle pw --takeover <verb> ...(the flag goes before the verb) frees the lease first. The evicted run notices before its next action and exits1with "session was taken over by ", leaving the page where it was. In front of a read verb it takes nothing over, since a read never needed the lease. - A holder that dies frees the browser by itself. The lease lasts 120 seconds without a renew, so a killed run blocks nobody for longer than that. Nothing is persisted: a daemon restart clears every lease, along with the browsers they guarded.
cuttle pwnever takes the lease itself. It only checks for a holder, so two agents both driving withcuttle pware not serialized against each other; only ajev-browserun holds the browser.- Plain CDP clients are not gated. The lease coordinates cuttle's own
drivers; a client attached straight to the CDP endpoint does not ask. The
HTTP surface is loopback-only:
GET /lease(status),POST /lease?owner=(acquire;&token=renews;409names the holder),DELETE /lease?token=(release) or?force=true&owner=(takeover).
cuttle logs prints the container's log (docker logs / kubectl logs) - the X
server, the viewer, Chrome's own stderr, and the daemon's lines.
That log is discarded when the container is replaced, so a session daemon with a
durable profile - what cuttle up runs by default - also writes its own lines
to /data/logs/serve.log inside the profile volume. That copy survives cuttle up --recreate and image upgrades, which is what makes yesterday's incident still
readable today:
docker exec cuttle cat /data/logs/serve.logIt is capped at 20MB with one previous generation kept alongside it
(serve.log.1). Pool mode does not write it (a fleet server's stdout is already
collected by compose or k8s), and neither does --ephemeral, which mounts no
volume for it to survive in.
The session's full Chrome profile - cookies, localStorage, IndexedDB, service
workers - lives in a named Docker volume (cuttle-<container>-profile) or a k8s
PVC. It survives cuttle up restarts, cuttle up --recreate, and image upgrades,
with no flag. Reset it deliberately: cuttle up --recreate --purge-profile,
cuttle purge-profile, or cuttle down --purge. --ephemeral opts out for a
disposable session. A plain cuttle down never touches the volume.
A stop saves what the profile has not. Chrome writes cookies to its profile on
a ~30s timer and localStorage on ~5s, so a login made moments before a stop lives
only in the browser's memory. On the way down the daemon snapshots each browser's
cookies over CDP first and restores them at the next launch, which is why logging
in and immediately running cuttle down still leaves you logged in. That ordering
is why the daemon puts Chrome and the X server in their own process groups: the
container's init signals the whole group at once, and a browser that dies in the
same instant as the daemon has nothing left to snapshot. Every seed is captured
concurrently under one 8s budget, inside the stop grace that docker (-t 15) and
k8s (30s by default) allow.
Creation-fixed settings. --image, the persistence choice, the host ports,
--idle-timeout, --humanize, --allow-context-creation and --block-third-party-cookies are
baked into the container at creation. (--idle-timeout reaps per-seed browsers
in a pool; a session daemon ignores it with a warning, since reaping the one
browser would empty the viewer.) Passing them against an existing container warns
and is ignored; use --recreate to change them. (On k8s they re-apply on every
helm upgrade.)
Third-party cookies are allowed, like stock Chrome. The upstream
ungoogled-chromium patch series compiles in a block-by-default
(extra/inox-patchset/0006-modify-default-prefs.patch re-registers
kCookieControlsMode as kBlockThirdParty), which is right for a privacy browser
and wrong for one whose job is to look like Chrome and keep logins: blocking
breaks embedded SSO, silent token refresh in an iframe, and some payment and
captcha challenges, all of which load but never finish. The daemon writes the pref
into every seed's profile on each launch, putting it back on stock behavior.
cuttle up --block-third-party-cookies (or CUTTLE_BLOCK_THIRD_PARTY_COOKIES=1,
or blockThirdPartyCookies: true in the chart) restores blocking for a
privacy-hardened profile.
cuttle secret is the host half of daemon-owned secrets. You hand the running
session a value under a name; a driver then fills the sentinel {{cuttle:NAME}}
and the daemon substitutes the real value inside the CDP frame. The value never
enters a driver command line, an agent's context, or a log.
op read op://vault/github/password | cuttle secret set GH_PASS --stdin
cuttle secret set GH_TOTP --exec 'op item get GitHub --otp' # registers a resolver
cuttle secret refresh GH_TOTP # re-runs it, fresh TTL
cuttle secret prompt SMS_CODE # ask the human, echo off
cuttle secret ls # names and shape, never values
cuttle secret rm GH_PASS # value AND resolver-
A value only ever travels on stdin or in a request body. Never in argv, which is world-readable in
/procand lands in shell history.settakes--stdinor--exec, never a positional value, and no verb prints a stored value back -lsreports source, state, length and origin only. The one verb that will take a value in argv isjev-browse --text name=value, which is exactly why a secret there belongs in a{{cuttle:NAME}}sentinel instead. -
Resolution happens here, not in the container. The daemon has no vault, no keychain and no biometrics, and there is no daemon-to-host callback, so
--execruns the command on this host atsettime. Its stderr is discarded on purpose: vault error text routinely quotes item names and partial values. -
--execwrites a recipe to your config file, at~/.config/cuttle/config.toml($XDG_CONFIG_HOME), as a plain table of name to command. The command is stored, never the value:[secret] GH_TOTP = "op item get GitHub --otp"
That file is what makes
refreshwork aftercuttle down && cuttle up- the daemon's memory is gone, the recipe is not. Treat it like any other dotfile holding a vault query: it is not a credential, but it names one. -
Values live in daemon memory, per seed, under a TTL - 15 minutes by default,
--ttlto change it, clamped at 12 hours. Never on disk, never in the profile volume, never in a snapshot. A container stop,cuttle down, or a browser restart drops every value. -
Expiry keeps the name. A fired TTL clears the value but leaves the registration, so the substitution error can say "run
cuttle secret refresh NAME" instead of "unknown name". This is why a TOTP works at all: resolve it atsettime and it is dead in 30 seconds, sorefreshimmediately before the fill is the intended shape. -
Every verb acts on ONE session. With more than one running, name it:
cuttle --name staging secret ls(its ports are discovered). Without--name(or--context) you are targeting the default session, which on a busy host is not necessarily the one being driven. -
The routes are loopback-only, behind the same Host and Origin guard as the rest of the daemon's HTTP surface. Anything that can reach the CDP port can reach them, so publishing that port to a network is publishing the secret store with it - see "Running on a server".
Reading a credential back is the leak this pair exists for: a snapshot taken to debug a failed login, with the value still in the field.
cuttle secret capture API_KEY --selector '#new-token' # into session memory
cuttle secret capture API_KEY --selector '#new-token' --to file:key.txt
cuttle secret capture API_KEY --selector '#new-token' --to exec:'gh secret set API_KEY'
cuttle secret capture API_KEY --from-clipboard # after a "copy" button
cuttle grab https://app.example.com/export.csv out.csv # authenticated fetch
cuttle downloads --latest --wait 30s # pull without naming itcapture --to memoryis the default and the cheapest: the value stays in the daemon under a TTL, ready to be filled as{{cuttle:NAME}}, so the common generate-on-A, type-into-B flow never lets it out of the container.file:writes 0600 and atomically, and refuses a path inside a git working tree (--forceoverrides).os.WriteFile's mode applies only on create, so writing over an existing scratch file would otherwise leave a credential world-readable.exec:puts the value on the command's stdin, never in its arguments.grabis cookie auth only. It fetches from inside the browser with that origin's cookies and noAuthorizationheader, so a token-auth API is out of reach this way. With a destination it writes 0600 and prints the path instead of the body.cuttle auth statusreports, per domain, how many cookies the profile holds and when the first expires - never names or values. Cookies are not proof of a valid session, so treat a hit as "probably still signed in, navigate and look". It is the cheap check that stops a session re-running a login it did not need, and every avoided login is a credential handling event avoided.cuttle open --wait/--untilhold the terminal while a human finishes something in the viewer - a captcha, an SSO redirect, a device approval - instead of the caller polling.--untiltakesurl:<glob>,gone:<glob>,title:<substring>orjs:<expression>.
The browser verbs take --cdp-port and --vnc-port. Use them when the defaults
are taken:
cuttle up --cdp-port 9444 --vnc-port 6099- Ports are pinned only when
upcreates the container. Every later verb -upitself (a restart, an idempotentup,--recreate),status,open,downloads,secret,authandgrab- discovers the instance's own ports, running or stopped, so afterwards you target it with just--name(and--context).statusand every verb that needs the live session refuse a stopped instance and name theupthat resumes it; none falls back to the default ports, which may be another instance's.pwandjev-browseexec inside the container and use no host port at all. - Port-shadow gotcha:
docker runerrors on a docker-vs-docker clash, but not when a native process already owns the host port.cuttle upthen prints a mapping that is silently dead - your client hits the other process. Verify withlsof -nP -iTCP:<port> -sTCP:LISTEN(want OrbStack/Docker), or checkcurl http://127.0.0.1:<port>/json/versionnames the engine you expect. - Moving an instance's ports takes
--recreate. A restarted container keeps the ports it was created with, so--cdp-port/--vnc-porton a plainupof an existing one warn and are ignored;up --recreate --cdp-port N --vnc-port Mrebuilds it on the new ones (the persistent profile survives).
--name is the other axis. It runs a separate docker (local/ssh) instance -
its own container, profile volume and tunnel - so unrelated persistent sessions can
sit side by side. Give each its own ports and pass the same --name to every verb.
Two logged-in sessions you want to keep at once (two accounts on one site, say) are
two --name containers. For many disposable identities driven by code, run pool
mode (below) instead of multiple containers.
Selecting an instance, once. --context and --name are flags on cuttle
itself, not on a particular verb, so everything that reaches an instance honors
them - cuttle pw and cuttle jev-browse as much as up/status/secret. Each
resolves flag > env var > config > built-in default:
| flag | env | config | default | |
|---|---|---|---|---|
| context | --context |
CUTTLE_CONTEXT |
default_context |
local |
| container | --name |
CUTTLE_NAME |
the context's name |
cuttle |
cuttle --name scraper up --cdp-port 9444 --vnc-port 6099
cuttle --name scraper pw snapshot # before the verb: see below
export CUTTLE_NAME=scraper # or select it for the whole shell
cuttle statuscuttle pw passes every argument to the bundled driver verbatim, so cuttle's own
--context/--name have to come first, ahead of the driver's verb and any
driver flag (cuttle --name scraper pw snapshot); from the first driver arg on,
everything is the driver's. Every other verb takes them in any position. A
context that stands for one instance can carry the name instead (name = "scraper" in its stanza), and then plain cuttle --context box pw snapshot
reaches it.
For many isolated identities behind one endpoint - no CLI, no viewer - run the
container directly with --mode=pool and select a seed per connection:
docker run --rm -p 9222:9222 ghcr.io/glim-sh/cuttle:latest \
cuttle serve --headless=false --mode=pool --idle-timeout=600http://127.0.0.1:9222?fingerprint=12345
http://127.0.0.1:9222?fingerprint=12345&timezone=America/New_York&locale=en-US
Each distinct fingerprint seed gets its own isolated Chrome with a stable,
coherent identity; point one CDP client per seed at the seed-parameterized URL.
Pool mode requires the seed: an unseeded connect or /json/version is a 400,
so a probing client can never spawn a direct-egress default browser by accident.
--fingerprint=<seed> on the server names a seed for unseeded connections if you
want one. Nothing in pool mode bounds how many seeds run at once except
--idle-timeout (set it) and your client's own seed ring, so size the container's
memory for the number of seeds you actually cycle. The mode is CUTTLE_MODE as an
env var and, like --idle-timeout, is fixed for the life of the container.
Proxy per seed: pass an authenticated proxy on the connect URL - cuttle strips
the inline credentials and answers the proxy 407 over CDP, so fork binaries that
reject inline creds still work. WebRTC shows what Chrome shows without a camera
grant - a .local host candidate and a srflx - with the srflx at the seed's exit
IP (the proxy's under geoip or webrtc-ip=auto, the host's own egress for a
direct seed), and sends no UDP packet from the real interface. Real-time media
connects only through a TURN server over TCP or TLS, which Chrome dials through
the proxy; a page's own STUN server can tell the srflx was never asked for. A
proxied seed whose exit IP does not resolve pins a WebRTC handling policy
instead, so ICE gathers nothing and WebRTC's own DNS lookups (a TURN hostname)
fail rather than reach the local resolver. A connection's own ?proxy= keeps
the server's default timezone (CUTTLE_FINGERPRINT_TIMEZONE, else UTC) unless it
also passes geoip=true or timezone and locale - set them together so the
identity is coherent. CUTTLE_PROXY sets
a server-level default for every seed, and its geo follows it without geoip.
The amd64 image runs native on any Linux server:
docker run -d --restart unless-stopped --name cuttle \
-p 127.0.0.1:9222:9222 --shm-size=2g \
-v cuttle-profile:/data -e CUTTLE_KEEP_PROFILE=1 \
ghcr.io/glim-sh/cuttle:latestBind CDP to 127.0.0.1 and reach it over an SSH tunnel
(ssh -L 9222:localhost:9222 user@server) - the tunnel is the auth boundary, CDP
has none of its own. --shm-size=2g avoids Chrome crashes under load. Add
-p 127.0.0.1:6080:6080 -e CUTTLE_VNC=1 for the viewer. The ssh backend
automates exactly this from the CLI.
Keep the volume AND CUTTLE_KEEP_PROFILE=1 together. The volume alone is not
a durable identity: without the env the daemon treats the profile as disposable,
so every browser launch gets a new fingerprint seed and the saved logins are
re-injected onto it - one account on a shifting device, which is what account
anti-fraud correlates. The startup log states which one you have (session profile is durable / disposable), and GET / reports durable_profile.
cuttle up sets both; a hand-written docker run must too.
A proxy for a signed-in account:
- Set it with
-e CUTTLE_PROXY=http://user:pass@host:port. Timezone, locale and the WebRTC address follow the proxy's exit IP on their own; pin them withCUTTLE_FINGERPRINT_TIMEZONE/CUTTLE_FINGERPRINT_LOCALEonly when the exit does not geolocate where it really is. - Keep the exit sticky: one account, one exit region. A login that moves between regions reads as a takeover.
- Never route the browser through a TLS-intercepting proxy (an egress gateway
that injects credentials by terminating HTTPS with its own CA). The site then
sees the gateway's TLS handshake, often HTTP/1.1 only, under a Chrome user
agent - a mismatch no fingerprint setting can repair. Give Chrome a plain
CONNECTtunnel to the proxy; checkhttps://tls.peet.ws/api/allfrom the browser if unsure.
VNC is loopback-only and unauthenticated. The viewer serves plain HTTP; the
-p 127.0.0.1:PORT mapping is the security boundary. Never bind it publicly.
Probes and metrics live on the CDP port and never launch a browser:
GET /healthz- liveness: 200 while the daemon serves HTTP and its pool lock can be taken; 503wedgedif the lock is stuck. Use it forlivenessProbeanddocker --health-cmd. Do not probe/json/version: that endpoint launches a browser on demand, and in pool mode refuses an unseeded request outright.GET /readyz- readiness: 200 when attaches will work; 503 with areasonwhile the daemon drains at shutdown, when a headed browser's X display is gone, when the data dir is not writable, or when the session browser has failed to launch 3 times in a row.GET /metrics- Prometheus text:cuttle_browsers_active,cuttle_cdp_connections_active,cuttle_cdp_attaches_total,cuttle_browser_launches_total{result},cuttle_browser_launch_seconds,cuttle_browser_exits_total{cause},cuttle_state_captures_total{result},cuttle_state_injects_total{result},cuttle_state_inject_seconds, plus the Go runtime and process collectors.GET /- the CLI's briefing JSON (live browsers and their connections); it predates the probes and stays forcuttle status.
The helm chart wires both probes by default (probes.* in values.yaml).
The image bakes our stealth-Chromium build at /opt/browser/chrome and selects it
with CUTTLE_BROWSER_BINARY. Point that variable at another Chromium-family
binary present in the container to swap engines.
- "is running but CDP is not answering" / "started but CDP never came up."
Usually a stale container from a previous
cuttle upthat failed because the host port was taken. Currentcuttle upauto-removes such zombies; on an older build runcuttle up --recreate.cuttle statusprints a log tail with the real cause. - Graceful down matters.
cuttle downdoesdocker stop -t 15so Chrome exits clean, which avoids crash-restore junk tabs. Neverdocker rm -fa running cuttle - the SIGKILL makes Chrome record a crash. - A crash on a
service_workertarget is a client bug, not detection. Olderplaywright-coreasserts on a service_worker target with nobrowserContextId.cuttle servepatches the shape so clients do not trip; with your own Playwright, passserviceWorkers: "block"tonewContext. - Chrome's container log noise is not a stealth failure.
vkCreateInstance: Found no drivers,Automatic fallback to software WebGL, dbus connect failures,Failed to adjust OOM scoreandGPU stall due to ReadPixelsare expected on a headless host. Never add--enable-unsafe-swiftshader: it exposes the raw software renderer and makes the fingerprint worse.