CRIUS
TutorialNode.jsPythonAPI

How to Connect an LLM to Robinhood Chain Data

Three working ways to give a language model live Robinhood Chain data: an MCP server, a tool-calling loop for any model API, and retrieval over the Crius docs.

Crius 9 min read

There are three practical ways to connect a language model to Robinhood Chain data: run a small MCP server that exposes Crius API calls as tools to any MCP-capable assistant, write a tool-calling loop against your model provider’s API, or retrieve the right documentation into the prompt (RAG) so the model writes correct API calls itself. This guide implements all three in under 100 lines each, all tested against api.crius.sh, with no dependency on a particular model vendor.

Which one to use:

Approach Best for You write
MCP server Desktop assistants, IDE agents, any MCP client A tool server (~60 lines)
Tool-calling loop Your own app or backend agent The loop and the tool runner
Docs retrieval Code generation: the model writes the integration A retriever over llms-full.txt

They combine well: a coding assistant with the MCP server for live data and the docs in context for writing code.

For the concepts behind this (which resources Crius publishes for models, and the guardrails that keep agents accurate), see Robinhood Chain API for AI agents.

1. An MCP server for Crius

The Model Context Protocol is an open standard for connecting assistants to tools and data. An MCP server declares tools with input schemas; the assistant decides when to call them. This one exposes four read-only Crius tools over stdio, using the official TypeScript SDK (tested with @modelcontextprotocol/sdk 1.32.1 and zod 4.6.5):

Terminal window
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/sdk@1.32.1 zod@4.6.5
// crius-mcp.mjs: a minimal MCP server exposing read-only Crius tools to any MCP-capable assistant.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const API = "https://api.crius.sh/v1/robinhood";
const KEY = process.env.CRIUS_API_KEY;
const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/).describe("Token or wallet address (0x…)");
// Every tool is a GET with a trimmed JSON answer; errors come back as text the model can read.
async function crius(path) {
const res = await fetch(`${API}${path}`, { headers: KEY ? { "x-api-key": KEY } : {} });
const body = await res.json();
const text = res.ok
? JSON.stringify({ data: body.data, block: body.meta?.block }).slice(0, 20_000)
: `Crius API error ${res.status}: ${body.error?.code} - ${body.error?.message}`;
return { content: [{ type: "text", text }], isError: !res.ok };
}
const server = new McpServer({ name: "crius", version: "1.0.0" });
server.registerTool("token_profile", {
description: "Profile of a Robinhood Chain token: price, market cap, liquidity, holders summary, launchpad and security summary.",
inputSchema: { address },
annotations: { readOnlyHint: true },
}, ({ address }) => crius(`/token/${address}`));
server.registerTool("token_candles", {
description: "OHLCV candles for a token (USD). Columnar arrays t, o, h, l, c, v.",
inputSchema: { address, interval: z.enum(["1m", "5m", "15m", "1h", "4h", "1d"]).default("1h"), limit: z.number().int().min(1).max(500).default(48) },
annotations: { readOnlyHint: true },
}, ({ address, interval, limit }) => crius(`/token/${address}/candles?interval=${interval}&limit=${limit}`));
server.registerTool("search_tokens", {
description: "Find Robinhood Chain tokens by name, symbol or address.",
inputSchema: { query: z.string().min(1).max(64) },
annotations: { readOnlyHint: true },
}, ({ query }) => crius(`/search?q=${encodeURIComponent(query)}&limit=10`));
server.registerTool("top_traders", {
description: "Most profitable traders of a token (average-cost PnL) over 1h, 24h, 7d or all time.",
inputSchema: { address, window: z.enum(["1h", "24h", "7d", "all"]).default("7d") },
annotations: { readOnlyHint: true },
}, ({ address, window }) => crius(`/token/${address}/traders?sort=pnl&window=${window}&limit=20`));
await server.connect(new StdioServerTransport());

Register it with your assistant. Most MCP clients use a JSON entry like this (the file name and location vary by client):

{
"mcpServers": {
"crius": {
"command": "node",
"args": ["/absolute/path/to/crius-mcp.mjs"],
"env": { "CRIUS_API_KEY": "your key" }
}
}
}

To test it without an assistant, drive it with the SDK’s own client:

// test-client.mjs: list the tools, then call them like an assistant would.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(new StdioClientTransport({ command: "node", args: ["crius-mcp.mjs"], env: { ...process.env } }));
const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name).join(", "));
const c = await client.callTool({ name: "token_candles", arguments: { address: "0xd0601ce157db5bdc3162bbac2a2c8af5320d9eec", interval: "1h", limit: 3 } });
console.log("candles:", c.content[0].text.slice(0, 160));
const bad = await client.callTool({ name: "token_profile", arguments: { address: "not-an-address" } });
console.log("validation:", bad.isError);
await client.close();
tools: token_profile, token_candles, search_tokens, top_traders
candles: {"data":{"bv":[44999.2,45814,9403.35],"c":[211.127,211.219,211.293],"currency":"usd","h":[212.304,211.659,211.653],"interval":"1h",...
validation: true

That’s NVIDIA’s tokenized stock: three hourly candles around $211. The invalid address never reaches the API; the schema rejects it first.

Design choices worth copying:

  • Narrow, read-only tools with readOnlyHint. Assistants are more willing to call them without asking, and nothing can change state.
  • Errors as text, not exceptions. “Crius API error 429: rate_limited” lets the model wait and retry; a thrown exception just ends the turn.
  • Trim results. 20 KB is plenty; whole holder lists aren’t.
  • Return block with every result so the assistant can cite it.

2. A tool-calling loop for any model API

If you control the application, run the loop yourself. Every major provider’s tool or function-calling API has the same shape: you send messages plus tool schemas, the model replies with text or with tool calls, you run the tools and send the results back. This TypeScript loop isolates the provider behind one Model function, so swapping vendors changes only that adapter:

// agent_loop.ts: a provider-neutral tool loop. Adapt `Model` to your provider's SDK.
type ToolCall = { id: string; name: string; args: Record<string, unknown> };
type Message =
| { role: "system" | "user" | "assistant"; content: string; toolCalls?: ToolCall[] }
| { role: "tool"; toolCallId: string; content: string };
type Tool = { name: string; description: string; parameters: object; run: (args: any) => Promise<unknown> };
type Model = (messages: Message[], tools: Omit<Tool, "run">[]) => Promise<{ text?: string; toolCalls?: ToolCall[] }>;
const API = "https://api.crius.sh/v1/robinhood";
const crius = async (path: string) => {
const res = await fetch(`${API}${path}`, { headers: { "x-api-key": process.env.CRIUS_API_KEY ?? "" } });
const body = await res.json();
return res.ok ? { data: body.data, block: body.meta?.block } : { error: body.error, status: res.status };
};
const tools: Tool[] = [
{ name: "search_tokens", description: "Find Robinhood Chain tokens by name, symbol or address.",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
run: ({ query }) => crius(`/search?q=${encodeURIComponent(query)}&limit=10`) },
{ name: "token_price", description: "Current USD price, liquidity and confidence of a token.",
parameters: { type: "object", properties: { address: { type: "string", pattern: "^0x[0-9a-fA-F]{40}$" } }, required: ["address"] },
run: ({ address }) => crius(`/token/${address}/price`) },
];
export async function answer(model: Model, question: string, maxSteps = 6): Promise<string> {
const messages: Message[] = [
{ role: "system", content: "You answer questions about Robinhood Chain using the tools. Cite meta.block for every number. Tickers are not unique: confirm the token address before quoting data." },
{ role: "user", content: question },
];
const schemas = tools.map(({ run, ...t }) => t);
for (let step = 0; step < maxSteps; step++) {
const reply = await model(messages, schemas);
if (!reply.toolCalls?.length) return reply.text ?? "";
messages.push({ role: "assistant", content: reply.text ?? "", toolCalls: reply.toolCalls });
for (const call of reply.toolCalls) {
const tool = tools.find((t) => t.name === call.name);
const result = tool ? await tool.run(call.args) : { error: `unknown tool ${call.name}` };
messages.push({ role: "tool", toolCallId: call.id, content: JSON.stringify(result).slice(0, 12_000) });
}
}
return "Stopped: too many steps.";
}

The Model adapter is the only provider-specific part: map Message and the tool schemas to your provider’s request format, and its response back to { text, toolCalls }. To test the plumbing without a model, a scripted stand-in that searches “TSLA”, picks the result labelled stock_tsla and prices it produced:

TSLA (tokenized) trades at $371.633 (block 96727730).

Two settings matter in production. maxSteps caps runaway loops; six is enough for most single questions. The 12 KB result limit keeps the context from filling up with JSON. For a larger tool set generated straight from the OpenAPI spec, with credit budgets, see the AI agents guide.

3. Retrieval over the docs

When the model’s job is to write an integration rather than fetch data, the best input is the relevant documentation, not live data. Crius publishes the docs in one Markdown file, llms-full.txt (about 170 KB), which is easy to chunk and search. A dependency-free BM25 retriever:

"""docs_rag.py: retrieve the right Crius docs sections for a question before the model writes code."""
import math, re, collections, requests
text = requests.get("https://crius.sh/llms-full.txt", timeout=30).text
# One chunk per section (## / ### heading), keeping the page title for context.
chunks, page = [], ""
for part in re.split(r"\n(?=#{1,3} )", text):
first = part.split("\n", 1)[0]
if first.startswith("# "):
page = first[2:].strip()
if len(part) > 80:
chunks.append((page, part[:4000]))
tok = lambda s: re.findall(r"[a-z0-9_]+", s.lower())
docs = [collections.Counter(tok(c)) for _, c in chunks]
df = collections.Counter(w for d in docs for w in d)
avg = sum(sum(d.values()) for d in docs) / len(docs)
def search(question, k=3):
"""BM25: good enough for API docs, where the exact words (endpoint names, params) matter most."""
scores = []
for i, d in enumerate(docs):
n = sum(d.values())
s = sum(math.log(1 + (len(docs) - df[w] + 0.5) / (df[w] + 0.5)) * d[w] * 2.2 / (d[w] + 1.2 * (0.25 + 0.75 * n / avg))
for w in set(tok(question)) if w in d)
scores.append((s, i))
return [chunks[i] for _, i in sorted(scores, reverse=True)[:k]]
if __name__ == "__main__":
print(f"{len(chunks)} chunks from {len(text) // 1024} KB of docs")
for page, chunk in search("who held the token when it crossed a market cap and still holds now"):
print(f"- {page}: {chunk.splitlines()[0]}")
227 chunks from 171 KB of docs
- Holders & traders: ## Holder snapshot
- Security & liquidity: ## Liquidity & locks
- Holders & traders: ## Holder cohort

The question never mentions “snapshot” or “cohort”, yet both relevant sections come back. Put the top chunks in the prompt ahead of the user’s request, and the model writes calls with the right parameters (at=mcap:…, compare_to=now) instead of inventing them.

You can swap BM25 for embeddings later; for API documentation, exact-term matching on endpoint and parameter names is usually the stronger signal. For a single known topic, skip retrieval and fetch the page’s Markdown directly, e.g. /docs/reference/holders.md.

Production checklist

  • A dedicated key for the model with read scope only, a per-key rate limit and an expiry (dashboard).
  • Address resolution first. Tickers repeat; tokenized stocks carry stock_* labels.
  • Citations. Every tool result includes block; ask the model to quote it.
  • Budgets. Read x-credits-cost from responses and cap calls per conversation.
  • Back-off. Pass 429 and 503 with Retry-After to the model as data, not errors.
  • Freshness. Data is typically under a second behind the chain (meta.lag_ms); for settled numbers, compare blocks with meta.safe_block.

Frequently asked questions

Is there an MCP server for Robinhood Chain data?

You can run the one above in a minute: it wraps Crius API calls as MCP tools for any MCP-capable assistant. Extend it with any endpoint from the API reference.

Which models does this work with?

Any model that supports tool or function calling, for the loop; any MCP client, for the server; any model at all, for retrieval. Nothing here depends on one vendor.

Should I give the model live data or documentation?

Live data (tools or MCP) when it must answer questions about the chain now; documentation (retrieval or .md pages) when it must write code against the API. Coding assistants benefit from both.

Does the API need a key for this?

Core endpoints (prices, profiles, pools, trades, candles, search, stats) work without one at 2 requests per second. Holders, analytics and streams need a key; the Free plan includes 1,000,000 credits a month.

How large is the documentation for context?

llms-full.txt is about 170 KB, too large to paste whole into most prompts but easy to chunk; the 227 sections above average well under 1 KB each.

Build with Crius

Robinhood Chain data in one API

Free plan with 1M credits a month. Your key in under a minute.

More from the blog