A natural-language AI trading agent for Synthra. Talk to it in plain English ("swap 10 USDC for SYN on Arc", "bridge 25 USDC from Sepolia to Arc") and it quotes, swaps, and bridges on-chain through the SynRoute API. The reasoning is backed by Claude or OpenAI, switchable with a single environment variable.
This repository is a reference implementation / starting point: a small, readable, tool-calling agent that turns natural language into SynRoute API calls and signs and submits the resulting transactions itself. It is deliberately dependency-light (one LLM SDK, ethers v5, dotenv) so it can be read end to end in a few minutes and forked into a custom agent.
⚠️ The agent holds a private key and executes transactions autonomously. The model decides which on-chain action to take from your message. Use a dedicated, low-balance test key on testnets, keep the safety limits tight, and read the code before pointing it at anything valuable.
- What it is
- Architecture
- Repository map
- Control flow
- Tool catalog
- SynRoute API surface
- Configuration
- Safety model
- Install and run
- Example session
- Extending the agent
- Design notes and gotchas
- Constants reference
- For AI coding agents
- Limitations
A REPL chat agent with three layers:
- LLM layer (
src/llm.ts) is a provider-agnostic tool-calling loop. The model is given a system prompt plus a set of tools and decides which to call. It works identically with Anthropic (Claude) and OpenAI (GPT); pick the backend withLLM_PROVIDER. - Tool layer (
src/tools.ts) is the six capabilities exposed to the model (list_tokens,get_balance,get_price,get_quote,swap,bridge). Tools enforce safety guardrails, resolve token symbols to addresses, call SynRoute, and sign/submit transactions. - Execution layer (
src/synroute/,src/chain/) is a thin SynRoute HTTP client plus anetherswallet wrapper that signs Permit2 typed data and broadcasts transactions.
The model never touches the chain directly and never sees the private key. It only emits structured tool calls; the tool layer does the rest and feeds results back as text.
Replies are streamed token-by-token to the console (both providers), and a short · running <tool>… notice is printed while a tool executes, so you see the agent think and act in real time rather than waiting for the full answer.
┌─────────────────────────────────────────────┐
you (stdin) ──────────▶ REPL (src/index.ts) │
│ │ │
│ ▼ │
│ AgentSession (src/llm.ts) │
│ - system prompt + injected context │
│ - tool-calling loop (Claude OR OpenAI) │
│ │ ▲ │
│ │ tool_use │ tool_result (JSON) │
│ ▼ │ │
│ Tools (src/tools.ts) │
│ - guardrails (chain/amount/slippage) │
│ - symbol → address (src/synroute/tokens) │
│ │ │ │
│ ▼ ▼ │
│ SynRoute client Wallet (src/chain/wallet) │
│ (HTTP, x-api-key) (ethers v5, Permit2) │
└─────┼──────────────────┼─────────────────────┘
▼ ▼
SynRoute REST API EVM RPC (Arc / Sepolia)
│
▼
on-chain transaction
Layered dependency direction (no cycles): index → llm → tools → {synroute/client, synroute/tokens, chain/wallet} → config.
| Path | Lines | Responsibility | Key exports |
|---|---|---|---|
src/index.ts |
~59 | REPL entry point. Builds tools + context, opens a session, reads stdin, prints replies. | main() |
src/llm.ts |
~147 | Provider-agnostic tool-calling loop. System prompt, AnthropicSession, OpenAISession, MAX_STEPS. |
createSession, buildSystemPrompt, AgentSession |
src/tools.ts |
~365 | The six tools, the swap/bridge execution logic, guardrails, and the runtime context injected into the prompt. | buildTools, buildAgentContext, ToolSpec |
src/synroute/client.ts |
~52 | Tiny typed fetch wrapper for the SynRoute REST API. Adds x-api-key, throws SynrouteError. |
synroute, SynrouteError, Json |
src/synroute/tokens.ts |
~85 | Loads the public token list, resolves symbols/addresses, native-coin aliasing, raw↔human amount conversion. | resolveToken, tokensForChain, toRawAmount, fromRawAmount, nativeToken |
src/chain/wallet.ts |
~92 | ethers v5 wallet: provider cache, sendTx (with Arc gas headroom), ERC20 allowance/balance, Permit2 signing. |
sendTx, ensureErc20Allowance, erc20Balance, signPermit2, walletAddress |
src/chain/abis.ts |
~13 | Minimal ABIs: the CCTP router swapAndBurn struct and a 5-method ERC20. |
CCTP_ROUTER_ABI, ERC20_ABI |
src/config.ts |
~50 | Central env-driven config: LLM, API, wallet, and safety guardrails. Chain registry. | CONFIG, CHAINS, rpcUrlForChain, chainLabel |
src/llm.ts runs a bounded loop (MAX_STEPS = 8). On each turn the model may either return text (done) or request one or more tool calls; the agent executes each tool and appends the JSON result to the conversation, then loops.
send(userText):
append user message
repeat up to MAX_STEPS:
response = model.create(system, tools, messages)
append assistant message
if no tool calls: return assistant text
for each tool call:
result = runTool(name, args) # may hit SynRoute and/or the chain
append tool_result(result as JSON)
return "Stopped after too many tool steps."
Both providers implement the same AgentSession interface, so the loop body is identical apart from SDK-specific message shapes (tool_use/tool_result for Anthropic, tool_calls/role:"tool" for OpenAI). Each turn is streamed: send(userText, { onText, onTool }) invokes onText(delta) for every text token and onTool(name) right before a tool runs (the REPL wires these to stdout). Tool errors are caught and returned to the model as { error: "..." } so it can recover or explain, rather than crashing the process.
The system prompt (BASE_SYSTEM_PROMPT) encodes the operating rules: map words to exact symbols, amounts are human units, same-chain means swap while different chains mean bridge, default to Arc, the wallet is both sender and recipient, use high slippage for bridges, never fabricate values. At startup, buildAgentContext() appends the live wallet address, the configured limits, and the per-chain token catalog so the model rarely needs list_tokens.
executeSwap in src/tools.ts:
1. assertSwapPolicy(chainId, amount, slippageBps) # guardrails
2. resolve tokenIn / tokenOut → addresses + decimals
3. amount(human) → amount(raw) via toRawAmount
4. POST /v1/swap (approvalMode = permit2 | erc20)
5. if ERC20 approval required: sendTx(approval.tokenApproval.approveTransaction)
6. if Permit2 signature required:
signature = signPermit2(approval.permit2.typedData)
POST /v1/swap again with permit2Signature + amount/expiration/nonce/sigDeadline
7. sendTx(response.transaction, withGasHeadroom=true)
8. return { status:'submitted', txHash, amountIn, amountOut, route }
Native input (e.g. ETH on Sepolia) skips steps 5 and 6 and is sent as msg.value.
executeBridge in src/tools.ts orchestrates Circle's Cross-Chain Transfer Protocol end to end:
1. assertChainAllowed(source) + assertSwapPolicy(dest, amount, slippage)
2. resolve tokenIn@source, tokenOut@dest; amount → raw
3. POST /v1/cctp/route → route.route.execution (ex) + bridgeLeg
4. POST /v1/cctp/quote (map bridgeToken→sourceUsdc, carry fee/maxFee)
→ signed.sourceQuote with quoteSignature + intentHash
5. if non-native: ensureErc20Allowance(source, tokenIn, ex.sourceRouter, amountIn)
6. encode swapAndBurn(struct) with the signed quote + SOURCE router commands
sendTx(source, { to: ex.sourceRouter, data, value }) # the burn
7. poll POST /v1/cctp/claim with DESTINATION router params,
every 5s up to 60x, until status in {success, already_settled, already_claimed}
8. return { status, sourceTxHash, destinationTxHash, ... }
Two correctness details are baked in (see Design notes): the sourceQuote field is named bridgeToken but the signer expects sourceUsdc, and the claim must pass the destination router's commands/inputs/deadline explicitly, otherwise the backend recovers source-leg calldata and the bridge silently delivers plain USDC instead of tokenOut.
The six tools the model can call (defined in buildTools()):
| Tool | Inputs (required) | Effect | On-chain? |
|---|---|---|---|
list_tokens |
chainId |
Returns {symbol, name, address, decimals}[] for a chain. |
no |
get_balance |
chainId, token |
Wallet balance of a token (human units). | read |
get_price |
chainId, base, quote |
Current price of base in quote. |
read |
get_quote |
chainId, tokenIn, tokenOut, amount |
Preview a same-chain swap (amountOut, route). No execution. |
read |
swap |
chainId, tokenIn, tokenOut, amount (+ slippageBps, default 50) |
Execute a same-chain swap; signs + submits. | write |
bridge |
sourceChainId, destChainId, tokenIn, tokenOut, amount (+ slippageBps, default 50) |
Cross-chain swap via CCTP; signs + submits + settles. | write |
All amounts are human units of the input token (e.g. "12.5"), never raw/wei; toRawAmount/fromRawAmount handle the conversion. Tokens may be symbols (USDC) or addresses; native coins use the alias ETH/NATIVE.
The agent talks to these endpoints (src/synroute/client.ts). Auth is x-api-key; base URL is SYNTHRA_API_BASE.
| Method | Endpoint | Used by | Notes |
|---|---|---|---|
| GET | /v1/price |
get_price |
?chainId&base"e[&amount] |
| GET | /v1/pools |
(available) | ?chainId&tokenA&tokenB&limit |
| POST | /v1/quote |
get_quote |
tradeType: EXACT_INPUT; returns amountOutDecimals, routeString |
| POST | /v1/swap |
swap |
Returns approval (tokenApproval and/or permit2) and transaction |
| POST | /v1/cctp/route |
bridge |
Returns route.route.execution + bridgeLeg |
| POST | /v1/cctp/quote |
bridge |
Signs the source quote (quoteSignature, intentHash) |
| POST | /v1/cctp/claim |
bridge |
Idempotent settlement; poll until terminal status |
Response fields the agent depends on:
/v1/swapreturnsapproval.tokenApproval.{needsApproval, approveTransaction},approval.permit2.{signatureRequired, typedData},transaction.{to,data,value,gasLimit},amountOut,routeString./v1/cctp/routereturnsroute.state === 'Success',route.route.execution.{sourceRouter, destinationRouter, destinationUniversalRouter, sourceRouterCommands/Inputs/Deadline, destinationRouterCommands/Inputs/Deadline, sourceQuote},route.route.bridgeLeg.{feeAmount, maxFee}./v1/cctp/quotereturnssourceQuote.{quoteSignature, intentHash, tokenIn, amountIn, ...}(the full struct passed toswapAndBurn)./v1/cctp/claimreturnsstatus,destinationTxHash.
For the full, authoritative API contract see the Synthra API docs / the
synthra-apiskill. This README documents only the subset the agent exercises.
All configuration is environment-driven (.env, loaded by dotenv). Copy .env.example to .env.
| Variable | Default | Purpose |
|---|---|---|
LLM_PROVIDER |
anthropic |
anthropic (Claude) or openai (GPT). |
ANTHROPIC_API_KEY |
(none) | Required when LLM_PROVIDER=anthropic. |
ANTHROPIC_MODEL |
claude-sonnet-4-6 |
Anthropic model id. |
OPENAI_API_KEY |
(none) | Required when LLM_PROVIDER=openai. |
OPENAI_MODEL |
gpt-4o |
OpenAI model id. |
SYNTHRA_API_BASE |
https://trading-api.synthra.org |
SynRoute REST base URL. |
SYNTHRA_API_KEY |
(none) | SynRoute API key (sent as x-api-key). |
SYNTHRA_APPROVAL_MODE |
permit2 |
permit2 (Universal Router, recommended) or erc20 (SwapRouter02). |
SYNTHRA_TOKENLIST_URL |
Synthra tokenlist | Source for symbol↔address resolution. |
PRIVATE_KEY |
(none) | The agent's signing key. Dedicated test key only. Unset means read-only mode. |
RPC_URL_5042002 |
(none) | Arc RPC endpoint. |
RPC_URL_11155111 |
(none) | Sepolia RPC endpoint. |
AGENT_MAX_INPUT_AMOUNT |
100 |
Max input amount per swap (human units of input token). |
AGENT_MAX_SLIPPAGE_BPS |
100 |
Max slippage the agent may use, in basis points. |
AGENT_ALLOWED_CHAINS |
5042002,11155111 |
Comma-separated chain ids the agent may touch. |
RPC for chain N is read from RPC_URL_<N> (rpcUrlForChain).
The agent is autonomous, so the guardrails are the contract:
assertChainAllowedrejects every action whose chain is not inAGENT_ALLOWED_CHAINS.assertSwapPolicyenforcesamount ≤ AGENT_MAX_INPUT_AMOUNTandslippageBps ≤ AGENT_MAX_SLIPPAGE_BPSbefore anything is signed. The model cannot talk its way past these; they run in code on everyswap/bridge.- Read-only mode: with no
PRIVATE_KEY,get_*/list_tokensstill work;swap/bridgethrow. - The key never reaches the model. The LLM only emits tool calls; signing happens in
src/chain/wallet.ts. - Authorization model: by design, the user asking to swap/bridge is the authorization (no second confirmation). Add a confirmation tool if you need human-in-the-loop.
Operational guidance: dedicated low-balance key, testnets only, narrow AGENT_ALLOWED_CHAINS, conservative limits, and a SynRoute key scoped to test usage.
Requirements: Node.js ≥ 18, an LLM API key (Anthropic or OpenAI), a SynRoute API key, a funded test wallet, and RPC endpoints for the chains you enable.
git clone <your-fork-url> synroute-agent
cd synroute-agent
npm install
cp .env.example .env # fill in keys, PRIVATE_KEY, RPC URLs, limits
# Dev (no build step, via tsx):
npm run dev
# or build + run:
npm run build && npm start
# type-check only:
npm run typecheck| Script | Command | Purpose |
|---|---|---|
dev |
tsx src/index.ts |
Run from source, fast iteration. |
build |
tsc -p tsconfig.json |
Emit dist/. |
start |
node dist/index.js |
Run the built agent. |
typecheck |
tsc --noEmit |
Strict type check (no emit). |
The build also exposes a synroute-agent bin (dist/index.js).
SynRoute Agent
provider : anthropic (claude-sonnet-4-6)
wallet : 0xA11ce…F00d
chains : 5042002, 11155111
limits : max 100/swap, slippage ≤ 100bps
you › quote 10 USDC to SYN on Arc
agent › 10 USDC ≈ 9,512.4 SYN (route: USDC → SYN, 0.3% pool)
you › swap 5 USDC for SYN
agent ›
· running swap…
Done. Swapped 5 USDC for ~4,756 SYN on Arc. (text streams in live)
tx: https://testnet.arcscan.app/tx/0x… (submitted)
you › bridge 10 USDC from Sepolia to Arc
agent › Bridging 10 USDC Sepolia → Arc via CCTP…
source burn: 0x… ; settled on Arc: 0x… (success)
Add a tool. Append a ToolSpec in buildTools() (src/tools.ts): a name, a JSON-Schema parameters, and an async handler(args) returning a JSON-serializable result. It is automatically exposed to both providers. Keep handlers disciplined: validate inputs (str/num), enforce policy, return structured data.
Add a chain. Add it to CHAINS in src/config.ts (with cctpDomain if it is CCTP-capable), include its id in AGENT_ALLOWED_CHAINS, set RPC_URL_<id>, and add any native-coin entry to NATIVE_TOKENS in src/synroute/tokens.ts. The token list must include that chain's tokens.
Switch / add an LLM provider. Set LLM_PROVIDER. To add a third provider, implement the AgentSession interface in src/llm.ts (a send() that runs the same tool loop against the new SDK) and wire it into createSession.
Change the swap execution path. Flip SYNTHRA_APPROVAL_MODE between permit2 and erc20. The tool layer handles both response shapes.
Tighten authorization. Add a confirm/simulate tool and instruct the model (system prompt) to call it before swap/bridge.
These are encoded in the code for a reason; preserve them when refactoring.
bridgeTokenvssourceUsdc./v1/cctp/route'ssourceQuotenames the bridge assetbridgeToken, but/v1/cctp/quote(the signer) expectssourceUsdc.executeBridgemaps it and carriesfeeAmount/maxFeefallbacks frombridgeLeg.- Destination router params on claim. The claim body must pass
universalRouter,routerCommands,routerInputs,routerDeadlinefrom the destination execution. Omit them and the backend recovers the source leg's calldata, which reverts on the destination chain and falls back to delivering plain USDC instead oftokenOut. - Arc gas headroom. Arc's native-USDC is an ERC20-facade precompile that gas estimation under-models.
sendTx(..., withGasHeadroom=true)sends swaps withmax(2× estimate, 4,000,000)gas (unused gas is refunded). Used for the swap and the CCTP burn. - Permit2 typed data.
signPermit2strips theEIP712Domainentry fromtypesbecauseethersderives the domain itself; signing it twice errors. - Native input. When
tokenInis the native coin, there is no ERC20 approval / Permit2 step and the amount is sent asmsg.value(must equalamountIn). - Idempotent settlement.
already_settled/already_claimedare treated as success terminal states, so re-running a claim is safe.
| Concept | Value |
|---|---|
| Arc chain id | 5042002 (CCTP domain 26) |
| Sepolia chain id | 11155111 (CCTP domain 0) |
| Default chain (unspecified) | first of AGENT_ALLOWED_CHAINS (Arc) |
| Agent loop cap | MAX_STEPS = 8 |
| Default swap slippage | 50 bps (capped by AGENT_MAX_SLIPPAGE_BPS) |
| Claim polling | every 5s, up to 60 attempts |
| Arc gas | max(2× estimate, 4,000,000) |
| Native alias | ETH / NATIVE (Sepolia native) |
If you are an LLM ingesting this repo to modify it, the invariants below should hold across edits:
- Single source of config: everything tunable lives in
CONFIG(src/config.ts). Do not readprocess.envelsewhere exceptrpcUrlForChain. - The model never sees secrets. Never pass
PRIVATE_KEY,*_API_KEY, or signing material into prompts, tool descriptions, or tool results. - Guardrails run in code, not in the prompt. Any new write action must call
assertSwapPolicy(orassertChainAllowed) before signing. - Amounts: tool inputs are human units; convert with
toRawAmountbefore hitting the API andfromRawAmountbefore returning to the model. Never surface raw/wei to the user. - Tool results must be JSON-serializable and small; the loop stringifies them back into the conversation.
- Determinism of the loop: keep
runToolreturning{ error }on failure rather than throwing out of the loop. - Where to edit: new capability means
buildToolsinsrc/tools.ts; new chain meansCHAINS/NATIVE_TOKENS; new provider meansAgentSessioninsrc/llm.ts; API shape changes meansrc/synroute/client.tsplus the relevantexecute*function. - Type safety:
tsconfigis strict withnoUnusedLocals/noUnusedParameters; runnpm run typecheckbefore considering a change done.
- Starting point, not production. No persistence, no retries/backoff beyond the CCTP claim poll, no nonce management for concurrent sends, minimal observability.
- Single wallet, single key. One signer across all chains; no key rotation or HSM.
- Trust in SynRoute responses. The agent submits calldata the API returns (except the CCTP source burn, which it encodes locally); it does not independently simulate every transaction.
- No human-in-the-loop by default. Asking is authorizing. Add a confirmation tool if you need approval gating.
- Testnet-shaped. Defaults target Arc and Sepolia; broaden
CHAINS/AGENT_ALLOWED_CHAINSdeliberately.
MIT, see LICENSE.