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:

  1. Click Add connection on the gate form.
  2. Ellivate opens a real browser session, you log in to the site yourself.
  3. 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 list

Workers 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. Use connection_state_required instead.
  • headless=False in production. The translator forces headless. There's no display.
  • Hardcoded paths to chromium-browser or /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