"Smart money" on Polymarket is a cohort definition: wallets with $1k+ realized PnL across 5+ resolved markets. That filter alone strips out the speculation and leaves the actors who've been right enough times for size to call them "smart" — about 1–2 % of the population. Once you have the cohort, two questions are interesting:

  1. Flows — which active markets is this cohort piling into right now?
  2. Mispricings — which markets has the cohort accumulated below the current mid (i.e., they bought cheap, the market re-rated, and they're sitting on paper edge)?

This case is a single page that flips between those two views with one tab click. Both are powered by the same MCP tool (polymarket_smart_money on vike.io/mcp), with the mode argument flipping the output shape. A second tool (polymarket_market_detail) hangs off each row for the drill-in.

Live demo: demo.vike.io/polymarket-smart-money/. Source: vike-io/demo-trading-apps/polymarket-smart-money.

Mode 1 — Flows

Flows mode ranks markets by net inflow from the cohort over the last N hours. Buy-side dollars minus sell-side dollars; positive means they're accumulating, negative means they're distributing. The header summarizes what you're looking at; rows are markets, sorted by net inflow descending.

Polymarket Smart Money in Flows mode — the 24h leaderboard. Roland Garros ATP, MLB, Dota 2, and LoL match markets at the top, with $59.5K / $33.9K / $32.3K net inflows from 18 / 12 / 18 smart wallets respectively. Bottom of the meta line: "Cohort = wallets with $1k+ realized PnL across 5+ resolved markets. Source: vike.io/mcp · polymarket_smart_money".

Every row links the market title straight to its Polymarket page, and the right-edge ▾ caret opens a per-market drill-in (more on that below).

Mode 2 — Mispricings

Same MCP tool, different mode argument. Mispricings returns markets where the cohort's average buy price sits meaningfully below the current mid. Different cohort signal, different column shape — Smart wallets / Cohort avg / Current mid / Edge:

Polymarket Smart Money in Mispricings mode — three rows surface. "Will Caracas FC win on 2026-05-27?" shows cohort avg 20.0¢ vs current mid 54.9¢ (cohort got in at one-third the current price). "Will Donald Trump dance on May 28, 2026?" — cohort avg 91.9¢ / current 98.0¢. "Will Bosnia and Herzegovina win on 2026-05-29?" — 52.4¢ / 56.5¢.

The mispricings list is short because the bar is high — the API only surfaces rows where the cohort's accumulation actually predates a meaningful re-rate. Top result in this run: cohort accumulated "Will Caracas FC win" at 20¢ and the market now trades at 55¢. Whether that's prescient money or wrong-side carry is a judgment call — what the demo gives you is the shortlist.

One tool, two output shapes

The whole "switch the view" mechanism is the mode argument on polymarket_smart_money. Nothing else needs to know which view is active:

const VIKE_MCP = "/api/polymarket-smart-money/vike/mcp";

async function loadSignals() {
  const args = {
    mode: state.mode,                        // "flows" | "mispricings"
    hours: Number($("#hours").value || 24),
    limit: 15
  };
  const cat = $("#category").value.trim();
  if (cat) args.category = cat;              // optional filter, server-side

  const res = await fetch(VIKE_MCP, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0", id: 1, method: "tools/call",
      params: { name: "polymarket_smart_money", arguments: args }
    })
  });

  if (!res.ok) throw new Error(`Vike proxy ${res.status}`);
  const env = await res.json();
  if (env.error) throw new Error(`Vike: ${env.error.message || env.error.code}`);
  const text = env.result?.content?.[0]?.text;
  state.rows = JSON.parse(text);

  renderHeader();   // different columns per mode
  renderRows();
}

The only branching on mode happens in the renderer — the header columns differ, and a few row cells differ:

function renderHeader() {
  const thead = $("#thead-row");
  if (state.mode === "flows") {
    thead.innerHTML = `
      <th class="rank">#</th><th>Market</th>
      <th class="num">Smart wallets</th>
      <th class="num">Net inflow</th>
      <th class="num">Buy</th>
      <th class="num">Sell</th>
      <th class="num">Trades</th>
      <th class="num">Last activity</th>
      <th class="expand"></th>`;
  } else {
    thead.innerHTML = `
      <th class="rank">#</th><th>Market</th>
      <th class="num">Smart wallets</th>
      <th class="num">Cohort avg</th>
      <th class="num">Current mid</th>
      <th class="num">Edge</th>
      <th class="num">Last activity</th>
      <th class="expand"></th>`;
  }
}

Tab click → state.mode flips → loadSignals() re-fires with the new arg → server returns the right shape → renderer picks the right column set. About fifteen lines of glue total.

The second tool — drill into one market

Click any row's ▾ caret and the panel fetches polymarket_market_detail for that market's condition_id. The result is the full per-market envelope: YES/NO mid prices, volume, the top YES/NO holders among tracked wallets, and the recent trade tape with Polygonscan transaction links.

const detailCache = new Map();

async function fetchMarketDetail(conditionId) {
  if (detailCache.has(conditionId)) return detailCache.get(conditionId);
  const res = await fetch(VIKE_MCP, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0", id: 1, method: "tools/call",
      params: { name: "polymarket_market_detail", arguments: { condition_id: conditionId } }
    })
  });
  if (!res.ok) throw new Error(`Vike proxy ${res.status}`);
  const env = await res.json();
  if (env.error) throw new Error(`Vike: ${env.error.message || env.error.code}`);
  return detailCache.set(conditionId, JSON.parse(env.result.content[0].text)).get(conditionId);
}

Two things worth pointing out:

The detail render is a fixed grid — stats row at top, two-column holders table (YES on the left, NO on the right), and a recent-trades table below with wallet handles and per-tx Polygonscan links. Resolved markets return empty holders (no live position to show), and the renderer shows a polite "This market has resolved — no live holder or trade feed available" when the upstream returns not-found.

Why this shape works

Concern Where it lives
Cohort definition ("$1k+ PnL across 5+ markets") Inside the MCP tool, opaque to the client
Aggregating per-market flow / cohort buys MCP tool, server-side
Picking which markets meet the mispricing bar MCP tool, server-side
Filter args (mode, hours, category) Client → MCP arguments
Lazy per-market drill-in Second MCP tool, called on row click, cached per condition_id
Branching the column layout Client, in renderHeader()

The client doesn't know what "smart money" means or how net inflow is computed. It calls the tool with a mode, gets back rows whose shape matches that mode, and renders them. If the cohort definition tightens tomorrow, the demo updates without a code change.

Gotchas

The whole template is 561 lines including HTML, CSS, and both MCP tool integrations. The interesting work is the cohort definition and the per-market aggregation — both server-side, both invisible to the client.