App shapes
Publish a Playwright agent
Write a Playwright script the way you always would — then `ellivate publish`. Ellivate turns it into an always-on worker, hooks the script up to a stored login (so it doesn't need to sign in itself), and gives you KV to share state across runs.
What "Playwright agent" means here
Most apps Ellivate publishes are user-facing — someone opens a URL and the app responds to a request. Playwright agents are different: they're long-running workers that loop on Ellivate's clock, doing work nobody's watching.
Two canonical shapes:
- Background watchers. Poll a website every few minutes; push the owner when something changes. (Reservation watcher, deal alert, content monitor.)
- Scheduled fetchers. Once a day, log in somewhere, scrape a report, save it to KV. (Bank balances, club rosters, anything that lives behind a login but doesn't have an API.)
Both work because Ellivate handles the two things Playwright scripts usually break on: a Chromium binary preinstalled in the container, and a way to give the script a logged-in browser without baking credentials into source.
A minimal worker
# worker.py
import time
import traceback
from playwright.sync_api import sync_playwright
from ellivate_client import ellivate, connection_state_required
TITLE_KEY = "last_title"
ERROR_KEY = "last_error"
POLL_SECONDS = 600 # ten minutes
def capture_title() -> str:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
context = browser.new_context(
storage_state=connection_state_required("example"),
)
page = context.new_page()
page.goto("https://example.com", wait_until="domcontentloaded")
title = page.title()
context.close()
browser.close()
return title
def main() -> None:
while True:
try:
title = capture_title()
ellivate.set(TITLE_KEY, title)
ellivate.set(ERROR_KEY, None)
except Exception as err:
ellivate.set(ERROR_KEY, f"{type(err).__name__}: {err}\n{traceback.format_exc()}")
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
main()That's the whole shape. The classifier picks up the loop + Playwright import + alwaysOn signature, the publish gate makes sure your “example” connection is attached, and the worker runs continuously.
Stored auth via connections
The interesting line is connection_state_required("example"). That's how the worker gets a logged-in browser without you committing a password.
When you publish a script that calls connection_state_required(...), the gate pauses and asks you to attach a connection for that name. You do it once, in the dashboard:
- Click Add connection on the gate form.
- Ellivate opens a real browser session, you log in to the site yourself.
- The session cookies are stored encrypted, scoped to the connection name.
From then on, every run of your worker calls connection_state_required("example") and gets a fresh storage-state JSON it can hand to browser.new_context(storage_state=...). No login flow in your code; no password in your repo.
When the session expires (most sites: weeks, sometimes months), the worker fails the next run with a clear “connection invalid” error — last_error gets the message, the dashboard shows it, you re-attach the connection from the same flow.
Persistent state via KV
A long-running worker needs somewhere to remember things — what URL it last polled, what items it's already notified you about. Use ellivate.set/ellivate.get the same way you would in any other server-lane Ellivate app:
seen = ellivate.get("seen_ids") or []
for item in fetch_new_listings():
if item["id"] not in seen:
ellivate.notify(
title="New listing",
body=item["title"],
to="owner",
)
seen.append(item["id"])
ellivate.set("seen_ids", seen[-500:]) # bounded listWorkers run in the server lane (no viewer in front of them), so reads and writes go to app:<appId>:app: prefixed keys — shared across runs of the same worker and across any reader pages you might add.
Pairing with a reader page
A worker that just writes to KV is useful, but most builders want a UI on top. Add a Flask or FastAPI app in the same project; Ellivate detects the pair and ships them as two services on the same Railway project, sharing KV:
# app.py — Flask reader, runs alongside worker.py
from flask import Flask, jsonify
from ellivate_client import ellivate
app = Flask(__name__)
@app.get("/")
def index():
return jsonify({
"title": ellivate.get("last_title"),
"error": ellivate.get("last_error"),
"booted_at": ellivate.get("worker_booted_at"),
})The classifier sees both files, emits a multi-service deploy, and you get a UI URL plus a worker that keeps running in the background.
What fails loudly
Ellivate's Playwright translator catches the common patterns that work locally but break in a container. You either get a class-level rewrite or a clear, specific error at publish time:
- Hardcoded Chrome channel. Code like
launch(channel="chrome")gets stripped. The container has Chromium preinstalled; named channels (chrome,msedge) point at binaries that don't exist. - Persistent profile directories.
launch_persistent_context("./profile")is a hard error — local filesystem state doesn't survive container restarts. Useconnection_state_requiredinstead. headless=Falsein production. The translator forces headless. There's no display.- Hardcoded paths to
chromium-browseror/usr/bin/chrome. Either get rewritten or fail loudly.
See the Ellivate contract for the full list of what the publish pipeline enforces.
What's next
- Sharing & invites — workers + private-app sharing for household watchers.
- Notifications — pairing
ellivate.schedule/ always-on workers with push. - Python SDK reference — full API surface.