Skip to main content

Overview

Autonome is an AI-powered autonomous cryptocurrency trading platform built with a split architecture for optimal deployment and scalability. The application separates the frontend and backend into independently deployable units while maintaining real-time synchronization through oRPC and Server-Sent Events (SSE).

Split Deployment Architecture

The application is divided into two distinct deployable units:

Frontend (Vercel)

  • Location: src/
  • Framework: TanStack Start (React 19 with SSR)
  • Deployment: Vercel (via Nitro preset)
  • Responsibilities:
    • Server-side rendering (SSR) for fast initial page loads
    • Client-side hydration and interactivity
    • Real-time UI updates via SSE subscriptions
    • State management with TanStack Query

Backend (VPS)

  • Location: api/src/index.ts
  • Framework: Hono (lightweight HTTP server)
  • Runtime: Bun
  • Deployment: Virtual Private Server (VPS)
  • Responsibilities:
    • oRPC procedure handlers
    • SSE event broadcasting
    • Trading schedulers (AI agents, price tracking)
    • Exchange simulator lifecycle
    • Database operations (PostgreSQL + Drizzle ORM)
The split architecture allows the frontend to scale independently on Vercel’s edge network while the backend runs stateful schedulers on a persistent VPS.

Communication Layer

oRPC over HTTP

The primary communication protocol between frontend and backend:
// Frontend: src/server/orpc/client.ts
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";

const link = new RPCLink({
  url: getRpcUrl(), // Points to VPS API server
});

export const client = createORPCClient(link);
export const orpc = createTanstackQueryUtils(client);
// Backend: api/src/index.ts
const rpcHandler = new RPCHandler(router);

app.all("/api/rpc/*", async (c) => {
  const { response } = await rpcHandler.handle(c.req.raw, {
    prefix: "/api/rpc",
    context: {},
  });
  return response ?? c.json({ error: "Not Found" }, 404);
});

Server-Sent Events (SSE)

Real-time updates from backend to frontend:
  • /api/events/positions - Position updates
  • /api/events/trades - Trade execution feed
  • /api/events/conversations - AI chat events
  • /api/events/portfolio - Portfolio snapshots
  • /api/events/workflow - Trading workflow events
See Data Flow for detailed SSE architecture.

Core Components

Frontend Components

1. TanStack Router

File-based routing with type-safe navigation:
src/routes/
├── index.tsx           # Main dashboard
├── leaderboard.tsx     # Model performance comparison
├── analytics.tsx       # Trading analytics
├── chat.tsx           # AI co-pilot chat
└── failures.tsx       # Failed workflow debugging

2. TanStack Query + oRPC Integration

Type-safe data fetching with automatic caching and refetching:
// Component usage
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/server/orpc/client";

function PositionsList() {
  const { data: positions } = useQuery(
    orpc.trading.getPositions.queryOptions({ input: {} })
  );
  // ...
}

3. SSE Event Subscriptions

Components subscribe to real-time updates via EventSource:
import { getSseUrl } from "@/core/shared/api/apiConfig";

const eventSource = new EventSource(getSseUrl("/api/events/positions"));
eventSource.onmessage = (event) => {
  const positions = JSON.parse(event.data);
  // Update UI or invalidate queries
};

Backend Components

1. oRPC Router

Organized by domain feature:
// src/server/orpc/router/index.ts
export default {
  trading: { getTrades, getPositions, getCryptoPrices, getPortfolioHistory },
  models: { getModels, getInvocations },
  simulator: { placeOrder, getAccount, resetAccount },
  analytics: { getModelStats, getLeaderboard, getFailures },
  variants: { getVariants, getVariantStats, getVariantHistory },
};
Each procedure follows a standard pattern:
// src/server/orpc/router/trading.ts
import "@/polyfill"; // Required for compatibility
import { os } from "@orpc/server";
import * as Sentry from "@sentry/react";

export const getPositions = os
  .input(z.object({ modelId: z.string().optional() }))
  .output(z.array(positionSchema))
  .handler(async ({ input }) =>
    Sentry.startSpan({ name: "getPositions" }, async () => {
      return await fetchPositions(input.modelId);
    })
  );

2. Event Broadcasting System

Centralized EventEmitter-based SSE broadcasting:
// src/server/features/trading/events/positionEvents.ts
import { EventEmitter } from "node:events";

const emitter = new EventEmitter();
let currentPositionsCache: PositionEventData[] = [];

export const emitPositionEvent = (event: PositionEvent) => {
  currentPositionsCache = event.data;
  emitter.emit(EVENT_KEY, event);
};

export const subscribeToPositionEvents = (listener) => {
  emitter.on(EVENT_KEY, listener);
  return () => emitter.off(EVENT_KEY, listener);
};

export const getCurrentPositions = () => currentPositionsCache;

3. Scheduler Bootstrap

Schedulers initialize once per server process:
// src/server/schedulers/bootstrap.ts
export async function bootstrapSchedulers() {
  if (isBootstrapped()) return;
  
  markBootstrapped();
  console.log("🚀 Server-side bootstrap: initializing schedulers...");
  
  if (IS_SIMULATION_ENABLED) {
    await ExchangeSimulator.bootstrap(DEFAULT_SIMULATOR_OPTIONS);
  }
  
  ensurePortfolioScheduler();  // Price tracking every 10s
  ensureTradeScheduler();       // AI decision loop every 60s
  
  console.log("✅ Schedulers initialized");
}
Called from API server startup:
// api/src/index.ts
async function main() {
  console.log("🚀 Starting Autonome API server...");
  await bootstrapSchedulers();
  console.log(`✅ API server running on http://localhost:${port}`);
}

4. ExchangeSimulator Lifecycle

The simulator maintains in-memory state for sandbox trading:
// src/server/features/simulator/exchangeSimulator.ts
export class ExchangeSimulator {
  static async bootstrap(options?: Partial<ExchangeSimulatorOptions>) {
    if (!globalThis.__exchangeSimulator) {
      globalThis.__exchangeSimulator = ExchangeSimulator.create({
        ...DEFAULT_SIMULATOR_OPTIONS,
        ...options,
      });
    }
    return globalThis.__exchangeSimulator;
  }

  private async initialise() {
    // 1. Initialize market state (BTC, ETH, SOL order books)
    for (const metadata of buildMarketMetadata()) {
      const market = new MarketState(metadata, orderApi);
      await market.refresh();
      this.markets.set(metadata.symbol, market);
    }

    // 2. Restore open positions from database
    await this.restorePositionsFromDb();

    // 3. Start polling for price updates and exit plan triggers
    this.startPolling();
  }
}
Key behaviors:
  • Singleton pattern: One simulator instance per process
  • DB restoration: Rehydrates positions from Orders table on startup
  • Auto-close triggers: Monitors stop-loss and take-profit conditions
  • EventEmitter: Broadcasts trade executions and account updates

Data Flow Summary

  1. Client requests data via orpc.*.*.queryOptions() → TanStack Query
  2. Hono receives request at /api/rpc/* → routes to oRPC handler
  3. oRPC Router executes procedure → reads from DB via Drizzle
  4. Response flows back → TanStack Query caches result
  5. Schedulers emit events (trades, positions, portfolio)
  6. SSE Endpoints broadcast events → clients update UI

Port Configuration

Development and production use different port configurations:

Development (.env.local)

PORT=8081                      # Backend API server
FRONTEND_PORT=5173             # Vite dev server
VITE_API_URL=http://localhost:8081  # Client-exposed API URL

Production

# Frontend (Vercel)
VITE_API_URL=https://api.autonome.ai  # Points to VPS

# Backend (VPS)
PORT=8081
CORS_ORIGINS=https://autonome.vercel.app

Vite Proxy (Development)

Vite proxies /api/* requests to the backend:
// vite.config.ts
export default defineConfig({
  server: {
    port: FRONTEND_PORT,
    proxy: {
      "/api": {
        target: API_URL,
        changeOrigin: true,
      },
    },
  },
});

Database Architecture

Schema (Drizzle ORM)

Key tables with quoted capitalized identifiers:
  • "Models" - AI trading agents with variant configuration
  • "Orders" - Single source of truth for positions (OPEN) and trades (CLOSED)
  • "Invocations" - AI model execution history
  • "ToolCalls" - Trade decisions (CREATE_POSITION, CLOSE_POSITION, HOLDING)
  • "PortfolioSize" - Time-series snapshots of portfolio value

Orders Table Design

The "Orders" table serves dual purposes:
-- OPEN orders = active positions (Positions tab)
SELECT * FROM "Orders" WHERE status = 'OPEN';

-- CLOSED orders = completed trades (Trades tab)
SELECT * FROM "Orders" WHERE status = 'CLOSED';
Key fields:
  • status: OPEN | CLOSED
  • exitPlan: JSONB with stop/target/confidence
  • realizedPnl: Populated when closed
  • closeTrigger: null (manual) | "STOP" | "TARGET" (auto)
Unrealized P&L is calculated live from current prices, never stored. This ensures accuracy and avoids staleness.

Environment Management

All environment variables are typed via src/env.ts using T3Env:
import { env } from "@/env";

// ✅ Type-safe, validated access
const apiKey = env.LIGHTER_API_KEY_INDEX;

// ❌ Never use directly
const bad = process.env.LIGHTER_API_KEY_INDEX;
Server-side variables:
  • DATABASE_URL, PORT, CORS_ORIGINS
  • NIM_API_KEY, OPENROUTER_API_KEY, etc.
  • LIGHTER_API_KEY_INDEX, LIGHTER_BASE_URL
  • TRADING_MODE: live | simulated
Client-side variables (must have VITE_ prefix):
  • VITE_API_URL - API endpoint for browser
  • VITE_APP_TITLE - Optional UI title override

Next Steps

  • Tech Stack - Detailed breakdown of technologies and versions
  • Data Flow - Request/response flow, SSE streaming, and state management

Build docs developers (and LLMs) love