# Agent protocol The pulsar agent is a JavaScript webapp loaded inside the android-bridge WebView OR a standalone browser (operator PCs — see `/pulsar/agent/install` in the webapp). It talks to sgapps.io through four endpoints under `/pulsar/agent/`. This page documents the wire shape so somebody can port the agent to native code (OkHttp, URLSession, …) if needed. ## Auth (D13 — allowlist model) Every agent request carries `{bid}` in the JSON body — no token. - `bid` — the device's identifier. - AB Desktop: `buildIdentifier` from `AndroidBridge.serverSyncGetConfig()` (base64 of 128 random bytes, generated by the Android app on first launch). - Browser mode: `browser-<32-hex>` generated once in `localStorage.pulsar.identity` on first open. The prefix lets operators spot browser agents in the Pending inbox. Authorization is resolved at request time: 1. Server looks up `pulsarApprovedDevices.findOne({bid})`. 2. If found → the row's `ownerUserId` scopes job dispatch to that owner's `pulsarJobs`. The response is `200 ok`. 3. If not found → server upserts a row in `pulsarPendingDevices` with `{bid, firstSeenAt, lastSeenAt}` and returns `403 device-pending`. An owner approves from the webapp Devices tab; next request succeeds. No HMAC. No token rotation. No shared secret. The approval row IS the auth decision; deleting it instantly revokes the agent (next `/claim` returns `403 device-not-approved`). ## GET /pulsar/agent/ui Serves the HTML webapp the WebView / browser renders. **No query string required** — the agent reads its `bid` from `AndroidBridge.serverSyncGetConfig()` on native, or from `localStorage.pulsar.identity` on browser. The URL is constant (`https:///pulsar/agent/ui`) and can be baked into AB Desktop presets or operator browser shortcuts. ## POST /pulsar/agent/api/session Open a session. The server creates a row in `androidBridge.pulsarAgents` and returns the session id. ### Request ```json { "bid": " for browsers>", "sessionId": "", "capabilities": { "insecureTLS": "ignore|accept|refuse", "privateIP": "ignore|accept|refuse" }, "userAgent": "" } ``` ### Response ```json { "status": "ok", "data": { "sessionId": "", "capabilities": {"insecureTLS": "ignore", "privateIP": "ignore"} } } ``` ### Errors | HTTP | code | Why | |---|---|---| | 400 | `bid-required` | request body missing `bid` | | 403 | `device-pending` | BID not in approved list; a row was inserted into `pulsarPendingDevices` for owner review | | 403 | `device-not-approved` | BID was previously approved then revoked — owner removed the row | | 500 | `internal-error` | server bug | Session rows TTL at 24 h — the agent must re-open the session before then. A simple pattern: `openSession()` once at page load, then loop. ## POST /pulsar/agent/api/claim Poll for jobs. Pass the current capabilities so the server pre-filters jobs the agent would refuse. ### Request ```json { "bid": "", "sessionId": "", "capabilities": {"insecureTLS": "accept", "privateIP": "refuse"}, "batch": 1 } ``` `batch` is clamped to `[1, 5]`. ### Response ```json { "status": "ok", "data": { "jobs": [ { "jobId": "", "request": { "url": "https://example.com/data.json", "method": "GET", "headers": { "User-Agent": "my-tool/1.0" }, "body": null, "bodyBase64": false, "timeoutMs": 10000, "maxResponseBytes": 2097152, "allowInsecureTLS": false, "allowPrivateIP": false, "rangeRequest": null, "followRedirects": true } } ], "retryAfterMs": 500 } } ``` - `jobs` can be empty — poll again after `retryAfterMs`. - `retryAfterMs` is `500` when jobs were returned (keep up the pace), `3000` when idle (don't flood sgapps.io). Each claim bumps the agent's `lastSeenAt` and refreshes the session's `expiresAt`. ### Dispatch rules (what sgapps.io does before replying) 1. Scan `pulsarJobs` for rows where: - `ownerUserId ∈ this device's owners[].userId` - `state === 'pending'` OR `(state === 'dispatched' AND dispatchDeadlineAt < now AND assignedAgents.length < 2)` - job's `allowInsecureTLS: true` → agent's cap must be `accept` - job's `allowPrivateIP: true` → agent's cap must be `accept` - this agent not already in `assignedAgents[].sessionId` 2. Sort by `createdAt` ASC (FIFO fairness). 3. For each candidate, atomically flip to `state: 'dispatched'`, push the agent into `assignedAgents[]`, set `dispatchDeadlineAt = now + dispatchTimeoutMs` (default 15 s). If a race loses (another agent claimed the same row first and `assignedAgents` is already full), that row is skipped. The agent sees only the rows it actually got. ## POST /pulsar/agent/api/callback Settle a job. Either `result` or `error` must be set. ### Request — success ```json { "bid": "", "sessionId": "<...>", "jobId": "", "result": { "status": 200, "statusText": "OK", "headers": { "content-type": "application/json" }, "body": "{\"ok\":true}", "bodyBase64": false, "cropped": false, "cropOriginalSize": null, "elapsedMs": 182, "bytesWritten": 12 } } ``` ### Request — failure ```json { "bid": "", "sessionId": "<...>", "jobId": "", "error": {"code": "timeout", "message": "upstream did not respond"} } ``` ### Response ```json {"status":"ok","data":{"state":"done|failed|refused|noop"}} ``` - `done` — this agent won the race, job marked done. - `failed` — this agent won the race, job marked failed. - `refused` — the agent returned `error.code: 'agent-refused-override'`; pulsar popped the agent from `assignedAgents[]` and (if no other agent is working on the row) re-armed `state: 'pending'` for another claim. - `noop` — another agent already settled the job (first-wins). The callback is silently discarded, counters NOT incremented. ### Agent-side defensive refuse If the agent's capability toggle has been flipped to `refuse` between `claim` and execution (e.g. operator just changed it), the agent should NOT execute the fetch. Instead POST a callback with `error: {code: 'agent-refused-override', message: '…'}`. The server re-arms the row for another agent. ## Rate limits / backoff rules of thumb - `claim` with no jobs → wait at least `retryAfterMs` (currently 3 s) before polling again. Shorter → noise. - `claim` after a `done`/`failed` callback → immediate poll is fine (the server returned `retryAfterMs: 300` in the demo agent to pick up the next job quickly). - `session` is idempotent — safe to re-issue if the agent loses track of its `sessionId`. ## Statistics The server-side settler bumps per-session counters on `pulsarAgents` (`stats.claims`, `stats.jobsDone`, `stats.jobsFailed`, `stats.jobsRefused`, `stats.bytesOut`) and lifetime per-user counters on `androidBridge.accountStats` (`pulsarJobsTotal`, …). The agent UI reads its own session row via a future `GET /pulsar/agent/api/stats` (not yet implemented in M1 — the webapp keeps a local counter instead).