The bot protocol
A code bot trades only through the Candle Harness’s core, over the same socket and JSON-RPC as the attach protocol, with a bot token of its own. This page is the reference for that conversation. Most bots never speak it directly: harness-client.ts, which the Build tab writes into your bots folder, does it for you, and its comments document every call. Read this to write a client in another language, or to see what the core holds a bot to.
The rules on a bot’s trades are the core’s, whatever the bot sends it: its card’s figures, its mode, wallet, agent and slippage bound, the wallet’s setting, its agent’s limits, the kill switch and the pace that keeps clear of Candle’s breaker. Nothing on this page lets a bot change any of them. They hold what goes through the core: a bot is a program its owner runs, and the core doesn’t confine it (Code bots).
The client
Section titled “The client”import { HarnessClient } from "../harness-client"
const harness = await HarnessClient.connect()const bought = await harness.buy({ mint, amountUsd: 2, exits: { stop_loss_pct: 30, take_profit_pct: 80 } })if (bought.state === "filled") console.log(bought.words)await harness.sell({ position: bought.position ?? "", percent: 100 })await harness.report({ status: "watching 38 tokens", health: "ok" })await harness.event("needs-a-look", "spread above 8%")harness.on("kill", (told) => console.log(told.words))One file, MIT, no dependencies, for Bun or Node 20 and later. It:
- finds its socket and token in
CANDLE_HARNESS_SOCKETandCANDLE_HARNESS_BOT_TOKEN, which the core sets when it runs the bot; for a bot run elsewhere, in.harness-bot.jsonin the folder it runs from (or the fileCANDLE_HARNESS_BOT_FILEnames), whichcandle-harness bots token <name>writes; - makes each trade’s request id, and waits for how the trade ended (90 seconds by default;
{ wait: false }answers at once); - refuses to trade while it isn’t connected (
NotConnected), and reconnects on its own, from half a second to 30 seconds apart; - asks a lost answer again under the same id once it’s back, so nothing is sent twice. Still lost after 2 minutes, it throws
AnswerLostwith the id; - stops trying once the core says its token will never connect (
UNAUTHENTICATED: the bot was removed or its token replaced); - throws
HarnessRefusedwith the core’scodeand words for anything refused.
| Call | Method |
|---|---|
HarnessClient.connect(options?) | core.hello, then subscribe {topics: ["bot"]} |
buy({mint, amountUsd, exits?, request?}) | bot.buy |
sell({position? | mint?, percent? | amount?, request?}) | bot.sell |
swap({from, to, amount, request?}) | bot.swap |
request(id) | bot.request |
setExits({position? | mint?}, exits | null) | bot.exits |
self() | bot.self |
positions(), fills(limit?) | bot.positions, bot.fills |
market(mint, chain?) | bot.market |
hacc(tool, args) | bot.hacc |
signals(handler, filter?) | subscribe {topics: ["signals"]} |
report(report) | bot.report |
event(word, text?) | bot.event |
on(kind, listener) | the bot topic |
Connecting
Section titled “Connecting”The socket, the message format and the error codes are the attach protocol’s (Transport). The first message is the hello, with the bot’s token:
{ "jsonrpc": "2.0", "id": 0, "method": "core.hello", "params": { "protocol": "2.0", "token": "chb_…", "clientName": "code bot" } }{ "protocol": "2.0", "coreVersion": "0.5.2", "clientId": "c_…", "role": "bot", "agent": "agent-a", "bot": "momentum" }The answer names only the bot and its agent: never the core’s other agents, the account or its mode.
The token (chb_<id>_<secret>) is the bot’s alone. The core keeps only its hash, and issues a new one each time it starts the bot, or when candle-harness bots token writes one: each new one replaces the last and ends its connection, so an earlier copy of the bot can’t trade through the core. The token also ends, with its connection, when the bot’s process is stopped, exits cleanly or is given up on after crashes, and when a new folder or command is approved for a bot run elsewhere; not on a restart, nor when the core stops. A hello is refused for a bot that isn’t approved (UNKNOWN_BOT), whose agent is gone (AGENT_GONE), or on Free (BOTS_NEED_PLAN). A bot can reach only the methods below and the bot and signals topics; anything else is FORBIDDEN_FOR_ROLE, and journalled.
Trading
Section titled “Trading”Every trade carries its own request id: 8 to 64 of A-Z, a-z, 0-9, _ and -, made by the bot. The core records it before anything is proposed. The same id asked again answers what became of it, never a second trade, even when it’s asked again while the first is still being recorded; two different ids are two trades. The core keeps each request’s detail for today, and the newest 500 ended ones from before: an older id answers state: "ended" from bot.request (the Journal has what became of it), and using it for a new trade is refused REQUEST_SPENT. A removed bot’s ids aren’t remembered for a new bot saved under its name, which also starts its day at zero. A request never names its mode, wallet, slippage or agent: one that names any of them (or a client id) is refused RUN_SETTINGS_FIXED.
| Method | Params | |
|---|---|---|
bot.buy | {request, mint, amountUsd, exits?} | Sized in dollars. exits are held by the core on the position it opens (below) |
bot.sell | {request, position | mint, percent | amount} | Its own only. mint sells its oldest open position in that token. percent is a whole number from 1 to 100, amount a number of tokens; neither sells it all. More than it holds sells what it holds, and says so |
bot.swap | {request, from, to, amount} | Between base assets (SOL, USDC, CNDL, ETH, USDG), amount in from. Live only |
bot.request | {request} | What became of it; state: "unknown" when the core never got it, so nothing was sent; state: "ended" for an id from before today whose detail is no longer kept (it may have filled) |
Each answers a trade’s state:
{ "request": "req_…", "side": "buy", "state": "filled", "words": "bought $2.00 of BONKY", "mint": "…", "usd": 2, "position": "…", "fill": { "side": "buy", "tokens": "…", "usd": 2, "at": "…" } }state is pending, waiting (a card for the person), sent, filled, refused, rejected, withdrawn, expired, failed or lost (recorded but never sent before the core stopped; said once). The core sends each change on the bot topic too.
Refused before anything is recorded (an RPC error, and the id stays free to use again):
| Code | Why |
|---|---|
FIGURE_TRADE, FIGURE_DAY, FIGURE_OPEN | Over its trade’s figure, its day’s (buys and swaps since local midnight) or open at most |
RUN_SETTINGS_FIXED | The request named a run setting |
PAUSED | The kill switch is on: buys and swaps are held, sells go |
BUYS_HELD | Paused, or held by the plan or a hand edit of bots.toml |
SELLS_HELD | Its sells don’t run now (it’s stopped) |
BREAKER_PACE, ROUTE_FAILING | The pace that keeps clear of Candle’s breaker, or a route Candle says is failing; data.retryAt says when |
FAILED_SELLS | A sell of this token is still under way on the key (one at a time), or two sells of it failed: the next waits, after a failure for the person’s retry |
SELL_UNDER_WAY | A sell of this position is under way already: the bot’s own under another id, or an exit the core holds. One sell of a position at a time |
REQUEST_SPENT | The id ended before today and is no longer kept: a new trade takes a new id |
NOT_ITS_POSITION, NOTHING_TO_SELL, POSITION_CLOSED | Not its own, nothing of it, or already sold |
TOO_SMALL, AMOUNT_UNKNOWN, HOLDING_UNREAD | Too little to sell; sell by percent; the wallet’s balance couldn’t be read |
BASE_ASSET, NO_PAPER_SWAPS, UNVALUED | A base asset can’t be bought, only swapped; swaps have no paper mode; a swap the core couldn’t value |
UNKNOWN_BOT | It isn’t approved in bots.toml now |
Refused after it’s recorded (its agent’s limits, a card rejected or expiring, Candle’s answer) ends as an outcome kept under the id. A buy asked for while the same buy waits on a card under another id ends refused, with SAME_TRADE_UNDER_WAY, and holds no slot and none of the day. Two identical buys asked at the same instant are two trades.
By side: its buys and swaps follow the wallet (a receipt inside the wallet’s figures, a card over them or on a wallet that asks, waiting 60 seconds); its sells of its own run with no card on any wallet.
Exits the core holds
Section titled “Exits the core holds”{ "take_profit_pct": 80, "stop_loss_pct": 30, "max_hold_hours": 24, "trailing": { "arm_multiple": 1.5, "drop_pct": 20 } }At least one. Percents are of what the position cost a token, Candle’s fee in it; a trailing stop arms once the price reaches arm_multiple times that, then sells on a fall of drop_pct from its highest since. exit_on_signal and ladder are refused: a bot sells on a signal itself.
- On a buy:
exitsonbot.buy, checked before anything is recorded (a bad one refuses the buy). - Later:
bot.exits {position | mint, exits}sets or changes them on one of its open positions;exits: nullclears them.
The core watches them on Candle’s price about every 5 seconds and sells as the bot’s own, with no card, while the bot is paused, crashed or not connected, and through the kill switch. They end when the position is sold, the bot is stopped or removed, or the person removes them. A held exit and the bot’s own sell never run on one position at once.
Its book and reads
Section titled “Its book and reads”| Method | Params | Answers |
|---|---|---|
bot.self | {} | {name, agent, mode, state, buys, sells, held?, figures: {perTradeUsd, perDayUsd, maxOpen, maxSlippageBps?}, paysFrom?}: the figures its card approved, whatever a hand edit says |
bot.positions | {} | {positions, totals: {paper, live}}: each open position’s id, mint, mode, wallet, tokens, cost, entry price, entryAt, sells, its held exits with who set them (buy, bot or person), and exiting while one sells it |
bot.fills | {limit?} | Its fills, newest first, each with its request and position, and exit for one an exit the core held made |
bot.market | {mint, chain?} | Candle’s market read for a token (its price, market cap and more). It doesn’t count toward the key’s request limit, and is kept 4.5 seconds |
bot.hacc | {tool, view, …} | One of Hacc’s reads its agent’s plan offers, with its agent’s key, as the agent’s hacc_* tools take them. NOT_OFFERED past the plan; READS_ONLY for a change (a follow, a rule, a strategy) |
Its book is the core’s count from its own fills, and never another bot’s or agent’s.
subscribe {topics: ["signals"], filters?: {signals: {pattern?, chain?}}} streams Hacc’s tape as the agent’s signals topic does (Pro and Max).
What it’s told
Section titled “What it’s told”The bot topic goes to the bot’s own connection only. Its snapshot is its state and its requests under way; then each event’s data has a kind:
kind | When |
|---|---|
request | A trade’s state changed: the trade answer above |
state | Paused, on or stopped, {state, buys, sells, words?, held?}: what runs now, and why when something holds it |
kill | The kill switch went on or off, {paused, words} |
stopping | The core is stopping, {words} |
exit | An exit the core held, {position, mint, state, reason?, words, fill?}. reason is stop_loss, take_profit, trailing_stop or max_hold; state is selling, waiting, held, sold, or how it ended (failed, refused, lost, left…) |
exits | The person removed a position’s held exits, {position, mint, exits: null, by: "person", words} |
What it says
Section titled “What it says”bot.report says what the bot is doing, shown on the Bots tab as “the bot says”. Each field sent replaces that field; one left out keeps what it said. The whole report is refused (INVALID_PARAMS, naming the field) if any part is over its size, of the wrong kind, unknown, or an event is dated more than 5 minutes ahead.
| Field | |
|---|---|
status | A line, 120 characters at most |
health | ok, warn or error |
alerts | Up to 8, each a line or {code, text} |
rules | How it trades, in words, up to 8 lines |
counts | Up to 12 named numbers, such as {"watching": 14} |
events | Up to 50 a report, each {kind, text, id?, at?, mint?}, kind being considered, entered, skipped, exited, alert or note. Added newest first; an id sent again is kept once; the newest 100 kept |
controls | Up to 4 chips for the person, each {id, label, pause: true} or {id, label, set: {perTradeUsd | perDayUsd | maxOpen | maxSlippageBps}}. A click that pauses or lowers takes at once; one that raises is a card |
Never its figures. A report naming pnl, realized, unrealized, positions, fills, balance, balances, value, basis, cost, costUsd, wallets, books or trades, as a field or a count, is refused whole (REPORT_FIGURES): those are the core’s count. A count’s name is read without case or punctuation, and a money word anywhere in it (pnl, pl, profit, realized, unrealized, balance, equity, roi) is refused too, so “P&L today” is; “stop loss hits” and “trades skipped” pass. The status line and other free text aren’t checked: they’re shown as the bot’s own words. Text is cleaned as all outside text is, and made one line. A report lives in memory; say it again after reconnecting.
bot.event {word, text?} says one word (lowercase letters, digits and dashes, 24 at most) and an optional line (200 characters). A trigger set as on bot "<name>" says "<word>" fires on it. One of a word every 10 seconds (TOO_SOON) and 30 a minute (TOO_MANY).
Another language
Section titled “Another language”Anything that can open a Unix socket and write a line of JSON can be a bot: send the hello, subscribe to bot, make an id per trade and keep it until you know the trade’s end, and ask bot.request with it after any lost answer. The TypeScript client is the reference; its tests run every method against a real core.