temperDocs
Menu

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 returns profile_exists.
  • version goes up by one each time a lease saves new state, and saved_at is the time of the last save.
  • Deleting a profile also deletes the saved state. It fails with profile_in_use while 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

ModeProfileBehavior
writeRequiredLoads 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.
readRequiredLoads the latest saved state and never writes back. Any number at once.
cleanNoneA 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:

bash
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 changes
js
const 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: and about:blank pages, 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.*) and javascript: or data: 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:

CategoryMatches, for example
payCard number or CVC fields; buttons like "Pay", "Buy now", "Place order", "Checkout", "Subscribe"; checkout or billing pages.
change_permissionAny password or one-time-code field; buttons like "Change password", "Share", "Invite", "Grant access".
deleteButtons like "Delete", "Remove", "Cancel subscription", "Close account".
sendButtons 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:

  1. 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_at is set.
  2. 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.
  3. 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:

bash
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:

FieldMeaning
max_leases_per_developerHow many leases can be live at once across all your environments.
max_leases_per_end_userHow many leases one end user can have live at once.
lease_ttl_secsHow 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.