Listener
The local listener is an HTTP + WebSocket server that the standalone CLI binds
on 127.0.0.1:53219. It exposes three things to localhost: a health endpoint,
a per-hook webhook fire endpoint, and a WebSocket upgrade that lets the web
app drive the standalone CLI as a remote target. Standalone-only — there is no
listener in the web CLI runtime. Relevant when you want to pair the web tab to
a running CLI, receive webhooks from TradingView (or anything else), or keep
accounts streaming on a dedicated host while you switch browsers.
HTTP surface
| Method + path | Auth | Purpose |
|---|---|---|
GET /health | none | Liveness probe. Returns { ok: true, pid, port }. |
POST /hook/<id> | X-Hook-Token: <32-hex secret> | Fire a registered hook. See Hooks & webhooks. |
WS upgrade /ws | origin allowlist + double-trust | Bidirectional channel for the web listener target. |
| anything else | — | 404. |
GET /health:
$ curl http://127.0.0.1:53219/health
{"ok":true,"pid":42101,"port":53219}
WebSocket handshake
When the web app's CLI tab targets listener, it opens
ws://127.0.0.1:53219/ws and runs through this sequence:
- Origin allowlist. The Sec-WebSocket-Origin header is checked against the configured allowlist before the socket is accepted.
helloframe. Client must send{ t: 'hello', clientId, label? }as its first frame.clientIdis a UUID the web app generates and persists per browser-tab namespace; max 64 chars.labelis an optional human string for the trust prompt.- Trust check. The CLI looks up
clientIdin~/.tealstreet/trusted-clients.json. Known clients pass through silently. Unknown clients pause the WS handshake and trigger a REPL prompt asking the operator to approve or deny. - Accept or reject. On approval the CLI replies
{ t: 'welcome', listenerId, user: { email } }. On rejection it replies{ t: 'rejected' }then closes the socket with code 4001. - Topic subscribe. Welcomed clients receive snapshots for the topics
they care about:
accounts,tasks,hooks,hook-output.
Once a client is welcomed, all further interaction happens via the wire frames below.
Wire frames
Every frame is JSON with a discriminator field t. Listed in handshake-then-
runtime order.
t | Direction | Payload | Purpose |
|---|---|---|---|
hello | client → CLI | { clientId, label? } | First frame after socket open. Identifies the web client. |
welcome | CLI → client | { listenerId, user: { email } } | Acceptance. listenerId is the CLI's persistent UUID — the web app pins it to detect "different CLI" on the next connect. Email is display-only. |
rejected | CLI → client | (empty body) | Trust denied. Always followed by close code 4001. |
cmd:exec | client → CLI | { id, command, account?, symbol? } | Execute command as if typed at the REPL prompt. Optional account / symbol scope the run. |
cmd:output | CLI → client | { id, kind, text } — kind ∈ print | success | warn | error | info | Streamed output. Same PrintKind values as the REPL renders (see Output). Webhook fires also stream on the hook-output topic. |
cmd:done | CLI → client | { id, success, error? } | Run finished. Stray late cmd:output frames from ; / & chains may still arrive after this — match by id. |
mutate:account | client → CLI | { id, op: 'upsert' | 'delete', account?, name?, updatedAt? } | LWW merge of an account from the web side into the CLI's accounts list. |
mutate:hook | client → CLI | { id, op: 'create' | 'rotate' | 'revoke', name?, command?, account?, hookId? } | Manage the hook registry from the web. See Hooks & webhooks. |
mutate:ack | CLI → client | { id, data? } | Mutation succeeded. |
mutate:err | CLI → client | { id, error } | Mutation failed. |
state:accounts | CLI → topic | { accounts: [...] } | Full snapshot. Pushed on subscribe + after each write (debounced). |
state:tasks | CLI → topic | { tasks: [...] } | Snapshot of tasks.json. |
state:hooks | CLI → topic | { hooks: [...] } | Snapshot of hooks.json with secret scrubbed. Web never sees raw secrets over the wire. |
Auth model
Two opaque identifiers cooperate to gate the WS.
| Identifier | Lives where | Used for |
|---|---|---|
clientId | Web app (browser-tab namespace UUID; max 64) | Sent in hello. Looked up in trusted-clients.json for double-consent. |
listenerId | ~/.tealstreet/listener.json (generated on first run) | Sent in welcome. Web pins it on first approval and surfaces a warning if a later welcome returns a different one. |
Layers on top:
- Double-consent. First connection from a new
clientIdalways triggers a REPL prompt. The operator must approve before the socket completes. See Trusted clients for the persistence shape and revocation commands. - Origin allowlist. Enforced before the handshake even starts.
- JWT email. The
welcome.user.emailis pulled from the standalone CLI's stored OAuth token. Display only — there is no incoming JWT and the email is not used as an auth credential.
Kill switches
All three default to true. They live under listener.* in config.json
and are read fresh on every call — no memoization, no restart needed.
| Flag | What it gates | Effect when false |
|---|---|---|
listener.enabled | Whether the loopback server binds at all. | Server never starts. POST /hook/<id> returns 503. WS upgrade refused. |
listener.accountSync | state:accounts broadcasts + mutate:account accept. | Accounts topic stops emitting; mutate:account returns mutate:err. |
listener.remoteExec | cmd:exec frames and POST /hook/<id> fires. | cmd:exec returns an error frame; webhook fires return 423. |
To set them, edit ~/.tealstreet/config.json directly:
{
"listener": {
"enabled": true,
"accountSync": true,
"remoteExec": false
}
}
Files on disk
All under ~/.tealstreet/. Override the directory with the
TEALSTREET_CONFIG_DIR env var if you need a non-default location.
| File | Contents | Sensitive? |
|---|---|---|
listener.json | { listenerId, createdAt } | no |
trusted-clients.json | { clients: [{ clientId, label, origin, approvedAt }] } | no |
hooks.json | { hooks: [{ id, name, secret, command, account?, createdAt }] } | yes — contains shared secrets |
See Config files for the full per-file map.
Port and reconnect
- Default port
53219, hardcoded. - 75 s client-hint timer. If nothing connects to the listener within 75 s of CLI startup, the REPL prints a hint reminding you to refresh the web tab. The web app caps its reconnect backoff at 60 s, so 75 s is enough headroom to assume the web side has given up.
- Topics:
accounts,tasks,hooks,hook-output. A client opts in per-topic afterwelcome.
For the end-to-end web ↔ standalone setup, see Pairing. For
the webhook HTTP variant of cmd:exec, see
Hooks & webhooks.