Gateway
Persistent local service. Runs alongside the browser as a separate Rust process. Hosts the agent harness, polls feeds, watches files, runs scheduled monitors, and serves the local HTTP API.
Why a separate process
If the agent only ran in the browser process, closing the window would stop feed polling, halt monitors, and silence the agent. The gateway exists so a closed browser doesn’t mean a stopped agent.
- The browser auto-launches the gateway on startup. On Windows it lives in the system tray and registers for boot start.
- Both processes connect to the same SQLite file in WAL mode. The gateway writes; the browser reads on demand.
- All chat streaming, tool dispatch, skill loading, and LLM calls happen in the gateway. The browser never runs the agent harness in-process.
- Shared crates (DB, fetcher, settings, skills, LLM, feeds, notifications, models, keychain, text utilities) are reused with the browser.
Gateway vs CDP BackgroundService
The word “background” shows up in two distinct places in Egg, and they don’t overlap. The gateway runs persistent services for tasks Egg itself orchestrates: poll feeds, check monitored pages, watch directories, dispatch notifications, run cron-driven jobs, drive multi-device sync. These are processes Egg composes to do work on the user’s behalf, regardless of whether the browser is open.
The Chrome DevTools Protocol exposes a separate BackgroundService domain that surfaces what web pages themselves register and run inside the engine: service workers, Background Fetch, Background Sync, Push Messaging, Notifications. This is an observability and debugging surface for in-page activity, not a place to schedule work. A site declares a service worker; the engine runs it; the BackgroundService domain lets a developer see when and what.
The line is: gateway services do work for you; BackgroundService tells you what work a page is doing inside the engine. A page monitor that polls a URL on a 30-minute cadence is a gateway service, with Egg owning the schedule, the fetch, the comparison, and the alert. A push subscription registered by a web page lives entirely inside the engine, with Egg able to observe it through CDP if you ask, but not own its lifecycle.
When to reach for which:
| You want to… | Use |
|---|---|
| Schedule recurring work that survives browser closure | Gateway scheduler |
| Poll a URL, diff results, fire an alert | Gateway monitor_checker |
| Pull from RSS, HN, GitHub feeds and build a digest | Gateway poller + digest |
| Watch a directory for filesystem events | Gateway watcher |
| Sync data across paired devices | Gateway sync engine |
| See what service workers a site has registered | CDP BackgroundService via the browser API’s CDP passthrough |
| Debug Background Fetch downloads or push subscriptions on a tab | CDP BackgroundService |
| Stream service-worker lifecycle events as they fire on a page | CDP BackgroundService |
The two surfaces also differ in lifetime. Gateway services run continuously inside egg-daemon, so they survive browser exit, system suspend (within reason), and tab churn. BackgroundService observations are scoped to a tab’s engine context: the moment that tab is closed, the observer goes with it. Nothing the gateway does relies on a tab being open; nothing the BackgroundService domain shows you persists past a tab.
Architecture
Browser and gateway are siblings, not parent-and-child. They communicate over a local HTTP/WebSocket bridge and coordinate state through SQLite WAL.
Two responsibilities sit at the architectural center: hosting the agent loop with full LLM provider access, and running the hidden engine fleet that scrapes pages too dynamic for an HTTP fetcher. The remaining modules (feed polling, scheduling, sync, notifications, the tray, the API server) are scaffolding around those two.
Services
Each module owns one job. They share the database and the HTTP server, and run on a single tokio runtime.
Agent & LLM management
This is the gateway’s primary purpose. Every chat turn, every tool call, every plan approval, every model invocation runs here. The browser never makes an LLM request directly; it streams from the gateway. Provider routing, API-key custody, retry and rate-limit handling, audit logging, and background thinking all live in this layer so the agent can keep working when no window is open.
The conceptual agent model (turn loop, autonomy, skills, tools, firewalls, memory, free agents, LLM routing) is covered on the Agents page. The table below lists the modules that host that work inside the gateway.
| Module | Job |
|---|---|
| gateway_agent | The agent harness. Owns the conversation loop, LLM dispatch, tool catalog, plan-approval gating, audit log. |
| agent_api | HTTP handlers for chat streaming, AG-UI, plan approval. |
| background_thinking | Long-running self-directed sessions. Records reflections and feeds the curiosity queue. |
| reflection | Identity, personality, and reflection state. |
| reservations | Private time, time-blocks, soft-plan, materialize-now, plan-now. |
| ai_api | The /api/ai/* family. One endpoint per modality (complete, stream, embed, transcribe, speech-recognize, tts, vision, generate-image, video-generate). Routes through capability resolver and tier gate. |
| media_api | The /api/media/* family. ffmpeg, ffprobe, yt-dlp, whisper.cpp model management. Path-validated to app data / tmp / downloads roots. |
| registry | Capability and asset registry. Local assets (Ollama models, whisper installs), cloud capability seeds, model-capability mappings. Single writer. |
| tier_gate | Daily-cap enforcement per capability + tier. Canonical writer of capability_usage. |
| llm_api | Internal /x/llm/* endpoints used by the in-tree egg-llm proxy. Skip the tier gate; not part of the public contract. |
| provider_credentials | Provider credential vault. CRUD over llm_providers. Read responses redact secret material. |
| credentials | Generic 3rd-party API credentials (non-LLM). |
Browser bridge
| Module | Job |
|---|---|
| browser_rpc | WebSocket calls to the running browser for tool dispatch (clicks, navigation, eval). |
Headless WebView2 fleet
Some target pages can’t be handled by an HTTP fetcher. They require a real engine to execute JavaScript, hold cookies and session state, render dynamic content, or pass anti-bot heuristics. The gateway owns a fleet of hidden engine instances for that work.
Each instance is a headless WebView2 process spawned by the gateway, driven over CDP, and torn down when the job completes. The windows never reach the screen. The fleet is what backs page monitors that target script-heavy URLs, social-feed collection (Twitter/X timelines, Reddit threads behind a wall, LinkedIn posts), and any agent tool call that needs to interact with a page rather than just fetch it.
Lifecycle is the gateway’s responsibility, not the browser’s. Tabs open in the user-facing browser are unaffected by what the fleet is doing; the fleet’s engine instances are isolated processes with their own data stores. A monitor checking ten sites every fifteen minutes might cycle ten short-lived engine instances per round and leave nothing behind.
Data collection
| Module | Job |
|---|---|
| poller | Feed-polling loop. RSS, Atom, HN, Reddit, GitHub, custom sources. ~15-minute default cadence. |
| scoring | Rule-based item relevance (0–100). Keywords, authors, domains. |
| digest | Rolling digest builder. |
| correlation | Cross-feed pattern detection: the same story breaking on multiple sources. |
| monitor_checker | Page monitors. Schedules URL checks, diffs results, writes Markdown reports. |
| watcher | Filesystem watcher for workspaces and the agent’s working folder. |
Notification & coordination
| Module | Job |
|---|---|
| scheduler | Cron-based task scheduling for monitors, polls, digest builds. |
| alerts | Desktop notifications and external channels (email, Telegram, push). |
| tray | Windows system-tray icon and menu. |
| api | The Axum HTTP server. ~170 routes across AI, media, fetch, privacy, agent, feeds, monitors, credentials, sync, lifecycle, plus internal /x/* surfaces. |
| public_api | The versioned /api/v1/* surface (fetch/page, fetch/image, privacy/hibp) plus /api/v1/openapi.json. The public-contract entry point. |
| private_api | Internal /x/* endpoints: skills, extensions, search, images, GCS storage, Egglet runtime hooks. |
| cloud_relay | Egg Cloud egress. Typed families (agent-calls, pairing, group-chat, dm, notifications, etc.) plus a generic passthrough for one-off paths. |
| cloud_commands | Inbound command queue from Egg Cloud. Polls upstream, queues locally, lets the browser drain via /x/cloud-commands/pending, ships results back. |
| filter_lists | EasyList / EasyPrivacy 24-hour refresh task. Runs only when aggressive ad-blocking is enabled. |
| net_safety | SSRF guard. Every caller-supplied URL passes through here: rejects loopback, RFC1918, link-local, CGNAT, ULA, multicast, mDNS; resolves DNS to defeat rebinding. |
Lifecycle
Single-instance. The browser auto-launches it, or run it yourself.
# Boot in foreground (logs to stdout) egg-daemon # Inspect the running instance egg-daemon --status # Terminate it egg-daemon --kill # Pin port + token for development egg-daemon --port=19999 --token=devtoken
- On startup, the daemon checks the discovery file. If the PID is alive, it exits. If the PID is dead but the file is present, the file is replaced.
- SQLite WAL allows the browser to read while the gateway writes.
SQLITE_BUSYretries with short backoff. - No telemetry. All data stays on disk. Crash logs are local until shared.
Calling it
Bearer token from daemon_info.json. JSON over HTTP. Streaming endpoints use Server-Sent Events. Full route list on the HTTP API page.
# Read the digest as JSON curl -s "http://127.0.0.1:$PORT/digest" \ -H "Authorization: Bearer $TOKEN" # Force-poll every feed curl -s -X POST "http://127.0.0.1:$PORT/feeds/poll-all" \ -H "Authorization: Bearer $TOKEN" # Stream sync events curl -N "http://127.0.0.1:$PORT/sync/events" \ -H "Authorization: Bearer $TOKEN"
Cross-device sync
Two devices running Egg can keep the user’s bookmarks, passwords, settings, and a small set of related categories in step without the server ever seeing the plaintext. The cloud relay’s only job is to forward opaque encrypted bytes between paired daemons. It can read addressing metadata (which pseudonym is talking to which), but never bookmark URLs, password vaults, settings values, or anything else inside the envelope.
The whole pipeline is event-driven: a local write triggers the push, not a clock. There is no polling loop deciding when to sync.
What gets synced
Two channels run in parallel.
Continuous. Every local write to bookmarks, passwords, passkeys, settings, or firewall rules is mirrored to every paired peer. Deletes propagate too: each of those categories has a database trigger that records a tombstone the moment a row is removed, so the next push round ships the deletion alongside any inserts and updates.
One-time bulk transfer. Browse history, open tabs, and cookies/sessions can ship once on first pairing if the user opts in. They’re useful for setting up a second machine without re-logging into everything; they aren’t kept in continuous sync after the initial transfer (that would be both noisy and not what users want for tabs).
Pairing
One device generates a 6-character invite. The other types it in. Behind the scenes, both layers of pairing happen against the same code in one flow: a cloud-side authorization so the relay knows these two pseudonyms are allowed to talk, and an end-to-end key agreement.
Device A Cloud relay Device B
| | |
| POST /sync/invite/create | |
| (registers code locally | |
| and in cloud’s pairing | |
| index) | |
|--------------------------->| |
| | user pastes 6-char code |
| |<----- POST /pair/accept ----|
| | |
| | ----- partner_id ------> |
| | |
| | KEX 1: A’s X25519 pubkey |
|<------ relay forwards -----| |
| | |
| KEX 2: B’s X25519 pubkey | |
|--------------------------->|------- relay forwards ---->|
| | |
[both sides derive shared AES-256-GCM key via
HKDF(ECDH-shared-secret, info = invite_code)]
| | |
[pairings flip to active on both ends]
The invite code is mixed into the HKDF info parameter on both sides. Without it, an attacker who could substitute their own X25519 keys at the relay would land in a working ECDH but with a key only they share with one device. Mixing in the code (which never crosses the relay in cleartext beyond the initial accept round-trip) binds the derived key to the human-typed secret. Both private keys stay in their device’s keystore the whole time, never crossing the relay.
Push and pull
A SQLite update_hook is installed at boot on every synced table. When a row is inserted, updated, or deleted, the hook fires synchronously on the writing thread, signals an in-process tokio::Notify, and returns. No I/O, no locking. A separate task waits on the Notify, then sleeps ~750 ms to coalesce bursty writes (typing into a settings form, importing bookmarks) into one push round per active pairing.
A push round, per category, walks rows newer than the device’s per-partner watermark for that category, capped at a per-envelope size limit. Rows are encoded, encrypted with the per-pair AES-GCM key, and POSTed as opaque ciphertext to the relay. The relay routes by recipient pseudonym and holds the envelope until the partner’s daemon picks it up over its long-poll connection.
The receiver decrypts, validates the replay counter, and applies rows where the inbound timestamp exceeds the local one (last-writer-wins on a per-row basis). It then advances its pull watermark for that category, increments the receive-side replay counter, and emits a per-category event so the renderer refreshes that surface without a reload. Watch the bookmark bar during a sync and new entries appear with no user action.
Watermarks & recovery
Watermarks are tracked from each device’s own perspective: per partner, per category, the high-water mark of locally-modified rows already shipped, alongside the last push and last pull timestamps. The watermark is what the device has confirmed-sent, not the last thing seen from the partner.
Replay counters are tracked per partner and per direction. The receiver rejects any envelope whose counter is ≤ the previous one for that direction. This catches reorder, duplication, and any attempt by a compromised relay to replay an old ciphertext.
A successful re-pair (KEX completing again, generally after a revoke) wipes both watermarks and replay counters for that partner, so the first push after re-pair sends every row from scratch and the new counters start at zero. Without this, leftover state from a prior pairing would silently truncate the first sync; the progress dialog would say “synced” while no data went out.
Local storage
Everything sync touches is in the same local database the rest of Egg uses. There is no separate sync store and no daemon-private database. The two processes coordinate through reads and writes against this shared file. Sync state (pairings, watermarks, replay counters, pending tombstones, outstanding invites) lives there alongside the synced categories themselves. The derived per-pair AES-GCM key never sits in the database; it is held by the OS keychain and only loaded into the daemon’s memory when a sync round runs.
Threat model
The cloud relay is treated as semi-trusted infrastructure. It sees device pseudonyms, ciphertext sizes, and timing, enough to know that two devices talk and roughly how often, never what they say. AEAD (AES-256-GCM) means a compromised relay cannot forge envelopes; the worst it can do is drop, delay, or reorder, and the replay counter catches reorder and duplication.
A device that has paired is fully trusted within whatever categories the user enabled; that’s the point. If you’re worried about a peer being compromised, revoke the pairing on this device; the next push attempt from that peer will fail at the AEAD check on its way back through the relay.
There is no cloud-held key escrow. If you lose every paired device, the data on the cloud is unrecoverable ciphertext. If you lose one device’s keystore, re-pair from a surviving device. The surviving copy ships the full state to the new pairing on first push.