TypeScript SDK
@candledottv/agent-sdk is a typed, fetch-based client with a small set of audited crypto dependencies, for the whole agent rail: launch configuration, launches (blocking or async), job polling, market state, quotes, feed reads, verification, activity reporting, agent profiles, and image uploads. It runs on Bun and Node 18+.
Install
Section titled “Install”npm install @candledottv/agent-sdk # or: bun add @candledottv/agent-sdkThe source lives in the public candledottv/agentic repo if you would rather vendor it or read along.
Quick start
Section titled “Quick start”import { CandleClient } from "@candledottv/agent-sdk"
const candle = new CandleClient({ apiUrl: "https://api.alpha.candle.tv", apiKey: process.env.CANDLE_AGENT_API_KEY, // cndl_live_... or cndl_test_...})
const { imageUrl } = await candle.uploadImage(pngBytes, "image/png")
const launch = await candle.launch({ chain: "solana", quoteAsset: "sol", mode: "open", name: "Trend Coin", symbol: "TREND", imageUrl, buyAmount: 1_000_000,})
console.log(launch.mint, launch.links.candle)clientLaunchId is generated for you when you omit it, so the retry policy below has a stable identity to reuse. The result carries it as launch.clientLaunchId, and so does any error the call throws: see Idempotency and retries.
Client
Section titled “Client”new CandleClient({ apiUrl, apiKey?, fetch?, maxRetries?, allowInsecureHttp?, privyAppId?, secretStore?, solanaRpcUrl?, evmRpcUrl?, hyperliquidBuilder?, hyperliquidNetwork? })apiUrl must be https://. Every authenticated call sends x-api-key, so a cleartext http:// URL hands the key to anything on the path, and a redirect to HTTPS does not help because the first request has already gone. The constructor throws rather than letting that happen. Loopback (localhost, 127.0.0.0/8, ::1) is exempt and needs no flag; for a trusted local endpoint that is not loopback, such as a devcontainer reaching its host, pass allowInsecureHttp: true.
The signing options are needed only on the paths that sign locally:
privyAppId: Privy’s public app id, the one the sign relay uses. Required to sign for a linked or TEE wallet.secretStore: where the wallet’s own P-256 signer lives (KeychainSecretStore,EncryptedFileSecretStorefor a passphrase-encrypted file on Node, orInMemorySecretStorefor tests and short runs). Candle never sees it.solanaRpcUrl/evmRpcUrl: the RPCsbroadcastSignedTransaction()and the linked-wallet one-shots (trade(),selfLaunch()) read and send over. A Hood linked payer needsevmRpcUrl.hyperliquidBuilder/hyperliquidNetwork: the perps builder to pin, andmainnet(default) ortestnet.
apiKey is required by the write paths (launch, dry run, jobs, activity reporting, image upload, wallet import, self-signed launches, trades) and not required by the public reads (it is sent with them when set). getAgentTier() is the one exception: the endpoint accepts either an agent key or a Privy session, so it sends whichever apiKey the client was built with, key or none. Pass your own fetch to route through a proxy or to test without a network.
| Method | What it does |
|---|---|
getQuotePairs(chain?) | The launch matrix, with matrixVersion |
getPresets() | First-party named launch configurations |
expandPreset(presets, name, overrides?) | Local merge of a preset into a launch body. No request |
dryRunLaunch(req) | Full validation with no spend and no cap slot consumed |
launch(req) | Blocking launch with built-in idempotency and retries. The result and every error carry the clientLaunchId it sent |
launchAsync(req) | Returns once accepted, with a jobUrl to poll. An error carries the clientLaunchId it sent |
getLaunchJob(clientLaunchId) | One attempt’s ledger row |
waitForLaunch(clientLaunchId, opts?) | Polls until the job reaches a terminal state |
getMarket(chain, mint) | Lifecycle, tradability, migration status |
getQuote(chain, mint, opts) | Exact-integer quote for one side |
getFeed(bucket, chain?) | Public feed buckets |
verify(chain, mint) | Is this a genuine Candle launch |
reportActivity(chain, signature) | Report a client-executed transaction |
getAgentProfile(idOrWallet) | Public agent track record |
getAgentTier() | The calling account’s tier snapshot: display/live tier, fee rate, lifetime fee totals, and the plan table as planTable |
getPlans() | The plan table this deployment serves, no key: each plan’s price, agent fee, perps builder fee, limits and capabilities, plus promoMaxDays. planTableRows() and planTableMarkdown() render it |
uploadImage(bytes, contentType) | Hosted image, returns a launch-ready imageUrl |
importWallet(params) | Ciphertext-only import of an existing wallet as a spend-capable linked wallet (Pro/Max) |
buildSelfLaunch(req) | Unsigned launch transaction (or Hood calldata) for a linked wallet to sign itself |
confirmSelfLaunch(req) | Verifies a self-signed launch’s own broadcast, records it like launch() does. The result carries the clientLaunchId |
buildTrade(req) | Build a trade; executes inline for a main payer, or returns an unsigned artifact for a linked one |
confirmTrade(req) | Confirm a linked payer’s broadcast trade after it signs and sends it |
submit(req) | Hand Candle already-signed legs to broadcast and confirm in one call, instead of broadcasting yourself |
trade(req) | One-shot build, sign through the relay, and submit. Solana submits server-side; Hood broadcasts client-side. The result and every error carry the clientTradeId it sent |
getTradeJob(clientTradeId) | One trade’s ledger row, without sending anything: the read after a trade() that threw or timed out. Needs swap:write; a missing row throws JOB_NOT_FOUND |
signLinkedTransaction(params) | Authorize a build-bound artifact with the wallet’s own P-256 signer, through the Candle sign relay |
broadcastSignedTransaction(...) | Broadcast bytes you already signed yourself |
swapFromLinked(req) | Cross-chain: SOL on a linked wallet into ETH or USDG, own-wallets-only destinations |
selfLaunch(req) | One-shot self-signed launch: build, sign, confirm. The result and every error carry the clientLaunchId it sent |
buildAtomicLaunch(req) | Assemble a launch plus 1 to 4 first buys as one Jito bundle (Pro/Max, Solana). The result and every error carry the clientLaunchId it sent |
submitAtomicLaunch(req) | Submit that bundle. Returns landed, failed, or timeout as a value, never a throw |
launchAtomic(req) | Build and submit the atomic bundle in one call. Returns the outcome plus clientLaunchId; an error carries it too, with the bundleId |
operationIds(error) | A function, not a client method: the { clientTradeId?, clientLaunchId?, bundleId? } on an error from one of the six one-call write methods, {} from anything else |
listWallets(opts) | The account’s linked wallets |
getProfilePnl(keyPrefix) | One profile’s realized P&L, fees and marked open positions, split by paying wallet (byWallet) and by chain (byChain), with chain on every position |
getPortfolio() | What the account’s embedded and TEE wallets hold on Solana and Hood, with prices. Hood is in hood; needs account:read |
getSpendLimits() | The calling key’s own spend caps (GET /agent/keys/self/limits) |
getProfileWallets(keyPrefix), setProfileWallets(keyPrefix, walletIds), setProfileWalletScope(keyPrefix, scope) | Read or replace which wallets a profile may spend from, and its selected or all scope; see Agent wallets |
getProfileTrades(keyPrefix, opts?) | One profile’s trade history, failed trades included |
swap(req) | One-shot base-asset swap through the account’s embedded wallets, bridging between Solana and Hood when the pair spans them. Moves real funds; clientSwapId makes it idempotent |
wallets.swapReceipt(hash) | A one-shot swap’s settlement, by any of its hashes |
wallets.selfBalances(opts?) | Balances of exactly the wallets this key may spend from, with optional extra mints and a cursor |
previewCloseEmptyAccounts(req?) | The embedded Solana wallet’s empty token accounts and the rent a close returns. Read-only |
closeEmptyAccounts(req) | Close them and return the rent to the same wallet. Needs transfer:write; clientTradeId makes it idempotent |
getCloseEmptyAccountsJob(clientTradeId) | Read a close run without sending anything |
perpsSetup(ref) | Hyperliquid perps: approve Candle’s builder fee once (0.1%) and report the account’s mode and balance; signs nothing when the approval is already there |
perpsOpen(params) | Open or add to a perps position (market without price, limit with it, optional takeProfit / stopLoss): build, check, sign through the relay, submit to Hyperliquid |
perpsClose(params) | Close all or part of a position with a reduce-only IOC order |
perpsCancel(params) | Cancel an order Candle built, by its cloid |
perpsModify(params) | Change a resting order’s price or size; the replacement gets a new cloid |
perpsLeverage(params) | Set a market’s leverage and margin mode, within the key’s maximum leverage |
perpsMargin(params) | Add or remove isolated margin on an isolated position |
perpsPositions(walletId), perpsOrders(walletId), perpsFills(walletId), perpsFunding(walletId, startTime?) | Live reads from Hyperliquid by the wallet’s address, and Candle’s record of every action it built |
perpsConfig() | The server’s network, Candle’s builder, this key’s fee and its limits |
perpsDeposit(params), perpsDepositStatus(clientDepositId) | Fund the Hyperliquid account from a TEE wallet through Relay, and read the deposit’s job. perpsDepositProblem(build, params) is the check the client runs on the build before signing |
The perps methods sign from a linked EVM TEE wallet, so they need privyAppId and a secretStore holding the wallet’s signer, like signLinkedTransaction(), and a key with perps:write. Every build is checked before anything is signed: the client recomputes the action hash from the plaintext action, checks that the action type is one Candle builds and that the builder is Candle’s, and refuses otherwise with no relay call. Pin Candle’s builder with the hyperliquidBuilder option; unset, the release’s CANDLE_HYPERLIQUID_BUILDER_ADDRESS applies when it carries one, else the builder perpsConfig() reports the first time. hyperliquidNetwork: "testnet" trades testnet. A failed submit keeps the signature, which stays valid until the nonce leaves Hyperliquid’s two-day window.
Idempotency and retries
Section titled “Idempotency and retries”launch() reuses the same clientLaunchId across retries, which is what makes retrying safe: the server resolves whether the original attempt already landed instead of minting a second token. It retries network errors, 5xx responses, and the retryable in-flight 409, with jittered exponential backoff bounded by maxRetries (default 3). It never retries a non-retryable envelope.
Two deliberate exceptions, both to keep control with the caller:
- 429 rate limits are not auto-retried.
RATE_LIMITEDandDAILY_CAP_REACHEDmean waiting minutes or until tomorrow, which is a scheduling decision your agent should make, not a blocking sleep inside a library call. waitForLaunchreturns a failed job as a normal terminal result rather than throwing. Branch onstatus; only exceeding the timeout throws.
Retrying a write that failed or timed out
Section titled “Retrying a write that failed or timed out”That reuse holds only inside one launch() call. Six methods send an idempotency id and generate one when you pass none: trade(), launch(), launchAsync(), selfLaunch(), buildAtomicLaunch() and launchAtomic(). The server treats the id as the whole identity of the operation: the same id is a replay, a new id is a second, independent trade or launch. A call that threw or timed out may still have executed.
- Pass your own id for anything that moves funds, and store it before the call. An error can carry the id only if the process is still running to catch it. A crash or a kill mid-call leaves nothing.
- If you did not, read the id from the result or the error and send it back on the retry with the same request body. Each of the six exposes the id it sent on the result (
result.clientTradeId,result.clientLaunchId) and on every error thrown after its first request was sent:operationIds(error)returns{ clientTradeId?, clientLaunchId?, bundleId? }, and{}when nothing was sent. - Never retry without the id. A call with no id, or a new one, is a second trade or launch. The SDK does not remember ids between calls and adds no retry of its own to
trade(),selfLaunch()orlaunchAtomic().
import { operationIds } from "@candledottv/agent-sdk"
try { return await candle.trade(req)} catch (error) { const { clientTradeId } = operationIds(error) if (!clientTradeId) throw error // nothing was sent return await candle.trade({ ...req, clientTradeId }) // from: "main" only: a replay, not a second trade}What to do with the id depends on who broadcasts:
trade()from the embedded wallet (from: "main"). ReadgetTradeJob(id), or calltrade()again with the same id and body. Both are safe. On the read, 404JOB_NOT_FOUNDmeans the write never began, so call again with the same id.confirmedmeans it landed andsignatureis on the job.failedwitherrorCode: "TRADE_REVERTED"means it landed and reverted, and a new trade needs a new id. Any otherfailedis not confirmed and may still be on chain: call again with the same id, which is the check.builtis in flight or unresolved: a same-idtrade()answersIDEMPOTENCY_CONFLICT, and if that carriesstage: "unconfirmed"andretryable: false, check itssignatureon chain and do not retry.launch()andlaunchAsync(). Retry with the same id, or readgetLaunchJob(id). The rules are in Headless launch, Idempotency.selfLaunch()and a linkedtrade(). You sign and broadcast here, so the id is necessary and not sufficient: an error after the broadcast may sit on a transaction that landed, and calling again under the same id builds and broadcasts a new one. Read the job first (getLaunchJob(id),getTradeJob(id)); if it is notconfirmed, check the wallet’s recent transactions before sending again. The example above is not safe for these flows.launchAtomic().timeoutandfailedare returned values and carryclientLaunchId.bundleIdis dead once/submithas been called and is never resubmitted; it is there for log correlation. After atimeout, afailed, or an error that carriesclientLaunchId:- Read
getLaunchJob(clientLaunchId). This needslaunch:readon the key. The Write and Withdraw access levels hold it; a key whose scopes were named one by one holds it only if it was named, so addlaunch:readto any key that runs atomic launches. A scope refusal is not “not found”. confirmed: the bundle landed. The mint is on the job. Stop.- 404
JOB_NOT_FOUND: the build never began. CalllaunchAtomic()again with the same id. failed: the bundle did not land. CalllaunchAtomic()again with the same id; the server starts a clean attempt under it.- Any other status: the bundle may still land. Wait, then read the job again or call
launchAtomic()again with the same id and the same body. The server resolves the first attempt before doing anything new. It answers 409IDEMPOTENCY_CONFLICTwhile the first attempt is unresolved or has just been found landed, so on that 409 read the job again. It builds a fresh bundle only when the first is proven reverted. This call can take about 95 seconds to answer. - Never send a new id while the job is not
failed. That is a second token. This includes the 409 whose message says “use a new clientLaunchId to launch again”: read the job first, because that message is also what you see for a launch you do not yet know landed. - One state has no terminal answer today: the same-id call keeps answering 409 “This launch was sent; its original attempt must be resolved” and the job is still neither
confirmednorfailed, a few minutes after the submit (a blockhash lives about 90 seconds and the rebuild itself can take about 95). Stop. Do not send a new id on a timer; report theclientLaunchIdandbundleIdto whoever operates the key. - Treat every
timeoutas unresolved.retryableisfalseon everytimeoutthe server returns today, and it is never permission to relaunch under a new id.
- Read
Errors
Section titled “Errors”Every structured error response becomes a CandleApiError carrying code, status, retryable, and field where the API supplied one. Branch on code, never on message text. Responses that are not structured envelopes throw the same error type with code set to HTTP_<status>.
From the six one-call write methods, a CandleApiError or JsonRpcError also carries the idempotency id the call sent (clientTradeId or clientLaunchId, and bundleId from launchAtomic()). Any other failure after the first request was sent, such as a rejected fetch or a 2xx body that is not JSON, is a CandleOperationError carrying the same fields, with the original as error.cause. Before 0.5.0 that case surfaced as the raw error, so code that matched error instanceof TypeError on these calls should read error.cause.
try { await candle.launch(req)} catch (err) { if (err instanceof CandleApiError && err.code === "INSUFFICIENT_OWNER_BALANCE") { await topUpWallet() }}Webhook verification
Section titled “Webhook verification”import { verifyWebhookSignature } from "@candledottv/agent-sdk"
const ok = verifyWebhookSignature( process.env.CANDLE_WEBHOOK_SECRET, req.headers["x-candle-signature"], rawBody, Math.floor(Date.now() / 1000),)Constant-time comparison and a 300 second default tolerance window. Pass the raw body text exactly as received, not a re-serialized object. See Webhooks for the full delivery contract.
Next steps
Section titled “Next steps”- The endpoints behind these methods: Headless launch API, Agent trading API, Market state API
- Getting a key, and the tier system: Agent access & API keys
importWallet(), spend limits, and self-signed launches: Agent wallets & spend limits- Trading a key’s TEE wallets with the CLI’s key signer PEM: SDK: trade with a key signer PEM
- The same surface as agent tools: Candle MCP server