> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentvault.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Sessions and timeouts

> How long a proxy session lives, which errors end it, and how to recover from each.

Tell a dead session from a stalled call, so you never relaunch a browser that
was still alive or replay an action that already landed.

## Overview

* A **proxy session** is one browser, with its tabs and cookies, living in the
  proxy. `chromium.launch()` starts one (the MCP server starts one on the first
  `open_page`).
* When the session ends, the browser, tabs and cookies are gone. There is
  nothing to reconnect to.
* A brief network drop does not end it. By default the SDK reconnects to the
  same session. See [Resume after a dropped connection](#resume-after-a-dropped-connection).
* Mock tokens do not expire. Only the session does.
* Every call carries a deadline, so one hung call never leaves your process
  waiting forever.

## Session limits

| Timer | Limit | WebSocket close code | Error code |
| - | - | - | - |
| Idle | 15 minutes with no message and no call in flight | 4008 | `idle_timeout` |
| Hard | 60 minutes total | 4009 | `hard_timeout` |

A call in flight counts as activity. While the proxy is still working on a
call, it keeps resetting the idle timer, and the answer resets it once more. So a
single call that runs longer than 15 minutes, such as a fill waiting on the
account owner, is not ended by `idle_timeout`. It can still end at its own
deadline (see [Proxy side](#proxy-side)) or at the 60 minute hard limit.

<Warning>
  The hard timer is never reset. It fires on its own schedule, not at a safe point
  between actions, so a call still running at the 60 minute mark can be cut off
  after it reached the merchant.
</Warning>

A session can outlive one of your turns. The proxy sets no per-turn limit, only
these two timers. See [Driving a session across turns](#driving-a-session-across-turns).

## Terminal and non-terminal errors

Four codes mean **this session is over**. They do not mean "retry this call".

| Code | Raised by | What happened |
| - | - | - |
| `request_timeout` | The SDK | The proxy did not answer within the call's deadline, so the connection is treated as dead and closed |
| `connection_closed` | The SDK | The socket closed while the call was in flight, from either end, with no more specific reason. Also a network drop the SDK could not resume |
| `idle_timeout` | The proxy | 15 minutes with no message and no call in flight (close code 4008) |
| `hard_timeout` | The proxy | 60 minutes total (close code 4009) |

A close with code 4001 arrives as `unauthorized` instead.

Recovery for all four is a fresh `chromium.launch()`. Before replaying anything,
apply [the unknown-outcome rule](#when-an-outcome-is-unknown): none of these
codes says whether the last action ran.

Three codes look like a dead connection and are **not** terminal:

| Code | What happened |
| - | - |
| `unanswered` | The proxy stopped waiting for one call. The transport is still up, and the browser, its pages and its cookies are intact |
| `action_timeout` | The SDK stopped waiting for one call after your `actionTimeoutMs`. The session is still open. See [The per-action bound](#the-per-action-bound) |
| `connection_resumed` | The connection dropped and the SDK took the same session back. Only the call in flight across the drop fails. See [Resume after a dropped connection](#resume-after-a-dropped-connection) |

None of the three says whether the call ran. Treat an action as possibly
landed, and check before you repeat it. After `unanswered` or `action_timeout`
the call may still be running, so follow
[Recover from `unanswered`](#recover-from-unanswered).

Relaunching after any of them throws away a session that was still there. If
you hold a browser across calls, check it before reusing it:

```ts theme={null}
if (!browser.isConnected) browser = await chromium.launch();
```

## The deadlines

### Client side

The SDK gives each call a deadline. Past it, the call rejects with
`request_timeout` and the session closes. `requestTimeoutMs(method, params)`
returns the value for any call.

| Call | Deadline |
| - | - |
| `browser.close` | 30 seconds. The socket closes with it, so a false timeout costs nothing |
| Any card or credential fill | 65 minutes, longer than the session's 60-minute lifetime, because it can wait on a person. In practice the session's hard timeout ends it first |
| `attestedCheckout`, `requestChallengeHandoff` | 65 minutes, so the session's lifetime |
| Everything else, including `context.close` and `page.close` | `SELF_AGENT_PAY_REQUEST_TIMEOUT_MS`, default 180000 |
| A call with an explicit `timeout`, such as `waitFor` or `goto` | That `timeout` plus 60 seconds, when that is longer than the default, up to 65 minutes |
| A call passed `{ timeout: 0 }` | 65 minutes, so the session's whole lifetime, not the default |

No deadline is longer than 65 minutes, so the session's 60-minute hard timeout
always ends a call first.

### The per-action bound

The deadline above answers "is the connection dead?", so it is generous and
it ends the session when it fires. To give up on one hung click sooner and keep
the session, set a hard bound per call:

```ts theme={null}
const browser = await chromium.launch({ actionTimeoutMs: 45_000 });
```

Or set `SELF_AGENT_PAY_ACTION_TIMEOUT_MS`. Unset means no per-action bound.

* A call that runs past it rejects with `action_timeout`. The session stays
  open, unlike `request_timeout`.
* A `timeout` you pass on the call does not raise it.
* It applies only when it is shorter than the call's own deadline.
* It never applies to `browser.close`, `requestChallengeHandoff`,
  `attestedCheckout`, `selectOption`, or any `fill`, `type` or
  `pressSequentially`. Those can be waiting on the account owner, and the proxy
  would carry on after you gave up.

The proxy is not told the SDK gave up, so the call may still be running there.
Recover exactly as for [`unanswered`](#recover-from-unanswered).

### Proxy side

The proxy keeps its own, shorter deadline on each page call and answers
`unanswered` when Playwright never comes back: 120 seconds, or the call's own
`timeout` plus 30 seconds, capped at an hour. That deadline is suspended while a
call is waiting on the account owner.

A few calls keep no proxy deadline: `browser.close`, `attestedCheckout`,
`requestChallengeHandoff`, `recordOutcome`, and any call passed
`{ timeout: 0 }`. For those, a stall ends in `request_timeout` or the hard timer,
both terminal.

<Warning>
  Keep `SELF_AGENT_PAY_REQUEST_TIMEOUT_MS` at 150000 or above. The proxy answers a
  stalled call at 120 seconds with `unanswered`. A shorter client deadline closes
  the session before that answer arrives, turning a recoverable stall into a dead
  session.
</Warning>

## Recover from `unanswered`

The stalled call was **abandoned, not cancelled**. It keeps running in the proxy
with its result dropped, and an action may still land later.

<Tabs>
  <Tab title="A read, wait or count">
    Retry once. The session is intact and the call changed nothing.
  </Tab>

  <Tab title="An action">
    Do not retry yet. An abandoned click, press or select can land at any moment
    until its tab is gone, so checking the merchant while the tab is open proves
    nothing.

    <Steps>
      <Step title="Close the tab">
        ```ts theme={null}
        await page.close(); // retires the target, which stops the action
        ```

        If the close itself comes back `unanswered`, call it again: the repeat waits on
        the close still in flight.
      </Step>

      <Step title="Read the merchant's own state">
        ```ts theme={null}
        const fresh = await context.newPage(); // keeps cookies and sign-in
        await fresh.goto(merchantOrderUrl);
        ```
      </Step>

      <Step title="Continue only if no order is there">
        Carry on in the new tab of the same context.
      </Step>
    </Steps>

    A stalled screenshot counts as an action here: its capture can fire a transition
    the page acts on. A stalled card fill may already have recorded its transaction
    and spend; check the dashboard's activity before filling again. Activity
    recording is best effort, so a missing entry does not prove the fill did not happen.
  </Tab>
</Tabs>

## Resume after a dropped connection

When the network or a load balancer drops the socket, the SDK redials and takes
the **same** session back. It keeps trying for about 30 seconds, or up to 150
seconds while the proxy is still letting go of the old connection. The browser, its pages, its
cookies and its fill state are all still there. Calls you make while it
redials wait for it. The proxy holds a dropped session for 60 seconds. A client
that has not come back by then finds it closed.

Only a call that was in flight across the drop fails, with
`connection_resumed`. Its answer was lost with the socket, so its outcome is
unknown:

* **A read, wait or count:** retry it.
* **An action:** it may have landed. Look at the page on the same browser before
  repeating it. Do not relaunch.

```ts theme={null}
try {
  await page.click("#place-order");
} catch (err) {
  if (err instanceof SelfAgentPayError && err.code === "connection_resumed") {
    // Same browser, same page. Check whether the order went through.
  } else throw err;
}
```

If the session cannot be resumed (the proxy refused, or the redial ran out of
time), the call fails with the terminal `connection_closed` instead. `err.dropReason` says
why the connection ended: `transport_lost`, `proxy_restart`, `proxy_error`,
`superseded` or `resume_refused`.

Resume is on by default. To end the session on any drop instead, pass
`chromium.launch({ resume: false })` or set `SELF_AGENT_PAY_RESUME=0`. A proxy
that does not support resume ends the session on a drop either way.

## When an outcome is unknown

A missing or connection-level error code means **the request got no verdict,
not that it never landed**. Any close rejects every in-flight call, and the
connection can drop after a click or submit reached the merchant.

```mermaid theme={null}
flowchart TD
    E[A call failed] --> R{Was it a read,<br/>wait or count?}
    R -- Yes --> S[Safe to repeat]
    R -- No --> M{Could it have<br/>changed the checkout?}
    M -- No --> S
    M -- Yes --> U[Unknown outcome]
    U --> C[Check durable merchant state:<br/>the order page, the account<br/>order list, or a receipt email]
    C --> D{Order exists?}
    D -- Yes --> Stop[Do not replay. Report it]
    D -- No --> Replay[Safe to replay]
```

A `request_timeout` on a **card fill** does not say whether the fill happened, so
a blind retry can pay twice. Check the merchant's order state, or the
transactions and activity feed on your dashboard. The activity feed is best
effort, so a missing entry there does not prove the fill did not happen.

### `browser.outcome()` cannot settle it

Its `confirmationDetected` flag is set only by your own `page.recordOutcome()`
call. The proxy never sets it by observing the merchant.

* `confirmationDetected: true` means your agent called `recordOutcome()`,
  nothing more. The proxy does not check it against the page. It is only as
  reliable as the check your agent made first, so confirm against merchant
  state before you report the order.
* `confirmationDetected: false` is **inconclusive**. An order that succeeded a
  moment before the failure looks exactly like one that never started.

Once the socket has closed, `outcome()` is not available at all: it runs over
that socket, and a fresh session reports its own empty outcome. The only sound
check is durable merchant state.

## Driving a session across turns

A browser session lives as long as the process that owns it. Nothing requires
launch, act and close to sit in one script. To drive a checkout one step at a
time, keep one process running that holds the browser and takes commands, for
example from a command file it polls.

```bash theme={null}
setsid nohup node driver.mjs &
```

<Note>
  `pkill -f driver` also matches the shell command that launched it. A driver that
  dies seconds after starting was most likely killed by its own launcher.
</Note>

## Related

<CardGroup cols={2}>
  <Card title="Error codes" icon="triangle-exclamation" href="/sdk/error-codes">
    Every code, and whether retrying is safe.
  </Card>

  <Card title="Confirming outcomes" icon="receipt" href="/guides/confirming-outcomes">
    Session outcome, run report, and what counts as proof.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.