Skip to content

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).

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_SOCKET and CANDLE_HARNESS_BOT_TOKEN, which the core sets when it runs the bot; for a bot run elsewhere, in .harness-bot.json in the folder it runs from (or the file CANDLE_HARNESS_BOT_FILE names), which candle-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 AnswerLost with the id;
  • stops trying once the core says its token will never connect (UNAUTHENTICATED: the bot was removed or its token replaced);
  • throws HarnessRefused with the core’s code and words for anything refused.
CallMethod
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

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.

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.

MethodParams
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):

CodeWhy
FIGURE_TRADE, FIGURE_DAY, FIGURE_OPENOver its trade’s figure, its day’s (buys and swaps since local midnight) or open at most
RUN_SETTINGS_FIXEDThe request named a run setting
PAUSEDThe kill switch is on: buys and swaps are held, sells go
BUYS_HELDPaused, or held by the plan or a hand edit of bots.toml
SELLS_HELDIts sells don’t run now (it’s stopped)
BREAKER_PACE, ROUTE_FAILINGThe pace that keeps clear of Candle’s breaker, or a route Candle says is failing; data.retryAt says when
FAILED_SELLSA 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_WAYA 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_SPENTThe id ended before today and is no longer kept: a new trade takes a new id
NOT_ITS_POSITION, NOTHING_TO_SELL, POSITION_CLOSEDNot its own, nothing of it, or already sold
TOO_SMALL, AMOUNT_UNKNOWN, HOLDING_UNREADToo little to sell; sell by percent; the wallet’s balance couldn’t be read
BASE_ASSET, NO_PAPER_SWAPS, UNVALUEDA base asset can’t be bought, only swapped; swaps have no paper mode; a swap the core couldn’t value
UNKNOWN_BOTIt 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.

{ "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: exits on bot.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: null clears 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.

MethodParamsAnswers
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).

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:

kindWhen
requestA trade’s state changed: the trade answer above
statePaused, on or stopped, {state, buys, sells, words?, held?}: what runs now, and why when something holds it
killThe kill switch went on or off, {paused, words}
stoppingThe core is stopping, {words}
exitAn 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…)
exitsThe person removed a position’s held exits, {position, mint, exits: null, by: "person", words}

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
statusA line, 120 characters at most
healthok, warn or error
alertsUp to 8, each a line or {code, text}
rulesHow it trades, in words, up to 8 lines
countsUp to 12 named numbers, such as {"watching": 14}
eventsUp 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
controlsUp 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).

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.