Start here

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 buildingWho signs inHeaders on trading callsStart here
A bot or agent trading its own fundsThe bot, once, with its own wallet key (SIWE)x-app-id and Authorization: Bearer <accessToken>This page
A platform for many usersEach user, on your clientx-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 UIEach user, in the browserx-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, and viem.
  • 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

SDK
// 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.

SDK
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. Call signIn() again.
  • With cURL, refresh yourself: POST /auth/token/refresh with { "refreshToken": "..." } and x-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.

SDK
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.

SDK
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".

SDK
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

SDK
const fill = await client.executeManaged({ quoteId: quote.quoteId, mode: MODE });

Execute quote

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

SDK
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

SDK
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

ResponseWhat to do
401 with code: "unregistered_domain" on verifyORIGIN is not one of the app's allowed origins. Add it under Domains.
401 Invalid SIWE signatureThe message changed after signing, or the nonce was reused. Run signIn() again.
401 on any other callThe 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 keyThe 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 fillPoll status for the same quoteId first. Re-quote only after the steps in Errors.
400 quote_already_executedAlready filled. Poll status.
403 testing limitThe app reached its testing caps (20 users, 100 trades). See Testing mode.
403 regionTrading is not available in the bot's country. See Venue geo availability.
429Wait 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.

SDK
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));

Next

On this page