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

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 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. The function browserSnapshotRefsToRefMap handles the population logic:

// 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, making it accessible across execution contexts.

Automatic Resolution and Lazy Re-Snapshotting

The 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., @1212)
  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.

// 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:

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 Holds snapshotImpl hook and mutable refMap Defines the global state container
src/browser-runtime.ts browserSnapshotRefsToRefMap Clears and repopulates refMap from snapshot data
src/element-resolver.ts @N locator resolution Triggers re-snapshot when refMap lookup fails
src/helpers.ts snapshot() and snapshotRaw() Public API for explicit snapshot calls
src/format.ts @N syntax formatting Defines how references appear in output

Complete Working Example

// 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 is cleared and rebuilt on every page.snapshot() call via browserSnapshotRefsToRefMap in src/browser-runtime.ts
  • Lazy re-snapshotting in 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 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) 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 to inspect the raw refs array containing all current backendNodeIds and their associated metadata:

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

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →