Quickstart: trading bot / agent
One headless TypeScript process that signs in with its own key, quotes, fills, and tracks a trade
Decide in 30 seconds
| You are building | Who signs in | Headers on trading calls | Start here |
|---|---|---|---|
| A bot or agent trading its own funds | The bot, once, with its own wallet key (SIWE) | x-app-id and Authorization: Bearer <accessToken> | This page |
| A platform for many users | Each user, on your client | x-app-id and that user's Authorization: Bearer token. Your backend adds x-app-api-key only for server features. | Quickstart: REST, Authentication & sessions |
| A trading terminal UI | Each user, in the browser | x-app-id and the user's token, sent by the SDK. Never ship x-app-api-key to a browser. | Quickstart: React |
Which call needs which header: Base URL, app IDs & API keys.
A bot is one user. It signs a SIWE message with its own private key, gets a user access token, and trades from that user's managed balance. No browser, no API key.
You'll need:
- An app ID from the admin dashboard (
https://admin.agg.market), under API. - One allowed origin on that app (Domains), for example
https://bot.example.com. It never has to serve a page. Wallet sign-in names it, and an app with none cannot complete sign-in. - Node 18 or later,
@agg-build/sdk, andviem. - A private key for the bot. Keep it out of source control.
Result: a filled order, polled until terminal is true, then the bot's positions.
The steps below run top to bottom as one file. The same code as a single script is in
Full script. It trades in paper mode unless you set AGG_LIVE=1.
0. Set up
// Add these imports (the full script below has them):
// import { createAggClient, isAggApiError, MarketStatus } from "@agg-build/sdk";
// import { privateKeyToAccount } from "viem/accounts";
const ORIGIN = "https://bot.example.com"; // one of your app's allowed origins
const MODE = process.env.AGG_LIVE === "1" ? "live" : "paper";
const client = createAggClient({
baseUrl: "https://api.t.sufwankhalid.com",
appId: process.env.AGG_APP_ID ?? "your-app-id",
persistSession: false, // no browser storage
});
const account = privateKeyToAccount(process.env.BOT_PRIVATE_KEY as `0x${string}`);1. Sign in with the bot's key
Get a nonce, build the SIWE message, sign it locally, and verify it. verify() keeps the access
and refresh tokens in the client and sends them from then on.
async function signIn() {
const start = await client.authStart({ provider: "siwe" });
if (start.type !== "nonce") throw new Error(`Expected a nonce, got ${start.type}`);
const message = client.buildSiweMessage({
address: account.address,
chainId: 1,
nonce: start.nonce,
statement: start.statement, // set only when your app configures one
domain: new URL(ORIGIN).host,
uri: ORIGIN,
});
const signature = await account.signMessage({ message });
return client.verify({ message, signature });
}
const { user } = await signIn();
console.log("signed in as", user.id);Start authentication · Verify sign-in
Keeping the session alive
- The SDK keeps tokens in memory only. A restarted process must call
signIn()again. Each sign-in uses a fresh nonce. - The access token lasts 15 minutes and the refresh token 7 days. When a call returns
401, the SDK refreshes once with the refresh token, stores the new pair, and retries the call. You write no refresh code. - If that refresh fails (the refresh token expired or was revoked), the SDK clears the session and
throws the
401. CallsignIn()again. - With cURL, refresh yourself:
POST /auth/token/refreshwith{ "refreshToken": "..." }andx-app-id, then use the new pair. See Refresh access token. - In Node, a client holds the process open. Call
client.destroy()when the bot is done.
2. Fund the bot
Live mode only. In paper mode, skip to the balance check: the paper account starts with a
simulated PAPER_USD balance.
if (MODE === "live") {
let deposit = await client.getDepositAddresses();
while (!deposit.ready) {
await new Promise((r) => setTimeout(r, 2000));
deposit = await client.getDepositAddresses();
}
console.log("send USDC to", deposit.evmAddress, "on", deposit.supportedChains);
}
const balances = await client.getManagedBalances({ mode: MODE });
console.log(balances.cash.map((c) => [c.tokenSymbol, c.availableRaw, c.decimals]));Get deposit addresses · Get balances · Funding
availableRaw is an integer string in the token's decimals: "5000000" with 6 decimals is 5 USDC.
3. Find an outcome
You trade an outcome, not a market. Take a venueMarketOutcomes[].id.
const markets = await client.getVenueMarkets({ status: MarketStatus.open, search: "bitcoin", limit: 5 });
const outcomeId = markets.data[0]?.venueMarketOutcomes?.[0]?.id;
if (!outcomeId) throw new Error("No open outcome found; try another search");List venue markets · Markets, outcomes & matching
4. Quote
The bot's token makes the quote executable. Fill only when status is "ok".
const quote = await client.getSmartRoute({
venueMarketOutcomeId: outcomeId,
tradeSide: "buy",
maxSpend: 5,
mode: MODE,
});
if (quote.status !== "ok") throw new Error(`${quote.status}: ${quote.error ?? ""}`);Get a quote (smart route) · Quotes & smart routing
A quote expires about 45 seconds after it is created. Fill right away.
5. Fill
const fill = await client.executeManaged({ quoteId: quote.quoteId, mode: MODE });The response means accepted, not done. If the call times out, do not re-quote: resend the same
quoteId or poll status. Resending a quoteId never trades twice. See
Errors, retries & idempotency.
6. Poll to a final state
let status = await client.getExecutionStatus({ quoteId: fill.quoteId, mode: MODE });
while (!status.terminal) {
await new Promise((r) => setTimeout(r, status.pollAfterMs ?? 1000));
status = await client.getExecutionStatus({ quoteId: fill.quoteId, mode: MODE });
}
console.log(status.overallState, status.errorReason ?? "");Get execution status · Order lifecycle & statuses
overallState ends as filled, partially_filled, failed, cancelled, or expired.
7. Read positions
const positions = await client.getExecutionPositions({ mode: MODE });
for (const p of positions.data) {
console.log(p.venueMarket.question, p.venueMarket.venueMarketOutcomes.map((o) => [o.label, o.totalSize]));
}
client.destroy();Get user positions. When a market settles, claim winnings with Claim winnings.
Paper mode
mode: "paper" on the quote, the fill, the status call, and the balance and position reads.
Paper fills price against live books but never reach a venue, so no deposit is needed. A paper
quote must be filled in paper mode. See Paper trading.
To trade live, set AGG_LIVE=1, fund the deposit address from step 2, and drop mode=paper from
the cURL calls.
Failures a bot hits
| Response | What to do |
|---|---|
401 with code: "unregistered_domain" on verify | ORIGIN is not one of the app's allowed origins. Add it under Domains. |
401 Invalid SIWE signature | The message changed after signing, or the nonce was reused. Run signIn() again. |
401 on any other call | The SDK already tried one refresh. Run signIn() again. |
/auth/start returns type: "challenge_required" | Bot protection is on and the app is past its user threshold. A headless bot cannot solve it. See Bot protection. |
401 that names an API key | The app has Require API key on. Pass apiKey to createAggClient from a secret store. |
Quote status not "ok" | insufficient_balance: fund or lower maxSpend. min_order_size_violated: raise maxSpend. insufficient_depth, no_orderbooks: pick another outcome. |
400 quote_not_found or quote_expired on fill | Poll status for the same quoteId first. Re-quote only after the steps in Errors. |
400 quote_already_executed | Already filled. Poll status. |
403 testing limit | The app reached its testing caps (20 users, 100 trades). See Testing mode. |
403 region | Trading is not available in the bot's country. See Venue geo availability. |
429 | Wait Retry-After seconds. The SDK error carries it as retryAfterMs. |
The SDK throws AggApiError with status, code, and retryAfterMs; check it with
isAggApiError(err). Every code: Errors, retries & idempotency.
Full script
Save as bot.ts and run with AGG_APP_ID=... BOT_PRIVATE_KEY=0x... npx tsx bot.ts.
import { createAggClient, isAggApiError, MarketStatus } from "@agg-build/sdk";
import { privateKeyToAccount } from "viem/accounts";
async function main() {
const ORIGIN = "https://bot.example.com"; // one of your app's allowed origins
const MODE = process.env.AGG_LIVE === "1" ? "live" : "paper";
const client = createAggClient({
baseUrl: "https://api.t.sufwankhalid.com",
appId: process.env.AGG_APP_ID ?? "your-app-id",
persistSession: false,
});
const account = privateKeyToAccount(process.env.BOT_PRIVATE_KEY as `0x${string}`);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
try {
// 1. Sign in with the bot's key
const start = await client.authStart({ provider: "siwe" });
if (start.type !== "nonce") throw new Error(`Expected a nonce, got ${start.type}`);
const message = client.buildSiweMessage({
address: account.address,
chainId: 1,
nonce: start.nonce,
statement: start.statement,
domain: new URL(ORIGIN).host,
uri: ORIGIN,
});
await client.verify({ message, signature: await account.signMessage({ message }) });
// 2. Fund (live only) and check the balance
if (MODE === "live") {
let deposit = await client.getDepositAddresses();
while (!deposit.ready) {
await sleep(2000);
deposit = await client.getDepositAddresses();
}
console.log("send USDC to", deposit.evmAddress, "on", deposit.supportedChains);
}
const balances = await client.getManagedBalances({ mode: MODE });
console.log(balances.cash.map((c) => [c.tokenSymbol, c.availableRaw, c.decimals]));
// 3. Find an outcome
const markets = await client.getVenueMarkets({ status: MarketStatus.open, search: "bitcoin", limit: 5 });
const outcomeId = markets.data[0]?.venueMarketOutcomes?.[0]?.id;
if (!outcomeId) throw new Error("No open outcome found; try another search");
// 4. Quote
const quote = await client.getSmartRoute({
venueMarketOutcomeId: outcomeId,
tradeSide: "buy",
maxSpend: 5,
mode: MODE,
});
if (quote.status !== "ok") throw new Error(`${quote.status}: ${quote.error ?? ""}`);
// 5. Fill
const fill = await client.executeManaged({ quoteId: quote.quoteId, mode: MODE });
// 6. Poll to a final state
let status = await client.getExecutionStatus({ quoteId: fill.quoteId, mode: MODE });
while (!status.terminal) {
await sleep(status.pollAfterMs ?? 1000);
status = await client.getExecutionStatus({ quoteId: fill.quoteId, mode: MODE });
}
console.log(status.overallState, status.errorReason ?? "");
// 7. Positions
const positions = await client.getExecutionPositions({ mode: MODE });
for (const p of positions.data) {
console.log(p.venueMarket.question, p.venueMarket.venueMarketOutcomes.map((o) => [o.label, o.totalSize]));
}
} catch (err) {
if (isAggApiError(err)) console.error(err.status, err.code, err.message);
throw err;
} finally {
client.destroy(); // lets Node exit
}
}
main().catch(() => process.exit(1));