SDK: trade with a key signer PEM
A TypeScript bot built on @candledottv/agent-sdk can trade its API key’s Solana TEE wallets with the key’s key signer, exported as a PEM file by the CLI. The SDK already signs with a P-256 PEM; this page shows where that PEM comes from and how to hand it to the client.
Read Run a bot on a headless machine first. The same rules apply to an SDK bot: the box holds the API key and the signer, never a device token.
The PEM is a weaker copy
Section titled “The PEM is a weaker copy”candle tee signer new --key <prefix> --out <path> writes the signer’s private key to <path> as a PKCS#8 PEM, mode 0600, and still keeps it in the CLI’s secret store. It exists for a process that cannot read that store. Before you use it:
- It is plaintext on disk. Anyone who can read the file, or a backup or snapshot of that disk, holds the signer of every wallet on the key. With the API key as well, they can trade those wallets within the key’s scopes and caps.
- It is opt-in. Without
--outnothing is written outside the secret store. Prefer running the bot through the CLI when you can. - Nothing keeps it in step. When you rotate the signer or
keys signer moveretires an old one, the CLI updates its store and leaves your PEM alone. Delete an old PEM yourself. - Do not log the bot box in. A box with a device token beside the signer can approve its own signer request. See Do not log the bot box in.
Keep the file on the box, readable only by the bot’s user, and out of source control, images and shared volumes.
Make the PEM
Section titled “Make the PEM”On the bot box, as the bot’s user, with the key’s API key in CANDLE_API_KEY:
candle tee signer new --key tr-2 --out /var/lib/candle-bot/key-signer.pemIt prints the signer’s fingerprint (CNDL-XXXX-XXXX-XXXX), an approval link and a code, and waits. Approve it from your own machine, typing the full fingerprint, then promote onto the key from the vault machine (candle vault promote-batch --to-key tr-2 ...). Wallets promoted onto the key are owned by this signer at once. Wallets the key held before keep their old signer until candle keys signer move runs on the machine that holds it.
Load it in the bot
Section titled “Load it in the bot”CandleClient signs a linked wallet’s trade with the PEM its secretStore returns for that wallet’s id. A key signer owns many wallets, so the store returns the same PEM for each wallet that signer owns, and nothing for the rest:
import { createHash, createPublicKey } from "node:crypto"import { readFileSync } from "node:fs"import { CandleClient, type SecretStore } from "@candledottv/agent-sdk"
const apiUrl = "https://api.alpha.candle.tv"const apiKey = process.env.CANDLE_API_KEY as stringconst pem = readFileSync("/var/lib/candle-bot/key-signer.pem", "utf8")
async function get(path: string): Promise<any> { const res = await fetch(`${apiUrl}${path}`, { headers: { "x-api-key": apiKey } }) if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`) return res.json()}
// The key's active signer must be this PEM. Compare the full SPKI hash, never the display form.const signer = await get("/api/v1/agent/keys/self/signer")const spki = createPublicKey(pem).export({ type: "spki", format: "der" })const spkiSha256 = createHash("sha256").update(spki).digest("hex")if (signer.state !== "active" || signer.spkiSha256 !== spkiSha256) { throw new Error(`This PEM is not key ${signer.keyPrefix}'s active signer (${signer.fingerprint})`)}
// The TEE wallets bound to this key, and which of them this signer owns.const wallets: Array<{ id: string; privyWalletId: string; chain: string; signerQuorumId?: string; active: boolean }> = []let privyAppId = ""let cursor: string | null = nulldo { const page = await get(`/api/v1/agent/wallets/trading${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`) privyAppId = page.privyAppId wallets.push(...page.page) cursor = page.isDone ? null : page.continueCursor} while (cursor)const owned = new Set(wallets.filter((w) => w.signerQuorumId === signer.signerQuorumId).map((w) => w.id))
const keySignerStore: SecretStore = { get: async (walletId) => (owned.has(walletId) ? pem : null), set: async () => { throw new Error("The key signer PEM is read-only") }, delete: async () => {},}
const candle = new CandleClient({ apiUrl, apiKey, privyAppId, secretStore: keySignerStore })
const wallet = wallets.find((w) => owned.has(w.id) && w.chain === "solana" && w.active)if (!wallet) throw new Error("No active Solana TEE wallet on this signer yet")
const result = await candle.trade({ mint: "<mint>", side: "buy", amountRaw: "10000000", // 0.01 SOL from: { linkedWalletId: wallet.id, privyWalletId: wallet.privyWalletId },})console.log(result)GET /api/v1/agent/keys/self/signeranswers with the calling key’s signer:state(active,pendingornone),fingerprint,spkiSha256, andsignerQuorumIdonce it is active, plus the key’s wallets asonSigner,legacyandmoving.GET /api/v1/agent/wallets/tradinglists the TEE wallets bound to the calling key, withprivyWalletId,signerQuorumIdandactive, and the deployment’sprivyAppId, which the client needs to sign.- A wallet whose
signerQuorumIdis not the active one is owned by an older signer or a per-wallet signer on another machine. This PEM cannot sign for it: the store returnsnull, and the SDK throws before it signs anything. Filter onownedbefore you build, as above. Runcandle keys signer move <prefix>on the machine that holds its signer. - Refresh the set when you promote or move wallets, or re-read it on each start.
What the SDK does not do here
Section titled “What the SDK does not do here”- Hood TEE wallets. A Hood TEE trade is sequenced: Candle hands out one leg at a time, with the nonce and gas it set, and the CLI signs exactly that leg. The SDK does not drive that sequence. Trade Hood TEE wallets with
candle swap. - Owner changes. Moving wallets onto a signer (
keys signer move,tee rebindonto a key with a signer) is a CLI job. The SDK has no call for it. - Store management. The SDK’s
KeychainSecretStorereads per-wallet signers (wallet_signer_<id>) written bycandle wallets import. It does not read key signers; that is what the PEM is for.
Next steps
Section titled “Next steps”- Run a bot on a headless machine: the secret store under systemd, launchd and SSH, and why the box is never logged in.
- TypeScript SDK: the client,
trade(), errors and retries. - Agent trading API: the build, sign relay and submit steps
trade()runs.