Documentation
BONDED is a mandate gate and reconciliation layer between an AI agent and a Binance Spot account. This page covers installation, the mandate schema, the tool contract and how to operate it.
Overview
The agent holds no Binance credential. Its entire capability is the five MCP tools below, so the constraint is structural rather than advisory. Every order is evaluated against a mandate before it is sent, and every order that reaches the exchange is reconciled against what BONDED actually authorised.
Three components, in the order an order passes through them:
| Component | Responsibility |
|---|---|
| Gate | Pure evaluation of an order against the mandate. No I/O, fails closed, names the clause that refused. |
| Audit log |
Append-only, hash-chained record of every decision, fsync'd before
the order is sent.
|
| Reconciler | Compares the exchange's order history against the audit log. Disagreement burns the bond. |
Requirements
- Node.js 22 or later (22.13+ for the binance-cli price source)
- Binance Spot Testnet API key and secret - testnet.binance.vision
- An MCP-capable agent: Claude Code, Codex CLI, Claude Desktop, Cursor, Windsurf, VS Code
No system binaries, no root, no database. Runtime state is two files on disk.
Install
git clone https://github.com/Ritapossible/Bonded
cd Bonded
npm install
npm run build
cp .env.example .env # fill in BINANCE_API_KEY and BINANCE_SECRET_KEY
openssl rand -hex 32 # -> BONDED_HMAC_SECRET in .env
mkdir -p data
cp examples/mandate.example.json data/mandate.json
npm start
The data/ directory is not in the repository - it holds runtime state, which
is deliberately not committed - so mkdir -p data comes before the copy.
BONDED creates the decision log itself; the mandate is the one file you supply.
Configuration
Copy .env.example to .env and fill it in. Configuration is
validated once at startup - a missing value fails immediately with the field named, rather
than at the first order.
| Variable | Default | Purpose |
|---|---|---|
BINANCE_API_KEY |
- | Required. Spot Testnet key. |
BINANCE_SECRET_KEY |
- |
Required. Never logged; wrapped so interpolation yields [redacted].
|
BONDED_HMAC_SECRET |
- |
Required, 32+ chars. Stamps authorised orders so a bypass is detectable. Generate
with openssl rand -hex 32.
|
BINANCE_API_ENV |
testnet |
testnet or prod. |
BONDED_ALLOW_PROD |
0 |
Must be 1 for production. A misconfiguration fails at boot. |
BONDED_ALLOW_PARTIAL_AUDIT |
0 |
Permit trading when only symbol-scoped observation is live. See Limits. |
BONDED_PRICE_SOURCE |
rest |
rest or binance-cli. The latter reads reference prices
through @binance/binance-cli, Binance's own Agent OS tooling, which
takes the same variables and supports testnet. Orders are always signed by BONDED
itself. Either source fails closed.
|
BONDED_WATCH_SYMBOLS |
empty | Extra symbols the polling backstop watches, comma separated, beyond the mandate's own. |
BINANCE_SPOT_BASE_PATH |
https://testnet.binance.vision |
REST base URL. Validated against an allowlist of Binance hostnames and must match
BINANCE_API_ENV.
|
BINANCE_STREAM_BASE_PATH |
wss://stream.testnet.binance.vision/ws |
User data stream endpoint. |
BONDED_MANDATE_PATH |
./data/mandate.json |
Mandate location. |
BONDED_DECISION_LOG_PATH |
./data/decisions.jsonl |
Hash-chained audit log. |
BONDED_LOOKBACK_MS |
86400000 |
How far back the first poll reaches, to catch orders placed while BONDED was down.
A fresh instance with an empty decision log should not inherit history it has no
records for - every order legitimately authorised in that window reads as
UNKNOWN_AUTHENTIC and burns the bond at startup.
|
BONDED_POLL_INTERVAL_MS |
15000 |
Reconciliation backstop interval. |
BONDED_CONSOLE_PORT |
7391 |
Owner console. 0 disables it. Always loopback. |
BONDED_LOG_LEVEL |
info |
debug, info, warn, error.
|
Disable withdrawals on the API key. BONDED does not implement a
withdrawal guard - it verifies the exchange enforces one, which survives BONDED being
wrong about everything else. On testnet that check cannot run, and the guard reports
WARN rather than claiming a pass.
Connect an agent
BONDED is an MCP server over stdio, so any MCP client can hold it. It is not tied to one agent - the point of the mandate is that whatever sits behind it gets the same limits. Every client below runs the same command:
node /absolute/path/to/Bonded/dist/cli.js
Use an absolute path, and pass the credentials in the config. An agent
launches this process itself, often from a desktop app with no shell and a working
directory you did not choose. BONDED reads a .env beside its own
installation as well as in the working directory, but a GUI-launched client is the case
most likely to find neither - so the env block below is the reliable route.
Anything already set in the real environment always wins over the file.
Claude Code
claude mcp add bonded -- node /absolute/path/to/Bonded/dist/cli.js
Codex CLI
~/.codex/config.toml - TOML, and the table name is plural:
[mcp_servers.bonded]
command = "node"
args = ["/absolute/path/to/Bonded/dist/cli.js"]
[mcp_servers.bonded.env]
BINANCE_API_KEY = "..."
BINANCE_SECRET_KEY = "..."
BINANCE_API_ENV = "testnet"
BONDED_HMAC_SECRET = "..."
Claude Desktop
~/Library/Application Support/Claude/claude_desktop_config.json on macOS,
%APPDATA%\Claude\claude_desktop_config.json on Windows:
{
"mcpServers": {
"bonded": {
"command": "node",
"args": ["/absolute/path/to/Bonded/dist/cli.js"],
"env": {
"BINANCE_API_KEY": "...",
"BINANCE_SECRET_KEY": "...",
"BINANCE_API_ENV": "testnet",
"BONDED_HMAC_SECRET": "..."
}
}
}
}
Cursor and Windsurf
Same JSON shape as Claude Desktop, in .cursor/mcp.json for one project or
~/.cursor/mcp.json for all of them; Windsurf reads
~/.codeium/windsurf/mcp_config.json.
VS Code
.vscode/mcp.json. Note the key is servers, not
mcpServers:
{
"servers": {
"bonded": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/Bonded/dist/cli.js"]
}
}
}
Anything else
Any client that speaks MCP over stdio works. It needs three things: the command
(node), one argument (the absolute path to dist/cli.js), and the
environment. Config file locations move between versions - if one of the paths above has
changed, check that client's own documentation for where its MCP config lives; the server
side is identical either way.
All logging goes to stderr - stdout carries the JSON-RPC frames, and a single stray line there would corrupt the transport. If a client reports the server as failed, run the command by hand first: the boot-guard banner prints the reason, and a guard that fails is a refusal to start rather than a crash.
Alongside Binance's own MCP server
BONDED does not replace Binance's MCP server. It sits next to it. Register both and the agent reads from Binance and writes through the mandate:
claude mcp add binance-mcp-server --transport http https://agent.binance.com/mcp/agentic
claude mcp add bonded -- node /absolute/path/to/Bonded/dist/cli.js
Grant Binance's server the Market data scope and withhold Trade. That division is the whole pairing: market data is public and needs no credentials, so the agent gets tickers, order books and candles from Binance directly, and every order it decides to place goes through BONDED - checked against the mandate and written to the hash-chained log before it is signed.
The two reach different accounts, and it is worth being exact about why. Binance's MCP server trades a dedicated Agentic sub-account on mainnet, over OAuth, with every trade confirmed by a human and no withdrawal scope in existence. That path is already guarded, and BONDED has nothing to add to it. BONDED guards the other path - raw API keys, no confirmation step - which is the one an unattended agent actually runs on, and the one Binance left open. Composed, the agent has Binance's market data on one side and a bound, audited account on the other.
Mandate
The complete statement of what the agent may do. It is data, not code: compiled once,
validated, grounded against live exchangeInfo, then hashed. The
mandateHash is stamped into every audit record, so a record always cites one
exact ruleset.
{
"version": 1,
"env": "testnet",
"symbols": ["BTCUSDT", "ETHUSDT"],
"orderTypes": ["LIMIT", "MARKET"],
"sides": ["BUY", "SELL"],
"maxNotionalUsd": "500",
"maxOpenOrders": 3,
"dailyLossLimitUsd": "50",
"maxDrawdownPct": "5",
"tradingWindowUtc": ["00:00", "23:59"],
"expiresAt": "2027-09-08T23:59:00.000Z"
}
All fractional values are decimal strings, matching Binance's own convention - a float loses precision that a limit check depends on. Only counts and the version are numbers.
An unrecognised key is a hard error, not an ignored field. A typo'd clause name must fail loudly: otherwise the operator believes a limit is in force while nothing enforces it.
Gate clauses
Seventeen clauses evaluated in a fixed order, plus two denials raised before them when the
gate cannot evaluate an order at all - nineteen names in total, all listed below. The four
that read nothing but the mandate and BONDED's own state (environment,
scope, auditPath, expiry) are evaluated first, so a
revoked scope is never reported as a stale snapshot. After those, the first denial wins.
Every clause lives in a single file, so the whole enforcement surface is auditable at
once.
| Clause | Denies when |
|---|---|
environment |
The mandate's env differs from the runtime env. |
scope |
Trade scope has been revoked by a finding. |
auditPath |
No order source is delivering - reconciliation is blind. |
expiry |
The mandate has expired. |
symbolAllowlist |
The symbol is outside the allowlist or unresolvable. |
symbolTradable |
The symbol is not currently trading. |
orderType |
The order type is not permitted. |
side |
The side is not permitted. |
tradingWindowUtc |
Outside the mandate's UTC window. |
accountTradingDisabled |
The exchange account cannot trade. |
maxOpenOrders |
The open-order limit is already reached. |
priceFilter |
Price breaches tick size or price bounds. |
lotSize |
Quantity breaches step size or quantity bounds. |
minNotional |
Notional is below the symbol's minimum. |
maxNotionalUsd |
Notional exceeds the mandate's maximum. |
dailyLossLimitUsd |
The day's realised loss reaches the absolute cap. |
maxDrawdownPct |
The day's realised loss reaches the proportional cap. |
referencePrice |
A base-sized market order has no live price to bound it. |
stateFreshness |
Exchange state is stale or timestamped in the future. |
Fail closed. Anything the gate cannot evaluate is a denial, never a pass. An outage must not silently convert a bounded mandate into an unbounded one.
Realised PnL
Both loss clauses compare against realised PnL computed with an
average cost basis per symbol, walking Binance's own trade history in
chronological order. A buy moves the average cost; a sell realises
(price − averageCost) × quantity. Quote-asset commission is subtracted;
base-asset commission reduces the quantity acquired.
dailyLossLimitUsd is absolute. maxDrawdownPct is the same loss
as a percentage of the account's quote-asset balance. Whichever is tighter fires first.
A position opened before the seven-day basis window has no known cost. Selling it realises an unknown amount, so that quantity is excluded and reported rather than guessed at. The figure therefore understates activity; it never overstates it.
Reconciliation
The gate only sees orders that pass through it. Anything holding the API key can go around it, and the gate is blind to that by construction - so BONDED keeps a second, independent account of reality and treats disagreement as the finding.
Every authorised order is stamped with a client order id of the form
bnd_<mandateHash8>_<seq>_<hmac>. Knowing the format is not
enough to mint one without the secret.
| Outcome | Meaning | Action |
|---|---|---|
AUTHORISED |
Executed exactly as authorised. | - |
MISMATCHED |
Authorised, but not for the order that executed. | Bond burns |
FOREIGN |
No BONDED identifier. A plain bypass. | Bond burns |
FORGED |
Wears the namespace without a valid tag. | Bond burns |
UNKNOWN_AUTHENTIC |
Valid tag, no matching record. | Bond burns |
Findings carry four things, not one:
- A plain explanation
- The verbatim exchange payload
- A Binance
orderId, checkable without trusting BONDED - An explicit list of what could not be determined
It is designed around two overlapping sources: the account-wide user data stream for near-instant detection, and a polling backstop that is a hard startup dependency. Seeing an order twice costs nothing; seeing it zero times is the failure this exists to prevent. Since February 2026 the stream is unavailable - Binance removed the listen-key endpoints and they answer 410 - so polling is currently the only source, which is why the boot banner reports coverage as DEGRADED rather than quietly carrying on.
Audit log
Every decision - allows as well as denials - is appended to a hash-chained JSONL file and
fsync'd before the order is sent. That ordering is the
safety argument: an order can never carry an authorisation a crash could erase.
Each record carries the SHA-256 of the previous record's canonical form. Altering or removing any record breaks every link after it. This does not stop an operator rewriting the whole file - that needs an external anchor - but it makes a silent edit impossible, which is the realistic threat.
Verifying a run
The demo shows a bypass being caught, and you have no reason to believe it - every frame could be staged. So the log is checkable by anyone, without running BONDED, without an API key, and without trusting whoever recorded the video. A sample log ships in the repository, written by the real writer, so the command can be tried offline:
node dist/cli.js verify examples/sample-decisions.jsonl
Holding nothing but the file, that verifies the hash chain from genesis, that sequence numbers are contiguous and that every record cites one mandate hash - and prints the exchange order ids, which are checkable against Binance independently of this project.
What a chain walk cannot do
The chain hash is unkeyed, so anyone can compute it. Walking the chain pins each record
against the one after it, which leaves the last record pinned by nothing.
Editing the final record, or appending one with a correctly computed
prevHash, therefore survives a chain walk - and both are exactly what faking
a demo would look like. The command reports them as unchecked rather than passing them off
as verified.
Closing that needs no secret, only a commitment made in advance. The head hash commits to the whole history: publish it - say it on camera, put it in a README - and pass it back.
node dist/cli.js verify examples/sample-decisions.jsonl \
--head 54aaa41cc363c850f66d81d583dbe0dbaa15587c00679e65369890b644669ee8
That is the head of the sample log, published here so the check means something. Change any byte of that file, the tail included, and the command exits non-zero.
What it still does not claim. Whether each stamped client order id is
authentic needs the HMAC secret, which is the operator's - pass --secret if
you hold it. An authentic tag covers the mandate hash and the sequence number, not the
order's symbol, side, quantity or price. And a log is a claim about what BONDED
authorised, never proof of what the exchange did.
MCP tools
place_order
Submit a Spot order for evaluation.
| Field | Type | Notes |
|---|---|---|
symbol |
string | Required. e.g. ETHUSDT |
side |
BUY | SELL |
Required. |
type |
LIMIT | MARKET |
Required. |
quantity |
string | Base asset. Required for LIMIT. |
price |
string | Required for LIMIT. |
quoteOrderQty |
string | Quote asset. MARKET only, never with quantity. |
Returns PLACED, DENIED or FAILED. A denial:
{
"status": "DENIED",
"seq": 412,
"clause": "maxNotionalUsd",
"clauseText": "Order notional must not exceed the mandate's maximum.",
"observed": "900",
"limit": "500"
}
A denial is a successful tool call, not an error. Three rules:
- Do not retry the same order. The mandate will not change between attempts.
-
Read
clauseand adjust. It names exactly what to change. -
clause: "scope"means stop. The bond has burned. Report it rather than working around it.
check_order
Same input as place_order. Evaluates without contacting the exchange and
without consuming an audit-log entry. Returns WOULD_ALLOW or
WOULD_DENY.
cancel_order
Takes symbol and clientOrderId. Never refused.
Every clause exists to limit exposure and cancelling only reduces it - a gate able to
refuse a cancellation could trap an agent in a position it is not allowed to close.
get_mandate_summary
Returns the mandate hash, expiry, clause names and scope status. Thresholds are deliberately withheld: an agent that can read its limits can shape its behaviour to sit exactly inside them. The agent learns the rules by being refused, and each refusal reports the limit for that clause.
get_account
Balances and open-order count as most recently observed, with the observation timestamp so staleness is visible.
Boot guards
BONDED refuses to start unless the guarantees it advertises are in place, and prints what
each guard actually verified. A guard that cannot perform its check reports
WARN and says what it checked instead - it never claims a pass it did not
earn.
BONDED boot guards
[PASS] environment testnet confirmed, base URL https://testnet.binance.vision
[PASS] mandate mandate 8b80eb8e valid until 2027-09-08T23:59:00.000Z
[WARN] withdrawalPermission not verified: apiRestrictions is unavailable on Spot Testnet
[PASS] decisionLog chain verified over 128 records, head 4f2ab910
[PASS] clockSkew clock within 42 ms of exchange
[PASS] symbolGrounding 2 symbols resolved from exchangeInfo
[PASS] authorisationIndex 128 prior authorisations replayed
[PASS] auditPath order history reconciled every 15000 ms
Console
A read-only owner view at http://127.0.0.1:7391, pushed over SSE. Shows the
bond state, the mandate's thresholds, reconciliation counters, findings and a live
decision feed.
-
Loopback only. Never
0.0.0.0- the payload carries balances and order history. -
Read-only. Every method but
GETreturns 405, so a compromised tab cannot become a trading capability. - No credentials reach the view model.
Unlike the agent's view, the console does show thresholds. The owner wrote them; hiding them would be theatre.
Exit codes
| Code | Meaning |
|---|---|
0 |
Clean shutdown. |
1 |
A boot guard failed - the banner names which. From
bonded verify: the log did not verify, or a pinned head did not
match.
|
70 |
Uncaught exception or unhandled rejection. The audit log is flushed first. |
78 |
Invalid configuration, with the failing fields listed. From
bonded verify: a missing or malformed argument.
|
Limits
Every one of these is a real weakness. They are here because a security design that does not name its own limits has not been examined.
Spot only - no futures, no margin
Every endpoint BONDED calls is /api/v3/*. There is no fapi, no
dapi and no margin surface in the code. A mandate cannot express a leverage
cap or a position side, because BONDED has no concept of either.
A scope decision rather than an omission, and extending it is not a configuration change. The gate's clauses are Spot-shaped - notional, lot size, tick size, open orders, realised PnL from trade history. Futures needs a different clause set: leverage, liquidation distance, funding, reduce-only, position side. Reconciliation would also have to account for position changes that no order explains, such as a liquidation.
The guarantee rests on key custody
BONDED is not a TEE and not a ZK circuit. It holds the credential and the agent does not, which makes the constraint structural - but compromise the host and that constraint is gone.
Reconciliation detects; it does not prevent
A bypass order reaches the exchange and fills before BONDED sees it. What is guaranteed is that it will not go unnoticed: attributed, bond burned, scope revoked.
It audits compliance, not quality
The gate can prove an order was inside the mandate. It has no opinion on whether the trade was sensible, and cannot acquire one.
The withdrawal guarantee is not verifiable on testnet
GET /sapi/v1/account/apiRestrictions does not exist on Spot Testnet, so the
guard reports WARN and states that it asserted the environment instead.
The gate is only as good as the mandate
A mandate that compiles to weaker rules than intended fails silently. The mitigations are human: clauses render as numbered text before anything runs, and an unrecognised clause name is a hard error.
Testnet fills are not real fills
Testnet liquidity does not resemble production. No PnL figure produced there means anything.
Detection is only as wide as the stream
GET /api/v3/allOrders requires a symbol - there is no account-wide REST
listing - so the polling backstop only ever sees the symbols it was given. The user data
stream is the only account-wide source, and with it down an order on a symbol the mandate
never named cannot be observed at all.
BONDED treats that as a loss of the audit path rather than a degraded one: without an
account-wide source, trading stops. BONDED_ALLOW_PARTIAL_AUDIT=1 accepts
symbol-scoped coverage and keeps trading, and the boot banner says which is in force.
BONDED_WATCH_SYMBOLS widens the poller beyond the mandate's own symbols.
As of 2026-09-06 this is not hypothetical. Binance removed the
listen-key REST endpoints in February 2026:
POST /api/v3/userDataStream answers 410 Gone on Spot
Testnet, verified on a real machine. The account-wide source is unavailable, so BONDED
refuses to start unless BONDED_ALLOW_PARTIAL_AUDIT=1 says symbol-scoped
coverage is acceptable - the guard behaving as designed rather than trading with less
observation than it claims. A removed endpoint is recognised as permanent: reported once
on the banner, never retried.
The replacement is POST /sapi/v1/userListenToken plus
userDataStream.subscribe.listenToken over the WebSocket API, and it is not
implemented here. Until it is, run with BONDED_ALLOW_PARTIAL_AUDIT=1 and a
short BONDED_POLL_INTERVAL_MS (3000 works): detection then comes from
polling the mandate's symbols rather than from the stream.
Aggregate limits bind against a snapshot, not the exchange's clock
The order path is serialised, and orders placed since the current account observation
count toward maxOpenOrders, so a burst cannot slip several orders through one
stale count. That closes the concurrency hole; it does not make the caps instantaneous.
The loss limits compare against realised PnL refreshed on a 30-second budget. A loss is realised when a position closes, and BONDED learns of it at the next refresh, so a fast sequence of losing trades can breach a limit and keep trading until that refresh sees it. This is a lag rather than a hole - the cap binds as soon as the loss is observable - and it is inherent to enforcing a limit against an exchange that fills orders without asking. Open positions are not counted at all; that is what "realised" means.
Why this runs on testnet, deliberately
Not a limitation worked around - a choice, and it would be the same choice with more time. The demo places an order that defeats a safety control. That is the whole argument: an order goes around BONDED, fills, and is caught. On mainnet that is real money being deliberately misused on camera, repeatedly, across takes. A security tool whose demonstration requires misusing a funded account has not thought about what it is asking its audience to do.
The code enforces this rather than trusting the operator to remember. BONDED refuses to
start against production unless BONDED_ALLOW_PROD=1 is set explicitly, and
the two scripts that exist to defeat it - scripts/bypass-order.mjs and
scripts/redteam.mjs - refuse production
with no override at all.
It is also the only place the argument can be made. Binance's hosted MCP server has no
testnet: it trades a funded Agentic sub-account on mainnet, over OAuth, with a human
confirming every trade. That path is already guarded. The path BONDED guards - raw API
keys, no confirmation, what an unattended agent actually runs on - is the one with a
testnet, and binance-cli supports testnet alongside
prod and demo for exactly that reason.
What has and has not been run live. The full sequence - allow, refuse,
bypass, detect, revoke - has run against Binance Spot Testnet from a real machine. An
agent's 900 USD order was refused by maxNotionalUsd; a 450 USD order
filled, stamped with a BONDED client id; a forged id and a raw API order both filled and
were then classified FORGED and FOREIGN, burning the bond;
every order after that was refused by scope.
npm run redteam reports eight of eight held.
Those live runs are also what found the removed listen-key endpoint, a clock-skew guard whose symmetric limit passed a clock Binance rejects, and a startup lookback that made a fresh instance burn its own bond on the operator's earlier orders.
Not run: anything on mainnet, deliberately. The
withdrawalPermission guard, which needs an endpoint testnet does not have.
The daily-loss and drawdown clauses, which no live run has yet lost enough to trip.
Multi-day operation. And testnet fills are simulated - a fill there says the request was
well-formed and accepted, not that it would have found a counterparty on a real book.
Development
npm run check # typecheck, lint, format, tests
npm test # 338 tests
npm run build # compile to dist/
The gate is a pure function, so it is tested exhaustively without mocks. The Binance
client is tested against a scripted fetch, including that the signature
covers exactly the string sent and that order placement is never retried. CI runs the full
gate plus a check that the published entry point resolves.