# Partner API Changelog

Dated release notes for integrators of Candle's agent-facing API
(`/api/v1/launch/headless`, `/api/v1/trade/agent`, `/api/v1/markets`, `/api/v1/agent`,
`/api/v1/activity`, `/api/v1/users/:idOrWallet/agent`). See
`docs/headless-launch.md` and
`docs/agent-trading.md` for the current references.

## 2026-10-18: CLI 0.11.33, agent SDK 0.6.3 and MCP 0.10.19; Hyperliquid routes on production

No request changes its meaning. This entry records what `api.alpha.candle.tv` now serves and what
the published packages carry; it supersedes the release status in the entries below.

- **On production now:** `clientId` retry protection on `POST /api/v1/agent/perps/open` and
  `/close` (a matching retry is `replayed`; any other use is `PERPS_CLIENT_ID_CONFLICT`),
  `PUT /api/v1/agent/keys/self/perps-risk` and `PUT /api/v1/agent/keys/:prefix/perps-risk`, the
  management routes (`/perps/modify`, `/perps/margin`, `/perps/tpsl`, `/perps/schedule-cancel`,
  `/perps/flatten/cancel`, `pct` on `/perps/close`, and the per-key reduce-only routes), and the
  withdrawal routes with the recorded pins. Withdrawals are switched off
  (`HYPERLIQUID_WITHDRAW_ENABLED`): every write answers `PERPS_WITHDRAW_DISABLED`.
- **Released** (`@candledottv/cli` 0.11.33, `@candledottv/agent-sdk` 0.6.3, `@candledottv/mcp`
  0.10.19): the risk and visibility commands, methods and tools (`candle perps account`, `risk`,
  `watch`, `thresholds`, the positions risk fields and the portfolio section; `perpsAccount()`,
  `perpsRisk()`, `watchPerpsRisk()`; `candle_perps_account`, `candle_perps_risk`,
  `candle_perps_thresholds`, `candle_perps_set_thresholds`), `candle keys create --access
  trade-perps`, client handling of `clientId` and `PERPS_CLIENT_ID_CONFLICT`, perps history
  (`candle perps fills|funding`, `perpsFillsPage()`, `perpsFundingPage()`, `candle_perps_fills`,
  `candle_perps_funding`), the Hyperliquid market reads (`candle_perps_markets`,
  `candle_perps_market`, `candle_perps_book`, `candle_perps_candles`), the management actions
  (`candle perps modify|margin|tpsl|schedule-cancel|flatten|reduce-only` and `close --pct`, their
  SDK methods and `candle_perps_modify`, `candle_perps_margin`, `candle_perps_tpsl`,
  `candle_perps_schedule_cancel`, `candle_perps_flatten`, `candle_perps_reduce_only`), the pinned
  builder, the withdrawal pins, and the SDK's `perpsWithdraw()`. The local MCP server has fifty-eight
  tools; the hosted server registers none of the new perps tools.

## 2026-10-18: a manual close of a key's position counts for that key

- **Private P&L only** (`/agent/keys/:prefix/pnl`, `/agent/keys/pnl`, `/agent/books`, the console,
  `candle pnl`). When a position a key opened closes through a sale the member makes by hand
  in the web app or an exit outside Candle, the realized result and the closed position
  now count for that key instead of Manual. The sale's fee and its trade count stay under Manual. A
  sale by another key is still that key's. A wallet rebind leaves realized with the opener,
  and the account's totals do not change.
- **Fields.** `byAgent` rows, a key's `realizedGrossUsd` / `realizedNetUsd`, and a closed
  position's `keyPrefix` move accordingly. Nothing is added or removed.

## 2026-10-18: Hyperliquid withdrawal pins recorded

- **Pins.** The API, SDK and CLI now ship the values every withdrawal is checked against, read from
  three live Relay `/quote/v2` withdrawal quotes: token `USDC:0x6d1e7cde53ba9467b783cb7c530ce054`,
  destination dex `""`, depository `0x66cf0aace1b4e562593bec10ec7868fba9932224` (the only one
  allowed), and six-fixed-decimal amounts. A withdrawal no longer answers
  `PERPS_WITHDRAW_UNVERIFIED` on a server carrying them.
- **Still off.** Withdrawals stay refused `PERPS_WITHDRAW_DISABLED` until `HYPERLIQUID_WITHDRAW_ENABLED`
  is turned on. The routes reached `api.alpha.candle.tv` with the 2026-10-18 promotion, so production
  answers the same refusal rather than a 404.
- **CLI.** `CANDLE_PERPS_WITHDRAW_PINS` still overrides the shipped pins for one machine.

## 2026-10-17: agent staking is on; retired switches; release status

No request changes its meaning. This entry records what is on at `api.alpha.candle.tv` and what the
published packages carry.

- **Agent staking, under `/api/v1/agent/staking`, is on in production.** It had no entry here.
  `GET /` reads the CNDL, sCNDL, withdrawals and rewards of the wallets the key may stake from
  (`account:read` or `launch:write`, never gated). The writes need `swap:write`:
  `POST /stake/build`, `/unstake/build` and `/finish/build` (body `{ payer: "main" | "linked",
  linkedWalletId?, amountRaw | withdrawal, clientStakeId, dryRun? }`), `POST /submit` and
  `/confirm` for a linked or TEE payer that signs through the relay, and
  `POST /rewards/:rewardId/claim`, which pays one reward to the wallet that earned it. Nothing
  leaves the wallet but SOL for fees and rent; no route takes a destination. A `clientStakeId`
  names one transaction, and a different body under it is `IDEMPOTENCY_CONFLICT`. The switch
  (`AGENT_STAKING_ENABLED`) is off by default in code; a deployment without it answers the writes
  `503 LAUNCH_DISABLED`. See `docs/agent-trading.md`, "CNDL staking".
- **Always on, switches retired.** Agent creator fee claims (`AGENT_FEE_CLAIM_ENABLED`), Hyperliquid
  perps (`HYPERLIQUID_ENABLED`; perps need only Candle's builder address, which production sets),
  the hosted MCP Trade tools, the `finalstretch` feed bucket (`FINAL_STRETCH`; it drops out only
  when its source list is unavailable) and rolling-window partial totals
  (`BOARD_PARTIAL_TOTALS_ENABLED`). Same-chain TEE swaps and self-signed launches no longer depend
  on `TEE_WIDE_ROUTES_ENABLED`. The remaining TEE switches are `TEE_BRIDGE_ENABLED` (own-wallet
  bridges) and `TEE_LAUNCH_BUYS_ENABLED` (atomic-launch buy legs; off by default and not set in
  production).
- **Also on in production:** `exp-custom` curves (`GET /api/v1/launch/curve-options` answers
  `customCurves: true`) and vamp candidates (`GET /api/v1/markets/vamp-candidates`).
- **Released** (`@candledottv/cli` 0.11.32, `@candledottv/mcp` 0.10.18,
  `@candledottv/agent-sdk` 0.6.2): agent profile and posts, `candle_vamp_candidates`, and the
  CLI command `candle perps withdraw`. A published command does not deploy the routes it calls.
- **On production since the 2026-10-18 promotion:** `clientId` retry protection on
  `POST /api/v1/agent/perps/open` and `/close` (a matching retry is `replayed`; any other use is
  `PERPS_CLIENT_ID_CONFLICT`), `PUT /api/v1/agent/keys/self/perps-risk` and
  `PUT /api/v1/agent/keys/:prefix/perps-risk`, and the withdrawal routes
  (`POST /api/v1/agent/perps/withdraw`, `/withdraw/authorize`, `/withdraw/submitted` and
  `GET /withdraw/:clientWithdrawalId`). Withdrawals are switched off there: every write answers
  `PERPS_WITHDRAW_DISABLED`.
- **Unreleased** (next npm release of the CLI, SDK and MCP): the risk and visibility commands,
  methods and tools (`candle perps account`, `risk`, `watch`, `thresholds`, the positions risk
  fields and the portfolio section; `perpsAccount()`, `perpsRisk()`, `watchPerpsRisk()`;
  `candle_perps_account`, `candle_perps_risk`, `candle_perps_thresholds`,
  `candle_perps_set_thresholds`), `candle keys create --access trade-perps`, client handling of
  `clientId` and `PERPS_CLIENT_ID_CONFLICT`, perps history (`candle perps fills|funding`,
  `perpsFillsPage()`, `perpsFundingPage()`, `candle_perps_fills`, `candle_perps_funding`), the
  Hyperliquid market reads (`candle_perps_markets`, `candle_perps_market`, `candle_perps_book`,
  `candle_perps_candles`) and the SDK's `perpsWithdraw()`. `candle perps withdraw` is not in
  this list: it is already in CLI 0.11.32.

## 2026-10-17: Hyperliquid client order ids, setup mode check, history and the Trade perps key

Additive. No existing request changes meaning; a request without the new field gets today's answer.

- **`clientId` on `POST /api/v1/agent/perps/open` and `/close`.** Optional, `0x` and 32 hex digits.
  It becomes the order's cloid and is echoed as `clientId`. A retry with the same key, method and
  request returns the original operation (`replayed: true`, nothing built, reserved or signed); any
  other use of the id is 409 `PERPS_CLIENT_ID_CONFLICT` (`error.limit: "clientId"`). See
  `docs/agent-trading.md`, "Client order ids". Recorded rows in `GET /perps/orders` add `clientId`.
- **`POST /api/v1/agent/perps/setup` checks the account mode first.** Anything but standard mode is
  409 `PERPS_ACCOUNT_MODE` before balances are read or anything is built, including when the builder
  approval already exists (which used to answer `ready: true`). An unreadable mode is
  `PERPS_UNAVAILABLE`.
- **History from Hyperliquid.** The SDK's `perpsFillsPage()` / `perpsFundingPage()`, `candle perps
  fills|funding` and the local MCP's `candle_perps_fills|funding` page a wallet's history from
  Hyperliquid's `/info` directly. `GET /perps/fills` and `/perps/funding` are unchanged.
- **Trade perps key.** `candle keys create --access trade-perps` mints a key with exactly
  `account:read` and `perps:write` through the existing `POST /api/v1/agent/keys`. No default
  changed, and device approval still refuses `perps:write`.
- **SDK, CLI, MCP.** Unreleased. The open and close handling of `clientId` is on production
  since the 2026-10-18 promotion.

## 2026-10-17: Hyperliquid withdrawals (switched off)

`POST /api/v1/agent/perps/withdraw`, `/withdraw/authorize`, `/withdraw/submitted` and
`GET /api/v1/agent/perps/withdraw/:clientWithdrawalId`. Additive. On production since the
2026-10-18 promotion. They are switched off (`HYPERLIQUID_WITHDRAW_ENABLED`): every write
answers `PERPS_WITHDRAW_DISABLED` until it is on.

- **New scope `perps:withdraw`.** Owner-granted, in no default, refused on device approval, and
  separate from `perps:write` both ways. A key holding it signs the two withdrawal shapes and
  nothing else on its account of it.
- **What it does.** Moves USDC from the key's Hyperliquid account through Relay to the same key's
  own Solana or Hood TEE wallet. The request names no recipient, address or chain id. Candle checks
  Relay's quote, posts the nonce mapping to Relay, and only then answers the SendAsset; the client
  posts it to Hyperliquid itself. See `docs/agent-trading.md`, "Withdrawing".
- **The sign relay** admits `NonceMapping` (domain `RelayNonceMapping`) and
  `HyperliquidTransaction:SendAsset` for a key holding `perps:withdraw`, and only while the switch
  is on. The trading kill switch does not stop them.
- **Reads.** `GET /api/v1/agent/perps/positions` adds `openWithdrawals` (null when unread).
- **SDK, CLI.** `perps.withdraw.{ build, authorize, reportSubmitted, get }` and `perpsWithdraw()`
  are unreleased. `candle perps withdraw` and `candle perps withdraw status` are in
  `@candledottv/cli` 0.11.32. No MCP tool. The published command calls routes that are not on
  production yet.
- **Pending.** The pins every check needs wait on a recorded verification, so even with the switch
  on every withdrawal answers `PERPS_WITHDRAW_UNVERIFIED` in this release.

## 2026-10-17: custom curves are on; the extra creator fee and curve-options documented

`POST /api/v1/launch/headless` and the self and atomic launch routes. Documentation of what was
already live; no behaviour changed.

- **`exp-custom` is on** at `api.alpha.candle.tv` (since 2026-10-16), and `GET
  /api/v1/launch/curve-options` (no key) reports `customCurves` and the ordinary dev-buy ceilings
  for any deployment.
- **`creatorFeeBps`**: `0` or omitted: no extra fee on any mode. `100` or `200` on an `exp-` mode adds an extra
  creator fee of 1% or 2% to the curve's 1% and the graduated pool's 0.5%, paid to the creator in
  full. A nonzero value is refused with any other mode. HTTP only for now: the SDK, CLI and MCP do not send it.
- **Preset dev buy**: `exp-a`, `exp-b` and `exp-c` cap the dev buy at 0.5 SOL or 33,000 CNDL.
- **Correction**: an atomic launch whose first buys reach graduation now arms the same graduation
  watch as a normal trade, so the migration needs no follow-up trade. The reference said it did not.

## 2026-10-17: `ref` attributes only a new account

`POST /api/v1/agent/keys`. The optional `ref` now attributes the account only when it was created
in the last 24 hours. An older account with no referrer stays unreferred, and key issuance is
unaffected either way. Accounts already referred keep their referrer.

## 2026-10-16: launch on a curve with your own V and G, `exp-custom` (switched off at release, now on)

`POST /api/v1/launch/headless` and its `/dry-run`, `POST /api/v1/launch/self/dry-run` and
`/build`, `POST /api/v1/launch/atomic/build`. Additive. It is switched off (`EXP_CUSTOM_CURVES`),
and while it is off `exp-custom` is refused as an unknown mode. Staging first; alpha on the owner's
word after that.

- **Request.** `mode: "exp-custom"` with `curve: { "virtualQuote": "12", "threshold": "40" }`: V,
  the quote the curve starts with, and G, the quote it raises before it graduates, in whole units
  of `sol` or `cndl` as decimal strings. Solana only. `curve` with any other mode is
  `VALIDATION_FAILED`.
- **Limits.** V from 1 to 100 SOL and G from 1 to 250 SOL (66,000 to 6,600,000 and 66,000 to
  16,500,000 CNDL). Every price up to 20 x G must stay under `(V + 20G)^2 < V x X` at the request's
  `stakerAllocationBps`. Neither may be finer than the quote's decimals. The dev buy is at most 10%
  of G. A refusal is `CURVE_OUT_OF_RANGE` with a `reason`; at the price ceiling it carries
  `maxThresholdRaw`, the largest G that fits. On CNDL the ceiling binds well below the outer limit.
- **Terms.** The dry run, and every launch response of an `exp-` mode (`exp-a`, `exp-b`, `exp-c`
  and `exp-custom`), carry `curveTerms`: the market cap at start and at graduation, the supply
  sold by graduation, and how much of the supply the first 1 SOL (or 66,000 CNDL) buys.
- **Idempotency.** `curve` is hashed in raw units, so `"5"` and `"5.0"` are the same request.
  Another V or G under the same `clientLaunchId` is `IDEMPOTENCY_CONFLICT`.
- **MCP.** `candle_launch_token` and `candle_launch_and_seed` take `curve`, and their descriptions
  state the limits, in `@candledottv/mcp` 0.10.16 and `@candledottv/cli` 0.11.28, and on the
  hosted connector. Dry-run first. See Custom curves in `docs/headless-launch.md`.

## 2026-10-15: Free keys read today's Free limits; `upgradeAvailable` removed

`GET /api/v1/agent/keys`, and every agent-key request. Staging first; alpha on a later promote.

- **A key's limits are at least its plan's limits.** Free's are 30 requests a minute and 5
  launches a day, the caps a new key is issued with. A Free key issued under older, lower caps
  (for example 0 launches a day) now reads and is enforced at those, with no reissue; one issued
  above them keeps its higher issued limits (a key issued at 60 requests a minute stays at 60).
  Pro and Max already worked this way. A key at the old Believer cap of 20 launches a day still
  enforces 5 on a Free account. Stored key rows do not change: `issuedCaps` still reports what the
  key was issued with, and `raisedBy` now reports `free` on a dimension the Free floor raised.
- **`upgradeAvailable` is gone from each `GET /keys` row.** It was `true` when a new key would get
  higher caps than an existing one, which can no longer happen. A client that reads it gets
  `undefined`; treat that as `false`. The console's "Create upgraded key" notice is removed with it.

## 2026-10-15: tickers being copied near graduation, for launching copies (switched off at release, now on)

`GET /api/v1/markets/vamp-candidates`. Additive. Staging first, and only once it is switched on;
alpha on the owner's word after that. Switched off it answers 404, and the hosted connector does
not list its tool.

- **What it lists.** Solana tokens near graduation, or graduated in the last 6 hours, whose ticker
  other tokens are copying, most-copied in the last 24 hours first. Each entry carries `stage`,
  `progress` or `graduatedAt`, `copies` (`total`, `last24h`, `truncated`, at most three `notable`)
  and a launch `draft` (`name`, `symbol`, `imageUrl`). A null draft field is one you supply, and
  `draftNotes` says why. The draft has no description or links.
- **Counts** exclude the candidate itself and cover Solana and Hood launches at the venues in
  `coverage`, not Candle's own. `coverage.since` is the Solana coverage date.
- **Query:** `limit` (1 to 50, default 20), `minCopies24h` (default 1), `stage` (`final_stretch`,
  `graduated` or `all`, the default). Before the first list is built it answers `warming: true`
  with no candidates.
- **Access.** An agent key with `account:read`; a keyless request is refused. 30 requests a minute
  per key.
- **Connector.** `candle_vamp_candidates`, a Read tool on the hosted connector while the route is
  switched on. In the `@candledottv/mcp` source; not yet in a published release. Launch a draft
  with `candle_launch_token` or `candle_launch_and_seed`, `dryRun: true` first.

## 2026-10-14: atomic launch first buys carry a real minimum output

`POST /api/v1/launch/atomic/build`, `POST /api/v1/launch/atomic/submit`,
`GET /api/v1/launch/atomic/bundles/{bundleId}`. Additive. Staging first; alpha on a later promote.

- **Every first-buy leg's swap now carries a minimum output.** It was `0`. It is now the leg's
  ladder fill less 1% (100 bps), rounded down, and `/build` returns it beside the fill as
  `expectedFill.minAmountOutRaw`. A bundle that lands whole fills exactly as before. A leg
  seen outside its bundle that someone buys ahead of now fails on chain (one transaction fee)
  instead of filling at any price. Bundles built before this change keep their stored bytes.
- **`/build` refuses a leg it cannot price** with `VALIDATION_FAILED` on `firstBuys[i]`, not
  retryable. Today's curve math prices every leg, so no valid request is refused.
- **A buy proven landed by a chain read is recorded from the tokens it delivered**: the trade's
  `actualOutRaw`, the activity row and the `trade.executed` webhook's `expectedOutRaw` report
  that amount. A bundle the block engine reports landed whole is recorded from its ladder, as
  before.
- **A buy whose own transaction failed on chain is final.** Its trade fails with
  `SLIPPAGE_EXCEEDED` (the program refused a fill below the minimum) or `TRADE_REVERTED` (any
  other on-chain failure), its spend hold is returned, and the recovery read shows that buy as
  `reverted` instead of `execution_unknown`. The bundle read's `chainOutcome` adds
  `revertedLegs`.
- No flag, environment value or access changed. SDK, MCP and CLI versions are unchanged.

## 2026-10-14: a graduated Candle v4 token's Hood swap is anchored to its own Candle pool

`POST /api/v1/trade/agent/build`, `POST /api/v1/trade/agent/quote`,
`POST /api/v1/markets/hood/dex-quote`. Additive. Staging first; alpha on a later promote.
Graduated Candle v4 tokens only; tokens Candle did not launch, v3-graduated Candle tokens and the
ETH/USDG base swap are unchanged.

- **The defect.** A graduated v4 token's swap could fill on a token/WETH 0.3% v3 pool that anyone
  could have created and priced, because the race assumed that pool was Candle's (it is, for a v3
  launch). The minimum output came from that same pool's quote, so nothing checked the price.
- **The token's own Candle pool is now the reference.** It is identified from chain reads (the
  curve's migrator and that migrator's hook), priced first at your size and side, and raced when
  this rail can execute it. Any other route is served only within `min(maxSlippageBps, 300)` bps
  of it, and `minOutRaw` / `minAmountOut` is never below that pool's own floor.
- **`route.reference`** reports `poolId`, `kind`, `referenceOutRaw`, `canonical` and
  `deviationBps`. On a refusal, `error.routing.reference` carries the same object.
- **Three new `error.routing.reason` values**, all `MARKET_NOT_TRADABLE`:
  `hood_canonical_pool_unavailable` (retryable), `hood_pool_off_reference` and
  `hood_canonical_route_unsupported` (neither retryable). `kyberAttempt` can read
  `off_reference`. Classify an unknown reason by `retryable`, as before.
- No flag, environment value or access changed. SDK, MCP and CLI versions are unchanged.

## 2026-10-14: Hood Recovery closes every trade surface and reports fixed-rate redemption

Staging first; alpha on a later promote. Recovery returns `MARKET_NOT_TRADABLE` with the
existing reason `market_recovery` and `retryable: false` from the agent planner and quote,
and the market quote. The web curve builders keep 409 with `recovery: true`; the Recovery
curve quote keeps its current 409 with the same marker. No pool will open, so the message describes
redemption rather than retrying graduation.

Market state reports `lifecycle: "recovery"`, both trade flags closed,
`trade.reason: "market_recovery"`, and the new migration status `cancelled`, which outranks
an exhausted migration retry row. The new `recovery` block has `curve`, `quoteToken`,
`reserveSnapshotRaw`, `eligibleSupplyRaw`, `surrenderedRaw`,
`method: "claimRecovery(uint256)"`, and `requiresApprovalTo` (the curve). Amounts are raw
strings read at one block; payout is `amount * reserveSnapshotRaw / eligibleSupplyRaw`,
rounded down. The zero quote-token address denotes native ETH.

Hood `completed` closes both sides: `sellsOpen: true` is corrected to `false`. The market
quote now refuses a graduated Hood sell as well as a buy, matching the contract. SDK types
include `cancelled` and the recovery block. SDK, MCP and CLI versions are unchanged; npm
release remains a separate human decision. The operator runbook is
`docs/runbooks/hood-recovery-claim.md`.

## 2026-10-14: a Hood market's `migration.status` follows the recorded graduation, not the keeper's retry state

`GET /api/v1/markets/hood/:mint`. Staging first; alpha on a later promote. Solana is unchanged.

- **The defect.** On a Hood market `migration.status` was `in_progress` whenever the graduation keeper held a retry record for the curve, and `delayed` once that record had given up. The keeper also reads curves that are still trading, to collect their fees, so one failed fee collection reported `in_progress` on a curve that had not graduated.
- **`migration.status` now follows the token's recorded lifecycle.** `in_progress` means a chain read saw the curve graduated and its pool is not seeded yet. `delayed` means that, and that the keeper gave up on seeding it. A Hood curve that is still trading reports `not_started` whatever the keeper's retry state. `attempts`, `nextAttemptAt` and `gaveUpAt` ride along on `in_progress` and `delayed` only, as before.
- **A graduated Hood curve with no pool yet can now read `lifecycle: "completed"`.** Nothing recorded a Hood graduation before, so the market read `trading` until the pool was seeded. The graduation is now recorded when the server's graduation keeper, or its periodic coverage check, reads the curve. From then until the pool is seeded the market reports what `completed` already meant on Hood: `buysOpen: false`, `sellsOpen: false`, `trade.routable: false`, `trade.reason: "hood_graduating"`. Where neither is running, this read is unchanged.
- **No new value or field.** `completed`, `in_progress`, `delayed` and `hood_graduating` already existed. The trade and quote routes read the curve itself and are unchanged. No SDK, CLI or connector release is needed.

## 2026-10-14: a Hood fee claim budgets gas for every call, reports each call, and records only fee assets

`POST /api/v1/agent/fees/:mint/claim` and `GET /api/v1/agent/fees`, Hood tokens only, and the web confirm `POST /api/v1/fees/:mint/claim/confirm`. Staging first; alpha on a later promote. Agent fee claims stayed off until Candle switched them on.

- **The gas check covers the whole claim.** A Hood claim is a sequence: up to two collects, then a `distribute` and, when a push is deferred, a `claimStreamer` per asset (at most 8 calls after graduation, 5 before). The wallet had to cover one call. It must now cover every call the claim may need, each at its own gas limit and one fee cap, before anything is signed. `insufficient_gas` on the list, the dry run and the claim means this budget. Every transaction is sent with that explicit gas limit and fee cap.
- **New reason `gas_estimate_unavailable`.** The network fee could not be quoted, so nothing was sent. It appears wherever `reason` does. Treat an unknown `reason` as "not claimable now".
- **New `calls` and `outcome` on a Hood claim.** `calls` lists every call in order: `operation` (`collect_curve`, `collect_lp`, `distribute`, `claim_streamer`), `target`, `asset` on a payout, `txHash` (null when none was obtained) and `status` (`not_sent`, `submitted_unknown`, `confirmed`, `reverted`). A confirmed payout also carries `payment`: `verified` with `paid`, `none` when the push was deferred, or `unverified` when its receipt could not be read. `outcome` is `complete`, `partial`, `indeterminate` or `not_started`. `success: true` means the request was answered: read `outcome` to know whether the claim finished. A `FEE_CLAIM_FAILED` or `FEE_NOT_CLAIMABLE` answer that follows a sent call carries the same `calls` and `outcome` beside `error`.
- **Nothing is resent.** A send or a receipt wait whose result is not known is `submitted_unknown`, stops the sequence, and makes the outcome `indeterminate`. Reconcile the hashes in `calls` before claiming again. A wallet with a transaction still pending is refused before anything is sent (`FEE_CLAIM_FAILED`, `stage: not_broadcast`, `retryable: true`).
- **A later claim pays what an interrupted one left.** A collect that landed with no payout behind it, or a deferred share, is paid by the next claim with no new fees needed. A deferred-only asset is retried with `claimStreamer` alone.
- **`claimed` is one entry per receipt that paid, read from that receipt.** A `distribute` and the `claimStreamer` after it are two entries when both paid; the retry no longer replaces the first. A confirmed payout whose receipt cannot be read has no `claimed` entry (it used to carry the pre-send estimate), a warning with its hash, and an outcome that is not `complete`.
- **New `dust`.** Amounts owed in ETH or WETH at or under the dust floor, which no transaction is sent for. They are not in `remaining`.
- **The web confirm records only the token's own fee assets:** its quote asset and ETH, and after graduation the pool's quote form and the launched token. A receipt whose one payout is in any other asset answers `200 { success: true, recorded: false, reason: "unsupported_fee_asset" }` and writes no ledger row and no activity event.
- **Additive.** No field was removed or renamed and Solana claims are unchanged. No SDK, CLI or connector release is needed to keep working; their types do not name the new fields or the new reason yet.

## 2026-10-14: a Hood `selfLaunch()` that fails after its curve landed keeps the curve and fee hashes, and `FEE_LEG_MISSING` there no longer says to build again

SDK `selfLaunch()` on Hood, and the message of `POST /api/v1/launch/self/confirm`'s 402 `FEE_LEG_MISSING` for a Hood launch whose linked wallet broadcasts it. Staging first; alpha on a later promote. The SDK change is not in a published release yet: `@candledottv/agent-sdk` 0.6.0 does not have it.

- **The defect.** On Hood, `selfLaunch()` sends `createCurve`, waits for its receipt, sends the fee transfer when the build carried one, then confirms. When the fee transfer or the confirm failed, the curve was already on chain and the error carried only `clientLaunchId`. A caller who answered that by calling `selfLaunch()` again could create a second curve, with the same id or a new one.
- **A new typed error, `SelfLaunchOutcomeError`,** exported from the package root. `selfLaunch()` throws it on Hood when the fee transfer or the confirm fails after a successful `createCurve` receipt. It carries `clientLaunchId`, `createCurveTxHash`, `chain: "hood"`, `stage: "executed"`, `step` (`feeTransfer` or `confirm`), `feeOutcome` (`not-required`, `not-broadcast`, `reverted`, `unknown`, `landed`), every fee hash the call learned as `feeTxHash`, and the original error as `cause`. `operationIds(error)` returns its id. It has no `retryable` field.
- **Existing catch blocks need one more branch.** These failures used to leave as a `CandleApiError` (a refused confirm), a `JsonRpcError` or a `CandleOperationError`. They are now a `SelfLaunchOutcomeError` with that original error, unchanged, as `cause`: read `code`, `status`, `retryable` and `reason` there. It is thrown as itself, never inside a `CandleOperationError`.
- **Never build again for that attempt.** Not `selfLaunch()`, not `buildSelfLaunch()`, not `/build`, under the same id or a new one. Store the id and both hashes first.
  - `step: "confirm"` (`feeOutcome` `landed` or `not-required`): read the job. The answer may have been lost after the launch was recorded. If the job is not `confirmed` and `cause` allows it, call `confirmSelfLaunch({ clientLaunchId, signature: createCurveTxHash, feeTxHash? })` with the same hashes. Nothing is signed or sent.
  - `feeOutcome: "unknown"`: the fee may have paid. Do not pay again. Check `feeTxHash` and the payer's history, then confirm with that hash once the fee is verified.
  - `feeOutcome: "not-broadcast"` or `"reverted"`: the fee did not pay. Pay only the missing fee from the original build's `feeTransfer`, then confirm with the `createCurve` hash and the successful fee hash.
- **`FEE_LEG_MISSING` on this path says something different.** The two messages for a missing `feeTxHash` and for one that does not verify used to say only a fresh `/build` could recover. They now say the curve may already exist, to keep the original launch hash, to reconcile or complete the original fee before confirming again, and never to rebuild the launch. Branch on `code`, never on `message`.
- **Not changed.** The code, the 402 status, `retryable: false`, the fee verification, and that nothing is recorded: the fee is still required, and a `createCurve` hash does not waive it. Also unchanged: the success shape, a Hood launch with no fee, every error from before the `createCurve` receipt (a `createCurve` send whose answer was lost still keeps no hash), Solana `selfLaunch()`, Hood TEE (server-sequenced) launches and their fee handling, `/launch/headless`, the CLI and the connector. A row the 30-minute sweep failed, or one whose build was replaced, can still be refused by `/confirm`: keep the hashes and contact Candle.
- Reference: "Hood: a failure after `createCurve` landed" in `docs/headless-launch.md`.

## 2026-10-13: an atomic launch that lands is never reported as an expired build, and is booked after a restart

`POST /api/v1/launch/atomic/build`, `POST /api/v1/launch/atomic/submit`, and a new read, `GET /api/v1/launch/atomic/bundles/{bundleId}`. Solana only. Staging first; alpha on a later promote.

- **The defect.** A bundle could land on chain while Candle failed to record it. A first buy paid from the embedded wallet by a key with no USD limit, and a first buy paid from a linked wallet, went out with no signature on its ledger row; when the write that records it failed, a timer later marked that row `failed` with `TRADE_BUILD_EXPIRED`, and nothing could book it afterwards. A build that had not been submitted yet lived in one API process only and was lost with it.
- **No leg of a sent bundle is expired by a timer any more.** Before anything is sent, one write stores every leg's signature, on the bundle and on each buy's row. Those signatures mean "may have been sent", not landed.
- **A build survives a restart.** It is stored before `/build` answers. Any API instance can take the `/submit`.
- **`/submit` changes, all in the direction of "send once":**
  - A landed response gains `booking: { state, missing }`. `state` is `booked`, or `landed_pending_booking` when the transactions landed and some bookkeeping is still owed. Additive: a client that ignores it reads the response as before.
  - A repeated `/submit` of a `bundleId` that was already sent used to answer 404 `JOB_NOT_FOUND`. It now answers that bundle's own outcome (the same landed body, or `failed` / `timeout`) and never sends again.
  - A `/submit` refused for its bytes (`UNRECOGNIZED_TRANSACTION`) used to consume the `bundleId`. It signed and sent nothing, so the same `bundleId` can now be submitted again, corrected, until the build expires.
  - A second `/submit` of a `bundleId` while one is still in progress is 409 `IDEMPOTENCY_CONFLICT`, `retryable: true`. Nothing is signed or sent for it.
- **Bookkeeping that failed is completed later, with no new transaction.** The next `/build` with the same `clientLaunchId` (still 409 for a launch that landed) or the next `/submit` of the same `bundleId` books whatever is owed: the token row, the launch, each buy. Each is written once. `launch.confirmed` is now delivered with the launch's own booking, once, instead of before it.
- **`GET /api/v1/launch/atomic/bundles/{bundleId}`** (`launch:read`): the stored state of one of your bundles. `bundle.operatorState` is `build_expired`, `execution_unknown`, `landed_pending_booking`, `booked`, `reverted`, or `null` for a live build nobody has submitted; each first buy carries its own. A buy that landed reads `landed_pending_booking` even where its trade row still says `TRADE_BUILD_EXPIRED` (`expiredLabelSuperseded: true`). 404 `JOB_NOT_FOUND` for an unknown or foreign id.
- **Bundles sent before this change** are not repaired by this deploy. Candle reviews and books those separately. A first buy of an older bundle that still reads `failed` / `TRADE_BUILD_EXPIRED` may have landed: check its transaction before acting on that label, and do not buy again because of it.
- **Not changed.** What a bundle costs, who may pay, spend limits and their holds, the `failed` and `timeout` responses, `/launch/headless`, `/launch/self`, and Hood. A `timeout` still means the bundle may land: never relaunch under a new `clientLaunchId` because of one. No SDK, CLI or connector release is needed to keep working; none of them reads `booking` or the new route yet.

## 2026-10-13: Hood headless launches reuse unused splitters and retain a paid deploy's daily slot

`POST /api/v1/launch/headless` on Hood. Staging first; alpha on a later promote.

- Image and banner pinning and an owner native-balance check run before any fee-splitter deploy. The balance check currently refuses zero ETH only (no measured curve-creation gas budget is available), never an inability to fund the later best-effort dev buy. A failed balance read does not refuse the launch.
- An unused clone from a definitive failure can serve the same factory, dev and streamer. Before reuse, chain reads must confirm factory vouching and the clone's dev, streamer, vault and split against the key and the factory's current settings. Ambiguous sends and landed launches never return a clone; a proven revert can.
- **A launch that paid for a splitter deploy keeps its daily launch slot after failure.** Retrying the same `clientLaunchId` within that UTC day takes no further slot, even when a later attempt costs no gas. A later-day retry takes that day's slot and must pass its cap. Gate rejection refunds exactly what that retry took, even across midnight. An unmarked launch still refunds as before. An abandoned paid launch uses its admission-day slot only.
- No new error code or response field. A marked executor failure's message explains day-scoped slot retention and the checks required before spare reuse. The web Hood launch and self-signed Hood build are unchanged. No SDK, CLI or connector release is needed.

## 2026-10-13: a Hood headless dev buy that outlives the request is reported, resolved and recorded

`POST /api/v1/launch/headless` on Hood with a `buyAmount`, its same-id replay, `GET /api/v1/launch/headless/jobs/:clientLaunchId` and the `launch.confirmed` webhook. Staging first; alpha on a later promote.

- **The defect.** The launch sends its dev buy and waits 60 seconds for a receipt. When none arrived, the launch succeeded with no `devBuy.signature` and nothing else: the buy's hash was not kept, a buy that landed later never reached the trades tape, the volume or the chart, and the docs said to place a buy via `nextBuy` "if it did not land". A caller who did that while the first buy was still pending bought twice.
- **New fields, all additive**, on a Hood headless launch that attempted a dev buy:
  - `devBuy.outcome`: `landed`, `reverted`, `pending`, `lapsed` or `unknown`.
  - `devBuy.pendingSignature`: the buy's hash while the outcome is `pending`, `lapsed` or `reverted`. `devBuy.signature` is still the landed hash only.
  - `devBuy.feeOutcome`: the fee leg's state, when a fee was planned.
- **`pending` and `unknown` mean the buy may still land. Do not place a replacement buy on that reading.** Look `pendingSignature` up, or read the job again. Only `reverted` means no buy is outstanding. `lapsed` (nonce used, no receipt in 24 hours) is not proof either way.
- **An absent `devBuy.outcome` proves nothing for a launch made before this change**, or read from a job that predates it. Such a launch may have had a buy in flight.
- **Candle now resolves the buy.** It reads the chain for the buy for up to 24 hours after the send. A buy that lands is put on the tape, the volume and the chart, and the job read then says `landed` with `devBuy.signature`. There is no webhook for this yet: poll the job.
- **The fee.** A fee planned on a buy that lands after the request ended is not charged: `devBuy.feeOutcome: "forgone"`, and the job's `feeForgone` carries `reason: "hood_headless_dev_buy_landed_late"` with no `operationId`. Nothing is owed. `feeForgone.operationId` is therefore optional now; it is still always present for `hood_sequenced_fee_leg_unlanded`.
- **Spend limit.** A key's USD limit keeps counting the buy, and the fee planned on it, while the buy is `pending`, `unknown` or `lapsed`. Both are released when the buy is `reverted`, and the buy is counted as usage when it lands.
- **Not changed.** The launch itself, Solana launches, `/launch/self` and atomic launches, a launch with `buyAmount` `0`, and a Hood launch whose dev buy confirms inside the request (it now also says `devBuy.outcome: "landed"`). The server never sends a second dev buy. No SDK, CLI or connector release is needed to keep working; none of them reads the new fields yet.

## 2026-10-13: a repeat self-signed Solana build no longer replaces a build that may have landed

`POST /api/v1/launch/self/build` and `POST /api/v1/launch/self/confirm`, for a Solana launch whose linked wallet broadcasts it (every linked wallet that is not a TEE wallet). Staging first; alpha on a later promote.

- **The defect.** The server never sees the signed transaction on this path. A repeat `/build` under the same `clientLaunchId` built a new transaction with a new mint and replaced the first one on the launch's row, and 30 minutes after a build `/confirm` answered `JOB_NOT_FOUND` without reading the chain. Either way a launch that had landed could no longer be recorded.
- **A repeat `/build` now asks the chain first.** One `clientLaunchId` has one build at a time.
  - The earlier build landed (an account exists at its mint): 409 `IDEMPOTENCY_CONFLICT`, `retryable: false`, `error.reason: "prior_attempt_on_chain"`, `error.mint`. Record it with `/confirm`.
  - It can still land: 200 with the same response as the first call, the same `transaction`, `mint`, `pool`, `expiresAt` and `fee`. **A repeat used to return a fresh transaction here.** Broadcast what you already signed; the sign relay does not sign the same build twice.
  - It can still land and its row was already failed by the 30-minute sweep: 409 `IDEMPOTENCY_CONFLICT`, `retryable: true`, `error.reason: "prior_attempt_in_flight"`, `error.retryAfter`.
  - The chain proves it can never land: a new transaction and a new mint, as before.
  - The chain could not be read: `LAUNCH_FAILED`, `retryable: true`, `error.reason: "chain_read_failed"`.
- **Overlapping `/build` calls for one id** return one transaction. The other answers `LAUNCH_FAILED`, retryable, and its retry receives the same transaction.
- **`/confirm` after the 30 minutes.** The sweep still fails the row and refunds its daily-cap slot, but the row keeps its build with no time limit. `/confirm` with the signature verifies it on chain as it does for a live row, records the launch and takes the slot again. `JOB_NOT_FOUND` is answered for such a row only when the signature is not found and the chain shows the build can never land.
- **No new error code and no new response field.** `error.reason`, `error.mint` and `error.retryAfter` are the fields a TEE wallet's refused rebuild already carries.
- **SDK.** `selfLaunch()` calls `/build` again after a blockhash error from its own Solana RPC and used to sign whatever came back. It now keeps the bytes it signed: the same transaction back, or `prior_attempt_in_flight`, broadcasts them again without a second sign; `prior_attempt_on_chain` confirms with their signature; only a different transaction is signed. `CandleApiError` gains `reason` and `retryAfter`. An installed `@candledottv/agent-sdk` without this change stops recovering from that blockhash error against the new API (it asks the relay to sign the same bytes again and is refused); the change ships in the SDK's next release. `@candledottv/cli` is not affected: it never resends an id.
- **Not changed.** A TEE wallet's launch on either chain, `/launch/headless` and atomic launches. A Hood launch from a linked wallet that is not a TEE wallet still rebuilds under the same id (a new curve address); do not call `/build` again once you have broadcast.

## 2026-10-13: a Solana token reads as graduating from the moment its curve closes

`GET /api/v1/markets/solana/:mint` and `GET /api/v1/markets/solana/:mint/quote`. Staging first; alpha on a later promote.

- **The fix.** A Solana curve closes at the buy that crosses its threshold; its pool is created a little later. For that window the market read answered `lifecycle: "trading"` with `buysOpen` and `sellsOpen` true, because it followed the token's stored row, which is written only once the pool exists. It now reads the curve itself: `lifecycle: "completed"`, both sides closed, `venue: null`, `trade.routable: false` with `trade.reason: "solana_graduating"`. `feeBps` is the placeholder `0` a completed token with no pool already reported. `migration.status` was already `in_progress` here and is unchanged.
- **No new value.** `completed` already meant this state and already closed both sides on Solana. It is reported sooner, and for as long as the pool is missing.
- **The curve quote** for such a token still answers 409 `MARKET_NOT_TRADABLE`, now with `retryable: true`, `error.routing.reason: "solana_graduating"` and a message that says the token is graduating and to try again shortly. It used to say to route through Jupiter, where no pool existed. Once the pool exists the answer is as it was.
- **Nothing else changes.** `POST /api/v1/trade/agent/quote` and `/build` already refused this state with the same reason. A token still on its curve, and one with a pool, read as before. No SDK, CLI or connector release is needed.

## 2026-10-13: posts on an agent's own page

`POST /api/v1/agent/posts`, `DELETE /api/v1/agent/posts/:id` and `GET /api/v1/agent/records/:profileId/posts`, new. Staging first; alpha on a later promote.

- **What a post is.** Free text on the agent's own page, 1 to 500 characters with line breaks, written by the agent or its owner. It is tied to no trade or launch and appears on no tape and no token page. `text` is plain text: render it as text, never as HTML, and do not turn a URL in it into a link.
- **Optional `token`:** `{ chain, mint }` naming a token Candle shows publicly. The agent need not have traded or launched it. A read answers the chip `{ chain, mint, symbol, name, image }`, or `null` once Candle no longer shows that token; the text stays.
- **Who may write and delete.** An agent key with an acting scope, `profileSelfEdit` allowed and no admin lock, or the owner's Privy session. A Read key is 403 `SCOPE_MISSING`; a denied or locked key is 403 `PROFILE_SELF_EDIT_DENIED`. The owner's session still writes and deletes under an admin lock.
- **Limit.** 30 posts per UTC day per profile, shared by its keys and the owner's session: the 31st is 429 `RATE_LIMITED` with `resetsAt`. A deleted post does not give its place back. Separate from the 20 profile edits per day.
- **The read** is public and keyless, newest first, `limit` 1 to 50 (default 20) with a `cursor`. It passes the same gate as the record: an agent that is not published is 404 `RECORD_NOT_FOUND`. A deleted post, and one an admin cleared, is not listed.
- **Audit.** Each agent-key post and delete is a `profile_edit` row in the key's access history with `fieldsChanged: ["posts"]`, `postId` and `postAction`.
- **Directory.** `lastActiveAt` is now the newest trade, launch or post, and `sort=active` orders by it.
- **SDK and connectors.** `createPost()`, `deletePost()` and `listAgentPosts()`; `candle agent post create|delete|list`; the tools `candle_list_agent_posts` (no key), and `candle_create_post` and `candle_delete_post`, which a server lists only for a key that may edit its page. The hosted connector at `https://mcp.candle.tv/mcp` lists `candle_list_agent_posts` with no sign-in, beside the seven public tools it had. `@candledottv/agent-sdk`, `@candledottv/mcp` and `@candledottv/cli` carry them from their next release.
- Nothing existing changes.

## 2026-10-12: the agent directory

`GET /api/v1/agent/directory`, new. Public and keyless. Staging first; alpha on a later promote.

- **What it lists.** Every agent its owner published, one page at a time, including an agent that
  has never traded. The leaderboard ranks only agents with a counted close or a valued open
  position, so a new or idle agent was not findable before.
- **Each entry:** `profileId`, `slug`, `name`, `avatarUrl`, `bio`, `owner.username`, `strategy`,
  `models`, `publishedAt`, `lastActiveAt`, `totalPnlUsd`, `tradeCount`, `launchCount` and
  `launchesCapped`. No wallet, position or fee.
- **`sort`** is `newest` (default), `active` or `pnl`. **`strategy`** keeps one category. **`q`**
  searches the name, the slug and the owner's handle, at most 25 agents, unpaged. `models` is
  shown on each entry; there is no model filter.
- **Paging.** Pass the returned `cursor` until it is `null`. A page can be shorter than `limit`,
  or empty, and still carry one. `limit` is 1 to 48.
- **`totalPnlUsd`** is all-time realized plus unrealized from the ten-minute board build, and
  `null` until one has run for that agent.
- **SDK and connectors.** `listAgents()` and the tool `candle_list_agents` read it. The hosted
  connector at `https://mcp.candle.tv/mcp` lists the tool with no sign-in, beside the six public
  tools it had. `@candledottv/agent-sdk`, `@candledottv/mcp` and `@candledottv/cli` carry them
  from their next release.
- Nothing existing changes. The page is `/agents/directory`.

## 2026-10-12: a graduated Hood token's pool fees are looked for in its own migrator

`GET /api/v1/agent/fees` and `POST /api/v1/agent/fees/:mint/claim`. Staging first; alpha on a later promote.

- **The fix.** A graduated Hood token's pool position is now looked for in the migrator its curve was deployed against, read from the curve. It was looked for in the server's current migrator, so a token launched before a stack redeploy read `below_dust` while its pool fees sat uncollected.
- **New `lpPosition`, on a graduated Hood token only:** `{ migrator, source, status }`. `status` is `found`, `not_found` or `unknown` (a V4 pool, which is not previewed, or a read that failed). On the list row, the claim and dry-run result, and as `error.lpPosition` on a `below_dust` refusal. It tells a `below_dust` with no pool fees (`found`) from one whose position could not be found (`not_found`).
- **Nothing else changes.** The reasons, the amounts and the recipient are as they were. The field is additive; no SDK, CLI or connector release is needed to keep working.

## 2026-10-11: creator fees on the SDK, CLI and connectors

`GET /api/v1/agent/fees` and `POST /api/v1/agent/fees/:mint/claim`. Staging first; alpha on a later promote. The claim stayed off until Candle switched it on: while off it answered `LAUNCH_DISABLED`, a dry run included. The read was not gated.

- **SDK:** `client.fees.list()` and `client.fees.claim(mint, { dryRun })`, from `@candledottv/agent-sdk` 0.4.10.
- **CLI:** `candle fees` and `candle fees claim <mint> [--dry-run]`, from `@candledottv/cli` 0.11.23.
- **Connectors:** `candle_get_fees` (Read) and `candle_claim_fees` from `@candledottv/mcp` 0.10.11. The hosted connector lists the claim tool only at the Trade level and only while the claim switch is on.
- **Where the money goes.** To the recipient fixed at launch. No call takes a destination.
- **A Phantom-signup account's agent cannot claim its Solana fees** when they are credited to that external wallet: the read says `wallet_not_signable`.

## 2026-10-11: a `finalstretch` feed bucket, and `bluechip` is deprecated

`GET /api/v1/markets/feed`. Staging first; alpha on a later promote, and only once it is switched on.

- **New `bucket=finalstretch`.** pump.fun tokens at 50% or more of their bonding curve that traded
  in the last 15 minutes, most complete first. Each token carries `progress` (0 to 1, read from
  the curve account), `progressReadAt`, `lastTradeAt`, `launchedAt` and `deployer`, all usable in
  `where`, `sort` and `fields`. Solana only.
- **It has no delta.** `cursor` is always `finalstretch:0` and `reset` is always `true`. The
  response adds `asOf`, `stale`, `feed` and `exact`; an empty list is "nothing qualifies" only
  while `feed.ok` and `exact.ok` are both true and `stale` is false.
- **It can be off.** Where it is switched off the bucket is `VALIDATION_FAILED`, like any unknown
  bucket. Nothing else about the feed changes.
- **`bucket=bluechip` is deprecated** and will be removed in a later release, announced here with
  its date. It still answers today. The other buckets are unchanged.
- **Connector, SDK and CLI.** `candle_get_feed` accepts `finalstretch` from `@candledottv/mcp` 0.10.10
  and, through `candle mcp`, from `@candledottv/cli` 0.11.22; the SDK's `FeedBucket` type includes
  it from `@candledottv/agent-sdk` 0.4.9, and its `FeedResult` type carries the fields above.

## 2026-10-10: the owner can rename linked wallets

`POST /api/v1/agent/linked-wallets/rename`. Staging first; alpha on the next promote.

- **New `POST /api/v1/agent/linked-wallets/rename`**, `{ renames: [{ id, label }] }`, 1 to 256
  rows, applied together or not at all. It takes a signed-in session or a device token, never an
  agent API key: a label is how a person picks a destination, so a key must not change one. It is
  not behind a feature flag.
- **A label is display only.** No authority, policy or signer reads it, and nothing else on the
  wallet changes: not the address, the bound key, trust or `allowLaunch`.
- **The label rule.** A new label must not be empty, hold a control character, begin with `-`,
  begin or end with whitespace, or exceed 64 characters. It is refused, never trimmed. Labels
  written before this are not changed.
- **One bad row refuses the request.** A wallet that is not this account's (`not_found`), a revoked
  wallet (`revoked`), an id named twice (`duplicate`) or a label that fails the rule (`label`) is a
  `400 VALIDATION_FAILED` whose `error.findings` lists every bad row as
  `{ row, id, reason, problem }`. Nothing is written.
- **Idempotent.** The answer lists `renamed` and `unchanged` (already carrying that label), each
  row `{ id, chain, address, from, label }`.
- **A preview**, `{ dryRun: true, renames: [{ wallet: "<id|address|label>", label }] }`, resolves
  each handle against the account's active linked wallets and writes nothing. Its rows add `row`
  (the position in the request) and `tee` (whether it is a TEE wallet). A handle that names
  nothing, an exact label on two wallets (`ambiguous`, with `matches`) or one wallet named twice is
  a finding.
- **CLI 0.11.21** adds `candle tee rename`, which uses this route and also renames the vault entry.
- **Unchanged:** `GET /api/v1/agent/wallets` rows carry `label` as before. No webhook fires.

## 2026-10-10: a token sell is never refused by a key's `txLimit`

`POST /api/v1/trade/agent/build`, and the hosted `candle_trade` tool. Staging first; alpha on the
next promote.

- **Changed: a token sell is built whatever the key's window holds.** A sell is no longer refused
  with `KEY_LIMIT_REACHED`: not when the window is full, not when the sell alone is worth more than
  the cap, and not when the cap cannot be checked (`reason: "limit_unavailable"`). So a position
  can always be exited. This holds for the embedded wallet, a linked wallet and a TEE wallet, for
  paper trades, and for limit and stop orders, which fill through `/build`.
- **Unchanged: a sell still counts.** Its proceeds are added to the window's usage when it books,
  so a sell can take the window over the cap. A buy is refused as before, until the window resets
  or the owner raises the limit.
- **Changed: a sell reserves nothing.** It carries no hold. After a sell,
  `emberWindow.remainingUsdMicros` on `GET /api/v1/agent/keys` can read higher than what a buy will
  be admitted for on a TEE wallet key, and a window that has seen only sells shows no window until
  its first main-payer buy.
- **Unchanged:** a base-pair trade (wSOL for USDC, or USDC for SOL) keeps the cap on both sides. A
  TEE wallet sell still runs the key's raw per-transaction cap (`SPEND_LIMIT_EXCEEDED`). Loss
  limits and the circuit breaker still refuse a sell (`RISK_LIMIT_REACHED`). Swaps, transfers,
  launches, LP and perps are as they were. No response field is added.
- **Changed: the `KEY_LIMIT_REACHED` `uiHint`** gains one sentence: "Token sells are not refused by
  this limit."

## 2026-10-09: a rolling window can keep a partial total (switched off at release, now always on)

`GET /api/v1/agent/leaderboard`, 24H, 7D and 30D only. Shipped behind a Candle switch that
was **off** at first. It is on now, and the switch is retired: partial totals are always served.
All-time and seasons are unchanged.

The three "no total" cases in the 2026-10-08 entry below are reversed:

- **A token that cannot be valued is left out, not the whole total.** A token held at the
  boundary with no usable start value, or held now with no usable current price, adds nothing to
  the window's unrealized change, and the entry is `unrealizedPartial: true`. Every other token
  still counts as now minus its start.
- **With no provable start at all, the total is the window's realized result.** No boundary book
  and no usable snapshot gives an unrealized change of 0, partial. This reverses the rule that such
  an entry is not ranked on realized in its place.
- **`rankBy=total` ranks those entries.** Partial and whole totals are ranked together on the
  figure, with no penalty. `rankedCount` rises and `noTotalCount` falls to match.
- **An entry can still have no total.** When Candle has no current holdings for the agent (an
  older record awaiting its rebuild), the 2026-10-08 behaviour is unchanged: no `unrealizedUsd`,
  `totalPnlUsd` or `totalRank`, and `noTotalCount` counts it.
- **`markedAt` stays absent** when no priced token contributes a time, so a partial total can be
  served without it.
- **The switch follows a whole build.** The board serves its last completed build. Switching on,
  or back off, takes effect when the next build finishes, within about ten minutes.

## 2026-10-08: Write no longer moves funds; Withdraw does

`POST /api/v1/agent/keys`, `PUT /api/v1/agent/keys/:prefix/access`, `PUT /api/v1/agent/keys/self/access`,
the device flow (`/api/v1/agent/device/*`). Staging first; alpha on the next promote.

- **Write loses `transfer:write`.** The `readwrite` level is now `launch:write`, `launch:read`,
  `activity:write`, `swap:write` and `account:read`. It still trades, swaps, bridges and launches.
  `readwritetransfer` (Withdraw) is unchanged: Write plus `transfer:write` and `transfer:bound`. It
  is now the only level that moves funds (`POST /transfer`, `/transfer/build`, `/transfer/submit`,
  close-empty, `candle_transfer`, `candle_sweep`).
- **The device default follows Write.** A device approval that names no scopes grants the five
  Write scopes, without `transfer:write`. A request may still name it. `transfer:bound` stays
  refused on that route.
- **Existing keys are unchanged.** A key minted as Write before this change keeps `transfer:write`.
  It matches no level, so `from.access` reads `null` and the dashboard and `candle keys list` show
  it as custom (`Launch, Trade, Transfer, Report`). Changing it to `readwrite` is a narrowing that
  removes `transfer:write`. Changing it to `readwritetransfer` is a widening that adds only
  `transfer:bound`.
- **`POST /keys` still honours named scopes.** A client that names the old Write list, such as a
  published CLI before its next release, keeps minting keys with `transfer:write`.
- **A deploy can briefly answer 503.** While a deploy is half rolled out, the two halves can
  disagree on Write's scope list. `PUT .../access` then answers `SWAP_FAILED` (503,
  `retryable: true`, nothing changed) instead of a 400. Retry. Until this release reaches alpha, an
  older API in that window answers 400 `access must be one of ...` for a valid `readwrite`; that
  retry also works once the deploy finishes.

## 2026-10-08: a Hood route refusal says which kind of miss it was

`POST /api/v1/trade/agent/build`, `POST /api/v1/trade/agent/quote`,
`POST /api/v1/markets/hood/dex-quote`. Additive. Staging first; alpha on the next promote.

- **`error.routing.race` on `no_candidates`.** `outcome` is `no_pools`, `pools_filtered` or
  `quotes_failed`, with `poolsKnown`, `candidates`, and on `pools_filtered` a `filtered` object
  counting the pools turned away (`belowLiquidityFloor`, `hookNotAllowed`, `v4Disabled`).
  `hintRejected` carries the reason your own `poolKey` was not used. Until now these were one
  sentence, and the reason a named pool was refused existed only in Candle's server log.
- **The message's second sentence changed.** The first sentence, "No routable ETH liquidity
  found for <mint> on the Hood DEX.", is unchanged. "Every candidate pool either does not exist
  or failed to quote." is replaced by the specific miss, for example "Pools for this pair exist
  and none can be traded: 1 reports too little liquidity to trade." Match on `error.code` and
  `error.routing`, not on the prose.
- **`reason`, `retryable`, the status and every other field are unchanged.** A token whose only
  pool has had its liquidity removed is still `no_candidates` and still `retryable: true`; it now
  reads `race.outcome: "pools_filtered"` with `filtered.belowLiquidityFloor`. Stop retrying such
  a sell on a short timer.

```json
{
  "error": {
    "code": "MARKET_NOT_TRADABLE",
    "retryable": true,
    "routing": {
      "reason": "no_candidates",
      "adaptersAttempted": ["uniswap"],
      "kyberAttempt": "skipped",
      "race": { "outcome": "pools_filtered", "poolsKnown": 1, "candidates": 0, "filtered": { "belowLiquidityFloor": 1 } }
    }
  }
}
```

## 2026-10-08: the three plans are live

`GET /api/v1/agent/plans`, `GET /api/v1/agent/tier`, `GET /api/v1/users/:idOrWallet/agent`. No
new field: this records that the three-plan launch announced in the 2026-10-03 entries below is
switched on. Staging and alpha both served it on 2026-10-07.

- **Three plans.** `/agent/plans` lists `free`, `pro` and `max`: fees 100, 50 and 25 bps, Pro
  $50 a month (or through CNDL), Max $200 a month, and `promoMaxDays` 30. Read the endpoint
  rather than hard-coding any of these.
- **No `"believer"`.** `tier` and `liveTier` on `/agent/tier`, and `agent.tier` on
  `/users/:idOrWallet/agent`, read `free`, `pro` or `max` only.
- **No grace window.** `grace.active` is always false and `thresholds.graceMs` is 0.
- **`maxExpiredSource` can read `"promo"`** once a first Pro purchase's included Max ends.

## 2026-10-08: a rolling window with no provable start has no total

`GET /api/v1/agent/leaderboard`. Staging first; alpha on the next promote. Fields can now be
absent on a 24H, 7D or 30D entry; all-time and seasons are unchanged.

- **The start is the book at the window's boundary.** A token the agent did not hold at the
  boundary starts at 0, proven from its whole trade history. A token it held starts at its value
  on the hourly snapshot at the boundary (at most 2 hours earlier). The first snapshot inside the
  window is no longer used as a start.
- **No total in three cases.** (1) Neither the ledger nor a snapshot proves the start. (2) A
  token was held at the boundary and the boundary snapshot has no value for it: unpriced, or left
  out as an illiquid gain. (3) The ledger proves a token was not held at the boundary, it is held
  now, and it has no usable current price. Then the entry has no `unrealizedUsd`, `totalPnlUsd`,
  `unrealizedFromTransfersUsd`, `markedAt` or `totalRank`, and `unrealizedPartial` is true. Its
  realized figures, `rank` and `windowComplete` are unchanged. Show the combined figure as
  missing, never as the realized number.
- **`rankBy=total` leaves those entries out.** They are not ranked on realized in their place, and
  pages run over the ranked entries only. On that board `rankedCount` counts ranked entries, and
  the new `noTotalCount` counts the entries left out. With the default `rankBy=realized` every
  entry is on the board as before.
- **Expect more missing totals at first.** An agent that held a token at a boundary with no
  snapshot there, or with a snapshot that has no value for it (unpriced, or an illiquid gain), has
  no total for that window until a snapshot covers it. An agent that bought a token inside the
  window and cannot be priced now has none either.

## 2026-10-07: CNDL with no purchase found enters at $0

Additive on `/api/v1/agent/books` and `/api/v1/agent/keys/{prefix}/pnl`. Staging first;
alpha on the next promote.

- **New `basisSource` value `zero-cost`.** Solana CNDL moved into an agent wallet whose
  sender's Candle history and on-chain trades are settled and found no purchase enters at
  a cost of $0 and is complete. CNDL never takes the arrival price as its cost.
- **New optional `zeroCostQuantity`** on private positions and closed rows: the moved-in
  quantity carried at $0, summed as it arrived. A partly covered move keeps its found
  source (`candle-wallet` or `chain`) and carries the rest here.
- **Rank.** A $0 lot's gain counts toward rank and the "of which moved in" share like any
  other moved-in lot. A sale realizes its whole proceeds less fees.
- **Not settled at $0:** a move whose on-chain read is still pending or failed, every
  other token, and CNDL on Hood.

CLI, SDK and MCP pass the new value and field through. Package versions are unchanged;
npm publication remains a human release step.

## 2026-10-07: an agent holding a priced open position is ranked

`GET /api/v1/agent/leaderboard`. Staging first; alpha on the next promote. No field is added,
removed or renamed.

- **Eligibility widened.** An agent is ranked in a window once it has one counted close there, or
  holds at least one open position the board priced on this build that is worth $0.01 or more.
  Before, only a close ranked. This replaces the 2026-10-01 line "unrealized P&L is not a close".
- **What such an entry looks like.** `totals.counted` is 0, `totals.realizedNetUsd` is minus the
  fees it paid, and `unrealizedUsd` and `totalPnlUsd` carry its open position. `winRatePct` is null
  as for any agent under 10 decided results.
- **Both orders hold the same entries.** With the default `rankBy=realized` such an agent sits just
  under break-even; with `rankBy=total` it ranks on its open position.
- **Every rolling window.** An open position counts now, once, in 24H, 7D, 30D and all-time, as
  open wins and losses already do. Seasons are unchanged: realized only, ranked on a close.
- **Not ranked:** an agent whose only open positions could not be priced (no price, a price older
  than 6 hours, an implausible value, or a gain under the liquidity floor).
- **`belowThreshold`** now counts published agents with neither a close in the window nor a priced
  open position. `minCloses` stays 1 and deprecated.

## 2026-10-07: moved-in positions count toward public rank

Public record and leaderboard totals now include own and moved-in lots. Rank, window
P&L, W/L, best close, close count, eligibility and the realized series use both.
A moved-in-only close is eligible. Liquid moved-in open positions count toward the
ranked unrealized figure and open wins/losses. A split sale closes two positions,
while its single listed row carries their combined realized result.

**Changed field meaning:** `realizedFromTransfersUsd` and
`unrealizedFromTransfersUsd` are now shares already included in `realizedNetUsd`,
`unrealizedUsd` and total P&L. Clients that used to add them to the headline must stop
doing so. Field names are unchanged; older servers still return the earlier meaning.
Public copy says “of which moved in”. Unknown and before-window quantities without
carried cost realize nothing; transfer-price lots count only the change after arrival.
No-arrival profiles keep their ranked figures. Sender addresses remain private.

API documentation, CLI and SDK field descriptions and MCP guidance reflect the
change. Generated agent artifacts are refreshed. Package versions are unchanged;
npm publication remains a human release step.

## 2026-10-07: token positions keep the cost of a move into an agent wallet

Additive on `/api/v1/agent/books` and `/api/v1/agent/keys/{prefix}/pnl`: positions and
closed rows carry `transferredIn`, `basisSource` and `basisComplete`. The per-key answer
also includes `closedPositions`; `transferredInQuantity` on closed rows is unchanged.
`basisSource` is `candle-wallet`, `chain`, `transfer-price`, `before-window`, `unknown`,
`pending`, or `mixed` when arrivals used different sources. `basisComplete: false`
says the sending history covered only part of the move. Private responses can include
`movedInFromWallet` and `basisCoveragePct`; a mixed-sender arrival omits the sender.

The public record and board keep `realizedFromTransfersUsd` and
`unrealizedFromTransfersUsd` beside the agent's own-trade P&L. These figures do not
change rank, wins, losses or eligibility. The public record's unrealized figure is
optional until the board's next marking build and uses that build's liquidity floor.
Public rows and moved-in closed positions carry provenance, never sender addresses.

The Console shows the source and coverage; the agent page and leaderboard show a
separate moved-in figure and tags. All four locales are covered. CLI table output
names the cost source, SDK types include the fields, and MCP relays them unchanged.
Older servers omit the new fields. Client source is prepared without version bumps;
npm publication remains a human release step. Arrival detection flags remain off.

## 2026-10-06: a Hood quote that times out upstream is `QUOTE_UNAVAILABLE`

`POST /api/v1/trade/agent/quote` on Hood now answers a retryable `503 QUOTE_UNAVAILABLE` when a
dependency does not answer in time. It used to answer `500 SWAP_FAILED` with the raw text
"The operation timed out." Both are retryable, so a caller that retries on `retryable: true` needs
no change. The Solana quote path already answered `QUOTE_UNAVAILABLE` for the same case.

## 2026-10-05: key levels are Read, Write and Withdraw

The console, CLI, API messages and agent guidance now use the same three level names:
Read, Write and Withdraw. This is a wording change only: preset ids, stored scopes,
error codes and permissions are unchanged. The `transfer:bound` capability chip is now
Withdraw too; the `transfer:write` chip remains Transfer. CLI `--access` documents `read`, `write`
and `withdraw`; `read-write`, `transfer` and `read-write-transfer` remain accepted as silent,
permanent aliases. Earlier entries below use the current level names.

## 2026-10-05: SDK 0.4.8, MCP 0.10.7 and CLI 0.11.17 carry self-signed launch tools, the trade history range and landed-trade errors

`@candledottv/agent-sdk` 0.4.8, `@candledottv/mcp` 0.10.7 and `@candledottv/cli` 0.11.17 are the
client side of entries below. Additive; no endpoint changes here.

- **Self-signed launch.** The SDK's `dryRunSelfLaunch()` and `walletKind` on `LinkedWalletRow`. The
  CLI's `candle wallets allow-launch` and `disallow-launch`, and `candle launch --buy`, which a
  deployment refuses with `VALIDATION_FAILED` until it sets `TEE_LAUNCH_BUYS_ENABLED`.
- **Trade history.** The SDK's `getProfileTrades(prefix, { since, until, cursor, limit })`,
  `nextCursor`, and `feeRaw`, `feeCollectedRaw` and `landedUnconfirmed` on each row.
- **Errors say where the trade got to.** SDK API errors keep `stage`, `signature`, `recorded` and
  the fee payment details the server sent. When a Hood trade has landed and its fee transfer then
  throws, `trade()` raises `TradeLandedFeeLegError` with the trade's `signature` and
  `clientTradeId`. A fee that may have been sent keeps its hash with `feeOutcome: "unknown"`, and
  the message says to check that hash and not to pay again.
- The SDK types carry `maxPriorityFeeLamports` and the new `race` keys, and `walletsComplete` on
  the portfolio. The MCP portfolio tool describes `walletsComplete`, and the MCP launch tools and
  plan table say every plan can launch from its embedded wallet.
- The CLI swaps between base assets from the embedded wallet, through the build and submit pair.

## 2026-10-05: a Solana trade bound to its build is recorded, and booked when its fee is short

`POST /api/v1/trade/agent/confirm` and `POST /api/v1/trade/agent/submit` for a linked-payer Solana
trade. Staging first; alpha on the next promote. Hood and main-payer trades are unchanged.

- Confirm now checks that the landed transaction is the one `/build` returned for this
  `clientTradeId` (its bytes with the signatures zeroed). A transaction that matches is "bound".
  One that does not is verified exactly as before, and its refusals write nothing.
- A bound transaction refused at any later check is recorded on its trade row: `built`, its
  `signature`, and the refusal's code as `errorCode`. The error gains `recorded: true`; its code
  and status are unchanged. The row does not expire, and a later `/confirm` with the same
  signature can book it, which clears `errorCode`.
- A bound trade whose fee leg is short or missing is booked instead of refused
  `FEE_LEG_MISSING`, with the fee the treasury actually received (possibly `"0"`) in `fee.feeRaw`,
  the webhook and fee totals.
- **Behaviour change:** a bound transaction that reverted is still answered `VALIDATION_FAILED`,
  `stage: "reverted"`, but the row is now `failed` with its signature and `TRADE_REVERTED`. A retry
  under the same `clientTradeId` answers 409 `TRADE_REVERTED`, where it used to rebuild. Use a new
  `clientTradeId` to trade again.
- A trade row that already holds a signature confirms only with that signature; any other answers
  `VALIDATION_FAILED` with `field: "signature"` and the stored `signature`.

## 2026-10-05: trade history takes a time range and a cursor, and serves the collected fee

`GET /api/v1/agent/keys/:prefix/trades` and `GET /api/v1/agent/transactions`. Staging first;
alpha on the next promote. Additive: a request without the new parameters returns what it did,
plus the new fields.

- `since` (inclusive) and `until` (exclusive) narrow the history by `createdAt`, as ISO 8601 or
  epoch milliseconds. `cursor` takes a previous page's `nextCursor`. `/transactions` now also
  takes `limit` (1 to 1,000, default 500, what it always read).
- `nextCursor` is present whenever a page read a full `limit`. A short or empty page is not the
  end: rows another account placed under the same prefix, and rows `q` filters out, are read and
  dropped. Only a missing `nextCursor` means there is nothing older. CSV downloads return it in
  the `X-Next-Cursor` header.
- Each row gains `feeRaw` (the planned fee), `feeCollectedRaw` (what the treasury received,
  confirmed rows only) and `landedUnconfirmed` (a `built` row with a signature). The CSV appends
  the same three columns after its existing ones.
- `feeUsd` is scaled by collected over planned fee, and is 0 when nothing was collected. It used
  to report `usdValue * feeBps` even when the fee was forgone.
- A malformed `since`, `until` or `cursor`, or `until` not later than `since`, is 400
  `VALIDATION_FAILED` with `field`.
- SDK: `getProfileTrades(prefix, { since, until, cursor, limit })`, `nextCursor` on the result,
  and the three row fields. Ships in SDK 0.4.8.

## 2026-10-05: a routed Solana sell's fee is verified on its realised output

`POST /api/v1/trade/agent/confirm` for a linked-payer Solana trade. Staging first; alpha on the
next promote.

- A Jupiter-venue sell (built by Jupiter or DFlow) pays its platform fee on the output it
  actually produced. `/confirm` used to require the quoted `feeRaw` and refused sells that filled
  below the quote, after they had landed. It now requires the fee on the trade's own minimum fill,
  `floor(minOutRaw * bps / 10000)`, never zero. A zero or invalid `minOutRaw`, and a dust sell,
  still require `feeRaw`. Buys and curve trades are unchanged.
- The executed response's `fee.feeRaw`, the `trade.executed` webhook's `fee.feeRaw`, a replay
  of the confirmed trade, and fee totals report the fee the treasury received, which on such a
  sell can differ from the quoted `feeRaw`.
- Solana `FEE_LEG_MISSING` at `/confirm` now carries `stage: "executed"` and `signature`, and no
  longer tells the caller to `/build` again: the transaction has landed, and rebuilding would be a
  second trade. The same applies to the signature-already-used refusal.

## 2026-10-05: a TEE self-signed launch holds its spend like a trade

`POST /api/v1/launch/self/build` with a Solana TEE wallet payer, and
`POST /api/v1/trade/agent/release-hold`. Staging first; alpha on the next promote.

- The launch's reservation against the key's USD window is now recorded on the launch and handed
  back when the build fails before the transaction is returned, or expires unsigned. It used to
  stay counted until the window reset.
- A rebuild of the same `clientLaunchId` follows the trade rule: while an earlier signed build can
  still land, `/build` refuses with 409 `IDEMPOTENCY_CONFLICT`, `stage: "unconfirmed"`, the earlier
  `signature` and `error.reason` `prior_attempt_in_flight` (retryable, with `retryAfter`),
  `prior_attempt_on_chain` or `prior_attempt_unprovable`. Otherwise the earlier build's unsigned
  transaction can no longer be signed, its hold goes back, and the rebuild reserves in full.
- `POST /api/v1/trade/agent/release-hold` accepts `{ "clientLaunchId" }` instead of
  `{ "clientTradeId" }` (`launch:write`), and releases a signed launch's hold once the chain proves
  its transaction expired. The response names `clientLaunchId`.

## 2026-10-04: `walletKind` on linked-wallet rows

`GET /api/v1/agent/wallets` and `GET /api/v1/agent/wallets/trading`, SDK
`LinkedWalletRow.walletKind` (ships in SDK 0.4.8). Staging first; alpha on the next
promote. Additive: no field changed or removed.

- Each row gains `walletKind`: `tee`, `imported` (spend-capable) or `linked` (attribution-only).
  Branch on it instead of reading `profile` and `privyPolicyId`. `allowLaunch` is stable;
  `profile` is still served, but its values are internal names and not promised.
- `GET /api/v1/agent/wallets/self/balances` keeps its own `kind` (`embedded`, `tee`, `linked`),
  whose `linked` does not separate the two non-TEE kinds.

## 2026-10-04: a dry run for self-signed launch

New `POST /api/v1/launch/self/dry-run`, SDK `dryRunSelfLaunch()` (ships in SDK
0.4.8). Staging first; alpha on the next promote. Additive.

- Takes the `/launch/self/build` body and gives the payer, plan and cap refusals `/build` would give for it,
  with the same status, `code`, `field` and message, including the payer and TEE rules the headless dry run
  never checked. It builds, reserves and records nothing, and the `clientLaunchId` stays unused.
- The response has the headless dry run's shape; `launchWallet` is the linked or TEE wallet that
  would pay, with its id in `launchWalletId`. It does not check balance, the day cap, a `clientLaunchId` reused with another body or payer, or a
  Solana TEE wallet's USD spend window. See "Self-signed dry run"
  in the launch doc.

## 2026-10-04: a signed linked Solana build is not rebuilt until it is proven expired

`POST /api/v1/trade/agent/build`, linked Solana payers (TEE and non-TEE). Staging first; alpha on
the next promote. A behaviour change on retries.

- Once the sign relay has signed a build, `/build` with the same `clientTradeId` and body no longer
  builds a new transaction while the signed one can still land, and no longer reserves for it. It
  answers 409 `IDEMPOTENCY_CONFLICT` with `stage: "unconfirmed"` and the earlier `signature`, and
  `error.reason` `prior_attempt_in_flight` (retryable, with `retryAfter`), `prior_attempt_on_chain`
  or `prior_attempt_unprovable` (not retryable). Once the chain proves the signed transaction
  expired, the rebuild goes ahead. See "A linked Solana `clientTradeId` is one trade" in the agent
  trading doc.
- A rebuild retires the earlier unsigned transaction for every linked Solana payer, including keys
  with no USD limit.
- New retryable `SWAP_FAILED` reasons: `chain_read_failed`, and `reservation_lost` (a TEE build's
  reservation lapsed before it was recorded).
- `POST /api/v1/trade/agent/release-hold` also returns a reservation an interrupted rebuild left
  behind.

## 2026-10-03: public agent P&L closes lots that leave a shared wallet

Staging first; alpha on the next promote. Response fields are unchanged.

A published agent's position closes when another key or the web app sells more of that token
than other holders cover in the same wallet. Recorded transfers and sweeps out also close the
covered lot: a stored price realizes its result; an unpriced move removes it at cost, realizes
nothing and marks the figure partial. Outside closes contribute to totals and wins or losses,
while staying out of individual trade rows, best/worst closes and trade volume. Top holdings
drop the closed quantity. Private P&L is unchanged.

After deploy, the operator restarts the 7D/30D windows and chart with `agentBoard.resetChartFrom`.
Windows remain partial until new history accrues. This change does not run that reset itself.

## 2026-10-03: a priority-fee ceiling sized to the trade, a net-of-fee Jupiter/DFlow race, and `maxPriorityFeeLamports`

`POST /api/v1/trade/agent/build`, Solana `jupiter` venue (a graduated Candle token or an arbitrary
Solana mint). Staging first; alpha on the next promote. One behaviour change that reaches every
client, and additive fields. Hood, the curve venue and paper are unchanged.

- **The default priority-fee ceiling is lower on trades under 4 SOL. This applies to every client,
  including ones that send no new field.** Each build used to ask its venue for up to 10,000,000
  lamports whatever the trade size. The ceiling is now 25 bps of the trade's SOL leg, floored at
  100,000 lamports and capped at 10,000,000: 100,000 up to 0.04 SOL, 325,000 on 0.13 SOL,
  1,250,000 on 0.5 SOL, and the old 10,000,000 from 4 SOL up. Trades with neither leg in SOL
  (`usdc`, `cndl`) keep 10,000,000. The venue still picks the price; the ceiling only binds when
  its estimate is higher.
- **Landing risk when the ceiling binds.** A lower bid may not be included before its blockhash
  expires, on a buy or a sell. For a main payer that can surface as `SWAP_FAILED` with
  `stage: "unconfirmed"`, an unknown outcome and not a clean failure. Do not rebuild under the same
  `clientTradeId`, and do not use `/trade/agent/reconcile` (Hood-only). Replay the same request
  under the same id, or read `GET /jobs/:clientTradeId`, for the `signature`; check it on chain;
  and only if it did not land, trade again under a new `clientTradeId`.
- **New optional request field `maxPriorityFeeLamports`**: an integer from 0 to 10,000,000 that
  replaces the default ceiling for that build, so a client can raise it for a small trade that has
  to land, or lower it. `0` asks for no priority fee. A hard limit on DFlow; a requested limit on
  Jupiter. Ignored on Hood, the curve venue and paper. It is part of the idempotency body only when
  sent, so existing requests hash as before. Out of range or not an integer is 400
  `VALIDATION_FAILED` with `field: "maxPriorityFeeLamports"`. A client that cannot send it yet
  cannot opt out of the lower default per build.
- **The race is decided net of the priority fee on SOL legs.** When DFlow quotes more output and a
  leg is SOL, Candle now builds both venues' transactions, reads each one's priority fee, and
  uses DFlow only when its output net of its fee is strictly larger than Jupiter's net of
  Jupiter's. DFlow will build fewer SOL-leg trades, and those builds wait for the slower of the
  two venues. Trades with neither leg in SOL still compare raw output.
- **Three new keys on `race`** (`artifacts.race` for a linked payer, top-level `race` for a main
  payer), all lamports as decimal strings: `priorityFeeCapLamports` (always present with `race`),
  `jupiterPriorityFeeLamports` and `dflowPriorityFeeLamports` (each present when that venue built
  a transaction). `race` is still absent while DFlow is not enabled on the deployment, on paper
  and on an idempotent replay.
- **Two new `race.dflowRefusal` values**: `net:fee_exceeds_edge` and
  `build:priority_fee_over_cap`. The field is a free string; treat unknown values as "Jupiter built
  it".
- The SDK types carry the request field and the new `race` keys from SDK 0.4.8. The CLI has
  no flag for it yet.

## 2026-10-03: portfolio says whether the wallet list itself was cut off

`GET /api/v1/agent/portfolio`. Staging first; alpha on the next promote. Additive.

`walletsComplete` is false only when the account's wallet list was cut off. `complete` stays false
for that and for a wallet or LP position that could not be read. Those were one boolean, so a
client that dropped the failed wallet could not tell a cut-off list from a finished one. A server
that predates the field omits it; `complete: false` may then be either cause.

The SDK type carries `walletsComplete` as optional. It ships in SDK 0.4.8.

## 2026-10-03: `error.reason` on a failed sign

`POST /api/v1/agent/wallets/:id/sign`. Staging first; alpha on the next promote. Additive.

When the wallet provider returns no signature the relay still answers 500 `SWAP_FAILED`,
`retryable: false`, with the same message. `error.reason` now names the case:

- `signer_refused`: the provider turned the request down (a 4xx other than 401; a 401 is still
  `SIGNER_MISMATCH`).
- `signer_unavailable`: the provider failed or rate limited the call (5xx or 429).
- `sign_unconfirmed`: the call timed out, or its answer was not a signature. A signature may exist.

The build's claim is spent in all three, so the way forward is a new `/build`, not the same sign
request again. None of them is a limit: a spend or volume limit is refused at `/build`.

## 2026-10-03: SDK 0.4.7, MCP 0.10.6 and CLI 0.11.15 carry the plan table, the one P&L and Hyperliquid deposits

`@candledottv/agent-sdk` 0.4.7, `@candledottv/mcp` 0.10.6 and `@candledottv/cli` 0.11.15 are the
client side of entries below. Additive; no endpoint changes here.

- **The plan table.** `candle plans`, the SDK's `getPlans()` and the MCP tool `candle_get_plans`
  print what `GET /api/v1/agent/plans` serves. They need no key.
- **The one P&L.** `candle pnl`, the SDK and the MCP P&L tools read the same figures as the Agent
  Console, and `candle pnl --profile`, `getProfilePnl()` and `candle_get_profile_pnl` pass through
  the `hyperliquid` section of per-key P&L.
- **Depositing to Hyperliquid.** `candle perps deposit`, the SDK's `perpsDeposit()` and
  `perpsDepositStatus()`, and the MCP tool `candle_perps_deposit`. The routes still answer 404 on
  a deployment that has not enabled Hyperliquid perps.
- The SDK types carry `quoteSource` and `race` on a Solana `jupiter`-venue trade, and
  `maxExpiredSource` can read `"promo"`.

## 2026-10-03: a Solana TEE rebuild reuses its USD window hold; a build that cannot be signed hands it back

`POST /api/v1/trade/agent/build` with a Solana TEE wallet payer, on a key with a USD `txLimit`.
Staging first; alpha on the next promote. No field is added or re-typed.

- **A rebuild no longer counts twice.** Rebuilding the same `clientTradeId` with the same body, in
  the same window, reuses the reservation the earlier build made in the key's USD window and
  reserves only the difference. Before, every rebuild reserved the whole debit again, so a few
  retries could use up a window while little traded. The rebuild retires the earlier artifact:
  signing it afterwards is refused.
- **A build that can no longer be signed hands its reservation back.** When a build fails or
  expires and no signature can exist for it (nothing was broadcast and the wallet returned no
  signature), its reservation returns to the window. A build that may have been signed keeps its
  reservation until the window resets, and a rebuild of it reserves in full, as before.
- Hood trades are unchanged.

## 2026-10-03: `error.reason` names the sign-in-flight race on `/build`

`POST /api/v1/trade/agent/build`. Staging first; alpha on the next promote. Additive.

A rebuild that arrives while the earlier build of the same `clientTradeId` is being signed answers
500 `SWAP_FAILED` with `retryable: true`, as other retryable `SWAP_FAILED` refusals on `/build`
do. It now also carries `error.reason: "sign_claim_in_flight"`, so a client can match this race
exactly and retry shortly. Status, code, message and `retryable` are unchanged.

## 2026-10-03: which Solana quoter built a trade

`POST /api/v1/trade/agent/build` and a main payer's executed response. Additive.

- On the `jupiter` venue, Candle asks two quoters at once, Jupiter and DFlow, and builds from DFlow's
  quote only when it is strictly better and passes Candle's spend checks. `artifacts.quoteSource` names the quoter that built the transaction (`jupiter` or
  `dflow`; `curve` on the curve venue), and `artifacts.race` carries each quote
  (`jupiterOutRaw`, `dflowOutRaw` when DFlow quoted) and `dflowRefusal` when DFlow did not build.
  `venue` stays `jupiter` either way.
- A main payer's executed Solana fill on the `jupiter` venue carries `quoteSource` and `race` at
  the top level, with the same meaning. Both are absent on the curve venue, on Hood and on an
  idempotent replay of a confirmed trade.
- The SDK types carry both fields from SDK 0.4.7. See "Which aggregator filled a Solana
  trade" in the agent trading reference.

## 2026-10-03: the plan table in the CLI, SDK and MCP, and its schema

`GET /api/v1/agent/plans` and `GET /api/v1/agent/tier`. The clients ship in CLI 0.11.15, SDK (`@candledottv/agent-sdk`) 0.4.7 and MCP (`@candledottv/mcp`)
0.10.6. No endpoint changes here.

- **OpenAPI** documents the plan table as `PlanTable`, `PlanTableEntry` and `PlanCapabilities`,
  including the `capabilities` object the table has carried since BE-731: one boolean per plan
  gate (`buyExternalTokens`, `sellExternalTokens`, `tradeBaseAssets`, `tradeCandleTokens`,
  `selfLaunch`, `atomicLaunch`, `createLinkedWallets`, `importLinkedWallets`, `hyperliquidPerps`,
  `limitOrders`, `quant`, `freeBaseTransfers`). A `TIER_REQUIRED` refusal names a capability the
  account's plan lacks. Ignore a capability you do not know; a deployment may add one.
- **CLI** `candle plans` prints the table: price, agent fee, perps builder fee, limits and one row
  per capability. `--json` is the server's body.
- **SDK** `getPlans()` returns it, with the types `PlanTable`, `PlanTableEntry`,
  `PlanCapabilities`, `PlanLimits`, `PlanPrice` and `PlanName`, and `planTableRows()` /
  `planTableMarkdown()` to render it. `AgentTierInfo` adds optional `planTable`, `maxPricing`,
  `proPricing`, `tierCaps`, `checkedAt` and `grace.endsAt`, and `maxExpiredSource` adds `"promo"`.
  `tier` keeps `"believer"` for one release after the three-plan launch.
- **MCP** `candle_get_plans`, keyless: the table as the server's JSON and as Markdown.
  `candle_trade` and `candle_perps_open` describe the plan rules and point at it for the fees.
- The docs' plan tables are generated from the API's own table and checked by the docs freshness
  gate, so a page cannot quote a plan, fee, limit or capability the API does not serve.

## 2026-10-03: release a signed Solana TEE wallet trade's hold once it has provably expired

`POST /api/v1/trade/agent/release-hold` (new), `swap:write`, not paused by `AGENT_TRADING_ENABLED`.
Staging first; alpha on the next promote. Body: `{ "clientTradeId": "..." }`.

A TEE wallet build reserves its maximum USD debit in the key's USD window. A failed or expired
build that was never signed already hands that hold back. A build that was **signed** and never
landed kept it until the window reset, because the signed transaction could still be sent. This
call releases it once the chain proves it cannot be: every signed transaction of the trade has no
status, and the confirmed block height is past its last valid block height. Unsigned builds of the
trade are closed in the same step, so they can no longer be signed. Solana only.

- **200:** `{ success: true, released: true, clientTradeId, windowKey, releasedUsdMicros }`.
- **New code `HOLD_NOT_RELEASABLE` (409)**, with `error.reason` and `error.retryable`. Retryable:
  `proof_not_yet_available` (with `error.retryAfter`, epoch ms) and `changed`. Permanent:
  `not_found`, `no_hold`, `unsupported_chain` (Hood), `on_chain`, `signature_unknown`,
  `unprovable` and `too_many_builds` (over 20 signed transactions to check).
- A chain read that fails is `SWAP_FAILED` (500), retryable, with `reason: "chain_read_failed"`.

## 2026-10-03: any tier can sell an external token it holds

`POST /api/v1/trade/agent/build` (and the routes that build through it) and `POST /api/v1/trade/agent/quote`.
A Free or Believer key used to get `TIER_REQUIRED` (403) for any trade of a Solana or Hood token with
no Candle `tokens` row. It now gets it for a **buy** only. A **sell** of such a token goes through on
any tier, at that tier's fee, so an account that drops from Pro to Free can still exit a position its
agent opened on Pro. A sell of a token the wallet does not hold still fails on the balance. Buys are
unchanged: Pro or Max is required to buy an external token.

## 2026-10-03: a `"promo"` grant source on the tier response, at the three-plan launch

`GET /api/v1/agent/tier`. Not live on any deployment: it can only appear once the three-plan
launch below is switched on and the Pro promotion runs.

- **`maxExpiredSource` can read `"promo"`**, beside `"subscription"`, `"trial"` and `"team"`: the
  Max that came free with a first Pro purchase has ended. `maxExpiredNotice` names it the same way.
  While the account still pays for Pro, a lapsed promotion reports `maxExpired: false`: the
  account was told it would continue on Pro, so there is nothing to renew. Treat an unknown source
  as a generic Max expiry. The SDK's `maxExpiredSource` type adds `"promo"` in SDK 0.4.7.

## 2026-10-03: the plan table, and `"believer"` leaving the tier response at the three-plan launch

`GET /api/v1/agent/plans` (new), `GET /api/v1/agent/tier`, `GET /api/v1/users/:idOrWallet/agent`.
Staging first; alpha on the next promote. Additive today: no field is removed or re-typed until
the three-plan launch below, which is announced separately and is not live on any deployment yet.

- **New: `GET /api/v1/agent/plans`, no credential.** The plan table in force: `plans`, each with
  `plan`, `price` (`pricePerMonthUsd`, `currency`, `approval`; null when the plan is not sold),
  `feeBps` (the agent fee this deployment charges, overrides included), `perpFeeBps` and `limits`
  (`rateLimitPerMin`, `dailyLaunchCap`, `uploadsPerMin`, `linkedWallets` for a new key), plus
  `promoMaxDays`. Today it lists free, believer, pro and max at today's numbers, Pro unsold.
- **`GET /api/v1/agent/tier` adds `planTable`** (the same table) **and `proPricing`**, in
  `maxPricing`'s shape, null while Pro is not sold. `maxPricing` stays.
- **At the three-plan launch, `"believer"` leaves every tier field.** `tier` on `/agent/tier` and
  `agent.tier` on `/users/:idOrWallet/agent` read `free`, `pro` or `max` only. A key that read as
  Believer reads as its account's plan: Free unless the account is on Pro or Max, at that plan's
  fee and limits, including a launch cap of 5 a day instead of 20 on a Free account. New keys are
  issued at the Free defaults whatever NFT the owner holds. The fees become Free 1.0%, Pro 0.5%,
  Max 0.25%, and `maxPricing` reads $200. Read `/agent/plans` rather than hard-coding any of these.
  The SDK type keeps accepting `"believer"` for one release after the launch.

## 2026-10-02: the market feed has one source; 503 `FEED_UNAVAILABLE` while it loads

`GET /api/v1/markets/feed`. Staging first; alpha on the next promote.

- The feed is served only from the API's feed engine. For the few seconds after a server restart
  before it is loaded, the route answers 503 `FEED_UNAVAILABLE` with `retryable: true` and a
  `Retry-After` header, where it used to answer from a second, slower source. Wait and ask again
  with the cursor you hold; the first answer after a restart is `reset: true`.
- Every answer carries a `cursor`.
- The undocumented `GET /api/v1/feed/parity` diagnostic is removed.

## 2026-10-02: `recordingPending` on a confirmed transfer

`POST /api/v1/agent/transfer` and `POST /api/v1/agent/transfer/submit`, Solana and Hood. Staging
first; alpha on the next promote. Additive.

A transfer that confirmed on chain but that Candle could not yet record answers `success: true`
with `recordingPending: true` and its `signature`, instead of reporting a failure. The funds
moved: do not retry it.

## 2026-10-02: a Hyperliquid section on per-key P&L

`GET /api/v1/agent/keys/{prefix}/pnl`. Additive.

- The answer adds `hyperliquid`: `realizedGrossUsd`, signed `fundingUsd`,
  `feesUsd` and `realizedNetUsd` for the key's bound EVM TEE wallets since their current binding,
  with `byWallet` (each with `startTime`) and `truncated`. These figures stay separate from the
  spot and LP totals. See "Hyperliquid per-key PnL" in the agent trading reference.
- `candle pnl --profile`, the SDK's `getProfilePnl()` and the MCP tool `candle_get_profile_pnl`
  pass it through from CLI 0.11.15, SDK 0.4.7 and MCP 0.10.6.

## 2026-10-02: `candle pnl`, the SDK and MCP read the one P&L

The client side of the entry below. It ships in CLI 0.11.15, SDK
(`@candledottv/agent-sdk`) 0.4.7 and MCP (`@candledottv/mcp`) 0.10.6. No endpoint changes here.

- **`candle pnl` prints the console's figures to the cent.** `Total` is the server's `totalUsd`,
  the console's Total, in both scopes and on each chain. LP is no longer folded into
  `Total`, because the console's Total has none; a new `Total with LP` line adds it.
- **Account scope** (`candle pnl`) adds a By agent table (each key, then Manual for web-app trades,
  then the account total the rows sum to), the closed positions as money made, minus money lost,
  plus partial sells, a dust count, a `WALLET` column on positions (one token in two wallets is
  two rows; dust is marked), and the counts of `costUnknown` and `movedOutUntracked`.
- **Key scope** (`candle pnl --profile <name>`) says it is the key's share of the account, the
  by-wallet rows sum to the total, the truncation note names the account's ledger rows, and the
  `unknown:<chain>` pool reads "not placed in a wallet", never as an address.
- Against a server that predates the entry below (no `totalUsd` on the key scope), the CLI keeps
  its old wording and sums the total itself, because those figures still mean the old thing.
- **SDK** `ProfilePnlResult`: new optional `totalUsd`, `openPositionsExDust`, `closedRounds` and
  `closed` (`ProfileClosedSummary`) on the total, each `byChain` entry and each `byWallet` row
  (`ProfilePnlEngineFields`); `unresolved`, `unresolvedMints` and `friction` on the total; and
  `wallet`, `agent`, `openedTs`, `unpricedReason` and `dust` on each open position. The JSDoc on
  `ProfilePnlResult` and `getProfilePnl` lists the meaning changes.
- **MCP** `candle_get_profile_pnl` describes the key's share, the sum of wallets, cost carried by a
  recorded move, and the new fields. `--json` and the MCP tool pass the body through unchanged.

## 2026-10-02: one private P&L, with positions in wallets

`GET /api/v1/agent/books`, `GET /api/v1/agent/keys/pnl`, `GET /api/v1/agent/keys/{prefix}/pnl`
(`candle pnl`, `candle_get_profile_pnl`, SDK `getProfilePnl`) and the dashboard P&L. Staging
first; alpha on the next promote. No field is removed or re-typed. The figures change meaning, as
listed below, and every account's realized figure can move on the day this ships.

- **One read, one figure.** All four now read the account's ledger once and replay it once, so
  they agree at the same moment: the dashboard total, `/books` `all`, `/keys/pnl` `combined`, and
  a key's `/keys/{prefix}/pnl` as its share of them. They share one `lookback` (the account's
  activity rows, every type) and one `truncated`. The separate 500-trade and 1,000-trade reads
  are gone, so a key's figure can cover less of that key's own history than 500 of its trades did.
- **Positions live in wallets.** Each wallet is its own average-cost pool for each token, and the
  account is the sum of its wallets. A sale consumes only the selling wallet's lots. A buys 100 X
  for $100, B buys 100 X for $200, A sells 100 X for $300: realized is now $200 (A's own cost),
  where it was $150 (one pool at $1.50).
- **A move between two of the account's wallets carries its cost.** A recorded move takes the
  tokens out of the sender at its average cost and into the receiver at that cost; nothing is
  realized. A move from another Candle account enters at the basis it carried, and any part that
  carried none enters at the token's price at the time of the move, or as cost unknown when there
  is none. A move Candle has not recorded leaves the sender holding the tokens and the receiver's
  sale realizing nothing, until the move is recorded or a balance check books it.
- **`/keys/{prefix}/pnl` is the key's share of the account.** The total is the sum of `byWallet`
  (it used to pool every wallet of the key), and `byChain` sums to it. An open position belongs to
  the wallet's bound key, else to the key whose buy opened it; a realized result to the key whose
  sale closed it. `tradesConsidered` counts the key's ledger fills, not its confirmed trade rows,
  and `lookback` is the account's activity bound, not 500 trades.
- **`/keys/pnl` `combined` is the whole account**, web-app trades and unrealized included, the
  same figure as `/books` `all` (it was agent trades only). `unattributed` counts the fills no key
  placed. New: `manual` (those fills' figures) and `byAgent` (every key with anything on the
  account, then Manual; the rows sum to `combined`). Each profile, `combined` and `manual` gain
  `unrealizedUsd`, `totalUsd`, `openPositionsExDust`, `unmarked`, `closedRounds` and `closed`.
- **`/books` positions and closed positions are per wallet**, so `openPositions` and
  `closedRounds` count one per wallet and token. A position's `book` is the key it belongs to (the
  wallet's bound key, else the key whose buy opened it). New on each position: `wallet`,
  `keyPrefix` and `dust` (worth under one cent; still listed). New on each closed position:
  `wallet` and `keyPrefix` (the key whose sale closed it). New top-level: `byAgent`, `costUnknown`
  (tokens that arrived with no cost anyone can state) and `movedOutUntracked` (tokens that left a
  wallet for somewhere Candle does not follow, at cost, never as a sale).
- **New on every scope** (`/books` `all`, `solana`, `hood`; each `/keys/{prefix}/pnl` total,
  wallet and chain): `openPositionsExDust`, and `closed`: `madeUsd`, `lostUsd`, `partialSellsUsd`,
  `wins`, `losses`, `costUnknown`, where made, minus lost, plus partial sells, equals
  `realizedNetUsd`. A key's figures add `totalUsd` and `closedRounds`.
- **The dashboard** keeps its shape and gains `openPositionsExDust`. It is public by address and
  never returns the agent split, wallets or positions.

## 2026-10-02: an agent has one name, its key's label; `publicName` is refused and no longer shown

`PATCH /api/v1/agent/keys/{prefix}/public-profile`, and every public surface that names an agent
(`GET /api/v1/agent/leaderboard`, `GET /api/v1/agent/records/{profileId}`, the public tape). Staging
first; alpha on the next promote.

- **The key's label is the agent's name.** Renaming a key (`PATCH /api/v1/agent/keys/{prefix}` with
  `label`) renames the agent on its public page at once and on the leaderboard from its next build.
  Public surfaces show the label as before: trimmed, cut to 32 characters, and replaced by the
  capability alias when it holds a control or bidi character.
- **`publicName` is refused.** Naming it in a `public-profile` body, with a value or null, is 400
  `VALIDATION_FAILED` with `field: "publicName"` and a message that the name is set by renaming the
  key. The other four fields are unchanged.
- **A stored `publicName` is no longer shown.** An agent that had one is now named by its label.
  No Candle client ever set it, so few or no agents change.
- **`display.name` in the `public-profile` answer is the label**, where it was the stored
  `publicName` (or null).

## 2026-10-02: public P&L is net of fees and counted per position; open positions join the win rate

`GET /api/v1/agent/leaderboard` and `GET /api/v1/agent/records/{profileId}`. Staging first; alpha on
the next promote. No field is removed or re-typed. Two fields are added; several change meaning.

- **Realized is after Candle's fees.** `totals.realizedNetUsd` is realized gross less the Candle fee
  on both sides of every trade the agent's key placed, as on the agent's private P&L. Until now the
  public figure carried no fee, though `totals.feesUsd` said otherwise. A trade reported through a
  key with no trade row (a Hood swap) still carries no fee. The figure moves for every published
  agent on the day this lands, and `totalPnlUsd` with it.
- **Wins, losses, best and worst are per closed position**, not per selling trade. A position is
  closed when the agent's holding of that token returns to zero through a sale; it is a win when
  its realized net is above zero and a loss when below. Two partial sells of one position are one
  count. `totals.counted` is closed positions. A sale of tokens the agent's record never saw bought
  is not a position and is in no count. A position that sold more than it was seen to buy is
  counted, and is left out of `wins` and `losses`.
- **New on every board entry: `openWins` and `openLosses`.** Open positions counted now, the same in
  every window: a position is a win when the realized net already booked on it (partial sells)
  plus its unrealized value is above zero, and a loss when below. Only positions that are priced
  and fresh count, and one showing an unrealized gain must also be liquid; dust (under one cent)
  does not. Two integers; no position is listed.
  `totals.wins` and `totals.losses` stay closed-only.
- **`winRatePct` is over closed and open positions together**: `(wins + openWins) / (wins + losses +
  openWins + openLosses)`, still null under 10 decided. Ranking and who ranks are unchanged: one
  closed position in the window.
- **Illiquid unrealized gains are left out of `unrealizedUsd`.** An open position showing a gain
  counts toward the public figure only when its token is still on a Candle bonding curve, or has at
  least $1,000 of liquidity stated within the last six hours. One left out sets `unrealizedPartial`,
  as an unpriced one does, and on a 24H, 7D or 30D window it adds no change. An open position
  showing a loss is counted whatever its liquidity. Realized P&L is never filtered.
- **Sizes.** Token amounts are scaled the way the private ledger scales them. A Solana agent trade
  whose raw amount nothing can scale is left out of every figure and counted in `totals.unvalued`,
  instead of being read as whole tokens.
- **Seasons** (`sinceMs` with `untilMs`) keep their per-trade replay and carry neither new field.

## 2026-10-02: Hyperliquid perps deposits through Relay

`POST /api/v1/agent/perps/deposit`, `POST /api/v1/agent/perps/deposit/submit` and
`GET /api/v1/agent/perps/deposit/:clientDepositId`. Additive.

- **Deposit** USDC onto the key's Hyperliquid account through Relay, paid in SOL or USDC from the
  key's Solana TEE wallet or ETH or USDG from its Hood TEE wallet. Candle resolves the account (an
  EVM TEE wallet's own address) and takes no recipient. Needs `swap:write` and a raw cap on the
  asset. No Candle fee. Refused below Relay's minimum and below a floor that covers Hyperliquid's
  1 USDC first-deposit charge. See "Depositing" in the agent trading reference.
- No withdrawal yet: `candle tee disable` and `tee sweep` do not reach Hyperliquid collateral.
- The SDK (`perpsDeposit()`, `perpsDepositStatus()`), the CLI (`candle perps deposit`) and the MCP
  server (`candle_perps_deposit`) carry the client side in source; they ship in SDK 0.4.7, CLI
  0.11.15 and MCP 0.10.6.

## 2026-10-02: SDK 0.4.6, MCP 0.10.5 and CLI 0.11.14 carry Hyperliquid perps

`@candledottv/agent-sdk` 0.4.6 adds `perpsSetup()`, `perpsOpen()`, `perpsClose()`, `perpsCancel()`
and the rest of the perps client, `@candledottv/mcp` 0.10.5 adds the `candle_perps_*` tools, and
`@candledottv/cli` 0.11.14 adds `candle perps`. These are the client side of the build routes in
the entry below. Each one checks a build (hash, action type and Candle's builder) before it is
signed and submits the signed action to Hyperliquid itself. Additive. Depositing to Hyperliquid
is not in these releases.

## 2026-10-02: Hyperliquid perps build routes

`/api/v1/agent/perps/*` and `PUT /api/v1/agent/keys/{prefix}/perps-limits`. Additive.

- **Build routes** for `setup`, `open`, `close`, `cancel`, `modify`, `leverage` and `margin`, each
  returning the plaintext Hyperliquid action, its nonce and the typed data a relay claim was
  stamped over. The client checks the build, signs through the existing relay
  (`eth_signTypedData_v4`, scope `perps:write`) and submits to Hyperliquid itself. See "Hyperliquid
  perps" in the agent trading reference.
- **Reads**: `positions`, `orders`, `fills`, `funding`, `config`, and `reconcile`.
- **Limits**: the key's USD window charged by notional, plus the new per-key `maxLeverage` and
  `maxPositionUsdMicros`. Every refusal names its `error.limit`.
- **`GET /api/v1/agent/keys/self/limits`** gains `perpsLimits` (`null` when unset).
- The SDK (`perpsOpen()` and the rest), the CLI (`candle perps`) and the MCP server
  (`candle_perps_*`) carry the client side in source; they ship in SDK 0.4.6, CLI 0.11.14 and MCP
  0.10.5.

## 2026-10-02: A position moved in from another Candle account keeps its cost basis

`GET /api/v1/agent/books`, and the dashboard P&L that reads the same ledger. Staging first; alpha
on the next promote. One optional field is added; none is removed or re-typed.

- **What changed.** When a member sends a token through Candle to a wallet that belongs to
  another Candle account (that account's own wallet, its embedded wallet, or one of its agents'
  wallets), the receiving account now holds those tokens at the sender's average entry for that
  token at the moment they left. The sender's open position falls by the same quantity. The move
  is not a trade: it realizes nothing on either side, charges no fee, and adds nothing to a fill
  count, a close or a win/loss figure. A later sale realizes against the carried basis.
- **What did not.** A move between wallets of one account was already invisible to the ledger,
  which holds positions per account, and still is. Tokens that arrive from a wallet Candle has no
  ledger for (an external wallet, an exchange), or by a path that records no transfer, still have
  no basis: a sale of them is clamped, and the closed round is still `basisIncomplete` with
  `basisIncompleteReason: "outside-ledger"`. Only the part of a move the sender's own ledger had
  a basis for is carried, so a round can be part carried and still flagged for the rest.
- **New optional field.** A `closedRounds[]` entry gains `transferredInQuantity` when part of its
  `quantity` arrived this way. `quantity` and `costBasisUsd` on that round include the moved
  tokens and their carried cost. An open position that arrived by transfer and was never bought
  on the account reports `buys: 0`, and `firstEntryTs` is when the sender opened it.
- **Figures that move.** An account that received such a move and sold the tokens showed a
  Partial round with the extra tokens' proceeds left out of Realized. Once the move is recorded,
  that round is whole and Realized includes those proceeds less the carried cost. Example: 603,660
  bought for $583.27, 21,428 moved in at the sender's $0.00095 average, 625,088 sold for $605.47.
  Before: Partial, realized gross $1.44. After: not Partial, realized gross $1.84, cost basis
  $603.63. The sender's open position, and the unrealized P&L marked on it, shrink by what left.
- **History.** Moves reported before this shipped are recorded by a one-off backfill, run per
  deployment by an operator. Until it runs, earlier rounds read as they did.

The public agent record and the agent board read a per-key ledger and are unchanged.

## 2026-10-02: Hood token prices in the portfolio are priced when you read them

`GET /api/v1/agent/portfolio`, `POST /api/v1/agent/prices`. Staging first; alpha on the next
promote. No field is added or removed.

- **A mark's age is when its price was written.** A Hood mark is fresh while its price is under 6
  hours old. Before, its age was the row's last update, which also moves on writes that carry no
  price, so a seven-hour-old price could read as fresh. `updatedAt` on a Hood entry is now that
  price time.
- **Priced on read, behind a server switch that is off until Candle turns it on.** With it on, a
  Hood token returned by the portfolio's own holdings read, with no mark or a mark over 6 hours old,
  is priced during `GET /agent/portfolio` (from the
  token's curve or pool for a Candle token, otherwise from Hood's market data provider) and the mark
  is kept for every later read. It answers `source: "market"` like any other mark. At most 20 tokens
  per request are priced this way; the rest keep their `unpricedReason` and are priced in the
  background within minutes, behind the same switch. `POST /agent/prices` stays on existing rows
  with the same freshness rule, and never calls the mark pricer or writes a row, with the switch on
  or off.
- **One shared provider limit.** The sweep and all portfolio reads share a process-wide cap of 4
  provider reads in flight, including graduated-token depth checks. Curve and pool chain reads do
  not take a slot. A token that cannot get a slot before the request deadline stays
  `source-unavailable` and makes no provider call.
- **What still reads unpriced, and why.** A price the provider has not confirmed for 7 days is
  `stale-mark`. A price whose deepest ETH, WETH or USDG pool holds under 500 USD, or a token whose
  only stated liquidity is in pools against other tokens, is `unusable-price`. The same 500 USD
  floor applies to a Candle token that has graduated to a pool, measured on the depth Hood's market
  data provider states for it; if the provider has no record, or states no depth, the token is
  priced from its pool. A token still on its curve has no floor. A read that fails or
  runs out of time is `source-unavailable`: the request itself never fails for it.

## 2026-10-01: SDK 0.4.5 and MCP 0.10.4 read the portfolio and per-chain P&L

`@candledottv/agent-sdk` 0.4.5 and `@candledottv/mcp` 0.10.4 type and expose the two reads in the
next entry. Additive: no existing method, tool or field changes.

- **SDK `getPortfolio()`** reads `GET /api/v1/agent/portfolio`, typed as `PortfolioResult`: the
  Solana wallets in `embedded` and `tee`, the Hood wallets in `hood`, `chain` on every wallet and
  holding, and `prices` with Hood's `unpricedReason`. Needs a key with `account:read`.
- **MCP `candle_get_portfolio`** is the same read as a tool, which brings the server to twenty tools.
- **SDK `getProfilePnl()`**: `ProfileOpenPosition.chain`, `ProfileWalletPnl.chain`, and
  `pnl.byChain` (`ProfileChainPnl` per chain). The `candle_get_profile_pnl` description says the
  same.
- **Against a server without the next entry** (alpha until its next promote), `hood`, `chain` and
  `byChain` are absent. Each is typed optional for that reason.
- Vault holdings are not in either: Candle does not know vault addresses, so they stay in the CLI's
  `candle portfolio`, read over the operator's own RPC.

## 2026-10-01: Hood in the portfolio and per-chain P&L on a profile

`GET /api/v1/agent/portfolio`, `POST /api/v1/agent/prices`, `GET /api/v1/agent/keys/{prefix}/pnl`.
Staging first; alpha on the next promote. Additive: every existing field keeps its value.

- **`/agent/portfolio` gains `hood`**: `{ embedded, tee, unavailable, unpriced, unpricedByReason }`.
  `embedded` is the account's Hood wallet and `tee` its Hood TEE wallets (`id`, `address`, `label`,
  `active`). Each has `wei` (native ETH, raw) and `tokens` (`mint` EIP-55, `amountRaw`,
  `decimals`, `symbol`, `name`), plus `truncated` when the wallet holds more than 200 tokens. A
  failed read is `null`, never zero, the address is in `hood.unavailable`, and `complete` is false.
  `embedded` and `tee` at the top level stay Solana-only, because older clients read every row
  there as a Solana wallet.
- **`chain` on every wallet and holding** in the portfolio, `"solana"` or `"hood"`.
- **Hood prices in `prices`**, keyed `hood:native` and `hood:<lowercased address>`. ETH, WETH and
  USDG carry `source: "reference"` (ETH from Hood's WETH/USDG pools, USDG at its peg). Any other
  Hood token uses Candle's market mark only when it is under 6 hours old; otherwise `priceUsd` is
  `null` with `unpricedReason` (`no-market-row`, `unusable-price`, `stale-mark` or
  `source-unavailable`), never zero. `hood.unpriced` counts the Hood holdings with no price and
  `hood.unpricedByReason` says why. Hood marks are refreshed only when a token trades, so most held
  Hood tokens read unpriced today.
- **`/agent/prices` accepts `hood`** beside `mints`: Hood token addresses or `"native"`. Either list
  may be left out; together they are 1 to 100. Hood entries answer under `hood:` keys.
- **`/agent/keys/{prefix}/pnl`**: `chain` on each `openPositions` entry and each `byWallet` row (and
  its positions), and `pnl.byChain: { solana, hood }`, the total's figures over each chain's fills
  alone, with `openPositions` as a count. Both chains are always present.
- **Fix: Hood trades were missing from a profile's P&L.** A Hood trade and its fill record spelled
  the token address in different cases, so every Hood trade placed since 2026-09-07 failed to join
  and was left out of `/agent/keys/{prefix}/pnl`, and its fill in `/agent/books` carried no book and
  no fee. Both now join. A Hood swap an agent executes itself and reports through `/activity/report`
  has no trade record: it stays out of the profile's P&L and is in `/agent/books` with no book.

## 2026-10-01: the agent leaderboard can rank on realized plus unrealized P&L

`GET /api/v1/agent/leaderboard`. Staging first; alpha on the next promote. Additive: with no
`rankBy` the board's order and every existing field are what they were.

- **New: `rankBy=realized|total`** (default `realized`). `total` orders the 24H, 7D, 30D and
  all-time board by realized plus unrealized P&L. Any other value is `400 VALIDATION_FAILED` with
  `field: "rankBy"`. The board echoes `rankBy`: the key it was actually ordered by.
- **New on every entry, whatever `rankBy`:** `unrealizedUsd`, `totalPnlUsd`
  (`totals.realizedNetUsd + unrealizedUsd`), `markedAt` (the oldest price used, ms; absent when
  nothing was marked), `unrealizedPartial` (true when anything was left out) and `totalRank` (the
  rank by `totalPnlUsd`). `rank` stays the realized rank. One aggregate per agent: open positions,
  their tokens, sizes and entry prices are never listed.
- **Windows.** On all-time, `unrealizedUsd` is unrealized now. On 24H, 7D and 30D it is the change
  in unrealized over the window, per token: now minus the hourly snapshot at the window's start.
  A token unpriced at either end adds 0 and sets `unrealizedPartial`. Snapshots start with this
  release, so a window that starts before the first snapshot is partial until they cover it (30
  days for 30D).
- **Stale prices.** A price older than 6 hours is not used: the position is left out and
  `unrealizedPartial` is true. It is never valued at zero or at its old price.
- **Seasons** (`sinceMs` with `untilMs`, or a `sinceMs` that is not a rolling window) stay realized
  only: `rankBy=total` is ignored there and the board echoes `rankBy: "realized"`.
- **Eligibility (BE-621, unchanged here).** An agent ranks with one counted close in the window
  viewed, under either key; unrealized P&L is not a close. `minCloses` is fixed at 1 and deprecated.
  A win rate still needs 10 decided closes (`wins + losses`).

## 2026-09-30: a profile's P&L split by paying wallet

`GET /api/v1/agent/keys/{prefix}/pnl` (`candle_get_profile_pnl`, SDK `getProfilePnl`). Staging
first; alpha on the next promote. Additive: every existing field keeps its value.

- **New: `pnl.byWallet`**, one row per wallet that paid for a trade in the window (the key's main
  wallet or a linked one): `wallet`, `payerType`, `linkedWalletId` (linked only), `label` (the
  linked wallet's own label, or null), the same figures as the total, and `tradesConsidered`.
  Ordered by `tradesConsidered`, then address.
- **Each row is an independent average-cost pool over its own fills**, so the rows need not sum to
  the total: a token bought in one wallet and sold from another, or two wallets holding the same
  token at different costs (A buys 100 X for $100, B buys 100 X for $200, A sells 100 X for $300:
  the total realizes $150, the rows $200). The total is unchanged and stays correct.

## 2026-09-30: a TEE rebind reports Hood trade readiness

`POST /api/v1/agent/tee-wallets/rebind`. Staging first; alpha on the next promote. Additive.

- **New: `toKey.hoodTradeReady`** (`{ eth, usdg }`), by the same rule as `tradeReady`: true unless
  the key sets a raw cap of zero for that asset. `missingCaps` now also names a zero ETH or USDG cap
  (`spendLimits.eth`, `spendLimits.usdg`).
- **New: `chain` on each `rebound` row** (`"solana"` or `"evm"`), so a client knows which quote pair
  applies to each moved wallet.

## 2026-09-30: feed rows carry `stats` as buy and sell counts only

`GET /api/v1/markets/feed` and the feed stream, when served by the feed engine. Staging first;
alpha on the next promote.

### Changed

- **`stats` now holds `numBuys` and `numSells` for `m5`, `h1` and `h24`.** The `h6` window and the
  other per-window fields (`priceChange`, `volume`, `volumeChange`, `holderChange`,
  `liquidityChange`, `numTraders`) are no longer on feed rows. They changed on every refresh and
  were most of each refresh batch sent to every stream subscriber. Price, volume and holder moves
  are on the row already (`priceUsd`, `change1h`, `change24h`, `priceChange5m`, `volume5m`,
  `volume1h`, `volume24h`, `holderChange5m`, `holderChange1h`, `holderChange24h`).
- A feed stream patch that would only have changed the removed fields is no longer sent.

## 2026-09-29: forensics names the launchpad or issuer, and answers for indexed Solana mints

`GET /api/v1/markets/:chain/:mint/forensics` (`candle_token_forensics`). Staging first; alpha on
the next promote. Additive: no field is removed or renamed.

### Added

- **`deployer.attribution`**: `{ kind: "launchpad" | "issuer", name }` or `null`, set only when
  `deployer.address` is null. `launchpad` only when the indexer `dev` is a known launch-service
  wallet (stonk.fun: its wallet is the pool creator, fee payer and signer, so the person is not
  on chain). A mint count or a launchpad id alone does not set it. `issuer` for tokenized stocks,
  from Jupiter's `xstocks` tag or a known issuer `dev` (`xStocks`, `Backpack Securities`), never
  from the mint authority. An issuer wins over a launchpad. `deployer.reason` says the same in a
  sentence. Candle launches and every report with a resolved developer carry `null`.

### Changed

- A Solana mint absent from Candle's feed and launch table no longer returns `MARKET_NOT_FOUND`
  when Jupiter's index knows that exact mint: it gets the same partial external report as a feed
  token (`coverage.reason: "external_launchpad"`). `MARKET_NOT_FOUND` with `unknown_mint` remains
  for mints nobody indexes. Hood is unchanged.

## 2026-09-29: `clientSwapId` is a durable idempotency key

`POST /api/v1/agent/swap`. Staging first; alpha on the next promote.

### Changed

- **`clientSwapId` is now a Convex-backed ledger** (`agentSwaps`, keyed on
  `(userAddress, clientSwapId)`), matching `clientLaunchId` and `clientTradeId`. The same id
  with the same `from`, `to`, `amountRaw`, and effective `maxSlippageBps` (omitted means 100)
  replays the original result and does not sign or broadcast again. The same id with a different
  body, including a different slippage, is `IDEMPOTENCY_CONFLICT`. An in-flight replay is also
  `IDEMPOTENCY_CONFLICT` with `retryable: true`. Omitting the id is unchanged: no coalescing,
  no ledger row.
- A timeout is **unknown**, not failed. Retry the same request (same id, same body, same
  slippage). The replay returns the stored result. An indeterminate first leg is stored on the
  submitted row and replayed as `SWAP_FAILED` (`retryable: false`) with the original message,
  including the signature. Verify that signature on-chain before a new id. Do not retry a
  timed-out call that omitted `clientSwapId`.
- A confirmed first leg whose later leg does not finish (including a signing refusal on leg 2)
  is stored and replayed with `payload.leg1` and `retryable: false`. That retry does not execute
  leg 1 again. `retryable: true` on the first `LEG2_FAILED` means send leg 2 as a new request.

## 2026-09-28: optional TEE limits and repeated buys

`POST /api/v1/trade/agent/build` and `POST /api/v1/agent/swap/build` with a TEE wallet payer, the
other TEE spend routes, and agent risk state. Staging first; alpha on the next promote.

- Only the limits a key's owner set apply, matching the key editor, where both are optional. A TEE
  trade or swap needs neither a per-asset raw spend cap nor a USD `txLimit`: a missing one is
  unlimited, and a configured one, including a raw cap of zero, still applies. With no `txLimit`,
  nothing is valued or reserved against a USD window.
- The USD `txLimit` is no longer required on any TEE route. TEE transfers, launches and LP deposits
  still need a raw cap for each asset they spend (`SPEND_LIMIT_EXCEEDED`, `no_raw_cap`).
- `KEY_LIMIT_REACHED` with reason `no_tx_limit` is no longer returned. A rebind's `tradeReady` is
  true unless a raw cap is zero, and `missingCaps` no longer lists `txLimit`.
- Buying the same token more than five times without selling no longer trips a profile. Existing
  `same_mint_buys` trips cease to block trades or appear as active stops. Trade-rate, failed-sell,
  and owner-set loss limits still apply.

## 2026-09-28: a per-key embedded-wallet permission, and new keys are denied by default

`/api/v1/agent`, `/api/v1/trade/agent` and `/api/v1/launch/headless`. Staging first; alpha on the
next promote. **Breaking for a client that mints keys and then spends the embedded wallet with
them.** Keys issued before this change are unaffected.

- **Each API key carries `embeddedWallet`, `allowed` or `denied`.** A `denied` key is refused with
  `EMBEDDED_WALLET_NOT_PERMITTED` (403, not retryable) before anything is signed, on every route
  that spends from, signs for, or launches with the account's embedded wallet: trades with
  `payer: {"type": "main"}` (build and execute), the one-shot `POST /api/v1/agent/swap`,
  `POST /api/v1/agent/transfer`, `POST /api/v1/agent/wallets/embedded/close-empty`, atomic
  launches with a main leg, headless launches and their dry runs, and Hood metadata edits. TEE and
  linked payers, paper trades, quotes and reads are not refused.
- **Changed: `POST /api/v1/agent/keys` without `embeddedWallet` creates the key `denied`.** Send
  `"embeddedWallet": "allowed"` for a key that launches headlessly or trades the embedded wallet.
  The response echoes the stored value. A key with no stored value (every key issued before this
  change) reads as `allowed`.
- **Changed: device authorization.** The approval page asks whether the key may use the embedded
  wallet, unchecked by default, and the key the device-token exchange issues carries that answer.
  The exchange's `apiKey` object gains `embeddedWallet`.
- **New `PUT /api/v1/agent/keys/self/embedded-wallet`** (the API key, any scope): `{ "permission":
  "denied" }` only. `allowed` is `LOOSEN_REQUIRES_SESSION` (403).
- **New `PUT /api/v1/agent/keys/{prefix}/embedded-wallet`** (a Privy session or the CLI device
  token): both directions, with `dryRun` and an `expect` compare-and-swap (`KEY_ACCESS_STALE`, 409).
  Changes appear in `GET /api/v1/agent/keys/{prefix}/access-changes` with `change: "embedded_wallet"`.
- **Additive:** `embeddedWalletPermission` on `GET /api/v1/agent/wallets/embedded`,
  `payerPermitted: false` on a quote from a denied key, and `embeddedWallet` on `GET /api/v1/agent/keys` rows.

## 2026-09-26: key signers, so the machine holding an API key trades that key's TEE wallets

`/api/v1/agent`, and the CLI release after 0.11.9. Staging first; alpha on the next promote. A key
with no signer behaves exactly as before.

- **New `POST /api/v1/agent/keys/self/signer/request`** (the API key, any scope). Takes
  `{ publicKeyDer }`, the base64 SPKI DER of a P-256 key generated on the trading machine. Creates
  a `pending` request for 10 minutes and returns `fingerprint` (`CNDL-XXXX-XXXX-XXXX`, display
  only), `spkiSha256`, `userCode`, `verificationUri` and `verificationUriComplete`. A new request
  supersedes the pending one. An API key can only request a signer, never register one.
- **New `POST /api/v1/agent/keys/:prefix/signer/approve`** (a Privy session or the CLI device token
  only; an agent key is refused). Takes the `userCode` and the full fingerprint string, and approves
  or rejects. Approval creates the key's Privy signer quorum and supersedes its previous signer.
- **New `GET /api/v1/agent/keys/:prefix/signer`** (the API key for `self` or its own prefix, a
  session, or the device token). `state` (`active`, `pending`, `none`), `fingerprint`,
  `spkiSha256`, `publicKeyDer`, `signerQuorumId` once active, `privyAppId`, and the key's TEE
  wallets as `onSigner`, `legacy` and `moving`.
- **New `POST /api/v1/agent/wallets/:id/owner`**, the owner relay (the API key the wallet is bound
  to, or the device token). Forwards a Privy owner change signed by the wallet's current owner,
  whose body is exactly `{ owner_id }` and whose only permitted value is the bound key's active
  signer quorum. Not stopped by the trading kill switch; it creates no spend.
- **Changed: `POST /api/v1/agent/wallets/import/submit`** takes `keySigner: true` with the pinned
  `spkiSha256` when the target key has an active signer: the wallet is owned by that key's quorum
  and bound to it in the same call. `POST /api/v1/agent/tee-wallets/rebind` onto a key with a
  signer changes the owner and the binding together.
- **Additive:** `signerQuorumId` on `GET /api/v1/agent/wallets/trading` rows, and `privyAppId` on
  a rebind preview that names owner changes.
- **New error codes:** `KEY_SIGNER_MISSING` (409), `KEY_SIGNER_CHANGED` (409, the pinned full hash
  is not the key's active signer), `KEY_SIGNER_ALREADY_ACTIVE` (409), `KEY_SIGNER_CODE_INVALID`
  (404, unknown, used or expired code), `KEY_SIGNER_FINGERPRINT_MISMATCH` (400),
  `KEY_SIGNER_OWNER_INVALID` (403), `KEY_SIGNER_READBACK_MISMATCH` (409, Privy's read-back did not
  show the target owner and the recorded policy; nothing was recorded),
  `KEY_SIGNER_SIGNATURE_REQUIRED` (409).
- **CLI:** `candle tee signer new`, `keys signer approve`, `keys signer move`, and `vault promote
  --to-key` and `tee rebind` onto a key with a signer. Existing wallets keep trading from the
  machine that holds their current owner until `keys signer move` runs there.
- **Formats (CLI):** `config.json` gains `keySigners`, and the secret store gains
  `key_signer_<prefix>_<spkiSha256>` refs; an older CLI ignores both. A wallet owned by a key
  signer trades only from a CLI with this release. The same release moves a vault to `vault.enc`
  version 4 when it creates its first Hood TEE wallet, which 0.11.9 and older refuse. See "CLI
  release notes: formats" on the docs site.

The headless setup (no device token on the bot box, the secret store under systemd, launchd and
SSH) and the SDK's use of the `--out` PEM are on the docs site: "Run a bot on a headless machine"
and "SDK: trade with a key signer PEM".

## 2026-09-26: a raised `txLimit` applies to an open TEE wallet window at once

The TEE wallet admission gate on `POST /api/v1/trade/agent/build`, the TEE swap and transfer
routes, and self-signed launch. Staging first; alpha on the next promote, together with the entry
below, which it supersedes in part.

- **Changed: a raise applies at once.** The gate judges an open window against the key's current
  `txLimit` in both directions. Before this, a raise waited for the next window while a cut applied
  at once. Budget the window has already reserved still counts, so a raise adds exactly the
  difference. Only the key's owner can raise a limit; an agent key can still only lower it.
- **Changed: `emberWindow`.** `capUsdMicros` is now always the key's current `txLimit`, and
  `nextWindowCapUsdMicros` equals it (kept for compatibility).
- **Changed: TEE wallet `KEY_LIMIT_REACHED` hints** name the owner raising the limit as a way out
  again, and the over-cap message no longer carries a "next window" clause.

## 2026-09-26: `emberWindow` on the keys list; TEE wallet limit refusals stop suggesting a raise

`GET /api/v1/agent/keys` and the TEE wallet admission gate on `POST /api/v1/trade/agent/build`,
the TEE swap and transfer routes, and self-signed launch. Staging first; alpha on the next
promote.

- **Additive: `emberWindow` on every key row.** The key's open TEE wallet budget window:
  `windowKey`, `capUsdMicros`, `reservedUsdMicros`, `remainingUsdMicros`, `resetsAt`,
  `nextWindowCapUsdMicros`. `remainingUsdMicros` is what the gate will admit. `null` when there is
  no open window; absent when it could not be read. See `docs/agent-trading.md`.
- **Changed: TEE wallet `KEY_LIMIT_REACHED` hints.** A raised `txLimit` applies from the next
  window, not the open one, so these refusals no longer carry the generic "raise the limit on the
  key" hint. The over-cap refusal now carries `resetsAt`, and after a raise its message names the
  window's cap. The reservation rule itself is unchanged.

## 2026-09-25: stored mint facts in forensics `safety`; `tokenRisks` and `/tokens/:mint/security` removed

`GET /api/v1/markets/:chain/:mint/forensics` and `GET /api/v1/tokens`. Staging first; alpha on the
next promote.

- **Additive: `safety.mintFacts`, `safety.mintChanges`, `safety.sellProbe`.** hacc's latest stored
  mint read, its newest 20 recorded authority and trap changes, and the latest runner sell probe,
  camelCased. Informational only: none of them changes a flag, `summary` or `coverage`. Each is
  `null` / `[]` when absent or malformed, and always so for base assets and Hood. **`sellProbe` is
  never sellability**: read `safety.flags.sellability`. See `docs/agent-trading.md`.
- **Removed: `forensics.tokenRisks`.** It was never documented. Read
  `safety.flags.tokenExtensions`, which is now the report's only extension verdict. A transfer fee
  at or below 300 bps reads `clear` there, with the fee in `value`. Quote, trade, LP and transfer
  responses keep their own `tokenRisks` unchanged.
- **Removed: `GET /api/v1/tokens/:mint/security`** (Birdeye). It was undocumented and had no
  caller in Candle's clients; it now answers 404. `GET /api/v1/tokens/:mint/details` still returns
  the same `security` block.
- **Label.** The feed's `mintAuthorityDisabled`, `freezeAuthorityDisabled`, `isSus` and
  `devBalancePercentage` are from Jupiter tokens v2 `audit`. Their values and filters are unchanged.

## 2026-09-24: change a key's access level in place

`/api/v1/agent`. Staging first; alpha on the next promote. Convex before the API: an API that
reaches an older Convex answers 503 `SWAP_FAILED` (retryable) and changes nothing.

- **New `PUT /api/v1/agent/keys/:prefix/access`**, `{ access: "read" | "readwrite" |
  "readwritetransfer", dryRun?: boolean, expectScopes?: string[] }`. A Privy session or the CLI
  device token only: an agent API key gets 401. Moves one key between the three access levels
  keeping its prefix, secret, profile, wallets, caps and `txLimit`; after the change the stored
  scopes equal the level's list exactly. `dryRun: true` previews and writes nothing. A commit
  requires `expectScopes`, the stored scopes the preview showed, compared as a set; a mismatch is
  409 `KEY_ACCESS_STALE` with the current `scopes`. The response carries `direction` (`widen`,
  `narrow`, `unchanged`, decided by the server from the stored scopes), `from`, `to`, `added`,
  `removed`, advisory `effects` and `warnings`, and on a commit `changeId` and `at`. A key
  holding `entitlement:read` or `hacc:sync` is 409 `KEY_ACCESS_PARTNER_KEY`. Widening to
  Withdraw grants no caps: a linked-wallet transfer still needs a spend cap and a
  `txLimit`, which the preview names.
- **New `PUT /api/v1/agent/keys/self/access`**, the same body, authenticated by the key itself
  (any scope, not stopped by the kill switches or a pause). Narrows only: a widening is 403
  `LOOSEN_REQUIRES_SESSION`. There is no prefix, so a key cannot change another key.
- **New `GET /api/v1/agent/keys/:prefix/access-changes?limit=`**: the key's changes, newest first,
  with `actor` (`session`, `device`, `agent`), `actorDevicePrefix` and `actorKeyPrefix`.
- **New webhook events** `key.access_widened` and `key.access_narrowed`, one per committed change.
- **The sign relay and `POST /transfer/submit`** now refuse a Withdraw transfer built
  before the key was narrowed: the relay answers 403 `SCOPE_MISSING` ("This key no longer holds
  transfer:bound; nothing was signed") and calls the signer zero times; submit answers 400
  `VALIDATION_FAILED`, not retryable, with the record consumed.
- **`PATCH /api/v1/agent/keys/:prefix`** is unchanged; access is its own route.
- The 2026-09-22 note that keys issued before `account:read` existed "keep their scopes until
  revoked" now reads "until the owner changes them": the revoke-and-reissue workaround is no
  longer needed.

## 2026-09-24: the owner can mark linked wallets trusted

`/api/v1/agent`. Staging first; alpha on the next promote.

- **New `POST /api/v1/agent/linked-wallets/trust`**, `{ walletIds: string[], trusted: boolean }`,
  1 to 200 ids. A Privy session or the CLI device token only: an agent API key gets 401. Returns
  `changed`, `unchanged` (already in that state), `sessionLinked` (a wallet linked while signed
  in: always an own destination, so the call writes no mark and clears none; revoke it to remove
  it) and `skipped` (`{ walletId, reason }`, reason `not_found` for an id that is not this
  account's, `revoked` for a revoked row). Idempotent. A session-linked row does not fire
  `linked_wallet.trusted` or `linked_wallet.untrusted`.
  A preview, `{ dryRun: true, wallets: ["<id|address|label|prefix*>"], trusted }`, resolves
  selectors against the account's active linked wallets and writes nothing; the CLI uses it.
- **What trust changes.** Since 2026-09-24 (BE-325) only a wallet linked in a signed-in session
  was one of the account's own destinations; an agent-imported wallet needed the withdrawal
  allowlist and was capped as an outflow. A wallet the owner marked trusted is now own too on
  `/transfer`, `/transfer/build` and `/swap/build`: `destinationClass: "own"`, no cap. A TEE-origin
  `/transfer/build` uses that same rule for `destinationKind: "linked"`. Revoking a wallet ends
  its trust, and re-importing the address starts untrusted.
- **`GET /api/v1/agent/wallets`** rows carry `trustedAt` (ms) and `trustedVia` (`session` or
  `device`) when marked; both absent otherwise.
- **New webhook events** `linked_wallet.trusted` and `linked_wallet.untrusted`, one per request,
  listing the wallets that changed.

## 2026-09-24: Withdraw, a key that can move funds out of the wallet it runs

A new scope, **`transfer:bound`**, and nothing else changes for existing keys: `transfer:write`
stays in Write, no default grants the new scope, and no key minted before today holds it.

- **`POST /api/v1/agent/keys`** accepts `transfer:bound` when `transfer:write` is named beside it;
  a list naming `transfer:bound` alone is `VALIDATION_FAILED` ("transfer:bound requires
  transfer:write"). **Withdraw** is exactly Write plus `transfer:bound`.
- **`POST /api/v1/agent/device/code`** refuses `transfer:bound`, with or without `transfer:write`
  (`VALIDATION_FAILED`, "transfer:bound is not available on device approval yet"), until the
  approval page that labels the scope has been in production long enough. A device login that
  omits scopes never requests it.
- **`POST /api/v1/agent/transfer/build`** admits a TEE wallet as the ORIGIN when the calling key
  is the one bound to it and holds `transfer:bound` (and `transfer:write`, which the route still
  requires). From such a wallet, `to` may be only that wallet's own pinned vault (`vaultDestination`,
  `destinationKind: "vault"`, any asset, `amountRaw: "max"` allowed, not spend-gated) or another
  ACTIVE linked wallet on the account that the owner linked while signed in or marked trusted
  (`destinationKind: "linked"`, base assets only, valued and reserved against the bound key's raw
  cap and USD `txLimit` exactly like a TEE trade). The embedded wallet, an approved withdrawal
  address, a wallet an API key imported that the owner has not marked trusted, and anything else
  answer `TRANSFER_DESTINATION_NOT_APPROVED` before anything is built. `to` equal to the source is
  `VALIDATION_FAILED`. A SOL amount above the per-wallet ceiling, including a `"max"` that
  resolves above it, is `SPEND_LIMIT_EXCEEDED` naming the ceiling. The build and submit bodies
  gain `destinationKind` for a TEE origin only; every other origin's body is unchanged.
- **`POST /api/v1/agent/transfer/submit`** re-reads the source for a TEE origin and refuses
  (`VALIDATION_FAILED`, not retryable) when the wallet was disabled or moved to another key since
  the build; the build is consumed.
- **`transfer.executed`** gains `walletId` (the origin linked wallet, every linked origin) and
  `destinationKind` (TEE origins). `destinationClass` keeps its two values.
- Reuses existing codes only: `VALIDATION_FAILED`, `TRANSFER_DESTINATION_NOT_APPROVED`,
  `TRANSFER_ASSET_NOT_SUPPORTED`, `SPEND_LIMIT_EXCEEDED`, `KEY_LIMIT_REACHED`, `TRANSFER_FAILED`.

## 2026-09-24: a Hood launch Candle cannot run says so

`POST /api/v1/launch/headless` and `/dry-run`, Hood only.

- **New code `HOOD_LAUNCH_UNAVAILABLE`, HTTP 503, `retryable: true`.** A Hood launch that cannot
  run because Candle's own configuration is incomplete (a missing signer key, RPC, or Hood
  contract address) used to return 500 `LAUNCH_FAILED` whose message named a server environment
  variable, for example `HOOD_BELIEVER_SIGNER_KEY is not defined`. It now returns this code with
  a fixed message. Nothing was broadcast, the attempt is recorded as failed, and retrying later
  with the same `clientLaunchId` starts a clean attempt. A real deploy failure is unchanged: still
  `LAUNCH_FAILED`.
- **Error catalog**: filed under `availability`.

## 2026-09-24: LP valuation on `/agent/portfolio`, `/agent/books` and `/agent/keys/{prefix}/pnl`

- **`GET /api/v1/agent/portfolio`** gains `lp: { positions, unreadable, complete }`. Each entry
  is a TEE wallet's Meteora DAMM v2 position: its share of the pool reserves and its unclaimed
  fees per token (raw units and decimals), the wallet holding it, and `valueUsd` at the same
  marks as the token rows (`null` when a side is unpriced, never a half). A position NFT that was
  resolved leaves that wallet's `tokens`; a candidate that is no position stays a token. A position
  whose pool could not be read is in `lp.unreadable`, and `complete` is false.
- **`GET /api/v1/agent/books`** and **`GET /api/v1/agent/keys/{prefix}/pnl`** gain an `lp` section
  beside the token figures (never inside `all` or `pnl`, which stay the web chart's numbers):
  `realizedUsd` (withdrawals net of the cost basis they realized, plus claimed fees),
  `unrealizedUsd` (open positions at current value against their remaining basis), `holdValueUsd`
  and `vsHoldingUsd` (the deposited tokens at today's marks, and the difference), the counts
  `valued`, `unpriced`, `unreadable`, `closedOutsideLedger`, and one row per open position with a
  `status`. A position the ledger shows open but the chain no longer has (a sweep close writes no
  `/confirm`) is `closed-outside-ledger` and never valued. The per-key route narrows the ledger to
  that key's operations. A failed LP read is `lp: { read: false, reason }` and the token figures
  still answer.
- **LP builds** for a remove now record the withdrawn share on the operation row, so the ledger can
  realize the cost basis in proportion; a remove written before this is taken as a full withdrawal
  and flagged `basisIncomplete`.

## 2026-09-23: move a promoted TEE wallet to another API key

Two owner-only routes, on the same `keysAuth` pair that manages keys (a Privy session or a
device token; never an agent API key):

- **`POST /api/v1/agent/tee-wallets/rebind`** moves 1 to 200 TEE wallets to another key on the
  same account, all or nothing. Preview with `dryRun: true` and selectors (`wallets` by id,
  address or label; `labelPrefix`); commit with `walletIds` and `expect`, the binding shown for
  each id (a compare-and-swap, `REBIND_STALE` on a race). Nothing changes on Privy; the address,
  key material, funds, exposure record, exit pin and `allowLaunch` stay. See "Moving a TEE wallet
  to another key" in the headless launch reference.
- **`GET /api/v1/agent/tee-wallets/rebinds`** lists the account's rebinds with the device prefix
  that did each one.
- **New codes**: `REBIND_TARGET_INVALID` (400, with `reason`), `REBIND_WALLET_INVALID` (400,
  with `reason`; `ambiguous` lists `matches`), `REBIND_TOO_MANY` (400), `REBIND_STALE` (409),
  `REBIND_BUILD_OPEN` (409, `walletIds`, `retryAfter`), `REBIND_FORWARD_OPEN` (409, `walletIds`,
  no clock; `reason: "unexpirable"` for a Hood claim), `REBIND_SCOPE_FULL` (409, `max`),
  `REBIND_RECONCILE_INCOMPLETE` (503, retryable; nothing changed).
- **The `key_not_bound` message changes** to "This TEE wallet is bound to a different API key;
  the account owner can move it with candle tee rebind". The code is unchanged.
- **The sign relay** now records what Privy answered for each consumed claim before it returns
  the bytes, and bounds the forward at 15 seconds; a timeout returns no signature. A signed
  payload's lifetime (`recentBlockhash`, `lastValidBlockHeight`) is recorded at build time for a
  TEE wallet, and a TEE Solana build that cannot record it fails closed with retryable
  `SWAP_FAILED`.

## 2026-09-23: a failed trade says whether it reached the chain

`POST /api/v1/trade/agent/build` (main payer), `/execute` and `/submit` now report whether a
failed execution reached the chain, and a same-id replay never trades twice after it did.

- **New envelope fields**, additive and optional: `error.stage` (`not_broadcast`, `unconfirmed`,
  `reverted`, `executed`), `error.signature` (the transaction the stage describes, present exactly
  when the server holds its identifier), and `error.priorSignatures` (Hood legs that landed
  earlier in the same request). No `stage` means the request never reached signing.
- **New code `TRADE_REVERTED`, HTTP 409, `retryable: false`.** A trade that landed and reverted
  was a signature-less `SWAP_FAILED` 500 with `retryable: true` and a "Token creation failed"
  message. It now carries the signature, and the message names the revert (Jupiter's slippage
  error by name) and says only the network fee moved.
- **Replay after a revert.** The `clientTradeId` is settled: a same-id call returns the recorded
  `TRADE_REVERTED` and never signs again. It used to revive the row and trade again at a fresh
  quote, spending a second network fee. `/execute` on a reverted deferred row answers
  `TRADE_REVERTED` instead of `JOB_NOT_FOUND`.
- **Replay after an unconfirmed trade leg.** Once a signature (or, for a Hood trade whose hash
  never came back, `broadcastAt`) is stored, a same-id call to `/build`, `/execute` or `/submit`
  never signs or submits. With a signature it is the in-flight 409 plus `stage` and `signature`,
  `retryable: true` for 120 seconds after the broadcast and `false` after. With no signature it is
  500 `SWAP_FAILED`, `stage: "unconfirmed"`, `retryable: false`. Such a row is no longer expired
  into a rebuildable one by the 30-minute sweep.
- **`retryable: true` becomes `false`** for three sends that may have reached the chain: a Solana
  send that failed in transit, a client-signed Hood send that failed in transit, and a Hood trade
  sent through the account's wallet that failed without a recognized reason. All three are now
  `stage: "unconfirmed"`. A Hood approval that never reached the trade stays `retryable: true`,
  and its row may be rebuilt.
- **`/submit` Hood fee leg.** A fee transfer that fails after the trade landed is reported as
  `stage: "executed"` with the trade's hash, and the row is no longer failed.
- **`/confirm`** keeps `VALIDATION_FAILED` for a transaction that landed and reverted, and adds
  `stage: "reverted"` and the transaction as `signature`.

The SDK and CLI surface these fields in a later release; MCP returns the API body verbatim, so it
carries them now.

## 2026-09-22: Read and Write agent key scopes; Launch Only retired

The dashboard's key picker now offers exactly two choices, **Read** and **Write**. Launch
Only and Full Access are gone.

- A new scope, `account:read`, reads the whole account the owner can see, across every profile,
  and writes nothing. It is the whole of what Read grants (`scopes: ["account:read"]`). With it a
  key reaches the unscoped `GET /api/v1/agent/trades` and any `?profile=` on the account,
  `GET /agent/books`, `/agent/paper`, `/agent/transactions` and `/agent/workers` (all four were
  session-only), and every other account read: `/agent/keys/pnl`, `/agent/keys/:prefix/*`,
  `/agent/tier`, `/agent/wallets`, `/agent/tracked-wallets`, `/agent/lp/positions`. The hacc.fun
  tape needs no Candle scope, so a Read key reads it as well.
- Write is the former Full Access list plus `account:read`: `launch:write`, `launch:read`,
  `activity:write`, `swap:write`, `transfer:write`, `account:read`. It gains no write power.
- `account:read` is self-service (nameable on `POST /api/v1/agent/keys` and on a device approval)
  and rides in both omission defaults, so a key created with no `scopes` now carries it.
- A key whose only scope is `account:read` is refused with `SCOPE_MISSING` by every write and
  build: swaps, launches, transfers, LP builds, the sign relay, `POST /agent/heartbeat`,
  `DELETE /agent/withdrawal-addresses/:id`, and every management write.
- Reads are never blocked by `HEADLESS_LAUNCH_ENABLED`, `AGENT_TRADING_ENABLED`, or a profile
  pause.
- **Existing keys keep the scopes they were issued with until revoked.** Nothing is remapped. A
  legacy Launch Only key keeps launching and keeps its per-profile reads; it does not gain the
  account-wide view. To get one, revoke and issue a Read or Write key.

## 2026-09-19: paper sells close external inventory when Jupiter has no route

`POST /api/v1/trade/agent/build` with `paper: true` on an external Solana mint no longer
returns `MARKET_NOT_FOUND` / `jupiter_no_route` when Jupiter loses the route after the paper
buy (rug, drained pool, vol0). A paper sell (`amountRaw` or `percent: 100`) now debits the
paper book at zero quote proceeds instead of leaving the position stuck. Live sells are
unchanged: they still refuse with `jupiter_no_route`.

## 2026-09-19: forensics replaces risk scoring with token safety flags (breaking)

`GET /api/v1/markets/:chain/:mint/forensics` removes the entire `risk` object (tier, score,
factors, verdict) and `concentration`, with no compatibility window. Read `safety.summary`
and its six sourced flags instead: mintAuthority, freezeAuthority, tokenExtensions, lpLock,
sellability, liquidityDrain. Unknown means unmeasured, not clean. `launch.deployerLaunches`
is the inclusive informational count, never a warning. The coverage vocabulary replaces
`risk_tier` with `token_safety` and removes `concentration`.

The public route, limiter and measurement cache stay in place. hacc failures return HTTP 200
with unknown flags and an incomplete summary; feed liquidity still answers. Safety retries
degraded readings after five seconds. Both published skills and the MCP descriptions use the
flags in this release. Indexed external Hood tokens now receive a partial report too.

## 2026-09-18: paper trades do not require an embedded spend wallet

`POST /api/v1/trade/agent/build` with `paper: true` (or `"true"` / `1`) no longer returns
`AGENT_WALLET_MISSING` when the account has no embedded wallet on the market's chain. A paper
buy still credits this key's paper book; a later paper sell (`amountRaw` or `percent: 100`)
still closes it. The planner uses a quote-only stand-in payer because nothing is signed.

Live main-payer `/build` is unchanged: no embedded wallet still returns `AGENT_WALLET_MISSING`.

`GET /api/v1/agent/wallets/embedded` (and `candle_execution_status` / `candle_get_wallets`)
can still report `wallets.solana: null` for a key whose Privy identity wallet is no longer
the Candle account address: for example an OAuth account that later connected Phantom.
Agent-key binding now accepts the embedded spend wallet as proof the privyDid belongs to
that account, so a real delegated Solana wallet on the same Privy user is returned again.
A privyDid that does not carry the key owner's Solana address still fails closed.

## 2026-09-18: paper inventory drops a mint after a full Jupiter / ledger exit

`GET /api/v1/trade/agent/paper/inventory` (and the MCP read of that book) now treats a fully
exited paper bag as closed. After `percent: 100` or an `amountRaw` that zeros the position, the
mint is gone from `positions`, including external / pump.fun mints routed through Jupiter
(`quoteOnly`) and Candle-curve paper sells. A 50% exit leaves half the raw bag.

Two bookkeeping holes left closed rows after a successful sell credit:

1. `/build` only treated `paper === true` (boolean) as paper. `paper: "true"` (string) or `1`
   planned a live Jupiter swap and wrote `agentTrades` (the ledger) without a `paperTrades` sell
   fill, so inventory still showed the buy.
2. If `inventoryByKey` returned the raw fill rows as `positions` (Convex lag / an un-netted
   payload), each Jupiter-closed buy stayed visible as an open bag. The API now re-nets fill-shaped
   rows the same way `paperInventoryPositions` does.

Mints are trimmed (and Solana-canonicalized / Hood-checksummed) so a buy and a later exit hash
to one bag.

An `updateAvailable` banner that names an older MCP version comes from an MCP process that
started before the upgrade: restart it.

## 2026-09-18: staging-minted agent keys authenticate on staging.api

A key minted at `https://staging.candle.tv/agents?tab=keys` is stored in **staging** Convex
and is valid only at `https://staging.api.candle.tv`. It will never authenticate on
`https://api.alpha.candle.tv` (production Convex). The reveal footer now shows the
deployment and API origin (`staging · https://staging.api.candle.tv · scopes`) instead of
the key prefix family (`production`), which is `cndl_live_` vs `cndl_test_` and is not the
host. `POST /api/v1/agent/keys` echoes `deployment` and `apiBaseUrl`. The verifier trims
whitespace, quotes, and a `Bearer ` prefix on `x-api-key`.

Verify a new staging key:

```
CANDLE_API_URL=https://staging.api.candle.tv
curl -sS -H "x-api-key: $KEY" "$CANDLE_API_URL/api/v1/agent/wallets/embedded"
```

Expect 200, not `UNAUTHORIZED` / Invalid API key. MCP must set `CANDLE_API_URL` the same way;
`@candledottv/mcp` still defaults at `https://api.alpha.candle.tv`.

## 2026-09-18: MCP 0.9.1 publishes the paper-inventory sell path

`@candledottv/mcp` 0.9.0 on npm still sizes percent sells from the live embedded wallet and
amount-sells of unlisted mints from `GET /markets/:chain/:mint`. That is why a paper buy of a
pump.fun mint on **`api.alpha.candle.tv`** succeeded and the exit returned `MCP_VALIDATION`
("embedded wallet holds none") or `MARKET_NOT_FOUND`.

0.9.1 is that client. `candle_trade` paper percent never reads the wallet; a paper amount sell
of an unlisted mint uses `GET /api/v1/trade/agent/paper/inventory`. Do a new paper buy after
upgrading: fills recorded before the change may lack `tokenDecimals`.

## 2026-09-18: paper sells close inventory even when `paper` is omitted on the exit

`candle_trade` no longer blocks a sell just because the embedded wallet is empty or Candle has
no market row. If this key has a paper position for the mint (from a prior paper buy), a percent
or amount sell closes that book and posts `paper: true` to `/build`. Explicit `paper: true` still
skips the wallet read entirely. String `"true"` is accepted.

`GET /api/v1/trade/agent/paper/inventory` and paper `percent` on `/build` exist in this repo and
on staging. They are not on `api.alpha.candle.tv` until Promote to alpha (Convex, then API).
`@candledottv/mcp` 0.9.1 is the matching client (see the note above); 0.9.0 on npm still uses
the pre-inventory sell path. Retest on alpha after promote, not on staging.

## 2026-09-18: paper trades credit inventory, including external Solana mints

Paper mode on `POST /api/v1/trade/agent/build` now records a position a later paper sell can
close. A paper buy of a mint Candle never launched (Jupiter / pump.fun / stonk) stamps
`tokenDecimals` and `tokenAmount` on the paper fill. `GET /api/v1/trade/agent/paper/inventory`
returns this key's open paper book in raw units. A paper sell may pass `percent` (1–100) instead
of `amountRaw` to close that book; live sells still take `amountRaw`.

Jupiter paper plans quote only and do not build a signable swap transaction. The live path is
unchanged.

The MCP `candle_trade` tool follows the same loop: a paper percent sell no longer reads the live
wallet, and a paper amount sell of an unlisted mint uses the paper position's decimals instead of
returning `MARKET_NOT_FOUND`.

## 2026-09-18: from-holdings caches `devMints` and batches token reads

`POST /api/v1/agent/tracked-wallets/from-holdings` now applies the structural half of the
shared-authority guard when the market row carries Jupiter's `devMints` count (cached beside
`dev` on the same poll path). A launchpad the denylist has never heard of is skipped when that
count is present. The route also resolves Candle launch rows with one batched
`tokens.getMultiple` read instead of one `getByMint` per mint.

## 2026-09-18: forensics stops making claims from failed or partial reads

`GET /api/v1/markets/:chain/:mint/forensics`:

- `deployer.record` is now `null` when the indexer did not answer the prior-launch lookup, and
  `coverage.unavailable` names `deployer_history`. Previously a provider timeout or 429 produced
  a zeroed record that read as "first launch from this account". `deployer.priorLaunches`,
  `graduated` and `onCurve` are counted from that one list, so `graduated <= priorLaunches`.
- `insiders` and `cluster` are `unavailable` (never `partial`) unless the developer's funder is
  its genuine first inbound transfer and that funder is not an exchange-shaped account. The
  `note` says which rule stopped the check. Treat anything but `coverage: "complete"` as not
  checked.
- Concurrent requests for the same cold mint share one computation, and a request that has not
  answered in 30 seconds returns `BUILD_TIMEOUT` (retryable; the report finishes in the
  background and the retry is served from cache).

`POST /api/v1/agent/tracked-wallets/from-holdings` applies the denylist half of the
shared-authority guard. The structural half needs a mint count the cached market row does not
carry; the OpenAPI description now says so.

## 2026-09-17: on-chain developer forensics for coins Candle did not launch

`GET /api/v1/markets/:chain/:mint/forensics` now resolves the developer on-chain for Solana
tokens that launched elsewhere (pump.fun, stonkfun, and the rest of the feed). The shared
metadata `updateAuthority` those launchpads write is never reported as the "dev". The report
adds `deployer.record` (went-to-zero rate and last coins), `insiders` (same-funder share of
supply), and `cluster` (other deployers funded by the same source). `fundingLineage` stays
`null`; older clients should keep treating it that way.

`POST /api/v1/agent/tracked-wallets/from-holdings` watches the resolved developers of coins
in a bag. Same shared-authority guard: a bag of pump.fun coins does not subscribe the
launchpad PDA.

## 2026-09-16: the agent console moved to `/agents`

- **`/dev/agent` is retired.** The console every account manages its keys, wallets and books from
  now renders at `/agents` for any signed-in member (signed out, `/agents` is still the pitch).
  `GET /dev/agent` answers **307** to `/agents` with the query string preserved, so `?tab=keys`,
  `?tab=wallets` and `?tab=books` deep links keep working; `?tab=connect` opens the renamed
  **Skills** tab. `GET /.well-known/candle-agent.json` now reports `"createKeyAt": "/agents"`.
- **`/dev/agent/device` is unchanged.** It is still the RFC 8628 `verificationUri` that
  `POST /api/v1/agent/device/code` returns, and CLIs already in the field keep working.
- **No API change.** Every `/api/v1/*` route, payload and error is as before; only the page the
  docs pointed at for key issuance, delegation and the tier display moved.

## 2026-09-14: streaming retired

`GET/POST /api/v1/streams/*` now answer **410 Gone**, as do the two legacy aliases
`GET /api/v1/all-recordings` and `GET /api/v1/active-stream`. Recordings were archived off-platform
and deleted from Cloudflare; the live inputs no longer exist. There is no replacement endpoint.

Launch attribution is unaffected in shape but simpler in practice: with nobody broadcasting, a
token's streamer share goes to the launching dev, which is what `streamerFeeRecipient ?? creator`
already did. Tokens launched during a stream keep paying that streamer.

## 2026-09-12: Agent features are enabled by default

- **`GET /api/v1/users/:idOrWallet/agent` now reports `enabled: true` when the account has no
  `agentFeaturesEnabled` value.** An explicit admin-set `false` still reports disabled and blocks
  agent API access. Existing clients can continue calling `POST /api/v1/agent/enable`; the route
  remains idempotent and restores an admin-disabled account.

## 2026-09-10: SDK 0.4.1 sends the Hood `permit2Approval` leg from `trade()`

`CandleClient.trade()` for a linked Hood payer skipped `artifacts.permit2Approval`. It sent
`approval`, `trade` and `feeTransfer` only, so the Universal Router never received its Permit2
allowance and the trade leg could revert. An integrator found it with an offline reproduction
against SDK 0.4.0, not through a live loss.

### Fixed

- **`trade()` sends all four Hood legs** in `@candledottv/agent-sdk` 0.4.1. The order is
  `approval`, `permit2Approval`, `trade`, `feeTransfer`, each only when the build produced it.
  Nonces stay contiguous, and each leg's receipt is awaited before the next leg's gas estimate.
  This affects linked-wallet Hood trades whose input the Universal Router pulls through Permit2,
  such as v4 sells. Main-payer trades were never affected, because the server sends those legs.
- **The SDK's jsdoc describes four legs.** `HoodTradeArtifacts`, `SubmitTradeRequest`,
  `buildTrade()` and `trade()` all said three.

### Clarified

- **Hood's `submit()` opt-in is safe only without either approval leg.** The trade leg's
  `eth_estimateGas` reverts until the `approval` and `permit2Approval` allowances are mined.
  Take `submit()` for Hood only when the build carries neither.
- **Two leftover lists in the trading guide now name `permit2Approval`**: the `/submit` leg order
  and the `HoodTradeArtifacts` field list.

### Unchanged

- The `/build` artifact shape. The API already emitted `permit2Approval`.
- Callers who sign and broadcast Hood legs themselves, following the four-leg order in
  `docs/agent-trading.md`.

## 2026-09-09: Echo `quoteAsset: "eth"` into `/build`, and settle Hood DEX in USDG when asked

An integrator copied `/quote`'s `quoteAsset: "eth"` onto `/build` for a Hood main-payer trade and
got `400 VALIDATION_FAILED` (`quoteAsset must be one of "sol", "usdc", "cndl"`). Omitting the
field worked. The quote was telling the truth; the build validator was Solana-only.

### Fixed

- **`/build` accepts `"eth"` and `"usdg"`.** The closed set is now `sol`, `usdc`, `cndl`, `eth`,
  `usdg`. Echoing `/quote` into `/build` is safe. A Hood id on a Solana mint is still 400 on the
  field, never a 500 from indexing the Solana pair table.

- **A Hood DEX trade can settle in USDG.** `/quote` takes the same field (`"eth"` default, or
  `"usdg"`) and prices that asset; `/build` settles in it. A USDG buy spends an ERC-20, so the
  plan carries an `approval` leg that an ETH buy does not. Omitted still means ETH, so every
  existing caller is unchanged.

### Clarified

- **A validation 400 creates no job.** `GET /trade/agent/jobs/{clientTradeId}` after that is 404
  `JOB_NOT_FOUND`. Treat the 400 as definitive; do not poll. The same id is reusable with a
  corrected body.

- **`amounts.quoteAsset` is what the wallet paid. `route.kind` is the path.** A fill can read
  `amounts.quoteAsset: "eth"` alongside `route.kind: "usdg"`: native ETH through a WETH → USDG →
  token route. Both are true. Receipt verifiers key off `route` (and `artifacts.trade.to`);
  cost-basis keys off `amounts.quoteAsset`.

- **`/quote` does not pin the `/build` route.** Both race independently. Hops and the hop `fee`
  (static pool tier) can change. Size against `minOutRaw` from the build.

### Docs

- Removed the stale "no server-side Hood DEX builder / graduated Hood is `MARKET_NOT_TRADABLE`"
  cut. Graduated seeded Hood and arbitrary Hood (Pro/Max) have been on this rail; mid-graduation
  with an unseeded pool is still 409 retryable. A Hood venues table names the routers a
  main-payer fill can hit (SwapRouter02, Universal Router, v2, Kyber).

Historical changelog rows that described the old graduated-Hood scope cut stay as history.

## 2026-09-04: What a Hood trade actually costs

A caller measured a pool reporting `fee: 0` and `priceImpactBps: 4` whose round trip cost **604
bps**, and asked us to make that visible. Neither field was wrong; both were being read as
something they are not.

### Added

- **`includeRoundTrip: true` on `/trade/agent/quote`**, which returns **`roundTripBps`**: what
  buying and immediately selling the proceeds costs, in basis points, 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. Opt-in, because it is a second quote's work.

- **`dynamicFee` on a hop**, for a v4 pool whose fee its hook sets per swap. Previously such a
  pool reported the dynamic-fee sentinel in `fee`, which reads as an 838% tier.

### Clarified

- **`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 it. A v4 hook's charge is
  invisible in it by construction. Do not use it as a cost floor.
- **A hop's `fee` is the pool's STATIC tier.** A hook that charges in `afterSwap` is not in it, so
  `fee: 0` on a hooked pool is truthful and is not "free".

## 2026-09-04: A v4 sell a main payer can actually exit

Found by wallet-track running a controlled trade on staging, which is the only way this class of
bug gets found. The buy filled through the bridged route at 1 bp impact and delivered slightly
more than quoted. Then the sell could not be made at all.

### Fixed

- **A v4 or bridged SELL from a `main` payer never broadcast its Permit2 grant, so the swap
  reverted.** The Universal Router pulls its input through Permit2, which takes two approvals:
  the ERC-20 grant to Permit2, and Permit2's own grant to the router. The build carried both legs
  and a linked payer signs and sends them itself, so that path was fine. On a main payer the
  SERVER broadcasts, and it knew only about the first. The second is now sent between the two, in
  the artifact contract's order.

  Effect while it was live: a position bought through a v4 or bridged route could not be exited
  by a main payer. Nothing was lost (the sell reverted before broadcast, no gas), but the exit
  was unavailable, which is the failure this rail guards hardest against.

### Added

- **`route` on the executed response.** A main payer never sees `artifacts` (the server signs and
  broadcasts), so a fill could not be attributed to a venue. The executed response now carries the
  same `route` object a build does.

- **`route.hinted`.** True when the winning route came from a pool the caller named rather than one
  the provider indexed. A hint for a pool we already knew produced the same route and the same
  price, so there was no way to tell a hint that worked from one that was dropped.

## 2026-09-04: Hood routes reach the pools that hold the liquidity

### Changed

- **The Hood DEX race can now route through Uniswap v4 and through an intermediate quote asset.**
  Nothing changes for a caller's request or response shape, and the behavior is off until an
  environment enables it, but it changes which tokens `/build` and `/quote` can serve.

  Why: on a real Hood launch the deep pool is a v4 pool quoted in USDG (or another asset) and
  usually behind a fee hook, while the only ETH bridge is a v3 pool. Candle's own router was
  ETH-terminal and refused every hooked pool, so it could not reach that liquidity at all and
  refused trades that were perfectly routable. A refusal now means what it says.

  Hooked pools are admitted by a policy over what a hook is PERMITTED to do (its address encodes
  its callbacks, and the PoolManager validates that at initialize), not by a list of addresses,
  because the launchpads deploy a fresh hook per pool. Pools below a liquidity floor are not
  quoted at all: a launch carries dozens of dust pools at fee tiers up to 94%, and filling
  through one is worse than the refusal it replaces.

  On a v4 or bridged **sell**, the artifact carries a second approval leg, `permit2Approval`,
  between `approval` and `trade`: the Universal Router pulls its input through Permit2, so the
  ERC-20 grant goes to Permit2 and this leg grants the router inside it. Both are emitted only
  when the live grant is short or expired, for the exact amount, with a short expiry.

## 2026-09-04: A provider that fails on one mint, and a stated quote limit

### Fixed

- **`/trade/agent/quote` and `/trade/agent/build` answered an opaque retryable 500 for a mint the
  market provider errors on.** The provider returns 500 for particular addresses (SENDER,
  `0x36cd5317...`, answers 500 to every request while the rest of the chain serves 200), and that
  reached callers as `SWAP_FAILED` with `retryable: true`, so a caller with the endpoint in its
  price path re-asked every cycle forever. The lookup now retries once and then answers
  `MARKET_NOT_FOUND` with `routing.reason: "hood_market_unavailable"` and `retryable: false`.

  It is deliberately a different reason from `hood_unindexed`: a 404 from the provider means the
  token is unknown, while an error means the token may be perfectly real and the only source that
  could describe it is broken for that address. Treat both the same way, which is to stop asking.

### Added

- **A stated rate limit on `/trade/agent/quote`: 600 per minute per key**, ten a second sustained,
  answered with `RATE_LIMITED` (429, retryable) above it. Per key rather than per IP, so one
  account's loop cannot throttle another's. Sized for a caller pricing every open position on
  every cycle.

- **`priceImpactBps` now has a written definition**, in the reference and the OpenAPI document:
  `10000 * (1 - (amountOut/amountIn) / (refOut/refIn))`, where the reference is 1% of the trade
  quoted through the same route by the same quoter. No USD oracle is involved, so it is
  comparable between the aggregator and the in-house race, and a larger figure on a sell than on
  a buy of the same token is expected rather than a bug.

### Clarified, not changed

- **`error.retryable` is a sibling of `error.routing`, not a field inside it.** Read it from
  `error.retryable`. Reading it from inside `routing` finds `undefined` and files every refusal
  as permanent. This placement is contract.
- **`route` rides the quote response for both quoters**, not only the aggregator and not only on
  a build, because an impact floor has to run before a build exists.

## 2026-09-04: Trade a pool before any indexer knows it

### Added

- **`poolKey` on `/trade/agent/quote` and `/trade/agent/build`** (Hood only): a Uniswap v4 PoolKey
  you read off the chain, for a pool the market provider has not indexed yet. This is the launch's
  first minutes, the window this rail could not serve at all: no indexed pools means no candidates
  means a refusal, exactly when the trade was worth making.

  Nothing in the key is trusted. Before it is used: the shape v4 itself requires, one side EXACTLY
  the mint being traded and the other a counter asset this rail settles, a hook that passes this
  environment's policy, and the pool initialized on chain with liquidity, read from the v4
  StateView. A key with one field changed names a pool that does not exist and is refused as such.

  It rides beside the indexed pools rather than replacing them, and the best execution still wins.
  A hint that fails verification is dropped when the token is tradable anyway, and becomes the
  refusal when it was the only route, so you learn which check failed instead of seeing "no route".

## 2026-09-03: Every trade refusal says which refusal it was

### Added

- **`POST /api/v1/trade/agent/quote`** (agent key, `swap:write`): a live price for a Hood token
  from the same quoters `/build` asks, without building anything. Body
  `{ "mint": "0x...", "side": "buy" | "sell", "amountRaw": "..." }`; the response carries
  `amountOut`, `quoteAsset`, `quoteDecimals`, `venue` and the same `route` object a build returns.

  This closes a real gap rather than adding convenience. The public market quote reads a Candle
  curve, so a token Candle never launched had no live price on this API at all until a
  third-party indexer noticed it, and a caller pricing fresh launches refused entries that were
  perfectly routable: it had no price, not no route. `/build` was not a substitute, because a
  build writes a row that expires in 30 minutes and, on the aggregator path, spends a vendor
  build call.

  Nothing is written and no fee applies. Refusals are the same errors `/build` raises,
  `error.routing` included. Hood only: Solana routes through Jupiter, which callers quote
  directly.

- **`error.routing` on a refused trade build**, beside the `retryable` flag the envelope always
  carried. Three different misses read as one sentence today ("No routable ETH liquidity found
  ... on the Hood DEX"): the aggregator knew no route, the aggregator was unreachable, or
  Candle's own quote race found no pool. A caller deciding whether to watch a token for a few
  minutes or drop it could not tell them apart. Now:

  ```json
  {
    "success": false,
    "error": {
      "code": "MARKET_NOT_TRADABLE",
      "message": "No routable ETH liquidity found for 0x... on the Hood DEX. ...",
      "retryable": true,
      "routing": { "reason": "no_candidates", "adaptersAttempted": ["kyber", "uniswap"], "kyberAttempt": "no_route" }
    }
  }
  ```

  `reason` is one of `hood_graduating`, `solana_graduating`, `no_candidates`, `hood_unindexed`,
  `solana_pool_missing`, `market_unknown`, `quote_pair_unknown`, `exclusive_window`. `retryable`
  now answers per reason rather than defaulting to false: a curve closed while graduating and a
  race that found nothing are both retryable; an unindexed mint and an unknown market are not.

  `adaptersAttempted` and `kyberAttempt` appear on the Hood DEX venue only. Read them together
  with `reason`, not instead of it: neither an aggregator no-route nor an aggregator outage is
  ever the terminal refusal itself, because both fail over to the in-house race without tripping
  the breaker. The refusal you receive is whatever the race then said, and `kyberAttempt` is how
  you see that the aggregator lagged behind it.

- **`artifacts.route` on a build served by the in-house race**, matching what an aggregator-served
  build has always returned: `hops` (the pools the swap runs, in execution order, with the v3/v4
  fee tier where one was chosen) and `priceImpactBps` (this size against a 1% reference quote of
  the same route, so no USD oracle is involved and no vendor's token valuation can move it). A
  caller can now refuse a build over its impact, or record which venue a fill went through,
  whichever quoter served it. `surplusToVendor` is `false` on race-served routes: positive
  slippage and dust go to the payer. Both fields are now documented in the OpenAPI document.

- **Every trade plan is now recorded on Candle's side, kept or refused.** No API surface changes;
  this is stated because it answers a question integrators have asked and could not get an answer
  to. `agentTrades` only exists once a build succeeds, so a refusal previously left no trace here
  at all, and the refusal counts behind this release had to come from a caller's own database. A
  refused plan now records its code, reason, retryability and (Hood DEX) the quoters asked; a kept
  plan records its venue and route source, so a refusal rate has a denominator. Telemetry only: it
  is written best-effort off the request path, so it can never fail or delay a trade, and it is
  swept on a 30-day retention window.

### Changed

- `HoodAggregatorHop.pool` is optional. The aggregator still names the pool it will swap through;
  the race derives no pool address and omits the field rather than sending an empty string.

## 2026-09-02: Reconcile a Hood trade from its receipt, and a confirm fix

### Fixed

- **`POST /api/v1/trade/agent/confirm` refused every genuine linked-wallet Hood DEX trade** with
  `VALIDATION_FAILED: This transaction did not move this token for the linked wallet as built`.
  The receipt the server read carried no logs (the mapper kept only status/from/to), so the DEX
  bind, which decodes the payer's own `Transfer`, always found nothing. Tokens landed, Candle
  recorded nothing and charged no fee. Fixed; the receipt now carries every log, and the bind's
  input type requires them.

### Added

- **`POST /api/v1/trade/agent/reconcile`** (agent key, `swap:write`): records a Hood trade one
  of the account's wallets already executed, from its receipt alone, with no build row. Body
  `{ "tradeTxHash": "0x...", "mint": "0x...", "side": "buy" | "sell", "clientTradeId"?: "..." }`.
  This is the remedy for a fill `/confirm` refused or that outlived its 30-minute build window,
  after which `/confirm` answers `JOB_NOT_FOUND` and can no longer be retried. Everything is read
  from the chain and bound as `/confirm` binds a live trade (sender is a wallet on this account;
  `to` is an allowlisted DEX router or the mint's own curve; the payer's own `Transfer` of the
  mint moved the right way, and that decoded amount is the base leg). The native-ETH quote leg is
  the transaction's `value` on a buy and the wallet's balance change plus gas on a sell;
  `quoteDerived` says which (`"value"`, `"balance_delta"`, or `"unavailable"`, in which case the
  base leg is still recorded with a zero quote). Idempotent on the hash (`alreadyRecorded: true`
  on a replay). **No fee is collected**: `feeCollected: false`, `feeRaw: "0"`. The expired
  `agentTrades` row is left as is; the activity ledger is the record of what the chain did.

## 2026-08-27: Filter the token list by chain

### Added

- **`GET /api/v1/tokens?chain=solana|hood`** (public, keyless): restricts the list to one
  chain. Added for indexers who want to enumerate Candle's Hood launches over HTTP rather than
  look one up at a time once they already know the address.

  Two behaviours worth knowing:

  - **`chain=solana` includes every token launched before multichain.** Those rows carry no
    `chain` field at all, and absent means solana, so the filter is "not hood" rather than an
    equality test. Every token matches exactly one of the two values.
  - **An unrecognized chain is refused with 400**, not silently ignored. A filter that is
    accepted and not applied answers 200 with a plausible body, and the caller finds out much
    later, somewhere else.

  Hood rows in this listing serve their cached market figures, which the Hood price stream keeps
  current. They are never sent to Jupiter, which is Solana-only.

## 2026-08-24: Token forensics, and the deployment's on-chain identity

### Added

- **`GET /api/v1/markets/{chain}/{mint}/forensics`** (public, keyless): deployer history,
  deploy-window buyers with the creator's own wallets marked disclosed, holder concentration,
  and a transparent risk tier with per-factor reasons. Surfaced in the MCP as
  `candle_token_forensics`, keyless, so `candle mcp --read-only` carries it. See "Token
  forensics" in the agent-trading reference.
- **`GET /api/v1/contracts`** (public): the Solana programs, Hood contracts and event signatures
  this deployment launches through, read from its own environment. `docs/contracts.md` is the
  prose twin.

## 2026-08-23: Spend limits are per-key only

### Breaking change

- **`GET`/`PUT /api/v1/agent/limits` are removed.** The account-wide `main`/`linked` spend-limit
  scopes are retired: keys are the management plane, so every per-asset cap now belongs to the
  API key that spends under it (`PUT /api/v1/agent/keys/:prefix/limits`, unchanged). The spend
  gates on trade builds, launches, and transfers check the calling key's own caps with no
  account fallback; a key with no cap for an asset is uncapped for it.
- **`GET /api/v1/agent/keys/self/limits` no longer returns `accountLimits`.** `keyLimits` is the
  whole answer.
- **`POST /api/v1/agent/wallets/import/submit` ignores `initialLinkedLimits`.** The import-time
  seeding of the account's linked scope is retired with the scope itself; the field is no longer
  rejected or acted on. Set caps on the key instead.
- `SPEND_LIMIT_EXCEEDED`'s `spendLimit.limitSource` is now always `"key"`.
- The tighten/loosen actor rule is unchanged where it still applies: an agent key may tighten a
  key's caps, only a Privy session may loosen (`LOOSEN_REQUIRES_SESSION`).

Existing stored account-level limits stop being enforced. If you relied on an account-wide cap
to bound a key, set the same cap on the key itself.

## 2026-08-19: `GET /api/v1/openapi.json`

### Added

- **An OpenAPI 3.1 description of the agent surface**, unauthenticated, at
  `/api/v1/openapi.json`. It covers every route under `/api/v1/launch/headless`,
  `/api/v1/trade/agent`, `/api/v1/trade/agent/orders`, `/api/v1/markets` and `/api/v1/agent`: 38
  operations, both auth schemes (`x-api-key` and the session bearer), path and query parameters,
  and the full `error.code` enum taken from the server's own list rather than copied.
- Request and response schemas are complete for launch, trade build, confirm and submit, and for
  the market reads. The remaining operations carry their path, method, auth, parameters and
  response codes, and link to the prose reference for their bodies. That unevenness is
  deliberate: a schema invented for a handler nobody re-read is how a description starts lying.
- The document is diffed against the real router table in CI, in both directions, so a route
  added without documentation fails the build and a documented path that stops existing does too.

Nothing about the API's behaviour changed. This describes what was already there.

## 2026-08-19: `/llms.txt` is now a short index, and the full corpus moved to `/llms-full.txt`

### Breaking change for anything fetching `/llms.txt`

- **`/llms.txt` used to serve roughly 246 KB**, the whole agent documentation set concatenated.
  It now serves an index of under 2 KB: what Candle is, the API base, the auth header, the fact
  that market reads need no key, and links onward. Anything that fetched it expecting the entire
  corpus now receives the map instead. The map names `/llms-full.txt` in its opening lines.
- **`/llms-full.txt`** carries what `/llms.txt` used to, with the same content.

### Added

- **`/docs/headless-launch.md`, `/docs/agent-trading.md`, `/docs/mcp-launch-and-seed.md` and
  `/docs/partner-api-changelog.md`**: each reference document now has its own URL, so an
  integrator that only needs the trading reference can fetch it alone rather than pulling every
  document to find it.
- **`/.well-known/candle-agent.json`**: the same orientation facts as JSON. Base URLs, the auth
  header and key prefixes, supported chains, tier names, capability list, and the client
  repository. Deliberately not named `agent-card.json`, which belongs to the A2A protocol; Candle
  publishes a discovery manifest, not an A2A endpoint.
- **`/robots.txt` and `/sitemap.xml`**, neither of which existed before.

All of the above are generated from the same source documents and verified in CI, so they cannot
drift from the references they summarise.

## 2026-08-16: An unroutable arbitrary mint is MARKET_NOT_FOUND, no longer a retryable SWAP_FAILED

### Behavior change

- **`POST /api/v1/trade/agent/build` on the Pro/Max arbitrary-mint path**: when Jupiter answers
  with one of its two definite-no-route errors (`NO_ROUTES_FOUND` for a real mint it cannot
  route, `TOKEN_NOT_TRADABLE` for a mint it does not recognize), the response is now
  `MARKET_NOT_FOUND` (404, `retryable: false`) instead of `SWAP_FAILED` (500, `retryable: true`)
  wrapping the raw Jupiter text. The old shape invited consumers to retry a mint that will never
  route; reported live 2026-08-16, with `sell` of `amountRaw: "1"` on a nonexistent mint as the
  clean repro. Consumers branching on `SWAP_FAILED` for these two cases should branch on
  `MARKET_NOT_FOUND` instead; every other planner failure still reports `SWAP_FAILED` unchanged.
- **A graduated Candle token's Jupiter route is deliberately NOT reclassified.** The same
  `NO_ROUTES_FOUND` on a freshly graduated Candle token stays a retryable failure: Jupiter needs
  a short while to index a new pool, and that clears on its own. Only the arbitrary path (a mint
  with no Candle token row) treats no-route as final.
## 2026-08-16: `bannerUrl` on headless launch

### Added

- **`bannerUrl`**, a new optional field on `POST /api/v1/launch/headless` (and its `/dry-run`),
  on both chains. An https URL to WIDE artwork for the token page's banner strip: same fetch,
  type, size and SSRF rules as `imageUrl`, re-hosted the same way, but the opposite shape gate.
  It must be **wider than 1.5:1** (e.g. 1200x630), else `BANNER_WRONG_SHAPE`. `null` reads as
  omitted, and omitting it leaves the strip falling back to `imageUrl` exactly as before, so
  nothing changes for a caller that ignores this field.
- **Why it exists.** The banner strip has always rendered `banner ?? image`, so before
  2026-08-08 an agent could fill it deliberately by passing wide artwork as `imageUrl`. The
  square-image gate added that day (`IMAGE_WRONG_SHAPE`, correctly: a 1200x630 avatar renders as
  an empty crop in every avatar slot) removed the only lever agents had over that strip, and left
  no field to replace it. This is that field. It is also the only way an agent-launched token can
  get a distinct banner at all: the web `POST /launch/update-banner` is session-authenticated and
  rejects agent keys.
- **The two shape gates now partition every image.** At most 1.5:1 is an avatar
  (`imageUrl`); wider than 1.5:1 is a banner (`bannerUrl`). Any given file is valid for exactly
  one of the two fields, with no gap and no overlap, so a wrong guess always fails loudly rather
  than silently landing in the wrong slot.
- **New error codes:** `BANNER_WRONG_SHAPE` (not wide enough, or portrait) and
  `BANNER_UNREACHABLE` (the banner's own fetch failed), both `400`, both reported against
  `field: "bannerUrl"`. Redirect and size failures keep the shared `IMAGE_REDIRECT_NOT_ALLOWED`
  and `IMAGE_TOO_LARGE` codes, since those describe the fetch and read identically for both slots.
- **`/dry-run`** validates the banner too, and its `checks` object gains `banner: "ok"` when a
  `bannerUrl` was supplied. The key is absent, not `"skipped"`, when it was not.
- **`bannerUrl` is part of the idempotency hash**, like every other request field: retrying the
  same `clientLaunchId` with a different banner is an `IDEMPOTENCY_CONFLICT`, and an identical
  retry still hashes identically.
- **SDK and MCP:** `LaunchRequest.bannerUrl` on `CandleClient`, and a `bannerUrl` parameter on the
  MCP `launch_token` tool.

## 2026-08-15: Atomic launch with first buys (Solana, one Jito bundle)

### Added

- **`POST /api/v1/launch/atomic/build` and `POST /api/v1/launch/atomic/submit`**, new. Launches a
  token and 1-4 first buys as one Jito bundle: every transaction lands together, on the same
  block, or none of them lands at all -- closing the gap every other launch path leaves open, where
  a snipe bot can land its own buy against a freshly-created pool before the creator's own
  follow-up buy gets there. **Solana only**; requires **Pro or Max tier** and an agent key with
  **both** `launch:write` and `swap:write` scopes. `/build` takes the same body
  `/launch/headless` does (without `buyAmount` -- give the creator a first buy via `firstBuys`
  instead) plus a creator `payer` and 1-4 `firstBuys` legs (Jito's own 5-transaction bundle cap: 1
  launch + up to 4 buys), and returns every leg's unsigned bytes for any `"client"`-signer
  (linked-wallet) leg. `/submit` takes `{ bundleId, signedTxsBase64 }` (exactly the client-signer
  legs, in leg order) and relays the assembled bundle to Jito, responding `200 { status: "landed",
  bundleId, mint, signatures }` or `502 { status: "failed" | "timeout", bundleId, retryable }` --
  both are normal, documented outcomes, not error envelopes. Every buy leg's own on-chain
  `minAmountOut` is always `"0"`: the bundle is atomic and internally ordered, so only your own
  earlier legs in the same bundle can move the price before a later leg executes -- `/build`'s own
  `expectedFill.amountOutRaw` per leg (computed sequentially against the same curve math the
  program uses) is the real consent surface. See the new "Atomic launch with first buys" section of
  `docs/headless-launch.md` for the full flow,
  worked `curl`/SDK examples, and the reasoning above stated in full.
- **`CandleClient.launchAtomic()`** (`packages/sdk/src/client.ts`), new: runs
  build -> sign every client-signer leg (via the existing `signLinkedTransaction()`, in leg order)
  -> submit in one call, skipping the signing round entirely for a bundle where every payer is
  `"main"`. Returns `"landed"`/`"failed"`/`"timeout"` as a normal return value, never thrown -- only
  a genuine 400/403/404/501/503 throws `CandleApiError`, same as every other client method.
  `buildAtomicLaunch()`/`submitAtomicLaunch()` are also exposed individually for callers who want
  to drive the steps themselves, the same split `buildTrade()`/`submit()` offer.
- **Two operational notes worth knowing before integrating.** First, `JITO_BLOCK_ENGINE_URL` must
  be configured server-side or `/build` refuses every request with a `501 VALIDATION_FAILED`
  (`retryable: true`); `JITO_TIP_LAMPORTS` (default 100,000 lamports) sets the per-bundle tip, paid
  from inside the launch transaction itself. Second, `bundleId` is single-use regardless of
  outcome (consumed on the first `/submit` call, before any verification) and every transaction in
  a bundle shares one `recentBlockhash` fixed at `/build` time with Solana's own ~60-90s liveness
  window -- sign and submit promptly; the 10-minute `expiresAt` on the build response is an outer
  bound on server-side storage, not a promise the bundle is still submittable that whole time.
- **Two existing checks now also apply to this route.** `KEY_LIMIT_REACHED` (a key's own `txLimit`
  cap, see the 2026-08-15 key-limits entry below) is checked once at `/build`, against the SUMMED
  value of every buy leg in the bundle (not per leg): every leg meters against the same key at
  settlement, so a per-leg check would let a bundle land several times the key's actual remaining
  windowed budget in one atomic event. `SPEND_LIMIT_EXCEEDED` is checked twice per leg here: once
  per leg individually (the same literal per-transaction cap a standalone trade gets), and again
  summed per distinct payer wallet across the whole bundle, since every buy leg lands from the same
  wallet(s) in one atomic event rather than as independent trades spread over time.

## 2026-08-15: The order.triggered webhook (limit-order triggers are pushed, not polled)

### Added

- **`order.triggered`, a new webhook event.** The limit-order keeper enqueues it the moment an
  order's price condition holds and its status flips `open` to `triggered`. Register for it the
  same way as every other event (`POST /api/v1/agent/webhooks` with `order.triggered` in `events`);
  delivery, signing, and retry semantics are unchanged. Payload: `clientOrderId`, `chain`, `mint`,
  `side`, `amountRaw`, `targetPrice`, `price` (the observed price that satisfied the condition),
  and `expiresAt`.
- **The payload is self-sufficient on purpose.** It carries the order's own `mint`, `side`, and
  `amountRaw`, so a stateless consumer can go straight to `POST /api/v1/trade/agent/build` without
  reading the order back first.

### Behavior change

- **Polling `GET /api/v1/trade/agent/orders?status=triggered` is now the fallback, not the
  mechanism.** The polling path is unchanged and still supported. Nothing else about limit orders
  moved: the flip still moves no money, the agent still completes the trade itself through
  build -> sign -> submit, and `trade.executed` still fires for that completing trade.
- **Exactly one event per flip that lands.** A `markTriggered` that loses a race emits nothing, so
  a triggered order is announced once. Expiry (of an `open` or a `triggered` order) emits nothing
  at all.

See the Limit orders section of
`docs/agent-trading.md` for the full order
lifecycle, and the Webhooks section of
`docs/headless-launch.md` for the event table.

## 2026-08-15: Key naming, expiration, transaction limits, and usage metering

### Added

- **`POST /api/v1/agent/keys` gains three optional fields**: `label` (1-64 characters, trimmed),
  `expiresInDays` (integer, 1-365, converted to an absolute `expiresAt` at creation) or `expiresAt`
  (absolute epoch ms, must be strictly future; mutually exclusive with `expiresInDays`), and
  `txLimit` (`{ usdMicros: positive integer, reset: "never" | "daily" | "weekly" | "monthly" }`, a
  windowed cumulative USD-value trading cap on that key, separate from the existing per-transaction
  `PUT /api/v1/agent/keys/:prefix/limits`). All three are echoed in the response only when set.
  **Expiration is fixed at creation**: there is no field to extend or clear it afterward, on this
  route or the new `PATCH` below; a new key is the only way to change it.
- **`PATCH /api/v1/agent/keys/:prefix`**, new. Body must carry exactly one of `{ label }`,
  `{ txLimit }`, or `{ clearTxLimit: true }`; more than one at once is `VALIDATION_FAILED`. An
  unknown prefix or one owned by a different account both report the same 404 a nonexistent key
  reports elsewhere in this API. At the Convex level, an `agent`-actor caller (reserved for a
  future `x-api-key` path; this route is session/device-token only today) may only tighten a key's
  own `txLimit` while keeping its existing `reset` cadence, and can never raise it, re-cadence it,
  or clear it -- only a session actor can do any of those three.
- **`GET /api/v1/agent/keys` now merges a per-key `usage` object** (`todayUsdMicros`,
  `weekUsdMicros`, `monthUsdMicros`, `lifetimeUsdMicros`, `days30UsdMicros`, `tradeCount30`,
  `launchCount30`, `lifetimeLaunches`) onto every row, plus a top-level `usageSince: "2026-08-15"`.
  **Usage metering starts counting from that date; there is no backfill.** A key active before
  2026-08-15 shows a `lifetimeUsdMicros` that undercounts its true history, since nothing before
  that date was recorded, not zeroed.
- **`GET /api/v1/agent/keys/:prefix/usage?days=N`**, new: a zero-filled daily series (`days`
  clamped 1-90, default 30) of `{ day, tradeVolumeUsdMicros, tradeCount, launchCount }`, ascending,
  oldest first, exactly `days` entries regardless of which days actually had activity.
- **Two new error codes.** `KEY_EXPIRED` (401): once a key's `expiresAt` passes, every
  authenticated call using it fails this way instead of the generic `UNAUTHORIZED`, checked only
  after the caller's possession of the real secret is verified so a bare prefix can never probe
  expiry. `KEY_LIMIT_REACHED` (403, at `POST /api/v1/trade/agent/build`): the key's `txLimit`
  would be exceeded by this trade; the body carries `resetsAt` (epoch ms, or `null` for a `never`
  cadence with no reset) and `retryable: true` only when `resetsAt` is non-null.
  **The `txLimit` cap is an approximate guardrail, not a hard accounting boundary**: it is checked
  against a usage rollup at build time, while the actual usage record for a trade lands only once
  that trade completes, asynchronously. Concurrent trades on the same key can each pass the check
  independently and, together, exceed the cap; a trade that passed the check at build time can
  still push the recorded total over the cap by the time it completes. A sell is valued by its
  quote-asset proceeds (the plan's quoted `expectedOutRaw`, or the decoded actual amount at Solana
  `/confirm` when available), never the raw token amount sold.
  See the new Key management section of
  `docs/agent-trading.md` for the full field tables,
  worked `curl` examples, and the disclosure above stated in full.

## 2026-08-14: Per-tier transaction fees (Pro and Max no longer pay the Believer rate)

### Changed

- **The platform fee is now four distinct per-tier rates, not two.** `fee.bps` on a trade
  response (`POST /api/v1/trade/agent/build` and `/confirm`) and `feeBps` on
  `GET /api/v1/agent/tier` now resolve to Free 100 bps (1%), Believer 50 bps (0.5%), Pro 25 bps
  (0.25%), Max 0 bps (free). Previously Pro and Max both paid the same 50 bps Believer rate as
  a Believer account; a Pro or Max integrator now pays less (25 bps or nothing at all) than
  before this shipped. Free and Believer rates are unchanged. See the Fee model section of
  `docs/agent-trading.md` for the full per-tier
  table and how the rate is resolved from live tier plus a key row's issuance-time cap.
- **A Max-tier trade legitimately carries `feeRaw: "0"` and no fee leg.** This is by design, not
  a bug: a treasury still needs to be configured and at least one tier's rate still needs to be
  nonzero for the fee mechanism to read as "enabled" at all, but a Max-tier caller's own
  resolved rate is zero, so `computeFeeRaw` returns `"0"` and no fee transfer is built or sent
  for that trade or launch dev buy.

### Migration

- **`AGENT_FEE_BPS_TIERED` is removed and inert.** It previously set the single rate every
  Believer, Pro, and Max account paid. Operators must now set `AGENT_FEE_BPS_BELIEVER`,
  `AGENT_FEE_BPS_PRO`, and `AGENT_FEE_BPS_MAX` individually (alongside the pre-existing
  `AGENT_FEE_BPS_FREE`) to retune any of the three rates that used to share one knob. An
  environment that still only sets `AGENT_FEE_BPS_TIERED` sees no effect from it: the var is no
  longer read anywhere, and Believer/Pro/Max each silently fall back to their own new default
  (50/25/0 bps respectively) instead of whatever custom value `AGENT_FEE_BPS_TIERED` used to
  carry.

## 2026-08-14: Linked cross-chain swaps (SOL on a linked wallet into ETH or USDG)

### Added

- **`POST /api/v1/agent/swap/build` and `POST /api/v1/agent/swap/submit`**, new, `swap:write`
  scope. Bridges SOL held on a **linked** Solana wallet into ETH or USDG on Hood, in the same
  build -> relay-sign -> submit shape the trade rail uses. `/build` takes
  `{ from: "SOL", to: "ETH" | "USDG", amountRaw, payer: { type: "linked", linkedWalletId },
  toWalletId?, maxSlippageBps? }` and returns `{ swapId, transactionsBase64, expectedOutRaw,
  statusChecks, recipient, expiresAt }`. `/submit` takes `{ swapId, signedTransactionsBase64 }`.
- **The one-shot rail (`POST /api/v1/agent/swap`) still cannot bridge from a linked wallet**, which
  is why this pair exists. That rail remains main-wallet only.

### Disclosures

- **Destinations are own-wallets-only.** `toWalletId` must be a linked EVM wallet on the same
  account (an attribution-only link qualifies, since 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 own funds but cannot exfiltrate them.
- **`/submit` verifies each signed transaction against the build by MESSAGE bytes** before any
  broadcast; a substituted transaction is refused `UNRECOGNIZED_TRANSACTION` (403).
- **Sign promptly.** The deposit carries a recent blockhash and goes stale in about a minute, even
  though the server-side build expires after 10 minutes. An expired `swapId` answers 404: rebuild.
- **The bridge fill is asynchronous.** `hashes` proves the Solana deposit landed; poll
  `statusChecks` to observe the destination side complete.

### Known limitations

- **v1 is `from: "SOL"` only.** SOL, USDC, and CNDL conversions are same-chain: use the free
  base-pair trade instead, and convert a USDC or CNDL origin to SOL that way first. Hood origins
  stay on the one-shot main-wallet rail.

The SDK wraps all three steps as `swapFromLinked()`. See
`docs/agent-trading.md`.

## 2026-08-14: Referral program Phase 1 (a wire-visible 10 percent fee discount)

### Added

- **`POST /api/v1/agent/keys` 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.
- **New referral routes**: `GET /api/v1/referrals/me`, `POST /api/v1/referrals/code`,
  `GET /api/v1/referrals/code/:code`, and `POST /api/v1/referrals/claim` (USDC commission claims).

### Behavior change

- **A referred account pays 10 percent less on the platform fee for every trade**, computed as
  `floor(bps * 9 / 10)` after the tier rate resolves. **This is visible on the wire**: a referred
  Pro account's trade response reports `"bps": 22`, not `25`. Anyone asserting on an exact
  `fee.bps` should read the tier rate as a ceiling, not a constant.
- **The discount applies to trades and atomic first-buy legs only, never to a launch dev buy.** The
  same account can therefore legitimately see 22 bps on a trade and 25 bps on a headless launch's
  dev buy in the same session. That asymmetry is deliberate, not a rounding artifact.

## 2026-08-10: Agent tier snapshot endpoint, and a tier strip on the dashboard

### Added

- **`GET /api/v1/agent/tier`** (agent key or Privy session, any tier; read-only, never
  tier-gated, kill-switch-exempt like `GET /limits`). Returns the calling account's full tier
  picture in one call: display tier and live tier (`max` > `pro` > `believer` > `free`,
  resolved identically to `GET /:idOrWallet/agent`'s own `tier` field), staked/held CNDL, the
  Pro qualification thresholds and current grace-window state, `maxTierExpiresAt`, the
  account's resolved platform fee in bps, and lifetime fee totals per chain and quote asset.
  That identical resolution holds on the session leg only: on the agent-key leg, Believer (and
  therefore `feeBps`) reflects the CALLING KEY's own issuance-time cap, not the account's
  overall Believer status, so a Believer account queried through a non-Believer key reports
  free tier and 100bps.
  See the new Agent tier snapshot section of
  `docs/headless-launch.md` for the full
  response shape.
- **`SDK: CandleClient.getAgentTier()`**, a thin wrapper returning the endpoint's body as
  `AgentTierInfo`. As with the endpoint itself, `feeTotals[].feeRawSum` is a raw-unit BigInt
  string, never a JSON number: format it with the quote asset's own decimals rather than
  coercing it directly.
- **A tier strip on `/dev/agent`.** The developer dashboard now opens with a four-tier
  comparison table (Free/Believer/Pro/Max, current tier highlighted), a Pro-qualification
  progress line for Free and Believer accounts, a grace-window banner while Pro is held open
  by the 48-hour grace period, a Max expiry line, the account's current fee rate, per-chain
  and per-asset lifetime fee totals, and a "Contact us" CTA for reaching Max tier. The CTA
  renders only when `NEXT_PUBLIC_MAX_TIER_TELEGRAM` is set for the environment; there is no
  hardcoded fallback handle. The strip is presentational only and consumes the new endpoint
  directly; it introduces no new server logic beyond that endpoint.

### Known limitations

- The tier strip's fee block shows the account's RATE, always. Whether fee collection is
  actually enabled on a given chain still depends on `AGENT_FEE_TREASURY_SOLANA` /
  `AGENT_FEE_TREASURY_HOOD` being set (see the entry below); an unset treasury and an account
  with no trades yet both render as an empty fee-totals list, and the strip does not
  distinguish the two.

## 2026-08-10: Agent trade API, and a tiered platform fee on every API-built value-moving transaction

### Added

- **`POST /api/v1/trade/agent/build` and `POST /api/v1/trade/agent/confirm`.** An agent can now
  buy or sell an existing Candle-tradable token through the API, from its own delegated wallet
  (`payer: { "type": "main" }`, executed inline server-side, no signing or confirming needed) or
  from an imported linked wallet (`payer: { "type": "linked", "linkedWalletId": "..." }`,
  build-unsigned/agent-signs/confirm-verifies, the same model self-signed launches already use).
  Requires a new, opt-in-only key scope, `swap:write` (never granted by omission). See the new
  `docs/agent-trading.md` for the full flow, the
  Hood send-ordering contract (`approval` then `trade` then `feeTransfer`), the 30-minute build
  expiry, and the Hood-DEX `MARKET_NOT_TRADABLE` scope cut (no server-side Uniswap builder yet).
- **`SDK: CandleClient.buildTrade()` / `.confirmTrade()`.** Thin wrappers, chain-discriminated
  result types (`BuildTradeResult`'s `"built"` branch narrows `artifacts` by `chain`); the SDK
  itself never signs anything.
- **`trade.executed` webhook.** Fires once per trade that actually records (a main payer's inline
  execution, or a linked payer's verified `/confirm`), on the same registration/signing/retry
  infrastructure as every other event.
- **A tiered platform fee, on every value-moving transaction this API builds**: 100 bps (Free
  tier) or 50 bps (Believer, Pro, Max), taken on top of the requested/quoted amount, ceil-rounded
  so a nonzero basis is never taxed to zero. Applies to every trade leg on the new rail above, and
  -- retroactively documented here, since it shipped without its own entry -- to the dev buy on
  **both** existing launch paths:
  - **Delegated headless** (`POST /api/v1/launch/headless`): the fee, when the dev buy is
    nonzero, rides inside the same Solana launch transaction, or is sent as a best-effort
    companion Hood transaction after the dev buy's own receipt lands.
  - **Self-signed** (`POST /api/v1/launch/self/build` + `/confirm`): the fee is embedded in the
    unsigned Solana build transaction, or returned as its own `feeTransfer` artifact in the Hood
    build response; `/confirm` now requires `feeTxHash` whenever that artifact was present.
  - Destinations are `AGENT_FEE_TREASURY_SOLANA` / `AGENT_FEE_TREASURY_HOOD`, one per chain, per
    environment. **Leaving a chain's treasury env var unset is the rollout lever**: every trade
    and dev buy on that chain then silently skips the fee entirely, with no separate feature flag.
  - See the Fee model section of `docs/agent-trading.md`
    for the exact basis per side (including Hood's consumed-quote basis at the graduation
    boundary) and the full env-knob list.
- **One new error code**: `FEE_LEG_MISSING` (402) -- at `/confirm` (a trade or a self-signed
  launch), the required fee transfer did not verify, or (Hood) `feeTxHash` was omitted despite the
  build carrying a fee. Not retryable as the same confirm call; only a fresh `/build` recovers.
  Also covers reuse: a Hood fee-transfer hash already claimed by a different trade or launch is
  refused, enforced by one atomic, cross-table Convex claim (`agentTrades.
  claimTradeFeeSignature` / `headlessLaunches.claimSelfFeeSignature` each check the other's own
  index). On Solana, where the fee rides inside the trade/launch transaction rather than its own
  hash, `/confirm` separately claims a linked payer's trade signature unconditionally to block
  trade-vs-trade replay.
- **One new error code, trade-specific**: `SPEND_LIMIT_EXCEEDED` (400) -- a buy's
  `amountRaw + feeRaw` exceeds the payer's own configured spend limit (main or linked scope). Not
  a new mechanism: reuses the same account spend limits `PUT /api/v1/agent/limits` already writes,
  just gated at this new endpoint too.
- **`MARKET_NOT_TRADABLE` (409) now also covers the agent trade rail**: a graduated-to-Uniswap or
  mid-graduation Hood curve, or a Solana curve not found on-chain. Message names where trading
  continues when known. The code itself already existed for the public quote endpoint.

### The enforcement boundary

The fee binds to API-built transactions. 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 and Solana's own activity-report path resolve identity the same way
regardless of how a transaction was built), it just does not carry this platform fee. This is a
deliberate scope boundary: enforcing a fee on activity Candle never assembled would require
policing every possible on-chain interaction with a curve, not just the ones this API produces.

### Known limitations

- No live end-to-end run against a funded Believer/Pro/Max account has exercised the tiered rate
  outside unit and route tests against mocked Convex/RPC calls.

## 2026-08-10: Self-signed launches (agents launch from a linked wallet, no delegation)

### Added

- **`POST /api/v1/launch/self/build` and `POST /api/v1/launch/self/confirm`.** An agent can now
  launch from a wallet it imported itself (see the Linked wallets entry below), rather than the
  account's own delegated embedded wallet: `/build` returns an unsigned transaction (Solana:
  base64; Hood: factory calldata), the agent signs and broadcasts it itself, then reports the
  resulting signature to `/confirm`, which verifies it on-chain and records the launch exactly
  like `/launch/headless` does. Candle never signs and never holds a key anywhere on this path.
  **Correction (2026-08-11):** signing still needs an app-authenticated call to Privy that an
  external agent cannot make alone, and goes through a Candle signing relay that is not built yet
  (Phase 3 work) -- so this path is not completable end to end by an external agent today. See the
  Self-signed launches section of the reference doc. Requires Pro or Max tier and a body identical
  to `/launch/headless` plus a required `linkedWalletId`. See the new Self-signed launches section
  of the reference doc for the full flow, payer rules, and per-chain mechanics.
- **`SDK: CandleClient.buildSelfLaunch()` / `.confirmSelfLaunch()`.** Thin wrappers over the two
  calls above; the SDK itself never signs anything.
- **One new error code**: `SIGNER_MISMATCH` (403, the confirmed transaction was not signed by the
  linked wallet that built the launch).

### Disclosures

- **The Hood dev-buy fee leg pays the linked (payer) wallet, by design.** This diverges from the
  owner-signed headless path's precedent (which pays the account's own delegated wallet) but is
  coherent self-funded semantics for this path: the wallet that paid for the launch is the one
  that earns its fees, claimable by that wallet even after it is later revoked as a linked wallet,
  since fee resolution reads the full wallet set, not just currently-active links.
- **The linked-scope spend gate is load-bearing on Solana, narrower on Hood.** On Solana it is the
  ONLY enforcement of the account's spend cap against a self-signed dev buy, since that leg is
  bundled into the launch transaction itself, invisible to the linked wallet's own Privy DENY
  overlay. On Hood the dev buy is the agent's own standalone, caller-sent second transaction, so
  the gate only ever bounds the amount DECLARED at `/build`, not what is actually sent; a
  standalone Hood transaction is, however, visible to the linked wallet's own Privy
  `ethereum_transaction.value` DENY rule, and `/confirm` binds any reported `devBuySignature` to
  the launch's own curve address and declared amount before recording it. See the reference doc's
  full explanation.
- **A launch confirmed after the 30-minute expiry sweep already ran has no register fallback.**
  Unlike the web launch flow, there is no self-serve way to re-attach a stranded, already-landed
  self-signed launch to its account once the ledger row has been expired out from under it. 
- **Three pre-existing Hood token-insert paths stamp `tokens.creator` with the launcher's EVM
  wallet address, not a Candle account id**: the web launch flow (`POST /hood/register`), the
  owner-signed headless flow, and the one-shot `backfillMissingCreators` repair mutation when it
  backfills from cached market data. Since the webhook keeper resolves a launch's owner by
  treating `tokens.creator` as a Candle `userAddress`, launches through those paths never trigger
  an agent webhook. The self-signed Hood insert (`/launch/self/confirm`) stamps `creator` with the
  Candle account address instead, and carries the checksummed EVM payer separately in
  `originalCreator`.

### Carried-forward hardening

Landed alongside this feature, applicable beyond it:

- **Spend-limit change classification is now atomic.** `PUT /api/v1/agent/limits` used to read the
  current limits, classify tighten-vs-loosen, and only then write, as two separate steps with a
  TOCTOU window between them. The classification now runs inside the same Convex mutation
  invocation that performs the write, closing the race.
- **The wallet-management surface stays reachable when launches are disabled.**
  `GET`/`DELETE /api/v1/agent/wallets`, `GET`/`PUT /api/v1/agent/limits` (tightening) now work via
  an agent key even while `HEADLESS_LAUNCH_ENABLED` is off. Importing a new wallet, linking an
  existing one, and loosening a limit stay gated on the kill switch, same as before, since none of
  the now-decoupled endpoints can create a new spend-capable wallet or widen an existing cap.
- **EVM `walletAddress` stamps are now EIP-55 checksummed** at the two remaining sites that were
  still stamping raw case (the Hood trade ingester and the Hood swap recorder). See the reference
  doc's `walletAddress` casing note.

## 2026-08-09: Account spend limits (unlimited by default, user-set caps)

### Changed

- **Tier-based default spend caps are retired.** The "every imported wallet gets a tier's
  default per-transaction cap" model from the Linked wallets entry below is replaced by a
  permissive, match-all base policy: every agent-controlled signer -- the account's own
  delegated launch wallet and every spend-capable linked wallet -- starts fully unlimited.
  A cap exists only if and when the account sets one.
- **Two account-level scopes.** `main` (the account's own delegated launch wallet, enforced
  server-side at the launch gate in `POST /api/v1/launch/headless`) and `linked` (every
  currently spend-capable linked wallet, compiled as Privy DENY-overlay rules on the
  permissive base and re-applied on every write). See the new Account spend limits section
  of the reference doc.

### Added

- **`GET`/`PUT /api/v1/agent/limits`** (agent key or Privy session, any tier) read and write
  both scopes. Tightening (a new cap, or a lower one) is allowed from either caller;
  loosening (raising or clearing a cap) requires a Privy session -- an agent key attempting
  to loosen is rejected with `LOOSEN_REQUIRES_SESSION` (403) before anything is written. A
  duplicate asset id within one `limits` array is rejected (`VALIDATION_FAILED`). `PUT` on
  `scope: "linked"` fans the change out to every active spend-capable wallet with bounded
  concurrency and reports per-wallet `updated`/`failed` counts plus a
  `fanout: "ok" | "list_failed"` field distinguishing "some wallets individually failed"
  from "the wallet listing itself failed, nothing was attempted."
- **`DELETE /api/v1/agent/wallets/:id` now reports `policyNeutralized`.** `true`/`false` when
  the revoked row carried a spend policy (an imported wallet), omitted entirely for an
  attribution-only link that never had one.
- **New wallet manager and spend-limit editors on `/dev/agent`.** The developer portal now
  lists linked wallets (with revoke) and exposes both spend-limit scopes as editors.

### Removed

- **The `AGENT_POLICY_PRO_*_CAP_*` / `AGENT_POLICY_MAX_*_CAP_*` env vars and
  `AGENT_POLICY_HOOD_ALLOWED_CONTRACTS`** are deleted; spend caps are account-level and
  user-set, not environment-configured. `HOOD_CURVE_FACTORY` itself is unrelated to this and
  remains in use for building Hood launches.

### Behavior change

- **Once ANY Solana spend limit is set, the agent signer loses plain
  `Transfer`/`Approve`/`SetAuthority`/`CloseAccount`/`Burn` for every SPL token**, not only
  the capped asset -- those instruction shapes carry no checkable amount for a DENY rule to
  bind against, so they are blocked outright rather than left open once any Solana cap
  exists. `TransferChecked` remains usable under the cap. `"0"` is rejected as a cap value.

### Known limitations

- **Whether program-routed spend (a DEX swap, a curve buy, any other CPI) is also caught by
  these caps is unverified against a live Privy app**, which is exactly what
  `scripts/verify-privy-policies.mjs`'s CPI/program-routed check exists to test. The
  pre-enable checklist opened by the Linked wallets entry below remains open: run the script
  once with real Privy credentials before enabling spend-capable imports or account spend
  limits anywhere real.

## 2026-08-09: Linked wallets (import, attribution-only linking, revocation)

### Added

- **Linked wallets for Pro and Max tier accounts.** An account can now attach additional
  Solana or EVM wallets to its Candle identity beyond its primary Privy wallet: **import** a
  wallet Candle did not previously manage (spend-capable, agent-driven) or **link** a wallet
  already on the caller's own Privy account (attribution-only, no spend authority). See the
  new Linked wallets section of the reference doc for the full flow.
- **Ciphertext-only import.** `POST /api/v1/agent/wallets/import/init` and
  `POST /api/v1/agent/wallets/import/submit` (agent key, `launch:write`, Pro/Max) exchange
  only an HPKE-sealed private key, never plaintext: the SDK's new
  `CandleClient.importWallet()` seals the key client-side (RFC 9180,
  `DHKEM(P-256, HKDF-SHA256)` / `HKDF-SHA256` / `ChaCha20-Poly1305`), and Candle's server
  never receives, stores, or logs the plaintext key at any point.
- **Every imported wallet's signer gets a spend policy attached atomically.** A wallet's
  Privy signer and its tier's default spend policy (per-transaction caps on SOL/USDC/CNDL
  transfers and Candle-program calls on Solana, on Hood-contract calls on EVM) are created
  in the same Privy call, so an agent-controlled signer is never registered without an
  attached cap.
- **Attribution-only linking.** `POST /api/v1/agent/wallets` (agent key or Privy session,
  Pro/Max) links an existing Privy wallet already on the caller's account for attribution
  only, verified against the caller's own Privy account before being trusted.
  `GET /api/v1/agent/wallets` lists an account's linked wallets (active first);
  `DELETE /api/v1/agent/wallets/:id` revokes one.
- **Linked wallets participate in trade attribution.** `POST /activity/report`'s Solana and
  Hood paths, and the Hood curve-trade ingester (for trades made outside Candle's own UI),
  now resolve a trader's identity through the primary wallet then every linked wallet, first
  match wins.
- **New Convex table and index.** `linkedWallets` (one row per link, tombstoned on revoke
  rather than deleted) and `users.by_evm_lower` (indexed EVM-address lookup, backing the
  linked-wallets primary-collision check).
- **New env knobs**: `AGENT_TIER_LINKED_WALLETS_PRO` (default `10`),
  `AGENT_TIER_LINKED_WALLETS_MAX` (default `1000`), and the per-tier, per-asset
  `AGENT_POLICY_*` spend caps (`_SOL_CAP_LAMPORTS`, `_USDC_CAP_RAW`, `_CNDL_CAP_RAW`,
  `_ETH_CAP_WEI`, for both `PRO` and `MAX`), plus `AGENT_POLICY_HOOD_ALLOWED_CONTRACTS`. See
  the Linked wallets section's Env knobs table for defaults.
- **Three new error codes**: `TIER_REQUIRED` (403, a Free/Believer account attempted an
  import or link), `WALLET_LIMIT_REACHED` (400, the active linked-wallet cap is reached for
  the account's tier), `WALLET_ALREADY_LINKED` (409, the address already has an active link
  on some account).

### Revocation

`DELETE /api/v1/agent/wallets/:id` tombstones the row immediately, which is authoritative
for attribution right away, and, for an imported (spend-capable) wallet, also neutralizes
its Privy spend policy to default-deny, best-effort. Attribution reads through a 60-second
per-account cache, so a just-revoked wallet can still attribute a trade for up to 60 seconds
after the revoke call returns.

### Known limitations

- **`ethereum_transaction.value` policy encoding (decimal vs. hex) is unverified against a
  live Privy account.** The default policy's per-tx caps are written as decimal-string raw
  amounts, matching how this codebase represents every other raw amount, but there is no
  live Privy project available in this environment to confirm the wallet-API policy engine
  accepts that encoding for `ethereum_transaction.value` rather than requiring hex. Confirm
  this against the first real EVM policy attach.
- **No live-Privy end-to-end import test yet.** The import flow is covered by unit and route
  tests against mocked Privy calls; it has not yet been exercised against a real Privy
  project end to end.

## 2026-08-09: Live agent tiers, effective caps, UTC-day launch counter

### Added

- **Four trust tiers, live-evaluated.** Free, Believer, Pro, and Max each carry their own
  rate limit, daily launch cap, and image-upload limit:

  | Tier | Gate | Requests/min per key | Launches/day per account | Image uploads/min |
  | --- | --- | --- | --- | --- |
  | Free | agent features enabled | 30 | 5 | 10 |
  | Believer | Believer NFT at key issuance | 30 | 20 | 10 |
  | Pro | 500k CNDL staked or 1M CNDL held (live, 48h grace) | 300 | 50 | 30 |
  | Max | contact us | 600 | 1,000 | 60 |

  Believer is unchanged from before: it is decided once, at key issuance, from whether the
  account's wallet held a Believer NFT that moment, and it never regresses. Pro and Max are
  new and live: Pro is re-checked continuously against the account's staked and held CNDL
  balance, with a 48-hour grace window after the balance drops below threshold before falling
  back to Free; Max is granted by an admin with an expiry, via the new
  `POST /api/v1/admin/agent-tier` endpoint.
- **Effective caps are `max(key row, live tier)` per dimension.** A key's row caps (set at
  issuance) and the account's current live tier are combined per request, taking the higher of
  the two on each dimension, so a key an operator hand-raised above its tier floor is never
  silently lowered by a tier re-evaluation. Live-tier evaluation is cached 5 minutes per
  account, single-flight coalesced (a burst of concurrent requests during a cache miss shares
  one balance read), with a 30-second failure cache on a balance-read error, and fails closed
  to the key's row caps if the tier cannot be determined.
- **Display `tier` on the public agent profile.** `GET /api/v1/users/:idOrWallet/agent` now
  returns a `tier` field, resolved `max` > `pro` > `believer` > `free`.
- **New env knobs**: `AGENT_TIER_PRO_MIN_STAKED_CNDL` (default `500000`),
  `AGENT_TIER_PRO_MIN_HELD_CNDL` (default `1000000`), `AGENT_TIER_GRACE_MS` (default
  `172800000`, 48h). See the Caps and trust tiers section of the reference doc.

### Changed

- **Daily launch cap is now a UTC calendar-day counter, not a rolling 24-hour window.** The
  counter resets at 00:00 UTC; a launch attempt increments it when it begins, a definitively
  failed launch refunds it, and an in-flight launch still counts against it. The old rolling-window
  `countSince` query is removed.
- **`touchLastUsed` now writes at most once per key per minute**, instead of on every
  authenticated request, to cut write volume on hot keys.

## 2026-08-07: Hood metadata contract cutover (LIVE)

The contract stack described in the entry below is deployed and bound. **Hood tokens launched from
block 30442973 onward carry on-chain metadata** and report `"onChain": true` from
`GET /api/v1/markets/hood/:mint`.

### Branch on the block, not on a date

Tokens whose curve was created **before block 30442973** were launched by the previous factory.
Their contracts have no metadata fields, no setter, and no proxy, so they can **never** be
retrofitted. They report `"onChain": false` and `"version": null` permanently. This is not a
backfill that is pending; it is not possible.

### New addresses (Robinhood Chain, 4663)

| contract | new | replaces |
|---|---|---|
| `CandleCurveFactory` | `0x63a289eB0F620fE7701D314022F52783547D8F3a` | `0xdaCfdBbb62FFEB32917547e130464CDc9747BdC2` |
| `CandleCurveRegistry` | `0x75934c16162075583435358f9F042D8D6E42Bd5C` | `0xFD54D094ad93349CA316bA309A3C8573E3D6C0cd` |
| `CandleUniV3Migrator` | `0x1F495F6326908f8e595b18BF6754F2a066Fd3846` | `0x5A51Fc39cFA93B14bD184D76F7Dc2C00aC13620b` |
| `CandleV4Migrator` | `0xeB4aAF61c5BC8c5cc6136dd8D9fdd89146B8D59a` | `0x934349F158b9e2BCA0d8324626962C01a1De6Bd0` |
| `CandleLaunchHook` | `0x299FbBECBdBec82B56117d7593a89fe64F77a000` | `0x000509f35530F91068684Fef08AF0405486eE000` |
| `CandleCurveDeployer` | `0x15257Bf766509388e72b523F1A0bF2C1697eE4F1` | new contract, no predecessor |
| `CandleStakingDistributor` | `0x4F1917c02101265bDC517D8C3aDc0e3BE52bEa22` | **unchanged, deliberately reused** |

**The old stack stays live indefinitely.** Every pre-cutover curve still routes its trading and
its migration through those contracts, and many have not graduated. Do not treat the old addresses
as deprecated or stop indexing them.

### If you derive curve addresses yourself

`CandleCurveFactory.predictCurveAddress` **no longer exists**, and the CREATE2 deploying address is
now the **`CandleCurveDeployer`**, not the factory. Deployment moved into a separate contract so the
factory would fit under EIP-170's 24,576-byte runtime limit. Derive with:

```
CREATE2(deployer,
        keccak256(abi.encode(launcher, userSalt)),
        keccak256(CandleCurve.creationCode ++ abi.encode(initParams)))
```

where `deployer` is `CandleCurveFactory.deployer()`. Reading it off the factory rather than
hardcoding it keeps you correct across any future redeploy.

`CandleCurve.InitParams` also gained three fields, `description`, `image`, and `socials`, directly
after `symbol`. They are part of `abi.encode(initParams)` and therefore part of the CREATE2
preimage, so a derivation that omits them produces the wrong address.

---

## 2026-08-07: Hood on-chain token metadata (API surface)

### Added

- **`metadata` on the market object.** `GET /api/v1/markets/:chain/:mint` now returns a
  `metadata` object alongside the existing lifecycle and pool fields:

  ```json
  {
    "metadata": {
      "name": "Example",
      "symbol": "EXMPL",
      "description": "...",
      "image": "ipfs://bafkrei...",
      "socials": { "twitter": "https://x.com/..." },
      "onChain": true,
      "version": 0
    }
  }
  ```

  Previously the market object carried no metadata at all, so there was no non-scraping source
  for a token's name, image, or socials. `metadata` is `null` when we hold none; that is
  distinct from a creator having supplied an empty description.

- **`onChain` tells you who is authoritative.** When `true`, the token contract itself stores
  `description`, `image`, and `socials`, and you can read them directly:

  ```
  description() -> string
  image()       -> string      // conventionally ipfs://<cid>
  socials()     -> string      // JSON object, or "" when unset
  tokenURI()    -> string      // data:application/json;base64,... (name, symbol, description, image)
  creator()     -> address     // the only address permitted to revise metadata
  metadataVersion() -> uint64  // 0 = never edited since launch
  ```

  The values in the API response are our last recorded copy, served so most callers need one
  HTTP round trip instead of an RPC one. If you need a guaranteed-current read, call the
  contract. That is the point of putting it on-chain: we stop being a required dependency.

- **Creator edits are permitted and always logged.** `setMetadata(string,string,string)` is
  callable only by `creator()` and emits
  `MetadataUpdated(address indexed editor, uint64 indexed version, string description, string image, string socials)`
  on every revision, **including version 0 at construction**. You can therefore reconstruct a
  token's full metadata history, and its current state, from logs alone with no constructor
  argument decoding. Metadata is mutable, but never silently.

### Important for indexers: two permanent generations of Hood token

Hood tokens launched **before** the metadata factory cutover have no metadata fields, no setter,
and no proxy, so they **can never be retrofitted**. For those, `onChain` is `false` and `version`
is `null` (not `0`, which would read as "on-chain and never edited"). Branch on `onChain` rather
than probing the contract and interpreting a revert.

Solana tokens report `onChain: false`: their metadata lives in the Metaplex account's IPFS JSON,
which is a different mechanism, not these contract fields.

### Now live

This entry originally shipped ahead of the contracts, when every Hood token returned
`onChain: false`. The launch-stack redeploy landed at **block 30442973**; see the cutover entry
above for the new addresses and the derivation change. Hood tokens launched from that block onward
report `onChain: true`.

## 2026-08-06: Phase 2 wave 3 (hosted images, SPL dev buys)

### Added

- **Hosted image pipeline.** `POST /api/v1/uploads/agent-image` (agent key, `launch:write`
  scope, 10/min per key): upload image bytes (multipart `image` field or raw body with an
  image content-type), get back a public `imageUrl` ready for the launch body. Same type/size
  limits and hosting pipeline as the web launcher; removes the "stand up your own image host"
  requirement. Invalid images fail `UPLOAD_INVALID` (400).
- **Atomic USDC and CNDL dev buys.** Headless Solana launches now accept a non-zero
  `buyAmount` on `usdc` and `cndl` quoted launches, bundled atomically in the launch
  transaction and drawn from the owner wallet's existing token balance (no wrap needed).
  Units are the pair's raw units; ceilings: `DEV_BUY_MAX_USDC_RAW` (default `500000000`,
  500 USDC) and `DEV_BUY_MAX_CNDL_RAW` (default `2000000000000`, 2,000,000 CNDL). The
  quote-pairs matrix now carries `headlessDevBuy` per pair so capability is machine-readable;
  `supportsDevBuy` remains the web-launcher flag. Web launch behavior is unchanged.

## 2026-08-06: Phase 2 wave 2 (signed webhooks)

### Added

- **Signed webhooks.** Self-serve endpoint registration on `/api/v1/agent/webhooks` (max 3
  active per account; secret shown once at registration). Events: `launch.confirmed`,
  `launch.failed`, `curve.graduated`, `migration.completed`, `migration.delayed`, delivered
  tenant-scoped to the launching account's endpoints only, signed
  `x-candle-signature: t=...,v1=<hmac-sha256>` over `"<t>.<body>"`, with a unique `id` per
  delivery for dedupe. Retries back off from 1 minute to 4 hours and stop after 8 failed
  attempts. See the Webhooks section of the reference doc.

## 2026-08-06: Phase 2 wave 1 (jobs, verify, migration status, presets, quoter)

### Added

- **Pollable launch jobs.** `GET /api/v1/launch/headless/jobs/:clientLaunchId` (first endpoint
  to enforce the `launch:read` scope) returns the ledger row for one launch attempt:
  `{ clientLaunchId, chain, status, mint?, pool?, signature?, devBuy?, errorCode?, createdAt,
  updatedAt }`. Unknown id: `JOB_NOT_FOUND` (404).
- **Async launches.** `POST /api/v1/launch/headless` accepts `async: true`: every pre-flight
  gate runs first (validation, idempotency, daily cap, eligibility, wallet), then the server
  responds `202 { accepted: true, clientLaunchId, status: "submitted", jobUrl }` and finishes in
  the background. Poll the jobs endpoint for the outcome. `async` is excluded from the
  idempotency hash, so async and blocking retries of the same launch never conflict.
- **Launch verification.** Public `GET /api/v1/verify/:chain/:mint` answers "is this a genuine
  Candle launch": tier, quote mint, graduation, creator, agent attribution, Hood factory
  provenance, and the Solana attribution rule. Unknown or non-production mints return
  `200 { candleLaunched: false }`, not 404.
- **Migration status.** The market-state response gains a `migration` block:
  `not_started | in_progress | completed | delayed`, with `migratedAt` (stamped from this
  release onward), and Hood keeper attempt detail. `delayed` means the keeper exhausted its
  retries and a human is looking.
- **Presets.** Public `GET /api/v1/launch/presets`: named, fully resolved launch configurations
  (`solana-open-sol`, `hood-open-eth-v4`, ...) with live tier terms and `matrixVersion`.
- **Matrix versioning.** `GET /api/v1/launch/quote-pairs` now returns `matrixVersion`, sets a
  strong `ETag` and `Cache-Control`, and honors `If-None-Match` with 304.
- **Reference quoter.** Public `GET /api/v1/markets/:chain/:mint/quote?side=&amountIn=` prices
  through the exact on-chain math: capped-refund on Hood (with `refund`/`quoteConsumed`),
  full-fill-surplus on Solana, `crossesGraduation` on both. Non-tradable curves return
  `MARKET_NOT_TRADABLE` (409) naming where trading continues.
- **`devBuySignature` persisted.** Replays and job reads now include `devBuy.signature` when
  the launch's dev buy landed; previously it was returned once and lost.

### Fixed

- A migrated Hood market's state previously kept reporting `lifecycle: "trading"`; `complete`
  now derives `migrated`, and a keeper mid-retry surfaces as `migration.status: "in_progress"`.

## 2026-08-05: Agent launches are owner-funded

### Breaking

- **A headless launch is now signed and paid for by the key owner's own
  wallet, never a platform wallet.** Agent access requires a Privy
  **embedded** wallet, the kind an account gets by signing up with Google,
  X, or email. A self-connected wallet (Phantom, MetaMask) can never be
  delegated and so can never be used for agent launches, on either chain.
  See the new "Agent access" section of
  `docs/headless-launch.md`.
- **Existing keys must re-approve delegation before they can launch again.**
  A key issued before this shipped has no wallet delegated for it yet; its
  next launch attempt fails `AGENT_WALLET_NOT_DELEGATED` until the owner
  delegates at least one chain from the profile's Agent access panel. There
  is no grandfathering and no platform-funded fallback.
- **`POST /api/v1/agent/keys` now requires at least one delegated chain to
  issue a key at all.** Its response (and every row
  `GET /api/v1/agent/keys` returns) now carries `launchChains`: the chains
  delegated at issuance, a display snapshot re-checked live at launch time,
  never the authority itself.
- **`INSUFFICIENT_FEE_PAYER_BALANCE` is retired**, replaced by
  `INSUFFICIENT_OWNER_BALANCE` (402): the owner's own wallet, not a
  platform one, is now the only wallet that ever pays for a launch, so an
  underfunded launch is now the owner's problem to fund, not Candle's to
  refill.

### Added

- **Four new error codes**: `AGENT_WALLET_NOT_EMBEDDED` (403, the account
  has no Privy embedded wallet for the requested chain -- sign up via
  Google, X, or email to get one), `AGENT_WALLET_NOT_DELEGATED` (403, an
  embedded wallet exists but agent access was never approved for it),
  `DELEGATION_REVOKED` (403, delegation was approved but Privy now rejects
  the signing call -- the key stays valid, only delegation needs
  re-approval), and `INSUFFICIENT_OWNER_BALANCE` (402, see above).

### Fixed

- **Exclusive Hood launches no longer disagree with themselves.** The
  on-chain `creator` is now the key owner's own wallet instead of a shared
  platform service wallet, so the on-chain NFT gate (which checks
  `creator`/`msg.sender`) and this API's own eligibility check (which
  checks the key owner's wallet) finally check the same address. Previously
  a request could pass this API's check and still revert
  `NftGatingRequired` on-chain, because the two checks were looking at
  different wallets.

### Migration

If an existing key starts failing `AGENT_WALLET_NOT_DELEGATED`:

1. Confirm the account has a Privy embedded wallet for the chain you launch
   on (sign up via Google, X, or email if it does not).
2. From the profile's Agent access panel, delegate that wallet.
3. Fund it: it now pays the network fee, rent, and any dev buy directly. An
   `INSUFFICIENT_OWNER_BALANCE` response means fund it further.
4. Retry the launch; no new key is needed, and no other request field
   changes.

## 2026-08-04: Headless launch v2

### Breaking

- **The shared `LAUNCH_BOT_API_KEY` is retired for headless launches.**
  Every account now issues its own self-serve agent key
  (`POST /api/v1/agent/enable`, then `POST /api/v1/agent/keys`, or via
  `/dev/agent` in the app) and authenticates with
  `x-api-key: cndl_live_...` / `cndl_test_...`. There is no shared bot
  credential to configure anymore.
- **`clientLaunchId` is now required** on every `POST /api/v1/launch/headless`
  call. A request without it is rejected `VALIDATION_FAILED`. It doubles as
  the idempotency key: retry a failed or timed-out attempt with the same id
  instead of generating a new one.
- **Error responses are now a structured envelope**:
  `{ success: false, error: { code, message, field?, retryable } }`.
  Integrators branching on response text or status code alone should switch
  to `error.code`.
- **`mode` now defaults to `"open"`**, not `"exclusive"`. Callers that relied
  on the old exclusive default must now pass `mode: "exclusive"` explicitly,
  and that account must be Believer-NFT eligible or the request is rejected
  `EXCLUSIVE_NOT_ELIGIBLE`.

### Added

- **Dual-chain launches**: `chain` (`"solana"` or `"hood"`), `quoteAsset`
  (per-chain quote pair id), `mode`, `stakerAllocationBps`, and `dexVersion`
  (Hood only) request fields.
- **Dry run**: `POST /api/v1/launch/headless/dry-run` validates a request
  (image reachability, quote pair, exclusive eligibility) without spending
  anything or consuming a daily-cap slot.
- **Markets endpoints**: `GET /api/v1/markets/:chain/:mint` (chain-agnostic
  market state: lifecycle, buy/sell open, curve/pool addresses, crossing
  model) and `GET /api/v1/markets/feed` (public feed reads by bucket, with
  an `isAgent` flag on Candle-origin rows launched via an agent key).
- **Agent-key activity reporting**: `POST /api/v1/activity/report` now
  accepts an agent key and attributes the row with `viaApiKey: true`.
- **Agent profile endpoint**: `GET /api/v1/users/:idOrWallet/agent`, a
  public read of an account's agent-enabled status and launch counts.
- **`@candledottv/mcp`**: an MCP server package exposing five tools (launch,
  market state, feed, activity reporting, agent profile) over stdio for
  MCP-capable agent clients.
- **Feed agent badge surface**: the trade roster and activity feed now show
  an Agent badge and an agents filter for tokens launched via an agent key.

### Migration

If you integrated against v1 (`x-api-key: $LAUNCH_BOT_API_KEY`, no
`clientLaunchId`, plain-text error bodies, exclusive-by-default):

1. Have the launching account enable agent features and issue its own key.
2. Add a unique `clientLaunchId` to every request; keep it stable across
   retries of the same logical launch.
3. Update error handling to read `error.code` from the structured envelope.
4. Pass `mode: "exclusive"` explicitly if that was the intended behavior.

### Addendum, 2026-08-04: dev-buy ceilings

`buyAmount` is now capped per launch: `DEV_BUY_MAX_LAMPORTS` (default
`500000000`, 0.5 SOL) on Solana, `DEV_BUY_MAX_WEI` (default
`20000000000000000`, 0.02 ETH) on Hood. (Correction, 2026-08-06: this
entry originally said the dev buy was funded by a platform service wallet.
That was superseded the next day by the 2026-08-05 owner-funded release
above: the dev buy is paid from the key owner's own wallet, so the ceiling
bounds the blast radius of a compromised key against the owner's funds.)
A `buyAmount` above the cap is rejected `DEV_BUY_TOO_HIGH`. See
`docs/headless-launch.md`
for the exact env names and defaults.
