Build a Durable Browser Agent

Build a durable browser agent with memory, approvals, and performance metrics.

Introduction

30 Second Summary

Routine web errands can swallow more time than the answer deserves. The frustration grows when every new session forgets your preferences.

In this project, you will build ErrandRunner, a personal browser agent that completes read-only web errands from plain-language requests. It keeps work across restarts while pausing consequential requests for your approval.

What You'll Build

You will watch Chromium carry out a public-web errand in a visible window before ErrandRunner prints a measured result in your terminal.

By the end of this project, you'll have:

  • A visible personal assistant that can collect public information from natural-language instructions. Each run produces a saved Markdown result.
  • A durable queue with persistent memory backed by SQLite. Reusable preferences persist between sessions. LangGraph checkpoints pause consequential tasks until you approve or reject them.
  • A measured comparison between standard and optimized inference modes. You can judge each mode by its step count, duration, and usage.
  • Secret Mission: Configure a reusable two-site morning brief. Prove that the durable queue respects your domain allowlist after a worker restart.

Are there any prerequisites?

You need a Linux computer with Python, pip, Git, and VS Code already available. You also need a Google AI Studio API key.

Before We Start

A useful browser agent needs clear boundaries before it starts acting for you. This is your moment to choose one time-saving read-only errand and define the approval rule that keeps it safe.

Set Up the Local Agent Stack

A browser agent depends on heavier packages than a normal Python script. Version drift can stop the agent before it opens a page.

Browser Use controls a managed Chromium browser. LangGraph provides the workflow foundation that later supports durable approvals.

This step creates an isolated Python environment with exact package versions. The isolation protects your system Python from dependency conflicts.

In this step, get ready to:
  • Create the ErrandRunner project folder with its pinned configuration files.
  • Build an isolated Python 3.12 environment with the agent dependencies.
  • Install managed Chromium and store your Google AI Studio key locally.
Create the project files

The project folder gives every command a predictable location. Its three starter files pin the dependencies and protect local data from Git.

  • Press the Super key to open your Linux application search.
  • Type Visual Studio Code into the search field.

You should see Visual Studio Code in the search results.

  • Press Enter to launch Visual Studio Code.

You should see the Visual Studio Code welcome window.

  • Click File in the top menu.
  • Click Open Folder.

You should see the Linux folder picker.

  • Select your Desktop folder.
  • Click New Folder.

You should see a field for the new folder name.

  • Enter ErrandRunner as the folder name.
  • Click Select Folder.

You should see ErrandRunner at the top of the Explorer sidebar.

Folder did not open?

Return to the folder picker and select the ErrandRunner folder itself. Visual Studio Code should show that folder in the Explorer sidebar.

Help me open my ErrandRunner folder in Visual Studio Code on Linux.

  • Click the New File icon in the Explorer sidebar.
  • Enter requirements.txt as the file name.

You should see an empty requirements.txt editor tab.

  • Add the pinned dependencies to requirements.txt by pasting this content:
browser-use==0.13.10
langgraph==1.2.11
langgraph-checkpoint-sqlite==3.1.1
python-dotenv==1.2.3

What do these pins provide?

  • The browser-use pin provides the browser agent loop at version 0.13.10.
  • The langgraph pin provides workflow graphs at version 1.2.11.
  • The langgraph-checkpoint-sqlite pin provides durable local checkpoints at version 3.1.1.
  • The python-dotenv pin loads local environment values at version 1.2.3.
  • Save requirements.txt.
  • Confirm that requirements.txt appears under ErrandRunner in the Explorer sidebar.

Missing a dependency line?

Compare each package name with the code block above. A missing equals sign can prevent the pinned installation from resolving.

Help me compare my requirements file with the four required package pins.

  • Click the New File icon in the Explorer sidebar.
  • Enter .env.example as the file name.

You should see an empty .env.example editor tab.

  • Add the environment template to .env.example by pasting this content:
GOOGLE_API_KEY=your-google-ai-studio-key-here
LANGGRAPH_STRICT_MSGPACK=true

What does this template define?

The GOOGLE_API_KEY entry tells the Gemini integration where to read its credential. The value in this template is a safe placeholder.

The LANGGRAPH_STRICT_MSGPACK=true entry enables strict checkpoint deserialization. This keeps the local checkpoint format constrained.

  • Save .env.example.
  • Confirm that .env.example appears under ErrandRunner in the Explorer sidebar.

Environment file hidden?

Linux treats names beginning with a dot as hidden files. Visual Studio Code still lists .env.example in the Explorer sidebar.

Help me find a hidden environment file in Visual Studio Code.

  • Click the New File icon in the Explorer sidebar.
  • Enter .gitignore as the file name.

You should see an empty .gitignore editor tab.

  • Add the local-file exclusions to .gitignore by pasting this content:
.env
.venv/
__pycache__/
*.pyc
*.sqlite
runs/

Why exclude these paths?

The first line keeps your live .env credential file out of Git. The .venv/ line excludes installed dependencies.

The remaining patterns exclude generated Python files. They also exclude local databases and result reports.

  • Save .gitignore.
  • Confirm that .gitignore appears under ErrandRunner in the Explorer sidebar.

File name looks different?

Check that the file name begins with a dot. Remove any automatic .txt suffix if your system added one.

Help me correct the .gitignore file name in Visual Studio Code.

Install the pinned agent stack

uv creates the isolated environment and installs the exact dependency pins. The required version keeps these setup commands consistent.

  • Open a terminal inside the ErrandRunner workspace from the Terminal menu.
  • Check the installed uv version by running this command:
uv --version

What does this check show?

The command prints the uv release available in your terminal. This project uses version 0.12.17.

✔️ I see the required uv version

Your uv installation is ready. Continue below to create the Python environment.

  • Leave the current terminal open inside the ErrandRunner folder.

ⓧ I see another uv version

Install the project version over the existing uv release. The pinned installer keeps the rest of this step predictable.

  • Install uv version 0.12.17 by running this command:
curl -LsSf https://astral.sh/uv/0.12.17/install.sh | sh

What does this installer do?

The command downloads the official Linux installer for uv version 0.12.17. It installs that release for your user account.

  • Close the current terminal from the terminal panel.
  • Open a new terminal from the Terminal menu.

The new terminal reloads your Linux shell path.

  • Confirm the uv version by running the check again:
uv --version

What should the version show?

The output should include version 0.12.17. That confirms the pinned installer is active.

ⓧ Command not found

Install uv with its version-specific Linux installer. A fresh terminal then loads the new executable into your shell path.

  • Install uv version 0.12.17 by running this command:
curl -LsSf https://astral.sh/uv/0.12.17/install.sh | sh

What does this installer do?

The command downloads the official Linux installer for uv version 0.12.17. It installs uv for your user account.

  • Close the current terminal from the terminal panel.
  • Open a new terminal from the Terminal menu.

The new terminal reloads your Linux shell path.

  • Confirm that uv is available by running the version check:
uv --version

What should the version show?

The output should include version 0.12.17. That confirms the installation is available in the new terminal.

  • Create the Python 3.12 environment inside ErrandRunner by running this command:
uv venv --python 3.12

What does this command create?

uv creates a Python 3.12 environment in .venv. It can download the requested interpreter when Python 3.12 is unavailable locally.

You should see terminal output confirming the Python 3.12 environment. You should also see .venv in the Explorer sidebar.

Environment creation failed?

Confirm that the terminal is inside the ErrandRunner folder. Check that the uv version command reports 0.12.17.

Help me debug uv failing to create a Python 3.12 .venv on Linux.

  • Activate the new environment by running this command:
source .venv/bin/activate

What does activation change?

Activation makes this terminal use the Python interpreter inside .venv. Package installations now stay inside the project.

Your terminal prompt should show that the .venv environment is active.

Activation did not change the prompt?

Confirm that .venv appears in the Explorer sidebar. Run the activation command from the ErrandRunner terminal.

Help me activate a .venv from the ErrandRunner folder on Linux.

  • Install the pinned packages into the active environment by running this command:
uv pip install -r requirements.txt

What does this installation use?

uv reads each exact package version from requirements.txt. The packages are installed only inside the active .venv environment.

The first dependency install can take several minutes. A busy terminal during package resolution is expected.

You should see installation output listing the four pinned packages when the command finishes.

Package installation failed?

Check that the terminal prompt still shows the active .venv environment. Compare requirements.txt with the pinned file above.

Help me debug a uv dependency installation error for ErrandRunner.

Install Chromium and secure your key

The agent needs a browser runtime that matches Browser Use. The managed installation supplies that runtime without changing your system browser.

The browser download is the slowest part of this setup. Several minutes of download output is normal.

  • Install the Browser Use managed browser by running this command:
uvx browser-use install

What does this installation add?

The command installs the managed Chromium runtime used by Browser Use. This gives the agent a known browser environment for visible automation.

You should see the browser installation finish without an error. The terminal then returns to your active environment prompt.

Browser installation failed?

Confirm that your Linux machine has internet access. Rerun the command after the connection is stable.

Help me debug the Browser Use managed Chromium installation on Linux.

  • Create the local environment file from the safe template by running this command:
cp .env.example .env

What does this copy preserve?

The new .env file starts with the same two settings as .env.example. You can add your credential locally without changing the shareable template.

You should see .env appear in the Explorer sidebar.

Your Google AI Studio key is a live credential. The next edit keeps it in the ignored .env file on your machine.

  • Select .env in the Explorer sidebar.
  • Replace your-google-ai-studio-key-here with your Google AI Studio key.

The GOOGLE_API_KEY line should now contain your credential.

  • Save .env.
  • Return to .gitignore in the Explorer sidebar.

You should see .env on the first line. This exclusion keeps the credential file out of Git.

Key still shows the placeholder?

Edit the local .env file. Keep .env.example unchanged so it remains safe to share.

Help me distinguish .env from .env.example without exposing my API key.

Before the final check, what message do you expect when both core packages import successfully?

  • Verify the installed agent stack by running this command:
python -c "import browser_use, langgraph; print('Agent stack ready')"

What does this verification prove?

Python imports Browser Use and LangGraph from the active environment. The print statement runs only after both imports succeed.

You should see Agent stack ready in the terminal. That confirms the pinned Python agent stack is ready.

Import check failed?

Confirm that the terminal prompt shows the active .venv environment. Rerun the pinned package installation if Python reports a missing package.

Help me debug the ErrandRunner agent stack import check.

✔️ Awesome, I've got everything!

Your project files match the starter configuration. Keep the .venv environment active for the next step.

ⓧ I'd like to double check the full code

Compare each starter file with these complete versions.

browser-use==0.13.10
langgraph==1.2.11
langgraph-checkpoint-sqlite==3.1.1
python-dotenv==1.2.3
GOOGLE_API_KEY=your-google-ai-studio-key-here
LANGGRAPH_STRICT_MSGPACK=true
.env
.venv/
__pycache__/
*.pyc
*.sqlite
runs/

Your local agent stack is ready. Next up, you will give ErrandRunner its first visible browser errand.

Give the Browser Its First Errand

Your pinned local stack is ready. Now ErrandRunner needs to turn a plain-language request into visible browser actions.

Browser Use supplies the agent loop that chooses each action from the task. The direct script has no persistent memory layer yet.

A second run starts a fresh process. Repeating one errand will expose what the direct agent carries forward.

In this step, get ready to:
  • Create a visible browser agent that accepts a natural-language errand.
  • Report the final result with run metrics.
  • Run the same read-only errand twice to test what the agent remembers.
Create the visible browser task

A headed Browser keeps Chromium visible while the agent works. A domain allowlist limits where that browser can navigate.

  • Return to the ErrandRunner project in VS Code from the setup step.
  • Use the file creation control in the left sidebar to create browser_agent.py inside the ErrandRunner project directory.
  • Add the imports plus model setup by pasting this code:
from __future__ import annotations

import asyncio
import json
import os
import sys
from typing import Any

from browser_use import Agent, Browser, ChatGoogle
from dotenv import load_dotenv

load_dotenv()

MODEL_ID = "gemini-2.5-flash"

What Sets Up the Script?

  • The standard library imports provide asynchronous execution, JSON output, environment access, command-line arguments, and type hints.
  • Browser Use provides Agent, Browser, and ChatGoogle.
  • python-dotenv loads the local .env values before the script checks for the API key.
  • MODEL_ID keeps the selected Gemini 2.5 Flash model in one place.
  • Save browser_agent.py.
  • Confirm browser_agent.py appears beside the existing project files in the left sidebar.

Seeing Import Warnings?

Confirm VS Code is using the activated .venv from the setup step. A different Python interpreter may not see the pinned packages.

Help me diagnose the import warnings in browser_agent.py.

The browser configuration controls the visible window before the agent begins. It also blocks navigation outside the supplied domains.

  • Place your cursor below the MODEL_ID line.
  • Add the task function plus browser configuration by pasting this code:
async def run_browser_task(
    task: str,
    mode: str,
    allowed_domains: list[str],
) -> dict[str, Any]:
    if mode not in {"standard", "optimized"}:
        raise ValueError("mode must be 'standard' or 'optimized'")

    optimized = mode == "optimized"
    browser = Browser(
        headless=False,
        window_size={"width": 1200, "height": 800},
        allowed_domains=allowed_domains,
        accept_downloads=False,
    )

How Is the Browser Constrained?

  • run_browser_task() receives the errand, execution mode, and permitted domains from its caller.
  • The mode check rejects any value outside standard or optimized.
  • headless=False gives you a visible browser window for every run.
  • allowed_domains restricts navigation to the supplied allowlist.
  • accept_downloads=False prevents automatic downloads.
  • Save browser_agent.py.
  • Confirm VS Code shows no syntax diagnostics under the new function.

Seeing a Syntax Diagnostic?

Check that the browser options remain indented inside Browser(). Confirm the closing parenthesis aligns with browser = Browser(.

Help me fix the browser configuration indentation.

Return results and run metrics

The agent history records the final answer plus evidence about the run. ErrandRunner converts that history into one metrics dictionary for later storage and comparison.

This continuation is a little fiddly because every line must remain indented inside run_browser_task().

  • Find the browser configuration ending with accept_downloads=False,.
  • Place your cursor after the closing parenthesis immediately below that line.
  • Append the agent execution plus history handling by pasting this code:
    try:
        llm = ChatGoogle(model=MODEL_ID)
        agent = Agent(
            task=task,
            llm=llm,
            browser=browser,
            use_vision="auto",
            flash_mode=optimized,
            max_history_items=4 if optimized else None,
            max_actions_per_step=5 if optimized else 1,
            calculate_cost=True,
        )
        history = await agent.run(max_steps=12)
        return {
            "result": history.final_result() or "No final result was returned.",
            "success": history.is_successful(),
            "steps": history.number_of_steps(),
            "duration_seconds": round(history.total_duration_seconds(), 2),
            "usage": str(history.usage),
        }
    finally:
        try:
            await browser.kill()
        except Exception:
            pass

What Does the Agent Return?

  • ChatGoogle connects the agent to the model selected by MODEL_ID.
  • Agent combines the task, model, browser, vision settings, and execution controls.
  • agent.run() performs up to 12 model-guided browser steps.
  • history exposes the final result, success assessment, step count, duration, and usage report.
  • finally closes the browser even when the run raises an exception.
  • Save browser_agent.py.
  • Confirm VS Code shows no indentation diagnostics in run_browser_task().

Still Seeing Indentation Problems?

Keep try: aligned with browser = Browser( inside the function. Keep the final cleanup block aligned with that try:.

Help me repair the run_browser_task indentation.

The command-line entry point turns the quoted terminal text into one task. It always starts this demonstration in standard mode with Show HN as the only permitted domain.

  • Place your cursor below the final pass in run_browser_task().
  • Add the command-line entry point by pasting this code:
def main() -> None:
    if not os.getenv("GOOGLE_API_KEY"):
        raise SystemExit("Set GOOGLE_API_KEY in .env or the shell first.")
    if len(sys.argv) < 2:
        raise SystemExit('Usage: python browser_agent.py "your browser errand"')

    task = " ".join(sys.argv[1:])
    metrics = asyncio.run(
        run_browser_task(task, "standard", ["news.ycombinator.com"])
    )
    print(json.dumps(metrics, indent=2))


if __name__ == "__main__":
    main()

How Does the Command Reach the Agent?

  • The first check stops before browser execution when GOOGLE_API_KEY is unavailable.
  • sys.argv collects the words supplied after the script name.
  • asyncio.run() runs the asynchronous browser task from the synchronous entry point.
  • json.dumps() formats the result plus metrics for readable terminal output.
  • Save browser_agent.py.
  • Confirm VS Code shows no syntax diagnostics anywhere in the file.

Entry Point Showing Diagnostics?

Confirm def main() begins at the left edge of the file. The final if __name__ block must also begin at the left edge.

Help me fix the browser_agent.py entry point.

✔️ Awesome, I've got everything!

Your saved browser_agent.py now contains the model setup, browser task, metrics return, cleanup path, and command-line entry point.

ⓧ I'd like to double check the full code

  • Compare browser_agent.py with this complete file:
from __future__ import annotations

import asyncio
import json
import os
import sys
from typing import Any

from browser_use import Agent, Browser, ChatGoogle
from dotenv import load_dotenv

load_dotenv()

MODEL_ID = "gemini-2.5-flash"


async def run_browser_task(
    task: str,
    mode: str,
    allowed_domains: list[str],
) -> dict[str, Any]:
    if mode not in {"standard", "optimized"}:
        raise ValueError("mode must be 'standard' or 'optimized'")

    optimized = mode == "optimized"
    browser = Browser(
        headless=False,
        window_size={"width": 1200, "height": 800},
        allowed_domains=allowed_domains,
        accept_downloads=False,
    )

    try:
        llm = ChatGoogle(model=MODEL_ID)
        agent = Agent(
            task=task,
            llm=llm,
            browser=browser,
            use_vision="auto",
            flash_mode=optimized,
            max_history_items=4 if optimized else None,
            max_actions_per_step=5 if optimized else 1,
            calculate_cost=True,
        )
        history = await agent.run(max_steps=12)
        return {
            "result": history.final_result() or "No final result was returned.",
            "success": history.is_successful(),
            "steps": history.number_of_steps(),
            "duration_seconds": round(history.total_duration_seconds(), 2),
            "usage": str(history.usage),
        }
    finally:
        try:
            await browser.kill()
        except Exception:
            pass


def main() -> None:
    if not os.getenv("GOOGLE_API_KEY"):
        raise SystemExit("Set GOOGLE_API_KEY in .env or the shell first.")
    if len(sys.argv) < 2:
        raise SystemExit('Usage: python browser_agent.py "your browser errand"')

    task = " ".join(sys.argv[1:])
    metrics = asyncio.run(
        run_browser_task(task, "standard", ["news.ycombinator.com"])
    )
    print(json.dumps(metrics, indent=2))


if __name__ == "__main__":
    main()
Run the same errand twice

The first run tests the complete path from terminal request to visible browser work. The final JSON shows what the agent produced plus how the run behaved.

Before you run this, where do you expect Chromium to navigate first?

  • Start the Show HN errand by running this command:
python browser_agent.py "Visit https://news.ycombinator.com/show and return the first three post titles and URLs without leaving this domain."

What Does This Command Send?

The quoted text becomes one task argument for browser_agent.py. It specifies the destination, result limit, requested fields, and domain boundary.

You will see a visible Chromium window visit Show HN. After the browser closes, the terminal prints a JSON result containing result, success, steps, duration_seconds, and usage.

That is your first visible browser errand complete. ErrandRunner can now turn one natural-language request into observable browser work.

Browser Run Not Completing?

Confirm the earlier .venv is still active. Confirm the local .env still contains your Google AI Studio key.

Help me debug the first browser_agent.py run.

Before the second run, do you expect a fresh process to retain the earlier task preferences?

  • Repeat the exact Show HN errand by running the same command:
python browser_agent.py "Visit https://news.ycombinator.com/show and return the first three post titles and URLs without leaving this domain."

Why Repeat the Exact Task?

Keeping the command unchanged isolates the process boundary. Any retained preference would need to come from storage outside the prompt.

You will see Chromium perform the errand again before a second JSON result appears. The task still succeeds because the command repeats the domain plus three-result preference.

  • Compare the task text used for both terminal runs.
  • Identify which preferences had to remain inside that repeated text.

Each run starts a new process with no persistent preference store. The allowed domain plus result limit survive only because you repeat them manually.

Your browser agent now completes useful visible work. Next, SQLite gives its preferences a life beyond one process.

Add Cross-Task Memory

Your browser agent can now complete a visible errand in Chromium. Each new process still forgets the preferences that shaped the previous run.

A durable SQLite database gives those preferences a home on disk. In this step, you will also prepare persistent tables for queued errands and run metrics.

In this step, get ready to:
  • Create the persistent database schema for preferences, errands, and run metrics.
  • Add helpers that manage preferences and queued errands.
  • Prove that a changed preference survives across separate Python processes.
Create the durable database foundation

The database needs a stable file path plus safe connection handling. Its default preferences capture the policy values that you previously repeated inside every browser task.

  • In the editor containing browser_agent.py, create memory.py inside the same ErrandRunner folder.
  • Paste this first foundation into memory.py:
from __future__ import annotations

import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
from uuid import uuid4

DATA_DB = Path("errands.sqlite")
DEFAULT_PREFERENCES = {
    "allowed_domains": "news.ycombinator.com,httpbin.org",
    "result_limit": "3",
    "output_format": "Markdown",
}


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")

What does this foundation define?

  • DATA_DB points every database operation to errands.sqlite.
  • DEFAULT_PREFERENCES holds the initial domain allowlist, result limit, and output format.
  • utc_now() produces a timezone-aware timestamp for queued errands and run records.
  • Paste the database context manager directly below utc_now():
@contextmanager
def database() -> Iterator[sqlite3.Connection]:
    connection = sqlite3.connect(DATA_DB)
    connection.row_factory = sqlite3.Row
    try:
        yield connection
        connection.commit()
    finally:
        connection.close()

How does the connection stay safe?

  • database() opens the on-disk database whenever a helper needs it.
  • sqlite3.Row lets later helpers read columns by name.
  • connection.commit() persists successful changes before the connection closes.
  • finally closes the connection even when an operation raises an error.

The schema groups three forms of durable state into one transaction. The next two snippets form one complete init_db() function.

  • Paste the first part of init_db() below database():
def init_db() -> None:
    with database() as connection:
        connection.executescript(
            """
            CREATE TABLE IF NOT EXISTS preferences (
                key TEXT PRIMARY KEY,
                value TEXT NOT NULL
            );

            CREATE TABLE IF NOT EXISTS errands (
                id TEXT PRIMARY KEY,
                task TEXT NOT NULL,
                mode TEXT NOT NULL,
                status TEXT NOT NULL,
                created_at TEXT NOT NULL
            );

What do these tables remember?

  • preferences stores one reusable value for each named setting.
  • errands stores each natural-language task with its mode, status, and creation time.
  • PRIMARY KEY prevents duplicate preference keys and duplicate errand IDs.
  • Finish init_db() by pasting this continuation immediately after the errands table definition:
            CREATE TABLE IF NOT EXISTS runs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                errand_id TEXT NOT NULL,
                mode TEXT NOT NULL,
                steps INTEGER NOT NULL,
                duration_seconds REAL NOT NULL,
                success INTEGER,
                result TEXT NOT NULL,
                usage TEXT NOT NULL,
                created_at TEXT NOT NULL,
                UNIQUE(errand_id, mode)
            );
            """
        )
        connection.executemany(
            "INSERT OR IGNORE INTO preferences(key, value) VALUES(?, ?)",
            DEFAULT_PREFERENCES.items(),
        )

Why is initialization repeatable?

  • runs stores the result, success state, step count, duration, and usage for each execution.
  • UNIQUE(errand_id, mode) gives each errand and mode pair one durable metrics record.
  • INSERT OR IGNORE seeds missing defaults without replacing preferences that the learner has changed.
  • Save memory.py.
  • Check the database foundation for syntax errors by running:
python memory.py

What does this check prove?

A clean return to the terminal prompt proves that Python can parse the imports, connection manager, and schema function. The executable entry point arrives after the remaining helpers.

Seeing a syntax error?

  • Confirm that the second schema snippet starts inside the open SQL string from the first snippet.
  • Check that the closing triple quotes sit above the first closing parenthesis.
  • Compare the indentation inside init_db() with the snippets above.

Ask for help with the exact traceback: Help me fix the syntax error in my memory.py database schema.

Add preference and queue helpers

A table becomes useful when the rest of the application has small helpers for reading and changing it. These functions keep SQL details out of the browser workflow.

  • Paste the preference helpers below init_db():
def set_preference(key: str, value: str) -> None:
    with database() as connection:
        connection.execute(
            """
            INSERT INTO preferences(key, value) VALUES(?, ?)
            ON CONFLICT(key) DO UPDATE SET value = excluded.value
            """,
            (key, value),
        )


def get_preferences() -> dict[str, str]:
    with database() as connection:
        rows = connection.execute(
            "SELECT key, value FROM preferences ORDER BY key"
        ).fetchall()
    return {row["key"]: row["value"] for row in rows}

How do preferences change safely?

  • set_preference() inserts a new setting or updates the value for an existing key.
  • ON CONFLICT(key) makes repeated updates target the same preference row.
  • get_preferences() returns the stored rows as one dictionary for future prompt building.
  • Paste the first set of errand helpers below get_preferences():
def add_errand(task: str, mode: str) -> str:
    errand_id = uuid4().hex[:8]
    with database() as connection:
        connection.execute(
            """
            INSERT INTO errands(id, task, mode, status, created_at)
            VALUES(?, ?, ?, 'pending', ?)
            """,
            (errand_id, task, mode, utc_now()),
        )
    return errand_id


def get_errand(errand_id: str) -> dict[str, Any] | None:
    with database() as connection:
        row = connection.execute(
            "SELECT * FROM errands WHERE id = ?", (errand_id,)
        ).fetchone()
    return dict(row) if row else None

How does each errand get an identity?

  • add_errand() creates an eight-character ID before inserting a pending task.
  • get_errand() retrieves one matching task for future execution or resumption.
  • Parameter placeholders keep task text separate from the SQL statement.
  • Save memory.py.
  • Check the new helpers for syntax errors by running:
python memory.py

What does this second check prove?

A clean return confirms that the preference and errand helpers parse with the database foundation. This keeps the feedback loop close to the code you just added.

  • Paste the remaining queue helpers below get_errand():
def list_errands() -> list[dict[str, Any]]:
    with database() as connection:
        rows = connection.execute(
            "SELECT * FROM errands ORDER BY created_at"
        ).fetchall()
    return [dict(row) for row in rows]


def get_next_pending() -> dict[str, Any] | None:
    with database() as connection:
        row = connection.execute(
            """
            SELECT * FROM errands
            WHERE status = 'pending'
            ORDER BY created_at
            LIMIT 1
            """
        ).fetchone()
    return dict(row) if row else None


def set_status(errand_id: str, status: str) -> None:
    with database() as connection:
        connection.execute(
            "UPDATE errands SET status = ? WHERE id = ?",
            (status, errand_id),
        )

How does the queue stay ordered?

  • list_errands() returns every durable task in creation order.
  • get_next_pending() selects the oldest task that still has a pending status.
  • set_status() records progress without replacing the task itself.

Run metrics need idempotent persistence because a retried workflow may report the same errand and mode again. The next two snippets complete one save_run() function plus the executable entry point.

  • Paste the first part of save_run() below set_status():
def save_run(errand_id: str, mode: str, metrics: dict[str, Any]) -> None:
    success = metrics.get("success")
    success_value = None if success is None else int(bool(success))
    with database() as connection:
        connection.execute(
            """
            INSERT INTO runs(
                errand_id, mode, steps, duration_seconds,
                success, result, usage, created_at
            )
            VALUES(?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(errand_id, mode) DO UPDATE SET
                steps = excluded.steps,
                duration_seconds = excluded.duration_seconds,
                success = excluded.success,
                result = excluded.result,
                usage = excluded.usage,
                created_at = excluded.created_at
            """,

Why update an existing run?

  • success_value converts a reported Boolean into a value SQLite can store.
  • ON CONFLICT(errand_id, mode) targets an existing record for the same errand and execution mode.
  • The update keeps the newest metrics without creating a duplicate record.
  • Complete save_run() by pasting this continuation directly below the open connection.execute() call:
            (
                errand_id,
                mode,
                int(metrics["steps"]),
                float(metrics["duration_seconds"]),
                success_value,
                str(metrics["result"]),
                str(metrics["usage"]),
                utc_now(),
            ),
        )


if __name__ == "__main__":
    init_db()
    print(json.dumps(get_preferences(), indent=2))

What completes the memory layer?

  • The values tuple converts each metric into the type expected by the runs table.
  • utc_now() timestamps the latest stored result.
  • The executable entry point initializes the schema before printing the current preferences as JSON.
  • Save memory.py.

✔️ Awesome, I've got everything!

Your memory module now contains the database schema, preference helpers, queue helpers, idempotent run storage, and an executable preference check.

ⓧ I'd like to double check the full code

The complete memory.py reference should match your file exactly.

from __future__ import annotations

import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
from uuid import uuid4

DATA_DB = Path("errands.sqlite")
DEFAULT_PREFERENCES = {
    "allowed_domains": "news.ycombinator.com,httpbin.org",
    "result_limit": "3",
    "output_format": "Markdown",
}


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


@contextmanager
def database() -> Iterator[sqlite3.Connection]:
    connection = sqlite3.connect(DATA_DB)
    connection.row_factory = sqlite3.Row
    try:
        yield connection
        connection.commit()
    finally:
        connection.close()


def init_db() -> None:
    with database() as connection:
        connection.executescript(
            """
            CREATE TABLE IF NOT EXISTS preferences (
                key TEXT PRIMARY KEY,
                value TEXT NOT NULL
            );

            CREATE TABLE IF NOT EXISTS errands (
                id TEXT PRIMARY KEY,
                task TEXT NOT NULL,
                mode TEXT NOT NULL,
                status TEXT NOT NULL,
                created_at TEXT NOT NULL
            );

            CREATE TABLE IF NOT EXISTS runs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                errand_id TEXT NOT NULL,
                mode TEXT NOT NULL,
                steps INTEGER NOT NULL,
                duration_seconds REAL NOT NULL,
                success INTEGER,
                result TEXT NOT NULL,
                usage TEXT NOT NULL,
                created_at TEXT NOT NULL,
                UNIQUE(errand_id, mode)
            );
            """
        )
        connection.executemany(
            "INSERT OR IGNORE INTO preferences(key, value) VALUES(?, ?)",
            DEFAULT_PREFERENCES.items(),
        )


def set_preference(key: str, value: str) -> None:
    with database() as connection:
        connection.execute(
            """
            INSERT INTO preferences(key, value) VALUES(?, ?)
            ON CONFLICT(key) DO UPDATE SET value = excluded.value
            """,
            (key, value),
        )


def get_preferences() -> dict[str, str]:
    with database() as connection:
        rows = connection.execute(
            "SELECT key, value FROM preferences ORDER BY key"
        ).fetchall()
    return {row["key"]: row["value"] for row in rows}


def add_errand(task: str, mode: str) -> str:
    errand_id = uuid4().hex[:8]
    with database() as connection:
        connection.execute(
            """
            INSERT INTO errands(id, task, mode, status, created_at)
            VALUES(?, ?, ?, 'pending', ?)
            """,
            (errand_id, task, mode, utc_now()),
        )
    return errand_id


def get_errand(errand_id: str) -> dict[str, Any] | None:
    with database() as connection:
        row = connection.execute(
            "SELECT * FROM errands WHERE id = ?", (errand_id,)
        ).fetchone()
    return dict(row) if row else None


def list_errands() -> list[dict[str, Any]]:
    with database() as connection:
        rows = connection.execute(
            "SELECT * FROM errands ORDER BY created_at"
        ).fetchall()
    return [dict(row) for row in rows]


def get_next_pending() -> dict[str, Any] | None:
    with database() as connection:
        row = connection.execute(
            """
            SELECT * FROM errands
            WHERE status = 'pending'
            ORDER BY created_at
            LIMIT 1
            """
        ).fetchone()
    return dict(row) if row else None


def set_status(errand_id: str, status: str) -> None:
    with database() as connection:
        connection.execute(
            "UPDATE errands SET status = ? WHERE id = ?",
            (status, errand_id),
        )


def save_run(errand_id: str, mode: str, metrics: dict[str, Any]) -> None:
    success = metrics.get("success")
    success_value = None if success is None else int(bool(success))
    with database() as connection:
        connection.execute(
            """
            INSERT INTO runs(
                errand_id, mode, steps, duration_seconds,
                success, result, usage, created_at
            )
            VALUES(?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(errand_id, mode) DO UPDATE SET
                steps = excluded.steps,
                duration_seconds = excluded.duration_seconds,
                success = excluded.success,
                result = excluded.result,
                usage = excluded.usage,
                created_at = excluded.created_at
            """,
            (
                errand_id,
                mode,
                int(metrics["steps"]),
                float(metrics["duration_seconds"]),
                success_value,
                str(metrics["result"]),
                str(metrics["usage"]),
                utc_now(),
            ),
        )


if __name__ == "__main__":
    init_db()
    print(json.dumps(get_preferences(), indent=2))
Prove memory survives a restart

An in-memory value disappears when its process exits. A value stored in errands.sqlite should remain available to every later process.

Before you run the check, do you expect the second process to print the seeded limit of 3 or the updated limit of 5?

  • Initialize the database, change the result limit, and read it from a second process by running:
python -c "from memory import init_db, set_preference; init_db(); set_preference('result_limit', '5')" && python memory.py

What does this command prove?

  • The first Python process creates the tables before changing result_limit to 5.
  • That process exits after committing the preference to errands.sqlite.
  • The second Python process opens the same file before printing its stored preferences.

You should see printed JSON containing "result_limit": "5". This confirms that the changed value came from disk after the first process ended.

Before you open one more process, do you expect the result limit to remain 5?

  • Read the database again from a fresh Python process by running:
python memory.py

What should the fresh process show?

The printed JSON should still contain "result_limit": "5". The default seeding leaves your changed value untouched.

Still seeing a result limit of 3?

  • Confirm that set_preference() uses ON CONFLICT(key) DO UPDATE.
  • Confirm that init_db() seeds defaults with INSERT OR IGNORE.
  • Confirm that the terminal is running inside the folder containing memory.py and errands.sqlite.

Share the printed preferences without sharing your API key: Help me debug why result_limit does not persist in errands.sqlite.

That closes the memory gap. Your preferences now survive process boundaries alongside the schema for queued errands and idempotent run metrics.

Your durable memory layer is ready. Next, you will use it to coordinate queued errands through a restart-safe workflow.

Pause, Exit, and Resume Safely

Your browser agent now remembers preferences across separate processes. A consequential errand still needs a safe place to stop before the browser acts.

In this step, LangGraph stores an approval checkpoint in SQLite. The browser side effect starts only after that checkpoint has been approved.

In this step, get ready to:
  • Create a checkpointed workflow with an approval boundary before browser execution.
  • Add a command-line interface for durable errands.
  • Prove that a rejected errand stays cancelled after a restart.
Build the durable approval workflow

A checkpoint records the workflow state before execution continues. The errand ID becomes the stable thread ID that retrieves this state later.

Why place the browser after approval?

A resumed LangGraph node starts again from its beginning. Keeping run_browser_task() in a separate node prevents an earlier browser action from being replayed.

The interrupt() call completes the safe side of the boundary. The browse_node() function owns the side effect on the other side.

  • Return to the existing project folder in VS Code.
  • Create workflow.py beside memory.py by using the new-file control in the file sidebar.
  • Use the full-code tab below to add the checkpointed workflow.

✔️ Awesome, I've got everything!

Save workflow.py. The file should define the workflow state plus five nodes.

ⓧ I'd like to double check the full code

  • Replace the contents of workflow.py with this complete reference:
from __future__ import annotations

import asyncio
from pathlib import Path
from typing import Any, Literal, TypedDict

from langgraph.graph import END, START, StateGraph
from langgraph.types import interrupt

from browser_agent import run_browser_task
from memory import get_preferences, save_run, set_status

CHECKPOINT_DB = Path("checkpoints.sqlite")
RUNS_DIR = Path("runs")
SENSITIVE_TERMS = (
    "submit",
    "purchase",
    "buy",
    "login",
    "sign in",
    "download",
    "upload",
    "send",
    "delete",
)


class ErrandState(TypedDict, total=False):
    errand_id: str
    task: str
    mode: str
    prompt: str
    allowed_domains: list[str]
    approval_required: bool
    approved: bool
    status: str
    result: str
    success: bool | None
    steps: int
    duration_seconds: float
    usage: str


def requires_approval(task: str) -> bool:
    lowered = task.lower()
    return any(term in lowered for term in SENSITIVE_TERMS)


def build_prompt(task: str, preferences: dict[str, str]) -> str:
    remembered = "\n".join(
        f"- {key}: {value}" for key, value in sorted(preferences.items())
    )
    return f"""
Complete this browser errand:
{task}

Remembered preferences:
{remembered}

Safety rules:
- Stay within the configured allowed domains.
- Do not bypass CAPTCHAs.
- Do not enter passwords or expose secrets.
- Do not submit forms, purchase, download, upload, send, or delete unless the task was explicitly approved.
- Return a concise final result with source URLs.
""".strip()


def prepare_node(state: ErrandState) -> ErrandState:
    preferences = get_preferences()
    domains = [
        item.strip()
        for item in preferences["allowed_domains"].split(",")
        if item.strip()
    ]
    return {
        "prompt": build_prompt(state["task"], preferences),
        "allowed_domains": domains,
        "approval_required": requires_approval(state["task"]),
        "status": "prepared",
    }


def approval_node(state: ErrandState) -> ErrandState:
    if not state["approval_required"]:
        return {"approved": True, "status": "approved"}

    decision = interrupt(
        {
            "question": "Approve this potentially consequential browser errand?",
            "task": state["task"],
        }
    )
    return {
        "approved": bool(decision),
        "status": "approved" if decision else "cancelled",
    }


def route_after_approval(state: ErrandState) -> Literal["browse", "cancel"]:
    return "browse" if state.get("approved") else "cancel"


def browse_node(state: ErrandState) -> ErrandState:
    set_status(state["errand_id"], "running")
    try:
        metrics = asyncio.run(
            run_browser_task(
                state["prompt"],
                state["mode"],
                state["allowed_domains"],
            )
        )
        return {
            "result": str(metrics["result"]),
            "success": metrics["success"],
            "steps": int(metrics["steps"]),
            "duration_seconds": float(metrics["duration_seconds"]),
            "usage": str(metrics["usage"]),
        }
    except Exception as error:
        return {
            "result": f"Browser errand failed: {error}",
            "success": False,
            "steps": 0,
            "duration_seconds": 0.0,
            "usage": "not reported",
        }


def save_node(state: ErrandState) -> ErrandState:
    metrics: dict[str, Any] = {
        "result": state["result"],
        "success": state.get("success"),
        "steps": state["steps"],
        "duration_seconds": state["duration_seconds"],
        "usage": state["usage"],
    }
    save_run(state["errand_id"], state["mode"], metrics)

    status = "done" if state.get("success") is not False else "failed"
    set_status(state["errand_id"], status)

    RUNS_DIR.mkdir(exist_ok=True)
    report = RUNS_DIR / f"{state['errand_id']}.md"
    report.write_text(
        "\n".join(
            [
                f"# Errand {state['errand_id']}",
                "",
                f"Mode: {state['mode']}",
                f"Status: {status}",
                f"Steps: {state['steps']}",
                f"Duration seconds: {state['duration_seconds']}",
                f"Usage: {state['usage']}",
                "",
                "## Result",
                "",
                state["result"],
            ]
        ),
        encoding="utf-8",
    )
    return {"status": status}


def cancel_node(state: ErrandState) -> ErrandState:
    set_status(state["errand_id"], "cancelled")
    return {"status": "cancelled", "result": "Cancelled before browser execution."}


def build_graph(checkpointer: Any):
    builder = StateGraph(ErrandState)
    builder.add_node("prepare", prepare_node)
    builder.add_node("approval", approval_node)
    builder.add_node("browse", browse_node)
    builder.add_node("save", save_node)
    builder.add_node("cancel", cancel_node)

    builder.add_edge(START, "prepare")
    builder.add_edge("prepare", "approval")
    builder.add_conditional_edges(
        "approval",
        route_after_approval,
        {"browse": "browse", "cancel": "cancel"},
    )
    builder.add_edge("browse", "save")
    builder.add_edge("save", END)
    builder.add_edge("cancel", END)
    return builder.compile(checkpointer=checkpointer)

How is the workflow organized?

  • The prepare_node() function loads remembered preferences into the browser prompt.
  • The approval_node() function interrupts errands containing a sensitive term.
  • The browse_node() function starts browser execution after approval.
  • The save_node() function stores idempotent metrics. It also writes a Markdown report inside runs/.
  • The cancel_node() function records rejection without starting the browser.
  • Save workflow.py.
  • Check the file for Python syntax errors by running this command:
python -m py_compile workflow.py

What does this check prove?

Python compiles workflow.py without executing the workflow. A successful check returns to the prompt without printing an error.

Your workflow now has a durable approval boundary. The browser cannot start from the rejected branch.

Seeing a syntax error in workflow.py?

Compare the reported line with the full-code tab. Check the indentation around interrupt() plus the multiline prompt.

Confirm every closing parenthesis from build_graph() is present.

Help me fix a syntax error in workflow.py.

Add the ErrandRunner command interface

The workflow needs a stable command surface for starting new threads. The same surface must retrieve paused threads from checkpoints.sqlite.

How do the two databases divide the work?

The existing errands.sqlite database stores preferences plus queue statuses. The new checkpoints.sqlite database stores LangGraph thread state.

Both records use the same errand ID. That shared identifier reconnects a queued task with its paused checkpoint.

  • Create errand_runner.py beside workflow.py by using the new-file control in the file sidebar.
  • Use the full-code tab below to add the command interface.

✔️ Awesome, I've got everything!

Save errand_runner.py. The file should expose the queue plus approval commands.

ⓧ I'd like to double check the full code

  • Replace the contents of errand_runner.py with this complete reference:
from __future__ import annotations

import argparse
import asyncio
import os
from typing import Any

from dotenv import load_dotenv

load_dotenv()
os.environ.setdefault("LANGGRAPH_STRICT_MSGPACK", "true")

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command

from browser_agent import run_browser_task
from memory import (
    add_errand,
    get_errand,
    get_next_pending,
    get_preferences,
    init_db,
    list_errands,
    set_preference,
    set_status,
)
from workflow import (
    CHECKPOINT_DB,
    build_graph,
    build_prompt,
    requires_approval,
)


def require_google_key() -> None:
    if not os.getenv("GOOGLE_API_KEY"):
        raise SystemExit("Set GOOGLE_API_KEY in .env before running browser errands.")


def show_result(result: dict[str, Any]) -> None:
    print(f"Status: {result.get('status', 'unknown')}")
    if result.get("result"):
        print(result["result"])
    if result.get("steps") is not None:
        print(f"Steps: {result['steps']}")
        print(f"Seconds: {result['duration_seconds']}")
        print(f"Usage: {result['usage']}")


def execute_errand(errand_id: str, resume: bool | None = None) -> str:
    require_google_key()
    errand = get_errand(errand_id)
    if errand is None:
        raise SystemExit(f"Unknown errand ID: {errand_id}")

    config = {"configurable": {"thread_id": errand_id}}
    with SqliteSaver.from_conn_string(str(CHECKPOINT_DB)) as checkpointer:
        graph = build_graph(checkpointer)
        if resume is None:
            graph_input: dict[str, Any] | Command = {
                "errand_id": errand_id,
                "task": errand["task"],
                "mode": errand["mode"],
                "status": "pending",
            }
        else:
            graph_input = Command(resume=resume)

        result = graph.invoke(graph_input, config=config)

    if "__interrupt__" in result:
        set_status(errand_id, "paused")
        print("Paused for approval:")
        for pending in result["__interrupt__"]:
            print(pending.value)
        print(f"Resume with: python errand_runner.py resume {errand_id} yes")
        return "paused"

    show_result(result)
    return str(result.get("status", "unknown"))


def work_queue() -> None:
    while True:
        errand = get_next_pending()
        if errand is None:
            print("No pending errands.")
            return
        status = execute_errand(errand["id"])
        if status == "paused":
            print("Worker stopped at an approval boundary.")
            return


def compare_modes(task: str) -> None:
    require_google_key()
    if requires_approval(task):
        raise SystemExit("Compare mode only accepts read-only tasks.")

    preferences = get_preferences()
    domains = [
        item.strip()
        for item in preferences["allowed_domains"].split(",")
        if item.strip()
    ]
    prompt = build_prompt(task, preferences)
    results: dict[str, dict[str, Any]] = {}

    for mode in ("standard", "optimized"):
        print(f"Running {mode} mode...")
        results[mode] = asyncio.run(run_browser_task(prompt, mode, domains))

    print("\nMode       Steps   Seconds   Usage")
    for mode in ("standard", "optimized"):
        metrics = results[mode]
        print(
            f"{mode:<10} {metrics['steps']:<7} "
            f"{metrics['duration_seconds']:<9} {metrics['usage']}"
        )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Durable personal browser errands with memory and approvals."
    )
    commands = parser.add_subparsers(dest="command", required=True)

    remember = commands.add_parser("remember", help="Save a reusable preference.")
    remember.add_argument("key")
    remember.add_argument("value")

    commands.add_parser("memory", help="Show remembered preferences.")

    add = commands.add_parser("add", help="Add an errand to the durable queue.")
    add.add_argument("task")
    add.add_argument(
        "--mode",
        choices=("standard", "optimized"),
        default="optimized",
    )

    commands.add_parser("list", help="List all queued errands.")

    run = commands.add_parser("run", help="Run one errand by ID.")
    run.add_argument("errand_id")

    commands.add_parser("work", help="Process pending errands until approval is needed.")

    resume = commands.add_parser("resume", help="Resume an approval checkpoint.")
    resume.add_argument("errand_id")
    resume.add_argument("decision", choices=("yes", "no"))

    compare = commands.add_parser("compare", help="Compare standard and optimized modes.")
    compare.add_argument("task")
    return parser


def main() -> None:
    init_db()
    args = build_parser().parse_args()

    if args.command == "remember":
        set_preference(args.key, args.value)
        print(f"Remembered {args.key}={args.value}")
    elif args.command == "memory":
        for key, value in get_preferences().items():
            print(f"{key}={value}")
    elif args.command == "add":
        errand_id = add_errand(args.task, args.mode)
        print(f"Added errand {errand_id}")
    elif args.command == "list":
        for errand in list_errands():
            print(
                f"{errand['id']}  {errand['status']:<9} "
                f"{errand['mode']:<9} {errand['task']}"
            )
    elif args.command == "run":
        execute_errand(args.errand_id)
    elif args.command == "work":
        work_queue()
    elif args.command == "resume":
        errand = get_errand(args.errand_id)
        if errand is None or errand["status"] != "paused":
            raise SystemExit("That errand is not waiting for approval.")
        execute_errand(args.errand_id, args.decision == "yes")
    elif args.command == "compare":
        compare_modes(args.task)


if __name__ == "__main__":
    main()

How does the command interface work?

  • The add command writes a pending task to the durable queue.
  • The run command invokes a graph thread using the errand ID.
  • The work command processes pending tasks until approval is required.
  • The resume command restores a paused thread with Command(resume=...).
  • The list command prints durable queue statuses from errands.sqlite.
  • Save errand_runner.py.
  • Check both new files for Python syntax errors by running this command:
python -m py_compile workflow.py errand_runner.py

What does this check prove?

Python compiles both files together. A successful check returns to the terminal prompt without an error.

The command interface is ready. It can create threads plus reconnect to approval checkpoints.

Seeing an import or syntax error?

Confirm that workflow.py plus errand_runner.py sit beside memory.py.

Compare the reported file with its full-code tab. Pay close attention to the imports plus the indentation inside execute_errand().

Help me debug the ErrandRunner file check.

Pause and reject the form rehearsal

The safe rehearsal contains the sensitive term submit. That term sends every learner through the approval branch before Chromium can open.

  • Add the form rehearsal to the durable queue by running this command:
python errand_runner.py add "Visit https://httpbin.org/forms/post, fill a demo customer name and pizza size, but do not submit"

What does this command do?

The add command stores the rehearsal with a pending status. The default mode is optimized.

The generated errand ID becomes the LangGraph thread ID. You use that same value to start plus resume the task.

You'll see Added errand followed by an eight-character ID. That ID now connects the queue record to its future checkpoint.

  • Record the printed ID here: your printed errand ID.

Before you run the errand, do you think Chromium opens before the approval decision?

  • Start the queued errand by running this command:
python errand_runner.py run [[ERRAND_ID="your printed errand ID"]]

What happens at the checkpoint?

The graph prepares the remembered policy. It reaches interrupt() before the browser node.

The SQLite checkpointer saves the paused thread under the errand ID. The command then returns control to the terminal.

You'll see Paused for approval plus the approval question. Chromium stays closed.

That safety boundary is working. The consequential request is waiting without performing a browser action.

Did the errand skip the approval pause?

Confirm the queued task contains the word submit. The deterministic policy searches the task text for sensitive terms.

Confirm that approval_node() appears before browse_node() in build_graph().

Help me debug an approval pause that did not trigger.

  • Close the current terminal session after the paused output appears.
  • Create a fresh terminal session in VS Code.
  • Reconnect the fresh terminal to the existing virtual environment by running this command:
source .venv/bin/activate

Why reactivate the environment?

A fresh terminal does not inherit the earlier shell activation. This command reconnects python to the existing .venv/ environment.

Before you resume the thread, what status do you expect after answering no?

  • Reject the paused errand by running this command:
python errand_runner.py resume [[ERRAND_ID="your printed errand ID"]] no

How does rejection resume safely?

The resume command retrieves the checkpoint with the same thread ID. It passes False back to the interrupted node.

The conditional route selects cancel_node(). Browser execution remains outside the chosen path.

You'll see Status: cancelled plus Cancelled before browser execution. Chromium stays closed.

You have now resumed a durable approval checkpoint after ending the original session. The rejection survived the restart.

For the final check, do you think the queue still records the rejected status in a separate command?

  • Inspect the durable queue by running this command:
python errand_runner.py list

What does the list confirm?

The list command reads the queue from errands.sqlite. It does not depend on the earlier terminal process.

You'll see your errand ID with the cancelled status. No Chromium window opens during this check.

Your agent can now stop at a durable safety boundary. Next, you'll measure whether its optimized inference mode improves steps, duration, or usage.

Measure and Optimize Inference

Your durable approval flow now stops consequential errands before Chromium opens. The same thread can resume after a process restart.

A long Browser Use trace repeatedly feeds history back into model inference. It also feeds repeated reasoning into the model.

This step compares a standard agent loop with an optimized loop. You will measure the result instead of assuming that optimization always wins.

In this step, get ready to:
  • Inspect the settings that distinguish standard mode from optimized mode.
  • Trace the three run metrics collected for each mode.
  • Compare both modes on the same read-only errand.
Inspect the two inference modes

Each model step can include one or more browser actions. Retained history controls how much prior context returns to the model.

  • Switch back to browser_agent.py in VS Code.
  • Find optimized = mode == "optimized" inside run_browser_task().
  • Trace optimized through the mode settings inside Agent().
  • Confirm that agent.run() uses the same max_steps=12 limit for both modes.

How does each mode work?

  • Standard mode sets max_actions_per_step=1.
  • Standard mode supplies None to max_history_items. This keeps the full available history.
  • Optimized mode enables flash_mode=True. This skips the explicit evaluation stage.
  • Optimized mode sets max_history_items=4. This bounds the history returned to the model.
  • Optimized mode sets max_actions_per_step=5. This allows the agent to batch more browser actions into one model step.
Trace the recorded metrics

Optimization needs observable evidence. The existing runner enables cost tracking before collecting the same three metrics from both modes.

  • Locate calculate_cost=True in the Agent() configuration.
  • Follow history.number_of_steps() into the returned steps value.
  • Follow history.total_duration_seconds() into the returned duration_seconds value.
  • Follow history.usage into the returned usage value.
  • Switch back to errand_runner.py in VS Code.
  • Find compare_modes() to confirm that it prints one metric row for each mode.

What do the metrics reveal?

  • The steps value reports how many model steps the agent used.
  • The duration_seconds value reports the elapsed time for that browser run.
  • The usage value reports the tracked model tokens for that run.
Test your optimization hypothesis

A fair comparison gives both modes the same task. The remembered preferences also remain identical between runs.

Before you run this, which mode do you expect to use fewer model steps? Your prediction gives the metric table something to test.

  • Return to the terminal from your approval test.
  • Keep Chromium visible until both browser runs finish.
  • Compare both modes by running this command:
python errand_runner.py compare "Visit https://news.ycombinator.com/show and return the first three post titles and URLs without leaving this domain."

What does this command do?

  • The compare command checks that the errand is read-only.
  • The runner builds one prompt from the task plus your remembered preferences.
  • The standard mode runs first.
  • The optimized mode runs second.
  • The final table places their steps, seconds, and usage side by side.

You will see Running standard mode... first. You will see Running optimized mode... after the first browser session closes.

After both sessions finish, the terminal prints standard and optimized rows containing steps, seconds, and usage. The values can differ between runs because live pages and model decisions can vary.

Missing a metric row?

  • Confirm that news.ycombinator.com remains in your remembered allowed_domains value if navigation is blocked.
  • Wait for the first Chromium session to close before checking for the optimized run.
  • Run the comparison again if a temporary browser or network failure interrupts one mode.

Help me troubleshoot an incomplete ErrandRunner comparison.

  • Compare the steps values across both rows.
  • Compare the seconds values across both rows.
  • Compare the usage values across both rows.
  • Choose the stronger run using the metric that matters most for your errand.

Judge the observed trade-off

  • A lower step count shows that batching reduced model turns in this run.
  • A lower duration shows that this specific run finished faster.
  • Lower usage shows that the run consumed fewer tracked tokens.
  • Optimized mode remains useful when its result quality stays acceptable.

The add command already defaults new errands to optimized mode.

  • Keep optimized mode for short read-only errands when its result quality remains acceptable.

You now have evidence from the same errand in both modes. Your optimization choice can follow measured behavior instead of an assumption.

Secret mission

Build a Reusable Morning Brief

Turn ErrandRunner into a reusable two-site morning brief. Prove that remembered policy guides both errands after the worker restarts.

Clean Up Your Resources

Clean Up Your Resources

Choose how much of ErrandRunner to keep.

Local files create no ongoing cost. A paid Gemini project can incur token charges when you run another errand.

Cost warning

Input for gemini-2.5-flash is free on the current Free Tier. Output is also free under that tier.

Active project quotas still apply. Normal token pricing can apply when the key belongs to a paid project.

Resources you used:

  • The .venv/ environment containing the pinned dependencies.
  • The errands.sqlite database containing durable preferences plus queue statuses.
  • The checkpoints.sqlite database containing approval checkpoints.
  • The runs/ folder containing Markdown reports.
  • The .env file containing local API configuration.

The managed Chromium installation sits outside the project folder. These cleanup choices leave it installed for future Browser Use projects.

Keep everything running

Keeping everything preserves your morning brief configuration. This option suits continued experiments with new errands.

  • Leave .venv/, errands.sqlite, checkpoints.sqlite, runs/, and .env in place.
  • Keep .env private because it contains access to Gemini API usage.
  • Run another errand when you want to reuse the saved allowlist plus result preferences.

Pause - I'll come back to this later

Pausing stops active browser work without deleting your agent state. Your queue stays on disk.

  • Press Ctrl+C in the worker terminal if a browser errand is still running.
  • Leave errands.sqlite in place so pending work remains available.
  • Leave checkpoints.sqlite in place so paused approval threads remain resumable.
  • Keep runs/ so completed reports remain available for review.

Delete - I don't want to use this again

Deleting these resources clears the agent's local working state. Your Python source files remain in place.

  • Return to the terminal in the ErrandRunner project directory from earlier.
  • Remove the local runtime plus durable project data by running this command:
rm -rf .venv/ errands.sqlite checkpoints.sqlite runs/ .env

What Does This Remove?

  • The virtual environment is removed.
  • The durable queue database is removed.
  • The approval checkpoint database is removed.
  • The generated reports are removed.
  • The local API configuration is removed.
  • Use the Explorer sidebar in VS Code to check the project folder.
  • Confirm that .venv/, errands.sqlite, checkpoints.sqlite, runs/, and .env are no longer listed.

Removing .env clears the key from this folder. The key still exists in your Google AI Studio project.

  • Return to Google AI Studio through the official Gemini API key guide.
  • Revoke the key associated with this project if you no longer plan to use it.

Nice Work!

Nice Work!

You made it! ErrandRunner now completes visible browser errands from natural-language instructions.

You've learned how to:

  • Build a visible browser agent with Browser Use and Gemini. Keep every errand inside a configurable domain allowlist.
  • Persist reusable preferences, queued errands, final statuses, and run metrics with SQLite. Resume approval checkpoints across process restarts with LangGraph.
  • Measure standard mode against optimized mode using step count, duration, and token usage. Choose the mode whose measured performance preserves acceptable result quality.
  • Secret Mission: Configure a reusable two-site morning brief. Prove that its durable queue survives a restart and respects the remembered domain allowlist.

Ready to quiz yourself?