Agent trading API
POST /api/v1/trade/agent/build and POST /api/v1/trade/agent/confirm let an agent buy or sell a
token on Solana or Hood (Robinhood Chain), against a market that already exists. For a
Candle-launched token (a live bonding curve on either chain, a graduated Solana token routed
through Jupiter, or a graduated Hood token whose Uniswap pool is seeded), every tier can trade
it. Pro and Max can additionally trade any mint the rail can route: an arbitrary Solana mint
through Jupiter, or an arbitrary Hood mint through the DEX race — see Tier gating below. This is
a separate rail from launching: see
docs/headless-launch.md for creating a token in
the first place (headless or self-signed) and for Auth, tiers, and Linked wallets, all of which
this rail reuses unchanged.
Every value-moving transaction this rail builds carries the same tiered platform fee that
delegated headless and self-signed launch dev buys now carry (see Fee legs on launches below):
this doc is the one place the fee model, the spend gate, and the enforcement boundary are written
up in full. The separate one-shot base-asset rail (POST /api/v1/agent/swap, covered in The
base-asset swap rail is free below) is the one exception: it never charges this fee.
The same cndl_live_ agent key also authenticates to hacc (api.hacc.fun) as Authorization: Bearer.
Pro and Max use that host for follows, tenanted rules, and (Max) invert/lab. Candle never proxies
those routes. Partner pull for hacc is GET /api/v1/hacc/keys/:prefix and
GET /api/v1/hacc/account/:address, scoped hacc:sync (partner-only, same rule as
entitlement:read).
Same agent key as everywhere else (x-api-key), but a distinct scope: both endpoints require
swap:write, which is opt-in only. Unlike launch:write/launch:read/activity:write, an
issued key never gets swap:write by omission — pass it explicitly:
{ "scopes": ["launch:write", "launch:read", "activity:write", "swap:write"] }to POST /api/v1/agent/keys (see Auth in docs/headless-launch.md). A key issued without it gets
SCOPE_MISSING (403) on both endpoints here. The SDK’s buildTrade()/confirmTrade() do not
check this client-side; the server is the authoritative gate.
A test-environment key cannot use this rail at all: both endpoints reject it outright with
TEST_ENVIRONMENT_FORBIDDEN (403), before any other validation. Unlike headless launches (which
narrow a test key to visibility: "test"/"hidden"), a trade has no non-production equivalent to
fall back to — every leg this rail can produce moves real value through a live venue.
Tier gating: which tokens each tier can trade
Section titled “Tier gating: which tokens each tier can trade”Every tier can trade a Candle-launched token: a live bonding curve on either chain, a graduated
Solana token routed through Jupiter, or a graduated Hood token whose Uniswap pool is seeded
(mint resolves to a Candle tokens row). On top of that:
- Pro and Max can also trade any mint the rail can route, Candle-launched or not. A
Solana
mintwith no Candletokensrow goes through Jupiter against a chosen base quote asset (quoteAsset:"sol"|"usdc"|"cndl", default"sol"). A Hood0xmint with no Candletokensrow goes through the DEX race against"eth"or"usdg"(default ETH). Same tiered platform fee as any other trade on this rail, charged on the quote asset (see Fee model below). - Free and Believer keys cannot trade an arbitrary mint. A Solana or Hood
mintwith no Candletokensrow returnsTIER_REQUIRED(403), naming that Pro or Max is required. - A Hood mint the market provider has never seen, with no
poolKeyhint, isMARKET_NOT_FOUND(reason: hood_unindexed), not a tier refusal. Mid-graduation with the pool not yet seeded is stillMARKET_NOT_TRADABLE(409, retryable). See Errors below.
The two payer modes
Section titled “The two payer modes”Every trade names a payer, exactly like the payer choice self-signed launches already offer:
{ "type": "main" }— the account’s own delegated embedded wallet, the same wallet that signs a headless launch. The trade executes immediately, inline, server-side via Privy, the moment/buildreturns: the response isstatus: "executed"outright, and there is nothing left to sign, broadcast, or confirm./confirmis never called for a main-payer trade; calling it anyway is rejectedVALIDATION_FAILED.{ "type": "linked", "linkedWalletId": "..." }— an imported linked wallet (Pro/Max tier only, see Linked wallets in the headless-launch doc). Candle never signs this trade and never holds its signer key./buildreturnsstatus: "built": an unsigned artifact for the agent to sign and broadcast itself, then report to/confirm, which verifies it on-chain before recording anything. Signing goes through the same Candle sign relay self-signed launches use (see “Self-signed launches” in the headless-launch doc): the agent authorizes locally with its own P-256 key, and Candle — authenticating with its own app secret — forwards the already-authorized request to Privy. Both chains work end to end today, on the curve and on the DEX. The SDK’sCandleClient.trade({ from: { linkedWalletId, privyWalletId } })runs build, sign (through the relay), and a server-side submit in one call for a Solana-linked payer: the signed transaction goes straight toPOST /api/v1/trade/agent/submit(see Server-side submit below), which broadcasts and confirms it through Candle’s own RPC — so a Solana-linkedtrade()call needs nosolanaRpcUrlconfigured at all. For a Hood-linked payer, the client must configureevmRpcUrl(an EVM JSON-RPC endpoint, separate fromsolanaRpcUrl) —trade()throws a clear error naming it when unset, before any signing. With it set,trade()assembles each ofartifacts.approval(if present),artifacts.permit2Approval(if present),artifacts.trade, andartifacts.feeTransfer(if present) as its own EIP-1559 (type 2) transaction — fetching nonce, chain id, and fee data fromevmRpcUrl— and broadcasts them in that exact order, waiting for each leg’s own receipt before assembling the next. This ordering is load-bearing, not a style choice: thetradeleg’s gas estimate reverts if it runs before theapprovalleg is mined, since the on-chain allowance is not yet set. A USDG-settled DEX buy now takes that approval path, which ETH-terminal buys do not (native value rides inmsg.value). It then callsconfirmTrade({ clientTradeId, tradeTxHash, feeTxHash? }). A graduated Hood market whose pool is seeded builds on the DEX venue (artifacts.venue: "dex"); see Hood venues below for whichartifacts.trade.toa fill can hit. Mid-graduation with an unseeded pool is stillMARKET_NOT_TRADABLE(409, retryable). To drive the legs yourself instead of using the one-shot, see Flow below for the manual sequence.
Pricing before you trade: POST /api/v1/trade/agent/quote
Section titled “Pricing before you trade: POST /api/v1/trade/agent/quote”A live price for a Hood token, from the same quoters /build asks, without building anything.
POST /api/v1/trade/agent/quote{ "mint": "0x...", "side": "buy", "amountRaw": "1000000", "quoteAsset": "eth" }quoteAsset is optional. On Hood it is "eth" (default) or "usdg": the asset the quote is
priced in, and the asset /build will settle in if you forward the field. A Solana id here is a
400. Take the returned quoteAsset straight to /build; echoing it is safe.
{ "success": true, "chain": "hood", "venue": "dex", "amountOut": "7000000", "quoteAsset": "eth", "quoteDecimals": 18, "route": { "source": "uniswap", "kind": "weth", "hops": [ ... ], "priceImpactBps": 42 }}Why it exists: the public market quote (/api/v1/markets/hood/:mint/quote) reads a Candle curve,
so a token Candle never launched had no live price on this API until a third-party indexer noticed
it. A caller pricing fresh launches was refusing entries that were perfectly routable, because it
had no price rather than no route.
It writes nothing. No trade row (a build row expires after 30 minutes, so a probe that created one
would fill your ledger with expirations), no fee leg, no calldata, and no aggregator build call.
amountOut is before slippage; the floor is set at /build, from maxSlippageBps.
/quote does not pin the /build route. Both calls race independently (and the aggregator, when
configured). Hops can change, and the hop fee (the pool’s static tier) can change. A 2026-09-08
main-payer fill quoted bridge fee 100 and executed 500; both tiers are in
HOOD_USDG_WETH_BRIDGE_FEES (100, 500, 3000) in
packages/shared/src/hood-dex.ts. Size against
minOutRaw from the build, not against a hop fee from the quote. priceImpactBps remains a
depth statistic, not a cost.
amounts.quoteAsset on the executed (or built) response is what the wallet paid or received.
route.kind is the path the race won. They answer different questions. A fill can read
amounts.quoteAsset: "eth" alongside route.kind: "usdg": the wallet paid native ETH through a
WETH → USDG → token path. Kind "usdg" is not “the wallet paid USDG”. Cost-basis and fee
denomination key off amounts.quoteAsset. A receipt verifier keys off route (hops, kind,
router). See Hood venues below.
Refusals are the same errors /build raises, error.routing included, so one classifier serves
both. Hood only: Solana routes through Jupiter, which callers quote directly.
Trading a pool before any indexer knows it
Section titled “Trading a pool before any indexer knows it”A launch’s first minutes were the window this rail could not serve: the market provider has not indexed the pool, so there are no candidates and the answer is a refusal, which is exactly when the trade was worth making.
If you are watching the chain you see the pool before we do. Pass its v4 PoolKey, read from the
pool’s Initialize event (or its first Swap), as poolKey on /quote and on /build:
{ "mint": "0x...", "side": "buy", "amountRaw": "1000000000000000", "poolKey": { "currency0": "0x...", "currency1": "0x...", "fee": 0, "tickSpacing": 60, "hooks": "0x2a100c4b29c2e2c76dbbc794334f54d794d8a0cc" }}Nothing in it is taken on trust. Before the key is used we check the shape v4 itself requires (currency ordering, fee, tickSpacing), that one side is EXACTLY the mint you are trading and the other is a counter asset this rail settles, that the hook passes this environment’s policy, and that the pool is initialized on chain and holds liquidity, read from the v4 StateView rather than from any indexer. A key with one field changed names a pool that does not exist, and reads that way.
A hint is extra reach, never a replacement: the indexed pools are still raced beside it, and the best execution wins. If your key fails verification and the token is otherwise tradable, the hint is dropped and the trade proceeds; if it was the only route, its refusal is the answer, so you learn which check it failed rather than seeing a bare “no route”.
Rate limit: 600 quotes per minute per key, ten a second sustained. Sized for a caller pricing
every open position on every cycle; over it you get RATE_LIMITED (429, retryable). Per key, so
one account’s loop cannot throttle another’s.
route is on the quote, for both quoters. The aggregator path and the in-house race both
stamp source, hops and priceImpactBps here, not only on a build, because an impact floor
has to run before a build exists.
What a trade costs, and which field does NOT tell you. This one has bitten a caller already, so it is worth being blunt.
priceImpactBps is a depth statistic, not a cost. It compares two sizes on the same route, so
any proportional fee is paid by both quotes and cancels out of the number entirely. A v4 hook’s
charge is invisible in it. So is the hop’s fee field, which reports the pool’s STATIC tier: a
launchpad pool whose hook takes its cut in afterSwap reports fee: 0 truthfully.
Measured on a real pool: fee: 0, priceImpactBps: 4, and a round trip through it cost 604
bps. A caller adding declared fee to impact underestimated the cost of that pool by about
fiftyfold.
For the all-in number, ask for it:
{ "mint": "0x...", "side": "buy", "amountRaw": "1000000000000000", "includeRoundTrip": true }The response then carries roundTripBps: what buying and immediately selling the proceeds costs,
including every fee on both sides and both impacts. It quotes the way back through the same
planner, so it reflects the route you would really get on the way out, not an assumption that the
return leg is symmetric. It is opt-in because it is a second quote’s work.
What priceImpactBps measures. How much worse this SIZE executes than a small one on the
SAME route:
10000 * (1 - (amountOut / amountIn) / (refOut / refIn))where the reference is 1% of the trade, quoted through the same route by the same quoter. It needs no USD oracle, so no vendor’s view of a token’s value can move it, and it is comparable between the two quoters. It is best-effort: when the reference quote fails the field is absent rather than guessed. A larger figure on a sell than on a buy of the same token is normal, since the two sides are different sizes against different pool depths.
1. POST /api/v1/trade/agent/build
Section titled “1. POST /api/v1/trade/agent/build”{ "clientTradeId": "my-bot-trade-1", "mint": "So1anaTokenMint...", "side": "buy", "amountRaw": "1000000000", "payer": { "type": "main" }, "maxSlippageBps": 100}| Field | Required | Notes |
|---|---|---|
clientTradeId | yes | Idempotency key, unique per account, shared with the matching /confirm call. Same ledger semantics as clientLaunchId (see Idempotency in the headless-launch doc): same id + same body + already confirmed replays the stored result; same id + different body is IDEMPOTENCY_CONFLICT (not retryable); same id, still in flight, is IDEMPOTENCY_CONFLICT (retryable). |
mint | yes | The token’s mint (Solana) or contract address (Hood). For a Candle-launched token, must already exist as a Candle-tradable market. A Pro/Max key can also name an arbitrary Solana mint (Jupiter) or an arbitrary Hood mint (DEX race) with no Candle token row — see Tier gating above. A non-Candle mint on a Free/Believer key is TIER_REQUIRED. A Hood address the market provider has never seen, with no poolKey hint, is MARKET_NOT_FOUND (reason: hood_unindexed). |
side | yes | "buy" or "sell". |
amountRaw | yes | A positive integer string, in raw units. Buy: quote-asset raw units to spend. Sell: base-token raw units to sell. |
payer | yes | { "type": "main" } or { "type": "linked", "linkedWalletId": "..." }. See The two payer modes above. |
maxSlippageBps | no | Integer, 0-10000. Defaults to 100 (1%). |
quoteAsset | no | Closed set: "sol" | "usdc" | "cndl" | "eth" | "usdg". Echoing /quote’s quoteAsset is safe. On Solana, used only for an arbitrary (non-Candle) mint; default "sol"; ignored for a Candle-launched token whose pair is fixed; a Hood id on a Solana mint is 400 VALIDATION_FAILED. On Hood, "eth" (default) or "usdg": this is the settlement asset, what actually leaves the wallet. A USDG-settled buy spends an ERC-20, so the plan carries an extra approval leg that an ETH buy does not (one extra transaction for a main payer, one extra artifact for a linked payer). This is not the route: the cheapest path to that asset is raced independently, which is why a fill can read amounts.quoteAsset: "eth" alongside route.kind: "usdg". The executed amounts.quoteAsset is what was actually paid. Omitted on Hood is the same ETH-terminal trade as "eth" for settlement (the idempotency hash still treats omitted as "sol"; that is existing hash stability, not a different fill). Unknown values are 400 VALIDATION_FAILED and create no job. |
A main payer, “executed” response (a Believer-tier account paying the Believer rate, 50 bps — see Fee model below for what each of the four tiers actually pays):
{ "success": true, "status": "executed", "clientTradeId": "my-bot-trade-1", "chain": "solana", "signature": "...", "fee": { "bps": 50, "feeRaw": "5000000", "treasury": "..." }, "amounts": { "amountRaw": "1000000000", "expectedOutRaw": "...", "minOutRaw": "...", "quoteAsset": "sol" }}A linked payer, “built” response (Solana; a Free-tier account paying the Free rate, 100 bps — again see Fee model below for the other three tiers):
{ "success": true, "status": "built", "clientTradeId": "my-bot-trade-2", "chain": "solana", "artifacts": { "venue": "curve", "transactionBase64": "<base64, unsigned>", "quoteAsset": "sol", "quoteMint": "So11111111111111111111111111111111111111112", "quoteDecimals": 9 }, "fee": { "bps": 100, "feeRaw": "10000000", "treasury": "..." }, "expectedOutRaw": "...", "minOutRaw": "...", "expiresAt": 1754401800000}On Hood, artifacts is calldata legs instead of a signed-once blob, since Hood cannot batch
multiple calls into one transaction the way Solana can:
{ "artifacts": { "venue": "curve", "trade": { "to": "0x...", "data": "0x...", "value": "0" }, "approval": { "to": "0x...", "data": "0x..." }, "feeTransfer": { "to": "0x...", "data": "0x...", "value": "0" }, "quoteAsset": "usdg", "quoteDecimals": 18 }}approval is present when the input is an ERC-20 and the payer’s existing allowance is
insufficient for amountRaw: a USDG-quoted curve buy, a USDG-settled DEX buy, or a token sell.
An ETH-terminal buy never needs one (the input rides in msg.value). permit2Approval is a
second grant, used on a v4 or bridged sell where the Universal Router pulls through Permit2.
feeTransfer is present only when a fee actually applies (see Fee model below). Linked Hood
must send approval → permit2Approval → trade → feeTransfer in that exact order, awaiting
each receipt: the trade leg’s gas estimate reverts before the allowance is mined. USDG-settled
DEX buys now enter that path, which ETH-terminal buys skip.
A main-payer Hood fill never returns artifacts (the server signs and broadcasts). The executed
response carries the same route object a build does, plus amounts.quoteAsset.
Hood venues
Section titled “Hood venues”artifacts.venue is "curve" on a live bonding curve and "dex" once the trade is a Uniswap
(or aggregator) swap. Receipt verifiers should allowlist artifacts.trade.to (linked) or the
fill’s to (main payer), not a Candle Swap event or a v4 PoolManager alone. /confirm and
/reconcile allowlist exactly these routers plus the mint’s own curve.
| Venue | When | artifacts.venue | Typical to | Constant |
|---|---|---|---|---|
| Candle curve | Live bonding curve | "curve" | the mint’s curveAddress | token row |
| Uniswap v3 | DEX race kind weth, usdg, usdg-direct, usdg-hop | "dex" | 0xCaf681a66D020601342297493863E78C959E5cb2 (SwapRouter02, multicall) | HOOD_UNIV3_ROUTER in packages/shared/src/hood-dex.ts |
| Uniswap v4 / bridged-v4 | DEX race kind v4, bridged-v4 | "dex" | Universal Router default 0x8876789976decbfcbbbe364623c63652db8c0904 | HOOD_V4_UNIVERSAL_ROUTER (env-overridable) |
| Uniswap v2 | DEX race kind v2 | "dex" | Router02 default 0x89e5db8b5aa49aa85ac63f691524311aeb649eba | HOOD_UNIV2_ROUTER (env-overridable) |
| Kyber aggregator | HOOD_DEX_AGGREGATOR=kyber and Kyber quoted | "dex", route.source: "kyber" | 0x6131B5fae19EA4f9D964eAc0408E4408b66337b5 | HOOD_KYBER_ROUTER |
route.kind values from the race: weth, usdg, usdg-direct, usdg-hop, v4, bridged-v4,
v2. Kind usdg means the path crosses USDG (often WETH → USDG → token). It does not mean
the wallet paid USDG. Settlement is quoteAsset / amounts.quoteAsset.
Jobs and a validation 400
Section titled “Jobs and a validation 400”GET /api/v1/trade/agent/jobs/{clientTradeId} is the read path for a build that may have begun.
Use it after a timeout or a restart rather than re-sending the write.
A 404 JOB_NOT_FOUND means no row for this account and id: the write never reached
agentTrades.begin. A /build rejected at validation (400 VALIDATION_FAILED, including a bad
quoteAsset) creates no row. Treat the 400 as definitive; do not poll. The same clientTradeId
is reusable with a corrected body.
A /build that planned and began, then failed later, has a row (failed/expired semantics as
already documented for /confirm).
2. The agent signs, through the Candle sign relay
Section titled “2. The agent signs, through the Candle sign relay”Signing a linked wallet needs an app-authenticated call to Privy that an external agent cannot
make alone: Privy requires both the agent’s own P-256 authorization signature over the exact
artifact AND app-level auth that only Candle’s server secret satisfies. The agent computes its
authorization signature locally with its own key (the SDK’s signLinkedTransaction() does this via
buildPrivyAuthorizationSignature, packages/sdk/src/authorization-signature.ts), which Candle
never sees, and calls the Candle sign relay, POST /api/v1/agent/wallets/:id/sign
(apps/api/src/routes/agent.ts, swap:write scope). Candle authenticates the app with its own
server secret and forwards that exact, already-authorized request to Privy unchanged: it cannot
substitute a different wallet or artifact, since the agent’s own signature covers both, and it
never holds the signer key. Candle’s server IS a required participant in this call, though: an
agent cannot reach Privy’s wallet-sign RPC directly, since Privy’s app-level gate only accepts
Candle’s own server secret. This is still no custody of the signer — Candle relays an
already-authorized request, it never generates the signature or receives the P-256 key. The flow:
- Solana. Authorize and sign
artifacts.transactionBase64via the linked wallet’s own Privy signer quorum (the 1-of-1 quorum registered at import time; see Import flow in the headless-launch doc), through Candle’s relay. This works today: the SDK’sCandleClient.trade({ from: { linkedWalletId, privyWalletId } })runs this whole step, plus build and a server-side submit, in one call (signLinkedTransaction()+submit()under the hood by default; see Server-side submit below). To drive it by hand instead, broadcast the signed bytes yourself withbroadcastSignedTransaction()and report the result to/confirm. - Hood. Send
artifacts.approval(if present), thenartifacts.permit2Approval(if present), thenartifacts.trade, thenartifacts.feeTransfer(if present) — in that exact order, each with any EVM client the agent controls, waiting for each one’s own receipt before sending the next. Hood has no way to bundle these into one atomic call the way Solana’s single transaction can, so the send order is a manual, caller-enforced contract, not something the artifacts themselves encode. This works today too: withevmRpcUrlconfigured,CandleClient.trade({ from: { linkedWalletId, privyWalletId } })assembles each leg as an EIP-1559 (type 2) transaction (nonce, gas, and fee data fetched fromevmRpcUrlviapackages/sdk/src/evm-tx.ts’s helpers), signs it withsignLinkedTransaction({ chain: "evm", evmTxParams }), broadcasts it withbroadcastSignedTransaction(), and waits for its receipt before assembling the next leg — plus build and confirm, all in one call. To drive it by hand instead, sign each leg withsignLinkedTransaction({ chain: "evm", evmTxParams }), broadcast withbroadcastSignedTransaction()(or your own EVM client), then callconfirmTrade()with the resultingtradeTxHash/feeTxHash.
Candle never signs anything on this path and never holds the signer key — identical posture to
self-signed launches (see What Candle never does on this path in the headless-launch doc), which
also spells out why Candle’s server is nonetheless a required participant in the signing call
itself. Requires privyAppId (a PUBLIC Privy identifier, the same value the app exposes as
NEXT_PUBLIC_PRIVY_APP_ID, not a secret) configured on the client, matching the app id the relay’s
server authenticates under, and a secretStore holding the agent’s P-256 signer PEM (see The
never-plaintext rule and Self-signed launches’ “Never holds the key” in the headless-launch doc for
what that means for recovery if the key is lost).
The relay signs only API-built transactions
Section titled “The relay signs only API-built transactions”The agent’s own P-256 authorization signature proves WHO is asking (the caller genuinely controls
this linked wallet’s signer), but not WHAT is being signed — nothing about that signature says
the transaction inside the request is one this API ever assembled. The relay (POST /api/v1/agent/wallets/:id/sign) closes that gap itself, ahead of any Privy call: at build time
(/build here, or /launch/self/build), Candle computes a canonical hash of each unsigned
artifact it hands back and stamps it onto a single-use claim row. At sign time, the relay
recomputes the identical hash from the request body and claims a matching, unconsumed, unexpired
row scoped to the same account and the same linked wallet (packages/db/convex/agentSignClaims.ts).
No matching claim — because the body is hand-rolled, already signed once before, or the claim
expired — refuses the request with UNRECOGNIZED_TRANSACTION (403), before Candle ever calls
Privy: a stolen or misused agent key cannot get an arbitrary self-authored transaction signed
through this relay, no matter how it authorizes the request. This is the compensating control that
let Spend-limits Phase A demote the linked wallet’s own Privy policy to a fixed structural backstop
instead of the swap-breaking catch-all denies it used to need — see “The wallet policy: a fixed
backstop, not per-asset caps” in the headless-launch doc.
Solana hashes the ENTIRE unsigned transaction (sha256 of the raw bytes), since Solana signs
a whole transaction and every byte of it is either something Candle assembled or something the
signature itself covers — an exact byte-for-byte match is both possible and required.
EVM is narrower by necessity, and this is an accepted, intentional residual, not an oversight.
The SDK assembles the full eth_signTransaction body client-side — nonce, gas, chain id, and fee
data are all fetched fresh right before signing, so the signed body is never byte-identical to
anything Candle built. The build-bound hash for an EVM leg therefore covers ONLY the
Candle-controlled fields, { to, data, value } (evmLegSignHash / evmTxSignHash in
apps/api/src/lib/sign-hash.ts), never nonce/chain_id/type/gas_limit/fee fields. The
practical consequence: within the ONE signature a matching claim authorizes, the relay never pins
nonce/chain_id/gas at all — the caller is free to choose any value for those fields and the
hash check still matches, since they were never part of what got hashed. And when two separate
builds legitimately produce the identical {to,data,value} (e.g. two same-size Hood trades
sharing an identical fee-transfer leg, see selectClaimableRow’s own comment in
packages/db/convex/lib/agentSignClaimsPolicy.ts), each gets its own single-use claim row, so
that identical payload can legitimately be signed more than once, each time with independently
chosen nonce/chain_id/gas. Either way, to and value stay fixed no matter what: the
recipient and the amount moved can never be redirected or increased by varying only the fields the
hash ignores. Every EVM leg this API ever builds sends to a Candle-controlled contract address (a
curve, the fee treasury, an ERC-20 for an approval) and value either 0 or the exact declared
trade/dev-buy amount, so this residual can shift WHEN or on WHICH chain/nonce a leg lands, never
WHERE the funds go or HOW MUCH moves. That is the intended scope of the EVM hash, not a gap to
close.
3. POST /api/v1/trade/agent/confirm
Section titled “3. POST /api/v1/trade/agent/confirm”Solana:
{ "clientTradeId": "my-bot-trade-2", "signature": "..." }Hood:
{ "clientTradeId": "my-bot-trade-3", "tradeTxHash": "0x...", "feeTxHash": "0x..." }feeTxHash is required whenever the matching /build response carried a nonzero fee
(artifacts.feeTransfer was present); omitting it there is refused FEE_LEG_MISSING. See The
fee-stripped-confirm refusal below.
Candle verifies, in order: the transaction landed on-chain and did not revert; it was actually
signed (Solana) or sent (Hood) by the declared linked wallet (SIGNER_MISMATCH otherwise); it
actually moved this trade’s own mint, in the direction the trade’s side claims (Solana: a
nonzero, side-matched token balance delta for the payer; Hood: bound to this row’s own curve
address via to); and, when a fee applies, that the fee transfer landed too. Only once every
check passes does anything get recorded: the activity row (or, on Hood, the same verified-decode
path recordHoodSwap already uses for every Hood trade), the ledger row’s confirmed status, and
the trade.executed webhook (see Webhooks below). A failed verification records nothing at all.
/confirm is idempotent: confirming an already-confirmed clientTradeId replays the stored
"executed" response outright, without touching the chain or Convex again.
The response shape is the same "executed" shape a main payer gets back from /build directly
(amounts, fee, signature).
Recording a Hood trade from its receipt: POST /api/v1/trade/agent/reconcile
Section titled “Recording a Hood trade from its receipt: POST /api/v1/trade/agent/reconcile”A build row lives 30 minutes. After that /confirm answers JOB_NOT_FOUND and the trade can no
longer be confirmed under its clientTradeId, even when the tokens are in the wallet and the
caller has already booked the fill from the chain. /reconcile records such a trade from its
receipt alone, with no build row, and needs only the hash, the mint and the side:
{ "tradeTxHash": "0x...", "mint": "0x...", "side": "buy", "clientTradeId": "my-bot-trade-3" }Everything else is read from the chain and bound the way /confirm binds a live trade: the
transaction succeeded; its sender is a wallet on this account (the embedded wallet or any linked
wallet, SIGNER_MISMATCH otherwise); its to is an allowlisted Hood DEX router or the mint’s own
Candle curve; and the sender’s own Transfer of the mint moved in the claimed direction, which
also gives the recorded token amount. The native-ETH quote leg is the transaction’s value on a
buy and the wallet’s balance change plus the gas it paid on a sell; the response’s quoteDerived
says which ("value", "balance_delta", or "unavailable", in which case the token leg is still
recorded with a zero quote). Hood only.
{ "status": "recorded", "alreadyRecorded": false, "chain": "hood", "signature": "0x...", "mint": "0x...", "side": "buy", "payerWallet": "0x...", "venue": "dex", "amountRaw": "250819583578439067446794", "quoteAsset": "eth", "quoteAmountRaw": "100000000000000000", "quoteDerived": "value", "feeRaw": "0", "feeBps": 0, "feeCollected": false, "clientTradeId": "my-bot-trade-3"}Idempotent on the hash: a replay answers alreadyRecorded: true and writes nothing. No fee is
collected: the agent-tier fee is a companion transaction /build composes, and none exists for
a trade recorded this way. clientTradeId is echoed for your own bookkeeping only; an expired
build row stays as it was.
Server-side submit: POST /api/v1/trade/agent/submit
Section titled “Server-side submit: POST /api/v1/trade/agent/submit”An alternative to steps 2 and 3 above, not a replacement for them — the client-broadcast-then-
/confirm round trip stays fully supported on both chains, unchanged. Instead of broadcasting the
signed legs itself and reporting the result to /confirm, a linked-payer caller can hand Candle
the already-signed bytes directly and let it broadcast and confirm in one call:
{ "clientTradeId": "my-bot-trade-2", "signedTransactions": ["<base64, signed>"] }signedTransactions is the ordered signed legs, produced by the exact same signing step 2
describes above (through the Candle sign relay): exactly one for Solana; one to four for Hood, in
the fixed order approval and permit2Approval (each only when the build produced it), trade,
feeTransfer (only when a fee applies), the same order step 2’s Hood bullet sends them in. Candle
broadcasts the bytes
itself, through its own RPC (broadcastSignedSolanaTx/broadcastSignedHoodTx in
apps/api/src/routes/trade-agent.ts), classifying a broadcast failure exactly like a main payer’s
inline execution does; on Hood, each leg’s own receipt is awaited before the next ever broadcasts,
the same ordering requirement the client-broadcast path has. Once every leg lands, /submit
confirms inline using the EXACT same verifiers /confirm uses (confirmSolanaTrade/
confirmHoodTrade) — the same on-chain checks, the same fee-leg enforcement (see The
fee-stripped-confirm refusal below), and the same "executed" response shape /confirm returns,
all in one call instead of two. Like /confirm, /submit is for linked-payer rows only — a main
payer already executed inline at /build; calling /submit against a main-payer row is rejected
VALIDATION_FAILED. And like /confirm, /submit is idempotent on clientTradeId: an
already-confirmed id replays the stored result outright, without broadcasting anything again.
A Hood v4 sell carries two approval legs, not one. The Universal Router pulls the input token
through Permit2, so approval grants Permit2 (not the router) and permit2Approval grants the
router inside Permit2, for the exact amount with a short expiry. Sign and broadcast them in the
order the build lists them: approval, permit2Approval, trade, feeTransfer. Either approval
leg is absent when the existing grant already covers the amount and has not expired.
This is no more custody than the client-broadcast path: signedTransactions are bytes the linked
wallet already signed through the relay in step 2; Candle only relays them onto the chain and reads
back the result. The linked wallet remains the on-chain signer and fee payer either way — Candle
never signs anything here, exactly the no-custody posture the rest of this doc describes for every
other linked-payer path.
Both chains can call /submit directly, but the SDK’s trade() only defaults to it for a
Solana-linked payer (see The two payer modes above). A Hood trade’s approval leg must be mined
before the trade leg can even be gas-estimated and signed, so its legs cannot all be signed up
front the way a server-side submit needs — trade()’s Hood default therefore stays on the
client-broadcast sequence described in step 2 above. A Hood caller who wants server-side submit for
an already-approved token (no approval leg needed) can still call submit() — or POST /submit directly — itself.
Referral attribution
Section titled “Referral attribution”POST /api/v1/agent/keys also accepts an optional ref (a referral code) alongside
environment and scopes. It attributes the calling account to that referrer once, set-once
and best-effort, and never blocks key issuance if attribution fails; a referred account then
pays 10% less on every platform fee this rail charges, from that key onward. The full referral
program surface (dashboards, claims) lands with Phase 2’s agent endpoint.
Fee model
Section titled “Fee model”| Tier | Transaction fee |
|---|---|
| Free | 100 bps (1%) |
| Believer | 50 bps (0.5%) |
| Pro | 25 bps (0.25%) |
| Max | 0 bps (free) |
Reuses the exact tier resolution the launch dev-buy fee uses (resolveAgentFeeBps in
apps/api/src/lib/agent-fee.ts): a live tier of pro pays the Pro rate, and a live tier of max
pays the Max rate (0 bps — a Max-tier trade legitimately reports "feeRaw": "0", by design, not
a bug). “Believer” is not a separately-evaluated live tier here (see Caps and trust tiers in the
headless-launch doc: Believer is a display label frozen at key issuance, never live-evaluated) —
a Free-live-tier account still pays the Believer rate if its agent key row’s own daily-launch
cap is 20 or higher, which is exactly the cap a Believer-eligible key was issued with. Rates
are env-tunable server-side, one env var per tier: AGENT_FEE_BPS_FREE (default 100),
AGENT_FEE_BPS_BELIEVER (default 50), AGENT_FEE_BPS_PRO (default 25), and
AGENT_FEE_BPS_MAX (default 0), each clamped to 0-1000.
Basis and rounding. The fee is ceil(basis * bps / 10000), integer BigInt math, in the
quote asset’s raw units — so any nonzero basis at a nonzero rate always charges at least 1 raw
unit; it can never round down to zero. The basis differs by side and, on Hood, by whether the buy
crosses the graduation boundary:
- Buy: the quote-asset amount actually spent —
amountRawon Solana, and on Hood the consumed quote (quoteConsumed, not the submittedamountRaw) whenever the buy crosses the curve’s graduation threshold. A capped-refund Hood buy fills only up to the threshold and refunds the unused principal straight back to the buyer; that refunded portion was never spent, so it is never taxed either. Below the threshold, consumed quote equalsamountRawexactly, so this only ever differs right at the boundary. - Sell: the quoted quote-asset amount the seller is expected to receive (
expectedOutRaw). - A launch’s dev buy (delegated headless or self-signed, either chain): the dev buy’s own amount. See Fee legs on launches below.
On top, not netted out of the quote. The fee is additive to what the trade itself moves, never
a slice carved out of the requested/quoted amount before the swap runs: a buy’s total outflow is
amountRaw + feeRaw (both a Solana buy’s spend gate, below, and the Hood buy’s two separate
transactions reflect this), and a sell’s fee is deducted from the proceeds only after the swap
has already been quoted and executed at its full expectedOutRaw — the swap itself is never
short-changed to make room for the fee.
Destinations. AGENT_FEE_TREASURY_SOLANA and AGENT_FEE_TREASURY_HOOD, one address per
chain, per environment. fee.treasury in every response echoes whichever address is currently
configured for that trade’s chain, or null when none is. Leaving a chain’s treasury env var
unset is the rollout lever: every trade and every launch dev buy on that chain then silently
skips the fee entirely (feeRaw: "0", feeBps: 0, no fee leg built, no approval/feeTransfer
artifact) — there is no separate feature flag, and no request-side way to opt out or in.
Send mechanics per chain:
- Solana. The fee instruction rides inside the same transaction as the swap itself
(appended after the swap’s own instructions, same signer, same broadcast). A buy’s fee is a
plain transfer from the payer; a sell’s fee draws on the payer’s balance after the swap’s
own output has already landed in the same transaction, so the payer never needs to hold the fee
amount going in, only end up with at least
feeRawonce the swap credits them (whichexpectedOutRaw, floored byminOutRaw, guarantees). There is no separate fee signature to track for a main payer; for a linked payer,/confirmclaims the trade’s own broadcast signature as its (unconditional) fee-reuse guard — see the SDK reference onfeeSignaturefor the exact rule. - Hood. The fee is always its own separate transaction (
feeTransfer), since Hood cannot batch calls. For a main payer it is sent after the trade’s own receipt has already landed, and is non-fatal: a failed fee transfer never undoes or fails the trade that already executed, it just means the fee did not collect (logged, andfeeSignatureis absent from the response). For a linked payer, the agent sends it itself as the last of the three legs (see Flow above) and reports its hash asfeeTxHashat/confirm, where it is a hard requirement, not best-effort — see the next section.
The fee-stripped-confirm refusal
Section titled “The fee-stripped-confirm refusal”Because Candle never signs a linked payer’s transaction, /confirm is the only enforcement
point for a linked-payer trade’s fee — there is no earlier moment where an agent could be stopped
from broadcasting a version of the trade with the fee leg quietly dropped. So when the matching
/build row carried a nonzero fee, /confirm refuses to record anything — no activity row,
no ledger completion, no webhook — unless the fee actually verifies on-chain:
- Solana: the same broadcast transaction must contain a transfer to the configured treasury of
at least
feeRaw, in the trade’s own quote asset. - Hood:
feeTxHashmust be present in the confirm body at all (its absence alone isFEE_LEG_MISSING), and must verify as a real transfer of at leastfeeRawto the configured treasury.
A verification failure here returns FEE_LEG_MISSING (402), and is not retryable as the same
confirm call: the broadcast transaction itself is what is missing the leg, so only a fresh
/build (producing a new artifact with the fee correctly included) and a new broadcast can
recover.
Hood fee-transfer hashes are single-use, across both this rail and self-signed launches. One
atomic, cross-table Convex claim (agentTrades.claimTradeFeeSignature and headlessLaunches. claimSelfFeeSignature each check the OTHER’s own fee-hash index before claiming) means a genuine
Hood fee-transfer receipt can only ever pay for one trade or one launch dev buy, on either
surface, never twice and never both. On Solana there is no separate fee-transfer hash to reuse in
the first place — the fee always rides inside the same transaction as the trade or the dev buy it
pays for — so what Solana’s /confirm guards instead is trade-vs-trade replay of the same
broadcast signature: a linked payer’s transaction signature is claimed unconditionally (fee or
not) the moment it verifies, so the identical signature can never be cited to confirm a second
trade.
The spend gate: buys only, amount plus fee
Section titled “The spend gate: buys only, amount plus fee”/build checks a buy’s total outflow — amountRaw + feeRaw, using the trade’s own freshly
quoted feeRaw, never a stale figure — against the calling key’s own configured spend limit for
the quote asset (per-key only since 2026-08-23; see Spend limits in the headless-launch doc),
before beginning the idempotency row at all. Exceeding it is SPEND_LIMIT_EXCEEDED (400),
naming the cap so the caller can retry with a smaller amountRaw or raise the key’s cap from a
session first. This is the operator’s own opt-in ceiling, not a platform-wide one; by default
every key is unlimited. Sells are never gated this way — a sell spends the base token being
sold, which this mechanism does not cap.
Per-key spend limits
Section titled “Per-key spend limits”Each agent key carries its own per-asset spend caps — since 2026-08-23 the ONLY spend caps; the
account-wide main/linked scopes are retired — set via PUT /api/v1/agent/keys/:prefix/limits
(body { "limits": [{ "asset", "maxPerTxRaw" }] | null }). An asset the key does not mention is
uncapped for that key. This lets each key run on its own leash without any account-wide setting
shared between keys.
The actor rule: only a Privy session (the human owner) may loosen — raise an existing key cap,
remove one, or clear the key’s limits back to unlimited. An agent key may only tighten its own
limits, or another key’s, never loosen them; a loosening attempt from an agent key returns
LOOSEN_REQUIRES_SESSION (403). Targeting a key prefix that is not one of the caller’s own (or
does not exist) returns 404; targeting a revoked key returns 409.
Key management
Section titled “Key management”Self-serve options on top of the account-level caps docs/headless-launch.md’s
Auth section already covers: a display label, a self-set expiration, and a per-key
windowed USD transaction limit, plus the usage figures behind it so an owner can see what
a key has actually spent before setting one. All four routes below sit on the same keysAuth
(Privy session or device token, see Auth in the headless-launch doc) as the existing POST /
GET / DELETE /api/v1/agent/keys — there is no x-api-key path into any of them today.
This is a different mechanism from Per-key spend limits above. PUT /api/v1/agent/keys/:prefix/limits caps a single asset’s raw amount on a single transaction,
enforced at the pre-build spend gate. The txLimit covered here caps a key’s own cumulative
USD-value trading volume over a rolling window (day/week/month) or its whole lifetime, enforced
separately at trade build. A key can carry both at once, independently.
Naming and expiration (POST /api/v1/agent/keys)
Section titled “Naming and expiration (POST /api/v1/agent/keys)”Three new optional fields on the existing issuance body, each echoed back in the response only when set:
| Field | Notes |
|---|---|
label | 1-64 characters after trimming; trimmed and validated server-side. With no label, the dashboard falls back to a capability-derived name (“Trader key”, “Launcher key”, …). |
expiresInDays | Integer, 1-365. Converted to an absolute expiresAt (now plus N days) at creation and echoed as that absolute value — the day count itself is not stored. |
expiresAt | Absolute epoch ms, must be strictly in the future. Mutually exclusive with expiresInDays: setting both is VALIDATION_FAILED. |
txLimit | { "usdMicros": <positive integer>, "reset": "never" | "daily" | "weekly" | "monthly" }. See KEY_LIMIT_REACHED below. |
Expiration is fixed at creation and cannot be changed afterward. There is no field for it on
PATCH and no renewal call: once a key is issued with (or without) an expiresAt, that value is
permanent for the rest of that key’s life. To change a key’s expiration, issue a new key and
revoke the old one. Omitting both expiresInDays and expiresAt keeps the existing default: no
expiry.
curl -X POST "$API_URL/api/v1/agent/keys" \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "label": "Trend bot (prod)", "expiresInDays": 90, "txLimit": { "usdMicros": 500000000, "reset": "daily" }, "scopes": ["launch:write", "launch:read", "activity:write", "swap:write"] }'{ "success": true, "key": "cndl_live_...", "keyPrefix": "abcd1234", "label": "Trend bot (prod)", "expiresAt": 1762900000000, "txLimit": { "usdMicros": 500000000, "reset": "daily" }}Renaming and transaction limits (PATCH /api/v1/agent/keys/:prefix)
Section titled “Renaming and transaction limits (PATCH /api/v1/agent/keys/:prefix)”The body must carry exactly one of { "label" }, { "txLimit" }, or { "clearTxLimit": true } —
these are three independent edits, not a partial merge, so sending more than one at once is
VALIDATION_FAILED. An unknown prefix and a prefix owned by a different account both report the
same 404 ("Key not found") every other owner-bound key route in this API reports, so a prefix
guess cannot distinguish “wrong owner” from “doesn’t exist.”
# Renamecurl -X PATCH "$API_URL/api/v1/agent/keys/abcd1234" \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "label": "Trend bot (v2)" }'
# Set a transaction limitcurl -X PATCH "$API_URL/api/v1/agent/keys/abcd1234" \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "txLimit": { "usdMicros": 250000000, "reset": "weekly" } }'
# Clear itcurl -X PATCH "$API_URL/api/v1/agent/keys/abcd1234" \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "clearTxLimit": true }'Each call succeeds with { "success": true, "label": "..." }, { "success": true, "txLimit": {...} }, or { "success": true, "txLimit": null } respectively.
Agent-actor rule, enforced at the Convex level. apiKeys.setTxLimit distinguishes a session
actor (a human owner, via Privy session or a device token) from an agent actor (a future
x-api-key-authenticated caller). A session actor may raise, lower, change cadence, or clear a
key’s txLimit freely. An agent actor may only tighten the usdMicros value while
keeping the key’s existing reset cadence — it may never raise the cap, change reset, or
clear the limit; any of those three throws server-side. Today PATCH /api/v1/agent/keys/:prefix
is keysAuth-only (session or device token), so nothing exercises the agent-actor path through
this route yet — the rule is a Convex-level guarantee already in place, ready for whichever
future agent-facing endpoint lets a key tighten its own cap.
Usage (GET /api/v1/agent/keys and GET /api/v1/agent/keys/:prefix/usage)
Section titled “Usage (GET /api/v1/agent/keys and GET /api/v1/agent/keys/:prefix/usage)”GET /api/v1/agent/keys now merges a usage object onto every row:
{ "success": true, "keys": [ { "keyPrefix": "abcd1234", "label": "Trend bot (prod)", "usage": { "todayUsdMicros": 12500000, "weekUsdMicros": 88000000, "monthUsdMicros": 340000000, "lifetimeUsdMicros": 950000000, "days30UsdMicros": 310000000, "tradeCount30": 41, "launchCount30": 2, "lifetimeLaunches": 6 } } ], "usageSince": "2026-08-15"}today / week / month are UTC-day, ISO-week (Monday start), and UTC-calendar-month to date;
days30 is a rolling trailing 30-day window, distinct from month. A key with no usage yet, or
one created before metering started, reports all zeros, never an omitted usage field.
usageSince names when metering itself started: 2026-08-15. There is no backfill. Trading and
launch activity from before that date is not represented in any of these numbers, including
lifetimeUsdMicros: it is not “zeroed because nothing happened,” it is “not counted because
nothing was recorded yet.” A key that was heavily used before this date shows a lifetime total that
understates its true history.
GET /api/v1/agent/keys/:prefix/usage?days=N returns a zero-filled daily series for charting,
days clamped to 1-90 (default 30):
{ "success": true, "keyPrefix": "abcd1234", "days": 30, "series": [ { "day": "2026-07-17", "tradeVolumeUsdMicros": 0, "tradeCount": 0, "launchCount": 0 }, { "day": "2026-08-15", "tradeVolumeUsdMicros": 12500000, "tradeCount": 3, "launchCount": 0 } ]}Ascending, oldest first, today last, exactly one entry per calendar day in the window regardless of
whether a trade or launch happened that day — a day with no matching row zero-fills rather than
being omitted, so the array is always exactly days entries long. An unknown or non-owned prefix
gets the same 404 "Key not found" PATCH reports above.
KEY_EXPIRED (401)
Section titled “KEY_EXPIRED (401)”Once a key’s expiresAt passes, every authenticated call using that key — any endpoint, any
scope — fails KEY_EXPIRED (401), not the generic UNAUTHORIZED a bad or revoked key gets. It is
checked immediately after the key’s hash is verified, so a caller must already prove possession of
the real secret before learning “expired” rather than “invalid” — a bare key prefix, which can
leak via a display UI, still cannot be used to probe a key’s expiry status. Expiration is fixed at
creation (see Naming and expiration above): there is no way to extend or clear it on an existing
key. Recovery is always the same: create a new key.
KEY_LIMIT_REACHED (403)
Section titled “KEY_LIMIT_REACHED (403)”txLimit caps a key’s own cumulative trading volume, valued in USD, over the window its reset
names: daily (UTC day), weekly (ISO week, Monday start), monthly (UTC calendar month), or
never (the key’s whole lifetime). It is checked once, at POST /api/v1/trade/agent/build, right
after the trade’s real quote asset is known: if the key’s usage in the current window plus this
trade’s own value would exceed the cap, the build fails KEY_LIMIT_REACHED instead of proceeding:
{ "success": false, "error": { "code": "KEY_LIMIT_REACHED", "message": "This key's transaction volume cap would be exceeded by this trade", "retryable": true, "resetsAt": 1755302400000, "uiHint": "This key hit the transaction limit its owner set. Wait for the window to reset, trade a smaller amount, or raise the limit on the key." }}resetsAt is the epoch ms when the current window rolls over, or null for a never (lifetime)
cadence, which has no reset. retryable is true only when resetsAt is non-null: a lifetime cap
can never be retried as the same trade, only a smaller amount or a raised limit gets past it.
Say this plainly: the cap is an approximate guardrail, not a hard accounting boundary.
Enforcement reads the key’s usage rollup at build time, before the trade has executed; the actual
usage record for that trade only lands once the trade completes (a main payer’s inline execution,
or a linked payer’s verified /confirm), asynchronously and best-effort. Two consequences follow
directly:
- Concurrent trades on the same key can each pass the check independently and, together, exceed the cap. Two builds that both check the window before either one’s usage has landed both see the same (stale) total and both pass, even though their combined value would have failed.
- A trade that legitimately passed the check at build time can still push the recorded total over the cap by the time it completes, since nothing re-checks the limit between build and completion.
A sell is valued by its quote-asset proceeds, never the raw token amount being sold: at build
time, the plan’s own quoted expectedOutRaw; at Solana /confirm, the decoded actual quote-asset
amount that landed, when that is available. This is the same valuation rule the usage numbers above
use, so what trips the cap and what appears in GET /keys’s usage object always agree.
RISK_LIMIT_REACHED (403)
Section titled “RISK_LIMIT_REACHED (403)”A different control from KEY_LIMIT_REACHED above, and the difference is the thing to get right:
that one caps how much a key may spend in a window and clears itself when the window rolls.
This one caps how much a profile may lose, and it does not clear on any clock.
An owner sets any of three bounds on a profile, through
PUT /api/v1/agent/keys/{prefix}/loss-limits:
| bound | what it measures |
|---|---|
maxDailyLossUsdMicros | realized loss inside the current UTC day |
maxDrawdownUsdMicros | fall from the profile’s high-water equity mark, which never resets |
maxConsecutiveLosses | losing closes in a row |
Alongside them, every profile carries a circuit breaker that nobody opts into and nobody can turn off, because it catches malfunction rather than losses: too many trades inside a minute, the same token bought repeatedly without selling, and three failed sells of one token in a row.
Breaching any of them stops the profile. Every later build answers:
{ "success": false, "error": { "code": "RISK_LIMIT_REACHED", "message": "This profile is stopped: it breached its daily loss limit. An account owner must clear it before trading resumes.", "retryable": false, "uiHint": "This profile stopped itself: it reached a loss limit or tripped its circuit breaker. Waiting will not clear it. An account owner reviews the profile and resumes it from the agent console." }}retryable is always false, and an agent should believe it. There is no window to wait out
and no smaller amount that gets through. The stop is cleared by a person, through the agent console
or POST /api/v1/agent/keys/{prefix}/risk/resume, and that route refuses an API key on purpose: an
agent that tripped its own limit must not be able to talk itself back into trading. The right
response to this code is to stop trading on this profile and say why.
GET /api/v1/agent/keys/{prefix}/risk answers with the bounds, the rollup they are judged against,
and which one broke. A key may read its own, so a stopped agent can report the reason without a
human having to go and look.
A key may also tighten its own bounds through the PUT above, and may never loosen or clear
one. Same rule txLimit follows, for the same reason.
Say this plainly: these are guardrails, not a hard accounting boundary. The rollup behind them is recomputed after each trade settles, so a bound stops the NEXT trade. It cannot unwind a position already open, and one large trade can carry a profile well past its limit before anything sees it. Two more consequences worth stating:
- Zero is a real setting, not “unset”.
maxDailyLossUsdMicros: 0means stop on the first losing trade. SendlossLimits: nullto clear the bounds instead. - A profile with bounds set is refused when its risk state cannot be read. This is the opposite
of
KEY_LIMIT_REACHED, which fails open on infrastructure trouble, and it is deliberate: a volume cap bounds what an abuser spends, while a loss limit bounds the owner’s own money while they are not watching. A profile with no bounds set is never affected by this.
Reading the feed without drowning in it
Section titled “Reading the feed without drowning in it”GET /api/v1/markets/feed?bucket=new|graduated|onfire|bluechip is the public read behind
candle_get_feed. Unfiltered it returns every token in the bucket with all of its fields, which
measured about 135KB and will not fit in an agent’s tool result.
Four query parameters narrow it server-side. No new data, no new pipeline; the caller says what it wants.
| parameter | form | what it does |
|---|---|---|
where | JSON object | Field constraints. Comparators: eq, ne, lt, lte, gt, gte, present. |
sort | field or field:asc / field:desc | A bare field means descending. |
fields | comma list | Which fields to return. chain, address and symbol always ride along. |
limit | 1 to 200 | Applied after filtering and sorting. |
GET /api/v1/markets/feed?bucket=graduated &where={"marketCap":{"lt":150000},"liquidityUsd":{"gte":25000},"mintAuthorityDisabled":{"eq":true}} &sort=change1h:desc &fields=symbol,marketCap,liquidityUsd,holderCount,change1h &limit=20That is roughly 2KB.
The response adds two counts beside tokens: matched, how many rows the filter found before
the limit, and scanned, how many were in the bucket before the filter. Without them a caller
that asked for 20 and received 20 cannot tell a complete answer from a first page, or a filter that
matched nothing from a bucket that was empty.
Say this plainly: a missing field is not a false one
Section titled “Say this plainly: a missing field is not a false one”mintAuthorityDisabled and freezeAuthorityDisabled were each absent on 17% of the rows in one
measured response, and liquidityUsd and holderCount on 16%. An absent field means nobody
checked. It does not mean the authority is disabled, and it does not mean the liquidity is zero.
So an absent field satisfies no comparison at all. {"mintAuthorityDisabled":{"eq":true}}
returns only tokens that actually say true, never tokens where the flag is missing. There is
exactly one operator an absent field can satisfy, and asking for it is how you measure the coverage
gap rather than guessing at it:
&where={"mintAuthorityDisabled":{"present":false}}The same rule governs sorting: rows missing the sort field sink to the bottom in both
directions. A token with no marketCap is not the smallest token on the platform, so an ascending
sort must not hand it back as the cheapest.
Unknown fields and comparators are refused, not ignored. A filter on a misspelled field that silently matched everything would return the whole feed to a caller who believed they had narrowed it, which is the exact failure these parameters exist to prevent.
Watching cheaply, with a cursor you already have
Section titled “Watching cheaply, with a cursor you already have”Polling the feed to see what moved means re-reading the whole bucket, which is the cost this
section exists to avoid. updatedAt turns that into a delta:
GET /api/v1/markets/feed?bucket=new&where={"updatedAt":{"gt":1757520000000}}&limit=200Keep the largest updatedAt you have seen and pass it back next time. Milliseconds, not seconds.
A row’s stamp only moves when its numbers do. The refresh re-sends every row on every tick, and the write is skipped when nothing but the clock changed. So an unchanged token does not come back in your next delta, which is exactly what makes this worth doing rather than a way of re-reading the same page with extra steps.
Two caveats. The window is what the bucket currently holds, so a token that left the feed entirely
does not appear as a deletion; compare against the set you are holding if disappearance matters.
And matched beside the results tells you how many rows changed before the limit, which is how
you find out you are polling too slowly.
Liquidity that is leaving
Section titled “Liquidity that is leaving”liquidityUsd is a level, and a level says nothing about direction. A pool holding $40,000 might
be filling or might be halfway out the door, and those are opposite trades.
Two fields carry the memory that tells them apart:
| field | what it is |
|---|---|
liquidityHighWaterUsd | the highest liquidity recorded for this token since it entered the feed |
liquidityDrawdownBps | how far below that peak the latest reading sits, in basis points |
The mark ratchets up only. A pool that drained and partly refilled is still measured against what
it once held, because that is the number that says how much has left. 5000 is half the peak gone.
This matters because liquidity leads the price. By the time a candle shows the damage the exit is already worse, so a drain is the earliest actionable warning in this data. Screen on it like any other field:
GET /api/v1/markets/feed?bucket=graduated &where={"liquidityDrawdownBps":{"gte":3000}} &sort=liquidityDrawdownBps:desc &fields=symbol,liquidityUsd,liquidityHighWaterUsd,liquidityDrawdownBpsOr invert it, and hold only what is not bleeding: {"liquidityDrawdownBps":{"lt":1000}}.
Absent is not drained, and this is the rule that matters. liquidityUsd is missing on roughly
one row in six. A token whose figure disappears has not drained to zero; nobody knows what it is.
So a missing reading produces no drawdown at all rather than a 100% one, and it leaves the mark
where it was. The field is simply absent, which the filter already treats as unknown.
That leaves you the choice, which is the point. {"liquidityDrawdownBps":{"present":false}} is the
unpriced sixth of the market, and whether you trade it is yours to decide. What the API will not do
is report those tokens as rugs.
Two limits worth stating plainly. The mark resets if a row leaves the feed and comes back, so it means “the peak since this token was last continuously listed”, not the peak of all time. And a pool that never had liquidity has no percentage to fall from, so it carries no drawdown either.
Holders arriving, and holders leaving
Section titled “Holders arriving, and holders leaving”holderCount has the same problem liquidityUsd has: it is a level, and the rate is the signal. A
token went from 347 holders to 13 over eight hours. The count said so only after the fact; the rate
said so throughout.
Three fields carry the rate, each the percent change in holders over its own window:
| field | window |
|---|---|
holderChange5m | five minutes |
holderChange1h | one hour |
holderChange24h | twenty-four hours |
They were already being collected. The source has always sent them and the feed has always stored
them, inside the nested stats object that the query parameters have no path syntax for. Lifting
them onto the row is the whole change, so they cost nothing new to produce:
GET /api/v1/markets/feed?bucket=new &where={"holderChange1h":{"lt":-20},"holderCount":{"gte":50}} &sort=holderChange1h:asc &fields=symbol,holderCount,holderChange1h,holderChange24hPair the rate with the count, as that example does. Three holders becoming six is +100% and means nothing. Below about fifty holders a single wallet moves the percentage by double digits, so the figure measures the size of the base rather than the behaviour of the holders.
On Hood these fields are always absent. The upstream publishes no holder rate in any window, so
a Hood token carries holderCount and no change figure at all. This is worth saying separately
from the general absent-is-not-false rule, because the gap is not random: a screen that read absent
as zero would conclude that every token on one entire chain has a perfectly stable holder base.
That is not a measurement, it is the absence of one. Solana rows also lose the figure whenever the
feed falls back to its secondary source, which supplies no holder data.
Who launched this before
Section titled “Who launched this before”GET /api/v1/markets/deployer/<address> answers what an address has launched and how those
launches ended. Public, no credential.
{ "address": "6Yk...", "priorLaunches": 14, "graduated": 0, "onCurve": 13, "recovery": 1, "graduationRate": 0, "minLaunchesForRate": 3, "scanned": 14, "truncated": false}Fourteen launches and none of them reached a market is a complete decision on its own. No other field competes with it.
The same arithmetic already ran inside a full forensics report and was discarded outside it, so
this was reachable only by asking for a report on one specific mint. That costs a chain walk and
answers about a token. This is keyed by address and reads one index. Pass excludeMint to
leave out the token you are already looking at, so the answer is what else this deployer shipped.
graduated, onCurve and recovery always sum to priorLaunches, because onCurve is computed
as the remainder rather than counted separately.
The rate is withheld below minLaunchesForRate. One launch that has not graduated is not a
zero percent record, it is no record, and a single data point rendered as a percentage is a
confident way of saying nothing. Above the floor a genuine zero is reported, because that is the
signal.
Two things this cannot tell you
Section titled “Two things this cannot tell you”Only launches Candle recorded are here. No table anywhere stores a deployer for a token we did not launch, so for an external mint this returns an empty record. Empty is not a clean history. It means nobody knows, and the honest reading of an empty answer for a pump.fun token is that the question was not answerable rather than that the deployer is new.
Not graduated is not the same as went to zero. A token that never graduated may be an hour old. There is no price history for external tokens and none after graduation for our own, so survival is a claim this data cannot support. The field names say what is actually measured, and an agent wanting a rug rate has to bring something else to it.
A profile’s configuration, as something you can hold
Section titled “A profile’s configuration, as something you can hold”GET /api/v1/agent/keys/<prefix>/config returns what a profile may do and every cap that bounds
it, and nothing that identifies or authenticates it.
{ "version": 1, "scopes": ["swap:write"], "environment": "production", "label": "Scalper", "expiresInDays": 30, "txLimit": { "usdMicros": 500000, "reset": "daily" }, "spendLimits": [{ "asset": "sol", "maxPerTxRaw": "50000000" }], "lossLimits": { "maxConsecutiveLosses": 3 }, "walletScope": "selected"}A profile’s row mixes three unrelated things: a credential, an identity, and the rules its owner chose. Only the third is the strategy, and it was not an object you could hold. You could read it off a screen and retype it, which is why cloning a profile was manual and why there was nothing to diff, version or publish.
Three rules worth knowing before you use it
Section titled “Three rules worth knowing before you use it”A config is not a credential. It is built from an allowlist of configuration fields, never from the row with a few fields removed. The key hash sits in the same row as the loss limits, and a serializer written the other way is one refactor away from putting a key hash in a file somebody shares.
Expiry travels as a duration. expiresInDays, not a date. An absolute expiry exported in
September and applied in December is already expired when it lands, so the profile would be born
dead and the failure would look like a bug rather than a stale file.
Wallet scope always exports as selected. Even when the profile it came from can use every
wallet. Applying all would hand a profile spending access to every wallet on the receiving
account, which is the largest privilege escalation in this feature. Narrowing on the way out is
safe; widening on the way in is not, and a config that crosses an account boundary is the case
this rule exists for. Widen it afterwards from a signed-in session if you mean to.
An omitted cap means no cap. Applying a configuration is a replace, not a merge. Absence never means “leave whatever is there”, because reading it that way is how a config that looks conservative produces an unlimited profile.
Stopping one profile without destroying it
Section titled “Stopping one profile without destroying it”POST /api/v1/agent/keys/<prefix>/stop with {"paused": true} stops one profile. {"paused": false}
starts it again.
There were two ways to stop an agent before this and neither fit the situation people are actually in. The account switch halts every profile at once, which is right when the account is the problem and far too blunt when one strategy is misbehaving and three others are fine. Revoking a key is precise but permanent: nothing anywhere un-revokes a key.
So the only precise tool was the irreversible one, and a person deciding between halting a working account and destroying a key forever hesitates. Hesitating is the one thing a kill switch must never cause.
A stopped profile’s own requests are refused with PROFILE_PAUSED. That is deliberately a
different code from RISK_LIMIT_REACHED, which reads almost the same to a machine and means
something quite different to a person:
| code | what happened |
|---|---|
RISK_LIMIT_REACHED | the profile stopped itself on a bound its owner set |
PROFILE_PAUSED | a person decided to stop it |
Telling an owner their agent “tripped a limit” when they pressed the button themselves would send them looking for a fault that does not exist.
Three rules worth knowing:
- Session only. A paused key cannot resume itself, which is the whole point of a stop, and a compromised key cannot turn its own leash off.
- A revoked key can be neither paused nor resumed. If resume worked on a revoked key, revocation would be reversible by another name and nobody could rely on it.
- Both directions are idempotent, and the response says whether anything actually changed. A stop button gets pressed twice by someone who is not sure the first press landed, and an error on the second press reads as though the stop failed.
Everything else about the profile survives: its name, history, limits, wallet scope and keys are untouched, and it comes back with one call.
What the trade actually cost
Section titled “What the trade actually cost”A trade response used to carry the Candle fee and the quoted output. At a $7 stake the fee is not the dominant cost. The gap between the price a quote promised and the price the fill delivered usually is, and nothing reported it.
Two fields on amounts now do:
| field | what it is |
|---|---|
actualOutRaw | what the trade actually delivered, read off the confirmed transaction |
slippageBps | how far that came in below expectedOutRaw, the quote taken at build time |
{ "amounts": { "amountRaw": "1000000", "expectedOutRaw": "2500000", "actualOutRaw": "2437500", "slippageBps": 250, "minOutRaw": "2375000", "quoteAsset": "usdc" }}Positive is adverse. Negative means the fill beat its quote, which genuinely happens and is deliberately not folded into a magnitude: an agent that read every deviation as a cost would learn to avoid the routes that were doing it a favour.
Both are Solana only, because that is where the fill can be decoded from balance deltas. Both are
omitted rather than zeroed when nothing could be decoded, so "actualOutRaw" in amounts is a
truthful test of whether a fill was measured. A zero would be a concrete claim that the trade moved
nothing.
Worth knowing why this arrived late. Two rails execute a trade, and only one of them was reading
the chain back. A linked payer’s /confirm decoded the real balance deltas; a main payer’s inline
/build proved the trade landed with a signature status, which carries no balances, and recorded
the build-time quote as though it were the fill. So the same trade placed two ways produced two
different records, and the difference between them was exactly the number this section is about.
And what it cost across a whole profile
Section titled “And what it cost across a whole profile”The same figure aggregates. A profile’s P&L now carries a friction block beside the result:
| field | what it is |
|---|---|
feesUsd | Candle fees across every counted fill |
slippageUsd | estimated USD given up to slippage, across the fills that could be measured |
slippageBpsAvg | size-weighted average slippage, absent when nothing was measured |
measured / unmeasured | how complete that is |
It is reported, never netted. Realized P&L is computed from what the chain actually filled, so the slippage is already inside it. Subtracting this figure as well would count the same loss twice and report a profile as losing money it never lost. What it answers is a different question: how much of the gap between the plan and the outcome was execution rather than the trade being wrong.
The average is weighted by size, not a plain mean. A 900 bps slip on a $2 trade and a 10 bps slip on a $500 trade do not describe the same execution quality, and a flat average says they do.
slippageUsd is an estimate and is labelled as one. It prices each fill’s shortfall against that
fill’s own USD size, which is exact for a sell, where the shortfall arrives as fewer dollars, and
an approximation for a buy, where it arrives as fewer tokens.
This is not the same thing as price impact. A pool can report 4 bps of impact and still cost
604 bps to round trip. Impact is a depth statistic and both sides of it pay the same proportional
fee, so every fee cancels out of it. Ask for roundTripBps on a quote if you want the all-in cost
before trading, and read slippageBps afterwards for what the fill actually did.
Alerts on the watchlist
Section titled “Alerts on the watchlist”Wallet tracking already filtered the live tape, but that filter ran in the browser, so the watchlist only worked while the tab was open. That is the opposite of what a watchlist is for.
Register for tracked_wallet.traded on a webhook endpoint and a sweep pushes a notification when
a wallet you watch buys or sells. There is nothing else to configure: the watchlist you already
have is the subscription.
Two rules decide whether this is useful or annoying, and both are worth knowing:
- You are never told the same thing twice. Each watched wallet carries a watermark, and only strictly newer trades qualify. Without it, a sweep that re-reads a window of recent activity would alert on the same trade every minute forever.
- You are never told about yourself. People routinely watch a wallet they own, and being told that you just bought something is not information.
A wallet that trades constantly is capped per sweep, and the watermark clears the trades that were held back rather than deferring them. An alert about a trade from twenty minutes ago is not worth the notification it costs, so a backlog is skipped rather than worked through.
Watch a deployer, not a token
Section titled “Watch a deployer, not a token”The same list does both. A wallet you track is watched for launches as well as trades, and
tracked_wallet.launched fires when it deploys.
This is the stronger of the two signals by some distance. A token tells you about one launch; a deployer tells you what that person does, and the wallet behind three of last week’s runners is deploying again is worth being woken for in a way that no individual mint is. It is also the natural companion to the deployer prior-token history in the forensics report.
There is no second list to maintain: a deployer is a wallet, and asking somebody to keep two overlapping lists of addresses is how both go stale.
Launch alerts are capped harder than trade alerts. A deployer minting eight tokens in one minute is not eight pieces of news, it is one piece of news about a deployer minting eight tokens. Non-production launches never alert.
Both halves share one watermark per watched wallet, so you get one stream about that wallet rather than two that can disagree about how far you have been told.
The sweep runs about once a minute. It reads, decides and enqueues; it never touches chain state and never moves money.
Telling someone you are still running
Section titled “Telling someone you are still running”POST /api/v1/agent/heartbeat (agent key, any scope). Optional body:
{ "nextExpectedBy": 1755000060000, "status": "scanning", "note": "waiting on a fill" }A dead agent looks exactly like a quiet market. Nothing errors, nothing alerts, the dashboard shows the last trade from four hours ago, and noticing requires already suspecting. This is what makes that visible.
The agent sets its own deadline, not the platform. nextExpectedBy is when YOU say you will
report again. Only you know whether you run every thirty seconds or every twelve hours, and one
platform-imposed timeout would alert constantly on the first and never on the second.
A deadline already in the past is refused, not stored. An agent computing
Date.now() + interval with a zero interval would report itself permanently overdue, and a screen
full of false alarms is worse than no screen: the operator learns to ignore it, then misses the
real one. More than a day out is refused too, because past that it is a calendar note rather than
a liveness signal.
The owner reads GET /api/v1/agent/workers (session only) and sees one of five states per profile:
| state | means |
|---|---|
live | Checked in within its own deadline. |
late | Past that deadline, inside a ten-minute grace. Usually a retry or a slow RPC. |
missing | Past the grace. Was reporting and stopped. This is the one worth acting on. |
unknown | Checked in but stated no deadline, so nothing can tell whether it is late. |
never | Has never checked in. |
never is not live, and unknown is not live either. An absent check-in reads naturally
as “no news is good news”, and a screen built that way shows green for agents that never ran. A
worker that reported without a deadline promised nothing, so it cannot be late; calling it healthy
would let an agent silence its own monitoring by omitting one field.
Heartbeating is entirely optional. A profile that never calls this is reported as never and
raises no alarm, because most profiles do not use it.
Paper mode
Section titled “Paper mode”Send paper: true on POST /api/v1/trade/agent/build and the trade runs every admission rule a
real one runs, records the quote, and never touches a wallet.
{ "clientTradeId": "paper-1", "mint": "So1...", "side": "buy", "amountRaw": "100000000", "payer": { "type": "main" }, "paper": true }A flag on /build rather than an endpoint of its own, deliberately. The entire value of a
paper trade is that it was admitted by the SAME rules as a live one: the same market resolution,
the same planner, the same wallet scope check, the same per-key spend caps, the same USD volume
cap, and the same loss limits and circuit breaker. A separate endpoint would be a second copy of
those rules, drifting quietly until paper stopped predicting anything about live.
What it does NOT do. No ledger row is written, no clientTradeId is burned on the live rail,
no sign claim is stamped, no graduation watcher is armed, and nothing is signed or broadcast. The
paper branch returns before the code that could do any of those exists.
The response always carries paper: true at the top level, so a caller cannot mistake it for a
live one by failing to look for a field.
Reading the results
Section titled “Reading the results”| route | what it answers |
|---|---|
GET /api/v1/agent/keys/{prefix}/paper | one profile’s paper P&L and its recent paper trades |
GET /api/v1/agent/paper | the whole account’s paper arm, per profile (session only) |
DELETE /api/v1/agent/keys/{prefix}/paper | discard a profile’s paper history (session only) |
Paper rows live in their own table. Nothing that reports live money can see them, so a paper fill can never appear in a profile’s public record. That matters more than it sounds: the value of a Candle trading record is that it is derived rather than asserted, and one paper fill inside it turns a checkable record back into a claim.
Clearing is session-only on purpose. Practice should be discardable, but the agent holding the key must not be able to erase its own bad paper run and re-report a clean one.
Say this plainly: paper P&L is optimistic
Section titled “Say this plainly: paper P&L is optimistic”A paper trade records the quote, which is what the planner said the trade would return at the moment it was admitted. No order ever reached a market. So a paper figure is better than the real one by exactly the slippage, the priority fees, and every fill that would not have happened at the quote.
That gap is not a defect. It is the measurement paper mode exists to make: run a paper arm beside a live one, and the difference between them is your execution cost, which is otherwise unknowable. Both arms are accounted by the same engine so that the difference means only that, and not a difference in convention as well.
Curve mechanics and graduation (Solana)
Section titled “Curve mechanics and graduation (Solana)”What an agent needs to know about how a Candle bonding curve actually holds funds and graduates, learned the hard way by a live agent that monitored the wrong account for an hour.
Where the SOL lives. A curve’s quote liquidity accumulates as wrapped SOL in the pool’s
quoteVault token account, never as native SOL on the curve address itself. The curve
address (virtualPool on the token row) is a program account whose native balance is just
rent and never moves with trading — polling it shows a flatline regardless of volume. The
vault’s total is quoteReserve + accumulatedFees: the reserve is what counts toward
graduation, the fees (the curve’s own feeBps, e.g. 100 = 1%) are collected on top.
Do not poll on-chain accounts for progress — the market API reports it. For a
curve-phase Solana token, GET /api/v1/markets/solana/:mint returns live progress inside the
migration block, read from the pool itself (5s cache):
"migration": { "status": "not_started", "reserveRaw": "250000000", "thresholdRaw": "1000000000", "progressBps": 2500}reserveRaw/thresholdRaw are in the quote asset’s raw base units; progressBps is the
ratio in basis points, capped at 10000. Detecting external buys is a matter of watching
reserveRaw grow beyond what your own trades put in.
Graduation is two stages. Stage 1 is on-chain and instant: the buy whose net-of-fee
inflow pushes quoteReserve past migrationThreshold closes the curve (there is no partial
fill or refund on Solana — the full buy lands, then the curve is done). Stage 2 is
server-side aftercare: withdraw the liquidity, seed the Meteora DAMM v2 pool, mark the token
complete, distribute staker rewards. Between the stages — typically seconds via the watcher,
worst case a few keeper passes (minutes) — the market reports migration.status: "in_progress", and both buys and sells return MARKET_NOT_TRADABLE (409, “closed while
graduating”). This is a normal, transient state: retry after status flips to
"completed", do not treat it as a broken market.
After graduation the token row carries complete: true and poolAddress (the DAMM v2
pool); trades on this same endpoint pair route through Jupiter automatically. One caveat:
Jupiter needs a short while to index a freshly created pool, so the first minutes after
graduation can quote no route — that also clears on its own.
The curve.graduated webhook fires to the token creator’s registered endpoints the
moment graduation is observed (see Webhooks below) — for an agent that launched the token,
subscribing beats polling entirely.
The base-asset swap rail is free
Section titled “The base-asset swap rail is free”A separate, one-shot endpoint, POST /api/v1/agent/swap (swap:write scope,
apps/api/src/routes/agent.ts), converts between the platform’s own closed set of base assets —
SOL, USDC, CNDL on Solana; ETH, USDG on Hood — never an arbitrary token. It is free for every
tier: no platform fee is charged, and its amount is not subject to any spend cap (the spend gate
described above governs this trade rail’s buys only, never a base-asset swap). It is still gated
like every other agent surface, though: the AGENT_TRADING_ENABLED kill switch, the swap:write
scope, and the caller’s own per-key rate limit all apply exactly as they do here. A route-level
test pins this behavior: a successful base-pair swap’s response carries no fee-shaped field and
executes exactly one broadcast, so a future change cannot silently reintroduce a fee.
The one-shot rail is main-wallet only. It quotes and executes server-side through the key
owner’s own embedded wallet(s); it has no payer parameter and never touches a linked wallet
(Candle never signs for a linked wallet, and the sign relay refuses any transaction the API did
not build). A linked wallet converts base assets through the trade rail instead, as a
base-pair trade: POST /trade/agent/build with a base-asset mint (USDC, CNDL, or wSOL
So11...112) against a base quoteAsset — e.g. mint: <USDC>, quoteAsset: "sol", side: "buy"
swaps SOL into USDC from the linked wallet through the normal build -> relay-sign -> submit flow.
The class carries the swap rail’s contract with it: every tier (no Pro/Max requirement), no
platform fee (fee.bps: 0), and no spend cap, while the kill switch, scope, and rate limit
apply as usual. Solana only, like the arbitrary path; the same asset on both legs is rejected
with VALIDATION_FAILED.
Linked cross-chain swaps: SOL into ETH/USDG
Section titled “Linked cross-chain swaps: SOL into ETH/USDG”The one-shot rail above cannot bridge from a linked wallet, so cross-chain conversion has its own linked pair of endpoints in the trade rail’s build -> relay-sign -> submit shape:
POST /api/v1/agent/swap/build{from: "SOL", to: "ETH"|"USDG", amountRaw, payer: {type: "linked", linkedWalletId}, toWalletId?, maxSlippageBps?}— quotes the bridge (Relay) with the linked wallet as payer, compiles the unsigned Solana deposit transaction, and stamps its bytes askind: "swap"claims so the sign relay will sign them. Returns{swapId, transactionsBase64, expectedOutRaw, statusChecks, recipient, expiresAt}.- Sign each transaction through the sign relay (
signLinkedTransaction()in the SDK). Sign promptly: the deposit carries a recent blockhash and expires in about a minute. POST /api/v1/agent/swap/submit{swapId, signedTransactionsBase64}— verifies each signed transaction against the build by MESSAGE bytes (a swapped-in transaction is refused asUNRECOGNIZED_TRANSACTIONbefore any broadcast), broadcasts server-side, and returns{hashes, statusChecks, recipient}.hashesproves the Solana deposit; the bridge fill is asynchronous — pollstatusChecksto observe it complete.
The SDK wraps all three as swapFromLinked({from: "SOL", to, amountRaw, payer: {linkedWalletId, privyWalletId}, toWalletId?}).
Destinations are own-wallets-only. toWalletId must be a linked EVM wallet of the SAME
account (attribution-only links qualify — receiving needs no signing); omitted, the output
lands on the owner’s embedded Hood wallet. An arbitrary address is never accepted, so an
over-scoped key can rearrange the account’s funds but not exfiltrate them.
v1 scope: from: "SOL" only. SOL/USDC/CNDL conversions are same-chain — use the free
base-pair trade above; a USDC/CNDL origin converts to SOL that way first, then bridges. Hood
origins remain the one-shot main-wallet rail. Builds expire after 10 minutes server-side (and
practically after ~1 minute with the blockhash); an expired swapId answers 404 — rebuild.
Build expiry and built-replay term refresh
Section titled “Build expiry and built-replay term refresh”A "built" row (linked payer only) that never gets confirmed lazily expires after 30 minutes,
the same window and the same lazy, per-account sweep mechanism self-signed launches use (see
Ledger and counter semantics in the headless-launch doc): every /build call sweeps the CALLING
account’s own other abandoned "built" rows before doing anything else; there is no cron and no
cross-account sweep. Once a row is swept (or simply old enough that a later /build call swept
it), /confirm against it reports JOB_NOT_FOUND, naming that a rebuild is needed.
Calling /build again with the same clientTradeId and the same body while the row is
still "built" and unexpired re-quotes it: the artifact is rebuilt fresh (a new Solana blockhash;
a Hood approval re-checked against the payer’s current on-chain allowance) and the row’s
economic terms (fee, expectedOutRaw, minOutRaw, plus builtAt) are refreshed to that
new quote, inside agentTrades.begin’s own transaction (see its existing.status === "built"
branch). Row, response, and artifact therefore always agree, which is load-bearing: /confirm
verifies the broadcast against the ROW, so a row left pinned to a superseded quote would reject a
legitimately fresh artifact. expiresAt in that replay response is the refreshed builtAt plus
30 minutes, so a rebuild restarts the window rather than inheriting the original one. An artifact
signed before a rebuild is intentionally rejected afterwards.
A main-payer row stuck "built" (an indeterminate broadcast, or a genuinely concurrent
request) is never silently re-executed on a same-id retry — that could double-spend if the
original attempt actually landed. It returns the retryable IDEMPOTENCY_CONFLICT (409) instead,
same as an in-flight headless launch.
Webhooks
Section titled “Webhooks”trade.executed (see Webhooks in the headless-launch doc for registration, signing, and retry
mechanics, all shared) fires once, after any trade that actually records: a main payer’s
inline execution, or a linked payer’s verified /confirm. Never fired for a failed verification
or an idempotent replay.
| Event | When | Payload core |
|---|---|---|
trade.executed | An agent trade recorded | clientTradeId, chain, side, mint, quoteAsset, amountRaw, expectedOutRaw, signature, fee: { bps, feeRaw } |
order.triggered | A limit order’s price condition held and its status flipped to triggered. Moves no money: the agent still completes the order through /build -> sign -> /confirm. Exists so a consumer can drop the poll on GET /orders?status=triggered | clientOrderId, chain, mint, side, amountRaw, targetPrice, price (the observed price that satisfied the condition), expiresAt |
transfer.executed | A transfer out of the embedded wallet recorded, on either the inline or the build/submit path | chain, asset, amountRaw, from, to, destinationClass (own or approved), signature |
withdrawal_address.added | An address was added to the account’s withdrawal allowlist. Pushed so a consumer can alert on the account’s spend boundary widening | chain, address, label (when set) |
withdrawal_address.revoked | An address was removed from that allowlist | chain, address, label (when set) |
tracked_wallet.traded | A wallet on this account’s watchlist bought or sold. Fired by a sweep rather than by the trade path: the wallet that traded is usually on somebody else’s account entirely | chain, wallet, label (the watcher’s own name for it, when set), side, signature, tokenMint, tokenSymbol, usdAmount (absent when unpriced), at |
tracked_wallet.launched | A wallet on the watchlist DEPLOYED a token. The stronger of the two signals: a token tells you about one launch, a deployer tells you what that person does | chain, wallet, label (when set), mint, symbol, at |
The three transfer-rail events above were emitted from the day the rail shipped but were missing from the registerable set until 2026-09-10, so registration refused them and every one of those deliveries was enqueued and then dropped. If you tried to subscribe to them before that date and concluded they did not exist, they do now.
Fee legs on launches
Section titled “Fee legs on launches”Task 4/5’s fee legs, referenced above, ship alongside this rail on both existing launch paths
(see docs/headless-launch.md for the launch flows themselves):
- Delegated headless (
POST /api/v1/launch/headless, owner-signed via Privy): the fee, when the launch carries a nonzero dev buy, rides inside the same Solana launch transaction (like a trade’s own Solana fee leg), or is sent as a companion Hood transaction after the dev buy’s own receipt lands (best-effort, same non-fatal posture as a main-payer trade’s Hood fee). - Self-signed (
POST /api/v1/launch/self/build+/confirm): the fee, when the requested dev buy is nonzero, is embedded directly in the unsigned Solana build transaction, or returned as its ownfeeTransferartifact in the Hood build response — structurally identical to a linked payer’s trade artifacts./confirmrequiresfeeTxHashunder the sameFEE_LEG_MISSINGrule described above, and a Hood fee hash is single-use across launches and trades alike (see the previous section).
The enforcement boundary
Section titled “The enforcement boundary”Stated plainly: the fee binds to API-built transactions. Everything above — the spend gate,
the fee-stripped-confirm refusal, the signature/hash reuse guard — constrains transactions this
API assembled. An agent that hand-builds a transaction directly against the deployed curve or
Jupiter contracts, bypassing /build entirely, pays only the on-chain curve fees every trader
already pays, and nothing more. That trade still attributes correctly — the Hood curve-trade
ingester (the keeper that catches trades made outside Candle’s own UI; see Attribution-only linking
in the headless-launch doc) and Solana’s own activity-report path both resolve identity the same
way regardless of how the transaction was built — it simply does not carry this platform fee. This
is a scope boundary, not a bug: enforcing a fee on activity Candle never assembled would require
policing every possible on-chain interaction with the curve, not just the ones this API produces.
Token forensics: the pre-buy gate
Section titled “Token forensics: the pre-buy gate”GET /api/v1/markets/:chain/:mint/forensics (public, keyless, candle_token_forensics in the
MCP) reports four things about a launch and scores them:
- deployer: every other token this account launched, and how each ended (
graduated,onCurve,recovery). - deployWindow: who bought in the first moments. Solana reads the launch slot plus two from
the pool’s own history; Hood reads the curve’s
Boughtevents, which the ingester already writes to the activity tape. The creator’s own wallets (embedded and imported) are markeddisclosed: an atomic launch’s first-buy legs and the dev buy are bundled by design and on the ledger, so they are never the bundle signal. A wallet the account does not own buying in the same window is. - concentration: Solana, the largest token accounts against supply with the curve vault excluded; Hood, net curve purchases per wallet, labeled as such.
- risk:
tier(LOW / MODERATE / HIGH / CRITICAL),score(0-100), and one entry per factor with its value, points, and reason, so an agent can explain a refusal.
Every measurement carries its own coverage (complete, partial, unavailable) and a note.
unavailable contributes no risk points and must never be read as clean. Funding-lineage
clustering (who funded each buyer) is deliberately absent in v1 and reported as null with the
reason. Rate-limited per IP; cached a minute per mint.
What was NOT checked, in a form you can branch on
Section titled “What was NOT checked, in a form you can branch on”The report also carries a whole-report coverage object, in a closed vocabulary:
{ "covered": false, "reason": "external_launchpad", "launchpad": "pump.fun", "checked": [], "unavailable": ["deployer_history","deploy_window","concentration", "disclosed_wallets","risk_tier"], "note": "This token launched on pump.fun, so Candle has no deploy-time record of it..." }And it rides on the refusal, which is the case that matters. Measured against one real feed
response, forensics could answer for 0 of 81 tokens: pump.fun 37, unlabelled 28, stonkfun 12,
met-dbc 3, letsbonk.fun 1, Candle 0. Every one of those calls came back MARKET_NOT_FOUND and
nothing else, which leaves a caller unable to tell we looked and it is clean from we did not
look. Reading the second as the first is the cheapest wrong move in this system.
So MARKET_NOT_FOUND from this route now carries error.coverage. The code and the status are
unchanged, so nothing that branches on the code breaks. reason distinguishes the two cases that
used to be identical:
reason | what it means | what to do |
|---|---|---|
external_launchpad | A real, tradeable token that Candle did not launch, so there is no deploy-time record. | Treat it as unchecked. Size for that, or skip it. |
unknown_mint | Nothing is indexed at this address on this chain. | Check the mint and the chain. |
On a report that DID run, covered is true only when every check ran, and checked /
unavailable name them individually. An agent gating a buy should require the specific checks it
cares about to appear in checked, rather than reading covered: false as “close enough”.
Errors
Section titled “Errors”Every error response is the same structured envelope every other agent endpoint uses:
{ "success": false, "error": { "code", "message", "field"?, "retryable" } }. VALIDATION_FAILED,
IDEMPOTENCY_CONFLICT, JOB_NOT_FOUND, EXCLUSIVE_NOT_ELIGIBLE, and SCOPE_MISSING mean exactly
what they mean everywhere else in the API (see Errors in the headless-launch doc); this rail’s own
codes, and a few reused codes worth a trade-specific note, are below. /submit mints no error code
of its own: a malformed body or wrong leg count is VALIDATION_FAILED, an unresolvable
clientTradeId is JOB_NOT_FOUND, a broadcast failure is SWAP_FAILED, and its post-broadcast
confirmation reuses /confirm’s own on-chain checks (FEE_LEG_MISSING, SIGNER_MISMATCH)
verbatim — so the table below covers /submit too.
| Code | Status | Meaning | Retryable |
|---|---|---|---|
TEST_ENVIRONMENT_FORBIDDEN | 403 | A test-environment key called either endpoint; this rail has no non-production mode. | no |
MARKET_NOT_FOUND | 404 | mint resolves to no tradable market: a Hood address the market provider has never seen and no poolKey hint (reason: hood_unindexed), a Hood mint the provider errored on (hood_market_unavailable), or a Solana mint Jupiter cannot route (NO_ROUTES_FOUND) or does not recognize (TOKEN_NOT_TRADABLE) on the Pro/Max arbitrary path. Arbitrary Hood is not a 404 by construction; Pro/Max take the DEX race. See Tier gating above. | no |
TIER_REQUIRED | 403 | A Free or Believer key named a Solana or Hood mint with no Candle tokens row; Pro or Max is required to trade an arbitrary mint. See Tier gating above. Limit-order placement has its own Max-only TIER_REQUIRED; that is a different gate, documented in Limit orders. | no |
MARKET_NOT_TRADABLE | 409 | This side cannot execute right now: a curve mid-graduation with its pool not yet seeded, a Solana curve not found on-chain, or a Hood DEX build where no quoter found a routable pool. error.routing.reason says which, and retryable follows it. | per retryable: a graduating curve and an empty race are retryable, a missing Solana pool is not |
QUOTE_PAIR_UNKNOWN | 400 | The market’s quote asset does not resolve to a known pair. | no |
SPEND_LIMIT_EXCEEDED | 400 | A buy’s amountRaw + feeRaw exceeds the payer’s own configured spend limit (main or linked scope). | no, as the same amount; smaller amount or a raised limit |
FEE_LEG_MISSING | 402 | /confirm: the broadcast (and, on Hood, the reported feeTxHash) does not verify a required fee transfer, or (Hood) feeTxHash was omitted despite the build carrying a fee. Also returned when a fee/trade signature was already claimed by a different trade or launch. | no, only a fresh /build recovers |
SIGNER_MISMATCH | 403 | /confirm: the broadcast was not actually signed (Solana) or sent (Hood) by the declared linked wallet. | no |
AGENT_WALLET_MISSING | 400 | Main payer: the account has no embedded wallet for the market’s chain. | no |
SWAP_FAILED | 500 | An unclassified failure executing or recording a leg (broadcast/RPC error, an on-chain revert, a post-broadcast recording failure). Message states whether the underlying transaction may already be on-chain. | sometimes; message states which |
Why a trade was refused
Section titled “Why a trade was refused”Every refusal from /build carries error.retryable, and a routing refusal also carries
error.routing:
error.retryable is a SIBLING of error.routing, not a field inside it. That placement is
contract: read it from error.retryable. Reading it from inside routing finds undefined and
files every refusal as permanent.
| Field | Meaning |
|---|---|
reason | Which refusal: hood_graduating, solana_graduating, no_candidates, hood_unindexed, hood_market_unavailable, solana_pool_missing, market_unknown, quote_pair_unknown, exclusive_window. |
adaptersAttempted | Hood DEX venue: which quoters were asked, in call order (kyber, uniswap). |
kyberAttempt | What the aggregator did: quoted, no_route, unavailable, skipped. |
The distinction that matters on the Hood DEX venue: an aggregator no-route and an aggregator
outage are never the terminal refusal. Both fail over to Candle’s in-house race, so the reason
you receive describes what the RACE found, and kyberAttempt is the only field that says the
aggregator lagged. A no_candidates refusal is retryable because a fresh launch’s deepest pool
is routinely one the race cannot express yet and the aggregator indexes minutes later; a
hood_unindexed one is not, because nothing discovers a pool the market provider has never seen.
hood_market_unavailable is its neighbour and also not retryable: the provider ERRORED on this
mint, twice, while serving others, so the token may well be real and the only source that could
describe it is broken for that address. Treat it as you treat hood_unindexed, and do not keep
asking.
Main-payer signing failures. SPEND_POLICY_DENIED (403, the owner wallet’s own Privy spend
policy rejected the transaction), DELEGATION_REVOKED (403, delegation existed but Privy now
rejects the signing/sending call), and INSUFFICIENT_OWNER_BALANCE (402, the owner wallet cannot
cover the trade plus fee) can all surface from a main payer’s inline execution. On this rail all
three report retryable: false in the envelope — unlike their headless-launch counterparts,
which mark DELEGATION_REVOKED/INSUFFICIENT_OWNER_BALANCE retryable once the underlying cause is
fixed (re-delegating, funding the wallet). The underlying fix is the same either way; only the
envelope’s own hint differs by route, so do not rely on retryable alone here for these three —
check code and act accordingly regardless of what the flag says.
packages/sdk/src/client.ts’s CandleClient.buildTrade() / .confirmTrade() / .submit() wrap
the three calls above exactly; the SDK never signs anything, only assembles and parses the HTTP
requests/responses (same posture as buildSelfLaunch()/confirmSelfLaunch()). All three require
an agent key with swap:write (see Auth above), and throw CandleApiError on every non-2xx
response, same as every other client method.
BuildTradeResult (the buildTrade() return type) is a discriminated union: status: "built"
narrows artifacts by chain (SolanaTradeArtifacts with transactionBase64, or
HoodTradeArtifacts with trade/approval?/permit2Approval?/feeTransfer?); status: "executed" (returned
directly for a main payer, or from confirmTrade() or submit()) carries signature/
feeSignature? instead, chain-agnostic. See the JSDoc on buildTrade()/confirmTrade()/
submit()/trade() in client.ts for the exact signing responsibilities per chain and payer
mode — including trade()’s own per-chain default (Solana submits server-side via submit();
Hood stays on the client-broadcast sequence) — and client.test.ts’s BuildTradeResult chain discriminant (type-level) block for how the discriminant’s compile-time narrowing is pinned.
Limit orders
Section titled “Limit orders”Roadmap item D. A Max-tier agent can place a limit order: a target price, side, and amount on a
Candle-launched Solana or Hood token. Candle watches the price (via the same tokenMarkets data
the rest of the platform already refreshes every 30 seconds) and flips the order to triggered
when the condition is met. There is no push notification: the agent’s own online loop polls
GET /trade/agent/orders?status=triggered to discover a trigger, then completes the trade through
the existing, unmodified POST /trade/agent/build -> sign -> POST /trade/agent/submit sequence
documented above, and finally reports completion through POST /trade/agent/orders/:clientOrderId/fill.
An order only fills while the agent is online to poll for it. There is no new signing model: the agent still signs at completion time through the sign relay, exactly like every other linked-payer trade on this rail. Main-payer orders are not supported; only a linked wallet may place one.
Say this plainly, because it is the limit of what a stop here can promise. Candle evaluates the
condition and flips the status; it never executes. So a stop placed here is a reliable alarm, not
a reliable exit: if the agent that placed it is not running when it fires, nothing sells. The
order.triggered webhook makes the alarm reachable without a polling loop, which narrows the
window, but a consumer still has to act on it. Server-side execution is a separate thing that does
not exist on this rail today, and no wording here should be read as implying it does.
Auth and tier. Same agent key (x-api-key, swap:write scope) as the rest of this rail, but
POST /orders additionally requires Max tier specifically (TIER_REQUIRED for a Free, Believer,
or Pro key), not the Pro-or-Max gate the arbitrary-mint path above uses. GET/DELETE/fill have
no additional tier gate of their own (a non-Max key simply never has any orders to act on, since
placement itself is Max-only).
Scope. Candle-launched tokens only, same tokens.getByMint check the rest of this rail uses. A
non-Candle mint gets MARKET_NOT_FOUND; the arbitrary-Solana-mint trading Pro/Max get on /build
does not extend to limit orders in this version.
POST /api/v1/trade/agent/orders
Section titled “POST /api/v1/trade/agent/orders”Place an order.
{ "clientOrderId": "order-abc123", "linkedWalletId": "linked-wallet-id", "mint": "So1...", "side": "buy", "amountRaw": "1000000", "maxSlippageBps": 100, "targetPrice": "1.50", "expiresAt": 1755000000000}trigger decides the direction, and it is optional: absent means limit, which is what every
order placed before this field existed is.
trigger | side | fires when | what it is |
|---|---|---|---|
limit | buy | price at-or-below targetPrice | buy the dip |
limit | sell | price at-or-above targetPrice | take profit |
stop | sell | price at-or-below targetPrice | stop-loss |
stop | buy | price at-or-above targetPrice | breakout entry |
trailing_stop | either | price retraces trailBps from the best price seen | a stop that follows |
The stop rows are the ones that did not exist before. Under limit semantics the side alone decides the direction, so a sell could only ever fire on the way UP: “sell if this falls to 0.7” was not merely missing, it was unrepresentable, and the only way to honour a stop was to stay online and sell by hand.
A trailing_stop takes trailBps instead of targetPrice, and sending a target with one is an
error. Its stop is derived from a watermark: the highest price seen since placement for a sell,
the lowest for a buy, seeded at the price when the order was placed. The watermark ratchets one way
only and never retreats, so the stop rises with a winning position and holds where it got to. A
trail is between 50 and 9000 basis points; tighter than half a percent is noise rather than
protection, and it would sell on the spread.
A stop on the wrong side of the current price is refused, not accepted. A stop-loss typed above the price is a valid order under every other rule, and it fires on the very next sweep, exiting the position immediately at market. It is nearly always a typo. A LIMIT order on the wrong side is allowed, because that is simply an order to transact now.
expiresAt is a unix-ms timestamp, at most 30 days out. clientOrderId is the idempotency key,
unique per account: the same id with the same body replays the original order; the same id with a
different body gets IDEMPOTENCY_CONFLICT. An account may have at most 25 open orders at once
(ORDER_LIMIT_REACHED past that).
GET /api/v1/trade/agent/orders?status=
Section titled “GET /api/v1/trade/agent/orders?status=”The agent’s own poll target. Lists the caller’s own orders, optionally filtered by
open/triggered/filled/expired/cancelled. An online agent calls this periodically (a
sensible interval is whatever cadence the agent already runs its own loop at) with
?status=triggered to discover orders ready to complete.
DELETE /api/v1/trade/agent/orders/:clientOrderId
Section titled “DELETE /api/v1/trade/agent/orders/:clientOrderId”Cancel. Allowed from any status except filled (ORDER_ALREADY_FILLED if already filled).
POST /api/v1/trade/agent/orders/:clientOrderId/fill
Section titled “POST /api/v1/trade/agent/orders/:clientOrderId/fill”Called by the agent once it has completed a triggered order’s trade through /build -> sign ->
/submit. Body: { "clientTradeId": "..." }, the SAME clientTradeId used on that /build call.
This is verified server-side against the durable trade ledger before the order is marked filled;
the agent’s call alone is never trusted. ORDER_NOT_TRIGGERED if the order is not currently
triggered (still open, or already acted on). TRADE_NOT_CONFIRMED if clientTradeId does not
resolve to a confirmed trade on this same account.
Error codes (limit orders)
Section titled “Error codes (limit orders)”| Code | Status | Meaning | Retryable |
|---|---|---|---|
TIER_REQUIRED | 403 | POST /orders from a non-Max key. | no |
MARKET_NOT_FOUND | 404 | mint is not a Candle-launched token. | no |
ORDER_LIMIT_REACHED | 400 | The account already has 25 open orders. | no, until one is filled/cancelled/expires |
ORDER_NOT_FOUND | 404 | clientOrderId does not resolve to an order on this account. | no |
ORDER_ALREADY_FILLED | 409 | DELETE on an already-filled order. | no |
ORDER_NOT_TRIGGERED | 409 | /fill called on an order that is not currently triggered. | no |
TRADE_NOT_CONFIRMED | 409 | /fill’s clientTradeId does not resolve to a confirmed trade on this account. | yes, once the referenced trade actually confirms |
TRADE_ALREADY_CLAIMED | 409 | /fill’s clientTradeId has already been used to fill a different order. | no, a fresh trade and clientTradeId are required |
Convex/preview note
Section titled “Convex/preview note”This feature added a new Convex table (limitOrders) and new crons/mutations/queries. Per this
project’s standing constraint, a Vercel preview deployment runs against the staging Convex
backend and does not apply a branch’s own schema changes, so the preview URL will not reflect this
feature until it merges. Verify against local dev Convex (bunx convex dev) instead.