Concepts
Browser
Give the agent a real browser with the end user's saved logins, without letting it read their cookies.
Some tasks need a browser that is signed in as the end user: checking an order on a shopping site, filing a form in a web app that has no API. Temper keeps each end user's logged-in browser state in a profile and lends the agent a browser through a lease. The browser runs in its own short-lived browser VM, separate from the end user's environment. The agent drives it only through a broker on the host, which filters what it can do.
Profiles
A profile is one end user's saved browser state for a set of sites: cookies, localStorage and IndexedDB. It does not include the cache, history or passwords saved by the browser. Profiles are encrypted at rest. The state is decrypted only on the host and inside the browser VM, and never enters the end user's environment, so the agent cannot read it from its own disk.
- A profile belongs to one
end_user_id. Names are unique per end user; a duplicate returnsprofile_exists. versiongoes up by one each time a lease saves new state, andsaved_atis the time of the last save.- Deleting a profile also deletes the saved state. It fails with
profile_in_usewhile a lease on it is pending, active or releasing.
import { Temper } from "@temper-hq/sdk";
const temper = new Temper();
const profile = await temper.browser.createProfile({ end_user_id: "user-42", name: "work" });
for await (const p of temper.browser.iterateProfiles({ end_user_id: "user-42" })) {
console.log(p.id, p.name, p.version, p.saved_at);
}from temper_hq import Temper
temper = Temper()
profile = temper.browser.create_profile(end_user_id="user-42", name="work")
page = temper.browser.list_profiles(end_user_id="user-42")
for p in page.data:
print(p.id, p.name, p.version, p.saved_at)profile, err := client.Browser.CreateProfile(ctx, "user-42", "work")
if err != nil {
log.Fatal(err)
}
page, err := client.Browser.ListProfiles(ctx, "user-42", 0, "")
if err != nil {
log.Fatal(err)
}
for _, p := range page.Data {
fmt.Println(p.ID, p.Name, p.Version, p.SavedAt)
}Leases
A lease is one browser VM for one environment, for a limited time. Each browser VM serves a single lease and is destroyed when the lease ends; it is never reused for another end user.
Modes
| Mode | Profile | Behavior |
|---|---|---|
write | Required | Loads the profile and saves the new state when released. Only one write lease per profile at a time; a second one fails with profile_locked. The default when a profile is given. |
read | Required | Loads the latest saved state and never writes back. Any number at once. |
clean | None | A fresh browser with no saved state. The only mode without a profile. Useful when the end user needs to take over, for example to get past a CAPTCHA, but no login needs to be kept. |
The profile must belong to the same end user as the environment, otherwise the request fails with bad_request. Other errors:
environment_destroyed, and lease_limit_reached (429) when you are at a concurrency limit.
Lifetime
A lease is valid for a fixed TTL (lease_ttl_secs from the limits endpoint). Renewing
gives it a full TTL from now. A lease that is not renewed expires, and an expired lease is reclaimed; acquire a new one. Renewing a
lease that is not active fails with lease_not_active.
States: pending, active, releasing, released, expired, failed.
Releasing and saving
Release a write lease with save left out or true to keep the new login state. The state is
still inside the browser VM at that point, so the lease first moves to releasing and the call returns. Once the host has exported and
encrypted the state, the lease becomes released with save_outcome: "saved" and the profile's version goes up. If the state does
not come back in time (for example, the host is offline), the lease is settled as released with save_outcome: "discarded" and the
version stays the same. Poll the lease to see the outcome. A releasing lease cannot be renewed or switched to save: false.
save: false, read leases and clean leases go straight to released. Leases that expire or whose browser VM fails never save: Temper
would rather have the end user sign in again than keep state that might be half written.
for await (const lease of temper.browser.iterateLeases({ environment_id: envId, state: "active" })) {
console.log(lease.id, lease.mode, lease.expires_at);
}
let lease = await temper.browser.releaseLease(leaseId); // save defaults to true for write leases
while (lease.state === "releasing") {
await new Promise((r) => setTimeout(r, 2000));
lease = await temper.browser.getLease(leaseId);
}
console.log(lease.save_outcome); // "saved" or "discarded"import time
for lease in temper.browser.list_leases(environment_id=env_id, state="active").data:
print(lease.id, lease.mode, lease.expires_at)
lease = temper.browser.release_lease(lease_id) # save defaults to true for write leases
while lease.state == "releasing":
time.sleep(2)
lease = temper.browser.get_lease(lease_id)
print(lease.save_outcome) # "saved" or "discarded"it := client.Browser.IterLeases(temper.ListLeasesParams{EnvironmentID: envID, State: "active"})
for it.Next(ctx) {
l := it.Value()
fmt.Println(l.ID, l.Mode, l.ExpiresAt)
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
lease, err := client.Browser.ReleaseLease(ctx, leaseID, nil) // nil: save (the default for write leases)
for err == nil && lease.State == "releasing" {
time.Sleep(2 * time.Second)
lease, err = client.Browser.GetLease(ctx, leaseID)
}
if err != nil {
log.Fatal(err)
}
fmt.Println(*lease.SaveOutcome) // "saved" or "discarded"Using the browser from the agent
The agent normally gets its lease from inside the environment, and the broker acquires it on the agent's behalf. The environment
includes a temper-browser tool that acquires a lease and opens a Chrome DevTools-compatible endpoint on 127.0.0.1, so Playwright or
Puppeteer can connect with connectOverCDP:
temper-browser bridge --profile bpr_0123456789abcdef0123456789abcdef --port 9222
# write mode by default with a profile; --mode read|clean, --no-save to discard this session's changesconst browser = await chromium.connectOverCDP("http://127.0.0.1:9222");The lease is renewed automatically while the connection is open. When the agent disconnects, the tool releases the lease (saving for a
write lease unless --no-save was given). Your backend can list, renew and release the same leases with the API and start takeovers;
acquiring and listing leases from the backend uses the same lease service and limits.
What the agent can and cannot do
In a lease with a profile, the broker passes only an allowlist of Chrome DevTools Protocol commands:
- Navigate: open
http:,https:andabout:blankpages, reload, go back and forward, manage tabs. - See: read the accessibility tree and take screenshots.
- Act: mouse, keyboard and text input, plus scrolling to and focusing elements found in the accessibility tree.
Everything else is refused, including:
- running JavaScript (
Runtime.*) andjavascript:ordata:navigation; - reading cookies or changing requests (
Network.*,Fetch.*); - reading storage (
Storage.*,IndexedDB.*,DOMStorage.*); - reading the raw DOM or hidden fields (
DOM.getDocument,DOM.getOuterHTML) and setting file inputs.
So a prompt-injected agent cannot read the end user's cookies or session tokens, even while it is using them. Refused commands get a CDP error back. The pages' own scripts still run normally, as in any browser.
A clean lease has no secrets to protect, so it allows the full protocol (Playwright works as usual), except a short blocklist:
navigating to file:, chrome: and other internal schemes, changing the browser's proxy, exposing the protocol to page scripts, and
other ways around the broker.
In both modes, the browser VM's traffic goes through an egress proxy that applies the environment's
egress allowlist, and every agent action is written to the audit log with source browser.
Approvals for risky submissions
In a lease with a profile, a click, Enter or text input that would submit a form is checked before it reaches the page. The broker looks at the form's target, the fields' types and names, and the button's label. It never reads the values the agent typed. It sorts the submission into an action category:
| Category | Matches, for example |
|---|---|
pay | Card number or CVC fields; buttons like "Pay", "Buy now", "Place order", "Checkout", "Subscribe"; checkout or billing pages. |
change_permission | Any password or one-time-code field; buttons like "Change password", "Share", "Invite", "Grant access". |
delete | Buttons like "Delete", "Remove", "Cancel subscription", "Close account". |
send | Buttons like "Send", "Post", "Publish", "Reply". |
These categories need an approval: the broker holds the input, Temper sends your backend a signed
approval.requested webhook with the button label, method and target URL, and the input goes through only if you approve. Denied or
expired approvals fail the action. Payments need approval every time; they accept only a one-time grant. Other submissions are
allowed, subject to your policies. See approval webhooks for handling the
requests.
Password fields count as risky because an agent signing in by itself is how account takeovers happen. Normal sign-in should go through a takeover instead.
Takeover
When a site needs the end user (to sign in, pass a CAPTCHA, or confirm a code), hand the browser to them:
- Your backend calls takeover on an active lease. From then on the broker refuses every
command from the agent, and the lease's
takeover_started_atis set. - The response has a
view_url. Give it to the end user, for example in your app. The page shows the live browser and lets them click and type. The link is single-use: it works until the page connects, and expires after 10 minutes. Call takeover again for a fresh link. - When they are done, the end user clicks Hand back on the page, or your backend calls handback. The agent continues where it left off.
What the end user types goes only to the browser. It does not pass through the agent and is not written to the audit log. If they sign in during a write lease, the new login is saved to the profile when the lease is released.
const { view_url, expires_at } = await temper.browser.takeover(leaseId);
await notifyEndUser(view_url); // your code
// Later, if the end user did not click "Hand back":
await temper.browser.handback(leaseId);takeover = temper.browser.takeover(lease_id)
notify_end_user(takeover.view_url) # your code
# Later, if the end user did not click "Hand back":
temper.browser.handback(lease_id)takeover, err := client.Browser.Takeover(ctx, leaseID)
if err != nil {
log.Fatal(err)
}
notifyEndUser(takeover.ViewURL) // your code
// Later, if the end user did not click "Hand back":
if _, err := client.Browser.Handback(ctx, leaseID); err != nil {
log.Fatal(err)
}Errors: lease_not_active if the lease is not active, host_unavailable (503) if the environment's host is not reachable, and
not_taken_over when handing back a lease that is not taken over.
When the agent asks for a takeover
The agent can ask for the end user when it gets stuck. With the temper-browser bridge running, it sends:
curl -X POST "http://127.0.0.1:9222/temper/request-takeover?reason=Please+sign+in+to+your+bank"Temper sends your backend a signed browser.takeover_requested webhook with lease_id, end_user_id, profile_id and reason
(text the agent wrote, for showing to the end user). Your backend then calls takeover as above. A request is refused if the lease is
not active, is already taken over, or already asked within the last minute.
Limits
Get browser limits returns:
| Field | Meaning |
|---|---|
max_leases_per_developer | How many leases can be live at once across all your environments. |
max_leases_per_end_user | How many leases one end user can have live at once. |
lease_ttl_secs | How long a lease lasts without renewal. |
Acquiring a lease beyond either concurrency limit fails with lease_limit_reached. Time in leases with a profile is metered as
browser_hours; see usage and limits.