The attach protocol
This is version 2.0 of the protocol.
The Candle Harness runs one core per account on a machine. The core is the only process that holds your agents’ keys and the only one that trades. Views (the Candle Harness’s screens), harnesses (coding agents such as Claude Code, through candle-harness mcp), code bots and Build panes attach to it over a local socket and speak this protocol. A code bot’s side of it is the bot protocol.
This page describes the methods and topics the core serves today.
Transport
Section titled “Transport”- A Unix socket at
~/.config/candle/harness/state/core.sock(or underCANDLE_HARNESS_HOME), mode0600, in a0700folder, so other users on the machine can’t reach it. - JSON-RPC 2.0, one message per line (newline-delimited JSON), UTF-8, at most 1 MiB per message. A larger line is refused with
MESSAGE_TOO_LARGE, and the stream carries on. A client that stops reading is dropped once 8 MiB waits unread for it, journalled asclient.dropped. - After the hello, requests on one connection run as they arrive and are answered by
id, not in order. A request waiting on Candle, such as an approve whose call is slow, never holds up the next one, such askill. - Errors use JSON-RPC’s own codes for transport and shape faults. Every refusal also carries a stable string in
error.data.code, which is what a client should branch on:
error.code | Meaning |
|---|---|
-32700 | Not valid JSON (PARSE_ERROR) |
-32600 | Not a JSON-RPC 2.0 request (INVALID_REQUEST), or a message over 1 MiB (MESSAGE_TOO_LARGE) |
-32601 | No such method (METHOD_NOT_FOUND) |
-32602 | Bad parameters (INVALID_PARAMS) |
-32001 | The method isn’t open to this client’s role (FORBIDDEN_FOR_ROLE) |
-32000 | Any other refusal; data.code says which |
The first message on a connection must be core.hello:
{ "jsonrpc": "2.0", "id": 1, "method": "core.hello", "params": { "protocol": "2.0", "token": "<token>", "clientName": "my-view", "harnessKind": "claude-code" } }It answers with the core’s identity and the client’s role:
{ "protocol": "2.0", "coreVersion": "0.1.0", "coreId": "…", "account": "acct_…", "agents": ["momentum", "swing"], "activeAgent": "momentum", "mode": "paper", "paused": false, "clientId": "c_…", "role": "view" }- A different protocol major is refused with
PROTOCOL_MISMATCH, naming both versions. - An unknown token, or any other method sent first, is refused with
UNAUTHENTICATED. - Either refusal closes the connection.
coreIdchanges each time the core starts. A client that reconnects to a differentcoreIdshould drop the eventseqs it remembers (see Events).
Roles and tokens
Section titled “Roles and tokens”The token decides the role; a client can’t claim one.
- View (
chv_…): the core writes the current view token tostate/view.token(mode0600) and replaces it on every view hello. A view reads the file once and says hello; the file then holds a new token, so a copied or replayed token is refused. The new token is written to the file first, and the core switches to it only once the file is in place, so the file and the core never hold different tokens; a hello while the replacement is being written is refused, and a write that fails answersVIEW_TOKEN_NOT_WRITTEN. Two views starting at once may race for one token: the loser re-reads the file and tries again. - Harness (
cht_<id>_<secret>): minted by the core for one engine turn of one agent, and revoked when the turn ends. The core keeps only its SHA-256 hash. A harness acts as the agent its turn was started for. A view token is never given to a harness. The doctor’s one-word turns (candle-harness doctor) attach to nothing: each engine runscandle-harness mcp --empty, an MCP server with no tools, so no token is minted and no core is needed. - Bot (
chb_<id>_<secret>): a code bot’s own, issued by the supervisor at each start (inCANDLE_HARNESS_BOT_TOKEN) or bybots.token(in.harness-bot.json,0600, in its folder), each issue replacing the last and ending its connection. Kept across a core restart, as a hash; ended when its process is stopped, exits cleanly or is given up on, and when a new folder or command is approved for a bot run elsewhere; revoked when the bot is removed. It reaches onlycore.hello, thebot.*methods and thebotandsignalstopics, all its own (The bot protocol). A paper test’s token is a bot token for a bot namedtest:<folder>, kept in memory and revoked when the test ends. - Build (
chd_<id>_<secret>): a Build pane’s, minted bybuild.openand handed once to the main view that asked, which puts it in the pane’s environment (CANDLE_HARNESS_BUILD_TOKEN). Revoked bybuild.close, when that view goes, or when the core stops. It reaches onlycore.hello, thebuild.*methods below marked for it, andbots.proposeCode, as the agent it was opened for.
The hello of a bot or a Build pane answers {protocol, coreVersion, clientId, role, agent, bot?}: never the core’s other agents, account or mode.
- Pane (
chp_<id>_<secret>): a single-use ticket for the CLI prompt pane’s launcher (see “The prompt pane” below). It’s minted when a view approves a card that runs in the pane, answered to that view only, and accepted in a hello only from the process that view named. The core keeps only its SHA-256 hash.
A view’s clientName says which kind of view it is. candle-harness doctor, candle-harness sampler and candle-harness stop only read (and stop the core: core.stop). candle-harness cards only lists cards (cards.list and subscribe), and an approval from it is refused (MAIN_VIEW_ONLY). They, candle-harness ask and candle-harness trigger are never the person’s open Candle Harness: none opens or closes a session (the away gap, “since last session”), is sent notifications, counts as the main view, or keeps an idle core running. A name holding --pane is a popped view. A message agent.send takes from candle-harness ask is the person’s own, under the same rules, marked as from outside.
These tokens protect against cooperative agents, not hostile code running as your user: anything that can read your files can read view.token. The engines the Candle Harness runs are confined to their own research folder, and their shell is off by default. An agent’s shell, once turned on in config ([agents.<name>.tools] shell), runs as you: it can read anything you can, this token and the agent keys included, and cards and the core’s limits don’t cover what it runs.
Methods
Section titled “Methods”own means the method is open to that role, scoped to the client’s own cards and operations.
| Method | View | Harness | What it does |
|---|---|---|---|
core.status | yes | no | Agents and their Candle connections, active agent, mode, pause, attached views, each key’s request budget, and each agent’s limits with who enforces them (limitLines, keyHeld; below). Also headless (started by candle-harness core, which runs until it’s stopped), triggersOn (how many triggers keep the core running), botsOn and botsLive (how many rules bots keep it running, and how many of them are live), keepAwake ({on, available, active, failed?}), holds (what keeps a core a view started running now, in words: triggers on, bots running, views attached, an agent’s turn, a trade in flight, cards waiting) and notify ({mode, native}: [notify] mode, and whether the core can show its own notifications here). An agent reads its own with harness_limits and harness_pnl |
core.usage | yes | no | What the core has fetched since it started: calls and answer bytes through each key, Hacc’s reads and bytes, its stream’s frames and bytes, and which background reads are live. Totals only, for the resource sampler |
core.stop | yes | no | {}: candle-harness stop, with no question. Answers {pid, headless, stopped}, stopped being what it stops in words (triggers that stop watching until the next core, a turn, a trade in flight that’s read back at the next start and never sent twice, cards waiting that expire, views that lose their core, keep-awake), then stops |
core.keepAwake | main view | no | {on}: on macOS, keeps the Mac from idle sleep (caffeinate -i -w <the core's pid>) while a trigger keeps the core running. Off by default; kept in state/keep-awake.json, and config.toml’s [core] keepAwake is where a fresh install starts. NOT_MACOS elsewhere. Journalled as core.keep_awake. Answers {on, available, active, failed?} |
tools.list | yes | yes | The tools offered to an agent, with Candle’s own JSON Schemas; transfers are never listed, nor any tool the agent’s allow and deny lists hide from its harness. On every Candle tool that takes a keyPrefix (the profile’s wallets, P&L and trades, and the wallet-set card), its description names the agent’s own key prefix, so it reads its own book rather than guessing |
tools.call | yes | yes | {name, args, reason?}: run a read, a paper trade, or propose a card (answers APPROVAL_REQUIRED, or with the operation when its wallet runs it without a card: below). From a harness, a tool its agent’s lists hide is refused (TOOL_HIDDEN), as it is from cards.propose. A call from a turn that was stopped is refused (TURN_ENDED), and makes no card; a card or trigger proposal from a turn that began on paper, once the core is live, is MODE_CHANGED. A card tool’s argument the core doesn’t know is UNKNOWN_ARGUMENT, and a base asset as a trade’s token is BASE_ASSET. A card’s answer carries its mode. Each engine’s own web fetch is journalled as agent.fetch {agent, turnId, tool, url} |
cards.propose | yes | yes | {tool, args, reason?}: propose a card directly, such as a typed trade. A view’s typed trade (typed: true, agent?) in paper mode needs no card: it runs as that agent’s paper trade and answers {paper}. A trade the core runs at once (under cards.typedTrades = "instant", or without a card inside its wallet’s figures) answers the card with instant, the approval’s result |
cards.list | yes | own | Cards: every open one, and the newest closed ones. The core keeps the newest 200 closed cards, and a list or snapshot is held under 768 KiB with open cards always in |
cards.get | yes | own | {cardId} |
cards.approve | main view | no | {cardId, cardHash, how?}: one key press. how is "enter" or "click", recorded on the journal’s card.approved row and the card’s approvedHow ("view" when left out; a typed trade under cards.typedTrades = "instant" records "instant", and a trade the core runs without a card inside its wallet’s figures, "inside-limits"; a view that sends either is recorded as "view"). A late approve proposes a fresh card instead |
cards.reject | yes | no | {cardId} |
cards.withdraw | own | own | {cardId}: the proposer takes back its own card |
cards.requote | yes | no | {cardId, wallet?}: an expired card’s re-quote, a fresh card for the same request valued now. With wallet (embedded, or a TEE wallet’s address), a trade or swap card, open or expired, is proposed again paid from that wallet, with a new hash, and the open one is withdrawn. Nothing runs. Only a card a planner rebuilds from what was asked is re-quoted (a trade, swap, launch, stake, claim or public action, and the CLI’s and the pane’s rows): any other (a trigger’s, a bot’s, autonomy, a Hacc change) answers ASK_AGAIN, as does a late approve of one |
operations.list | yes | own | Operations: what approved cards did, bounded as cards.list is |
operations.get | yes | own | {opId} |
operations.resolve | yes | no | {opId, action: "resend" | "abandon"}: settle an operation whose outcome Candle couldn’t confirm. A re-send is the same request under the same id, from the main view only (MAIN_VIEW_ONLY); abandon is any view’s but the read-only clients’ (OBSERVER: the doctor, the sampler, stop and cards) |
mode.set | main view | no | {mode: "paper" | "live"}. The mode is kept across restarts (state/mode.json); config.toml’s mode is only where a core starts before one has been picked. Triggers and bots keep the mode each was approved with; the answer may carry said, naming the triggers that stay as approved |
hacc.undo | yes | no | {changeId}: the undo chip on an Activity line, for a Hacc change the agent made at once (a follow, a group, a wallet put in a group, a strategy saved). It runs at once, since the click is the person’s press, and answers {undone, text}. A group is undone only while it’s still empty (GROUP_NOT_EMPTY); a strategy’s undo retires it. ALREADY_UNDONE and NOTHING_TO_UNDO say so. Refused from the observers (OBSERVER), as agent.setActive, engine.choose and a preset pick are |
wallets.show | yes | no | {show: "active" | "all" | "group:<name>" | [names]}: which wallets the Wallets and Positions panes show, published as status.walletsShown. "active" is the wallets an agent here can pay from that Candle hasn’t stopped; "group:<name>" one of the person’s wallet groups (UNKNOWN_GROUP when there’s none by that name); a list names wallets by "embedded", label or address, and a name that matches none is UNKNOWN_WALLET. Kept across restarts (state/wallets-shown.json); config.toml’s [wallets] show is where a fresh core starts. The agent sets it with its harness_show_wallets tool. It changes only what’s shown |
wallets.name | yes | no | {names: [{wallet, name}]}, or {wallet, name} for one: names wallets, each by its address or its name now, 1 to 256 at once. The whole ask is checked first, against Candle’s label rule (at most 64 UTF-16 code units, no control character, no leading -, no edge whitespace, never trimmed) and the Candle Harness’s (unique across every wallet without case; never embedded, an agent’s name, or shaped like an address or a short address). A name the Candle Harness keeps (the embedded wallet, a destination) is written to wallets.toml at once; a TEE or linked wallet’s is Candle’s (candle tee rename) and a vault key’s the vault’s (candle vault rename), each run in the prompt pane at once, several as one --pairs-from run whose file the core writes, its --dry-run shown first. Answers {said, panes?, changeId?}: panes are the opened panes as a typed pane row answers them, and changeId the naming wallets.undo takes. Refusals: UNKNOWN_WALLET, WALLET_AMBIGUOUS, WALLET_NAME_MARKED, NAMED_TWICE, UNCHANGED, NAME_REFUSED, TOO_MANY, WALLETS_FILE_PROBLEM (a wallets.toml with a problem is never written over). The agent names wallets with its harness_name_wallets tool: the Candle Harness’s names at once, Candle’s and the vault’s as cards |
wallets.undo | yes | no | {changeId}: puts back the names of the last naming written at once, or (a wg_ id) the group as it was before the last change to a group: the undo under the agent’s reply. Anything older is NOTHING_TO_UNDO |
wallets.group | yes | no | {group, op, wallets?, prefix?}: one change to one wallet group, kept in wallets.toml beside the names. op is "add" or "remove" with wallets (1 to 256, each a name or address, found as from finds it and kept by address), "prefix" with prefix (every wallet whose name starts with it, without case; Candle’s trailing * is taken off; null drops it) or "delete". A new group’s name is one word of letters, digits, ., _ and - (at most 32), never a wallet’s name, an agent’s, all, embedded, active or none. A group left with no prefix and no wallets is gone. Answers {said, changeId}. Refusals: UNKNOWN_GROUP, GROUP_NAME_REFUSED, PREFIX_REFUSED, UNKNOWN_WALLET, WALLET_AMBIGUOUS, WALLET_NAME_MARKED, IN_BY_PREFIX (a wallet in by its name’s start can’t be taken out by hand), UNCHANGED, WALLETS_FILE_PROBLEM. A group organises only: it never pays (from <group> is WALLET_IS_GROUP) and never sets a wallet’s approvals. Journalled as wallets.grouped (with def and was) or wallets.group.refused |
autonomy.set | yes | no | {wallet, setting: "ask" | "inside", perTradeUsd?, perDayUsd?}: a wallet’s autonomy. "ask": every trade is a card (the default). "inside": trades inside the wallet’s two figures, per trade and per day across every agent paying from it, counting only what ran without a card, may run without one. wallet is an address, "embedded" or a TEE wallet’s label, of a wallet an agent here pays from (else UNKNOWN_WALLET); a wallet that’s gone may only go back to "ask". Each figure is a dollar amount from $0.01 to $1,000,000, to the cent, and the day’s at least the trade’s (else INVALID_PARAMS). A main view only: a popped view (--pane, or holding a popped pane) is MAIN_VIEW_ONLY. Answers {wallet, name, was, now}; journalled as autonomy.set with from, to, by and how: "view"; kept by address in state/autonomy.json (0600). No config key sets it. The agent may only narrow it, on a card (below) |
triggers.list | yes | no | {}: every trigger, as the triggers topic carries them. There’s no method that saves one: a trigger is saved only by the person’s press on its card (below, Triggers) |
triggers.set | yes | no | {name, on}: off at once, from any view, even when triggers.toml can’t be written. Back on, as it was approved, from the main view only (MAIN_VIEW_ONLY); refused with what holds it (NOT_APPROVED, CHANGED_OUTSIDE, AGENT_GONE, PLAN_FREE, PLAN_UNKNOWN) or TRIGGERS_FILE. UNKNOWN_TRIGGER names the triggers there are. Journalled as trigger.changed. Answers {name, on, said} |
triggers.remove | yes | no | {name}: removes a trigger from any view. Answers {name, said}; with triggers.toml unwritable it’s off and won’t fire, and said says so. Journalled as trigger.removed |
triggers.fire | yes | no | {name, wait?}: candle-harness trigger <name>, firing an on call trigger, with its cooldown, its wallet’s figures and the kill switch as any firing. Answers {name, fireId, said} once the firing is recorded; with wait, once what it did ends, adding ended (in words) and line, its Activity line. Refused: TRIGGERS_NEED_PLAN, UNKNOWN_TRIGGER, NOT_ON_CALL, TRIGGER_OFF, COOLDOWN (when it can fire again), what holds it, and OBSERVER from the doctor, the sampler and candle-harness stop |
triggers.runNow | main view | no | {cardId}: “run it now” on a trigger’s card that expired with nobody there: that part and the rest of its line proposed again at a fresh price, once per card (ALREADY_RUN). It isn’t a firing: the cooldown and arming stay as they are. Refused: CARD_NOT_FOUND, NOT_A_TRIGGER_CARD, CARD_NOT_EXPIRED, UNKNOWN_TRIGGER (removed), TRIGGER_CHANGED, TRIGGERS_NEED_PLAN. Journalled as trigger.run_now. Answers {said, runId} |
bots.list | yes | no | {}: every bot, rules and code, and its book, as the bots topic carries them. There’s no method that saves one: a bot is saved only by the person’s press on its card (below, Rules bots and Code bots) |
bots.pause | yes | no | {name}: no new entries, at once, from any view; its exits still sell. Answers {name, state, said}. Journalled as bot.paused |
bots.narrow | yes | no | {name, definition, maxSlippageBps?}: a narrower definition (Hacc’s JSON) or a bound tightened as far as the route’s default, at once; for a code bot, {name, perTradeUsd?, perDayUsd?, maxOpen?, maxSlippageBps?}, lower figures or such a bound. WIDER names what isn’t narrower, for a card: a bound under the route’s default, exits that would sell an open position now, an exit under 1% or a hold under 5 minutes, and an emptied filter list among them. A bound under 10 bps is INVALID_SLIPPAGE; NO_CHANGE, INVALID_DEFINITION, INVALID_SLIPPAGE, NOT_LIVE (a field that can only be backtested). Answers {name, version, narrowed, said}, narrowed each change in words. Journalled as bot.changed |
bots.stop | main view | no | {name}: nothing trades; its open positions are left to the person. Answers {name, state, said}. Journalled as bot.stopped |
bots.resume | main view | no | {name}: back on as it was approved. Refused with what holds it (NOT_APPROVED, CHANGED_OUTSIDE, AGENT_GONE, PLAN_FREE, PLAN_UNKNOWN) or BOTS_FILE. Answers {name, state, said}. Journalled as bot.resumed |
bots.flatten | main view | no | {name}: sells every open position now, as its exits sell, a stopped bot’s too. Answers at once with {name, said}; the journal says how each sell ends |
bots.retry | main view | no | {name}: a bot’s exits held after two failed sells of a token on the key try once more. Answers {name, released, said} |
bots.remove | main view | no | {name, positions}: positions is "flatten", to sell its open positions first, or "leave", to leave them in the wallet. Answers {name, said}. Journalled as bot.removed. A code bot’s token is revoked, and its process ended |
bots.exits.remove | main view | no | {name, position}: removes the exits the core holds on a code bot’s position. The bot is told. Journalled as the person’s |
bots.control | main view | no | {name, id}: a control a code bot’s report offers. A pause or a lowered figure takes at once; a raised figure, a looser bound or one under the route’s default proposes a card for the approved run with the control’s figures. UNKNOWN_CONTROL, NOT_APPROVED |
bots.process.start, .stop, .restart | yes | no | {name}: a code bot’s process, run by the supervisor. Stopping it leaves the bot on, and its held exits run; starting it clears a give-up and brings a bot run elsewhere back under the core. Not from the doctor or the sampler |
bots.token | yes | no | {name}: for a bot run elsewhere: ends its process here, issues a new token and writes {bot, socket, token} to .harness-bot.json in its approved folder (0600). Answers where, never the token, with warning when the folder’s git doesn’t ignore the file |
bots.logs | yes | no | {name, lines?}: the end of a code bot’s log, redacted when it was kept |
bots.proposeCode | no (build: own) | no | {name, folder, command, mode, wallet, perTradeUsd, perDayUsd, maxOpen, maxSlippageBps?, replace?, reason?}, from a Build pane: a code bot’s card in the main view. Nothing is saved before the press. Refused on Free |
build.open | main view | no | {agent, harness?}: a Build pane’s token for that agent, answered once to this view with the socket’s path. Journalled as build.session |
build.close | yes | no | {tokenId}: revokes it, from the view that opened it (NOT_YOURS) |
build.prefs | yes | no | {set?: {folder?, harness?, opened?}}: the Build pane’s folder and harness as last picked (set from the main view), else config.toml’s [build], with each engine’s binary |
build.endTest | yes | no | {name}: ends a paper test, from the view whose Build pane started it |
build.books, build.logs, build.journal | no (build: own) | no | Every bot’s book and health (the bots topic’s shape); a code bot’s log {bot, lines?}, for a bot of the pane’s own agent (NOT_ITS_AGENT otherwise); the pane’s agent’s own record, as harness_history reads it |
build.test, build.testEnd | no (build: own) | no | {folder, perTradeUsd?, perDayUsd?, maxOpen?, command?}: a paper test of the bot in that folder, with a test token answered once to this pane; ended by its command, the pane closing, build.testEnd or 30 minutes. One per folder; refused on Free |
view.link | yes | no | {mint, chain?}: the person’s pick moves the link, the token every token pane not pinned follows, in every view (chain is "solana", the default, or "hood"). The agent moves it by showing a token: a Candle read, a Solana token read on Hacc, a card, or the one token its reply names. The latest asked for wins: a pick answered after a later one answers {linked: false, superseded: true} and changes nothing. Otherwise {linked: true, symbol?}, or BASE_ASSET (what trades spend) or NO_MARKET (Candle has no market for it), and the link stays. The agent’s next turn is told the person’s pick once, fenced as the Candle Harness’s note, and its mint counts as shown to the address gate |
view.pin | yes | no | {at, pane?, mint?}: holds a token pane at at (a tab’s name) on a token while the link moves on: the linked token, or a pinned one named by mint (NOT_WATCHED otherwise). pane is which of the tab’s token panes, token (the default), token:2 or token:3. At most three pins across every tab and pane, each token watched at the link’s 5 s; a fourth is TOO_MANY_PINS, naming the three. Kept across restarts (state/pins.json). Answers {pins} |
view.unpin | yes | no | {at, pane?}: that token pane (token by default) follows the link again; pane: "all", every pin at at. Answers {pins} |
view.pop | yes | no | {pane}: this view shows pane alone, popped out of the tiled views (any pane but agent: the conversation stays in the main window). It’s held by this connection: when the view closes or dies, the pane goes back to the tiled views. A pane another view holds is POPPED_ELSEWHERE; the same view asking again (after a reconnect) is fine. Answers {popped} |
view.signals | yes | no | {at, mine?, pattern?, chain?}: what the Signals pane at at (a tab’s name, or popped out) shows: the account’s own rules’ signals (mine: true) or Hacc’s tape, filtered by one of Hacc’s pattern names and "solana" or "hood" (null clears either). The whole tape is kept as no choice. Kept across restarts (state/signals-filters.json). Answers {signals} |
view.repin | yes | no | {from, to}: a tab renamed takes its pins and its Signals choice with it. Answers {pins, signals} |
view.active | yes | no | {}: the person pressed a key or clicked in this view. The agent’s harness_view goes to the main view used last (a popped view never says it). A view says it at most once a second, after a key’s own request |
view.answer | yes | no | {id, result}: this view’s answer to an asks event, {ok: true, text, words?} or {ok: false, code, text}. Only the view asked can answer; anything else answers {taken: false}. text goes to the agent, cut to 12 KB of plain text; words is Activity’s line, journalled as view.changed |
view.note | yes | no | {agent, words}: the person’s click on an agent’s change to the screen (kept, discarded, undone, used), journalled as view.decided and shown in Activity as “you …” |
view.set | yes | no | {words}: the person’s own change to the tabs or a layout, in words (at most 160 characters), journalled as view.set for the record. Changes nothing itself. Answers {} |
pnl.window | yes | no | {window}: the P&L window, "24h", "midnight", "session" (since the main view was last closed) or a time of day such as "18:00" (its last occurrence). Kept across restarts (state/pnl-window.json, 0600); config.toml’s [pnl] window is where a fresh install starts. Journalled as pnl.window. Answers {window}. The person picks it with the P&L pane’s 24h ▾ chip, settings ▾, the palette or a typed pnl since 18:00. The agent reads the figure with harness_pnl, and may name its own window there; it can’t change the setting |
pnl.series | yes | no | {from?, points?}: the Candle Harness’s agents’ summed P&L over time from the core’s samples, for the chart: points (2 to 1000, 120 by default) buckets from from (epoch ms, the last 24 hours by default) or the first sample, whichever is later, each {at, usd} with each agent’s last sample in it, and usd: null where the samples have a gap (the core wasn’t running, or more than 12 minutes passed) |
journal.query | yes | no | {text?, kinds?, agent?, mint?, from?, to?, before?, limit?}: the Journal. Everything the core recorded, in words, newest first, over the whole file: one entry for each card’s life (proposed, approved and how, how it ended, how long), one for each agent turn, and every other row in Activity’s words. text: words that must all appear (any case). kinds: any of trades, cards, kill, hacc, memory, agents, view, core (all by default; the view leaves core off). from/to: epoch ms. limit: up to 100 (50 by default). Answers {entries, more?, scanned, oldest?}, at most 64 KB; each entry {id, seq, at, last, kind, mode, agent?, mint?, symbol?, text, tone, seqs}. Pass more back as before for the next page: a card’s life that spans two pages comes whole, and nothing comes twice. Rows in memory are read from memory, older ones from journal.jsonl backwards, a slice at a time, so a search never holds up a key press. A newer query from the same view stops the one before it ({superseded: true}). Writes nothing; what it sends passes the redactor again. An agent reads its own record with harness_history |
journal.rows | yes | no | {seqs} (up to 200): those rows as recorded, oldest first, for a Journal entry’s details (ctrl-o): {rows, cut?}, at most 128 KB. Writes nothing; redacted again |
away.get | yes | no | {}: the last “while you were away” summary, as the away topic carries it |
onboarding.get | yes | no | {straightIn?, again?}: onboarding’s steps, {state, facts, steps, at}. state is what state/onboarding.json (0600) keeps, the person’s choices only: straightIn, the steps gone past that leave nothing behind (passed), the engine and model picked before any agent exists, vaultChoice (now or later), the agent’s name with when its key was asked for, and finishedAt, marked by the core the first time every step reads done or skipped (until then a machine with agents still opens onboarding). facts are read back each time from where they live, never from the file: the sign-in from candle auth status --json as the profile the Candle Harness acts as, or as the CLI picks while none is recorded (only the account and the profile it answered as kept), profile (config.toml’s candle.profile), and, while none is recorded, profiles from candle profile list --json (each one’s name, whether it’s active, its account and host; never a key), the vault from candle vault status --json, the key from the Candle Harness’s own key file or, failing that, the owner’s candle keys list --json, and the wallet from Candle’s reads with the agent’s key: payable with how many and the first one (address, kind, name; the embedded wallet first), or none with embedded: "denied" where Candle denies the key it. It also carries plan (the agent’s status read’s tier) and vaultKey (the one vault key a fresh TEE wallet is promoted from, with a vault). Each of steps (welcome, signIn, engine, vault, key, wallet, limits, approvals, fund, tour, start) is done, skipped (straight in, or no vault for now) or todo; a key labelled as the agent, made since it was asked for and held by no key file, is offered for revoking as revoke: [{prefix, line}], a line to type, and nothing makes another. at is the first step to do, or done. The state is started when there’s none, straightIn as asked; again (the person’s candle-harness setup) starts a new one only once the last is done |
onboarding.set | yes | no | One change: {straightIn}, {passed} (a step that leaves nothing behind), {engine, model?}, {vaultChoice}, {profile} or {agent} (a free name: AGENT_EXISTS for one in config.toml). {profile}, from the main view only, is a profile candle profile list shows (NO_SUCH_PROFILE otherwise), recorded as candle.profile in config.toml by adding one line (PROFILE_NOT_RECORDED when the file names another), journalled onboarding.profile; from then every candle run, the pane’s too, gets it as CANDLE_PROFILE. {passed: "engine"} needs the test message to have answered on the engine and model chosen (NOT_TESTED). Answers as onboarding.get, and the onboarding topic carries it. Journalled as onboarding.changed |
onboarding.signIn | yes | no | {}, main view only: Candle’s device code in the prompt pane, as the person’s typed step. With a recorded profile, candle auth login --profile <it>; with none, a new profile named candle-harness (then candle-harness-2, …, never one on the machine) on https://api.alpha.candle.tv. Answers {profile, opened}, opened as cli.run’s pane answer. When the pane ends and candle auth status --profile <it> shows it signed in, it’s recorded as {profile} is, whatever the CLI’s exit said |
onboarding.harness | yes | no | {engine}, main view only: a ticket for the harness’s own sign-in, from a fixed list (claude auth login, codex login --device-auth, grok login, opencode auth login, Pi with --no-tools --no-extensions for its /login), run by the launcher as the person with nothing of Candle’s in its environment. {ticket, ticketId, title}. Its pane.redeem answers {title, bin, steps, identity: "owner", harness, note?}; its pane.report reads the engine again and answers {outcome, words}. Journalled onboarding.harness and onboarding.harness_ended |
onboarding.limits | yes | no | {perTradeUsd, perDayUsd}, main view only: the Candle Harness’s two limits for the agent onboarding made, written into its own [agents.<name>.limits] in config.toml (two lines changed, nothing else; LIMITS_NOT_RECORDED with why) and used at once; then limits is gone past. NO_AGENT_YET before its key is stored or once setup has finished. Answers as onboarding.get. Journalled onboarding.limits |
onboarding.paper | yes | no | {}, main view only: “start on paper”: paper mode, as mode.set, and fund gone past. Answers as onboarding.get |
onboarding.test | yes | no | {}, main view only: step 2’s test message, the doctor’s one-word turn on the engine and model chosen, with an MCP server that has no tools. {ok: true, ms, reply, view} (kept as state.tested) or {ok: false, words}. Journalled onboarding.tested |
vault.forget | yes | no | {}, the person’s typed forget vault, from a main view (MAIN_VIEW_ONLY otherwise): this machine’s vault is gone on purpose. Only while the CLI finds no vault (VAULT_PRESENT; VAULT_UNREADABLE when it didn’t say): it removes the core’s mark that there was one (state/vault-seen.json), so owner steps run without the factor again. Answers {said}. Journalled as vault.forgotten |
agent.setActive | yes | no | {agent} |
kill | yes | no | The kill switch: pauses buys and swaps, ends every agent’s turn and withdraws open buy and swap cards. Sell cards stay open. Answers {paused, report, already}; a second kill while paused journals nothing new |
resume | main view | no | Allows buys again. Answers {paused: false, already} |
agent.send | yes | no | {text, agent?}: a message to the agent (the active one when left out). Answers {agent, turnId, queued}: one turn at a time per agent, so a message sent mid-turn waits. From candle-harness ask it’s marked as from outside (the agent events’ outside, Activity, the Journal and harness_history). A message holding something shaped like a key is refused (SECRET_IN_MESSAGE) and never reaches the engine |
agent.cancel | yes | no | {agent?, keepQueued?}: end the agent’s turn and drop the messages waiting behind it; with keepQueued, the next one runs (the view’s Esc). Answers {cancelled} |
engine.models | yes | no | {engine, fresh?}: the models that engine can run here, read from the harness itself in the background: as the core starts, every 10 minutes, and on fresh (the picker opening), never on a turn’s path. Without fresh it answers the list last read at once (reading first if there’s none); with it, a list newer than 30 s, reading again if there isn’t one, and joining a read in flight. Answers {engine, models: [{id, label, isDefault?}], notes?, readAt, error?}: notes are the sign-ins held back, and error says a read failed, with the last good list kept. An engine not in this build answers ENGINE_NOT_IN_BUILD |
engine.choose | yes | no | {agent?, engine, model?}: the agent’s engine and model from its next turn (a turn running now finishes on its engine; a message waiting runs on the new one). Left out, model runs the engine’s own default; for an engine whose output never names the model that answered (Codex), the core reads that default from the engine’s own list in the background and passes it by name, so the journal records what ran. Refused for an engine the core doesn’t know (UNKNOWN_ENGINE), one not in this build (ENGINE_NOT_IN_BUILD) or one whose check fails here (ENGINE_CANT_RUN, with what to do). The choice survives a restart. Answers {agent, engine, model?} |
agent.run | main view | no | {agent?, on}: the code runner for an agent. On writes the programs this machine has (of python3, bun, node) into its [agents.<name>.tools] run; off writes "off". Used from its next turn. NOT_HELD where the system can’t hold it, NOT_INSTALLED |
engine.check | yes | no | {engine, again?}: checks that engine again now, if its last check failed (a harness installed since, say), and reads its models once it passes. again: true checks it even if it passed, for a harness that updated itself or dropped its sign-in; its models are read again only when its version changed. Answers {engine, check}, check as in the engine event |
memory.read | yes | no | {}: every agent’s memory read fresh from disk, as the memory topic carries it, and published on it. The overlay calls it as it opens, so an edit made outside a turn shows |
memory.delete | yes | no | {agent?, entry? | file? | all?}: the person’s delete from an agent’s memory: one line of memory/index.md by the id the memory topic gives it, one file in memory/ by its path there, or all: true. Never through a link, never outside memory/. Journalled as memory.change with by: "you" and shown in Activity. Answers {agent, view} with the agent’s memory now; NO_ENTRY when the line has changed since, BAD_PATH, NO_FILE, NO_MEMORY. The agent can’t call it: its memory is its own to write, with its own file tools |
memory.pin | yes | no | {agent?, entry, pinned}: the person’s pin or unpin of one line of memory/index.md, by its id. Pinned, the line moves under a ## Pinned heading at the top of the index, made if it isn’t there; unpinned, it moves to just under that section, which goes once it’s empty. Its words never change. Journalled as memory.change with by: "you" and pinned or unpinned. Answers {agent, view}; NO_ENTRY, NO_INDEX, NO_MEMORY. The agent is told to keep pinned lines word for word |
skills.read | yes | no | {}: the skill folders of the person’s own, read fresh, as the skills topic carries them, and published on it |
skills.set | main view | no | {folder, on}: turns a folder of skills on or off for every agent: one of the harnesses’ own (~/.claude/skills, ~/.codex/skills, ~/.agents/skills, ~/.config/opencode/skills, ~/.pi/agent/skills) or any folder from / or ~/. Each <name>/SKILL.md in a folder that’s on is served to the agents as a skill of the person’s own: standing guidance, not data. Kept in state/skills.json, journalled as skills.folder, and said in Activity. Refused from a popped view (MAIN_VIEW_ONLY). Answers the skills topic |
preset.choose | yes | no | {agent?, preset}: the agent’s preset from its next turn: quant (the default: Candle’s trader prompt with an analyst’s rules and Candle’s skills), trader (the plain trader prompt) or bare (the essentials only). Journalled as preset.chosen and shown in Activity. The choice survives a restart, kept beside the engine; config.toml’s [agents.<name>] preset is only where an agent starts. Answers {agent, preset} |
cli.run | main view | no | Run one of your own candle … command lines |
harness.register | tests only | no | {name, agent?}: mints a harness token by hand, for the Candle Harness’s own tests; a released build refuses it (NOT_IN_THIS_BUILD) and attaches a harness only for an agent’s turn (the runner mints its turns’ tokens directly). Unlike a turn’s, the token lasts until the core stops. Answers {token, tokenId}, the token once |
pane.spawned | yes | no | {ticketId, pid}: the pid of the prompt pane’s launcher this view started for a ticket it was given. Only that view, once |
subscribe | yes | own | {topics, sinceSeq?, filters?} |
unsubscribe | yes | own | {topics?} |
A permitted method the running core doesn’t have yet answers METHOD_NOT_AVAILABLE.
A pane may call only pane.redeem and pane.report (below); a view and a harness may call neither.
No method returns an agent key or a stored token, apart from harness.register, which returns the new harness token once, to the view that asked, and pane.redeem, which returns an agent’s key once, to the launcher, for a row run with that agent’s key. Every message the core sends passes through a redactor first; pane.redeem’s key is added after, and only ever to a pane.
Events
Section titled “Events”subscribe {topics, sinceSeq?, filters?} starts notifications of the form:
{ "jsonrpc": "2.0", "method": "event", "params": { "topic": "cards", "seq": 12, "data": { … } } }seq rises by one per topic.
| Topic | What | Who |
|---|---|---|
status | Mode, pause, agents, Candle connections, attached views, each key’s request budget, each agent’s limits with who enforces them, and what keeps the core running (core.status’s fields). setup: true while there’s no agent: nothing reads Candle or Hacc, a trade or a message is refused NO_AGENT_YET, and the prompt pane, the engines and a new agent’s key work | view |
cards | Every card change. The snapshot holds every open card and the newest closed ones, under 768 KiB (the core keeps the newest 200 closed cards) | view; a harness gets only its own |
operations | Every operation change. One that has ended carries settledAt, when it did (the receipt’s “filled in 4.2 s”); a swap whose first leg went and whose second failed carries partial. The snapshot is bounded as the cards’ is | view; a harness gets only its own |
activity | What happened, in words | view |
agent | The engine’s event stream for the conversation | view |
market | The token pane’s data: the linked token’s (followed, with by: you or agent) and each pinned token’s (pinned), and Candle’s on-fire list (trending, the feeds topic’s On Fire) | both |
feeds | Candle’s four lists, New, Graduated, On Fire and Final Stretch, each read while a subscription names it | view |
portfolio | Wallets, holdings and paper books, by wallet | view |
wallets | What each agent can pay from | view |
autonomy | Each wallet’s autonomy: {read, wallets}, each wallet {wallet, name, kind?, setting, perTradeUsd?, perDayUsd?, usedTodayUsd, agents, gone?, setAt?, notes}. usedTodayUsd is what ran without a card today. gone: no agent here pays from it now (said only once every agent’s wallets are read); its entry is kept and applies to nothing. notes are {kind, agent, words}: binds-first, an agent’s own Candle Harness limit under the wallet’s figure, and uncapped, a key Candle doesn’t cap, where only the Candle Harness’s limits hold | view |
onboarding | Onboarding’s steps after each onboarding.set, as onboarding.get answers | view |
away | While you were away: {summary, firstRun?}, the last summary, kept in state/away.json (0600) until the next gap, so a restart or a second view shows the same one (below). firstRun: the main view hasn’t been closed and opened again since the record began | view |
engine | Each agent’s preset, engine and model, and each engine’s check | view |
memory | Each agent’s memory: its index’s lines and the files beside it | view |
skills | The skill folders of the person’s own: each {shown, label, on, exists, count, skipped} | view |
pnl | P&L: each agent’s book from Candle, the P&L window’s figure, the wallets across agents, recent fills while subscribed, the journal’s swaps and paper P&L, each kept apart (below). withoutCard lists the signatures of the live trades the core ran without a card, from the journal, so a view marks those fills; a journal swap that ran without a card carries withoutCard: true. A view subscribes while one of the P&L tab’s panes (pnl, pnl-chart, pnl-positions, trades) is on screen | view |
research | The linked token in depth: its whole token check, top traders, its own signals and posts about it, each read while a subscription names its pane (below) | view |
hacc | Hacc, Candle’s market data engine: on or off, each agent’s plan and key, the stream | view |
signals | Hacc’s signal tape, live, from the core’s one socket | both |
signals:mine | The signals the account’s own alert rules produced (Pro and Max) | both |
asks | The agent’s harness_view, sent to one main view only, the one used last (view.active), with the newest attached when none has been: {id, agent, args}. The view makes the change and answers view.answer within 5 s; a view that closes or doesn’t answer in time is said to the agent in words. A layout the agent changes is a preview on its tab until the person keeps or discards it; a tab change is written at once with an undo; a theme it makes is written, checked and used only on the person’s click | view |
views | What every view shows the same: the linked token and who moved it (link), the pinned token panes (pins: {at, pane?, mint, chain, symbol?}, pane being token:2 or token:3, absent for a tab’s first token pane; a popped token pane’s at is popped out), each place’s Signals choice where it isn’t the whole tape (signals: {at, mine?, pattern?, chain?}), and the panes popped out into views of their own (popped), which a tiled view leaves out | view |
triggers | Every trigger and what holds it: {gate?, triggers, problems} (below, Triggers). gate is {plan: "free" | "unknown", words, fix?} while the plan doesn’t open them; problems are triggers.toml’s, by line | view |
bots | Every bot and its book: {gate?, bots, codeBots?, problems, books?} (below, Rules bots and Code bots). gate is {plan: "free" | "unknown", words, fix?} while the plan doesn’t open them; problems are bots.toml’s, by line | view |
notices | Notifications, sent to one main view only, the one the person used last (view.active), with the newest attached when none has been; never a popped view, nor the views that are never the person’s open Candle Harness (Roles and tokens, above). {notice: {kinds, title, body}}: what notifies, in Activity’s words, with addresses short and no key (kinds from cards, triggers, bots, wakes, kill, keys, core, as [notify] on keeps), for the view to show while its window isn’t in front. Things that happen together come as one. With no such view open, the core shows its own where the machine can ([notify] mode auto or native), journalled as notify.sent or notify.failed. Before a planned stop every view gets {stopping}, the reason. The snapshot is {settings: {mode, on}} | view |
filters gives a topic that takes one a filter for this subscription, by topic: signals, as {pattern?, chain?}, each a name or a list of names ({"signals": {"pattern": ["confluence", "large_move"]}}); feeds, as {buckets}, a list of new, graduated, onfire and finalstretch ({"feeds": {"buckets": ["new", "onfire"]}}); and research, as {parts}, a list of check, traders, signals and mentions. A filter for a topic that takes none, a topic not in the same call’s topics, or a filter that isn’t that shape is refused before anything is subscribed. Subscribing to a topic again sets its filter afresh; leaving it out means everything. An event that leaves nothing for a subscriber’s filter isn’t sent to it, so seq on a filtered topic can skip. A signals filter of one pattern and one chain at most is also read back from Hacc’s REST tape, once per filter and at most once a minute: its snapshot carries what the read-back found merged in by id, back ({at, ok, rows, words?}) once it answered, and since, the oldest signal the core holds from the stream; the read-back’s answer arrives as one event with for (the filter’s key), back and since, sent only to the subscriptions holding that filter, never to the whole tape’s.
An agent event carries {agent, turnId, type} and one of:
queuedandstarted, withuser, the message;startedalso names theengineand themodelpassed (nullfor the harness’s own default). A turn a trigger woke carrieswoken, the triggers’ names, andusersays what woke it in place of the person’s words (wakes that come while the agent is busy join one woken turn, and itsqueuedis sent again with each). A message fromcandle-harness askcarriesoutside: true;text, withdelta: the reply as it streams. An address no tool has shown yet streams as[checking address];tool_callandtool_result, withid,nameand a one-linesummary(tool_resultaddssource:candle,web,shellorfile). A tool’s name is held to letters, digits and_ . : / -, 64 at most, and engine error text is cleaned for the terminal, before either reaches a view, the journal or a later prompt;reask, withunknown: the reply named addresses no tool result showed, so the engine is asked once more, told what its first run already did. What streamed before it is void. In that second run, the same request answers the first run’s card, however it ended: never a second card or a second trade. An address the person pasted in their own message this turn counts as shown, labelledyou;ended, withoutcome(done,stoppedat the step cap,cancelled, orerror),reply(the text to show, after the address gate),labelled(each address and the most trusted source that showed it:candle,hacc,web,shell,fileor, last,you),markedandsteps.markedis the figure gate’s: each price, market cap, liquidity, volume, balance or holding the reply states as read data (a data word just before or after it, or its table column’s header) that matches no number this turn’s tool results showed (nor the core’s own words to the agent, nor the agent’s memory index, nor the person’s message), and that the reply doesn’t itself say is unread (in the figure’s paragraph or table, or the table’s caption: “from memory”, “training data”, “unverified”, “I called no tools”…), as{at, text}, where it starts inreplyand as written. A match is within 0.5%, or the figure’s own rounding with two significant digits or more, after K, M and B are read; a raw amount beside itsdecimals(and lamports and wei) counts as the whole-token amount too. A dollar figure on a balance or holding is the agent’s own valuation, so it isn’t checked. Nothing is held back: the view dims each marked figure with a?after it, “not read from a tool”. A figure the agent worked out and states without a data word (a conversion, a sum, a size) isn’t checked, so a mark always warrants a check. Percents, counts, dates and times are never figures, and while a reply streams nothing is marked yet. A turn that reached its engine also carriesengine,modelandansweredModel: the model the harness’s stream says answered,nullwhere it never says (Codex).
P&L. Candle is the book of record: each agent’s P&L is candle_get_profile_pnl for its own key’s prefix, read with its own key inside its request budget (kind pnl): every 5 minutes, every 60 s with the agent’s recent fills (candle_get_profile_trades, 50) while a view subscribes to pnl, and 5 s after each fill. A read that fails keeps the last book with its age and Candle’s words. The Candle Harness adds only what Candle can’t give yet, and never sums any of it with Candle’s figures:
- Samples. Each read’s totals are kept in
state/pnl.jsonl(0600), a minute apart at most per agent and thinned past 30 days to one an hour, so a restart keeps them. Candle’s P&L has no window (R3). - The window’s figure. For each agent, its total now less its first sample in the window from the same key; the figure is their sum, the Candle Harness’s agents’ and not the whole account’s (other keys on it aren’t read). An agent added mid-window adds only its change since its first sample. Where the samples start later than the window (more than 12 minutes after its start),
shortis true and the figure runs fromfrom.usdisnullbefore any sample in the window, never 0. Since last session alone (thesessionwindow,harness_pnl’s"session"and the away summary), an agent’s last sample at or before the mark, within 12 minutes of it, is its start: the core was running at the mark, so a gap where it stopped afterwards is counted whole. - Swaps. Candle’s book counts a swap into a token, but its trades read lists none, so the journal’s filled live swaps in the window are listed, never added.
- Paper. No Candle tool reads a key’s paper book (R4), so each agent’s paper trades are costed at their quotes and valued at the price book’s prices, ≈, apart from live.
status.pnl is {window, usd, label, from, short}: the window’s figure, always Candle’s live books (the status bar says “live P&L” in paper mode), with window the window as pnl.window takes it (24h, midnight, session, 18:00) and label its name (24h, since midnight, since 18:00, since last session). The kill report’s standing.pnl is the same.
The pnl topic carries {window, figure, agents, sum, wallets, trades, swaps, paper, samplesFrom}:
figure:{window, label, start, usd, from, short, agents},agentsgiving each agent’s{usd, from};agents: each{agent, prefix, readAt, book, failed?},bookbeing{totals, positions, wallets, closedRounds, crossed, oldestMarkAt, unmarked, truncated, hyperliquid}.totalsis Candle’srealizedGrossUsd,realizedNetUsd,unrealizedUsd,totalUsdandfeesUsd. A position is{mint, symbol?, chain, wallet, quantity, costBasisUsd, markPriceUsd, markedAt, marketValueUsd, unrealizedUsd, openedTs}, a figure Candle couldn’t give beingnull.crossedlists rounds Candle booked to this agent that another opened (openedBy, an agent’s name or a key prefix): on a shared wallet Candle books each round whole to one key, so an agent’s own figure can move when another sells what it opened;sum: the books’ totals summed, the Candle Harness’s agents’ (nullbefore any book);wallets: each wallet’s row summed across the agents’ books, with itsagents; the books add up, so this is the wallet’s total;trades: recent fills, newest first,{agent, at, mint, symbol?, side, status, usd, feeUsd, wallet?, signature?, error?};swaps:{at, agent, opId, title, usd, wallet?},usdbeing the card’s value when proposed;paper: by agent,{realizedUsd, unrealizedUsd, totalUsd, positions, unpriced}, every figure ≈,nullwhere a price or a sell’s proceeds is missing;samplesFrom: the first sample the Candle Harness holds, where a chart can start.
An activity event with kind: "kill.report" carries the kill report: what the kill stopped, the cards still open (sells, unstakes, claims and post deletes), the operations in flight (sent again as each one settles), where you stand from the last portfolio read, each wallet that trades inside limits (insideLimits: {wallet, name, words}; the kill doesn’t change the setting, and the pause holds its buys and swaps as it holds cards, while its sells still go), each live trigger the pause holds (triggersHeld: {name, agent, words}, one that buys, swaps or wakes; its sells still go, and resuming leaves it as it was), each bot whose entries the pause holds (botsHeld: {name, agent, mode, open, words}, open the tokens its exits still guard; its exits still sell, and resuming leaves it as it was), what the kill didn’t stop with the steps that would (for an agent with TEE wallets, disableTee: candle tee disable <address> for each, which stops it on Candle’s server, bots included, for good: Candle never re-enables a stopped TEE wallet, so its funds are then swept home and a new wallet takes its place), and how to resume. The activity snapshot holds killReport (null when not paused), so it stays pinned for a view that attaches later.
An activity event with kind: "line" carries one line for the activity pane, entry: {seq, at, agent?, text, tone, undo?, undoes?, hidden?, mint?, runNow?, firing?}, made from a journal row: cards proposed, approved, rejected, withdrawn and expired; fills and failures; refusals; paper trades; the kill switch; mode changes; typed CLI runs; processes left running after a turn; Hacc’s changes and how a replay ended; what a turn or the person changed in an agent’s memory; triggers saved, changed, fired, held, missed and woken; each web fetch an engine made, with its full address; a client dropped for not reading, and an engine on a newer major version than the one its holds were checked on (client.dropped, send.oversize and engine.newer_major rows, of the Journal’s core kind; agent.fetch of its agents kind). undo is a Hacc change’s id that hacc.undo takes; undoes names the change a later line undid, so its chip goes. hidden lines aren’t drawn. mint is the token the line is about, which picking the line links. A trigger’s firing is one line, firing: {name, said}: what it saw, then what came of it, sent again under the same seq as its cards and trades go, while the firing’s other rows come hidden (text keeps each row’s own words, for the Journal and notices). runNow is a trigger’s card that expired with nobody there, for triggers.runNow. The activity snapshot also holds the last 50 lines.
A portfolio event carries the last portfolio read, sent after each read and each new activity line: readAt (null before the first read), wallets (each wallet once, with the agents allowed on it, its SOL and token holdings valued at the core’s prices, null where unpriced, and for each holding the agents whose buys of it filled; a TEE wallet names the one agent whose key it’s bound to, none when it’s no agent’s, and carries Candle’s active flag; a TEE wallet Candle no longer lists carries stopped: true, with stoppedOutside: true where the Candle Harness didn’t stop it (left out of two reads in a row), its SOL and heldTokens read on chain over the RPC (or balanceError), until a sweep retires it; each wallet’s label is its kept name and name what it’s called, from the Candle Harness’s one book of names, with marked where the name is taken by address only), readOnly (agents denied the embedded wallet, with the owner’s command), paper (each agent’s paper book from this core’s own paper trades, at the quoted price: no Candle read returns a key’s paper book) and deposit. On the Free plan it also carries teeNote, since TEE wallets need Pro or Max. While the last read failed, it carries failed, Candle’s words for why, until a read answers; it’s also sent when those words change.
A wallets event carries what each agent can pay from, sent with each portfolio event and after each read of the keys’ wallets: agents (for each agent, payable: the embedded wallet when its key is allowed it and the TEE wallets bound to its key, each with its label and active, readAt, unreadable and the plan’s tier; and embeddedWallet, the key’s prefix, its defaultWallet from config, and defaultPin, the address that line was last found at, which it pays from while the line is unchanged), account (every TEE wallet on the account) boundTo (each TEE wallet’s address to the agent whose key holds it), names (every wallet the Candle Harness knows, once each, {address, kind, name?, owner: "candle" | "vault" | "harness", marked?, shadowed?}, where marked says why a name breaks the Candle Harness’s rules and is taken by address only) groups (each wallet group in name order, {name, prefix?, list, members, unknown, marked?}, where members are the wallets in it by address in names’ order, unknown the listed words that find no wallet, and marked why its name clashes with a wallet’s or an agent’s), empty (each Solana TEE wallet an agent pays from, by address, as Candle’s close-empty preview last counted it through the user’s own CLI with that wallet’s key, {count, netSol, totalSol, feeSol, transactions, firstOnly?, at}, or why it couldn’t be, {code, failed, at}; read 20 s after a view first attaches, every 30 minutes while one is, and 15 s after a trade, swap or close from it settles, never on approve), and, after a naming or a change to a group written at once, named or grouped ({changeId, words, at, agent?}, so a view puts the undo under the agent’s reply). A typed from <group> is refused by the same rule, WALLET_IS_GROUP. A view resolves a typed from <wallet> with it, by the same rule the core uses, and the core checks it again when the card is proposed.
A market event carries the token pane: followed (the token a Candle tool last showed the agent, or a card was proposed on; an address Candle has no market for isn’t followed), its market read and check (the token check’s summary and its flagged and unknown flags, each with when it was read), samples (the core’s own price samples, every 5 s while a view is attached, less often when the key’s request budget slows them or Candle rate-limits a read, the last hour), history (the token’s last hour from Hacc at 1m, read once when it’s followed and at most once a minute per token, before the first sample: the first candle’s open at its start, then each close at its minute’s end), historyRead ({source: "hacc", at}, or {source: "samples", at, words} when Hacc’s history didn’t load, with why; null while it’s read), change1h (from the sample nearest an hour ago, or Hacc’s last close at or before it; null when neither reaches back an hour) and trending (Candle’s on-fire list, read before any token is followed).
Each key’s request budget. Candle limits each key’s requests a minute (30, 300 and 600 on Free, Pro and Max), and the core’s background reads share a key with its trades and turns. So the core counts every call through each agent’s key over a sliding minute (an MCP call that reaches Candle’s server, and a candle CLI run with the key, counted as one), and background reads ask before they go. Nothing on the trade path is asked or held: a card’s call and a turn’s are counted, never queued.
- A key’s limit is its status read’s
tierCaps.rateLimitPerMin, else its plan’s, else Free’s 30 until it’s read. For two minutes after Candle answersRATE_LIMITED, the core works to half of it. - Background reads may use at most half of it, planned to 90% of that half, in this order: prices (the portfolio read cards are valued from), the linked token’s samples, P&L, pinned tokens’ samples, and Candle’s lists. Each gets one read a minute first (a pin counts one each), then each its full pace in order; what doesn’t fit is read less often. A background read also waits while the key’s calls in the last minute come within 5 of its limit, whoever made them, so a card always has room.
- A read that’s the same for any key (a token’s market and check, the lists) goes to the agent key with the most room, the active agent’s first among equals. Prices and P&L keep to their own key. A read someone asked for (the person’s pick, a token the agent showed, a card’s token) never waits, and counts so the planned reads make room after it.
status.budgetcarrieskeys(each agent’s{agent, limit, keyLimit, lowered, used, background, allowance}: the limit worked to, the key’s own, whether it’s halved after aRATE_LIMITED, its calls and background reads over the last minute, and what background reads may use) andpace, each slowed read by kind (prices,linked,pnl,pins,lists) as{everyMs?, why}:whyislimit(slowed to fit),rate-limited(Candle refused one in the last 2 minutes) orbusy(waiting on the room kept for a card, with noeveryMs). It’s sent again whenpacechanges. A pane whose read is slowed says how often it now reads, and why, in its title (“every 10 s (Candle’s limit)”); the doctor shows each key’s use from a running core.
A hacc event carries what the core knows of Hacc (api.hacc.fun), which the core reads with each agent’s own key. It’s sent when an agent’s plan changes, from the status read every 5 minutes, and when the stream’s state changes:
enabled(config’s[hacc] enabled);agents: for each agent, the account’splan(free,pro,max, ornulluntil read) andplanReadAt, andkey(ok, orrefusedwithwhyin words, when Hacc last refused it);stream: the signal stream (below): itsstate,wordswhere something’s wrong, theagentwhose key holds the socket,openSince, how manyrowsthe core holds, thecursor({at, id}, the newest signal heard),leftOff(where the last core left the tape, read fromstate/signals.jsonat start), andmine, personal signals’{state, words?, rows};lastRefusal: the last of Hacc’s refusals,{agent, code, message, at}, ornull.
It never carries a key or a row of Hacc’s data.
The signal stream. Hacc caps the sockets open on one key (8; the web app and other apps count), so the core opens one, /v1/signals/stream with the first configured agent’s live key Hacc takes, and fans it out to every subscriber. It opens when anything subscribes to signals (or an agent reads the tape) and closes 5 minutes after the last need. Its state is off ([hacc] enabled = false), closed, opening, open, filling (reopened, reading back what came in while it was closed), reconnecting (after a drop, or 60 s with no frame, at 1, 2, 5, 15 and then 30 s), full (Hacc’s cap: tried again every 30 s, while Hacc’s reads still answer) or no-key. A key Hacc refuses passes the socket to the next agent’s.
- A
signalsevent carries{rows}: new signals, oldest first, each as Hacc sends it with every string cleaned for the terminal. The first after the socket opens carries Hacc’s snapshot (its newest 100). - Nothing recoverable is lost. After any reopen the core reads the tape back from the last signal heard (
GET /v1/signals?since=<its created_at>&order=asc, a page of 200 at a time), holding what the socket sends until it’s done, and sends each signal once, by id. A stretch it can’t read back (Hacc refuses, or more than 1,000 came in) is sent as a row of its own,{kind: "gap", id, created_at, from, to, words}, where it falls. Every filter keeps gap rows. - The
signalssnapshot is{state, rows}: the newest 100 rows the core holds that match the filter (it keeps 500). state/signals.json(0600) keeps the newest signal’s{at, id}, so a later core knows where this one left the tape.signals:mine: Hacc doesn’t push the account’s own rules’ signals, so the core polls/v1/me/signalswith Hacc’snext_aftercursor every 30 s while anything is subscribed (5 minutes after Hacc says the plan doesn’t reach it). Its events are{rows}; its snapshot{state, rows}, the newest 200.
Candle’s lists. Each is one candle_get_feed read by its bucket, whole at limit 20 with only the fields its pane draws (no since_seq: a delta came back reset within 20 s). The core reads a list while any subscription’s filter names it (a subscription with no filter names all four): every 15 s, and On Fire every 60 s when no other list is wanted beside it. On Fire is also read every 60 s while any view is attached, for market’s trending. Every read goes through the key’s request budget (the lists read, last in its order), so each may be read less often; status’s budget says so. A feeds event and snapshot carry {<bucket>: list} for the subscription’s own buckets, each list {bucket, at, rows, matched?, failed?, state?, behindS?}:
atis when the list shown was read,nullbefore any answer;rowsare{mint, symbol, name?, marketCap, priceUsd, change1h, createdAt?, graduatedAt?, progress?, lastTradeAt?}, a figure Candle didn’t sendnull(never zero),change1ha fraction, times in epoch ms;matchedis how many the list holds in all;failed: {at, words}: the last read failed, Candle’s words or its code, androwsare the last good list’s. A read that never reached Candle (its server not up, a wait for room in the key’s budget) isn’t a try, and is asked again in 5 s;- Final Stretch only:
state: "off"where Candle’s server doesn’t have it (it refuses the bucket as unknown), asked again every 10 minutes;state: "behind", withbehindSwhen Candle says, unlessfeed.ok,exact.okand!staleall hold, the only case in which its empty list means nothing qualifies.
Research. For the linked token, while any subscription’s filter names a part (no filter names all four), the core reads Hacc with the first agent Hacc takes: its top traders over 24 hours (traders), its own signals (/v1/signals?asset=<mint>&limit=30&quality=all, signals), its profile and its launch overview (check’s holders, top-10 share, liquidity, age and stage; mentions’ posts). Each is read at most once a minute for each token, a refusal included; a key Hacc refused isn’t a try, and goes again in 2 s with the next key. check’s Candle half is the token pane’s own candle_token_forensics read, kept whole and read again every 5 minutes while check is wanted. When the link moves, the new token is sent at once with its parts reading, and an answer for the old token is never sent under the new one. A research event and snapshot carry {token, check?, traders?, signals?, mentions?} for the subscription’s own parts, token being {mint, chain, symbol?} or null. Each part is {at, data, failed?, cant?}: at null while its first read runs; failed: {at, words} with the last good data; cant, the words for why it isn’t read at all (Hacc off, no key it takes, or a plan that doesn’t reach it), with no read made. check is {candle, hacc}, two parts. Every string is cleaned for the terminal; a post’s text is third-party data.
The agent snapshot holds each agent’s recent conversation (each exchange with its marked, when it has any), its running turn and how many messages wait.
An engine event carries agents (each configured agent’s {engine, model?, from, preset, presetFrom, last?, tools?}, where from is saved for a choice made, or config for config.toml’s engine.default, preset is quant, trader or bare and presetFrom is saved for a preset picked or config for config.toml’s (Quant when unset), and last is what its last turn that reached an engine ran on, {engine, model, answeredModel}, read back from the journal after a restart, and tools is its shell and tool settings when config.toml sets any, {shell, allow?, deny}) and engines (each engine in this build by id: label, minVersion, check, which is {ok: true, version} or {ok: false, version?, message} and null before the first check; capabilities: webSearch, webFetch, files (own, write or none), shellList and deltas; signedIn, true, false or null when unknown, asked of the harness or by whether its sign-in file exists, never by reading it, and for an engine with no check of its own (OpenCode, Pi) whether it lists any model; signIn, what to run to sign in or add a model; needsModel for an engine that can’t run without a model by name (OpenCode, Pi); defaultModel, the harness’s own default by name once a list is read; and modelsReadAt, null before the first read). Every engine is checked as the core starts and its list read; the event is sent again after each read, each choice and each turn’s end. It carries no models: engine.models reads them.
An engine turn’s journal rows (agent.turn_started, agent.turn) name the engine, its engineVersion, the model picked (null for the engine’s own default) and the preset it ran as. agent.turn also names the answeredModel for a turn that reached its engine. A Claude Code turn also carries claudeVersion. The figure gate’s count is in agent.turn too: figures, how many figures the turn’s reply stated, and marked, those no tool showed, as in ended.
Research tools. Beside Candle’s tools, tools.list offers every engine research_read {path, offset?, limit?} and research_list {path?}, answered by the core and held to the agent’s research folder: the path is resolved (relative to the folder, ~ expanded, every symlink followed) and refused unless it ends inside the folder (OUTSIDE_RESEARCH_FOLDER); research_read then reads only the file that check found: on Linux the opened file’s own path must be inside the folder; elsewhere no part of the checked path may be a link and the opened file’s device and inode must match, which a process swapping links at that same moment (an agent with its shell on) could still slip past. It reads text files up to 256 KB, at most 2,000 lines at a time, and answers {path, text, fromLine, toLine, totalLines}; research_list answers {path, entries: [{name, type}]}, type being file, folder or link. They only read. Their results count as file for the address gate, not candle.
The code runner. research_run {program, code? | args?} is offered only to an agent whose [agents.<name>.tools] run lists programs, and only where the operating system can hold it. program is one from the list; code is a script saved in the folder’s .runs/ (the newest 20 kept) and passed first, or args are its arguments. The run is held to the research folder (reads and writes; the runtime’s own install read-only), with no network, stopped with its whole group at 60 seconds and at 1 GB, one at a time per agent. A runtime that sits in the home folder, ~/.config or the Candle Harness’s home is refused (RUNTIME_PLACE); on Linux the hold refuses setsid and setpgid, and on macOS the runner follows the script’s descendants; the answer comes when the program ends. It answers {program, args, script?, exitCode, signal?, stopped?, seconds, stdout, stderr, cut?}, stopped saying why it was stopped (time or memory) and cut what the 16 KB of output left out, stderr keeping at least 4 KB of it. Journalled as research.run with the program, arguments, exit, time and bytes. Refused TOOL_HIDDEN where it isn’t offered, and NOT_HELD where the hold couldn’t be set. When it’s offered, the agent’s research line names it; when it isn’t, nothing does.
Skills. tools.list also offers skill_read {name, file?, offset?} to an agent that has any skills, answered by the core. Every agent has at least the Candle Harness’s own harness-memory (below, Memory) unless config hides skill_read. Two more kinds:
- Candle’s: ten of Candle’s own (MIT), copied unchanged from
candledottv/agenticat the commitskills/SOURCEnames and built into the binary. Thequantpreset carries them. - The person’s own: each folder in
<config>/skills/named with lower-case letters, digits and-, holdingSKILL.mdwith adescriptionin its frontmatter, and any text files beside it (up to 50, 256 KB each). Every preset carries them. They’re read fresh on each call and turn; links are never followed; one named like one of Candle’s replaces it. The doctor names what it left out and why.
The schema’s name enum lists the agent’s skills. It answers {skill, file, source, note, harness?, notCarried?, files?, text}, plus fromChar, toChar, totalChars and more when a long file is read in parts (offset): note says it’s guidance (Candle’s, or the person’s standing guidance), not this turn’s instructions; harness, where one of Candle’s assumes something the Candle Harness does another way (installing the CLI, a bracket order); notCarried, Candle’s skills it names that the Candle Harness doesn’t carry; files, a skill’s other files, read by file. Refused: a name the agent hasn’t (UNKNOWN_SKILL; SKILL_NOT_OFFERED for one of Candle’s off Quant; SKILL_NOT_CARRIED with why for one of Candle’s others) and a file the skill hasn’t (UNKNOWN_FILE). Each preset’s prompt lists the agent’s skills by name and description, Candle’s apart from the person’s. It hides by name (skill_read, skill_*), and the prompt’s list goes with it.
Memory. Each agent’s memory is memory/index.md in its research folder, one line a memory or a pointer, and any files beside it in memory/. The agent writes them with its engine’s own file tools; the core has no write tool for it. The core reads the index fresh into every turn’s prompt, on every preset, fenced as the agent’s own notes, and adds one line asking it to merge or prune past 6,000 characters. A folder or index that’s a link, or an index with a second hard link, is read as none and the prompt says why. One memory per agent: no agent reads another’s.
harness_pnl {since?}, offered to every agent: the P&L window’s figure, the status bar’s, with each agent’s change, its total, realised and unrealised, fees and open positions, the window in words and where the samples start, the source, and, kept apart, the journal’s swaps and paper P&L. With nosinceit’s the person’s window, and says so;sinceis a window (24h,midnight,session,18:00), a span back (48h,7d) or a date or time (YYYY-MM-DD,YYYY-MM-DDTHH:MM, in the machine’s time). Its description tells the agent to name the window in its answer. Within the 16 KB budget.harness_history {date?, since?, token?, text?, before?, limit?}, offered to every agent: its own past turns (kind: "turn", with the person’s words, its reply and its tools), cards (kind: "card", with how each ended, andranwhen the core ran it without a card or a typed trade went instant), paper trades (kind: "paper") and the kill switch going on and off (kind: "kill", which stops every agent), from the journal, newest first. A turn a trigger woke haswokenByandwoke(what woke it) in place ofperson; a message fromcandle-harness askhasfromOutside; a card’sbynames the trigger whose firing proposed it, or the trigger that woke the turn.dateis a day or a range (YYYY-MM-DD..YYYY-MM-DD) in the machine’s time.sinceis"last session", from the main view’s last close (the P&L window’ssessionmark; after a core that died with the view attached, the journal’s last row before the nextcore.started, when later), orharness_pnl’s other forms; the answer’ssincesays from when, or that there’s no close yet.tokenandtextmatch anything in an item, in any case;beforetakes an item’sseqto read further back. Within the 16 KB budget, withleftOutandmorewhen it’s cut. Never another agent’s rows.harness_limits {wallet?, setting?, perTradeUsd?, perDayUsd?, reason?}, offered to every agent. With none ofsetting,perTradeUsdandperDayUsd, a read:{agent, mode, withoutCard, wallets, limits, notListed, changing}.withoutCardis what runs without asking, in sentences: paper mode, the kill switch’s pause and a held key first, then each wallet the agent pays from (inside limits, its figures, the day’s use and what’s left, and that every sell runs without a card, any trade with a slippage bound looser than the default excepted; or every trade a card), the agent’s own Candle Harness limit, and that every other kind is always a card.walletshas each wallet’s setting, figures, use today without a card, the other agents paying from it and the desk’s notes.limitsis the status topic’slimitLinesfor the agent, each{by: "Harness" | "Candle", words}.notListednames Candle’s loss limits and circuit breaker, which no tool reads. Withsetting: "ask"or a lower figure (a figure alone means"inside", lower), it proposes the narrowing card (kindautonomy,coreOpId) and answersAPPROVAL_REQUIRED; one that would widen is refusedWIDENS_AUTONOMYand journalled astool.refused, and a resend under the card’scoreOpIdreads that card. Its description says it can’t widen anything. One prompt line a turn says what runs without a card, in live mode only where a wallet the agent pays from trades inside limits, beside the wallets line.harness_group_wallets {group, add? | remove? | prefix? | delete?}, offered to every agent: one change to one group aswallets.groupdoes, exactly one ofaddorremove(wallets by name or address),prefix(text, ornull) ordelete: true. It’s made at once and answered{success: true, done}, and the person can undo it under the agent’s reply (walletstopic,grouped). A group never pays: a trade naming one is refusedWALLET_IS_GROUP, naming a wallet in it.harness_name_wallets {names: [{wallet, name}]}, offered to every agent: names wallets aswallets.namedoes, checked the same way. A name the Candle Harness keeps is written at once and answered{success: true, done}, and the person can undo it under the agent’s reply (walletstopic,named). A TEE, linked or vault key’s name is a pane card (tee renameorvault rename, a batch as one--pairs-fromrun with its preview), answered withcards, eachAPPROVAL_REQUIREDascandle_clianswers a pane row. A refusal is journalled aswallets.name.refused. There’s no prompt line: its description says what it does.harness_triggers {action, line?, replace?, name?, all?, reason?}, offered to an agent whose account is on Pro or Max (not on Free, nor before the plan is read).actionisread,proposeoroff.readlists every trigger, as thetriggerstopic does, with how its command runs now.proposetakes the exact line and makes a trigger’s card (below, Triggers), answeredAPPROVAL_REQUIREDwithtrigger: {line, words, runs}and anywarning; nothing is saved before the press. Withreplaceandname, a change that only narrows a trigger running as approved (a smaller amount, a level harder to reach, a longer cooldown, a filter that lets fewer tokens through) is made at once and answered{success: true, done, line, words, on}; for a trigger that sells, a smaller amount or a level harder to reach is a card; anything else is a card.offswitches off one (name) or every one that’s on (all), at once: any trigger in a turn the person started, only its own agent’s in a woken turn (ANOTHER_AGENTS;allthen leaves the others on and names them). At-once narrowing is for the agent’s own triggers; another agent’s is a card. A proposal from a turn that began on paper, once the core is live, isMODE_CHANGED. Refused:TRIGGERS_NEED_PLAN,PLAN_UNKNOWN,INVALID_TRIGGER,NAME_TAKEN,UNKNOWN_TRIGGER,UNKNOWN_AGENT, andWOKEN_TURNfor anything butreadandofffrom a turn a trigger woke. It never switches a trigger back on. Theharness-triggersskill, throughskill_readon every preset, sets out the grammar; there’s no prompt line.- The
harness-memoryskill, the Candle Harness’s own, throughskill_readon every preset: how to write a hook, when to point rather than copy, and how to merge. One of the person’s own namedharness-memoryreplaces it. - The
memorytopic carriesagents, each{dir, entries: [{id, text, pinned?, heading?}], files: [{path, bytes, link?}], chars, long, unread?}, published when a turn or the person changes it.pinnedlines sit under the index’s## Pinnedheading, first (memory.pin). A heading is an entry too (heading), deleted like a line and never pinned; only blanks, bare fence marks, rules and the## Pinnedheading itself aren’t. memory.changejournal rows say what changed:{agent, by, turnId?, added, removed, written, deleted, pinned?, unpinned?, more?, cleared?, rewritten?}, with up to 20 of each and lines cut at 300 characters. A change to a heading alone is a row, andrewrittenmarks an index written again with every line kept. A turn’s row (by: "agent") is written after itsagent.turn, from a snapshot taken as the turn started; the person’s delete during a turn isn’t counted as the agent’s.
Hacc’s tools. tools.list also offers seven of the core’s own tools over Hacc, Candle’s market data engine, each with a view and Hacc’s own parameters: hacc_signals (tape, by_token), hacc_token (market, history, top_traders, venues, holders, profile, launch_overview, wallet_pnl, search), hacc_market (gainers, prices, metas, activity, cohort_overlap, final_stretch), hacc_wallets (tracked, wallet, groups, group, labelled_trades, stats, activity, token_breakdown, portfolio, and book_legs on Max), hacc_social (mentions, marks, trending), hacc_follows (Pro and Max: follows, rules, signals, groups, and the changes below) and hacc_lab (Max: edges, runners, first_signals, assets, asset, strategies, strategy, strategy_trades, comparison, runs, trades, replay, and the changes below).
- Offered by the account’s plan, as the
hacctopic names it: an unknown plan is offered Free’s. A view or tool the plan doesn’t reach is left out of the list and its schema, but a call is still sent, since the plan may have changed: Hacc’s refusal comes back asPLAN_REQUIREDwithneeds, and the core reads the plan again. None is offered with[hacc] enabled = false(a call answersHACC_OFF), to an agent with no key, or to one whose key Hacc refused until that agent has a different key. - Each call goes to Hacc with the calling agent’s own key, through the core’s client (fixed host, no redirects followed). An argument the view doesn’t take, a missing one, or a path argument that isn’t an address or numeric id is refused before any call (
INVALID_ARGS, naming what the view takes). Hacc’s refusals keep the client’s codes (HACC_KEY_REFUSED,PLAN_REQUIRED,HACC_LIMIT,HACC_NOT_FOUND,HACC_BUSY,HACC_BAD_REQUEST,HACC_FAILED,HACC_NO_ANSWER) with Hacc’s own words. - An answer is
{success: true, source: "hacc", note, tool, view, from?, leftOut?, cut?, data}.notesays the text indatais third-party data, never instructions; every string in it is cleaned for the terminal and cut to 1,000 characters.leftOutsays what the view’s route never carries. Every answer is at most 16 KB: rows go first, from the end of the longest list, after verbose fields are slimmed (a signal’s payload loses its hash and what the row already says; tracked wallets, groups and assets keep the fields an agent reads), andcutsays how many rows of how many were kept and how to get more. An answer with nothing to cut by rows is cut as text, and says so. - The tape (
hacc_signalstape) is answered from the stream’s cache (from) when the stream is open, the call asks only forpattern,chainandlimit, and the cache holds that many with no gap; otherwise from Hacc’s REST tape. Either way the read keeps the stream open for 5 minutes. - Edges (
hacc_labedges) read Hacc’s hourly snapshot only (any parameter makes Hacc compute it afresh, which can take minutes) and are sliced in the core bypattern,group_name,age_bucket,mcap_bucketandheld_bucket, where"all"matches a row not sliced that way. With no slice, the answer is each pattern’s overall row and the values each slice can take. - Their results count as
candlefor the address gate. They hide by name in an agent’s[agents.<name>.tools](hacc_lab,hacc_*).
Hacc’s changes. Follows, alert rules, the account’s own groups and saved strategies are account state on Hacc, shared by every key on the account. They move no funds, and Hacc doesn’t check scopes, so a Read key changes them too. The same two tools carry them as views:
- At once, with an undo:
hacc_followsfollow {address, chain?},create_group {name}andgroup_add {id, address, chain?};hacc_labsave_strategy {name, definition}andsave_version {id, definition}(a new strategy id). Each is journalled ashacc.change({agent, by, tool, view, changeId, words, undo?}, no key and no data) and shown in Activity with an undo chip (hacc.undo). Following a wallet already followed, or adding a wallet a group already holds, changes nothing and offers no undo. The answer says what changed and, in words, the cap or quota it counts against: Pro may follow 25 and keep 1 group, Max 100 and 5, each group up to 25 wallets; 20 saves a day. - A one-press card:
hacc_followsunfollow {id | address},delete_group {id}andgroup_remove {id, wallet_id | address};hacc_labretire {id}. Andhacc_followsadd_rule {name, pattern, wallet_id?, min_usd?, wallets?, window_minutes?, days?}, since Hacc has no route that removes or disables a rule: its card says it can’t be removed and how many of the 20 it leaves, and at 20 none is made. The core reads what it names first (a removal of something that isn’t there answersHACC_NOT_FOUNDand makes no card), checks a rule’s parameters by its pattern, and sends nothing until the press. The card’skindishacc, itswalletnone, its title what goes (“Unfollow 7xKX…3fGh”), and the agent reads its outcome withcandle_get_operation(kind: "hacc"). One Hacc didn’t answer is read back from Hacc’s state and never sent again:operations.resolverefusesresend(NO_RESEND). - Replays are reads:
hacc_labrun_replay {definition, from, to, asset_ids?}waits up to 45 s (under Codex’s 60 s for an MCP call) and answers with the result, cut to the budget, or with itsjob_idto read withreplay; the core polls on and journalshacc.replaywhen it ends, which Activity shows.cancel_replay {id}stops one. Hacc’s one-at-a-time rule answersHACC_BUSYwith the runningjobId, and its quotaHACC_LIMITwithquotaandused. - A definition Hacc refuses answers
HACC_BAD_REQUESTwith Hacc’s ownpath: message. - The kill switch neither stops nor lists any of it: none of it spends.
Shell access and tool permissions. Each agent’s [agents.<name>.tools] in config.toml sets them; a change takes effect when the core next starts.
shellis"off"(the default),"everything", or a list of commands, each a command and optionally its first arguments (["ls", "git log"]). With a list, only a single plain command whose words start with an entry’s runs: nothing joined with;,&&or|, redirected, or substituted with$(…). Claude Code holds a list with its own permission rules; OpenCode with its own and the Candle Harness’s gate; Pi with the Candle Harness’s gate; Grok with its own rules, which also always run its read-only commands, can’t list its dangerous ones (rm,chmod,kill,git push…), and end the turn at a refused command. Codex can’t hold one, so there a list means off, and the picker and the doctor say so. Codex’s shell runs in Codex’s own sandbox (writes held to the research folder, no network); Grok’s under the Candle Harness’sstrictprofile, so it never reads the core’s key folder. Engines sets out every engine’s differences and limits for users.- With the shell on, reads aren’t held to the research folder on any engine, since a shell can read anything you can, and cards and the core’s limits don’t cover what it runs. The picker says so in amber beside each engine the agent’s shell is on for, the doctor names the agent, the agent is told never to trade or move funds through it, and the journal’s
agent.turn_startedandagent.turnrows carry theshella turn had. allowanddenyare lists of tool names: the research tools (web_search,web_fetch,read,write) and Candle’s or the core’s own astools.listnames them (candle_trade,research_read,harness_show_wallets,hacc_lab), with*for any characters (candle_get_*,hacc_*). A denied tool is hidden from the agent entirely; whenallowis set, so is every tool it doesn’t name (a headless agent has nobody to ask, so a tool that isn’t allowed is hidden). They only narrow: no list adds a tool beyond the research set and Candle’s.readcoversresearch_readandresearch_list. The core hides Candle’s and its own tools from the harness for every engine; each engine leaves the hidden research tools out of its own list, except Codex’s patch tool, which has no switch: withwritehidden, Codex runs read-only and the write is refused. The shell isn’t an allow or deny entry:shellsets it.
While you were away. When a main view (not a popped one) opens with no other open, the gap since the last session’s mark ends: the main view’s last close, or, after a core that died with the view open, the journal’s last row before the next core.started. A gap of 10 minutes or more makes a summary, published on away at once and filled as its reads at start answer; a shorter one leaves the last summary as it was. summary is {id, from, to, madeAt, forView, coreDown, coreGoneThroughout, turns, trades, cards, changes, stopped, triggers, waitingNow, cut?, pnl, outside, signals, holdings, counts, happened}:
forViewis the clientId of the view whose attach made it, so that view alone shows the note on startup.coreDownlists each stretch of a minute or more when the core wasn’t running (from itscore.stopped, or a dead core’s last row, to the nextcore.started);coreGoneThroughoutsays it wasn’t running at all from the mark to this attach.- From the journal, in the Journal’s words (
{at, last, agent?, mode, text, tone, mint?, symbol?, seqs}, newest first, 100 each at most;countshold every one):turnsthat ended in the gap;trades, every trade with a card or without and how it ended (outcome:filled,failed,refused,unknown,sent,abandoned,rejected,withdrawn,expired,waitingorapproved;withoutCard,instantandpaperwhere they apply, and the card’susd, ≈); the othercards;changes, the agents’ changes on Hacc and in memory;stopped, the kill switch, refusals at proposal, a Candle connection or Hacc stream that failed; andtriggers, what they did: fired, waited for the person, held by the kill switch or the plan, missed, woke an agent or skipped a wake (counts.firedis how many firings). A trade or card a trigger proposed carriestrigger, and one that expired with nobody there and wasn’t run againrunNow, its card’s id. A card proposed up to 15 minutes before the close counts where it ended in the gap.cutsays from when, if the gap held more than 20,000 rows. - From three reads at start, each a read the person asked for (
budget.ask: it never waits, and the planned reads make room after it), eachstate: "reading" | "read" | "failed"withwordswhen it failed:pnl, each agent’s P&L book read now and the figure since the mark, asharness_pnl’s"since last session", from the mark’s own sample, so the hours a stopped core missed are in it;outside, each agent key’s fills since the mark fromcandle_get_profile_trades(Candle’s newest 200;reachesBackwhen they don’t reach the mark) that the Candle Harness didn’t send. A fill is the Candle Harness’s by itsclientTradeId(the core’s opId, on both routes), else its signature; a Candle Harness trade whose outcome is unknown claims the one fill of its agent, token and side with no id made after it was sent (claimed), and where there are several each is inmaybe. Andsignals, Hacc’s personal signals since the mark by date (count, thenewestsix,morepast 500),"off","needs-plan"or"no-key"where they can’t be read. holdingscompares what each wallet held at the main view’s close (the last portfolio read then, kept instate/away.json) with the first portfolio read after the attach:changes, each{wallet, name, asset, symbol?, then, now}."unknown"when the view didn’t close cleanly,"failed"when no read came within a minute.happenedis false for a quiet gap: nothing in any list (the triggers’ included), no fill outside, no signal and no change in the wallets.
Each subscribed topic starts with a snapshot (snapshot: true). A client that reconnects can pass sinceSeq per topic. If the core still holds every event since then for cards, operations or agent, it replays them instead of sending a snapshot. The core holds the last 1,000 events per topic. A message too large to send is left out and journalled (send.oversize), never thrown.
Cards, in brief
Section titled “Cards, in brief”Limits, with who enforces them. Each agent in status carries limitLines, every limit on it as {by, what, words, wallet?}: by is "Harness" (the core, for what it runs) or "Candle" (Candle’s server, per key, whatever runs it, but only for what Candle builds), and words start with it ("Harness · $2.00 a trade", "Candle · $10.00 a UTC day, all volume", "Candle · 0.04 SOL a buy"). what is trade or day (the agent’s Candle Harness limits), wallet-trade or wallet-day (the figures of each wallet the agent pays from that trades inside limits, autonomy.set, for trades without a card, counting only those, across every agent paying from it), volume (Candle’s txLimit), asset (Candle’s per-asset cap on a buy), none (Candle caps nothing on the key) or unread (its status isn’t read yet). An agent whose key Candle refused with KEY_LIMIT_REACHED carries keyHeld: {until, why} (why is window, lifetime with until: null, or unavailable for Candle’s 30 s limit_unavailable): its buys and swaps are refused with that code at proposal and at approval until it lifts, and its sells go. One Candle stopped with RISK_LIMIT_REACHED (a loss limit or its circuit breaker, which only the owner clears in Candle’s Agent Console, and which no MCP read shows) carries keyHeld: {until, why: "stopped"} for 5 minutes: every trade on it, sells included, is refused with that code until then, and then Candle is asked again. A buy over the key’s Candle cap for the asset it spends is refused at proposal with SPEND_LIMIT_EXCEEDED, in Candle’s words.
Trades with no card. On a wallet set to trade inside limits (autonomy.set), a live buy, sell or swap runs at once: it’s proposed as any card is (the same pricing read, hash, address gate, limits, pause and key hold), then approved by the core in the same call as how: "inside-limits", on both routes (Candle’s MCP, and a TEE wallet’s candle swap). It does when it’s valued (a sell needn’t be), at or under the wallet’s per-trade figure, and, with it, at or under its day figure, which counts only trades that ran without a card, from every agent paying from the wallet, since local midnight; a sell always fits. A launch, stake, claim, post or any other kind keeps its card. Otherwise it’s a card as before, whose cardBecause (and display.cardBecause) says which figure, or “no price”, or that the same trade from the same agent is still in flight without a card. Over the agent’s own Candle Harness limits, paused, or on a held key, it’s refused as any card is. The day’s spend is recorded before anything is awaited, so two agents racing on one wallet can’t both pass its figure. The journal’s card.approved carries inside: {words, perTradeUsd, perDayUsd, usedUsd}, what it was checked against with the day’s use before it, and is written before operation.submitting. The agent’s answer is the operation’s (success: true once filled, else OPERATION_PENDING or OUTCOME_UNKNOWN), with approved: "without a card, inside the embedded wallet's limits ($1.00 of $1.00 a trade, $2.00 of $5.00 today)"; a card a figure sent to the person answers APPROVAL_REQUIRED with why. A view’s typed trade takes the same path, but under cards.typedTrades = "instant" it always runs as instant, inside the figures or over them, and never counts towards them. It holds whether a view is attached or not. A kill switch’s fresh sells, a re-quote and a late approve’s fresh card stay cards.
The agent narrows a wallet’s autonomy on a card. A card of kind: "autonomy", tool harness_limits, wallet none, with params: {wallet, name, to}: back to asking, or lower figures, on a wallet the agent pays from (a figure left out stays as it is). Anything that would widen it (turning it on, or raising either figure) is refused at proposal with WIDENS_AUTONOMY, naming settings ▾ → approvals; the same setting is ALREADY_SET. Nothing changes before the press. Approved, it runs in the core, checked again against the setting then: if the person has set it lower since, it would widen it and is refused (WIDENS_AUTONOMY). It’s journalled as autonomy.set with how: "card", the agent and its opId; one a restart cut off is read from the setting. The kill switch leaves it open: it’s less access. The agent reads its outcome with candle_get_operation (kind: "autonomy").
A trade or swap an agent proposes in live mode becomes a card: valued in USD when it’s proposed (from prices read fresh at that moment, or for ETH on Hood the portfolio’s price no older than 20 s, read again when older, or from Candle’s quote once its MCP server offers one; the card’s display.pricedAt and pricedBy say which), checked against your Candle Harness limits, bound by a SHA-256 hash of exactly what will run, and alive for 60 seconds. A view approves it with cards.approve {cardId, cardHash}: one key press, no network call before the trade is sent. Paper trades need no card. What trades spend (SOL, USDC, ETH, USDG) is refused as a trade’s token, live and on paper (BASE_ASSET). A proposal that arrives for a turn already stopped makes no card at all, for every kind of card a harness proposes: it’s refused TURN_ENDED and journalled as card.refused. The core writes every card and every outcome to its journal before acting on it, and an outcome it can’t confirm is read back by the same id, never re-sent under a new one. A trade or swap error with no stage is such an outcome, unless its code is one Candle documents as answered before anything is sent. A kill switch that goes on while an approval is being written refuses it before it’s sent: the operation ends refused with code PAUSED, and the kill report lists it in flight until then.
A card’s display is for showing, and isn’t part of its hash. Besides title (one line of text, for any view), it carries the parts a compact card is laid out from:
headline: what was asked, in the unit it was asked in, such asBUY $1.00 of MOONorSELL 50% of ORE;gets,sellsandspend: figures as{amount, asset}, for the view to round;valueUsd: the card’s value in dollars, when the core could value it;nearLimit: the limits line, set only when the trade is within 20% of a Candle Harness limit, or the core couldn’t price a buy or swap. An unpriced one that fills is valued then at what it spent, journalled ascard.valued {opId, agent, usd, asset}, and counts toward the agent’s day;slippageandslippageLooser: amaxSlippageBpsthe agent set that isn’t its route’s default (Candle’s 100, the CLI’s 50 on a TEE wallet’s card), in words such asslippage 50%, the default is 1%, withslippageLooser: truewhen it’s looser. A view shows it on the card and on the receipt of a trade that ran without one;mint, shown short on a trade card’s who line and its receipt, andotherMint, an amber line when the same name is held in a wallet, or was on an earlier card, for another token;publishesandlaunchTerms: on a launch, the description and the hosts its links and images point to, and, in amber, any terms that aren’t the defaults;modeDiffers: an amber line when a live bot’s trade or proposal arrives while the core is on paper;askAgain: once expired, the card is asked for again, never re-quoted;walletLabel: which wallet pays:embedded, or a TEE wallet’s label (its short address when it has none);paysFrom: on a TEE wallet’s card, the line naming it, such aspays from tee-one (TEE · TeeF…1111);resendNote: what re-send does for this card when an answer is lost, such as on a TEE wallet’s card, where it reads the trade back and the CLI never sends it twice;preview: what Candle’s dry run or a read said when the card was proposed: a launch’s curve and ceilings, a stake’s pool wait and costs, what a fee claim would pay, a post’s text;publicNote: on a launch, a post or a profile edit, that it’s public and can’t be taken back;warning: a line to read before approving, such as a TEE wallet that revoking a key would leave with no key to trade it;triggerandfired: on a card a trigger’s firing proposed, its name and what the firing saw, in words, in place ofwhy;woken: on a card proposed in a turn a trigger woke, that trigger’s name;triggerCard: on a trigger’s own card,{line, words, runs, kind, agent?, replaces?}(below, Triggers).
A typed cli.run that narrows access returns the same warning with its result, since it runs at once.
Launches, staking, claims, profile edits and posts. Candle’s own tools for these are cards too, in live mode only, from the embedded wallet. A card’s kind is launch (candle_launch_token, candle_launch_and_seed, id clientLaunchId), stake (candle_stake, candle_unstake, candle_finish_unstake, id clientStakeId, payer always main), claim (candle_claim_fees, candle_claim_staking_reward) or public (candle_update_profile, candle_create_post, candle_delete_post); the core mints every id. A launch, a stake and a fee claim are dry-run when proposed, under the card’s own id, and a dry run Candle refuses makes no card. A launch takes its images as URLs only, so its dry run publishes nothing. A launch’s seed is valued and limited like a buy, and its card has side: "buy". These cards live 10 minutes. A call with dryRun: true is a read, and runs at once. A lost answer is read back by candle_get_operation {kind: "launch"} for a launch, and replayed under the same id and body within the card’s lifetime for staking and a staking reward claim; a fee claim, a post and a profile edit wait for the user’s re-send or abandon. Paused, the kill switch withdraws and refuses launches, stakes, profile edits and new posts, and keeps unstakes, claims and post deletes; the kill report’s stillOpen lists them with their kind and side.
The CLI’s card rows. A few candle commands are cards through candle_cli, run with the agent’s key on route: "cli": stake add, stake remove and stake finish with --wallet <a TEE wallet> (kind stake), and image upload <path> (kind upload). The card’s params are {argv, identity: "agent", agent, json: true, row}, where row is the command ("stake add") and argv is exactly what runs: the wallet by address, the core’s own id on --client-stake-id (or --client-trade-id), --yes where the command takes one, --json, and --rpc-url @config where it takes one: the saved argv never holds the RPC address itself, which goes in only as the command runs (and at pane.redeem), so it’s in no card, operation or journal row. The hash covers all of it; an id the proposer gives is refused (CLIENT_ID_BY_HARNESS). Staking from the embedded wallet, or with no --wallet, answers USE_MCP_TOOL: it’s Candle’s own tool. image upload takes the only path the agent may give: a file whose real path is inside that agent’s research folder, at most 10 MB, a PNG, JPEG, GIF or WebP. The file is copied into the core’s state folder when the card is made, and params.file carries the copy’s path, sha256 and bytes; the copy is checked again before the run (UPLOAD_FILE_CHANGED otherwise), and the operation’s result.imageUrl is the hosted URL. lp add, lp remove, lp claim, launch --wallet <a TEE wallet> and wallets close-empty --wallet <a TEE wallet> (kinds lp, launch, close) are built and held: candle_cli answers NOT_YET, saying why. wallets close-empty from the embedded wallet (no --wallet, embedded, or its address) is never an agent key’s: NEEDS_TRANSFER_WRITE. Once switched on, a close’s card says Candle’s last count, and typed (cli.run), it’s approved at once as typed, whatever cards.typedTrades says, with said; a close read back under its id that Candle built and never submitted is failed (CLOSE_NOT_SUBMITTED), and the kill switch keeps it. Every row is live only (NO_PAPER_FORM). A lost answer on a staking row is read back by running the same argv again within the card’s lifetime, which the CLI turns into a resume under the same id; a rerun that’s refused leaves it unknown, for the user. An upload has no id: a lost one waits for the user’s re-send or abandon. Paused, the kill switch stops stake add, lp add, a launch and an upload, and keeps the rest. Typed, cli.run with one of these rows returns the same card (the user’s), or under cards.typedTrades = "instant" the card with instant, the core’s own approval.
Which wallet pays. candle_trade and candle_swap take a Candle Harness field, wallet: embedded, or a TEE wallet bound to the agent’s key, by label, address or id. Left out, the agent’s default in config applies (agents.<name>.wallet), followed by the address it was last found at (state/default-wallets.json), so a rename never leaves it naming no wallet and its old name given to another wallet never moves it there; else the embedded wallet where the key is allowed it, else its one TEE wallet. Candle never sees the field. A wallet the agent can’t pay from is refused when proposed, with details.canPayFrom. A card a TEE wallet pays has route: "cli": it runs the user’s own candle swap … --wallet <address> --client-trade-id <id> --yes --json, with the agent’s key, and its params are that exact argv, {argv, identity: "agent", agent, json: true}, which the hash covers. Its operation carries route: "cli" and wallet. TEE wallets have no paper form: in paper mode a trade naming one is refused.
A view that doesn’t know them can keep using title. They’re added fields; none were removed.
Who proposed it. A card’s origin is {clientId, role, source, trigger?, woken?}. source is person (a view), agent (an agent’s harness) or trigger: a trigger’s firing, proposed by the core’s own client (role: "core"), with trigger: {name, fireId, part}, part being which part of its line (0 for a single trade). woken: {trigger, fireId} marks a card from a turn a trigger woke. The journal’s card rows carry the same (trigger, fireId, part; woken, wokenFireId), so nothing a trigger did reads as the person’s, and a trigger waiting on fills or expired cards never fires on its own.
Triggers
Section titled “Triggers”A trigger is “when X, do Y”, kept in triggers.toml beside config.toml and run by the core with no model and no turn. Each belongs to an agent: its trades use that agent’s key, limits and wallets. Triggers need a Pro or Max plan.
- Saved only by the person’s press. No method saves one. The agent proposes it with
harness_triggers, as a card ofkind: "trigger", toolharness_triggers, walletnone, idcoreOpId, alive 10 minutes, withparams: {line, proposer, name, expansion, replace}(the hash covers the exact line and what it expands to now, aliases expanded and names as mints).display.triggerCardis{line, words, runs, kind, agent?, replaces?}:kindsays how its command will run,own(inside its wallet’s figures, with no card),asks(a card for the person each time, said inwarning, withfillstoward a wallet of its own),notifies,wakesorunknown. Approved, the core checks the line whole again and saves it (LINE_CHANGEDwhen an alias or a token name changed under the card,NAME_TAKENfor a name saved meanwhile), journalled astrigger.saved(trigger.changedfor one it replaces). One a restart cut off is read fromtriggers.toml. The agent reads its outcome withcandle_get_operation(kind: "trigger"). - A harness can’t save one or switch one on. It may read, propose, narrow its own agent’s at once, and switch off: any trigger in a turn the person started, its own agent’s in a woken turn.
- Its mode is its own.
mode(paper or live) is in the approved hash and on the topic, and a trigger fires and is read in it whatever the core’s mode. One saved before modes were kept takes the mode its approval was journalled in. Off and removal are at once from any view; back on, as approved, only from the main view (triggers.set). - Hand edits. The core keeps the hash each trigger was approved as (
state/triggers.json, 0600), so a trigger added or changed in the file by hand is held (NOT_APPROVED,CHANGED_OUTSIDE) until it’s approved again. Onlyon = falseby hand is taken. - The plan gate. On Free nothing is proposed, read or fired:
harness_triggersisn’t offered, a proposal ortriggers.fireisTRIGGERS_NEED_PLAN, every trigger is held asPLAN_FREEand kept as it is, and the topic’sgatesays so withfix: "candle plan buy". A plan that returns puts them back. While the plan isn’t read yet, they wait (PLAN_UNKNOWN). - Firing. A firing is recorded (
trigger.fired) before anything it does. A command is proposed part by part as the trigger’s own, never as typed, socards.typedTrades = "instant"never applies: inside its wallet’s figures it runs with a receipt; over them, or on a wallet that asks every time, it’s a card that waits for the person, says why and notifies. A chain’s next part goes once the one before has filled. The agent’s limits, the kill switch’s pause (which holds a trigger’s buys, swaps and wakes, and lets its sells through) and a key Candle holds refuse a part at proposal as any card. The core’s own client may hold 20 open cards across every trigger (TOO_MANY_OPEN_CARDS).notifyis a notice;wake <agent>wakes an agent. - A woken turn opens with what woke it, fenced as data, then the person’s own words on the trigger. Its trades run as the person’s would, through the wallet’s figures, but it never counts as the person asking: funds out can’t start from one, and its
harness_triggersmay only read and switch off its own agent’s triggers (ANOTHER_AGENTSfor another’s). Each agent may be woken 4 times in the last hour and 24 in the last 24 hours by default ([agents.<name>.wake] perHour,perDay); past either a wake is skipped, said and notified (trigger.wake_skipped). Wakes that come while the agent is busy join one woken turn, which counts once. The pause holds wakes. - Kept running. A trigger that’s on keeps a core a view started running after the view quits (
status.triggersOn,holds);core.stopends it.
The triggers topic’s triggers are each {name, agent, mode, line, expansion, words, class, on, live, held?, again, cooldownMs?, armed, lastFired?, nextRun?, readsNow?, watching?, paceMs?, runs?, paysFrom?, expired?, fills?}: class is level, tape, mine, schedule or event; on is switched on, live on with nothing holding it, held {code, words} (UNREADABLE while its line doesn’t read, journalled as trigger.held after a minute and trigger.released when it reads again); watching says what its watcher sees now, in words; runs is how its command runs now ({runs, words, amber?, suggestion?}, runs as triggerCard.kind); expired is its last card that expired with nobody there, {cardId, at}, for triggers.runNow.
Rules bots
Section titled “Rules bots”A rules bot is a Hacc strategy definition, kept in bots.toml beside config.toml and run by the core with no model and no turn: it enters on Hacc’s full tape and sells by its exits. Each belongs to an agent: its trades use that agent’s key, limits and the wallet it was approved on. Bots need a Pro or Max plan.
- Saved only by the person’s press. No method saves one. The agent proposes it with
harness_bots, as a card ofkind: "bot", toolharness_bots, idcoreOpId, whose hash covers the definition and its run settings (agent, wallet, paper or live, slippage bound), not its name.display.botCardis{name, words, definition, mode, runs, worst, parts?, backtest?, differs?, from?, replaces?, live?, flatten?}:partsare the card’s labelled lines (buys,sells,worst,tested),runshow its buys and exits will run,differshow Hacc’s backtest differs from a live run;paysFromandslippage(amber whenslippageLooser) sit beside it, andwarningwithfillstoward a wallet of its own where the wallet is shared or asks. A flatten from the agent is a card of the same kind withflatten. Approved, the core checks it whole again and saves it (BOT_CHANGEDwhen what it would save moved under the card,NAME_TAKEN), journalled asbot.saved(bot.changedfor one it replaces), each approved version kept. The agent reads its outcome withcandle_get_operation(kind: "bot"). - A harness can’t save, widen, resume, stop or take one live. It may read, propose, narrow and pause at once, and flatten on a card. A woken turn may only read, pause and narrow.
- Its trades come from the core’s own client,
core:bot:<name>, withorigin.sourcebot: a buy through the wallet’s figures (inside them approved asinside-limits, else a card that waits 60 seconds); an exit approved asbot-exit, on any wallet. Only the core givesbot-exit; a view’s approval saying it is taken as a plain one. Every card and row names the bot (bot,botAct,botPosition,botSignal,botReason). - Hand edits. The core keeps each bot’s approved hash and versions (
state/bots.json, 0600). What a bot does is the stricter of the file and the core:state = "paused"or"stopped"by hand takes; a bot added by hand is held (NOT_APPROVED) and runs nothing; one changed by hand is held (CHANGED_OUTSIDE), taking no entries while its exits run as approved. - The plan gate. On Free nothing is proposed or entered:
harness_botsisn’t offered, a proposal isBOTS_NEED_PLAN, every bot is held asPLAN_FREEwith its exits still selling, and the topic’sgatesays so withfix: "candle plan buy".
The bots topic’s bots are each {kind: "rules", name, agent, wallet, paysFrom?, mode, state, entries, exits, held?, stateWords?, byCandle?, open?, words, definition, maxSlippageBps?, slippageLooser?, refused?, version?, versions?, savedAt?, onSince?, lastSignal?, sharedWith?, sellsHeld?}: state is on, paused or stopped; entries and exits say what runs now; held is {code, words}; byCandle marks one paused because Candle stopped its agent’s key; sharedWith names who else trades from a live bot’s wallet; sellsHeld is each sell its exits hold now, {mint, why, words}, why one of failed_sells (which bots.retry lets go), pace, route, unread or decimals. books holds each bot’s book by name, the Candle Harness’s count from its own fills: {open, closed, realizedUsd, todayUsd, unrealizedUsd?, valued, exitsBy, skips?, recent, backtest?, lab}, each open position with its entry, the price now and its exits as prices (stopUsd, targetUsd, trail, holdUntil). The topic is told after a bot’s journal rows, and after each heartbeat while a bot holds a position, only when it changed.
Code bots
Section titled “Code bots”A code bot is a program of the user’s, run by the core’s supervisor or elsewhere on the machine, that trades through the core with a bot token (The bot protocol). Each is kept in bots.toml as kind = "code".
- Saved only by the person’s press on its card, which a Build pane proposes (
bots.proposeCode). The card’s hash binds its folder, command, agent, wallet, mode,perTradeUsd,perDayUsd,maxOpenandmaxSlippageBps, not its code. A lower figure or a bound tightened as far as the route’s default is at once (bots.narrow); anything higher, a bound under the route’s default, a change of mode, wallet, folder or command is a new card. Build panes of one agent are one proposer: the same bot proposed again with other settings withdraws the earlier card, and five cards may be open between them. - A harness, the bot itself, a popped view and a woken turn can’t save, widen, resume or take one live. The agent (
harness_bots) reads, pauses, lowers figures (narrow) and flattens on a card; it can’t propose one (CODE_BOT). - Hand edits: one changed by hand has its buys held (
CHANGED_OUTSIDE); its sells of its own run, and the supervisor runs the approved folder and command.
The bots topic’s codeBots are each {kind: "code", name, agent, wallet, paysFrom?, mode, state, buys, sells, held?, stateWords?, byCandle?, folder, folderShown, command, commandShown, perTradeUsd, perDayUsd, maxOpen, figures, maxSlippageBps?, slippageLooser?, words, version?, versions?, savedAt?, onSince?, usedTodayUsd?, token?, connected, says?, controls?, heldExits?, process?}: buys and sells say what runs now; token is {issuedAt} once one is issued; says is the bot’s own report, never figures; controls each {id, label, how, words}, how being at once, card or same; heldExits each position’s held exits in words, who set them and whether one is selling; process is the supervisor’s view (running with its pid and since when, starting, restarting and when, given up, stopped, ended, waiting and why, or outside; crashes in ten minutes, its last end, memory and CPU, a warning, its log’s path). books holds each code bot’s book by name, as for rules bots, each position’s held exits as prices.
The prompt pane
Section titled “The prompt pane”Some candle commands ask at their own prompt: a vault passphrase, a typed confirm, a path. The core has no terminal, so these run in a terminal pane the view opens, which the agent never sees. That covers tee disable and tee fund, owner steps, and ceremonies: the steps where the user types or is shown a secret (a private key, the 24 words, a passphrase, an API key) or names a file. And moving funds out: vault transfer, tee sweep and external sweep, each signed from this machine’s vault, never with an agent’s key.
- The card. From the agent, such a command is a card like any other, with
route: "pane"and akindby its rule (owner,ceremony,funds-out,lessaccess,read). Itsparams.paneis exactly what runs:{row, rule, identity, agent?, steps, factorFirst, askPaths?, capture?, addsYes?}, where each step is{stage: "factor" | "command", argv}. The factor step,vault status --unlock --json, comes first for a command that never unlocks the vault itself. No vault: read withvault status --jsonwhen the card is made, a machine with no vault that the core has never seen one on leaves the factor step out, withparams.pane.noVault: true;display.paneStepsand the pane’stitlesay “no vault: approved by you in the pane”. Once the core reads a vault it keeps a mark (state/vault-seen.json, journalledvault.seen), and from then a missing vault refuses such a row,VAULT_GONE, namingcandle vault restoreand the person’sforget vault. A row that needs a vault whatever its factor (the CLI unlocks it to run it, ortee rename) keepsdisplay.blockedwithcandle vault init, and approving it isNO_VAULT;vault initandvault restoreopen. The hash covers all of it.display.paneStepssays what the pane will ask, in order, anddisplay.paneNotethat the agent can’t see it. A ceremony’sdisplay.paneShowssays what it shows or takes (“Shows your 24 words.”). A command whose secret goes into a hidden prompt takes no positional that could hold it:secrets settakes the name only, andwallets importnone (SECRET_AT_THE_PROMPT). Paths the agent may not give are named inaskPathsand typed by the user in the pane: the core never learns them. - Moving funds out (
kind: "funds-out", and the owner rowsvault fundandvault demote) goes by where the funds go. A destination is known when it’s one of the user’s own: the embedded wallet, a TEE wallet, a trusted linked wallet, a TEE wallet’s pinned vault, or a vault key from the lastvault listthe user ran in the pane. One that shares the first and last four characters with a known address but isn’t it is refused (LOOKALIKE_DESTINATION); a label nobody knows is refused (UNKNOWN_DESTINATION); a known label goes on the command line as its address. From the agent, the user’s own message in the turn its call belongs to must name the destination and the amount (a sweep: the wallet, and “everything”, “all” or “sweep”;vault demote: every wallet it stops), or it’s refused (ASK_FOR_IT_YOURSELF). Where the message names more than one asset, the amount must be the one said beside this asset; a harness with no turn of the core’s engine has no message, so it can’t, and nor can a turn a trigger woke, which never counts as the person asking. A new address is allowed:display.newAddressholds it in full, in groups of four, andcards.approverefuses anything buthow: "click"for it (NEW_ADDRESS_NEEDS_A_CLICK).display.paneNotessays what goes where. Typed,cli.runruns the same checks: a known destination opens the pane at once, and a new address answers{kind: "card", cardId, newAddress: true}, the card to click.candle transfer, and Candle’scandle_transferandcandle_sweep, need a Withdraw key, which no agent key in the Candle Harness has: they’re refused, saying so. - Approving it runs nothing in the core.
cards.approveanswers{kind: "pane", card, ticket, ticketId, title, operation}to the approving view only, and the card’s operation ispendingwithroute: "pane". Typed,cli.runwith such a command answers the same at once: the card is approved asapprovedHow: "typed". A view can’t claimtypedorinstantitself. - The view starts
candle-harness cli-panein a terminal pane of its own, with the ticket inCANDLE_HARNESS_PANE_TICKETand the socket’s path inCANDLE_HARNESS_PANE_SOCKET(never on a command line), and sendspane.spawned {ticketId, pid}. The pane’s environment is the view’s own, lessCANDLE_HARNESS_*,CANDLE_API_KEY,CANDLE_DEVICE_TOKENandCANDLE_KEYSTORE_PASSPHRASE. - The launcher says hello with the ticket as its token. The core checks the connection’s peer pid (from the kernel) is the one the view named. The hello answers
{protocol, coreVersion, clientId, role: "pane"}.pane.redeemspends the ticket and answers{title, bin, steps, identity, agent?, askPaths?, capture?, fundsOut?, profile?, apiKey?}(profile: the CLI profile the Candle Harness acts as, which the launcher givescandleasCANDLE_PROFILE). It’s refused if the ticket is spent, or the card no longer hashes to what was approved. A card made with no vault (params.pane.noVault, below) hasvault statusread again here: a vault there now, or a read that didn’t answer, puts the factor step first insteps(journalled aspane.factor_added); a vault gone since the core first saw one refuses it,VAULT_GONE.pane.report {stage: "factor" | "paths" | "command", exit?, signal?, code?, stopped?, captured?}settles the operation, answers{outcome, words}, and ends the connection.capturedcomes only from the rows whose output is piped:vault status’s{code}, a new key’s{prefix, agent, stored}(never the key), andvault list’s{addresses: [{address, label?, role}]}, which the core keeps as known destinations. Nothing else is accepted.
- Outcomes, in
operation.result.paneas{outcome, words}:finished(exit 0, the operationsucceeded);didnt_finish(exit 1,failed; for a command that may show a secret once it starts, the words say what it may have shown, never that nothing was);usage(exit 2,failed, journalled as a fault in the Candle Harness’s table);pending(exit 3,unknown);stopped(a signal or a pane closed;failed, orunknownfor moving funds out, which may still land);refused_before(the factor step’scode, or a changed card;refused, nothing ran);not_opened(no launcher redeemed the ticket within 120 s;refused, nothing ran);unknown(the launcher’s connection ended with no report). Nothing a pane runs is re-sent:operations.resolveanswersNO_RESENDfor a re-send. The agent hears the outcome’s words, and nothing the pane showed.