architecture
How Ava is built
One API process holds every rule that stands between an agent and a user’s capital. This page maps that process, what surrounds it, and where each responsibility lives in the repository. Every diagram was drawn from the source in apps/ and packages/, not from the roadmap.
What Ava is
Ava is the execution layer for agents that do DeFi. A coding agent can describe what it wants done with money, but it holds no key, no signer and no RPC. Ava holds those, and it holds them behind rules the agent cannot change: a mandate the user agreed to, a policy envelope, a registry of what is actually executable, and a receipt that separates what was claimed from what a chain confirmed.
The whole system is one spine, and every live spend walks it in order:
- 01
Intent. An agent names an action through MCP or REST: supply 5 USDC to Morpho on Base, under mandate mdt_…
- 02
Authority. The caller's bearer token must carry the spend audience, and the mandate must belong to that caller.
- 03
Executability. The capability registry decides whether that kind, chain and venue may run on this deployment.
- 04
Preview. Ava reads real chain state and builds the exact action. Its hash is what a human approves.
- 05
Gates. Mandate gate, policy gate, a single-flight execution claim and a durable budget reservation.
- 06
Signature. Only now is a signer built. Turnkey signs with the user's own key, in the user's own sub-organization.
- 07
Settlement. Ava broadcasts, waits for the chain, and compares what settled with what was authorised.
- 08
Receipt. A hash-chained receipt is written. Only a chain-confirmed settlement may be reported as a fill.
System context
Every caller reaches the API through the getava.xyz origin. Agents speak MCP as JSON-RPC over HTTP; browsers and the CLI also use REST. The site forwards /v1, /mcp and /health to the API on Fly.io, which is the only component that talks to Postgres, Turnkey and the chains.
Rendering diagram
flowchart LR
subgraph callers["Callers"]
direction TB
agent["Coding agent<br/>Claude Code · Cursor · Codex"]
claw["OpenClaw skill"]
cli["npx @getava-xyz/connect"]
human["Human in a browser"]
verifier["Third-party verifier"]
end
subgraph edge["getava.xyz · Vercel"]
direction TB
site["Next.js site<br/>docs · install · dashboard"]
console["Operator console<br/>Vite SPA at /console"]
end
subgraph core["Ava API · Fly.io"]
direction TB
api["api-worker<br/>Hono · REST + MCP"]
sched["Scheduler<br/>same process"]
end
pg[("Postgres<br/>ledger · tenancy · claims")]
turnkey["Turnkey<br/>per-user sub-org keys"]
chains["EVM + Sui RPC<br/>Base · Monad · Avalanche · BNB · Arbitrum"]
venues["Venue + data APIs<br/>Kyber · 0x · LI.FI · CoW · Jupiter<br/>CoinGecko · DefiLlama"]
llm["OpenRouter<br/>optional, copilot only"]
agent -- "MCP over HTTP" --> site
claw -- "MCP over HTTP" --> site
cli -- "MCP + REST" --> site
human --> site
human --> console
verifier -- "GET /v1/receipts/:hash/verify" --> site
site -- "rewrite /v1 /mcp /health" --> api
console -- "/v1 via site origin" --> api
api --> pg
sched --> pg
api -- "sign" --> turnkey
api -- "read · broadcast · confirm" --> chains
sched -- "receipt anchors" --> chains
api -- "quotes · prices · yields" --> venues
api -.-> llmSolid arrows are synchronous calls. The dashed OpenRouter edge is optional: the copilot tries a deterministic parser first and calls the model only for trade-shaped messages.
The proxy exists for a concrete reason recorded in apps/site/next.config.ts: two of the domain’s four nameservers answered NXDOMAIN for api.getava.xyz, so roughly half of visitors could not resolve the API host. Serving the API from the site’s own origin removed that dependency and the CORS preflight with it.
Deployables
The monorepo builds eight things that ship. Only one of them holds state or keys. Two that look like services by name, agent-service and execution-worker, are libraries the API imports and runs in its own process.
| path | built with | runs on | responsibility |
|---|---|---|---|
| apps/api-worker | Hono on Node 22 | Fly.io, always on | The product. Every REST route, the MCP server, every gate, every executor and the scheduler run in this one process. |
| apps/site | Next.js 16, React 19, Tailwind 4 | Vercel | getava.xyz: landing, install guide, these docs, the hosted dashboard. Proxies /v1, /mcp and /health to the API so browsers never cross origins. |
| apps/web | Vite SPA, no framework | Vercel, mounted at /console | The operator console and chat copilot. Reached through the site's rewrites so a user never leaves getava.xyz. |
| packages/connect | Node CLI on npm | npx @getava-xyz/connect | Creates a session over MCP, writes HTTP MCP config for Claude Code, Cursor and Codex, registers a byo-external agent, provisions a Base wallet. |
| packages/openclaw-skill | Skill pack | openclaw skills install | The same MCP loop packaged for OpenClaw. |
| apps/hud-daemon | Node CLI | Runs on the developer's machine | Receives coding-agent hook events and binds each terminal session to an Ava agent instance for the local dashboard and status line. |
| apps/agent-service | Library (Mastra) | In-process, imported by api-worker | Structured intent extraction, policy explanation and CoW dry-run drafts for the copilot. It never signs. |
| apps/execution-worker | Library | In-process, imported by api-worker | The standing-rotation tick, the CoW transport and the Turnkey signature-request port. Not a separately deployed worker today. |
Stack
| concern | choice |
|---|---|
| Language | TypeScript 5.7 everywhere, ESM, strict with exactOptionalPropertyTypes in the execution packages. |
| Workspace | pnpm 9 workspaces; turbo.json declares the build, typecheck and test graph. 49 packages under packages/, 7 apps under apps/. |
| HTTP | Hono 4 with @hono/node-server. One app factory, createApp(), wired by runtime.ts. |
| Validation | zod at every request boundary. A body that does not parse is a 422 with the issues attached. |
| Persistence | Postgres 16 through node-postgres. Schemas are applied idempotently at boot; there is no separate migration runner. |
| Keys | Turnkey via @turnkey/sdk-server. Each user's wallets live in that user's own Turnkey sub-organization. |
| Chains | Hand-built EIP-1559 serialization and JSON-RPC clients per chain family; @mysten/sui for Sui. |
| Tests | Vitest across packages (about a thousand tests), PGlite for in-process Postgres, Anvil forks in testkit-fork. |
API worker layers
Inside the API there are two transports and one implementation. REST routes live in apps/api-worker/src/routes; the MCP server in lib/mcp.ts dispatches each tool to the same functions those routes call. This is deliberate: a second implementation behind MCP is how the mandate gate was once enforced over HTTP and skipped over MCP.
Rendering diagram
flowchart TB
subgraph transport["Transport"]
direction LR
rest["REST routes<br/>routes/*.ts"]
mcp["MCP JSON-RPC<br/>lib/mcp.ts · 29 tools"]
rest ~~~ mcp
end
mw["Middleware chain<br/>CORS · request id · logging · auth · spend audience · idempotency"]
subgraph domain["Domain"]
direction LR
tenancy["Sessions · agents · wallets<br/>@ava/tenancy"]
mandates["Mandates · standing envelopes<br/>@ava/mandates"]
planner["Intent + workflow planner<br/>@ava/intent"]
copilot["Copilot<br/>lib/copilot.ts"]
tenancy ~~~ mandates ~~~ planner ~~~ copilot
end
subgraph gates["Gates, in the order a spend meets them"]
direction LR
registry["Capability registry<br/>implemented · apiReachable · operatorEnabled"]
mgate["Mandate gate"]
policy["Policy gate<br/>@ava/policy"]
claim["Execution claim<br/>single flight"]
budget["Policy reservation<br/>daily + exposure"]
registry --> mgate --> policy --> claim --> budget
end
subgraph exec["Execution"]
direction LR
lend["Lend executors<br/>Morpho · Aave · Compound · Suilend"]
swap["Swap venues<br/>Kyber · 0x · Uniswap v3 · LI.FI"]
bridge["Across bridge"]
lend ~~~ swap ~~~ bridge
end
subgraph ports["Ports"]
direction LR
signer["Turnkey signer"]
rpc["RPC transport"]
ledger["Receipt ledger<br/>chain + sign at write"]
signer ~~~ rpc ~~~ ledger
end
transport --> mw --> domain
domain --> gates --> exec --> portsA request descends one way. Nothing in the execution layer can produce a signature until every gate above it has passed, and the signer is constructed after the gates rather than injected before them.
What each layer owns
- Transport. Parses and validates input with zod, resolves the portal, and returns the shared envelope { ok, …, requestId }. MCP narrows what tools/list advertises to an 18-tool default (opt in to more with ?toolsets=) because Cursor caps a user at 40 tools across all servers; tools/call still accepts every tool by name.
- Domain. Tenancy maps a user to agent instances and wallet families (evm, solana, sui). Mandates hold objective, capital, constraints and exit conditions, and are hashed over their terms only, so status changes never break a receipt’s binding.
- Gates. Each gate answers a different question: may this kind run here, does the mandate allow it, does policy allow it, has it already been claimed, and is there budget left today. They are ordered so a refused request never consumes a reservation.
- Execution. One executor per venue family, each with its own settlement check. Aave re-reads the Pool’s own Supply event; Compound reads Comet.balanceOf in the settling block; Morpho reads the ERC-4626 position pinned to the confirmed block.
- Ports. The ledger wrapper chains and optionally signs every receipt at the storage boundary, so no writing path can forget to.
Request pipeline
Every request crosses the same middleware chain, registered in apps/api-worker/src/app.ts. Authentication is deny-by-default: a short allowlist of public routes answers anonymously and everything else needs a credential.
Rendering diagram
flowchart TB
req(["HTTP request"]) --> pre["CORS allowlist · inject stores<br/>request id · structured log"]
pre --> auth{"Credential presented?"}
auth -- "invalid" --> e401a["401 UNAUTHORIZED"]
auth -- "none, private path" --> e401b["401 AUTH_REQUIRED"]
auth -- "valid API key or session token" --> principal["Principal set<br/>userId · kind · audience"]
auth -- "none, public path" --> spend
principal --> spend{"Live-spend path?"}
spend -- "audience is not spend" --> e403["403 SPEND_CREDENTIAL_REQUIRED"]
spend -- "allowed" --> idem{"Mutation that needs<br/>an Idempotency-Key?"}
idem -- "key missing" --> e422["422 IDEMPOTENCY_KEY_REQUIRED"]
idem -- "key seen before" --> replay["Replay the cached 2xx"]
idem -- "proceed" --> route["Route handler"]
route --> envelope["Any thrown ApiError becomes<br/>ok false · error · requestId"]Spend-audience checks run before idempotency on purpose, so a dashboard token on a spend route is told 403 rather than a misleading 422 for a missing header.
Public routes
GET /health, GET /v1/capabilities, GET /v1/portal, GET /v1/portal/catalog, POST /v1/users/session, POST /v1/users/session/device, /mcp and /v1/mcp (each tool authenticates itself), GET /v1/agents/:id/record (handler still hides owner-only records) and GET /v1/receipts/:ref/verify.
Live-spend routes
POST /v1/lend/execute, /v1/workflows/execute, /v1/copilot/approve, /v1/execute/:id and /v1/bridge/execute refuse any principal that cannot spend. Over MCP, the same rule is applied per tool to ava_lend_execute and ava_approve_execute. The credential model is on Data and trust.
Package graph
The 49 workspace packages fall into six groups. Dependencies point downward toward a small set of shared contracts, which is what lets an adapter be tested in isolation and replaced without touching the API.
Rendering diagram
flowchart TB api["apps/api-worker"] libs["apps/agent-service · apps/execution-worker<br/>in-process libraries"] gov["Authority and policy<br/>mandates · policy · wallet-policy<br/>capability-registry · condition-engine · automations"] ident["Identity<br/>tenancy · portal · agent-record"] venues["Venue adapters<br/>adapter-morpho · aave · compound · suilend<br/>adapter-kyber · 0x · uniswap · lifi · jupiter<br/>adapter-bridge · broker-router · broker-cow"] infra["Infrastructure<br/>ledger · ledger-postgres · wallet-turnkey"] contracts["Shared contracts<br/>intent · execution-contract · verifiability"] api --> libs api --> gov api --> ident api --> venues api --> infra libs --> gov libs --> venues libs --> infra gov --> contracts venues --> contracts infra --> contracts ident --> contracts
Grouped rather than drawn per package. The api-worker imports from every group; groups depend on the shared contracts and never on the api-worker.
| package | owns |
|---|---|
| intent | AvaIntent and StrategyIR schemas, intent hashing, and decomposeWorkflow, which turns one sentence into dependent legs. |
| execution-contract | Fixed-point money, the execution state machine, and claim identity. One owner, consumed by every lane. |
| verifiability | Receipt schema, RFC 8785 canonical hashing, Ed25519 receipt signing, proof and settlement standing, anchor payloads. |
| mandates | Mandate store and hash, the enforcement gate, EIP-712 plan and standing signatures, revocation, standing consult and debit. |
| policy | Deterministic risk policy: allowlists, notional and daily limits, expiry, and the human-confirmation threshold. |
| capability-registry | Every kind × chain × venue row with its implemented, apiReachable, mcpReachable, operatorEnabled and mainnetProven flags. |
| tenancy | Users, agent instances, wallet families and per-user Turnkey sub-organization ids. |
| ledger-postgres | Every durable store and its schema: receipts, claims, reservations, tokens, envelopes, revocations, anchors. |
| wallet-turnkey | Turnkey client, sub-organization provisioning, EVM and Sui signing. |
| adapter-* | One venue each. Quote and build calldata from live state; never fabricate a route. Most stop at unsigned calldata. |
Boot and runtime
apps/api-worker/src/runtime.ts builds the app from the environment. Postgres mode applies every schema before serving and hydrates the mandate cache, so the API never answers from a view that is missing mandates the database holds. Asking for Postgres without a DATABASE_URL is a boot error, not a fallback to memory.
Rendering diagram
flowchart TB
start(["index.ts → createRuntimeAppFromEnv"]) --> mode{"AVA_LEDGER_MODE"}
mode -- "in_memory" --> mem["In-memory stores<br/>developer machine"]
mode -- "postgres" --> url{"DATABASE_URL?"}
url -- "missing" --> fail["Boot error, no fallback"]
url -- "set" --> schema["Apply every schema idempotently<br/>then hydrate the mandate cache"]
schema --> wire
mem --> wire["createApp with Turnkey provisioner, CoW transport,<br/>API keys, auth mode, operator token, CORS"]
wire --> serve["Serve on PORT, then start the scheduler<br/>only if AVA_SCHEDULER_ENABLED=true"]The scheduler
lib/scheduler.ts runs inside the API process when AVA_SCHEDULER_ENABLED=true, which is why the Fly machine is never auto-stopped. Every 60 seconds it evaluates active automations and mandates against a live CoinGecko price and evaluates due standing envelopes. A separate, slower timer publishes receipt chain heads to Base when an anchor key is configured.
Infrastructure
Production is one always-on Fly.io machine in Singapore beside its managed Postgres, plus two Vercel projects. The /health check reports the ledger mode and dependency modes, and is what Fly polls every 30 seconds.
Rendering diagram
flowchart TB
dns(["www.getava.xyz"])
subgraph vercel["Vercel"]
direction LR
siteapp["apps/site<br/>Next.js 16"]
webapp["apps/web<br/>Vite console"]
end
subgraph fly["Fly.io · ava-v4-api · region sin"]
direction LR
machine["api-worker container<br/>shared-cpu-1x · 1 GB · min 1, never auto-stopped"]
flypg[("Managed Postgres<br/>same region")]
end
subgraph ext["External"]
direction LR
tk["Turnkey API"]
rpcs["Chain RPC endpoints"]
apis["Venue and price APIs"]
end
dns --> siteapp
siteapp -- "/console · /workspace · /assets" --> webapp
siteapp -- "/v1 · /mcp · /health" --> machine
machine --> flypg
machine --> tk
machine --> rpcs
machine --> apisFrom fly.toml, the Dockerfile and apps/site/next.config.ts. The API region was chosen to sit beside Postgres and avoid a cross-region hop on every ledger write.
The repository also carries a root vercel.json that serves the same Hono app as a Vercel function through api/, and a railway.toml for the Dockerfile. Both boot the same createRuntimeAppFromEnv. The README and the site’s proxy default name Fly.io as production; whether the other two are deployed anywhere today cannot be determined from the repository.
External integrations
| service | used for | on the spend path |
|---|---|---|
| Turnkey | Wallet provisioning in per-user sub-organizations; EVM and Sui transaction signing; the receipt anchor key. | yes |
| Chain RPC | Position reads, nonce and fee estimation, broadcast, receipt polling, settlement event reads. Base supports a list of fallback URLs. | yes |
| Kyber, 0x, LI.FI, Uniswap v3 | Swap quotes and calldata for workflow swap legs, gated by the registry's router allowlist. | when a swap leg runs |
| Across | Monad to Base USDC bridge deposits, with a destination fill reader. | when enabled by the operator |
| CoW Protocol, Jupiter | Copilot swap quotes. CoW defaults to a dry-run transport unless AVA_COW_TRANSPORT=http. | copilot only |
| CoinGecko | Live price snapshots for mandate and automation conditions and notional pricing. | pricing input |
| DefiLlama | Yield opportunities ranked by @ava/yield-router. | no |
| OpenRouter | Optional structured intent extraction in the copilot, behind a 2.5 s deadline. | no |
| Membase | Optional external agent memory when AVA_MEMORY_URL is set. | no |
Configuration
Every setting is read from the environment at boot. The defaults are the safe ones: auth is required unless a developer opts out, operator controls are disabled without a token, and receipts are recorded as unsigned rather than signed with an absent key.
| variable | effect |
|---|---|
| AVA_LEDGER_MODE · DATABASE_URL | postgres or in_memory. Postgres without a URL refuses to boot. |
| AVA_REQUIRE_AUTH · AVA_DEV_ALLOW_UNAUTHENTICATED | Auth is required unless the second is set on a developer machine. Configured API keys force it back on. |
| AVA_API_KEYS | userId:key bindings for headless callers. A malformed entry fails boot. |
| AVA_SIGNER_PROVIDER · TURNKEY_* | turnkey enables real wallet provisioning and signing. Anything else is a boot error. |
| AVA_OPERATOR_TOKEN | Enables pause-all, resume-all, per-execution pause and revoke, and usage. Absent means those routes are disabled. |
| AVA_SCHEDULER_ENABLED · AVA_SCHEDULER_INTERVAL_MS | Turns the in-process tick on. Not a spend switch. |
| AVA_RECEIPT_SIGNING_KEY · _KEY_ID | Ed25519 receipt signing. Unset means receipts carry an unsigned reason. |
| AVA_RECEIPT_ANCHOR_* | Chain, address, interval and Turnkey org for publishing receipt chain heads. |
| AVA_CONTROLLED_SIGNERS | Addresses Ava controls. A plan or standing signature from one of them is refused, so Ava cannot authorise itself. |
| AVA_BASE_RPC_URL(S) | Base RPC endpoints, tried in order. |
| AVA_CORS_ORIGINS | Comma-separated browser origins for direct API calls. |
| AVA_COW_TRANSPORT | http for live CoW; the default is a deterministic dry run. |
Where to go next
- Core workflows walks onboarding, a live lend, a multi-leg plan, standing authorization and verification as sequence diagrams.
- Data and trust covers credentials, what is stored where, the receipt model, and the known gaps.
- API and MCP reference lists the endpoints, the live MCP tool list, and which registry rows execute today.