# 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` - 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 | | Primitive | `/api/v1/primitive` | Atomic actions: click, scroll, type, paste, keys, zoom | | 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 `server.py`). Page states reported by the pool: `idle`, `busy`, `loading`, `error`, `crashed`, `stuck`. ### 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, `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. --- ## System ### `GET /api/v1/system/ping` Health check. Sleeps ~2s, then returns a timestamp. No auth. ```json { "pong": 1739381923123, "status": "alive" } ``` ### `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, "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 performs the kill/pull/restart cycle for every instance it manages. 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-instance cpu/memory samples collected by the supervisor (every 15 s, last 10 min — `METRICS_INTERVAL` / `METRICS_WINDOW` env on the supervisor). Same data from every instance. 404 when not running under `supervisor.py`. ```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-instance numbers cover the whole process tree under that `server.py` (the Chromium it launched). `cpu_pct` is summed over cores like `top`, so it can exceed 100. An instance 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}/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 two base64-encoded PNGs: the viewport and the full page. ```json { "success": true, "page_id": "scraper", "screenshot": "iVBORw0KG…", "full_screenshot": "iVBORw0KG…", "url": "https://example.com/" } ``` ### `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`. ### `POST /api/v1/primitive/{page_id}/click-position` ```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`. ### `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}/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. --- ## 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`.) 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). ### `nytimes-search` Search nytimes.com, or go straight to a nytimes.com URL. See [plugins/nytimes_search/README.md](../../plugins/nytimes_search/README.md). ### `detect-bounding-box` Screenshot the page and ask Gemini vision where an object is; feed the result to `click-position`. See [plugins/detect_bounding_box/README.md](../../plugins/detect_bounding_box/README.md). ### `instagram/*` A plugin family driving instagram.com in the persistent browser: search/open a profile, list grid posts, open a post, step its carousel, download every media file, read structured metadata, close the post, or return to a safe "home" state. It's a chain — bootstrapped by `instagram/state` (states: `feed` | `profile` | `post_open` | `unknown`), 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). --- ## 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`. | | `BROWSER_PROFILE` | `./browser-profile` | Persistent Firefox profile directory | | `BROWSER_SLOWMO` | `200` | Milliseconds of artificial delay after actions | | `UPDATE_INTERVAL` | `60` | Updater only: seconds between git fetch polls | | `HEALTH_TIMEOUT` | `90` | Updater only: seconds to wait for `/system/ping` after (re)start before rolling back | | `PING_URL` | `http://127.0.0.1:5000/api/v1/system/ping` | Updater only: health check URL | | `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 `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 | `/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}/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}/zoom-page` | yes | | POST | `/api/v1/primitive/{page_id}/reset-zoom` | yes | | GET | `/api/v1/plugins` | no | | GET | `/api/v1/plugins/{name}/docs` | no | | POST | `/api/v1/{page_id}/plugin/{plugin-path}` | yes |