# Penelope REST API Penelope exposes a persistent, non-headless Firefox (Playwright/Patchright) through a REST API. The server keeps a pool of browser pages ("tabs"); every operation targets one page, identified by a `page_id`. - Base URL: `http://:5000`, or the port of the persona's worker (`PORT` in its `persona.env`). The waker, which starts and stops the workers, has its own API: [WAKER_API.md](WAKER_API.md). - All API routes are prefixed with `/api/v1` - Interactive OpenAPI docs: `/docs` (Swagger) and `/redoc` - Control panel UI: `/` ## Layers | Layer | Prefix | Purpose | |---|---|---| | System | `/api/v1/system` | Browser/server lifecycle, health, pool status | | Ops | `/api/v1/ops` | Page management: create, destroy, navigate, extract, screenshot, network | | Primitive | `/api/v1/primitive` | Atomic actions: click, scroll, type, paste, keys, zoom | | Media | `/api/v1/media` | One-shot download links for files a plugin produced | | Plugins | `/api/v1` | High-level workflows built on top of the primitives | --- ## Authentication Every endpoint that touches the browser requires a bearer token matching the server's `PENELOPE_KEY` environment variable: ``` Authorization: Bearer ``` Unauthenticated endpoints (no header needed): - `GET /api/v1/system/ping` - `GET /api/v1/system/status` - `GET /api/v1/system/status/pages` - `GET /api/v1/plugins` Failure modes: - `401` — missing or wrong token (`{"detail": "Invalid API key"}`) - `500` — server started without `PENELOPE_KEY` set (`{"detail": "Server not properly configured"}`) --- ## Conventions ### Page IDs A `page_id` is an arbitrary string naming a browser tab (`main`, `scraper1`, ...). - Pages are **not** created implicitly. Create one with `POST /api/v1/ops/create-page` before using it; operating on an unknown id fails with `Page not found`. - `POST /api/v1/ops/create-page` with no body (or `page_id: null`) auto-generates the id and returns it. - The pool is capped at `MAX_PAGES` (default 5, set in `worker/server.py`). Page states reported by the pool: `idle`, `busy`, `loading`, `error`, `crashed`, `stuck`. ### Leases Each call reserves its page only while it runs. A multi-step flow (e.g. an instagram ring: open-following → get-following) can therefore be interleaved by another client between steps. To prevent that, **optionally** lease the page with `POST /api/v1/ops/{page_id}/lease` and send the returned token on every call: ``` X-Lease: ``` - Unleased pages behave as before: any client can use them, no header needed. - On a leased page, every ops, primitive and plugin call (and `destroy-page`) without the matching token fails immediately with `423`. - The lease expires `ttl` seconds after the last call that used it; each call renews it. - Calls without a `page_id` never pick a page leased by someone else. - A leased page is brought to the foreground when the lease is taken or renewed and on every call that uses it, so it is the tab visible on VNC. Full guide (lifecycle, expiry rules, recovering from `423`, patterns): [docs/howto/USING_LEASES.md](../howto/USING_LEASES.md). ### Requests All request bodies are JSON (`Content-Type: application/json`). Endpoints that take no parameters accept an empty body. ### Responses Successful responses share a base shape and add endpoint-specific fields: ```json { "success": true, "page_id": "scraper", "...": "endpoint-specific fields" } ``` Errors raised by the application layer use the `ErrorResponse` envelope: ```json { "success": false, "error": "Page scraper not found", "page_id": "scraper" } ``` Status codes: `400` bad request / plugin validation failure, `401` unauthorized, `408` navigation timeout, `409` page busy past the wait timeout, `423` page leased by another client, `500` browser navigator or page pool not initialized. Note: some browser-level failures are reported *inside a `200` response* with `"success": false` and an `error` field, rather than as an HTTP error. Always check `success`, not only the status code. ### Request log The worker appends two rows for every request under `/api/v1/` except `/api/v1/system/`: one when it starts and one when it ends, with the same `id`. Rows go to one CSV per UTC day, `requests/YYYY-MM-DD.csv` in the persona's folder (in the repo root without `PERSONA_DIR`). An `end` row goes to its `start`'s file, even past midnight. The first write of a day deletes the files older than 50 days. Columns: | Column | Value | |---|---| | `time` | UTC, ISO 8601 with milliseconds | | `event` | `start` or `end` | | `id` | 8 hex characters, joins the two rows | | `method` | HTTP method | | `kind` | `plugin`, `primitive`, `ops`, else the first path segment (`media`, `plugins`) | | `name` | plugin route (`airbnb/search`) or primitive/ops action (`click-element`); empty otherwise | | `path` | request path, without the query string | | `status` | `end` only: HTTP status sent; empty if the worker stopped mid-request | | `ms` | `end` only: duration in milliseconds | A client that hangs up does not stop the work: its `end` row still has the status sent. A `start` without an `end` means the worker died mid-request. `status` is the HTTP code, so a `200` with `"success": false` counts as `200`. The waker reads these files for its panel's workload ([WAKER_API.md](WAKER_API.md), `GET /api/v1/stats/workload`). --- ## System ### `GET /api/v1/system/ping` Health check. Sleeps ~2s, then returns a timestamp, the git commit the server started from (`commit` is `null` when git is unavailable), the persona it runs (`persona`, the `PERSONA_DIR` folder name, `null` when run without one), its client activity and, behind a proxy, how many browser requests failed at the proxy. No auth. ```json { "pong": 1739381923123, "status": "alive", "commit": { "hash": "7cd30d1", "subject": "feat(instagram): …", "date": "2026-10-09T13:20:00+02:00" }, "persona": "ig-1", "activity": { "in_flight": 0, "idle_seconds": 42 }, "proxy_errors": 0 } ``` `activity` counts client requests outside `/api/v1/system/`: `in_flight` running now, `idle_seconds` since the last one finished (`null` while one is in flight). The waker stops a `sleepy` worker once it has been idle for its timeout. `proxy_errors` counts the browser's requests that failed at the proxy since start (`ERR_TUNNEL_CONNECTION_FAILED`, `ERR_PROXY_CONNECTION_FAILED`, …); `null` without `identity.json`. When it grows, the waker checks the proxy, and on a `410` restarts the worker with a new session ([WAKER_API.md](WAKER_API.md#the-place-check)). ### `GET /api/v1/system/status` Browser navigator status. No auth. ```json { "initialized": true, "browser_context_active": true, "page_pool_active": true, "max_pages": 5, "slowmo": 200, "page_count": 2, "available_pages": 1 } ``` `page_count` and `available_pages` are `null` when the pool is not up. When the navigator is stopped, `initialized` is `false` and the numeric fields are `0`. ### `GET /api/v1/system/status/pages` Detailed pool status. No auth. `500` if the navigator or pool is not initialized. ```json { "total_pages": 2, "max_pages": 5, "status_by_state": { "idle": 1, "busy": 1 }, "pages": [ { "page_id": "scraper", "state": "idle", "current_operation": null, "operation_duration": null, "responsive": true, "loading": false, "stuck": false, "error_count": 0, "leased": false, "lease_expires_in": null, "last_activity": 1739381923.12, "url": "https://example.com", "title": "Example Domain" } ] } ``` ### `GET /api/v1/system/start` Auth required. Starts the browser navigator; if one is already running it is closed first (restart). All existing pages are lost. ```json { "success": true, "page_id": null, "message": "Browser navigator started successfully" } ``` ### `GET /api/v1/system/stop` Auth required. Closes the browser and all pages. The HTTP server keeps running. ### `GET /api/v1/system/kill` Auth required. Triggers graceful shutdown of the whole server process. ### `GET /api/v1/system/update` Auth required. Requests an auto-update by touching an `.update-trigger` flag file in the repo root. Only meaningful when the server is managed by the external supervisor (`uv run supervisor.py`): the supervisor notices the flag within a second and immediately pulls and restarts the waker, which restarts the `always` workers on the new code. When running the server directly, this endpoint has no effect (the flag file is simply created). ### `GET /api/v1/system/metrics` Auth required. Host and per-persona cpu/memory samples collected by the waker (every 15 s, last 10 min — `METRICS_INTERVAL` / `METRICS_WINDOW` in `.env`). Same data from every worker; `instances` is keyed by persona name. 404 when the waker is not running. ```json { "interval_s": 15, "window_s": 600, "samples": [ { "ts": 1758200000.0, "system": {"cpu_pct": 23.5, "mem_total_mb": 3794, "mem_used_mb": 1810, "load1": 0.8, "load5": 0.6, "temp_c": 52.1}, "instances": { "a": {"pid": 1234, "procs": 14, "cpu_pct": 41.2, "rss_mb": 910}, "b": null } } ] } ``` Per-persona numbers cover the whole process tree under that worker (the Chromium it launched). `cpu_pct` is summed over cores like `top`, so it can exceed 100. A persona is `null` while it is down. `temp_c` is `null` where `/sys/class/thermal/thermal_zone0` does not exist. --- ## Ops — page management All Ops endpoints require auth. ### `POST /api/v1/ops/create-page` Body (optional): ```json { "page_id": "scraper" } ``` Response: ```json { "success": true, "page_id": "scraper", "message": "Page 'scraper' created successfully", "url": "about:blank", "state": "idle", "total_pages": 1 } ``` `400` if the page cannot be created (e.g. duplicate id, pool full). ### `POST /api/v1/ops/{page_id}/destroy-page` ### `DELETE /api/v1/ops/{page_id}/destroy-page` Both verbs do the same thing: close and remove the page. ```json { "success": true, "page_id": "scraper", "message": "Page 'scraper' destroyed", "total_pages": 0 } ``` ### `POST /api/v1/ops/{page_id}/lease` Leases the page (see [Leases](#leases)). Body (optional), `ttl` in seconds, `0 < ttl ≤ 3600`, default `300`: ```json { "ttl": 300 } ``` ```json { "success": true, "page_id": "scraper", "lease_token": "J152Qv0L88Ropw1ZAAC-cQ", "ttl": 300 } ``` Called again with the current `X-Lease`, it renews the lease (same token, new `ttl`). `423` if another client holds it, `404` if the page does not exist. ### `DELETE /api/v1/ops/{page_id}/lease` Releases the lease held via `X-Lease`. `423` if the token does not hold it. ```json { "success": true, "page_id": "scraper", "message": "Lease on 'scraper' released" } ``` ### `POST /api/v1/ops/{page_id}/navigate` Navigates and waits for `domcontentloaded`. The handler sleeps ~2s before starting and aborts with `408` after 60s. ```json { "url": "https://example.com" } ``` ```json { "success": true, "page_id": "scraper", "url": "https://example.com", "status_code": 200, "final_url": "https://example.com/" } ``` ### `POST /api/v1/ops/{page_id}/extract-content` No body. Returns the full HTML of the current document. ```json { "success": true, "page_id": "scraper", "url": "https://example.com/", "title": "Example Domain", "content": "…", "content_length": 1256 } ``` ### `GET /api/v1/ops/{page_id}/screenshot` Returns the viewport as a base64-encoded PNG. `?full=true` adds the full page in `full_screenshot` (otherwise `null`). ```json { "success": true, "page_id": "scraper", "screenshot": "iVBORw0KG…", "full_screenshot": null, "url": "https://example.com/" } ``` The full-page shot is not a pure probe: it resizes the viewport for the capture, which blurs the focused element and closes popups on some sites (airbnb's search dropdown closes). Leave it off while an input or dropdown is open. ### `GET /api/v1/ops/{page_id}/network` Reads the traffic the page captured passively: document/xhr/fetch responses plus main-frame URL changes (`http`), and WebSocket lines (`websocket`). Does not reserve the page, so it works while a plugin runs on it; a lease held by another client still refuses it (423). A full load (`goto`, reload) empties both lists, SPA URL changes only add a `navigation` line. Bodies are kept zlib-compressed; both lists share a 30 MB budget per page (compressed size), oldest lines dropped first. URLs a plugin family blacklists for the page's current site are never stored; `blacklist` lists them. `seq` is shared by both lists and never reset: pass the returned `mark` as `since` next time to get only the new lines. | Query | Default | Meaning | |---|---|---| | `since` | `0` | Only lines with `seq` greater than this | | `url_contains` | — | Substring filter on the URL | | `resource_type` | — | `document`, `xhr` or `fetch` (HTTP lines only) | | `include_body` | `false` | Include response bodies and WebSocket payloads | ```json { "success": true, "page_id": "scraper", "url": "https://example.com/spa", "mark": 42, "blacklist": [], "http": [ {"seq": 40, "ts": 1790000000.1, "kind": "navigation", "url": "https://example.com/spa"}, {"seq": 41, "ts": 1790000000.3, "kind": "response", "method": "GET", "url": "https://example.com/data.json", "resource_type": "fetch", "status": 200, "content_type": "application/json", "headers": {"content-type": "application/json"}, "body": "{\"a\":1}", "body_length": 7, "body_truncated": false, "error": null} ], "websocket": [ {"seq": 42, "ts": 1790000001.0, "kind": "received", "url": "wss://example.com/ws", "payload": "hello", "binary": false, "payload_length": 5} ] } ``` Non-text bodies are skipped (`body: null`), bodies over 2M chars are cut (`body_truncated`), binary WebSocket frames are base64 (`binary: true`). A body that could not be read (redirects, a navigation raced it) leaves `error` set. ### `POST /api/v1/ops/{page_id}/search-object` Vision-based object lookup on the current page (screenshot → Gemini). Same engine as the `detect-bounding-box` plugin. ```json { "description": "the blue submit button" } ``` ```json { "success": true, "page_id": "scraper", "description": "the blue submit button", "bounding_box": { "…": "model-dependent" }, "center_x": 412.0, "center_y": 233.0, "tokens_used": 1834, "costs_usd": 0.0004, "duration_seconds": 2.71 } ``` Requires a Gemini API key in the server environment (`GEMINI_API_KEY_PAGA` / `GEMINI_API_KEY_GRATIS`). Pair it with `click-position` to click what was found. --- ## Primitive — atomic actions All Primitive endpoints require auth and return at least `success`, `page_id` and `url` (the page URL after the action). > Keyboard primitives (`type-text`, `paste-text`, `press-enter`, `press-tab`) act on the > **currently focused element**. Focus something first — usually with `click-element`. ### `POST /api/v1/primitive/{page_id}/click-element` ```json { "selector": "input[name=\"q\"]" } ``` Response adds `selector`. The click point is picked before the mouse travels there. If the element moved during the approach (a late re-layout, an animation), the mouse follows it with up to two short corrective moves before clicking. The call fails if the element keeps moving, disappears or leaves the viewport. ### `POST /api/v1/primitive/{page_id}/click-position` ```json { "x": 100, "y": 200 } ``` Response adds `position`. ### `POST /api/v1/primitive/{page_id}/hover-element` Moves the mouse over the element exactly like `click-element` (same curved path, same randomized point inside the visible part of the element, same corrections if it moves), without clicking. ```json { "selector": "div[role=\"dialog\"] ul" } ``` Response adds `selector` and `position` (where the mouse stopped). ### `POST /api/v1/primitive/{page_id}/hover-position` Moves the mouse to the coordinates like `click-position`, without clicking. ```json { "x": 100, "y": 200 } ``` Response adds `position`. ### `POST /api/v1/primitive/{page_id}/check-visible` Checks whether an element exists, is rendered, and is fully inside the viewport (not clipped by scrolling or container overflow). ```json { "selector": "header nav" } ``` Response adds `selector`, `found`, `visible` (at least partially on screen), `fully_visible`, `bounding_box` (full layout rect), `intersection` (the actually visible portion — element rect clipped to the viewport and any overflow-clipping ancestors), `viewport`. ### `POST /api/v1/primitive/{page_id}/scroll-page` ```json { "distance": 1000 } ``` `distance` is optional (default `1000`), in pixels; **negative scrolls up**. There is no `direction` field — direction is only the sign of `distance` (an unknown field like `"amount"` or `"direction"` is silently ignored and the default 1000 is used). `scrolled_distance` in the response is the **gross** distance actually emitted during the main scroll, not the net viewport shift: - the request is inflated by a random overshoot factor ×1.1–1.2 - the overshoot is then partially recovered with a small back-scroll of ×0.1–0.2 of the inflated distance (net ≈ the requested distance, ±10%) - the scroll is emitted as a burst of wheel notches of ~80–120 px each with human-like pacing (slow at start/end, fast in the middle); the page can keep settling after the call returns Example: `distance: 500` → overshoot ~570, recovery ~−80, `scrolled_distance` reports ~570 while the page actually moved ~500. Because of the ±10% tolerance, never use scroll for precise positioning: scroll, then verify with `check-visible` and correct with a small signed `distance` if the target is not `fully_visible` yet. Response adds `scrolled_distance`. The wheel goes to whatever is under the mouse. The main burst is emitted at the current pointer position; only then does the mouse move to a random point (100–300, 100–300) for the overshoot recovery. To scroll an inner container (a popup list, a sidebar), `hover-element` over it right before every `scroll-page`: the previous scroll left the pointer elsewhere. ### `POST /api/v1/primitive/{page_id}/type-text` Types character by character with human-like timing (and occasional corrected typos). ```json { "text": "hello world" } ``` Response adds `text_length`, `chars_typed`, `typos_made`. ### `POST /api/v1/primitive/{page_id}/paste-text` Clipboard paste — fast, use for long strings. ```json { "text": "a very long string…" } ``` Response adds `text_length`, `fumbled`, `delay_before_ms`. ### `POST /api/v1/primitive/{page_id}/press-enter` No body. Response adds `key`. ### `POST /api/v1/primitive/{page_id}/press-tab` ```json { "with_shift": false } ``` `with_shift: true` sends Shift+Tab (focus backwards). Response adds `key`. ### `POST /api/v1/primitive/{page_id}/reload-page` No body. What F5 does: a full reload of the current page, so the site's in-memory state is dropped and the page's network capture starts over. A synthetic F5 key cannot be used (CDP key events reach the page, not the browser), so this is a browser reload. Response adds `key` (`"F5"`) and `status_code`. ### `POST /api/v1/primitive/{page_id}/zoom-page` ```json { "x": 100, "y": 200, "zoom_level": 1.5 } ``` `zoom_level` is optional (default `1`). Response adds `zoom_level` and `position`. ### `POST /api/v1/primitive/{page_id}/reset-zoom` No body. Restores 100% zoom. --- ## Media — download links A plugin that produces a file (e.g. `instagram/get-post-media`) does not inline it in its JSON: it returns `media_url`, a link to the raw bytes held in the server's memory (never on disk). ### `GET /api/v1/media/{token}` Requires auth. Returns the raw file with its `Content-Type`, `Content-Length` and `Content-Disposition: attachment; filename="…"`. One-shot: the bytes are dropped once served, and dropped anyway 300 s after the plugin call if nobody fetched them. Unknown, expired or already used tokens give `404`: ```json { "success": false, "error": "media link unknown, expired or already downloaded", "page_id": null } ``` Unclaimed files share a 512 MB cap per server; a plugin that would exceed it fails instead. Tokens live in that server process only: a restart (or update) invalidates them. ```bash curl -H "Authorization: Bearer $PENELOPE_KEY" -o post.mp4 "http://:5000$MEDIA_URL" ``` --- ## Plugins Plugins are discovered at startup from `plugins/` and their routes are generated dynamically, so the list below reflects the plugins currently shipped. The request model of each plugin is built from its declared parameters, so the OpenAPI schema at `/docs` is always authoritative. Route shape: ``` POST /api/v1/{page_id}/plugin/{plugin-path} ``` `{plugin-path}` is the plugin's folder path under `plugins/` (underscores become hyphens), e.g. `archive/check` from `plugins/archive/check/`, or the nested `instagram/open-account` from `plugins/instagram/open_account/`. `GET /api/v1/plugins` reports each plugin's `route` field — use that when building URLs. (Note the different shape: no `ops`/`primitive` segment — `page_id` comes right after `/api/v1`.) Plugins declaring `GET` take their parameters from the query string (`?n=29&shortcode=abc`) or from a JSON body; the body wins on collision. Query values of `number` params become floats and `checkbox` params accept `true/false/1/0/yes/no/on/off`, exactly like the JSON body; anything unparseable is passed through and rejected by validation. Parameter validation happens before execution; a violation returns `400` with the plugin's own message, e.g. `"paste_probability must be between 0.0 and 1.0"`. ### `GET /api/v1/plugins` Lists every registered plugin with its parameter schema. No auth. ```json { "success": true, "page_id": null, "count": 5, "plugins": [ { "name": "archive-check", "description": "Archive Check", "methods": ["POST"], "bg_color": ["#c2601f", "#ff9924"], "parameters": [ { "name": "url", "type": "url", "label": "URL to Check", "required": true, "default": null, "placeholder": "https://example.com", "options": [], "help_text": "URL to check for archived copies on archive.is" } ] } ] } ``` Parameter `type` values map to JSON types: `text`, `url`, `textarea`, `select` → string; `number` → float; `checkbox` → boolean. ### `GET /api/v1/plugins/{name}/docs` Raw `README.md` content documenting a plugin — its own doc if it has one, otherwise the nearest one up its folder tree (e.g. a stub like `bank-bper-login` with no README of its own resolves to the shared `plugins/bank/bper/README.md`). No auth, no repo access needed: this is how an external client reaches the same per-plugin docs that live next to the code. `name` is the registry name from `GET /api/v1/plugins`, not the route. Returns `text/markdown`, or `404` for an unknown plugin name or one with no README anywhere between its folder and `plugins/`. ### `archive/*` Check whether a URL is archived on archive.is, submit one for archiving, or open the newest snapshot and read it. ``` POST /api/v1/{page_id}/plugin/archive/check POST /api/v1/{page_id}/plugin/archive/save POST /api/v1/{page_id}/plugin/archive/get ``` Full endpoint reference: [plugins/archive/README.md](../../plugins/archive/README.md). ### `misc/nytimes-search` Search nytimes.com, or go straight to a nytimes.com URL. See [plugins/misc/nytimes_search/README.md](../../plugins/misc/nytimes_search/README.md). ### `misc/detect-bounding-box` Screenshot the page and ask Gemini vision where an object is; feed the result to `click-position`. See [plugins/misc/detect_bounding_box/README.md](../../plugins/misc/detect_bounding_box/README.md). ### `instagram/*` A plugin family driving instagram.com in the persistent browser: search/open a profile or your own, follow it, list grid or feed posts, open a post, step its carousel, download every media file, read structured metadata, close the post, open and close the story viewer, read a profile's full following / followers list, read what a profile says about itself (bio, links, category, counts, highlights — from the profile data Instagram loads, captured by the page), or return to a safe "home" state. It's a chain — bootstrapped by `instagram/state` (states: `feed` | `profile` | `post_open` | `story_open` | `following_open` | `followers_open` | `unknown`, plus `follow_status`, `story`, `own_handle` / `own_profile`, left-nav `badges` and the open list's `{rows_in_view, expected}`), the client picks the next plugin from the reported state. Full endpoint reference, state-machine signals and the media-download mechanics: [plugins/instagram/README.md](../../plugins/instagram/README.md). ### `bank/tinaba/*` A plugin family driving `homebanking.bancaprofilo.it` (Tinaba / Banca Profilo) in the persistent browser. Like Instagram, it is a chain: run `bank/tinaba/read-state` first and pick the next plugin from `state` + `hint`. All the interaction logic lives in `plugins/bank/tinaba/_tinaba_base.py` (state classification, login form locators, datepicker and movimenti-table helpers), so the observer and the action plugins never disagree about the page. States: `login` (empty form), `login_hydrated` (fields filled), `app_confirmation` (2FA pending), `logged_in` (+ `_home` / `_conto` / `_carta` section variants), `unknown`. Credentials come from the server environment: `TINABA_CELL`, `TINABA_CODE`. ``` POST /api/v1/{page_id}/plugin/bank/tinaba/init-login POST /api/v1/{page_id}/plugin/bank/tinaba/hydrate-login GET /api/v1/{page_id}/plugin/bank/tinaba/read-state GET /api/v1/{page_id}/plugin/bank/tinaba/read-qr POST /api/v1/{page_id}/plugin/bank/tinaba/vai-a-home POST /api/v1/{page_id}/plugin/bank/tinaba/vai-a-conto POST /api/v1/{page_id}/plugin/bank/tinaba/vai-a-carta POST /api/v1/{page_id}/plugin/bank/tinaba/seleziona-periodo GET /api/v1/{page_id}/plugin/bank/tinaba/leggi-movimenti ``` (`login`, `logout`, `vai-a-movimenti-*` and `scarica-movimenti-*` exist as placeholders but are not implemented yet.) Full endpoint reference: [plugins/bank/tinaba/README.md](../../plugins/bank/tinaba/README.md). ### `bank/bper/*` A plugin family driving `homebanking.bpergroup.net` (BPER Banca Smart Web) in the persistent browser. Like Tinaba, it is a chain: run `bank/bper/read-state` first and pick the next plugin from `state` + `hint`. All the interaction logic lives in `plugins/bank/bper/_bper_base.py` (state classification, login form locators, sidenav and movimenti-filter helpers), so the observer and the action plugins never disagree about the page. States: `login` (empty Smart Web form), `login_hydrated` (fields filled), `2fa_pending` (Accedi submitted, Smart app authorization running — provisional until re-classified from a real post-2FA overlay), `logged_in` (dashboard home), `logged_in_conti` (Conti section), `unknown`. Credentials come from the server environment: `BPER_ALIAS`, `BPER_PWD`. ``` POST /api/v1/{page_id}/plugin/bank/bper/init-login POST /api/v1/{page_id}/plugin/bank/bper/hydrate-login GET /api/v1/{page_id}/plugin/bank/bper/read-state POST /api/v1/{page_id}/plugin/bank/bper/vai-a-conti POST /api/v1/{page_id}/plugin/bank/bper/seleziona-periodo GET /api/v1/{page_id}/plugin/bank/bper/leggi-movimenti ``` (`login`, `logout`, `vai-a-movimenti`, `imposta-date-movimenti` and `scarica-movimenti` exist as placeholders but are not implemented yet.) Full endpoint reference: [plugins/bank/bper/README.md](../../plugins/bank/bper/README.md). Adding your own plugin: see [docs/howto/ADDING_PLUGINS.md](../howto/ADDING_PLUGINS.md). Building one together with an agent: [docs/howto/BUILDING_PLUGINS_TOGETHER.md](../howto/BUILDING_PLUGINS_TOGETHER.md). --- ## Worked example ```bash BASE=http://localhost:5000/api/v1 AUTH="Authorization: Bearer $PENELOPE_KEY" JSON="Content-Type: application/json" # 1. make sure the browser is up curl -s "$BASE/system/status" # 2. create a page curl -s -X POST "$BASE/ops/create-page" -H "$AUTH" -H "$JSON" \ -d '{"page_id": "scraper"}' # 3. navigate curl -s -X POST "$BASE/ops/scraper/navigate" -H "$AUTH" -H "$JSON" \ -d '{"url": "https://example.com"}' # 4. interact: focus the search box, type, submit curl -s -X POST "$BASE/primitive/scraper/click-element" -H "$AUTH" -H "$JSON" \ -d '{"selector": "input[name=\"q\"]"}' curl -s -X POST "$BASE/primitive/scraper/type-text" -H "$AUTH" -H "$JSON" \ -d '{"text": "penelope"}' curl -s -X POST "$BASE/primitive/scraper/press-enter" -H "$AUTH" # 5. read the result curl -s -X POST "$BASE/ops/scraper/extract-content" -H "$AUTH" # 6. run a plugin curl -s -X POST "$BASE/scraper/plugin/archive/check" -H "$AUTH" -H "$JSON" \ -d '{"url": "https://example.com"}' # 7. clean up curl -s -X DELETE "$BASE/ops/scraper/destroy-page" -H "$AUTH" ``` The same flow in JavaScript: ```js const BASE = "http://localhost:5000/api/v1"; const headers = { "Content-Type": "application/json", Authorization: `Bearer ${PENELOPE_KEY}`, }; const post = (path, body) => fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(body ?? {}), }).then((r) => r.json()); await post("/ops/create-page", { page_id: "scraper" }); await post("/ops/scraper/navigate", { url: "https://example.com" }); const { content } = await post("/ops/scraper/extract-content"); ``` --- ## Configuration Read from the environment (`.env` is loaded for the API key): | Variable | Default | Meaning | |---|---|---| | `PENELOPE_KEY` | — | Bearer token required by the write endpoints. Without it every authenticated call returns `500`. | | `PERSONA_DIR` | — | Persona folder: profile in `profile/`, overrides from `persona.env` on top of `.env` | | `BROWSER_PROFILE` | `./browser-profile` | Profile directory when `PERSONA_DIR` is not set | | `BROWSER_SLOWMO` | `200` | Milliseconds of artificial delay after actions | | `UPDATE_INTERVAL` | `10` | Supervisor only: seconds between git fetch polls | | `HEALTH_TIMEOUT` | `90` | Supervisor and waker: seconds a worker may take to answer its ping after a start; after an update the supervisor waits this long per `always` persona before rolling back | | `WAKER_PORT` | `4900` | Supervisor and waker: port of the waker API | | `IPROYAL_USERNAME`, `IPROYAL_PASSWORD` | — | Waker only: IPRoyal residential account, for `sleepy` personas abroad. Workers drop them after reading `.env` | | `IPINFO_TOKEN` | — | Waker only, optional: higher ipinfo.io limit for the place check | | `GEMINI_API_KEY_PAGA`, `GEMINI_API_KEY_GRATIS` | — | Gemini keys for the vision endpoints | `MAX_PAGES` (pool size, default `5`) and the bind address (`0.0.0.0:5000`) are constants in `worker/server.py`. ## Endpoint index | Method | Path | Auth | |---|---|---| | GET | `/api/v1/system/ping` | no | | GET | `/api/v1/system/status` | no | | GET | `/api/v1/system/status/pages` | no | | GET | `/api/v1/system/start` | yes | | GET | `/api/v1/system/stop` | yes | | GET | `/api/v1/system/kill` | yes | | GET | `/api/v1/system/update` | yes | | GET | `/api/v1/system/metrics` | yes | | POST | `/api/v1/ops/create-page` | yes | | POST · DELETE | `/api/v1/ops/{page_id}/destroy-page` | yes | | POST · DELETE | `/api/v1/ops/{page_id}/lease` | yes | | POST | `/api/v1/ops/{page_id}/navigate` | yes | | POST | `/api/v1/ops/{page_id}/extract-content` | yes | | GET | `/api/v1/ops/{page_id}/screenshot` | yes | | POST | `/api/v1/ops/{page_id}/search-object` | yes | | POST | `/api/v1/primitive/{page_id}/click-element` | yes | | POST | `/api/v1/primitive/{page_id}/click-position` | yes | | POST | `/api/v1/primitive/{page_id}/hover-element` | yes | | POST | `/api/v1/primitive/{page_id}/hover-position` | yes | | POST | `/api/v1/primitive/{page_id}/check-visible` | yes | | POST | `/api/v1/primitive/{page_id}/scroll-page` | yes | | POST | `/api/v1/primitive/{page_id}/type-text` | yes | | POST | `/api/v1/primitive/{page_id}/paste-text` | yes | | POST | `/api/v1/primitive/{page_id}/press-enter` | yes | | POST | `/api/v1/primitive/{page_id}/press-tab` | yes | | POST | `/api/v1/primitive/{page_id}/reload-page` | yes | | POST | `/api/v1/primitive/{page_id}/zoom-page` | yes | | POST | `/api/v1/primitive/{page_id}/reset-zoom` | yes | | GET | `/api/v1/media/{token}` | yes | | GET | `/api/v1/plugins` | no | | GET | `/api/v1/plugins/{name}/docs` | no | | POST | `/api/v1/{page_id}/plugin/{plugin-path}` | yes |