# How Snapshot References (`@N`) Work Across Multiple Heredoc Execution Rounds in Ego-Lite

> Learn how snapshot references @N work across multiple heredoc rounds in Ego-Lite. Discover how refMap rebuilds enable lazy re-resolution for persistent references.

- Repository: [CitroLabs/ego-lite](https://github.com/citrolabs/ego-lite)
- Tags: internals
- Published: 2026-08-02

---

**Snapshot references (`@N`) persist across heredoc execution rounds because Ego-Lite's browser runtime rebuilds the global `refMap` on every `page.snapshot()` call, enabling automatic lazy re-resolution when stale references are encountered.**

In Ego-Lite, the `citrolabs/ego-lite` repository provides a browser automation harness that uses the Chrome DevTools Protocol (CDP) to interact with web pages. A **snapshot reference** (`@N`) is a compact, agent-friendly way to refer to DOM elements by their **backendNodeId**—a numeric identifier assigned by Chrome. Understanding how these references survive across multiple heredoc execution rounds requires examining how the runtime manages state in `refMap` and when snapshots are triggered.

## What Snapshot References (`@N`) Actually Represent

A snapshot reference is not a persistent DOM selector. Instead, it is a transient mapping to a CDP **backendNodeId**.

When `page.snapshot()` or `page.snapshotRaw()` is invoked, the runtime:

- Captures the page's semantic structure as human-readable content
- Extracts a `refs` array containing metadata for visible elements
- Rebuilds the global `refMap` in [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) from scratch

This design means `@N` references are only valid relative to the most recent snapshot. The runtime ensures freshness through lazy re-snapshotting.

## How `refMap` Is Rebuilt Each Round

The core mechanism lives in [`src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts). The function `browserSnapshotRefsToRefMap` handles the population logic:

```typescript
// Conceptual flow from src/browser-runtime.ts
refMap.clear();
for (const ref of snapshot.refs) {
  refMap.set(ref.backendNodeId, ref);
}

```

Because `refMap` is cleared and repopulated on every snapshot, any heredoc round that calls `page.snapshot()` automatically refreshes all available references. The map is stored in a runtime singleton defined in [`src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/state.ts), making it accessible across execution contexts.

## Automatic Resolution and Lazy Re-Snapshotting

The [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) module handles the actual lookup when an agent uses `@N` syntax:

1. **Parse** the locator to extract the numeric id (e.g., `@12` → `12`)
2. **Query** `refMap.get(refId)` for the corresponding `RefEntry`
3. **Trigger re-snapshot** if the map is empty or the id is missing
4. **Resolve** the element or throw `ElementResolutionError` for invalid ids

This lazy approach means agents do not need explicit snapshot calls between rounds. The resolver automatically ensures the map is current.

```javascript
// Round 1: Explicit snapshot to establish references
const snap = await page.snapshot();
console.log(snap);  // Contains "@12", "@34", etc.

// Round 2: Reuse reference without manual snapshot
await page.click('@12');  // Resolver checks refMap, re-snapshots if stale

```

## Handling Navigation and DOM Changes

Navigation or significant DOM mutations invalidate the `refMap` because backendNodeIds are page-specific. However, the resolver's lazy re-snapshotting handles this transparently:

```javascript
await page.goto('https://example.com');  // Navigation clears refMap
await page.click('@12');                 // Auto-triggers fresh snapshot

```

If the element with backendNodeId `12` no longer exists, the re-snapshot will populate `refMap` with current ids, and the resolver will throw `ElementResolutionError` for the stale reference.

## State Management Across File Boundaries

The persistence mechanism spans several source files with distinct responsibilities:

| File | Key Function | Role in `@N` Persistence |
|------|-----------|------------------------|
| [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) | Holds `snapshotImpl` hook and mutable `refMap` | Defines the global state container |
| [`src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts) | `browserSnapshotRefsToRefMap` | Clears and repopulates `refMap` from snapshot data |
| [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) | `@N` locator resolution | Triggers re-snapshot when `refMap` lookup fails |
| [`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts) | `snapshot()` and `snapshotRaw()` | Public API for explicit snapshot calls |
| [`src/format.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/format.ts) | `@N` syntax formatting | Defines how references appear in output |

## Complete Working Example

```javascript
// Multi-round heredoc pattern with automatic reference persistence

// Round 1: Establish baseline snapshot
const initial = await page.snapshot();
// Output: "Search button [@12]\nSubmit form [@23]"

// Round 2: Interact using established references
await page.click('@12');  // Clicks search button

// Round 3: Navigate, then reuse reference pattern
await page.goto('https://example.com/results');

// Despite navigation, @N syntax still works via auto-re-snapshot
await page.click('@5');   // Resolver fetches fresh map automatically

// Round 4: Handle potential staleness explicitly
try {
  await page.click('@999');  // Invalid reference
} catch (err) {
  // ElementResolutionError: reference not in current snapshot
}

```

## Summary

- **Snapshot references (`@N`)** map to CDP `backendNodeId` values, not stable DOM selectors
- **`refMap`** in [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) is cleared and rebuilt on every `page.snapshot()` call via `browserSnapshotRefsToRefMap` in [`src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts)
- **Lazy re-snapshotting** in [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) ensures references resolve correctly across heredoc rounds without explicit snapshot calls
- **Navigation and DOM changes** trigger automatic refresh on the next `@N` usage, with clear error semantics for permanently invalid references

## Frequently Asked Questions

### What happens if I use `@N` without calling `page.snapshot()` first?

The resolver in [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) automatically triggers `page.snapshot()` when `refMap` is empty or the requested id is missing. Your code will still execute, incurring a one-time latency penalty for the implicit snapshot.

### Are `@N` references stable across page navigations?

No. BackendNodeIds are assigned per-page by the Chrome DevTools Protocol. After navigation, previous `@N` values become invalid. However, the resolver detects stale maps and fetches fresh references automatically, throwing `ElementResolutionError` only if the specific id does not exist in the new page state.

### Can multiple heredoc scripts share the same `refMap`?

Yes. `refMap` is stored in a runtime singleton ([`src/state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/state.ts)) shared across all execution contexts within the same browser session. Any script's snapshot call updates the map for all subsequent reference resolutions.

### How do I debug which `@N` values are currently available?

Call `page.snapshotRaw()` from [`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts) to inspect the raw `refs` array containing all current backendNodeIds and their associated metadata:

```javascript
const raw = await page.snapshotRaw();
console.log(raw.refs.map(r => ({ id: r.backendNodeId, role: r.role, name: r.name })));

```