DEV Community

Cover image for Designing Browser Agents That Don’t Double-Post: State, Idempotency, and Verification
Harshith Vaddiparthy
Harshith Vaddiparthy

Posted on

Designing Browser Agents That Don’t Double-Post: State, Idempotency, and Verification

Browser agents are easy to demo and difficult to operate.

A demo can navigate to a page, fill an editor, and click Publish. A durable agent must answer a longer list of questions:

  • Is it acting through the correct account?
  • Has another scheduled run already reserved this action?
  • Did the click create an external effect?
  • Can that effect be identified with a canonical URL or exact state?
  • If the browser timed out, is a retry safe?
  • Can the next run resume without duplicating the action?

I encountered a small example of this while building a browser-based publishing agent. The agent needed to shorten a draft, but the web editor appended the replacement text instead of replacing the original. A naive implementation could have continued clicking or restarted the publishing flow. The safer implementation read the editor state back, detected that it did not match the intended payload, refused to publish, and recorded a failure.

That is the core principle of this design:

The model chooses an action. The surrounding system makes the action trustworthy.

This article develops a minimal architecture for that surrounding system. The examples use Python-like code and SQLite, but the patterns apply to any stack that operates external interfaces.

Start with an explicit action lifecycle

Do not let an agent move directly from “the model suggested this” to “click the button.” Give every external mutation a lifecycle:

observed
   ↓
planned / reserved
   ↓
executing
   ├──────────────→ verified ──→ budget committed
   ├──────────────→ failed
   └──────────────→ ambiguous
Enter fullscreen mode Exit fullscreen mode

The important state is ambiguous. It means the system attempted a mutation but could not prove whether the external effect occurred.

An ambiguous action is not a failed action. It must be reconciled before any retry.

1. Persist intent before performing the mutation

A durable action record should capture enough information for a future run to understand what was intended and how to verify it.

CREATE TABLE actions (
    id TEXT PRIMARY KEY,
    idempotency_key TEXT NOT NULL UNIQUE,
    run_id TEXT NOT NULL,
    action_type TEXT NOT NULL,
    actor TEXT NOT NULL,
    target_url TEXT,
    intended_payload TEXT NOT NULL,
    status TEXT NOT NULL CHECK (
        status IN (
            'planned',
            'executing',
            'ambiguous',
            'verified',
            'failed'
        )
    ),
    canonical_result TEXT,
    failure_reason TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    verified_at TEXT
);
Enter fullscreen mode Exit fullscreen mode

The intended_payload should contain the exact information needed for later comparison. For a post, that could include text, media hashes, alt text, and an expected parent URL. For a follow, the target account is sufficient.

Avoid persisting browser cookies or session tokens in this operational database. Authentication should remain inside the controlled browser profile.

2. Build a deterministic idempotency key

The idempotency_key prevents multiple runs from independently executing the same intention.

A useful key is based on stable fields:

import hashlib
import json


def idempotency_key(action_type, actor, target_url, payload):
    material = {
        "action_type": action_type,
        "actor": actor,
        "target_url": target_url,
        "payload": payload,
    }
    encoded = json.dumps(
        material,
        sort_keys=True,
        separators=(",", ":"),
    ).encode()
    return hashlib.sha256(encoded).hexdigest()
Enter fullscreen mode Exit fullscreen mode

Then reserve the action before opening the mutation UI:

def reserve_action(db, action):
    cursor = db.execute(
        """
        INSERT INTO actions (
            id,
            idempotency_key,
            run_id,
            action_type,
            actor,
            target_url,
            intended_payload,
            status,
            created_at,
            updated_at
        )
        VALUES (?, ?, ?, ?, ?, ?, ?, 'planned', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
        ON CONFLICT(idempotency_key) DO NOTHING
        """,
        (
            action.id,
            action.idempotency_key,
            action.run_id,
            action.action_type,
            action.actor,
            action.target_url,
            json.dumps(action.payload, sort_keys=True),
        ),
    )
    db.commit()
    return cursor.rowcount == 1
Enter fullscreen mode Exit fullscreen mode

If reserve_action() returns False, the run must inspect the existing record. It must not create a slightly different key simply to force the action through.

3. Prevent concurrent operators with a lease

Idempotency protects individual intentions. A lease prevents two scheduled runs from controlling the same browser workflow concurrently.

CREATE TABLE operator_lease (
    name TEXT PRIMARY KEY,
    owner_id TEXT NOT NULL,
    acquired_at TEXT NOT NULL,
    heartbeat_at TEXT NOT NULL,
    expires_at TEXT NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Lease acquisition should be atomic:

def acquire_lease(db, name, owner_id, expires_at):
    db.execute("BEGIN IMMEDIATE")
    current = db.execute(
        "SELECT owner_id, expires_at FROM operator_lease WHERE name = ?",
        (name,),
    ).fetchone()

    if current and not expired(current["expires_at"]):
        db.rollback()
        return False

    db.execute(
        """
        INSERT INTO operator_lease (
            name, owner_id, acquired_at, heartbeat_at, expires_at
        )
        VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
        ON CONFLICT(name) DO UPDATE SET
            owner_id = excluded.owner_id,
            acquired_at = excluded.acquired_at,
            heartbeat_at = excluded.heartbeat_at,
            expires_at = excluded.expires_at
        """,
        (name, owner_id, expires_at),
    )
    db.commit()
    return True
Enter fullscreen mode Exit fullscreen mode

Long browser rounds should refresh the heartbeat. A run should release the lease in a finally block, while expiry handles processes that disappear entirely.

4. Perform preflight checks before every mutation

Browser state can change between actions. A preflight at the beginning of a run is not enough if the run lasts several minutes.

Before each public mutation, verify:

  1. The browser is on the expected origin.
  2. The signed-in identity is exactly the intended actor.
  3. No login, CAPTCHA, restriction, or rate-limit screen is visible.
  4. The agent still owns the browser task space.
  5. The target URL resolves to the intended object.
  6. The relevant control has an unambiguous current state.

Treat identity or target uncertainty as a hard stop. Do not work around it with alternate endpoints, extracted cookies, or stealth behavior.

5. Separate preparation from the external effect

Drafting text, rendering an image, and calculating a target URL are reversible preparation. Clicking Publish is an external mutation.

Keep those phases separate:

def execute_action(browser, db, action):
    assert preflight(browser, action)

    prepared = prepare_exact_payload(action)
    assert prepared == action.payload

    set_status(db, action.id, "executing")

    try:
        browser.perform_once(action, prepared)
    except BrowserOutcomeUnknown as exc:
        set_ambiguous(db, action.id, str(exc))
        return

    verification = verify_live_result(browser, action)

    if verification.exact_match:
        mark_verified(
            db,
            action.id,
            canonical_result=verification.canonical_url,
        )
    elif verification.definitive_absence:
        mark_failed(db, action.id, "external effect absent")
    else:
        set_ambiguous(db, action.id, "result could not be proven")
Enter fullscreen mode Exit fullscreen mode

perform_once() is intentionally named. It should not contain a generic retry decorator.

Retries may be safe for observation operations such as loading a page. They are unsafe around mutations unless the destination supports a genuine idempotency token.

6. Define verification contracts by action type

Verification should be stricter than “the click did not throw an exception.”

For a published post, verify:

  • exact signed-in author;
  • exact normalized text;
  • expected media count and type;
  • alt text when available;
  • parent relationship for a reply;
  • canonical status URL.

For a like, verify that the target-specific control changed from a like state to an unlike state. For a follow, verify that the control associated with the exact account now represents “Following.”

For a thread, store each part separately:

CREATE TABLE thread_parts (
    thread_id TEXT NOT NULL,
    position INTEGER NOT NULL,
    text TEXT NOT NULL,
    expected_parent_url TEXT,
    published_url TEXT,
    verified INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (thread_id, position)
);
Enter fullscreen mode Exit fullscreen mode

Do not publish part n + 1 until part n has a verified canonical URL. That URL becomes the expected parent for the next part. If the run stops midway, recovery resumes from the first unverified part rather than rebuilding the thread.

7. Reconcile before retrying

At the beginning of every run, process unfinished actions before creating new ones.

def reconcile_unfinished(db, browser):
    for action in load_unfinished_actions(db):
        result = search_exact_live_result(browser, action)

        if result.exact_match:
            mark_verified(db, action.id, result.canonical_url)
        elif result.definitive_absence:
            mark_failed(db, action.id, "safe to replan")
        else:
            raise HardStop(
                f"Action {action.id} remains ambiguous"
            )
Enter fullscreen mode Exit fullscreen mode

The difficult function is search_exact_live_result(). Its implementation depends on the platform, but the contract should remain narrow. Search the intended actor or destination, compare exact payload attributes, and use a bounded time window.

Do not accept a vaguely similar result as proof. It is better to stop one run than to manufacture certainty.

8. Commit budgets only after verification

Autonomous systems need action budgets, but reservation and consumption are different.

Check the budget before acting. Consume it only after the result is verified. A malformed draft that never publishes should not reduce the successful-publication allowance.

Budgets should also aggregate related formats. If text posts, image posts, and threads all count as original publications, enforce both their format-specific limits and a shared original-content limit.

This prevents a system from bypassing an overall limit by switching action labels.

9. Use a useful failure taxonomy

Not every non-success is the same:

  • Definitive failure: The external effect did not occur. The action may be replanned.
  • Ambiguous outcome: The mutation may have occurred, but verification is incomplete. Reconcile before retrying.
  • Hard stop: Identity, authentication, target, ownership, restriction, or repeated selector uncertainty makes further mutations unsafe.
  • Quality skip: The system has permission and capacity to act, but the proposed action lacks sufficient evidence or usefulness.

Recording these categories makes strategy analysis more honest. A quality skip is not a broken agent. It can be evidence that the quality boundary is working.

10. Keep the model inside the boundary

The language model can help with discovery, ranking, drafting, and deciding which action has the highest expected value. It should not redefine the execution guarantees at runtime.

Keep these controls deterministic:

  • lease acquisition;
  • idempotency-key uniqueness;
  • budget enforcement;
  • action-state transitions;
  • verification requirements;
  • hard-stop conditions.

This division lets the model be creative where creativity is useful and constrained where external side effects demand precision.

A complete run

The resulting operator loop is straightforward:

def run_operator(db, browser, run_id):
    if not acquire_lease(db, "browser-operator", run_id, ttl()):
        return {"status": "locked_skip"}

    try:
        reconcile_unfinished(db, browser)
        verify_identity(browser)

        observations = collect_due_observations(browser)
        candidates = model.rank(observations)

        for candidate in candidates:
            if not quality_gate(candidate):
                record_quality_skip(db, candidate)
                continue

            action = build_action(candidate, run_id)

            if not within_budget(db, action):
                continue

            if not reserve_action(db, action):
                continue

            execute_action(browser, db, action)

            if action_is_verified(db, action.id):
                commit_budget(db, action)

        return summarize_run(db, run_id)
    finally:
        release_lease(db, "browser-operator", run_id)
Enter fullscreen mode Exit fullscreen mode

The example omits platform-specific selectors deliberately. Selectors change. The reliability contract should survive those changes.

Final principle

Browser agents operate in a world where the interface, network, identity, and human owner can all change underneath them. More capable models improve planning, but they do not remove that uncertainty.

The durable design is:

observe → plan → reserve → act once → verify → commit
Enter fullscreen mode Exit fullscreen mode

When the result is unclear:

stop → reconcile → prove → continue
Enter fullscreen mode Exit fullscreen mode

The safest agent is not the one that retries most aggressively. It is the one that can distinguish a failed action from a failed observation, preserve that distinction across runs, and refuse to guess when an external effect is at stake.

The model is not the system. State, idempotency, verification, and recovery are the parts that turn model output into trustworthy operation.

Top comments (0)