A skill is the unit of reuse: a named, versioned action path distilled from a successful run, with the parts that varied lifted out so it serves every customer, date or record rather than only the one it was compiled on. This guide covers compiling one, reading its contract, replaying it, and the mistake that produces a skill which technically runs and does the wrong thing forever.
What is a browser skill?
It is a compiled program: the ordered actions that accomplished a goal, minimized to drop the retries and dead ends, addressed by element index rather than by selector, with the variable parts replaced by tokens. Because it is deterministic, replaying it makes no planner call — it executes the recorded path against the live page.
How do I compile one?
POST /api/v1/skills. It takes either shape — { target, goal, as } for a registry goal, or { url, prompt, success, as } for free text — and `as` is what names the skill. A compile costs 50 credits, settled higher-of against the model cost of the discovery it ran. A successful compile also embeds the intent, which is what makes the skill reachable later through dispatch.
curl -X POST https://twin-browser.com/api/v1/skills \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://app.example.com/invoices",
"prompt": "Export the statement for {{param:month}} as PDF",
"success": { "kind": "urlIncludes", "value": "/invoices/export" },
"as": "export-invoices"
}'
# → the compiled skill descriptor, with its version and runIdThe three token families — and the mistake to avoid
A skill is only account-independent when everything that varied is late-bound, and there are three kinds with three different owners. Get this wrong and you get a skill that runs perfectly and does the wrong thing every time — a "post a comment" skill that re-posts whatever sentence the compile run happened to type.
{{secret:NAME}} — owned by the vault
A credential. Stored write-only at POST /api/v1/secrets, resolved inside the browser at fill time, redacted from the returned path, the frames and the logs. Never appears in a prompt as a literal.
{{var:NAME}} — owned by your tenant
A per-tenant entity value: a profile URL, an account id. Captured after a run and forwarded on the next one, so it does not have to be passed by the caller.
{{param:NAME}} — owned by the caller
A call argument: the month to export, the text to post. Supplied per invocation in `params`. A replay with a param token still unresolved is REFUSED before it opens a browser, rather than run with a stale literal.
How do I know what a skill expects?
Read the catalog. GET /api/v1/catalog returns each compiled skill with the params it takes, the secrets its path fills, and whether the host's auth is ready for it — so an agent can plan a run before spending a credit, and you can see at a glance whether a skill is genuinely parameterized or quietly frozen.
curl "https://twin-browser.com/api/v1/catalog?host=app.example.com" \
-H "Authorization: Bearer $TWIN_API_KEY"
# {
# "count": 1,
# "skills": [
# { "name": "export-invoices", "version": 3,
# "params": ["month"],
# "secrets": ["EXAMPLE_PASSWORD"],
# "auth": { "mode": "session", "ready": true, "missing": [] } }
# ]
# }How do I replay it?
POST /api/v1/skills/{name}/run — the path parameter is the NAME you compiled it under, not an id — with a `target` and the params the catalog named. The replay costs 1 credit and makes no model call.
curl -X POST https://twin-browser.com/api/v1/skills/export-invoices/run \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{ "target": "https://app.example.com/invoices",
"params": { "month": "2026-05" } }'
# { "success": true, "steps": 4, "runId": "…",
# "credits_charged": 1 }What happens when the page changes?
A replay that no longer matches returns a failure and is refunded — it does not silently fall back to the planner, because a cheap deterministic path that quietly becomes an expensive one is a worse surprise than a failure. Route the goal through POST /api/v1/dispatch instead and a failed match falls through to a fresh compile, storing a new version under the same name.