Architecture
Two web applications, nine backend services, one database and one Redis. The shape is ordinary; the parts worth explaining are where the trust boundaries sit and why they sit there.
Two apps, one database
Section titled “Two apps, one database”The product is a React Router application in SSR mode, running as a
Cloudflare Worker. It owns its own pages, and the browser calls the API host
api.tradeharness.xyz directly for everything else: that host is the
router, the one ingress in front of the agent backend and the venue mirrors,
WebSocket upgrades included.
The operator console is a separate application on its own hostname, with its own account table. Not a route on the product and not a role on the user record — sharing either would put the credential manifest one role check away from a stolen session. It signs in against a whitelist that is its own table, and there is no sign-up.
They share one PostgreSQL instance and one schema, owned by the product. The console holds no database connection: it reaches the backend only through a private operator hostname, where the router reads the console’s own two tables for it. A typed client built from the shared schema would hand the operator console access to every user, account and trade in the system.
The router is the authorisation boundary
Section titled “The router is the authorisation boundary”The two web applications are Cloudflare Workers; nothing on the backend machine serves them. Only the router is reachable from outside. Every other service binds loopback and has no inbound port at all — the tunnel dials out, so there is nothing to reach.
That makes the router the only way in to those services, and it has to decide identity rather than accept it. Both backends behind it take their caller from input they are handed: the agent backend from a user-id header, the venue mirrors from an account id in a path or a channel name. That is reasonable between services and catastrophic behind a proxy that forwards it untouched.
So the router resolves the session cookie against the session table and
replaces whatever the client sent. Account-scoped routes then check
ownership and answer 404 rather than 403, because a refusal that
distinguishes the two confirms that an id exists.
The services
Section titled “The services”Nine backend services, each owning one thing. None of them speaks MCP — two were once named for it and are not any more; they are plain HTTP.
| Service | Runtime | What it owns |
|---|---|---|
| hl-state | Bun | Hyperliquid state: streamed prices, one-second candles, account state, market metadata |
| lighter-state | Bun | Lighter market streams, authenticated account events, venue-scoped alerts |
| lighter-signer | Python | The Lighter credential boundary. Signing happens here so that keys never cross an HTTP interface |
| execution | Bun | Order placement, the trade ledger, drawdown state |
| agent | Bun | The agent loop, the tools, the credential manifest |
| charting | Python | Chart rendering |
| market-state | Python | Market hours, news, economic calendars |
| telegram-ingest | Python | The allowlisted Telegram archive |
| model-service | Python | Daily equity return-quantile forecasts |
lighter-signer is the one worth pausing on, because it is a boundary rather
than a feature. A venue key that can sign an order is the most dangerous secret in
the system, so it is decrypted and used inside a single loopback process. The
service that decides what to trade never receives it.
Redis is the seam
Section titled “Redis is the seam”Services do not call each other for state. The venue mirrors write prices and account state into Redis; the agent and execution read it; and the browser is fed by server-sent events from one channel per account.
harness:price:{venue}:{symbol} a mark, with the time it was writtenharness:stream:{accountId} the event channel a browser listens onharness:risk-config:{accountId} the caps in forceharness:trades:risk:{accountId} open risk, for the portfolio capEvery price record carries the moment it was written, and readers on the pricing path are required to check it. A mark with no legible age is treated as unusable, not as current — see Execution for why that distinction is the whole point.
Credentials
Section titled “Credentials”Model providers and data-source keys live in one encrypted manifest in Postgres, written only by the operator console. Everything that needs to know what is available asks the manifest rather than a table — the model switcher, the agent’s setup gate and tool availability all resolve the same way, because they disagreed when they did not.
Venue credentials are different: they belong to your account, are encrypted at rest with AES-256-GCM, and are used only to read your account state and to act within the permissions you set. Disconnecting an account removes them.
OAuth subscriptions are stored as manifest credentials with a refresh token and renewed on a five-minute sweep. A refresh that fails marks the credential as needing reconnection rather than retrying, because no amount of retrying fixes a revoked grant.