diff --git a/docs/docs.json b/docs/docs.json
index f9cf5278..c41e6f7f 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -42,6 +42,14 @@
"multi-step-automations/workflow-parameters"
]
},
+ {
+ "group": "Optimization",
+ "pages": [
+ "optimization/browser-sessions",
+ "optimization/browser-profiles",
+ "optimization/cost-control"
+ ]
+ },
{
"group": "Going to Production",
"pages": [
diff --git a/docs/optimization/browser-profiles.mdx b/docs/optimization/browser-profiles.mdx
new file mode 100644
index 00000000..2e49366c
--- /dev/null
+++ b/docs/optimization/browser-profiles.mdx
@@ -0,0 +1,912 @@
+---
+title: Browser Profiles
+subtitle: Save and reuse authenticated browser state across runs
+slug: optimization/browser-profiles
+---
+
+A **Browser Profile** is a saved snapshot of browser state (cookies, localStorage, and session files) that you can reuse across multiple runs. Profiles let you skip login steps and restore authenticated state instantly.
+
+Profiles are ideal when you:
+- Run the same workflow repeatedly with the same account (daily data extraction, scheduled reports)
+- Want multiple workflows to share the same authenticated state
+- Need to avoid repeated authentication to save time and steps
+
+---
+
+## How profiles work
+
+When a workflow runs with `persist_browser_session=true`, Skyvern archives the browser state (cookies, storage, session files) after the run completes. This archiving happens asynchronously in the background. Once the archive is ready, you can create a profile from it, then pass that profile to future workflow runs to restore the saved state.
+
+---
+
+## Create a Browser Profile
+
+Create a workflow with `persist_browser_session=true` in the workflow definition, run it, wait for completion, then create a profile from the run. Session archiving happens asynchronously, so add brief retry logic when creating the profile.
+
+
+`persist_browser_session` must be set when **creating the workflow**, not when running it. It is a workflow definition property, not a runtime parameter.
+
+
+### From a workflow run
+
+
+```python Python
+import asyncio
+from skyvern import Skyvern
+
+async def main():
+ client = Skyvern(api_key="YOUR_API_KEY")
+
+ # Step 1: Create a workflow with persist_browser_session=true
+ workflow = await client.create_workflow(
+ json_definition={
+ "title": "Login to Dashboard",
+ "persist_browser_session": True, # Set here in workflow definition
+ "workflow_definition": {
+ "parameters": [],
+ "blocks": [
+ {
+ "block_type": "navigation",
+ "label": "login",
+ "url": "https://dashboard.example.com/login",
+ "navigation_goal": "Login with the provided credentials"
+ }
+ ]
+ }
+ }
+ )
+ print(f"Created workflow: {workflow.workflow_permanent_id}")
+
+ # Step 2: Run the workflow
+ workflow_run = await client.run_workflow(
+ workflow_id=workflow.workflow_permanent_id,
+ wait_for_completion=True,
+ )
+ print(f"Workflow completed: {workflow_run.status}")
+
+ # Step 3: Create profile from the completed run
+ # Retry briefly while session archives asynchronously
+ for attempt in range(10):
+ try:
+ profile = await client.create_browser_profile(
+ name="analytics-dashboard-login",
+ workflow_run_id=workflow_run.run_id,
+ description="Authenticated state for analytics dashboard",
+ )
+ print(f"Profile created: {profile.browser_profile_id}")
+ break
+ except Exception as e:
+ if "persisted" in str(e).lower() and attempt < 9:
+ await asyncio.sleep(1)
+ continue
+ raise
+
+asyncio.run(main())
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+async function main() {
+ const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+ // Step 1: Create a workflow with persist_browser_session=true
+ const workflow = await client.createWorkflow({
+ body: {
+ json_definition: {
+ title: "Login to Dashboard",
+ persist_browser_session: true, // Set here in workflow definition
+ workflow_definition: {
+ parameters: [],
+ blocks: [
+ {
+ block_type: "navigation",
+ label: "login",
+ url: "https://dashboard.example.com/login",
+ navigation_goal: "Login with the provided credentials"
+ }
+ ]
+ }
+ }
+ }
+ });
+ console.log(`Created workflow: ${workflow.workflow_permanent_id}`);
+
+ // Step 2: Run the workflow and wait for completion
+ const workflowRun = await client.runWorkflow({
+ body: { workflow_id: workflow.workflow_permanent_id },
+ waitForCompletion: true,
+ });
+ console.log(`Workflow completed: ${workflowRun.status}`);
+
+ // Step 3: Create profile from the completed run
+ let profile;
+ for (let attempt = 0; attempt < 10; attempt++) {
+ try {
+ profile = await client.createBrowserProfile({
+ name: "analytics-dashboard-login",
+ workflow_run_id: workflowRun.run_id,
+ description: "Authenticated state for analytics dashboard",
+ });
+ break;
+ } catch (e) {
+ if (String(e).toLowerCase().includes("persisted") && attempt < 9) {
+ await new Promise((r) => setTimeout(r, 1000));
+ continue;
+ }
+ throw e;
+ }
+ }
+
+ console.log(`Profile created: ${profile.browser_profile_id}`);
+}
+
+main();
+```
+
+```bash cURL
+# Step 1: Create a workflow with persist_browser_session=true
+WORKFLOW_RESPONSE=$(curl -s -X POST "https://api.skyvern.com/v1/workflows" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "json_definition": {
+ "title": "Login to Dashboard",
+ "persist_browser_session": true,
+ "workflow_definition": {
+ "parameters": [],
+ "blocks": [
+ {
+ "block_type": "navigation",
+ "label": "login",
+ "url": "https://dashboard.example.com/login",
+ "navigation_goal": "Login with the provided credentials"
+ }
+ ]
+ }
+ }
+ }')
+
+WORKFLOW_ID=$(echo "$WORKFLOW_RESPONSE" | jq -r '.workflow_permanent_id')
+echo "Created workflow: $WORKFLOW_ID"
+
+# Step 2: Run the workflow
+RUN_RESPONSE=$(curl -s -X POST "https://api.skyvern.com/v1/run/workflows" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{\"workflow_id\": \"$WORKFLOW_ID\"}")
+
+RUN_ID=$(echo "$RUN_RESPONSE" | jq -r '.run_id')
+
+# Wait for completion
+while true; do
+ STATUS=$(curl -s "https://api.skyvern.com/v1/runs/$RUN_ID" \
+ -H "x-api-key: $SKYVERN_API_KEY" | jq -r '.status')
+ [ "$STATUS" != "created" ] && [ "$STATUS" != "queued" ] && [ "$STATUS" != "running" ] && break
+ sleep 5
+done
+
+# Step 3: Create profile (retry while session archives)
+for i in {1..10}; do
+ PROFILE_RESPONSE=$(curl -s -X POST "https://api.skyvern.com/v1/browser_profiles" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"name\": \"analytics-dashboard-login\",
+ \"workflow_run_id\": \"$RUN_ID\",
+ \"description\": \"Authenticated state for analytics dashboard\"
+ }")
+ if echo "$PROFILE_RESPONSE" | jq -e '.browser_profile_id' > /dev/null 2>&1; then
+ echo "$PROFILE_RESPONSE"
+ break
+ fi
+ sleep 1
+done
+```
+
+
+**Parameters:**
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `name` | string | Required. Display name for the profile. Must be unique within your organization |
+| `workflow_run_id` | string | ID of the completed workflow run to create the profile from |
+| `description` | string | Optional description of the profile's purpose |
+
+### From a browser session
+
+You can also create a profile from a [Browser Session](/optimization/browser-sessions) that was used inside a workflow with `persist_browser_session=true`. After the workflow run completes and the session is closed, pass the session ID instead of the workflow run ID.
+
+
+Only sessions that were part of a workflow with `persist_browser_session=true` produce an archive. A session created with `create_browser_session()` alone does not archive its state. Archiving happens asynchronously after the session closes, so add retry logic.
+
+
+
+```python Python
+import asyncio
+from skyvern import Skyvern
+
+async def main():
+ client = Skyvern(api_key="YOUR_API_KEY")
+
+ # browser_session_id from a workflow run with persist_browser_session=true
+ session_id = "pbs_your_session_id"
+
+ # Create profile from the closed session (retry while archive uploads)
+ for attempt in range(10):
+ try:
+ profile = await client.create_browser_profile(
+ name="dashboard-admin-login",
+ browser_session_id=session_id,
+ description="Admin account for dashboard access",
+ )
+ print(f"Profile created: {profile.browser_profile_id}")
+ break
+ except Exception as e:
+ if "persisted" in str(e).lower() and attempt < 9:
+ await asyncio.sleep(2)
+ continue
+ raise
+
+asyncio.run(main())
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+async function main() {
+ const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+ // browser_session_id from a workflow run with persist_browser_session=true
+ const sessionId = "pbs_your_session_id";
+
+ // Create profile from the closed session (retry while archive uploads)
+ let profile;
+ for (let attempt = 0; attempt < 10; attempt++) {
+ try {
+ profile = await client.createBrowserProfile({
+ name: "dashboard-admin-login",
+ browser_session_id: sessionId,
+ description: "Admin account for dashboard access",
+ });
+ break;
+ } catch (e) {
+ if (String(e).toLowerCase().includes("persisted") && attempt < 9) {
+ await new Promise((r) => setTimeout(r, 2000));
+ continue;
+ }
+ throw e;
+ }
+ }
+
+ console.log(`Profile created: ${profile.browser_profile_id}`);
+}
+
+main();
+```
+
+```bash cURL
+# browser_session_id from a workflow run with persist_browser_session=true
+SESSION_ID="pbs_your_session_id"
+
+# Create profile (retry while session archives)
+for i in {1..10}; do
+ PROFILE_RESPONSE=$(curl -s -X POST "https://api.skyvern.com/v1/browser_profiles" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"name\": \"dashboard-admin-login\",
+ \"browser_session_id\": \"$SESSION_ID\",
+ \"description\": \"Admin account for dashboard access\"
+ }")
+ if echo "$PROFILE_RESPONSE" | jq -e '.browser_profile_id' > /dev/null 2>&1; then
+ echo "$PROFILE_RESPONSE"
+ break
+ fi
+ sleep 2
+done
+```
+
+
+**Parameters:**
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `name` | string | Required. Display name for the profile. Must be unique within your organization |
+| `browser_session_id` | string | ID of the closed browser session (starts with `pbs_`). The session must have been part of a workflow with `persist_browser_session=true` |
+| `description` | string | Optional description of the profile's purpose |
+
+---
+
+## Use a Browser Profile
+
+Pass `browser_profile_id` when running a workflow to restore the saved state. Skyvern restores cookies, localStorage, and session files before the first step runs.
+
+
+```python Python
+import asyncio
+from skyvern import Skyvern
+
+async def main():
+ client = Skyvern(api_key="YOUR_API_KEY")
+
+ # Run workflow with saved profile, no login needed
+ result = await client.run_workflow(
+ workflow_id="wf_daily_metrics",
+ browser_profile_id="bp_490705123456789012",
+ wait_for_completion=True,
+ )
+
+ print(f"Output: {result.output}")
+
+asyncio.run(main())
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+async function main() {
+ const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+ // Run workflow with saved profile, no login needed
+ const result = await client.runWorkflow({
+ body: {
+ workflow_id: "wf_daily_metrics",
+ browser_profile_id: "bp_490705123456789012",
+ },
+ waitForCompletion: true,
+ });
+
+ console.log(`Output: ${JSON.stringify(result.output)}`);
+}
+
+main();
+```
+
+```bash cURL
+curl -X POST "https://api.skyvern.com/v1/run/workflows" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "workflow_id": "wf_daily_metrics",
+ "browser_profile_id": "bp_490705123456789012"
+ }'
+```
+
+
+**Example response:**
+
+```json
+{
+ "run_id": "wr_494469342201718946",
+ "status": "created",
+ "run_request": {
+ "workflow_id": "wf_daily_metrics",
+ "browser_profile_id": "bp_490705123456789012",
+ "proxy_location": "RESIDENTIAL"
+ }
+}
+```
+
+
+`browser_profile_id` is supported for workflows only. It is not available for standalone tasks via `run_task`. You also cannot use both `browser_profile_id` and `browser_session_id` in the same request.
+
+
+---
+
+## Tutorial: save and reuse browsing state
+
+This walkthrough demonstrates the full profile lifecycle: create a workflow that saves browser state, capture that state as a profile, then reuse it in a second workflow. Each step shows the code and the actual API response.
+
+
+
+
+ The workflow must have `persist_browser_session=true` so Skyvern archives the browser state after the run.
+
+
+ ```python Python
+ workflow = await client.create_workflow(
+ json_definition={
+ "title": "Visit Hacker News",
+ "persist_browser_session": True,
+ "workflow_definition": {
+ "parameters": [],
+ "blocks": [
+ {
+ "block_type": "navigation",
+ "label": "visit_hn",
+ "url": "https://news.ycombinator.com",
+ "navigation_goal": "Navigate to the Hacker News homepage and confirm it loaded"
+ }
+ ]
+ }
+ }
+ )
+ print(workflow.workflow_permanent_id) # wpid_494674198088536840
+ print(workflow.persist_browser_session) # True
+ ```
+
+ ```typescript TypeScript
+ const workflow = await client.createWorkflow({
+ body: {
+ json_definition: {
+ title: "Visit Hacker News",
+ persist_browser_session: true,
+ workflow_definition: {
+ parameters: [],
+ blocks: [
+ {
+ block_type: "navigation",
+ label: "visit_hn",
+ url: "https://news.ycombinator.com",
+ navigation_goal: "Navigate to the Hacker News homepage and confirm it loaded"
+ }
+ ]
+ }
+ }
+ }
+ });
+ console.log(workflow.workflow_permanent_id); // wpid_494674198088536840
+ ```
+
+ ```bash cURL
+ curl -s -X POST "https://api.skyvern.com/v1/workflows" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "json_definition": {
+ "title": "Visit Hacker News",
+ "persist_browser_session": true,
+ "workflow_definition": {
+ "parameters": [],
+ "blocks": [
+ {
+ "block_type": "navigation",
+ "label": "visit_hn",
+ "url": "https://news.ycombinator.com",
+ "navigation_goal": "Navigate to the Hacker News homepage and confirm it loaded"
+ }
+ ]
+ }
+ }
+ }'
+ ```
+
+
+ ```json Response
+ {
+ "workflow_permanent_id": "wpid_494674198088536840",
+ "persist_browser_session": true,
+ "title": "Visit Hacker News"
+ }
+ ```
+
+
+
+
+
+ Run the workflow and wait for it to complete. Skyvern opens a browser, executes the navigation block, then archives the browser state in the background.
+
+
+ ```python Python
+ run = await client.run_workflow(
+ workflow_id=workflow.workflow_permanent_id,
+ wait_for_completion=True,
+ )
+ print(run.run_id) # wr_494674202383504144
+ print(run.status) # completed
+ ```
+
+ ```typescript TypeScript
+ const run = await client.runWorkflow({
+ body: { workflow_id: workflow.workflow_permanent_id },
+ waitForCompletion: true,
+ });
+ console.log(run.run_id); // wr_494674202383504144
+ console.log(run.status); // completed
+ ```
+
+ ```bash cURL
+ RUN=$(curl -s -X POST "https://api.skyvern.com/v1/run/workflows" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{\"workflow_id\": \"$WORKFLOW_ID\"}")
+ RUN_ID=$(echo "$RUN" | jq -r '.run_id')
+
+ # Poll until complete
+ while true; do
+ STATUS=$(curl -s "https://api.skyvern.com/v1/runs/$RUN_ID" \
+ -H "x-api-key: $SKYVERN_API_KEY" | jq -r '.status')
+ [ "$STATUS" != "created" ] && [ "$STATUS" != "queued" ] && [ "$STATUS" != "running" ] && break
+ sleep 5
+ done
+ ```
+
+
+ ```json Response
+ {
+ "run_id": "wr_494674202383504144",
+ "status": "completed"
+ }
+ ```
+
+
+
+
+
+ Archiving happens asynchronously after the run completes, so add retry logic. In practice the archive is usually ready within a few seconds.
+
+
+ ```python Python
+ for attempt in range(10):
+ try:
+ profile = await client.create_browser_profile(
+ name="hn-browsing-state",
+ workflow_run_id=run.run_id,
+ description="Hacker News cookies and browsing state",
+ )
+ print(profile.browser_profile_id) # bp_494674399951999772
+ break
+ except Exception as e:
+ if "persisted" in str(e).lower() and attempt < 9:
+ await asyncio.sleep(2)
+ continue
+ raise
+ ```
+
+ ```typescript TypeScript
+ let profile;
+ for (let attempt = 0; attempt < 10; attempt++) {
+ try {
+ profile = await client.createBrowserProfile({
+ name: "hn-browsing-state",
+ workflow_run_id: run.run_id,
+ description: "Hacker News cookies and browsing state",
+ });
+ console.log(profile.browser_profile_id); // bp_494674399951999772
+ break;
+ } catch (e) {
+ if (String(e).toLowerCase().includes("persisted") && attempt < 9) {
+ await new Promise((r) => setTimeout(r, 2000));
+ continue;
+ }
+ throw e;
+ }
+ }
+ ```
+
+ ```bash cURL
+ for i in {1..10}; do
+ PROFILE=$(curl -s -X POST "https://api.skyvern.com/v1/browser_profiles" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"name\": \"hn-browsing-state\",
+ \"workflow_run_id\": \"$RUN_ID\",
+ \"description\": \"Hacker News cookies and browsing state\"
+ }")
+ PROFILE_ID=$(echo "$PROFILE" | jq -r '.browser_profile_id // empty')
+ if [ -n "$PROFILE_ID" ]; then
+ echo "$PROFILE" | jq .
+ break
+ fi
+ sleep 2
+ done
+ ```
+
+
+ ```json Response
+ {
+ "browser_profile_id": "bp_494674399951999772",
+ "organization_id": "o_475582633898688888",
+ "name": "hn-browsing-state",
+ "description": "Hacker News cookies and browsing state",
+ "created_at": "2026-02-12T01:09:18.048208",
+ "modified_at": "2026-02-12T01:09:18.048212",
+ "deleted_at": null
+ }
+ ```
+
+
+
+
+
+ List all profiles or fetch one by ID to confirm it was saved.
+
+
+ ```python Python
+ # List all profiles
+ profiles = await client.list_browser_profiles()
+ print(len(profiles)) # 1
+
+ # Get a single profile
+ fetched = await client.get_browser_profile(profile_id=profile.browser_profile_id)
+ print(fetched.name) # hn-browsing-state
+ ```
+
+ ```typescript TypeScript
+ // List all profiles
+ const profiles = await client.listBrowserProfiles({});
+ console.log(profiles.length); // 1
+
+ // Get a single profile
+ const fetched = await client.getBrowserProfile(profile.browser_profile_id);
+ console.log(fetched.name); // hn-browsing-state
+ ```
+
+ ```bash cURL
+ # List all profiles
+ curl -s "https://api.skyvern.com/v1/browser_profiles" \
+ -H "x-api-key: $SKYVERN_API_KEY" | jq '.[].name'
+
+ # Get a single profile
+ curl -s "https://api.skyvern.com/v1/browser_profiles/$PROFILE_ID" \
+ -H "x-api-key: $SKYVERN_API_KEY" | jq .
+ ```
+
+
+ ```json List response
+ [
+ {
+ "browser_profile_id": "bp_494674399951999772",
+ "name": "hn-browsing-state",
+ "created_at": "2026-02-12T01:09:18.048208"
+ }
+ ]
+ ```
+
+
+
+
+
+ Pass `browser_profile_id` when running a workflow. Skyvern restores the saved cookies, localStorage, and session files before the first block runs. The second workflow starts with the browser state from step 2, no repeat navigation needed.
+
+
+ ```python Python
+ result = await client.run_workflow(
+ workflow_id=data_workflow.workflow_permanent_id,
+ browser_profile_id=profile.browser_profile_id,
+ wait_for_completion=True,
+ )
+ print(result.status) # completed
+ ```
+
+ ```typescript TypeScript
+ const result = await client.runWorkflow({
+ body: {
+ workflow_id: dataWorkflow.workflow_permanent_id,
+ browser_profile_id: profile.browser_profile_id,
+ },
+ waitForCompletion: true,
+ });
+ console.log(result.status); // completed
+ ```
+
+ ```bash cURL
+ curl -s -X POST "https://api.skyvern.com/v1/run/workflows" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"workflow_id\": \"$WORKFLOW2_ID\",
+ \"browser_profile_id\": \"$PROFILE_ID\"
+ }"
+ ```
+
+
+ ```json Response
+ {
+ "run_id": "wr_494674434311738148",
+ "status": "created"
+ }
+ ```
+
+
+
+
+
+ Clean up profiles you no longer need.
+
+
+ ```python Python
+ await client.delete_browser_profile(profile_id=profile.browser_profile_id)
+
+ # Confirm deletion
+ remaining = await client.list_browser_profiles()
+ print(len(remaining)) # 0
+ ```
+
+ ```typescript TypeScript
+ await client.deleteBrowserProfile(profile.browser_profile_id);
+
+ // Confirm deletion
+ const remaining = await client.listBrowserProfiles({});
+ console.log(remaining.length); // 0
+ ```
+
+ ```bash cURL
+ curl -s -X DELETE "https://api.skyvern.com/v1/browser_profiles/$PROFILE_ID" \
+ -H "x-api-key: $SKYVERN_API_KEY"
+ ```
+
+
+
+
+
+
+In a real scenario, step 1 would be a login workflow that authenticates with a site. The saved profile then lets all future workflows skip the login step entirely.
+
+
+---
+
+## Best practices
+
+### Use descriptive names
+
+Include the account, site, and purpose in the profile name so it is easy to identify later.
+
+
+```python Python
+# Good: identifies account, site, and purpose
+profile = await client.create_browser_profile(
+ name="prod-salesforce-admin",
+ description="Admin login for daily opportunity sync",
+ workflow_run_id=run_id,
+)
+
+# Bad: unclear what this is for
+profile = await client.create_browser_profile(
+ name="profile1",
+ workflow_run_id=run_id,
+)
+```
+
+```typescript TypeScript
+// Good: identifies account, site, and purpose
+const profile = await client.createBrowserProfile({
+ name: "prod-salesforce-admin",
+ description: "Admin login for daily opportunity sync",
+ workflow_run_id: runId,
+});
+
+// Bad: unclear what this is for
+const badProfile = await client.createBrowserProfile({
+ name: "profile1",
+ workflow_run_id: runId,
+});
+```
+
+
+### Refresh profiles periodically
+
+Session tokens and cookies expire. Re-run your login workflow and create fresh profiles before they go stale. Adding the date to the name makes it easy to track which profile is current.
+
+
+```python Python
+from datetime import date
+
+# Create dated profile after each successful login
+profile = await client.create_browser_profile(
+ name=f"crm-login-{date.today()}",
+ workflow_run_id=new_login_run.run_id,
+)
+
+# Delete old profile
+await client.delete_browser_profile(old_profile_id)
+```
+
+```typescript TypeScript
+// Create dated profile after each successful login
+const profile = await client.createBrowserProfile({
+ name: `crm-login-${new Date().toISOString().split("T")[0]}`,
+ workflow_run_id: newLoginRun.run_id,
+});
+
+// Delete old profile
+await client.deleteBrowserProfile(oldProfileId);
+```
+
+```bash cURL
+# Create dated profile after a successful login run
+curl -X POST "https://api.skyvern.com/v1/browser_profiles" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"name\": \"crm-login-$(date +%Y-%m-%d)\",
+ \"workflow_run_id\": \"$NEW_RUN_ID\"
+ }"
+
+# Delete old profile
+curl -X DELETE "https://api.skyvern.com/v1/browser_profiles/$OLD_PROFILE_ID" \
+ -H "x-api-key: $SKYVERN_API_KEY"
+```
+
+
+### Capture updated state after each run
+
+To capture state changes during a run (like token refreshes), the workflow must have `persist_browser_session=true` in its definition. This lets you create a fresh profile from each completed run.
+
+
+```python Python
+from datetime import date
+
+# Step 1: Create workflow with persist_browser_session in the definition
+workflow = await client.create_workflow(
+ json_definition={
+ "title": "Daily Sync",
+ "persist_browser_session": True, # Set here, not in run_workflow
+ "workflow_definition": {
+ "parameters": [],
+ "blocks": [...]
+ }
+ }
+)
+
+# Step 2: Run with an existing profile
+result = await client.run_workflow(
+ workflow_id=workflow.workflow_permanent_id,
+ browser_profile_id="bp_current",
+ wait_for_completion=True,
+)
+
+# Step 3: Create updated profile from the completed run
+if should_refresh_profile:
+ new_profile = await client.create_browser_profile(
+ name=f"daily-sync-{date.today()}",
+ workflow_run_id=result.run_id,
+ )
+```
+
+```typescript TypeScript
+// Step 1: Create workflow with persist_browser_session in the definition
+const workflow = await client.createWorkflow({
+ body: {
+ json_definition: {
+ title: "Daily Sync",
+ persist_browser_session: true, // Set here, not in runWorkflow
+ workflow_definition: {
+ parameters: [],
+ blocks: [/* ... */]
+ }
+ }
+ }
+});
+
+// Step 2: Run with an existing profile
+const result = await client.runWorkflow({
+ body: {
+ workflow_id: workflow.workflow_permanent_id,
+ browser_profile_id: "bp_current",
+ },
+ waitForCompletion: true,
+});
+
+// Step 3: Create updated profile from the completed run
+if (shouldRefreshProfile) {
+ const newProfile = await client.createBrowserProfile({
+ name: `daily-sync-${new Date().toISOString().split("T")[0]}`,
+ workflow_run_id: result.run_id,
+ });
+}
+```
+
+
+---
+
+## Next steps
+
+
+
+ Maintain live browser state for real-time interactions
+
+
+ Optimize costs with max_steps and efficient prompts
+
+
diff --git a/docs/optimization/browser-sessions.mdx b/docs/optimization/browser-sessions.mdx
new file mode 100644
index 00000000..f3c9293c
--- /dev/null
+++ b/docs/optimization/browser-sessions.mdx
@@ -0,0 +1,709 @@
+---
+title: Browser Sessions
+subtitle: Persist live browser state across multiple tasks and workflows
+slug: optimization/browser-sessions
+---
+
+A **Browser Session** is a live browser instance that persists cookies, local storage, and page state between task or workflow runs. Think of it as keeping a browser tab open. Use sessions when you need back-to-back tasks to share state, human-in-the-loop approval, or real-time agents.
+
+---
+
+## Create a session
+
+Start a session with optional configuration for timeout, proxy, browser type, and extensions.
+
+
+```python Python
+import asyncio
+from skyvern import Skyvern
+
+async def main():
+ client = Skyvern(api_key="YOUR_API_KEY")
+
+ session = await client.create_browser_session(
+ timeout=60, # Max 60 minutes
+ proxy_location="RESIDENTIAL", # US residential proxy
+ browser_type="chrome", # Chrome or Edge
+ extensions=["ad-blocker"], # Optional extensions
+ )
+
+ print(f"Session ID: {session.browser_session_id}")
+ print(f"Status: {session.status}")
+
+asyncio.run(main())
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+async function main() {
+ const client = new Skyvern({
+ apiKey: process.env.SKYVERN_API_KEY!,
+ });
+
+ const session = await client.createBrowserSession({
+ timeout: 60,
+ proxy_location: "RESIDENTIAL",
+ browser_type: "chrome",
+ extensions: ["ad-blocker"],
+ });
+
+ console.log(`Session ID: ${session.browser_session_id}`);
+ console.log(`Status: ${session.status}`);
+}
+
+main();
+```
+
+```bash cURL
+curl -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "timeout": 60,
+ "proxy_location": "RESIDENTIAL",
+ "browser_type": "chrome",
+ "extensions": ["ad-blocker"]
+ }'
+```
+
+
+**Parameters:**
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `timeout` | integer | Session lifetime in minutes. Min: 5, Max: 1440 (24 hours). Default: `60` |
+| `proxy_location` | string | Geographic proxy location (Cloud only). See [Proxy & Geolocation](/going-to-production/proxy-geolocation) for available options |
+| `browser_type` | string | Browser type: `chrome` or `msedge` |
+| `extensions` | array | Extensions to install: `ad-blocker`, `captcha-solver` |
+
+**Example response:**
+
+```json
+{
+ "browser_session_id": "pbs_490705123456789012",
+ "organization_id": "o_485917350850524254",
+ "status": "running",
+ "timeout": 60,
+ "browser_type": "chrome",
+ "extensions": ["ad-blocker"],
+ "vnc_streaming_supported": true,
+ "app_url": "https://app.skyvern.com/browser-session/pbs_490705123456789012",
+ "started_at": "2026-02-01T10:30:03.110Z",
+ "created_at": "2026-02-01T10:30:00.000Z",
+ "modified_at": "2026-02-01T10:30:03.251Z"
+}
+```
+
+**Session statuses:**
+
+| Status | Description |
+|--------|-------------|
+| `running` | Browser is live and accepting tasks. This is the status returned on creation. The browser launches within seconds. |
+| `closed` | Session was closed manually or by timeout. No further tasks can run. |
+
+
+Sessions close automatically when the timeout expires, **even if a task is still running**. The timeout countdown begins when the browser launches. Set timeouts with enough margin for your longest expected task.
+
+
+---
+
+## Run tasks with a session
+
+Pass `browser_session_id` to `run_task` to execute tasks in an existing session. Each task continues from where the previous one left off: same page, same cookies, same form data.
+
+
+```python Python
+import asyncio
+from skyvern import Skyvern
+
+async def main():
+ client = Skyvern(api_key="YOUR_API_KEY")
+
+ # Create session
+ session = await client.create_browser_session(timeout=30)
+ session_id = session.browser_session_id
+
+ try:
+ # Task 1: Login (wait for completion before continuing)
+ await client.run_task(
+ prompt="Login with username 'support@company.com'",
+ url="https://dashboard.example.com/login",
+ browser_session_id=session_id,
+ wait_for_completion=True,
+ )
+
+ # Task 2: Search (already logged in from Task 1)
+ result = await client.run_task(
+ prompt="Find customer with email 'customer@example.com'",
+ browser_session_id=session_id,
+ wait_for_completion=True,
+ )
+
+ print(f"Customer: {result.output}")
+
+ finally:
+ # Always close when done
+ await client.close_browser_session(browser_session_id=session_id)
+
+asyncio.run(main())
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+async function main() {
+ const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+ const session = await client.createBrowserSession({ timeout: 30 });
+ const sessionId = session.browser_session_id;
+
+ try {
+ // Task 1: Login (wait for completion)
+ await client.runTask({
+ body: {
+ prompt: "Login with username 'support@company.com'",
+ url: "https://dashboard.example.com/login",
+ browser_session_id: sessionId,
+ },
+ waitForCompletion: true,
+ });
+
+ // Task 2: Search (reuses login state)
+ const result = await client.runTask({
+ body: {
+ prompt: "Find customer with email 'customer@example.com'",
+ browser_session_id: sessionId,
+ },
+ waitForCompletion: true,
+ });
+
+ console.log(`Customer: ${JSON.stringify(result.output)}`);
+
+ } finally {
+ await client.closeBrowserSession(sessionId);
+ }
+}
+
+main();
+```
+
+```bash cURL
+# Create session
+SESSION_ID=$(curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"timeout": 30}' | jq -r '.browser_session_id')
+
+echo "Session: $SESSION_ID"
+
+# Task 1: Login
+RUN_ID=$(curl -s -X POST "https://api.skyvern.com/v1/run/tasks" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"prompt\": \"Login with username 'support@company.com'\",
+ \"url\": \"https://dashboard.example.com/login\",
+ \"browser_session_id\": \"$SESSION_ID\"
+ }" | jq -r '.run_id')
+
+# Poll until complete
+while true; do
+ STATUS=$(curl -s "https://api.skyvern.com/v1/runs/$RUN_ID" \
+ -H "x-api-key: $SKYVERN_API_KEY" | jq -r '.status')
+ [ "$STATUS" != "created" ] && [ "$STATUS" != "queued" ] && [ "$STATUS" != "running" ] && break
+ sleep 5
+done
+
+# Task 2: Search (reuses login state)
+curl -s -X POST "https://api.skyvern.com/v1/run/tasks" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"prompt\": \"Find customer with email 'customer@example.com'\",
+ \"browser_session_id\": \"$SESSION_ID\"
+ }"
+
+# Close session when done
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions/$SESSION_ID/close" \
+ -H "x-api-key: $SKYVERN_API_KEY"
+```
+
+
+---
+
+## Run workflows with a session
+
+Pass `browser_session_id` to `run_workflow` to execute a workflow in an existing session. This is useful when you need to run a predefined workflow but want it to continue from your current browser state.
+
+
+```python Python
+import asyncio
+from skyvern import Skyvern
+
+async def main():
+ client = Skyvern(api_key="YOUR_API_KEY")
+
+ # Create session
+ session = await client.create_browser_session(timeout=60)
+ session_id = session.browser_session_id
+
+ try:
+ # First, login manually via a task
+ await client.run_task(
+ prompt="Login with username 'admin@company.com'",
+ url="https://app.example.com/login",
+ browser_session_id=session_id,
+ wait_for_completion=True,
+ )
+
+ # Then run a workflow in the same session (already logged in)
+ result = await client.run_workflow(
+ workflow_id="wf_export_monthly_report",
+ browser_session_id=session_id,
+ wait_for_completion=True,
+ )
+
+ print(f"Workflow completed: {result.status}")
+
+ finally:
+ await client.close_browser_session(browser_session_id=session_id)
+
+asyncio.run(main())
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+async function main() {
+ const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+ const session = await client.createBrowserSession({ timeout: 60 });
+ const sessionId = session.browser_session_id;
+
+ try {
+ // First, login manually via a task
+ await client.runTask({
+ body: {
+ prompt: "Login with username 'admin@company.com'",
+ url: "https://app.example.com/login",
+ browser_session_id: sessionId,
+ },
+ waitForCompletion: true,
+ });
+
+ // Then run a workflow in the same session (already logged in)
+ const result = await client.runWorkflow({
+ body: {
+ workflow_id: "wf_export_monthly_report",
+ browser_session_id: sessionId,
+ },
+ waitForCompletion: true,
+ });
+
+ console.log(`Workflow completed: ${result.status}`);
+
+ } finally {
+ await client.closeBrowserSession(sessionId);
+ }
+}
+
+main();
+```
+
+```bash cURL
+# Create session
+SESSION_ID=$(curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"timeout": 60}' | jq -r '.browser_session_id')
+
+# Login via a task
+RUN_ID=$(curl -s -X POST "https://api.skyvern.com/v1/run/tasks" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"prompt\": \"Login with username 'admin@company.com'\",
+ \"url\": \"https://app.example.com/login\",
+ \"browser_session_id\": \"$SESSION_ID\"
+ }" | jq -r '.run_id')
+
+# Poll until login completes
+while true; do
+ STATUS=$(curl -s "https://api.skyvern.com/v1/runs/$RUN_ID" \
+ -H "x-api-key: $SKYVERN_API_KEY" | jq -r '.status')
+ [ "$STATUS" != "created" ] && [ "$STATUS" != "queued" ] && [ "$STATUS" != "running" ] && break
+ sleep 5
+done
+
+# Run workflow in the same session (already logged in)
+curl -s -X POST "https://api.skyvern.com/v1/run/workflows" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"workflow_id\": \"wf_export_monthly_report\",
+ \"browser_session_id\": \"$SESSION_ID\"
+ }"
+
+# Close session when done
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions/$SESSION_ID/close" \
+ -H "x-api-key: $SKYVERN_API_KEY"
+```
+
+
+
+You cannot use both `browser_session_id` and `browser_profile_id` in the same request. Choose one or the other.
+
+
+---
+
+## Close a session
+
+Close a session to release resources and stop billing. The browser shuts down immediately.
+
+
+```python Python
+await client.close_browser_session(
+ browser_session_id="pbs_490705123456789012"
+)
+```
+
+```typescript TypeScript
+await client.closeBrowserSession("pbs_490705123456789012");
+```
+
+```bash cURL
+curl -X POST "https://api.skyvern.com/v1/browser_sessions/pbs_490705123456789012/close" \
+ -H "x-api-key: $SKYVERN_API_KEY"
+```
+
+
+
+**Always close sessions when done.** Active sessions continue billing even when idle. Use try/finally blocks to ensure cleanup.
+
+
+
+```python Python
+try:
+ session = await client.create_browser_session(timeout=30)
+ session_id = session.browser_session_id
+
+ # Do work...
+ await client.run_task(
+ prompt="...",
+ browser_session_id=session_id,
+ wait_for_completion=True,
+ )
+
+finally:
+ # Always close, even if task fails
+ await client.close_browser_session(browser_session_id=session_id)
+```
+
+```typescript TypeScript
+const session = await client.createBrowserSession({ timeout: 30 });
+const sessionId = session.browser_session_id;
+
+try {
+ await client.runTask({
+ body: {
+ prompt: "...",
+ browser_session_id: sessionId,
+ },
+ waitForCompletion: true,
+ });
+
+} finally {
+ // Always close, even if task fails
+ await client.closeBrowserSession(sessionId);
+}
+```
+
+
+---
+
+## Example: Human-in-the-loop
+
+A shopping bot that pauses for human approval before completing a purchase.
+
+
+```python Python
+import asyncio
+from skyvern import Skyvern
+
+async def shopping_with_approval():
+ client = Skyvern(api_key="YOUR_API_KEY")
+
+ session = await client.create_browser_session(timeout=15)
+ session_id = session.browser_session_id
+
+ try:
+ # Step 1: Add to cart
+ await client.run_task(
+ prompt="Find wireless headphones under $100, add top result to cart",
+ url="https://shop.example.com",
+ browser_session_id=session_id,
+ wait_for_completion=True,
+ )
+
+ # Step 2: Wait for human approval
+ approval = input("Approve purchase? (yes/no): ")
+
+ if approval.lower() == "yes":
+ # Step 3: Checkout (cart persists from Step 1)
+ result = await client.run_task(
+ prompt="Complete checkout and confirm order",
+ browser_session_id=session_id,
+ wait_for_completion=True,
+ )
+ print(f"Order placed: {result.output}")
+ else:
+ print("Purchase cancelled")
+
+ finally:
+ await client.close_browser_session(browser_session_id=session_id)
+
+asyncio.run(shopping_with_approval())
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+import * as readline from "readline";
+
+async function shoppingWithApproval() {
+ const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+ const session = await client.createBrowserSession({ timeout: 15 });
+ const sessionId = session.browser_session_id;
+
+ try {
+ // Step 1: Add to cart
+ await client.runTask({
+ body: {
+ prompt: "Find wireless headphones under $100, add top result to cart",
+ url: "https://shop.example.com",
+ browser_session_id: sessionId,
+ },
+ waitForCompletion: true,
+ });
+
+ // Step 2: Wait for human approval
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+ const approval = await new Promise((resolve) =>
+ rl.question("Approve purchase? (yes/no): ", resolve)
+ );
+ rl.close();
+
+ if (approval.toLowerCase() === "yes") {
+ // Step 3: Checkout (cart persists from Step 1)
+ const result = await client.runTask({
+ body: {
+ prompt: "Complete checkout and confirm order",
+ browser_session_id: sessionId,
+ },
+ waitForCompletion: true,
+ });
+ console.log(`Order placed: ${JSON.stringify(result.output)}`);
+ } else {
+ console.log("Purchase cancelled");
+ }
+
+ } finally {
+ await client.closeBrowserSession(sessionId);
+ }
+}
+
+shoppingWithApproval();
+```
+
+
+The browser maintains the cart contents during the approval pause. No state is lost.
+
+---
+
+## Best practices
+
+### Set appropriate timeouts
+
+Sessions bill while open, so match the timeout to your use case. A task typically completes in 30 to 90 seconds, so a 10-minute timeout covers most multi-step sequences with margin. Human-in-the-loop flows need longer timeouts to account for wait time.
+
+
+```python Python
+# Quick multi-step task (2-3 tasks back to back)
+session = await client.create_browser_session(timeout=10)
+
+# Human-in-the-loop with wait time for approval
+session = await client.create_browser_session(timeout=60)
+
+# Long-running agent that monitors a dashboard
+session = await client.create_browser_session(timeout=480) # 8 hours
+```
+
+```typescript TypeScript
+// Quick multi-step task (2-3 tasks back to back)
+const session1 = await client.createBrowserSession({ timeout: 10 });
+
+// Human-in-the-loop with wait time for approval
+const session2 = await client.createBrowserSession({ timeout: 60 });
+
+// Long-running agent that monitors a dashboard
+const session3 = await client.createBrowserSession({ timeout: 480 }); // 8 hours
+```
+
+```bash cURL
+# Quick multi-step task (2-3 tasks back to back)
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"timeout": 10}'
+
+# Human-in-the-loop with wait time for approval
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"timeout": 60}'
+
+# Long-running agent that monitors a dashboard
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"timeout": 480}'
+```
+
+
+### Use workflows for predetermined sequences
+
+If your steps don't need pauses between them, a workflow runs them in a single browser instance without the overhead of creating and managing a session. Each task in a session incurs its own startup cost, while workflow blocks share one browser.
+
+
+```python Python
+# Less efficient: multiple tasks in a session (each task has startup overhead)
+session = await client.create_browser_session()
+await client.run_task(prompt="Step 1", browser_session_id=session.browser_session_id, wait_for_completion=True)
+await client.run_task(prompt="Step 2", browser_session_id=session.browser_session_id, wait_for_completion=True)
+
+# More efficient: single workflow (blocks share one browser, no inter-task overhead)
+await client.run_workflow(workflow_id="wf_abc", wait_for_completion=True)
+```
+
+```typescript TypeScript
+// Less efficient: multiple tasks in a session (each task has startup overhead)
+const session = await client.createBrowserSession({});
+await client.runTask({ body: { prompt: "Step 1", browser_session_id: session.browser_session_id }, waitForCompletion: true });
+await client.runTask({ body: { prompt: "Step 2", browser_session_id: session.browser_session_id }, waitForCompletion: true });
+
+// More efficient: single workflow (blocks share one browser, no inter-task overhead)
+await client.runWorkflow({ body: { workflow_id: "wf_abc" }, waitForCompletion: true });
+```
+
+
+### Choose the right browser type
+
+Chrome has the widest compatibility. Use Edge only when a site requires or detects it specifically.
+
+
+```python Python
+# Chrome (default) - widest compatibility
+session = await client.create_browser_session(browser_type="chrome")
+
+# Edge - for sites that require or fingerprint Edge
+session = await client.create_browser_session(browser_type="msedge")
+```
+
+```typescript TypeScript
+// Chrome (default) - widest compatibility
+const chromeSession = await client.createBrowserSession({ browser_type: "chrome" });
+
+// Edge - for sites that require or fingerprint Edge
+const edgeSession = await client.createBrowserSession({ browser_type: "msedge" });
+```
+
+```bash cURL
+# Chrome (default) - widest compatibility
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"browser_type": "chrome"}'
+
+# Edge - for sites that require or fingerprint Edge
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"browser_type": "msedge"}'
+```
+
+
+### Use extensions strategically
+
+Extensions add startup time, so only enable them when needed. The ad-blocker removes overlay ads that can interfere with automation. The captcha-solver handles CAPTCHAs automatically but is only available on Cloud.
+
+
+```python Python
+# Block ads that overlay content and interfere with clicks
+session = await client.create_browser_session(extensions=["ad-blocker"])
+
+# Auto-solve captchas (Cloud only)
+session = await client.create_browser_session(extensions=["captcha-solver"])
+```
+
+```typescript TypeScript
+// Block ads that overlay content and interfere with clicks
+const adBlockSession = await client.createBrowserSession({ extensions: ["ad-blocker"] });
+
+// Auto-solve captchas (Cloud only)
+const captchaSession = await client.createBrowserSession({ extensions: ["captcha-solver"] });
+```
+
+```bash cURL
+# Block ads that overlay content and interfere with clicks
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"extensions": ["ad-blocker"]}'
+
+# Auto-solve captchas (Cloud only)
+curl -s -X POST "https://api.skyvern.com/v1/browser_sessions" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"extensions": ["captcha-solver"]}'
+```
+
+
+---
+
+## Sessions vs Profiles
+
+Skyvern also offers [Browser Profiles](/optimization/browser-profiles), saved snapshots of browser state (cookies, storage, session files) that you can reuse across days or weeks. Choose based on your use case:
+
+| Aspect | Browser Session | Browser Profile |
+|--------|----------------|-----------------|
+| **What it is** | Live browser instance | Saved snapshot of browser state |
+| **Lifetime** | Minutes to hours | Days to months |
+| **State** | Current page, cookies, open connections | Cookies, storage, session files |
+| **Billing** | Charged while open | No cost when not in use |
+| **Best for** | Back-to-back tasks, human-in-the-loop, real-time agents | Repeated logins, scheduled workflows, shared auth state |
+
+
+You can create a [Browser Profile](/optimization/browser-profiles) from a completed session to save its authenticated state for future reuse.
+
+
+---
+
+## Next steps
+
+
+
+ Save session state for reuse across days
+
+
+ Optimize costs with max_steps and efficient prompts
+
+
diff --git a/docs/optimization/cost-control.mdx b/docs/optimization/cost-control.mdx
new file mode 100644
index 00000000..f0d8ef3a
--- /dev/null
+++ b/docs/optimization/cost-control.mdx
@@ -0,0 +1,292 @@
+---
+title: Cost Control
+subtitle: Limit steps and optimize prompts to manage costs
+slug: optimization/cost-control
+---
+
+Skyvern Cloud uses a **credit-based billing model**. Each plan includes a monthly credit allowance that determines how many actions you can run.
+
+| Plan | Price | Actions Included |
+|------|-------|------------------|
+| Free | $0 | ~170 |
+| Hobby | $29 | ~1,200 |
+| Pro | $149 | ~6,200 |
+| Enterprise | Custom | Unlimited |
+
+Use `max_steps` to limit steps per run and prevent runaway costs.
+
+---
+
+## Limit steps with max_steps
+
+Set `max_steps` to cap the worst-case cost of any run. If a task gets stuck in a loop or hits repeated failures, `max_steps` stops it before it burns through your budget. The run terminates with `status: "timed_out"` when it hits the limit.
+
+### For tasks
+
+Pass `max_steps` in the request body.
+
+
+```python Python
+from skyvern import Skyvern
+
+client = Skyvern(api_key="YOUR_API_KEY")
+
+result = await client.run_task(
+ prompt="Extract the top 3 products",
+ url="https://example.com/products",
+ max_steps=15,
+ wait_for_completion=True,
+)
+print(f"Steps taken: {result.step_count}")
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+const result = await client.runTask({
+ body: {
+ prompt: "Extract the top 3 products",
+ url: "https://example.com/products",
+ max_steps: 15,
+ },
+ waitForCompletion: true,
+});
+console.log(`Steps taken: ${result.step_count}`);
+```
+
+```bash cURL
+curl -X POST "https://api.skyvern.com/v1/run/tasks" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "Extract the top 3 products",
+ "url": "https://example.com/products",
+ "max_steps": 15
+ }'
+```
+
+
+### For workflows
+
+Pass `max_steps_override` as a parameter (Python) or `x-max-steps-override` header (TypeScript/cURL). This limits total steps across all blocks.
+
+
+```python Python
+from skyvern import Skyvern
+
+client = Skyvern(api_key="YOUR_API_KEY")
+
+result = await client.run_workflow(
+ workflow_id="wf_data_extraction",
+ max_steps_override=30,
+ wait_for_completion=True,
+)
+print(f"Steps taken: {result.step_count}")
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+const result = await client.runWorkflow({
+ "x-max-steps-override": 30,
+ body: { workflow_id: "wf_data_extraction" },
+ waitForCompletion: true,
+});
+console.log(`Steps taken: ${result.step_count}`);
+```
+
+```bash cURL
+curl -X POST "https://api.skyvern.com/v1/run/workflows" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "x-max-steps-override: 30" \
+ -H "Content-Type: application/json" \
+ -d '{"workflow_id": "wf_data_extraction"}'
+```
+
+
+
+If runs consistently time out, increase `max_steps` or simplify the task. If `step_count` is much lower than `max_steps`, reduce the limit.
+
+
+---
+
+## Use code generation for repeatable tasks
+
+On Skyvern Cloud, the default **Skyvern 2.0 with Code** engine records the actions the AI takes and generates reusable code from them. Subsequent runs execute the generated code instead of the AI agent — skipping LLM inference and screenshot analysis entirely. This makes them faster, deterministic, and significantly cheaper.
+
+1. Run your task with the default engine. Skyvern generates code from the recorded actions.
+2. Subsequent runs execute the cached code directly, no AI reasoning required.
+3. If the code doesn't handle an edge case, adjust your prompt and re-run to regenerate. Skyvern also falls back to the AI agent automatically if the cached code fails.
+
+You can control this with the `run_with` parameter. Set it to `"code"` to use cached code, or `"agent"` to force AI reasoning.
+
+
+```python Python
+from skyvern import Skyvern
+
+client = Skyvern(api_key="YOUR_API_KEY")
+
+# First run: AI agent executes and generates code
+result = await client.run_task(
+ prompt="Extract the top 3 products",
+ url="https://example.com/products",
+ wait_for_completion=True,
+)
+
+# Subsequent runs: execute cached code instead of AI
+result = await client.run_task(
+ prompt="Extract the top 3 products",
+ url="https://example.com/products",
+ run_with="code",
+ wait_for_completion=True,
+)
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+// First run: AI agent executes and generates code
+const result = await client.runTask({
+ body: {
+ prompt: "Extract the top 3 products",
+ url: "https://example.com/products",
+ },
+ waitForCompletion: true,
+});
+
+// Subsequent runs: execute cached code instead of AI
+const codeResult = await client.runTask({
+ body: {
+ prompt: "Extract the top 3 products",
+ url: "https://example.com/products",
+ run_with: "code",
+ },
+ waitForCompletion: true,
+});
+```
+
+```bash cURL
+# First run: AI agent executes and generates code
+curl -X POST "https://api.skyvern.com/v1/run/tasks" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "Extract the top 3 products",
+ "url": "https://example.com/products"
+ }'
+
+# Subsequent runs: execute cached code instead of AI
+curl -X POST "https://api.skyvern.com/v1/run/tasks" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "Extract the top 3 products",
+ "url": "https://example.com/products",
+ "run_with": "code"
+ }'
+```
+
+
+
+Set `publish_workflow: true` to save the generated workflow so you can re-trigger it later or schedule it on a cron.
+
+
+---
+
+## Choose a cheaper engine
+
+Not every task needs the most powerful engine. Use a lighter engine for simple, single-objective work.
+
+| Engine | Cost | Best for |
+|--------|------|----------|
+| `skyvern-2.0` | Highest | Complex, multi-step tasks that require flexibility |
+| `skyvern-1.0` | Lower | Single-objective tasks like form fills or single-page extraction |
+
+
+```python Python
+from skyvern import Skyvern
+
+client = Skyvern(api_key="YOUR_API_KEY")
+
+result = await client.run_task(
+ prompt="Fill out the contact form",
+ url="https://example.com/contact",
+ engine="skyvern-1.0",
+ wait_for_completion=True,
+)
+```
+
+```typescript TypeScript
+import { Skyvern } from "@skyvern/client";
+
+const client = new Skyvern({ apiKey: process.env.SKYVERN_API_KEY! });
+
+const result = await client.runTask({
+ body: {
+ prompt: "Fill out the contact form",
+ url: "https://example.com/contact",
+ engine: "skyvern-1.0",
+ },
+ waitForCompletion: true,
+});
+```
+
+```bash cURL
+curl -X POST "https://api.skyvern.com/v1/run/tasks" \
+ -H "x-api-key: $SKYVERN_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "prompt": "Fill out the contact form",
+ "url": "https://example.com/contact",
+ "engine": "skyvern-1.0"
+ }'
+```
+
+
+For self-hosted deployments, you can also swap the underlying LLM to a cheaper model (e.g., Gemini 2.5 Flash instead of a pro-tier model) via the `LLM_KEY` environment variable. See [LLM configuration](/self-hosted/llm-configuration) for details.
+
+---
+
+## Write better prompts
+
+Small prompt changes can cut step count significantly.
+
+- **Be specific about the goal and completion criteria.** "Extract the price, title, and rating of the first 3 products" finishes faster than "look at the products page."
+- **Avoid open-ended exploration.** Prompts like "find interesting data" or "look around" cause the agent to wander.
+- **Use `data_extraction_schema`** to constrain what fields the AI extracts. This prevents it from spending steps parsing irrelevant content.
+- **Provide `url`** to start on the correct page instead of making the agent search for it.
+- **Use [browser profiles](/optimization/browser-profiles)** to skip login steps on repeated runs.
+
+---
+
+## Monitor usage
+
+- Check `step_count` in run responses to understand actual consumption per task.
+- Use `get_run_timeline()` to inspect individual steps and identify waste (loops, unnecessary navigation, retries).
+
+---
+
+## Next steps
+
+
+
+ Maintain live browser state between calls
+
+
+ Save authenticated state for reuse across days
+
+