Claude decides, the Hyperliquid SDK executes, an indexed feed supplies the market. Plus the three ways the data will quietly lie to your agent, all of which I hit.
An agent that trades your own account is easy. Fifty lines, one SDK, done.
An agent that trades your account based on what the rest of the market is doing is a different build, and on most exchanges it is impossible, because the exchange never tells you what the rest of the market is doing at the grain you would need.
Hyperliquid is the exception, and it is the reason to build this here rather than on Binance or on any of the perp venues competing with it. The order book runs on its own L1. Every placement, cancel, modify and fill is a signed action sitting in a block, with the wallet attached. Your agent can see who is quoting, who just got liquidated, and how much of the book is real, because all of it is on chain.
Getting at it takes more work than a websocket subscribe message. Here is the whole build.
Upfront: I work on developer content at Bitquery, and Bitquery sells the indexed Hyperliquid feed used for the read path below. The write path is Hyperliquid’s own free SDK, and I will be specific about where the free native API is the better choice.
The architecture: three paths, three tools
The instinct is to use one API for everything. That is the first mistake, because reading the market and writing to your account are different problems with different best answers.
PathWhat it doesWhat serves it bestWritePlace, cancel and modify your own ordersHyperliquid’s native SDK. Closest to the matching engine, free, canonical.Read (own account)Your positions, fills, marginHyperliquid’s native Info API. Same reason.Read (the market)Who else is positioned, quoting, blowing upAn indexed feed. The native API cannot serve this.DecideTurn the above into an order or a decision to sit stillClaude, with the other three wired in as tools
That third row is the one people get wrong, so it is worth being precise about why.
Hyperliquid’s public websocket gives you l2Book, which is size totalled per price level, up to 20 levels a side. Forty BTC rests at $95,000 and the feed cannot tell you whether that is one order or twenty, whose it is, or whether it got pulled rather than filled. Order-level detail does exist in the native API through orderUpdates and userFills, but only for your own account. Liquidations are the same story: userEvents reports them for one address you already know about, and there is no exchange-wide liquidation feed at all.
So if your agent’s job is “react to what other people are doing”, the native API cannot feed it. You need someone to have indexed the chain. That is the read path below.
The write path
Start here. It is the part that can lose money, and the part to get familiar with first.
The write path is the hyperliquid-python-sdk, Hyperliquid’s own client.
pip install hyperliquid-python-sdk anthropic eth-account requestsimport os, time
from eth_account import Account
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
from hyperliquid.utils import constants
from hyperliquid.utils.types import CloidBASE_URL = constants.TESTNET_API_URL # change this last, and deliberatelywallet = Account.from_key(os.environ[“HL_SECRET_KEY”])
address = os.environ[“HL_ACCOUNT_ADDRESS”]exchange = Exchange(wallet, BASE_URL, account_address=address)
info = Info(BASE_URL, skip_ws=True)
The order call is positional and easy to get backwards, so here it is spelled out:
# exchange.order(name, is_buy, sz, limit_px, order_type, reduce_only=False, cloid=None)
result = exchange.order(
“ETH”, True, 0.2, 1100.0,
{“limit”: {“tif”: “Alo”}},
cloid=Cloid.from_int(1734029481),
)
Three things in that call matter more than they look.
{“limit”: {“tif”: “Alo”}} is post-only. The order is rejected outright if it would cross the spread and take liquidity. For an agent this is the safest default you have, because the worst case of a mispriced quote becomes a rejection instead of a fill at a price you did not intend. Use Gtc when you actually want to rest and cross, Ioc when you want fill-or-kill behaviour.
cloid is your idempotency key, and it is what stops a retry after a network timeout from double-submitting. Derive it from the decision itself:
import hashlib
from hyperliquid.utils.types import Cloiddef decision_cloid(*parts) -> Cloid:
“””Stable 16-byte client order id derived from the decision.”””
key = “|”.join(str(p) for p in parts).encode()
return Cloid.from_str(“0x” + hashlib.sha256(key).hexdigest()[:32])
Do not reach for Python’s built-in hash() for this, which is the mistake I made first. It is salted per process, so the same decision hashes to a different id after every restart, which is the one property an idempotency key cannot have. An agent loop without a stable cloid will place the same order twice sooner or later, and you will find out during a fast market.
And reduce_only=True is worth wiring into any tool whose job is to close rather than open. It is a cheap way to stop “flatten the position” from opening a new one the other way.
Cancels come in both flavours, which is why the cloid pays off:
exchange.cancel(“ETH”, oid) # by exchange order id
exchange.cancel_by_cloid(“ETH”, cloid) # by your own id
The read path
The read tools hit an indexed copy of the chain over GraphQL. The technique is the same one I used to track bonding curves and graduations on Pump.fun, just pointed at a different chain. The useful property is that the same document works as a query and as a live stream: change query to subscription, drop limit and orderBy, point it at the websocket endpoint, and it pushes.
Here is the whole exchange’s fill flow (Trades cube reference), which is the feed you would run in a separate process to keep a market picture warm:
subscription {
Hyperliquid {
Trades {
Block { Time }
Trade {
Market { Symbol CoinRaw Kind }
Execution { Price Size Side Direction IsAggressor Oid }
Fees { Fee FeeToken }
Position { Leverage IsCross SizeBefore }
Trader { Address }
}
}
}
}
No coin filter, so one subscription carries every market. A message looks like this:
{
“Block”: { “Time”: “2026-09-04T11:19:51.137023Z” },
“Trade”: {
“Market”: { “Symbol”: “ASTER”, “CoinRaw”: “ASTER”, “Kind”: “perp” },
“Execution”: {
“Price”: “0.75677”, “Size”: “175.0”, “Side”: “Sell”,
“Direction”: “Open Short”, “IsAggressor”: true, “Oid”: “535941127746”
},
“Fees”: { “Fee”: “0.01907”, “FeeToken”: “USDC” },
“Position”: { “Leverage”: 5, “IsCross”: true, “SizeBefore”: “-175858.0” },
“Trader”: { “Address”: “0xa33a4a057334c7811ad5f45f3c4f0dfa3d081ff8” }
}
}
Two fields there are worth handing to a model. Direction arrives resolved to Open Short, so the agent is not inferring intent from side plus position state. SizeBefore says the wallet was already short 175,858 ASTER before this fill, which is the difference between “someone sold” and “a large short added”. A negative Fees.Fee is a maker rebate, which is a cheap way to separate passive flow from aggressive.
For book data the cube to know about is BookUpdates, which is market-by-order rather than aggregated. One message is one order, carrying its Oid and the Trader.Address that placed it. Oid joins across the schema: the same id appears on Orders as the lifecycle and on Trade.Execution.Oid when it fills, so a single order can be followed end to end. Filter it to one address and you are watching a specific market maker quote and pull in real time (worked examples), which is not something a centralised venue will sell you at any price.
Wiring the tools
Claude gets read tools that hit the feed and exactly one write tool that touches the exchange.
import requests
from anthropic import Anthropic, beta_toolclient = Anthropic()
BQ_URL = “https://streaming.bitquery.io/graphql”
BQ_AUTH = {“Authorization”: f”Bearer {os.environ[‘BITQUERY_TOKEN’]}”}
ALLOWED_MARKETS = {“BTC”, “ETH”}def bq(query: str, variables: dict) -> dict:
r = requests.post(BQ_URL, headers=BQ_AUTH,
json={“query”: query, “variables”: variables}, timeout=30)
r.raise_for_status()
payload = r.json()
if “errors” in payload:
raise RuntimeError(payload[“errors”][0][“message”])
return payload[“data”][“Hyperliquid”]
The liquidation read tool:
@beta_tool
def recent_liquidations(symbol: str, minutes: int = 60) -> str:
“””Count Hyperliquid liquidations on one market over a recent window. Returns distinct liquidation events, the wallets hit, and the raw fill
count. Prefer the liquidation count over the fill count. Args:
symbol: Market symbol. Must be BTC or ETH.
minutes: Lookback in minutes, 1 to 60.
“””
if symbol not in ALLOWED_MARKETS:
return f”refused: {symbol} is not in the allowlist”
minutes = max(1, min(int(minutes), 60)) query = “””
query ($sym: String!, $mins: Int!) {
Hyperliquid {
PerpLiquidations(where: {
Liquidation: {Market: {Symbol: {is: $sym}}}
Block: {Time: {since_relative: {minutes_ago: $mins}}}
}) {
fills: count
liquidations: count(distinct: Liquidation_Execution_Hash)
wallets: count(distinct: Liquidation_LiquidatedUser)
}
}
}
“””
rows = bq(query, {“sym”: symbol, “mins”: minutes})[“PerpLiquidations”]
if not rows:
return f”{symbol}: 0 liquidations in the last {minutes}m”
r = rows[0]
return (f”{symbol}: {r[‘liquidations’]} liquidations hitting “
f”{r[‘wallets’]} wallets in the last {minutes}m “
f”({r[‘fills’]} individual fills)”)
Note the return value is a sentence, not a JSON dump. Tool results are input tokens on every subsequent turn of the loop, and a compact string the model reads correctly beats a nested object it has to parse and might misread.
The write tool is where the care goes:
MAX_NOTIONAL_USD = 250.0@beta_tool
def place_post_only_order(symbol: str, is_buy: bool, size: float,
limit_price: float, reason: str) -> str:
“””Place one post-only limit order on Hyperliquid. Post-only means the exchange rejects the order outright if it would
cross the spread. Rejection is normal and expected, not an error. Args:
symbol: Market symbol. Must be BTC or ETH.
is_buy: True to bid, False to offer.
size: Contracts. Notional is capped server-side by this tool.
limit_price: Limit price in USD.
reason: One sentence on why, recorded in the audit log.
“””
if symbol not in ALLOWED_MARKETS:
return f”refused: {symbol} is not in the allowlist”
notional = size * limit_price
if notional > MAX_NOTIONAL_USD:
return (f”refused: ${notional:,.0f} notional exceeds “
f”the ${MAX_NOTIONAL_USD:,.0f} cap”) cloid = decision_cloid(symbol, is_buy, round(limit_price, 2),
int(time.time() // 60))
audit.write(symbol, is_buy, size, limit_price, reason, str(cloid)) result = exchange.order(symbol, is_buy, size, limit_price,
{“limit”: {“tif”: “Alo”}}, cloid=cloid)
if result.get(“status”) != “ok”:
return f”exchange rejected the request: {result}” status = result[“response”][“data”][“statuses”][0]
if “resting” in status:
return f”resting on the book, oid {status[‘resting’][‘oid’]}”
if “filled” in status:
return f”filled immediately: {status[‘filled’]}”
return f”not resting, no fill: {status}”
Two decisions in there carry the weight.
The allowlist and the notional cap are Python, not prompt text. A model asked politely to stay under a cap will stay under it nearly every time, and nearly every time is not a risk control. Anything you would be unhappy to see violated once belongs in an if that runs before the order does.
And the tool reports back which of three things happened: resting, filled, or neither. That distinction is not cosmetic, for a reason the next section gets to.
The reason argument is doing quiet work too. Requiring the model to state why, in the same call that places the order, gives you an audit log that explains itself six weeks later, and it costs one extra field.
The loop
You do not have to write the agent loop. The SDK’s tool runner drives the call, execute and continue cycle:
DESK_RULES = “””You watch two Hyperliquid perp markets and quote passively.Doing nothing is a valid and common answer, and most runs should end that way.
Never chase price. Place at most one order per run.
A post-only rejection means your price crossed the spread. Do not resubmit it
at a crossing price; either move the price passive or stand down.
Liquidation counts are events, not fills. Do not treat a fill count as activity.”””runner = client.beta.messages.tool_runner(
model=”claude-opus-5″,
max_tokens=16000,
thinking={“type”: “adaptive”},
output_config={“effort”: “high”},
system=[{
“type”: “text”,
“text”: DESK_RULES,
“cache_control”: {“type”: “ephemeral”},
}],
tools=[recent_liquidations, open_position, place_post_only_order],
messages=[{“role”: “user”, “content”:
“Check BTC. If liquidations are elevated versus a normal hour, consider “
“quoting passively on the side that just got run over. Otherwise do nothing.”
}],
)for message in runner:
log(message)
thinking={“type”: “adaptive”} lets the model decide how much reasoning a given run deserves, which matters when most runs should end in “nothing to do here”. The cache_control block matters because the rules and tool schemas get resent every turn, and cached reads bill at roughly a tenth of the input rate.
Rough cost. Claude Opus 5 is $5 per million input tokens and $25 per million output. A run that reads about 2,000 input tokens and writes about 1,500 comes to roughly five cents. On a five-minute cadence that is 288 runs a day and roughly thirteen dollars, before caching brings the input side down. That number is worth computing for your own cadence before you leave anything running, because the cost of an agent that thinks every minute is not obvious until the invoice arrives.
Three ways the data will lie to your agent
Every one of these cost me a wrong number before I caught it, and each one produces a plausible wrong answer rather than an error, which is the dangerous kind.
It thinks one liquidation is sixteen
Counting rows on the liquidation feed overstates activity, badly. In one recent hour:
fills: 127
liquidations: 33
wallets: 33
markets: 11
A single XPL position unwind produced 16 rows, all in one block, all sharing one execution hash:
11:27:28.537 Buy size= 5010.0 px=0.10143
11:27:28.537 Buy size= 490.0 px=0.10142
11:27:28.537 Buy size= 11059.0 px=0.10149
11:27:28.537 Buy size= 28173.0 px=0.10160
… (12 more)
One forced unwind ate 16 resting orders at 16 prices, and the feed gives you one row per fill because that is what happened on chain. An agent told “127 liquidations” when the real number is 33 will read a calm hour as a cascade and quote into it.
Count distinct execution hashes:
fills: count
liquidations: count(distinct: Liquidation_Execution_Hash)
wallets: count(distinct: Liquidation_LiquidatedUser)
Fix it at the tool boundary where you can see it. A model handed a number labelled count will reason confidently about the wrong quantity and will not flag that it is confused.
It thinks its quote is resting when it was rejected
Count BTC order events by status over ten minutes and the shape is startling:
badAloPxRejected 1,848,618 83.6%
open 150,206 6.8%
canceled 131,269 5.9%
perpMarginRejected 43,063 1.9%
iocCancelRejected 20,579 0.9%
tooManyOpenOrdersRejected 14,775 0.7%
filled 1,608 0.1%
TOTAL 2,210,732
Eighty-four percent of everything that happens to a BTC order is badAloPxRejected, and one tenth of one percent is a fill. Checking what those rejected orders were, every one is a post-only limit order, split near evenly between buys and sells:
Limit Buy Tif=Alo 478,047
Limit Sell Tif=Alo 431,349
That is the quoting race on the most liquid market on the exchange: market makers trying to post at the touch, losing, and getting bounced. Two million of those in ten minutes. ETH is the same shape, 78.6% rejected and 0.06% filled.
Your agent is posting Alo orders into exactly that. Rejection is the normal outcome, not the exception, which is why the write tool above distinguishes resting from filled from neither. An agent that assumes its quote is live when the matching engine bounced it will keep reasoning about a position it does not have, and will hedge or size against a phantom.
It also breaks any activity metric you build. If you compute a cancel-to-fill ratio from a bare event count, 84% of your denominator on BTC never reached the book.
It trades the wrong BTC
HIP-3 lets outside builders deploy their own perp markets on Hyperliquid, under a namespace prefix, trading in the same infrastructure. A lot of them are tokenized equities, which is the same land grab Arcus is running at the dYdX team. There are currently 279 live across 10 deployers, the largest being xyz with 119 markets, then para with 33 and hyna with 25.
Query mark prices filtered to the symbol BTC:
flx:BTC 91470.2
hyna:BTC 76888.0
cash:BTC 70000.0
Three builders, three markets called BTC, three prices more than twenty thousand dollars apart, each on its own oracle. If your ingestion keys on Symbol, an agent can read a price from one market and send an order to another. Key on CoinRaw, which carries the full namespace:symbol identifier.
No data provider invented this. It falls out of permissionless market listing, and it will bite anyone who assumes symbols are unique.
State between runs
An agent that only reads the market and never reads itself will drift. Two things need reconciling at the top of every run.
The real position, from the native API rather than from memory:
@beta_tool
def open_position(symbol: str) -> str:
“””Report the agent’s actual open position on one market. Args:
symbol: Market symbol. Must be BTC or ETH.
“””
state = info.user_state(address)
for entry in state[“assetPositions”]:
p = entry[“position”]
if p[“coin”] == symbol:
return (f”{symbol}: size {p[‘szi’]}, entry {p.get(‘entryPx’)}, “
f”unrealized {p[‘unrealizedPnl’]}”)
return f”{symbol}: flat”
And the resting orders, so the agent does not stack five quotes across five runs because each run forgot the last. info.open_orders(address) covers this, and a cheap policy that works well is to cancel everything the agent placed at the start of a run and requote from a clean book.
Feed both in as tools rather than as prompt text. The model then reads current state at the moment it needs it, instead of trusting a snapshot you pasted in at the top of the turn that may already be stale.
Running it without losing money
constants.TESTNET_API_URL is not decoration. Moving off it should be a separate, deliberate commit made after the thing has run for a couple of weeks and surprised you at least once.
Some specifics that are worth more than a paragraph of general caution.
Expect it to do nothing. Exchange-wide, Hyperliquid liquidates in the low tens of positions an hour, and BTC alone can go four hours without a single one. An agent gated on BTC liquidations will correctly sit still on most runs. That is the right way round to test it: watch it decline to act on a quiet market before you point it at a busy one.
Keep the kill switch outside the process. A supervisor you can kill -9, or an exchange-side cancel-all you can fire by hand, beats any instruction in a system prompt. The system prompt is guidance. The process boundary is a guarantee.
Log the tool calls, not just the outcome. An agent that placed a strange order is only debuggable if you can replay what it saw when it decided. Arguments and results, every call, including the refusals from your own guardrails, since a spike in refusals is the earliest signal that the reasoning has gone somewhere odd.
Cap what one run can do, not just one order. The notional cap above limits a single order. A run that places one order twenty times is still within that cap and nowhere near safe.
Where this approach is weaker than the alternatives, plainly. The indexed feed sits behind the matching engine by an indexing step, so anything reacting in single-digit milliseconds belongs on the native websocket instead. The GraphQL window is a rolling thirty days or so, which covers live trading and recent-history checks but not a multi-year backtest. And the highest-volume cubes, Orders and BookUpdates, run to hundreds of millions of rows a day on a busy market, so filtered scans over long windows time out; keep interactive windows to an hour and accumulate anything longer in your own store.
What this is and is not
This is plumbing. Nothing above tells you what to trade or suggests you should, and a language model wired to a market data feed is not an edge. It is a way to act on one you already have, and equally a way to act on a bad idea faster than you could by hand.
What Hyperliquid genuinely changes is the input. On a centralised venue your agent reasons about price and its own fills, because that is all the exchange will sell you. Here it can reason about who is positioned where, which quotes are real, and who just got carried out, because the book is on a public chain and the wallet is attached to every order.
The reasoning layer is the easy part now. Getting clean, correctly counted market state into it is the work, and three of the traps are above.
Docs for the read-path queries: Hyperliquid API on Bitquery. The native API and SDK: hyperliquid.gitbook.io. Every figure was pulled live on 4 September 2026 and will have moved by the time you read this.
Disclosure: I work on developer content at Bitquery, which sells the indexed feed used for the read path. The write path is Hyperliquid’s own free SDK, and the sections on latency, history depth and query limits are there because they are real constraints.
Building an AI Crypto Trading Bot on Hyperliquid was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.
