> ## 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.

# Build a Real-Time Holders Tab like Axiom

Learn how to build a real-time token holder table, a live view of who holds a token, with each holder position, bag percentage and unrealized PnL updating as trades happen. This guide walks the frontend logic end to end in TypeScript, with no CSS, so you can drop it into any framework and style it yourself.

<Card title="CabalSpy/kol-realtime-holder-table" icon="github" href="https://github.com/CabalSpy/kol-realtime-holder-table">
  The full styled demo is open source. This guide rebuilds its core logic from scratch.
</Card>

## What you will build

By the end you will have a holder table with an initial snapshot fetched over the holder stream, live updates as wallets trade, live price driven updates as the market cap moves, and a small store that keeps it all in sync.

## How CabalSpy differs from a raw swap feed

If you have built a holder table on a raw swap feed before, you had to track each wallet balance yourself, apply every buy and sell, and guard against drift. The CabalSpy holder stream does that work server side. It sends you a full position per holder, already computed, then keeps sending the updated position. You never add up trades by hand, you just replace a holder position with the latest one you receive.

That makes the whole thing simpler. The three events you handle are init for the starting snapshot, holder\_update when a single holder trades, and position\_update when the market cap moves and the unrealized numbers change.

## Architecture overview

One WebSocket connection carries everything. A small store holds the holder list keyed by wallet, and three event handlers write into it.

```text theme={null}
  holder stream (wss://stream.cabalspy.xyz)
    |
    |  init            -> replace the whole list with the snapshot
    |  holder_update   -> upsert one holder (they just traded)
    |  position_update -> refresh the list (market cap moved)
    v
  HolderStore  (Map keyed by wallet address)
    |
    v
  renderTable(holders[])   your UI, styled however you like
```

## Step 1, project setup

Any bundler works. Starting from scratch with Vite and TypeScript is enough, there are no dependencies beyond the browser WebSocket.

```bash Terminal theme={null}
npm create vite@latest holder-table -- --template vanilla-ts
cd holder-table
npm install
```

Get your API key at [apidashboard.cabalspy.xyz](https://apidashboard.cabalspy.xyz). The key is passed when you open the connection, so keep it out of source control and load it from an environment variable in production.

## Step 2, type the data

Type the two shapes you receive, a holder and the position it carries. These mirror the fields the holder stream sends.

```typescript src/types.ts theme={null}
export interface Profile {
  name: string;
  image_url: string;
  twitter: string;
  telegram: string;
  type: string;
}

export interface Position {
  held: number;
  bag_pct: number;
  supply_pct: number;
  invested: number;
  invested_usd: number;
  remaining_sol: number;
  remaining_usd: number;
  realized_pnl_usd: number;
  unrealized_pnl_usd: number;
  unrealized_pnl_pct: number;
  entry_market_cap_usd: number;
  first_buy_at: string;
  last_activity_at: string;
}

export interface Holder {
  wallet: string;
  wallet_type: string;
  win_rate: number | null;
  profile: Profile;
  position: Position;
}
```

## Step 3, the holder store

The store keeps holders in a Map keyed by wallet address, so an update is a single replace. Keeping a Map, rather than an array, means an update from a trade is O(1) and never duplicates a wallet.

```typescript src/store.ts theme={null}
import type { Holder } from './types';

export class HolderStore {
  private byWallet = new Map<string, Holder>();

  /** Replace the whole set from an init snapshot. */
  setAll(holders: Holder[]): void {
    this.byWallet.clear();
    for (const h of holders) this.byWallet.set(h.wallet, h);
  }

  /** Insert or replace a single holder (they just traded). */
  upsert(holder: Holder): void {
    this.byWallet.set(holder.wallet, holder);
  }

  /** Current holders as an array, sorted by remaining value. */
  list(): Holder[] {
    return [...this.byWallet.values()].sort(
      (a, b) => b.position.remaining_usd - a.position.remaining_usd,
    );
  }

  get count(): number {
    return this.byWallet.size;
  }
}
```

## Step 4, connect and subscribe

Open one WebSocket, authenticate with the key in the query string, and subscribe to a token. Subscribing to a single mint returns a starting snapshot, so you get the whole table on connect and live updates after.

```typescript src/stream.ts theme={null}
export interface SubscribeOptions {
  apiKey: string;
  mint: string;
  walletTypes?: string[];
  mode?: 'full' | 'events';
  mcInterval?: number;
  onMessage: (msg: any) => void;
  onStatus?: (status: 'open' | 'closed') => void;
}

export function connectHolderStream(opts: SubscribeOptions): () => void {
  const ws = new WebSocket(`wss://stream.cabalspy.xyz?apiKey=${encodeURIComponent(opts.apiKey)}`);

  ws.onopen = () => {
    opts.onStatus?.('open');
    ws.send(JSON.stringify({
      op: 'subscribe',
      stream: 'holder',
      token: opts.mint,
      wallet_types: opts.walletTypes ?? ['kol', 'smart', 'whale'],
      mode: opts.mode ?? 'full',
      ...(opts.mcInterval ? { mc_interval: opts.mcInterval } : {}),
    }));
  };

  ws.onmessage = (e) => {
    try { opts.onMessage(JSON.parse(e.data)); } catch { /* ignore malformed frames */ }
  };

  ws.onclose = () => opts.onStatus?.('closed');

  // Return an unsubscribe/close function for cleanup.
  return () => { try { ws.close(); } catch { /* already closing */ } };
}
```

## Step 5, handle the three events

This is the heart of it. Route each event to the store. init replaces the list, holder\_update upserts the one holder that traded, and position\_update refreshes the list when the market cap moves.

```typescript src/holders.ts theme={null}
import { HolderStore } from './store';
import { connectHolderStream } from './stream';
import type { Holder } from './types';

export function startHolderTable(
  apiKey: string,
  mint: string,
  render: (holders: Holder[], count: number) => void,
): () => void {
  const store = new HolderStore();

  const close = connectHolderStream({
    apiKey,
    mint,
    mode: 'full',
    onMessage: (msg) => {
      switch (msg.event) {
        case 'init':
          // Full snapshot of every tracked holder.
          store.setAll(msg.data.holders ?? []);
          break;

        case 'holder_update':
          // One holder traded, replace just that holder.
          if (msg.data.holder) store.upsert(msg.data.holder);
          break;

        case 'position_update':
          // Market cap moved, the server resends the current holders with fresh
          // unrealized numbers. Replace the set so PnL stays accurate.
          if (Array.isArray(msg.data.holders)) store.setAll(msg.data.holders);
          break;

        default:
          return; // welcome / subscribed / pong frames, nothing to render
      }
      render(store.list(), store.count);
    },
  });

  return close;
}
```

## Step 6, render the table

The store hands you a plain array, so rendering is up to you and your framework. Here is a dependency free version that writes rows into a table body. No CSS, style it later.

```typescript src/render.ts theme={null}
import type { Holder } from './types';

const usd = (v: number) => '$' + (Number(v) || 0).toLocaleString('en-US', { maximumFractionDigits: 2 });
const pct = (v: number) => (Number(v) || 0).toFixed(2) + '%';

export function renderTable(holders: Holder[], count: number): void {
  const countEl = document.querySelector('#holder-count');
  if (countEl) countEl.textContent = String(count);

  const body = document.querySelector('#holder-body');
  if (!body) return;

  body.innerHTML = holders.map((h) => {
    const p = h.position;
    const pnlUp = p.unrealized_pnl_usd >= 0;
    return `
      <tr>
        <td>${h.profile.name || h.wallet.slice(0, 4)}</td>
        <td>${h.wallet_type}</td>
        <td>${usd(p.remaining_usd)}</td>
        <td>${pct(p.bag_pct)}</td>
        <td>${pct(p.supply_pct)}</td>
        <td style="color:${pnlUp ? 'green' : 'red'}">${usd(p.unrealized_pnl_usd)} (${pct(p.unrealized_pnl_pct)})</td>
      </tr>`;
  }).join('');
}
```

## Step 7, wire it together

A minimal entry point. Give it an API key and a mint, and the table fills in and stays live. The returned function closes the connection when you are done, for example on a route change.

```typescript src/main.ts theme={null}
import { startHolderTable } from './holders';
import { renderTable } from './render';

const API_KEY = import.meta.env.VITE_CABALSPY_KEY as string;
const MINT = '3Pkrq4MmLvDXyn1fa3sz5MekKRkZkc1iLDcz5AzWpump';

const stop = startHolderTable(API_KEY, MINT, renderTable);

// Later, when leaving the page:
// stop();
```

And the markup it writes into, the only HTML you need.

```html index.html theme={null}
<h2>Holders (<span id="holder-count">0</span>)</h2>
<table>
  <thead>
    <tr><th>Trader</th><th>Type</th><th>Value</th><th>Bag</th><th>Supply</th><th>Unrealized PnL</th></tr>
  </thead>
  <tbody id="holder-body"></tbody>
</table>
```

## Choosing a mode

The mode you pass on subscribe controls how much you receive, and how many credits you spend.

| mode   | What you receive                                                               | mc\_interval |
| ------ | ------------------------------------------------------------------------------ | ------------ |
| full   | Trades plus live position updates every time the market cap moves. Default.    | Supported    |
| events | Only real trade events. Unrealized PnL will not tick between trades. Cheapest. | Ignored      |

With mode full you can pass mc\_interval, a number of seconds from 1 to 30, to receive at most one market cap update per token in that window. It is the simplest way to keep a busy token from using credits quickly while still staying live.

## Lessons worth knowing

A few things that save you time.

The snapshot arrives for free. Because you subscribe to a single mint, the init event gives you the whole current table before any live update, so there is no separate REST call to seed the list.

You never compute balances. Unlike a raw swap feed, every holder\_update and position\_update carries the full, server computed position. Always replace, never add up trades yourself, and you cannot drift.

Reconnect cleanly. If the socket closes, reopen and resubscribe. The next init gives you a fresh snapshot, so you recover the correct state automatically without patching up missed messages.

## Reference

<Card title="WSS holder stream" href="/websockets/holder">
  The full holder stream reference, every field on init, holder\_update and position\_update.
</Card>
