Skip to main content

AGENTS.md

Top-level Context

Circuit is a consumer interface and general-purpose infrastructure for executing programmatic financial strategies (called “agents”) on self-custodial wallets. It lets users run algorithmic agents—ranging from simple yield farming scripts to complex AI-assisted trading strategies—on their own wallets across Ethereum, Solana, Hyperliquid, Polymarket, and other blockchains, without ever giving up custody of their private keys.

Agent Requirements Checklist

  1. ABIs MUST be in a separate constants file, most constants SHOULD be in a separate constants file.
  2. Use viem (TypeScript) or web3py (Python) for most onchain tasks such as calldata encoding or RPC calls - almost everything except signing a transaction.
  3. Use DefiLlama as your primary source of truth for high-quality data.
  4. Logs should be interesting to a human audience, including live stats (APY, TVL, entry price) or new world information, and explain why the agent did what it did.
  5. Focus on clear simple understandable auditable financial logic - they must be trustworthy and reliable since people will run money through them.

Agent Developer — Building Circuit Agents with the SDK + CLI

End-to-End Workflow Summary

  1. Install CLI and SDK
  2. Authenticate with circuit signin
  3. Initialize project with circuit new
  4. Install dependencies (bun install or uv sync)
  5. Write agent logic in run() and unwind() functions
  6. Test locally with circuit run then circuit unwind
  7. Publish with circuit publish

Installation

Prerequisites:
  • Bun (required for CLI and TypeScript)
  • Python 3.12+ (for Python agents)
  • A Circuit account
CLI (always via Bun):
bun install -g @circuitorg/agent-cli
circuit --version
Python SDK:
uv pip install circuit-agent-sdk
# or: pip install circuit-agent-sdk
TypeScript SDK:
bun add @circuitorg/agent-sdk
# or: npm install @circuitorg/agent-sdk
Recommended tooling:
  • Python: uv (package manager), ruff (linter), ty (typechecker)
  • TypeScript: Bun (runtime), Biome (linter)

Project Structure

After circuit new, you get: TypeScript:
<agent-name>/
├── index.ts          # Main agent code
├── DESCRIPTION.md    # Agent description
├── circuit.toml     # Agent configuration
└── package.json      # Dependencies
Python:
<agent-name>/
├── main.py           # Main agent code
├── DESCRIPTION.md    # Agent description
├── circuit.toml     # Agent configuration
└── pyproject.toml    # Dependencies (pin versions!)

The circuit.toml Configuration File

This is critical — it defines your agent’s metadata, asset requirements, and behavior:
name = "My Agent"
tagline = "Five-word subtitle goes here"
walletType = "ethereum"  # or "solana"
allowedExecutionModes = ["auto", "manual"]
runtimeIntervalMinutes = 15
filesToInclude = []
filesToExclude = []
imageUrl = "https://cdn.circuit.org/agents/default"
version = "0.0.1"

[startingAsset]
network = "ethereum:1"
address = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
minimumAmount = "10000000000000000"
Agent descriptions go in a separate DESCRIPTION.md file in the same directory as circuit.toml:
## Summary

A brief description of what your agent does and the value it provides to users.

## Strategy

Explain the core logic and decision-making process your agent follows.

## Risks

Outline the key risks users should understand before starting a session.

## Changelog

- **0.0.1** — Initial release.
Key fields:
  • walletType — The wallet type for this agent: "ethereum" or "solana"
  • allowedExecutionModes"auto" (transactions execute immediately) and/or "manual" (user must approve in UI). First entry is used for circuit run.
  • runtimeIntervalMinutes — How often run() is called
  • filesToInclude / filesToExclude — For monorepo setups
  • imageUrl — URL for the agent icon displayed in the Circuit UI
  • version — Semver version (major.minor.patch), auto-bumped on publish
  • startingAsset — The asset a user must have to start a session; minimumAmount in raw units (wei/lamports)

Agent Code Pattern

TypeScript:
import { Agent, type AgentContext, type CurrentPosition } from "@circuitorg/agent-sdk";

async function run(agent: AgentContext): Promise<void> {
  await agent.log("Hello from my agent!");
  // Your strategy logic here
}

async function unwind(agent: AgentContext, positions: CurrentPosition[]): Promise<void> {
  await agent.log(`Unwind requested for ${positions.length} positions`);
  // Unwind logic for each position
}

const agent = new Agent({
  runFunction: run,
  unwindFunction: unwind
});

export default agent.getExport();
Python:
from agent_sdk import Agent, AgentContext
from agent_sdk.agent_context import CurrentPosition

def run(agent: AgentContext) -> None:
    agent.log("Hello from my agent!")
    # Your strategy logic here

def unwind(agent: AgentContext, positions: list[CurrentPosition]) -> None:
    agent.log(f"Unwind requested for {len(positions)} positions")
    # Unwind logic for each position

agent = Agent(run_function=run, unwind_function=unwind)
handler = agent.get_handler()

if __name__ == "__main__":
    agent.run()

Execution Model

  1. Circuit sends a run or unwind command to your agent
  2. SDK creates an AgentContext object
  3. SDK calls your run function with the context
  4. Your code uses SDK methods to analyze positions, execute trades, etc.
  5. SDK returns results to Circuit
  6. In manual mode, transactions are submitted for user approval in the Circuit UI
Two modes:
  • auto — Transactions execute automatically
  • manual — Transactions become suggestions for user approval
From the agent’s perspective, the code is identical — Circuit’s API handles the routing based on mode. The response will include suggested: bool and suggestionId: int in manual mode. Important: All suggested transactions are automatically soft-deleted at the beginning of each run execution. Use expiresAt field for shorter expiry. Execution interval: Set via runtimeIntervalMinutes in circuit.toml. The interval starts after the previous run completes.

Agent Context (AgentContext)

The AgentContext object is passed to both run and unwind functions. It contains: Session Data:
  • sessionId (number) — Unique session identifier
  • sessionWalletAddress (string) — Wallet address for this session
  • currentPositions (array) — Assets allocated at execution start
  • executionMode (string) — "auto" or "manual"
Each position in currentPositions:
  • network (string) — e.g., "ethereum:137", "solana"
  • assetAddress (string) — Token contract address
  • tokenId (string | null) — For NFTs/ERC1155
  • avgUnitCost (string) — Average unit cost in USD (currently hardcoded to 0)
  • currentQty (string) — Current quantity in raw units
SDK Methods (TypeScript / Python):
  • log() — Send messages to users and log locally
  • memory — Session-scoped key-value storage (.set(), .get(), .delete(), .list())
  • swidge — Cross-chain swap and bridge operations (.quote(), .execute())
  • platforms.hyperliquid — Hyperliquid DEX integration
  • platforms.polymarket — Polymarket prediction market integration
  • signAndSend() / sign_and_send() — Sign and broadcast custom transactions
  • signMessage() / sign_message() — Sign messages (EVM only)
  • transactions() — Get transaction history
  • getCurrentPositions() / get_current_positions() — Get live positions
  • clearSuggestedTransactions() / clear_suggested_transactions() — Clear pending manual mode suggestions

Network Identifiers

Used everywhere in the SDK:
  • EVM: "ethereum:{chainId}" — e.g., "ethereum:1" (Mainnet), "ethereum:137" (Polygon), "ethereum:42161" (Arbitrum), "ethereum:8453" (Base)
  • Solana: "solana"
  • Hyperliquid Perps: "hypercore:perp"
  • Hyperliquid Spot: "hypercore:spot"
Native token addresses:
  • EVM chains: "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" (EIP-7528)
  • Solana: "11111111111111111111111111111111" (System Program)

SDK Response Pattern

Every SDK method returns a response object with consistent shape:
{
  success: boolean;
  data?: any;       // present on success
  error?: string;   // present on failure
}
Always check success before using data:
const result = await agent.swidge.quote({...});
if (result.success && result.data) {
  // Safe to use result.data
} else {
  await agent.log(result.error || "Request failed", { error: true });
}
Uncaught exceptions in run/unwind are automatically caught by the SDK — the execution is marked failed and the error is logged. You do NOT need try/catch around SDK methods. Use try/catch only for your own parsing, calculations, or external API calls.

Amounts and Units

  • All amounts are strings in smallest units (wei for EVM, lamports for Solana)
  • Keep as strings to preserve precision
  • Exception: Hyperliquid uses formatted values (e.g., “1.5” for $1.5 USDC)

Logging

// Standard log (visible to user in Circuit UI)
await agent.log("Processing transaction");

// Error log
await agent.log("Failed to load settings", { error: true });

// Debug log (console only, NOT sent to user)
await agent.log("Internal state", { debug: true });

// Log objects only with debug flag
await agent.log({ key: "value" }, { debug: true });
Python equivalent: agent.log("msg"), agent.log("msg", error=True), agent.log("msg", debug=True) Best practice: User-facing logs should be clean, concise strings. Use debug=True for internal/object logging. Logs should be interesting to a human user and provide context about why the agent did a specific action. Logs should also provide entertaining or informative bits of information or statistics, such as a protocol’s TVL or current yield or an observed event in the world.

Memory (Session-Scoped Key-Value Storage)

Keys are auto-namespaced by agent and session. Memory persists across execution cycles within the same session, cleared when the session ends. Values must be strings — serialize JSON/numbers before storing.
// Set
await agent.memory.set("lastPrice", "45000.50");

// Get
const result = await agent.memory.get("lastPrice");
if (result.success && result.data) {
  const price = parseFloat(result.data.value);
}

// Delete
await agent.memory.delete("tempKey");

// List all keys
const keys = await agent.memory.list();
Python: agent.memory.set("key", "value"), agent.memory.get("key"), agent.memory.delete("key"), agent.memory.list()

Suggestions (Manual Mode)

If your agent supports manual mode, transactions become suggestions for user approval. Circuit auto-clears suggestions at the start of each run. You can also manually clear:
await agent.clearSuggestedTransactions();
Python: agent.clear_suggested_transactions()

Positions

agent.currentPositions — Snapshot at execution start. Use for initial decision-making. getCurrentPositions() / get_current_positions() — Fetch live positions. Note: indexing can lag by chain; may not reflect transactions immediately. Handling pending transactions: Check hasPendingTxs flag. If true, balances may not be accurate. For time-sensitive logic, track deltas in memory.
const positions = await agent.getCurrentPositions();
if (positions.success && positions.data) {
  if (positions.data.hasPendingTxs) {
    await agent.log("Pending transactions detected");
  }
  for (const pos of positions.data.positions) {
    await agent.log(`${pos.assetAddress}: ${pos.currentQty}`);
  }
}

Swap and Bridge

Two-step workflow: quote then execute.
// 1. Get quote
const quote = await agent.swidge.quote({
  from: { network: "ethereum:137", address: agent.sessionWalletAddress },
  to: { network: "ethereum:137", address: agent.sessionWalletAddress },
  amount: "1000000",  // 1 USDC (6 decimals), in raw units
  fromToken: "0x2791bca1f2de4661ed88a30c99a7a9449aa84174",  // USDC
  toToken: "0xd93f7e271cb87c23aaa73edc008a79646d1f9912",    // wSOL
  slippage: "10.0"
});

// 2. Execute
if (quote.success && quote.data) {
  const result = await agent.swidge.execute(quote.data);
  if (result.success && result.data?.status === "success") {
    await agent.log("Swap completed!");
  }
}
Key notes:
  • Omit fromToken/toToken for native tokens (ETH, SOL)
  • Same network = swap; different networks = bridge
  • Default slippage: 0.5%; use 1-2% for volatile/cross-chain
  • Circuit filters >100% price impact automatically — validate against your own thresholds
  • Minimum $10-20 recommended to avoid fee issues
  • Bulk execution: pass array of quotes to execute()
  • Execution status is final: "success", "failure", "refund", or "delayed"
  • execute() supports waitForConfirmation (default true) — polls EVM transaction receipts and returns success: false on reverts
  • In manual mode, execute() returns a suggestion instead of executing (data.suggested = true)

Custom Transactions / Signing

All transactional methods (signAndSend, swidge.execute, placeOrder, transfer, marketOrder) accept an optional expiresAt parameter (ISO 8601 string) to control suggestion expiry in manual mode. signAndSend also accepts waitForConfirmation (boolean, EVM only, default true). The SDK polls a public RPC for the transaction receipt after broadcast and returns success: false if the transaction reverted onchain. Set to false to skip receipt polling and return immediately after broadcast. It is recommended to use viem or web3py to populate calldata and other raw transaction data, rather than doing raw low-level transformations in the script. Sign and send (EVM):
const response = await agent.signAndSend({
  network: "ethereum:1",
  request: {
    toAddress: "0x...",
    data: "0x...",     // calldata, use "0x" for simple transfers
    value: "100000000000000",  // wei
  },
  message: "Description for UI",
  waitForConfirmation: true, // optional, wait for onchain confirmation
  expiresAt: "2030-01-01T00:00:00Z" // optional, manual mode only
});
Sign and send (Solana):
const response = await agent.signAndSend({
  network: "solana",
  request: {
    hexTransaction: "010001030a0b..."  // Serialized VersionedTransaction as hex
  }
});
Sign message (EVM only):
const signature = await agent.signMessage({
  network: "ethereum:1",
  request: {
    messageType: "eip191",  // or "eip712"
    data: { message: "Hello, world!" },
    chainId: 1
  }
});
// signature.formattedSignature contains the complete hex signature
Python equivalents: agent.sign_and_send(), agent.sign_message() with snake_case field names in dicts (to_address, hex_transaction, etc.). The SDK converts to camelCase internally.

Transaction History

const result = await agent.transactions();
if (result.success && result.data) {
  for (const tx of result.data) {
    await agent.log(`${tx.tokenType} ${tx.amount} on ${tx.network} - ${tx.transactionHash}`);
  }
}
Each record includes: network, transactionHash, from/to, amount, token, tokenType, tokenUsdPrice, timestamp. Note: indexing has a delay per chain.

Hyperliquid Integration

Configuration in circuit.toml:
[startingAsset]
network = "hypercore:perp"
address = "USDC"
minimumAmount = "1000000"  # 1 USDC (6 decimals)
Key differences for Hyperliquid agents:
  • Only one Hyperliquid agent per wallet (to safely manage margin)
  • Your entire wallet balance is available to the agent
  • Use agent.platforms.hyperliquid.balances() and agent.platforms.hyperliquid.positions() instead of currentPositions
  • Avoid calling balances/positions in loops — Hyperliquid will rate limit
  • All values are in formatted amounts (not raw units)
  • Must respect Hyperliquid’s tick and lot size rules when placing orders
Place order:
const order = await agent.platforms.hyperliquid.placeOrder({
  symbol: "BTC",          // Perps: "BTC". Spot: "BTC/USDC"
  side: "buy",
  size: 0.0001,
  price: 110000,          // Slippage limit for market orders
  market: "perp",         // "perp" or "spot"
  type: "market",         // "market", "limit", "stop", "take_profit"
  expiresAt: "2030-01-01T00:00:00Z" // optional, manual mode only
});
Other methods:
  • balances() — Perp account value + spot balances
  • positions() — Open perpetual positions
  • order(orderId) — Get order info
  • deleteOrder(orderId, symbol) — Cancel order
  • openOrders() — All open orders
  • orders() — Historical orders
  • orderFills() — Fill history
  • transfer({ amount, toPerp, expiresAt? }) — Transfer between spot and perp
  • liquidations(startTime?) — Liquidation events

Polymarket Integration

Market order:
// tokenId identifies a specific outcome — get it from Polymarket's CLOB API or from getCurrentPositions()
// BUY: size = USD amount to spend
const buy = await agent.platforms.polymarket.marketOrder({
  tokenId: "71321045679252212594626385532706912750332728571942532289631379312455583992563",
  size: 10,      // Spend $10
  side: "BUY",
  expiresAt: "2030-01-01T00:00:00Z", // optional, manual mode only
  waitForConfirmation: true, // optional (default true), polls receipt and detects reverts
});

// SELL: size = number of shares to sell
const sell = await agent.platforms.polymarket.marketOrder({
  tokenId: "71321045679252212594626385532706912750332728571942532289631379312455583992563",
  size: 5,       // Sell 5 shares
  side: "SELL"
});
Redeem positions:
// Redeem all settled positions
const result = await agent.platforms.polymarket.redeemPositions();

// Redeem specific positions
const result = await agent.platforms.polymarket.redeemPositions({
  tokenIds: ["123456", "789012"]
});
Notes: Polymarket has decimal precision differences for buys/sells — may result in dust. Clean up with redeemPositions() after market expiry. Position data includes enriched metadata (question, outcome, PNL) via getCurrentPositions(). Both marketOrder and redeemPositions support waitForConfirmation (default true) — the SDK polls for transaction receipts on Polygon and returns success: false if any transaction reverts.

Error Handling Best Practices

  1. Always check success before using data
  2. Log errors with { error: true } for UI visibility
  3. Return early on errors
  4. Don’t wrap SDK calls in try/catch — use .success checks
  5. Uncaught exceptions are auto-caught by SDK
  6. Use try/catch for your own logic, external APIs, parsing
Common error patterns:
ErrorCauseSolution
”Key not found”Memory key doesn’t existHandle missing keys gracefully
”Quote failed”Amount too small / no liquidityIncrease amount, adjust slippage
”Transaction failed”Insufficient balance/gasCheck getCurrentPositions() first
”Transaction reverted onchain”Tx was mined but revertedCheck contract logic, input params
”Invalid request”Bad parametersValidate inputs before SDK calls

CLI Commands Reference

CommandDescription
circuit signinAuthenticate (opens browser)
circuit signoutSign out
circuit whoamiShow current user
circuit newCreate new agent project
circuit runTest agent locally
circuit unwindUnwind positions and end session
circuit devWatch mode — re-runs agent on file changes
circuit checkValidate agent project offline (no auth required)
circuit publishPublish to production
circuit wallet listList wallets
circuit wallet importImport wallet for testing
Global flags (all commands): --path / -p (agent directory), --json / -j (JSON output), --help / -h. circuit new flags: --language / -l (typescript or python), --name / -n (agent name), --template / -t (bare, swap, hyperliquid, polymarket). All skip interactive prompts. circuit run flags: --wallet / -w (wallet address), --mode / -m (auto or manual), --amount / -a (token amount in smallest unit), --path / -p (agent directory), --local-sdk-dependencies / -l (skip dep install). circuit run behavior: Creates a test session, starts local agent server, executes run(), streams logs to terminal. Requires dependencies installed (bun install / uv sync). circuit unwind flags: --wallet / -w (wallet address), --path / -p (agent directory), --local-sdk-dependencies / -l (skip dep install). circuit dev flags: Same as circuit run. Watches for file changes and runs rununwind cycle on each iteration. circuit publish flags: --path / -p (agent directory), --env KEY=VALUE / -e KEY=VALUE (environment variables, repeatable). circuit publish behavior: Validates config, uploads files, publishes to Circuit infrastructure. Requires authentication and valid circuit.toml.

Multi-Agent Monorepo Support

You can organize multiple agents in a monorepo with shared utility code:
my-circuit-agents/
├── agents/
│   ├── agent-1/
│   │   ├── main.py / index.ts
│   │   ├── DESCRIPTION.md
│   │   ├── circuit.toml
│   │   └── pyproject.toml / package.json
│   ├── agent-2/
│   │   └── ...
└── utils/
    ├── __init__.py / index.ts
    └── shared_module/
Use filesToInclude = ["../../utils"] in circuit.toml to include shared directories. For Python, add sys.path.insert(0, ...) at the top of main.py. For TypeScript, configure tsconfig.json path aliases.

Complete Example: Yield Agent

See the Yield Agent example in the docs for a full working agent that:
  1. Checks currentPositions for USDC balance
  2. Approves Aave V3 once (max uint256, tracked via memory), then deposits USDC using signAndSend
  3. Withdraws all USDC from Aave V3 on unwind
For more detail on any topic above, see the full docs at docs.circuit.org: