Skip to main content

Python-native browser automation with cached LLM-resolved selectors.

Project description

Engram

CI License: MIT Python: 3.10+

Python-native browser automation with cached LLM-resolved selectors.

Engram lets you write deterministic Playwright flows in Python and delegate element resolution to an LLM — and crucially, caches those resolutions so the LLM is consulted once per UI version, not once per run.

import asyncio
from engram import EngramBrowser, EngramConfig

async def main() -> None:
    async with EngramBrowser(EngramConfig.from_env()) as browser:
        page = await browser.new_page()
        await page.goto("https://example.com/sessions")
        await page.act("click the join session button")

asyncio.run(main())

The first run pays one LLM call to find the button. Every subsequent run on the same UI hits the local cache for free.


Why?

Existing options force you to choose:

Tool Language Caching Control
Stagehand TypeScript ❌ Re-queries LLM every run You own the flow
Browser Use Python ❌ LLM picks every action LLM owns the flow
Engram Python ✅ One LLM call per UI version You own the flow

The conceptual insight from Stagehand — you control the flow, the LLM resolves elements — implemented as it should be in Python, with caching as a first-class architectural primitive.

The name is a neuroscience term for the physical trace a memory leaves in the brain — the pattern that encodes a learned experience so it can be recalled later without re-learning it. The first time Engram sees a UI, it "learns" how to interact with it via LLM. That learning is encoded as an engram — a cached selector resolution keyed to a hash of the relevant DOM subtree. Every subsequent run retrieves the engram instead of re-querying. When the UI changes, the hash changes, the old engram is invalid, and a new one is formed.


Install

pip install engram-browser   # import name is unchanged: `import engram`
playwright install chromium

For the Redis-backed shared cache (recommended for teams):

pip install 'engram-browser[redis]'

Note on the distribution name. The engram name on PyPI is held by an unrelated placeholder package — see the PEP 541 reclaim request we've filed. Until that resolves, install via engram-browser. The Python import name is always engram regardless of the distribution name.


The three primitives

act(intent) — execute a natural-language action

async with EngramBrowser() as browser:
    page = await browser.new_page()
    await page.goto("https://example.com/login")

    # First call: LLM resolves "the username field" → '#username' (~1s, ~$0.001).
    # Cached.
    await page.act("type 'alice' in the username field")

    # First call: LLM resolves "the password field". Cached.
    await page.act("type 'hunter2' in the password field")

    # First call: LLM resolves "the sign in button". Cached.
    await page.act("click the sign in button")

    # Second run of this script: 0 LLM calls. The cache returns the
    # selectors directly and Playwright executes them.

act raises EngramResolutionError if no candidate selector validates against the page after the configured retry budget. It raises EngramBrowserError if a validated selector then fails at the click/fill layer (the cache is not poisoned in this case — the entry is only written after a successful execute).

extract(intent, schema) — extract structured data with a Pydantic schema

from pydantic import BaseModel

class Participant(BaseModel):
    name: str
    role: str

class ParticipantList(BaseModel):
    participants: list[Participant]

async with EngramBrowser() as browser:
    page = await browser.new_page()
    await page.goto("https://example.com/meeting/123/participants")

    # The schema fingerprint is part of the cache key, so changing the
    # Pydantic model produces a fresh extraction (no stale type-mismatched
    # results from a previous schema version).
    data = await page.extract(
        "everyone in the participants panel",
        schema=ParticipantList,
    )

    for p in data.participants:
        print(p.name, p.role)

extract returns a validated instance of your model. If the LLM's output fails Pydantic validation, the call retries with the validation errors fed back into the prompt; if it still fails after the retry budget, EngramExtractionError is raised.

observe() — list the meaningful actions on the current page

async with EngramBrowser() as browser:
    page = await browser.new_page()
    await page.goto("https://example.com/dashboard")

    actions = await page.observe()
    for a in actions:
        print(f"[{a.confidence:.2f}] {a.description}{a.selector}")
    # Example output:
    # [0.95] Click the New Project button → button[data-testid="new-project"]
    # [0.92] Type a query into the search box → #search
    # [0.83] Click the Settings link → a[href="/settings"]

observe returns a confidence-sorted list of ObservedAction. Hallucinated selectors (those the LLM proposes but which match zero elements on the live page) are filtered out before the result is cached, so subsequent observe() calls on the same DOM hash don't re-pay validation cost.


How the cache works

act("click join")
  ├── preprocess DOM (strip scripts, styles, SVG, comments, hidden elements)
  ├── hash sha256(intent + DOM)
  ├── cache lookup
  │   ├── HIT → re-validate selector against current DOM → execute
  │   └── MISS → LLM call → validate candidates → execute → cache
  └── done
  • Cache key: sha256(primitive_namespace + normalized_intent + preprocessed_dom). For extract, the schema fingerprint is also folded in.
  • Cache value: the chosen selector + ranked candidate list + metadata.
  • Invalidation: automatic when the DOM hash changes (the UI was updated, so a new engram is formed) or when a cached selector no longer matches the live page (re-resolved on the spot).
  • Backends: local SQLite (default, zero-config) or shared Redis (recommended for teams — community-resolved selectors benefit everyone).

Configuration

Configure via EngramConfig(...) directly, or via environment variables read by EngramConfig.from_env():

Field Env var Default Notes
llm_model ENGRAM_LLM_MODEL anthropic/claude-sonnet-4-5 Any LiteLLM identifier
llm_api_key ENGRAM_LLM_API_KEY (provider env vars) SecretStr
llm_temperature ENGRAM_LLM_TEMPERATURE 0.0
cache_backend ENGRAM_CACHE_BACKEND sqlite sqlite or redis
cache_path ENGRAM_CACHE_PATH .engram/cache.db SQLite file path
redis_url ENGRAM_REDIS_URL None Required when backend is redis
redis_key_prefix ENGRAM_REDIS_KEY_PREFIX engram: Multi-tenant namespace
redis_ttl_seconds ENGRAM_REDIS_TTL_SECONDS None Optional hard staleness cap
cache_signing_key ENGRAM_CACHE_SIGNING_KEY None HMAC-signs Redis entries (see SECURITY.md)
max_resolution_retries ENGRAM_MAX_RESOLUTION_RETRIES 2 Re-prompt with feedback when output is unusable
max_llm_calls_per_session (manual) None Hard session-level call cap
min_candidate_confidence ENGRAM_MIN_CANDIDATE_CONFIDENCE 0.5 Reject candidates below this floor
dom_max_chars ENGRAM_DOM_MAX_CHARS 50_000 Preprocessed DOM size cap
log_intents_verbatim ENGRAM_LOG_INTENTS_VERBATIM False When False, log a hash instead of the raw intent
headless ENGRAM_HEADLESS True Browser visibility

For Redis on a multi-machine setup:

from pydantic import SecretStr

config = EngramConfig(
    cache_backend="redis",
    redis_url="rediss://cache.internal:6380/0",
    cache_signing_key=SecretStr("a-shared-secret"),  # see SECURITY.md
    max_llm_calls_per_session=200,                   # session budget cap
)

Remote browsers (Browserbase, BrowserCat, self-hosted CDP)

Engram doesn't ship vendor-specific browser backends — it doesn't need to. EngramPage accepts any Playwright Page, so any remote Chromium that speaks CDP works out of the box via connect_over_cdp:

import asyncio
from playwright.async_api import async_playwright
from engram import EngramConfig, EngramPage
from engram.cache import build_cache
from engram.llm.litellm_client import LiteLLMClient

async def main() -> None:
    config = EngramConfig.from_env()
    cache = build_cache(config)
    llm = LiteLLMClient(config)

    async with async_playwright() as pw:
        # Browserbase, BrowserCat, Steel.dev, self-hosted — any CDP URL.
        remote = await pw.chromium.connect_over_cdp(
            "wss://connect.browserbase.com?apiKey=YOUR_KEY"
        )
        try:
            context = remote.contexts[0] if remote.contexts else await remote.new_context()
            page = context.pages[0] if context.pages else await context.new_page()

            engram = EngramPage(page=page, config=config, cache=cache, llm=llm)
            await engram.goto("https://example.com")
            await engram.act("click the join button")
        finally:
            await remote.close()
            await cache.close()

asyncio.run(main())

The same code works for Browserbase, BrowserCat, Steel.dev, a self-hosted Selenium grid that exposes CDP, or any future CDP-speaking provider — Engram doesn't care where the browser lives, as long as it produces a Playwright Page.

Tradeoff vs. EngramBrowser: you own the browser/cache lifecycle (close the connection, the context, the cache) yourself. EngramBrowser is the convenience wrapper for the local-Chromium case; the remote case is one level lower-level so you can plug in whichever CDP provider makes sense for your deployment.


Architecture

EngramBrowser (Playwright + cache + LLM)
  └── EngramPage
       ├── act(intent)
       ├── extract(intent, schema)
       └── observe()
             │
             ├─→ DOM preprocessing  (engram.dom)
             ├─→ AbstractCache      (engram.cache: SQLite / Redis)
             └─→ AbstractLLMClient  (engram.llm: LiteLLM-backed by default,
                                     wrapped in BudgetedLLMClient when a
                                     session budget is configured)

Every component is behind an interface. Tests mock the interfaces; real deployments wire real implementations. mypy --strict runs clean across the codebase.


Logging

Engram emits structured logs (via structlog) for every cache hit/miss, LLM call, selector validation, action execution, and resolution failure. By default, structlog renders human-readable key-value pairs to stderr; for JSON output (production), call:

from engram import configure_logging
configure_logging(level="INFO", json_output=True)

Each act / extract / observe call binds a unique request_id to structlog.contextvars, so all downstream events from one user-facing call share a correlation ID.


Security

See SECURITY.md for the threat model and the residual risks to be aware of:

  • Page content is untrusted input to the LLM (prompt-injection risk).
  • Pre-filled sensitive form values (passwords, OTPs, credit cards) are scrubbed from the preprocessed DOM before it reaches the LLM.
  • A shared Redis cache must be on a trusted network or signed with cache_signing_key.
  • Intents are logged as a hash by default — flip log_intents_verbatim only in trusted environments.

Status

v0.2.0 — alpha. Public API surface is the three primitives + EngramConfig. Anything in engram._* (private) may change without notice. mypy --strict

  • ruff + the full pytest suite pass on every commit.

For what we're considering and what we've explicitly decided not to build, see ROADMAP.md.

License: MIT.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

engram_browser-0.2.0.tar.gz (78.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

engram_browser-0.2.0-py3-none-any.whl (47.0 kB view details)

Uploaded Python 3

File details

Details for the file engram_browser-0.2.0.tar.gz.

File metadata

  • Download URL: engram_browser-0.2.0.tar.gz
  • Upload date:
  • Size: 78.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for engram_browser-0.2.0.tar.gz
Algorithm Hash digest
SHA256 05671ed7a640656013db1036d9bb0b22d45491e98fa7c089b478eacead54f840
MD5 bb762f0aa226b91b63058e9a4d7fd621
BLAKE2b-256 a45f21814f72a83b5a3866d8f61990ed2b1b677997b734c5aafff89a21fbc271

See more details on using hashes here.

Provenance

The following attestation bundles were made for engram_browser-0.2.0.tar.gz:

Publisher: release.yml on juvantlabs/engram

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file engram_browser-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: engram_browser-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 47.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for engram_browser-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a60cdbaac22212b5f147f908171052dcee46950106179998ee0650fac69219e2
MD5 d23cdc284079d5f6522e8dddc2678d65
BLAKE2b-256 a4d025aa7e1510433471f0cc654a465a22a22eeb142a1b677ff54338b8d627aa

See more details on using hashes here.

Provenance

The following attestation bundles were made for engram_browser-0.2.0-py3-none-any.whl:

Publisher: release.yml on juvantlabs/engram

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page