> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cabalspy.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Thesis Markers on Your Price Chart

> Put token theses from verified holders on your price chart as live markers, colored by bull or bear, labelled with the author and their real PnL. Plain JavaScript with TradingView Lightweight Charts and a tiny Node relay.

Learn how to put [token theses](/cabalspy-id/theses) on your price chart. Every thesis is written by a verified holder of the token, so a marker on the chart shows when a real holder called it, whether they were bullish or bearish, and how their position is doing now. Hover a marker to read the thesis.

This recipe loads the existing theses once, then keeps the chart live with the [theses stream](/cabalspy-id/theses-stream). It targets **TradingView Lightweight Charts 5.0.8** (`createSeriesMarkers`), in plain JavaScript.

## What you will build

A candlestick chart with one marker per thesis: green arrow below the bar for `bull`, red arrow above the bar for `bear`, grey circle without a stance. The label reads `@author +54%`, the author's live PnL on the token. New theses appear the moment they are posted, deleted or hidden ones disappear.

## How it fits together

Your API key must never reach the browser. A tiny relay on your server holds the key, loads the history and keeps one connection to the theses stream. The browser only talks to the relay.

```text theme={null}
browser ──GET /theses──────▶ relay ──GET /v1/theses──────────▶ api-id.cabalspy.xyz
browser ◀──ws /live─────────  relay ◀──wss /v1/theses/stream──  api-id.cabalspy.xyz
```

The endpoints used:

<CardGroup cols={2}>
  <Card title="GET /v1/theses" href="/cabalspy-id/theses-list">
    The theses of a token, with author, stance, created\_at and position.
  </Card>

  <Card title="Theses stream" href="/cabalspy-id/theses-stream">
    Live thesis events for up to 200 tokens per connection. Every plan.
  </Card>
</CardGroup>

## Step 1, the relay

Node 22 or newer (global `fetch` and `WebSocket`), plus the `ws` package for the browser-facing socket: `npm install ws`. Set `CABALSPY_API_KEY` to your key with CabalSpy ID enabled.

The relay keeps one upstream connection, subscribes to every token a browser is watching, and forwards each thesis event only to the browsers watching that token.

```javascript relay.mjs theme={null}
import http from 'node:http';
import { WebSocketServer } from 'ws';

const KEY = process.env.CABALSPY_API_KEY;
const API = 'https://api-id.cabalspy.xyz/v1';
const STREAM = `wss://api-id.cabalspy.xyz/v1/theses/stream?apiKey=${KEY}`;
const ORIGIN = process.env.ALLOWED_ORIGIN || '*';       // your site, e.g. https://app.example.com
const CHAINS = new Set(['solana', 'bnb', 'base', 'eth', 'rh']);

// solana mints are case sensitive, EVM addresses are not
const keyOf = (chain, mint) => `${chain}:${chain === 'solana' ? mint : mint.toLowerCase()}`;
const tokenOf = (k) => { const [blockchain, mint] = k.split(':'); return { blockchain, mint }; };
function readToken(url) {
  const chain = url.searchParams.get('blockchain') || '';
  const mint = (url.searchParams.get('mint') || '').trim();
  return CHAINS.has(chain) && /^[0-9A-Za-z]{32,44}$/.test(mint) ? keyOf(chain, mint) : null;
}

// ── upstream: one connection to the theses stream ──
const watchers = new Map();   // token key -> Set of browser sockets
let up = null;

function connectUpstream() {
  up = new WebSocket(STREAM);
  up.onopen = () => {
    const tokens = [...watchers.keys()].map(tokenOf);
    if (tokens.length) up.send(JSON.stringify({ op: 'subscribe', tokens }));   // again after every reconnect
  };
  up.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    if (msg.event === 'error') return console.error('theses stream:', msg.error.code, msg.error.message);
    if (!msg.event.startsWith('thesis')) return;              // ready, subscribed, heartbeat, pong
    const sockets = watchers.get(keyOf(msg.data.blockchain, msg.data.mint));
    for (const ws of sockets ?? []) if (ws.readyState === 1) ws.send(e.data);
  };
  up.onclose = () => setTimeout(connectUpstream, 2000);
}
connectUpstream();

function upstream(op, k) {
  if (up?.readyState === WebSocket.OPEN) up.send(JSON.stringify({ op, tokens: [tokenOf(k)] }));
}

// ── HTTP: the history of a token ──
const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://relay');
  const k = url.pathname === '/theses' && readToken(url);
  if (!k) { res.writeHead(404).end(); return; }
  const { blockchain, mint } = tokenOf(k);
  const r = await fetch(`${API}/theses?` + new URLSearchParams({ blockchain, mint, sort: 'new', limit: '50' }), {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  res.writeHead(r.status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': ORIGIN });
  res.end(await r.text());
});

// ── WebSocket: live events of one token per browser socket ──
const wss = new WebSocketServer({ server, path: '/live' });
wss.on('connection', (ws, req) => {
  const k = readToken(new URL(req.url, 'http://relay'));
  if (!k) return ws.close(1008, 'blockchain and mint required');
  if (!watchers.has(k)) { watchers.set(k, new Set()); upstream('subscribe', k); }
  watchers.get(k).add(ws);
  ws.on('close', () => {
    const set = watchers.get(k);
    set?.delete(ws);
    if (set && set.size === 0) { watchers.delete(k); upstream('unsubscribe', k); }
  });
});

server.listen(8787, () => console.log('relay on http://localhost:8787'));
```

The relay only accepts known chains and address-shaped mints, so nobody can use it to call other endpoints with your key. One upstream connection carries up to 200 tokens; open a second one if you show more.

## Step 2, the chart

Load Lightweight Charts 5.0.8 and give the chart a container and a tooltip element.

```html index.html theme={null}
<div id="chart" style="position:relative;height:480px"></div>
<div id="tip" style="position:absolute;display:none;max-width:320px;padding:8px 10px;border-radius:8px;
     background:#12161c;color:#e8edf2;font:13px/1.4 system-ui;pointer-events:none;z-index:10"></div>
<script src="https://unpkg.com/lightweight-charts@5.0.8/dist/lightweight-charts.standalone.production.js"></script>
<script type="module" src="./markers.js"></script>
```

## Step 3, theses to markers

A marker needs a time, a position, a shape, a color and a text. The time must be a Unix timestamp in seconds, and markers must be sorted by time. Round `created_at` down to the bar interval so the marker sits on the right candle.

```javascript markers.js (part 1) theme={null}
const RELAY = 'http://localhost:8787';
const RELAY_WS = 'ws://localhost:8787';
const BAR_SECONDS = 60;                       // match the interval of your candles

const theses = new Map();                     // id -> thesis

function pnlLabel(t) {
  const pos = t.position;
  if (!pos || pos.private) return '';
  if (pos.now?.status === 'sold') return ' sold';
  const p = pos.now?.pnl_percent;
  return p == null ? '' : ` ${p >= 0 ? '+' : ''}${p.toFixed(0)}%`;
}

function toMarker(t) {
  const time = Math.floor(Date.parse(t.created_at) / 1000 / BAR_SECONDS) * BAR_SECONDS;
  const base = { id: t.id, time, text: `@${t.author.username}${pnlLabel(t)}` };
  if (t.stance === 'bull') return { ...base, position: 'belowBar', shape: 'arrowUp', color: '#3ddc84' };
  if (t.stance === 'bear') return { ...base, position: 'aboveBar', shape: 'arrowDown', color: '#ff5c6c' };
  return { ...base, position: 'aboveBar', shape: 'circle', color: '#8b96a3' };
}

function allMarkers() {
  return [...theses.values()].map(toMarker).sort((a, b) => a.time - b.time);
}
```

## Step 4, draw, load and go live

Create the chart and the marker plugin, load the history from the relay, then apply live events. `thesis` adds a marker, `thesis.removed` deletes one. Hovering a marker shows the thesis body, using the marker id that Lightweight Charts reports as `hoveredObjectId`.

```javascript markers.js (part 2) theme={null}
const { createChart, CandlestickSeries, createSeriesMarkers } = LightweightCharts;

export async function showTheses(blockchain, mint, candles) {
  const el = document.getElementById('chart');
  const tip = document.getElementById('tip');
  const chart = createChart(el, { autoSize: true, layout: { background: { color: '#0b0e12' }, textColor: '#8b96a3' } });
  const series = chart.addSeries(CandlestickSeries);
  series.setData(candles);                    // your OHLC data: [{ time, open, high, low, close }, ...]
  const markers = createSeriesMarkers(series, []);
  const redraw = () => markers.setMarkers(allMarkers());

  // 1. history
  const q = new URLSearchParams({ blockchain, mint });
  const body = await (await fetch(`${RELAY}/theses?${q}`)).json();
  for (const t of body.data ?? []) theses.set(t.id, t);
  redraw();

  // 2. live
  const ws = new WebSocket(`${RELAY_WS}/live?${q}`);
  ws.onmessage = (e) => {
    const { event, data } = JSON.parse(e.data);
    if (event === 'thesis') { theses.set(data.id, data); redraw(); }
    if (event === 'thesis.removed' && theses.delete(data.id)) redraw();
  };

  // 3. tooltip with the thesis body
  chart.subscribeCrosshairMove((param) => {
    const t = param.hoveredObjectId && theses.get(param.hoveredObjectId);
    if (!t || !param.point) { tip.style.display = 'none'; return; }
    tip.textContent = `@${t.author.username}${t.author.kol_badge ? ' (KOL)' : ''}: ${t.body}`;
    tip.style.left = `${param.point.x + 12}px`;
    tip.style.top = `${param.point.y + 12}px`;
    tip.style.display = 'block';
  });
}
```

Call it with your own candles:

```javascript theme={null}
import { showTheses } from './markers.js';
showTheses('solana', '3Pkrq4MmLvDXyn1fa3sz5MekKRkZkc1iLDcz5AzWpump', await loadCandles());
```

`textContent` keeps the thesis text as plain text: never insert it as HTML.

## The same relay with the SDK

The [JavaScript SDK](/cabalspy-id/sdks#theses) (`npm install cabalspy-id ws`) wraps the stream in `ThesesStream`: it reconnects on its own, watches the heartbeat and sends your subscriptions again after every reconnect. Replace the upstream part and the history call of the relay with this:

```javascript relay-sdk.mjs theme={null}
import http from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import { CabalSpyID } from 'cabalspy-id/thesis';

const csid = new CabalSpyID({ apiKey: process.env.CABALSPY_API_KEY });
const stream = csid.thesesStream({ WebSocketImpl: WebSocket });   // WebSocketImpl only needed on Node < 22
const CHAINS = new Set(['solana', 'bnb', 'base', 'eth', 'rh']);
const keyOf = (chain, mint) => `${chain}:${chain === 'solana' ? mint : mint.toLowerCase()}`;
const watchers = new Map();   // token key -> Set of browser sockets

function forward(event) {
  stream.on(event, (data) => {
    const msg = JSON.stringify({ event, data });
    for (const ws of watchers.get(keyOf(data.blockchain, data.mint)) ?? []) if (ws.readyState === 1) ws.send(msg);
  });
}
['thesis', 'thesis.reply', 'thesis.reaction', 'thesis.removed'].forEach(forward);
stream.on('error', (err) => console.error('theses stream:', err.code, err.message));
await stream.connect();

function readToken(url) {
  const blockchain = url.searchParams.get('blockchain') || '';
  const mint = (url.searchParams.get('mint') || '').trim();
  return CHAINS.has(blockchain) && /^[0-9A-Za-z]{32,44}$/.test(mint) ? { blockchain, mint } : null;
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://relay');
  const token = url.pathname === '/theses' && readToken(url);
  if (!token) { res.writeHead(404).end(); return; }
  const data = await csid.listTheses({ ...token, sort: 'new', limit: 50 });
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*' });
  res.end(JSON.stringify({ data }));
});

new WebSocketServer({ server, path: '/live' }).on('connection', (ws, req) => {
  const token = readToken(new URL(req.url, 'http://relay'));
  if (!token) return ws.close(1008, 'blockchain and mint required');
  const k = keyOf(token.blockchain, token.mint);
  if (!watchers.has(k)) { watchers.set(k, new Set()); stream.subscribe([token]); }
  watchers.get(k).add(ws);
  ws.on('close', () => {
    const set = watchers.get(k);
    set?.delete(ws);
    if (set && set.size === 0) { watchers.delete(k); stream.unsubscribe([token]); }
  });
});

server.listen(8787, () => console.log('relay on http://localhost:8787'));
```

The browser code from steps 2 to 4 stays the same: `/theses` answers `{ data: [...] }` and `/live` sends `{ event, data }` messages in both versions.

## Lessons worth knowing

Keep the key on the server. The relay is a few dozen lines and is the only thing that ever sees your API key. Validate the chain and mint it accepts.

Load once, then stay live. The history comes from one request, everything after that from the stream. After a relay restart or a dropped browser socket, load the history again to fill the gap.

Snap times to your bars. Markers sit on bar times, so round `created_at` down to your candle interval and keep the marker list sorted.

Only theses from your platform? Open the stream with `scope=instance` and call `/v1/theses` with `scope=instance` (`csid.thesesStream({ scope: 'instance' })` and `listTheses({ scope: 'instance' })` in the SDK). See [Reach](/cabalspy-id/theses#reach).

## Reference

<CardGroup cols={2}>
  <Card title="Token theses" href="/cabalspy-id/theses">
    How theses work, the widget and the API.
  </Card>

  <Card title="Theses stream" href="/cabalspy-id/theses-stream">
    Frames, events and limits of the live stream.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.