# Agent guide — implement the pchat-server API on a device client **You are another Claude Code agent.** Your job is to write the *client* side that consumes this server's HTTP/WS API from a device (ESP32-S3 firmware, a companion app, a test harness — whatever the human asked for). This page is your contract. It is hardware-agnostic; for the ESP32-S3 firmware port specifically (LVGL, flash/PSRAM, fonts) read the sibling doc [`esp32s3-port.md`](./esp32s3-port.md) — this page is the **API reference** it leans on, with exact, *real* response bodies captured from the running server. The server is **`app:app`** (`app.py`), a FastAPI quote-proxy. It runs as the systemd service `pchat-server` on `0.0.0.0:50800`. **Do not modify the server** unless the human explicitly asks — treat it as a fixed contract and build the client to match. --- ## 0. Ground rules for you, the implementing agent - **Verify against the live server, don't trust this page blindly.** It runs locally; `curl http://127.0.0.1:50800/healthz` first. If a shape here disagrees with what the server returns, the server wins — re-capture and follow that. - **No auth on device-facing endpoints.** No tokens, no API keys from the device. Only the homepage-admin and portfolio endpoints are gated, and those are **not** part of a device client — ignore them. - **One server fan-outs to many devices.** Every quote is cached server-side (≈30 s). Poll politely; you are not the only client. Don't hammer — see cadences in §7. - **All timestamps are UNIX seconds** (`ts`) plus a parallel ISO string (`ts_utc`). Prices can carry an *upstream* `ts` far ahead of the request `ts` (market clock vs wall clock) — that's expected, not a bug. - **Numbers are JSON numbers, not strings.** `null`/missing fields happen — code defensively (a symbol the hub hasn't seen is simply absent from `quotes`). --- ## 1. Connection basics ``` Base URL: http://:50800 (HTTPS via the Caddy front in prod) Fast edge: https://pchat-api.photonicat.com (Rust edge, port 50801 — same API, hot quote endpoints served from Redis with ~ms latency; prefer it for device polling. /hl/ws works there too, routed by the front.) Auth: none for device endpoints Encoding: JSON everywhere except /hl/{coin} (CSV) and /clock helpers ``` Optional identity headers (only matter for the collaborative watchlist endpoints, §6.2 — skip entirely if your client doesn't edit shared lists): | Header | Value | Meaning | |---|---|---| | `X-Client-Id` | a stable per-device UUID | who owns a watchlist edit | | `X-Client-Name` | optional display name | shown as the list owner instead of the UUID | Liveness / diagnostics: ```jsonc GET /healthz → {"ok":true,"ts":1780653269,"ts_utc":"2026-06-05T09:54:29Z"} GET /status → {"ts":…,"ok":true,"services":[{"name":"hyperliquid","ok":true, "detail":"WS up · mids 1s old · 605 mids · 418 markets", …}, …]} ``` Use `/healthz` for a boot reachability check; `/status` for an on-device "server health" diagnostic screen. --- ## 2. Symbol formats — the thing that trips up every client The quote endpoints accept **two** symbol vocabularies and normalize internally: - **Native Sina codes:** `sh600519`, `sz000858`, `hk00700`, `gb_aapl`, `int_sp500`, `fx_susdcny`, `hf_GC` (gold future). - **Yahoo-style aliases:** `AAPL`, `0700.HK`, `600519.SS`, `USDCNY=X`, `GC=F`, `^GSPC`, `XAUUSD`. Case-insensitive. The response echoes your `input_symbol` so you can map results back to what you asked for, and returns a canonical `symbol` (e.g. `gb_aapl` → `AAPL`, `600519.SS` → `SH600519`). **Hyperliquid tickers** are their own namespace: a bare ticker (`BTC`, `ETH`, `NVDA`, `GOLD`, `SP500`) on the `/hl/*` endpoints. Tokenized equities/commodities live on builder dexes and come back fully-qualified as `xyz:NVDA`, `xyz:GOLD` — the `/hl/quotes` endpoint resolves friendly aliases for you (you send `NVDA`, it finds `xyz:NVDA`). > **Live-source rule (mirror it on the device).** eToro's CDN feed is **end-of-day > close only**. For anything you want *live*, prefer: **Hyperliquid** for tokenized > equities/commodities/crypto (live 24/7), **Sina** for FX (`fx_s`, live 24/5), > and fall back to eToro EOD only when nothing live exists. The `live:true/false` flag > on `/watchlist/search` results tells you which feed a market resolved to. --- ## 3. Quote endpoints (Sina / CoinGecko) ### `GET /q?syms=A,B,C` — batch snapshot (your workhorse) ```jsonc // GET /q?syms=gb_aapl,600519.SS,0700.HK { "quotes": [ {"symbol":"AAPL","name":"苹果","price":311.23,"change":0.97,"change_pct":0.31, "currency":"USD","exchange":"US","ts":1780682034,"ts_utc":"2026-06-05T17:53:54Z", "source":"sina","input_symbol":"GB_AAPL"}, {"symbol":"SH600519","name":"贵州茅台","price":1272.86,"change":4.86,"change_pct":0.383, "currency":"CNY","exchange":"SH","ts":1780671602,"source":"sina","input_symbol":"600519.SS"}, {"symbol":"HK00700","name":"腾讯控股","price":456.6,"change":-2.4,"change_pct":-0.523, "currency":"HKD","exchange":"HKEX","ts":1780675560,"source":"sina","input_symbol":"0700.HK"} ], "ts":1780653270, "ts_utc":"2026-06-05T09:54:30Z" } ``` One upstream request serves the whole batch — **always prefer `/q?syms=` over N× `/q/{symbol}`**. Names come back localized (Chinese for A-shares; that's why the device needs a CJK font — see `esp32s3-port.md` §6). ### `GET /q/{symbol}` and `GET /quote/{symbol}` — single symbol `/quote/{symbol}` returns the same per-quote object as above (full detail). Use only for a one-off detail view; for lists, batch. ### `GET /metals` — gold + silver (USD/oz) ```jsonc {"gold_usd_oz":4493.85,"gold_change_pct":-0.248, "silver_usd_oz":72.731,"silver_change_pct":-1.676, "ts":1780653269,"source":"sina"} ``` ### `GET /crypto?ids=bitcoin,ethereum` — CoinGecko (USD + 24h %) ```jsonc {"ts":…,"coins":{"BTC":{"usd":62853,"change_pct_24h":-0.1}, "ETH":{"usd":1680.85,"change_pct_24h":-4.246}}, "source":"coingecko"} ``` CoinGecko `ids` are slugs (`bitcoin`, `ethereum`), keyed back by symbol in the response. For live crypto prefer Hyperliquid (§5) — CoinGecko is a slower fallback. ### `GET /clock?stocks=…&coins=…` — one-shot bundle for the clock face Bundles `stocks` (Sina quotes) + `metals` + `crypto` (CoinGecko) in a single response — handy for the clock page's first paint so you don't fire three requests at boot: ```jsonc {"ts":…, "stocks":[{"symbol":"AAPL","name":"苹果","price":311.23,"change_pct":0.31,…}, …], "metals":{"gold_usd_oz":4493.85,…}, "crypto":{"coins":{"BTC":{"usd":62853,…},"ETH":{…}}}} ``` ### `GET /weather?city=` — open-meteo ```jsonc {"ok":true,"city":"Shanghai","country":"China","country_code":"CN", "timezone":"Asia/Shanghai","utc_offset_seconds":28800, "temp_c":23.1,"feels_c":24.7,"humidity":76,"wind_kmh":10.4,"is_day":true, "code":2,"text":"Partly cloudy","icon":"partly","high_c":26.6,"low_c":20.8, "ts":…,"source":"open-meteo"} ``` `icon` is a short slug (`partly`, …) you map to a local glyph; `code` is the WMO weather code if you prefer your own mapping. `utc_offset_seconds` lets you show the *city's* local time on the clock page (config flag `clockUsesCity`, §6.1). ### `GET /symbols`, `GET /symbols/search?q=` `/symbols` is the curated catalog grouped for a picker UI (`{"groups":{"A-shares (Shanghai)":{"sh600519":"Kweichow Moutai", …}, …}}`). `/symbols/search?q=茅台` searches the full A-share universe. Use these to power an on-device "add symbol" screen. --- ## 4. The `/hl/{coin}` CSV escape hatch (smallest possible client) ``` GET /hl/NVDA → NVDA,216.06,1780653259 ``` `COIN,price,unix_ts` as **plain text**, no JSON parser needed. Works for crypto *and* tokenized equities. Ideal for a tiny MCU loop or a sanity check. For more than one symbol or any change-% / metadata, use `/hl/quotes` (§5) instead of looping this. --- ## 5. Hyperliquid — live markets + WebSocket hub (the live-price path) ### `GET /hl/quotes?syms=BTC,ETH,NVDA,GOLD` — enriched snapshot ```jsonc {"ts":…,"mkt_ts":1780653259,"ready":true, "quotes":[ {"symbol":"BTC","ticker":"BTC","pair":"BTC/USDC","name":"Bitcoin","dex":"", "category":"crypto","hours":"24/7","icon":"/hl/icon/BTC", "icon_src":"https://…/XTVCBTC.svg","price":62830.0,"prev_day":63231.0, "change":-401.0,"change_pct":-0.634,"volume_24h":5.7e9,"max_leverage":40, "sz_decimals":5,"input":"BTC"}, {"symbol":"xyz:NVDA","ticker":"NVDA","name":"NVIDIA","dex":"xyz","category":"equity", "price":216.06,"prev_day":213.07,"change_pct":1.403,"icon":"/hl/icon/xyz:NVDA",…} ]} ``` - `change`/`change_pct` are vs `prev_day` (24h). `category` ∈ `crypto|equity|commodity|fx|index`. `hours` is `"24/7"` for HL. - `icon` is a **server-relative** path (`/hl/icon/`) → fetch it as a PNG/SVG and cache it on-device (see `esp32s3-port.md` §7). `icon_src` is the upstream original. - `ready:false` means the markets catalog is still warming at server boot — retry. ### `GET /hl?syms=BTC,ETH,SOL` — bare mids from the live hub ```jsonc {"ts":…,"mids_ts":1780653275, "quotes":[{"coin":"BTC","price":62841.5,"ts":…},{"coin":"ETH","price":1680.45,"ts":…}], "source":"hyperliquid-ws"} ``` Lighter than `/hl/quotes` (no metadata/change%). Empty `syms` dumps **every** mid the hub has seen (`{"mids":{…}}`) — useful once to discover coverage, too big to poll. ### `GET /hl/markets?q=&category=&dex=&page=&limit=` — browse/search all markets Paginated catalog with price + 24h change. `category` as above; `dex` is `""` (all), `default`/`core`/`crypto` (core perp dex), or a builder name like `xyz`. Powers an "explore markets" screen. ### `WS /hl/ws` — **the live price stream** (subscribe once, update forever) A transparent fan-out proxy of `wss://api.hyperliquid.xyz/ws`. The client protocol **mirrors Hyperliquid's own**: ```jsonc // → send {"method":"subscribe","subscription":{"type":"allMids"}} {"method":"subscribe","subscription":{"type":"l2Book","coin":"BTC"}} {"method":"subscribe","subscription":{"type":"trades","coin":"BTC"}} {"method":"subscribe","subscription":{"type":"candle","coin":"BTC","interval":"1m"}} {"method":"unsubscribe","subscription":{…}} {"method":"ping"} ``` ```jsonc // ← receive {"channel":"subscriptionResponse","data":{…the echo of your request…}} {"channel":"allMids","data":{"mids":{"BTC":"62841.5","NVDA":"216.06", …}}} // mids are STRINGS here {"channel":"pong"} {"channel":"error","data":"unknown method"} // bad method / subscription / json ``` **Device recipe:** open one WS, send `{"method":"subscribe","subscription":{"type":"allMids"}}`, then on every `{"channel":"allMids"}` frame update the on-screen labels for symbols you carry. **Mids in the `allMids` frame are JSON *strings*** — parse to float. Send a `ping` every ~30 s to keep the link alive; on disconnect, reconnect with backoff and re-subscribe. This is the only push channel — everything else is polled. --- ## 6. Device config & shared watchlists (state) ### 6.1 `GET /device/config` · `PUT /device/config` — the device's own settings The webapp/device config is a **single global** object, DB-backed (survives server restarts). This is the source of truth — cache it locally (NVS) but reconcile from here on boot. ```jsonc // GET /device/config {"config":{"city":"Milan","units":"c","clock24":true,"showSeconds":false, "bigSeconds":false,"clockUsesCity":true, "featured":["BTC","XYZ100"], "stocks":["NVDA","AAPL","TSLA","MSFT","AMZN","GOOGL","AMD","MU"], "coins":["BTC","ETH","SOL","BNB","XRP","DOGE","HYPE","ADA"], "markets":["BTC","ETH","GOLD","SP500"]}, "updated_at":1780565844.6} ``` ```jsonc // PUT /device/config — body is the WHOLE config object (replace, not merge) // (Content-Type: application/json; must be a JSON object; ≤16 KB or you get 413) {"city":"Milan","units":"c","clock24":true,"stocks":[…],"coins":[…],"markets":[…]} // → {"ok":true,"bytes":271} ``` Rules: **PUT replaces the entire object** — read-modify-write, never send a partial. A non-object body → `400`; over the 16 KB cap → `413`. There's no per-device scoping; it's collaborative/global by design (`watchlist-vs-device-config-persistence`). ### 6.2 Watchlists (optional — only if your client edits shared lists) Global, collaborative, no auth; identity from the `X-Client-*` headers (§1). `GET /watchlists → {"lists":[…]}`. Full CRUD: `POST /watchlists`, `PATCH/DELETE /watchlists/{id}`, `POST /watchlists/{id}/items` + `DELETE /watchlists/{id}/items/{sym}`, `PATCH /watchlists/owner`, `POST /watchlists/{id}/upgrade` (re-point EOD items at live feeds). Most device clients only need `/device/config` (§6.1) and can skip this. ### 6.3 `GET /watchlist/search?q=` — unified "add a market" search ```jsonc // GET /watchlist/search?q=nvidia&limit=3 {"q":"nvidia","total":4,"results":[ {"src":"HL","sym":"xyz:NVDA","ticker":"NVDA","name":"NVIDIA","category":"equity", "hours":"24/7","price":216.05,"live":true,"feed":"Hyperliquid"}, {"src":"ETORO","sym":"et:8760","ticker":"NVDA.RTH","name":"NVIDIA Corporation", "category":"equity","price":218.66,"live":false,"feed":"eToro"} ]} ``` Deduped across Hyperliquid + eToro + A-shares, **live results ranked first** (`live:true`). The `sym` is what you store in a config list. Frontend requests up to 60; the list is scrollable. --- ## 7. News ```jsonc // GET /news?syms=AAPL&limit=2 (per-instrument; or ?sym=AAPL) {"items":[{"title":"…","url":"https://news.google.com/rss/…","source":"The Motley Fool", "published_at":1780651854.0,"summary":"","sym":"AAPL","ticker":"AAPL", "icon":"/hl/icon/xyz:AAPL"}], "feeds":[{"sym":"AAPL","ticker":"AAPL","n":2,"stale":false,"status":"ok"}]} // GET /news/general?topic=&lang=&limit=2 (world/business timeline) {"items":[{"title":"…","url":"…","source":"The Block","published_at":1780651649.0,"summary":""}], "topic":"all","lang":"en","count":2} ``` Each per-instrument item carries the instrument `icon`. News is cached server-side (30 min TTL) — refresh on the device every **10–15 min**, no faster. --- ## 8. Recommended client architecture **Polling cadences (be a good citizen — the server caches, so faster won't help):** | Data | Endpoint | Refresh | |---|---|---| | clock time | local RTC/SNTP, reconcile via `/clock` | 1 s local; drift-check minutes | | live prices (crypto, HL equities) | `WS /hl/ws` `allMids` | push; coalesce redraws ≤5 Hz | | Sina quotes (A-share/HK/US/FX) | `GET /q?syms=` | 15–30 s | | metals | `GET /metals` (or in `/clock`) | 60 s | | weather | `GET /weather?city=` | 5–10 min | | news | `GET /news` / `/news/general` | 10–15 min | | config | `GET /device/config` | boot + on remote-change poll (minutes) | **Boot sequence:** `GET /healthz` → read cached config from local store, render immediately → `GET /device/config` and reconcile → open `WS /hl/ws`, subscribe `allMids` → start the poll loops above. **Implementation order (suggested for you, the agent):** 1. `/healthz` + `/q?syms=` — prove connectivity and JSON parsing with one screen. 2. `/device/config` GET/PUT round-trip — settings persistence. 3. `WS /hl/ws` `allMids` — live updates (the trickiest piece; test reconnect). 4. `/weather`, `/news`, icons — fill out the remaining pages. 5. `/clock` bundle to optimize first paint; `/watchlist/search` for add-symbol UX. --- ## 9. Gotchas (read before you ship) - **`allMids` values are strings**, but `/hl/quotes` and `/hl?syms=` give numbers. Normalize to float on ingest either way. - **Two clocks.** A quote's `ts` is the *upstream/market* time and may be hours ahead of the response-level `ts` (wall clock). Don't treat the gap as staleness. For the US session badge (PRE/AFTER HRS/CLOSED) compute the NYSE clock yourself — the webapp's `marketSession()` uses a **year-specific** `NYSE_HOLIDAYS` set (currently 2026); carry and update it yearly. - **Icons are server-relative paths** (`/hl/icon/`, `/etoro/icon/`). Prefix with the base URL, fetch, decode, and **cache locally** — don't re-fetch every paint. - **eToro = EOD only.** If a price looks "stuck," it resolved to eToro; re-resolve to a live feed (HL/Sina) via `/watchlist/search` or `/watchlists/{id}/upgrade`. - **PUT /device/config replaces the whole object** and is global — read-modify-write, and expect another device to have changed it (re-GET before a blind overwrite). - **A-share/HK/city names are CJK.** A device renderer needs a CJK font path (`esp32s3-port.md` §6); a desktop/app client just needs UTF-8. - **Don't poll faster than the cache.** 30 s quote TTL means sub-30 s polling returns identical bytes and just adds load. Live deltas come over the WS, not faster polling. --- ## 10. Quick verification commands ```bash B=http://127.0.0.1:50800 curl -s $B/healthz curl -s "$B/q?syms=gb_aapl,600519.SS,0700.HK" curl -s "$B/hl/quotes?syms=BTC,ETH,NVDA,GOLD" curl -s $B/hl/NVDA # CSV: NVDA,216.06,1780653259 curl -s $B/device/config # live WS (needs a ws client, e.g. websocat): # websocat ws://127.0.0.1:50800/hl/ws # > {"method":"subscribe","subscription":{"type":"allMids"}} ``` If any shape here is stale, the **running server is the source of truth** — re-capture and follow it, and (if you have a reason to) flag the drift to the human rather than editing the server to match this doc.