Skip to content

Infrastructure & Feature Flags ​

pocket agent runs as a single backend service and a handful of thin clients, all backed by one database. This page covers how it is deployed, how it scales, and — most usefully — the complete feature-flag reference that controls every optional and money-moving behavior in the system.

If you only read one section, make it the flag reference: it is the operator's single source of truth for what is on, what is off, and what each switch gates.

System at a glance ​

The whole product is one Hono app (src/server.ts) that serves the API, the webhooks, the static web client, and the operator console. State lives in Postgres; Redis coordinates work across replicas; everything else is an external provider the app calls out to.

The clients. The web SPA (web/) is a React + Vite app served same-origin from web/dist by the API itself (a static catch-all after the routes). The native app (native/) is an Expo/React Native build on a separate app-store track. Telegram is a Mini App plus a notification bot. Web, PWA, and Telegram are live today; the native store listing is a parallel track.

The backend. A single Hono v4 app on @hono/node-server, Node 20+ (targeting Node 22). It holds no authoritative state in process memory, so you can run as many copies as you like behind a load balancer.

Runtime & deployment ​

The app is hosted on Railway with Nixpacks. The deploy config (railway.json) is deliberately small:

SettingValueNotes
BuilderNIXPACKS
Build commandnpm run buildBuilds the web client only (web/dist); the server is not bundled
Start commandnpm startRuns the server with tsx directly (TypeScript at runtime)
Health checkGET /health120s timeout
Restart policyON_FAILURE, max 5 retries

The tsx-in-production gap ​

Production runs npm start, which is tsx src/server.ts — the TypeScript is executed directly, not compiled. A compiled path exists but is not wired into the deploy:

jsonc
// package.json scripts
"start":          "tsx src/server.ts",            // ← what prod runs today
"build:server":   "esbuild src/server.ts … dist/server.js --target=node22",
"start:compiled": "node dist/server.js"           // ← exists, unused in prod

This is a known gap, not a design choice: the esbuild bundle (build:server) and the compiled entrypoint (start:compiled) are ready, but railway.json still points at the tsx start command. Moving prod onto start:compiled removes the runtime TypeScript transpile from the hot path.

Boot sequence (fail-loud in production) ​

Boot is intentionally strict in production: rather than start "healthy" and then 401/500 every request, the app refuses to start when a critical dependency is missing, and it logs the resolved money-flags so an operator can confirm intent.

Concretely, in production the app throws if PRIVY_APP_ID + PRIVY_APP_SECRET, OPENROUTER_API_KEY, or REDIS_URL are missing. It also throws at boot if a TREASURY_EVM/TREASURY_SOL address-only override is set while ENABLE_WITHDRAWALS is on — because an address-only treasury can receive card-funding but cannot sign a redeem payout, so every cash-out would fail. The signing secret is mandatory whenever the app is in production, REAP is live, or the admin panel is enabled (that same secret HMACs admin session cookies).

Request pipeline ​

Every request flows through the same ordered layers before it reaches a handler:

  • Body limit: 1MB default; the media routes (/agents/:id/run, /agents/:id/transcribe) get 20MB for base64 audio/image payloads. Over the cap → 413.
  • CORS: when CORS_ORIGINS is set, only those origins are allowed. In production with it unset, CORS fails closed (deny all cross-origin) — the same-origin web and header-less native clients are unaffected. In development, it is permissive with a boot warning.
  • Auth: /api/* is behind Privy (privyAuth()), with a per-user global rate limit of 300 requests / 60s plus per-route caps.
  • Envelope: every response is { ok: true, data } or { ok: false, error: { message, code? } }. Plan-gate refusals use HTTP 402 with code: "UPGRADE_REQUIRED".

Data layer: Postgres is the source of truth ​

All authoritative state is in Postgres (src/db.ts). There is no in-memory fallback — initDb() throws if DATABASE_URL is absent. Because nothing authoritative lives in a single process, the app is stateless and horizontally scalable.

Pool / timeoutValue
pg Pool max16 connections
statement_timeout15s
query_timeout15s
idle_in_transaction_session_timeout15s
SSLauto-enabled for proxy.rlwy.net, .railway.app, or sslmode=require URLs

A background gauge emits a structured db.pool log line only under pressure (requests queued, or ≥14 of 16 connections in use), so connection saturation is an early-warning signal rather than periodic noise.

Schema is created on every boot (no migration system yet) ​

initDb() runs ensureSchema() on every boot of every replica — a long list of CREATE TABLE IF NOT EXISTS statements. This keeps the schema self-describing, but it is not a migration system: there is no versioned, ordered, advisory-locked "run-once" migration path. Adding a column or an index safely today means hand-crafting idempotent DDL that is safe to run concurrently across replicas on redeploy. A proper advisory-locked migration runner is a flagged gap.

The core tables (all defined in src/db.ts):

GroupTables
Identity & agentsusers, agents, enrollments, agent_settings, chats, inbox, sent
Card ledgerledger_agents (balance of record), reservations, mock_pans
Money opsfund_ops, redeem_ops, transactions (unified ledger), idempotency, processed_events, kv
Human-in-loop & agentapprovals, agent_tasks, agent_memory, reminders, defi_positions
Growth & opsevents, cost_events, signals, gold_ledger, user_badges, referrals, waitlist, admin_audit
Pushpush_subs (web VAPID), expo_push_tokens

See the data-model deep dive for how money mutations stay exactly-once (processed_events, idempotency, and per-row ON CONFLICT claims).

Redis: the cross-replica coordinator ​

Redis (src/net/redis.ts, ioredis) is what makes running more than one replica safe. It holds the shared state that must not live in a single process's memory:

  • Money locks — a per-user lock (money:<userId>, 90s TTL, heartbeat-renewed) serializes every money op for a user across all replicas.
  • Leader election — the scheduler acquires a leader lock so reconcilers and autonomous tasks fire on exactly one replica.
  • Webhook dedup — a fast-path "have we seen this event id" check (the real exactly-once guard is the processed_events table).
  • Rate limits, caches, ephemeral state — shared sliding windows and short-lived values.

Each helper degrades gracefully to an in-memory fallback when REDIS_URL is empty — but that is dev-only, single-instance. In production the app refuses to boot without Redis, precisely because the in-memory fallbacks would let two replicas each think they hold the lock and double-send or double-credit.

Scaling model ​

Because the app is stateless with Postgres as the source of truth and Redis as the coordinator, you scale by adding replicas. Two things keep that safe:

  1. Per-user money locks serialize all of one user's money ops regardless of which replica serves them.
  2. A leader-locked scheduler (startScheduler, 30s tick) means the reconcilers and autonomous-task runner execute on only one replica at a time. The leader lock has a 90s TTL — comfortably longer than a tick plus a slow run — and leadership is re-checked (renewLeader) between tasks so a lost lease stops the loser cleanly.

The current ceiling. The pg Pool caps at 16 connections per replica, and there is no PgBouncer in front of Postgres, so practical horizontal scale is roughly 5–6 replicas before connection pressure. A connection pooler is the first infrastructure change needed to scale past that. There is also no documented point-in-time-recovery / backup runbook yet — both are flagged as hard blockers for real-money production.

Graceful shutdown ​

Railway sends SIGTERM on every deploy. The app stops accepting new connections (so no fresh money op starts mid-shutdown), gives in-flight handlers a ~4s window to finish under their per-user locks, then exits. Anything cut off is safe: every send records its intent in Postgres before broadcasting, and the reconcilers heal unresolved ops on the next boot.

Health & observability ​

EndpointPurpose
GET /healthDeep probe — runs SELECT 1 against Postgres, reports Redis state and uptime. Returns 200 only if the DB answers (503 otherwise); a degraded Redis is reported but tolerated.
GET /configPublic, safe config — the VAPID public key plus the resolved feature flags the client needs (reapUserFunded, embeddedWallets, defiConfigured, sandbox, etc.). No secrets.
  • Structured logs — logEvent() writes one-line JSON for money moves, errors, slow requests (>1.5s), and all /api, /webhooks, /providers traffic, so a log drain can parse and count them.
  • Alerting — alert() POSTs to ALERT_WEBHOOK_URL (Slack/Discord-compatible) for states that need a human: stranded funds, a reconciler that cannot resolve an op, kill-switch flips, and unhandled errors.
  • COGS model — rough per-active variable costs (cardIssue $0.10, gasSponsor $0.15, llmPerRun $0.02) are recorded to cost_events so a gross-margin tile exists today; they are estimates to be replaced with provider-reported actuals.

Feature-flag reference ​

Everything optional, risky, or provider-dependent in pocket agent is controlled by an environment variable read in src/config.ts. This is the complete list.

The boolean parser is a safety feature

Boolean flags use a custom parser (boolFlag), not z.coerce.boolean(). Only 1, true, yes, or on (case-insensitive) count as true; an unset/empty value takes the default. This deliberately avoids a money-safety footgun: z.coerce.boolean() applies JS Boolean(), so the string "false" would coerce to true — an operator setting ENABLE_WITHDRAWALS=false to stay report-only would have turned real on-chain sends on.

Money gates (the ones that move funds) ​

FlagDefaultWhat it gates
ENABLE_WITHDRAWALSfalseThe master money gate. Gates all on-chain sends — withdraw, card funding, transfers, redeem, swaps, DeFi. When off, money paths validate and report only.
ENABLE_SWAPSfalseReal Relay swap execution (signing the returned steps). Off like withdrawals.
GASLESS_EVMtrueTry Privy gas sponsorship on EVM sends (gasless when a policy exists; otherwise pay gas from the wallet). Solana is always gasless.
SMART_ACCOUNTSfalseThe ERC-4337 smart-account path (Kernel + Pimlico bundler/paymaster). Needs BUNDLER_RPC_URL to be effective (smartAccountsConfigured).
WITHDRAW_MAX_USD2000Max USD-equivalent per single transfer/withdraw/redeem. 0 = unlimited. Active even while withdrawals are off.
WITHDRAW_DAILY_MAX_USD10000Max USD-equivalent out per user per rolling 24h. 0 = unlimited.
WITHDRAW_DAILY_MAX_COUNT30Max money-out transactions per user per rolling 24h. 0 = unlimited.

Velocity caps are live and on by default

The WITHDRAW_* caps are enforced on every money-out path (withdraw, transfer, card redeem, swap) by src/provider/moneygate.ts, via the withMoneyGuard wrapper around each money op. They are active regardless of ENABLE_WITHDRAWALS, so the ceilings are safe to leave in place. See Admin & Ops for how the kill-switch layers on top.

Compliance & non-custodial tracks ​

FlagDefaultWhat it gates
OFAC_COUNTRIESOFAC default setComma-separated ISO-3166 alpha-2 codes blocked at onboarding/KYC. Empty → a sensible built-in set.
COMPLIANCE_DENY_ADDRESSES(empty)Comma-separated sanctioned wallet addresses blocked as withdrawal/transfer destinations. Always applied.
COMPLIANCE_SCREEN_URL(empty)Optional external address-screening endpoint (POST {address} → {sanctioned}). Empty → denylist only.
COMPLIANCE_FAIL_CLOSEDfalseWhen true, a destination that cannot be screened (provider error) is blocked (strict OFAC posture). Default false so a screening-provider outage never freezes all withdrawals (the denylist still applies).
AGENT_CARD_ONLYfalseNon-custodial Track C. When on, agents are card-only: their on-chain money tools (wallet_withdraw, defi_swap/bridge/earn/smart_account) are not registered, so an agent cannot move on-chain funds.
EMBEDDED_WALLETSfalseNon-custodial Track A. Off = Privy server wallets signed server-side (custodial). On = user-owned embedded wallets; the backend only reads balances and builds unsigned transactions, and the user signs client-side.
REAP_USER_FUNDEDfalseNon-custodial Track B. Off = Program-Funded + external authorization (our treasury holds collateral, we authorize each swipe). On = Reap provisions a per-user account whose signer is the user's own wallet. Effective only with a real REAP key (reapUserFunded = flag && key).

Reap (cards, KYC, commerce) ​

FlagDefaultWhat it gates
REAP_API_URLhttps://sg.sandbox.api.reap.globalReap base URL. A URL matching /sandbox/i sets reapSandbox, which gates all checkout/enrollment simulation (X-Simulate-Checkout).
REAP_VERSION2025-02-14API version header.
REAP_API_KEY(empty)Reap bearer token. Non-empty → reapConfigured. Empty → mock card provider.
REAP_AUTH_SECRET(empty)Signing secret for the AUTHORIZATION endpoint (real-time approve/decline). Also HMACs admin session cookies.
REAP_WEBHOOK_SECRET(empty)Signing secret for the NOTIFICATION endpoint (async events).
REAP_REVEAL_PRIVATE_KEY(empty)RSA private key (PEM or base64 PEM) that decrypts the full-PAN blob for a real checkout. Empty → the app returns the encrypted blob only (mock PAN offline).

Identity & wallets (Privy) ​

FlagDefaultWhat it gates
PRIVY_APP_ID(empty)Privy app id. With the secret → privyConfigured.
PRIVY_APP_SECRET(empty)Privy app secret.
PRIVY_AUTHORIZATION_KEY(empty)P-256 delegated-signer key for @privy-io/node. The enclave-enforced "session key" that lets the agent act on user-delegated wallets strictly within policy (Track C).

Treasury ​

FlagDefaultWhat it gates
TREASURY_EVM(empty)Ecosystem treasury EVM address override. Address-only → cannot sign payouts. Empty → a Privy server wallet is provisioned and cached.
TREASURY_SOL(empty)Same, for Solana.
TREASURY_PAYOUT_ADDRESS(empty)The only EVM destination an operator can sweep treasury funds to. Empty → payout disabled.
TREASURY_PAYOUT_SOL(empty)The only Solana payout destination.

Brain & DeFi ​

FlagDefaultWhat it gates
OPENROUTER_API_KEY(empty)The agent's brain. Non-empty → openrouterConfigured (required in production).
OPENROUTER_MODELdeepseek/deepseek-chatDefault model.
OPENROUTER_BASE_URLhttps://openrouter.ai/api/v1API base.
ENSO_API_KEY(empty)DeFi intent router. Non-empty → ensoConfigured → defiConfigured.
ENSO_BASE_URLhttps://api.enso.buildEnso base.
ALCHEMY_API_KEY(empty)Pre-sign simulation + RPC. With Tenderly → simulateConfigured.
TENDERLY_ACCESS_KEY / TENDERLY_ACCOUNT / TENDERLY_PROJECT(empty)Alternative simulation provider.
PORTFOLIO_PROVIDER(empty)debank | zerion | off. With the key → portfolioConfigured.
PORTFOLIO_API_KEY(empty)Portfolio read-layer key.
BUNDLER_RPC_URL(empty)ERC-4337 bundler (supports ${chainId} templating).
PAYMASTER_RPC_URL(empty)Sponsorship endpoint (often the same as the bundler for Pimlico).
PAYMASTER_POLICY_ID(empty)Provider sponsorship policy id, if required.
RELAY_API_KEY(empty)Relay Link cross-chain swap. Quotes are permissionless; the key adds attribution/limits (relayConfigured).

RPC endpoints ​

FlagDefaultWhat it gates
SOLANA_RPC_URL(empty)Override the public Solana mainnet RPC for gasless sends.
RPC_URL_ETHEREUM / RPC_URL_BASE / RPC_URL_BNB / RPC_URL_POLYGON(empty)Keyed/paid EVM endpoints tried first for balance reads (public endpoints stay as fallback). Strongly recommended for production so a funded wallet never reads $0 from a rate-limited free RPC.

Email, push & voice ​

FlagDefaultWhat it gates
RESEND_API_KEY(empty)Agent email send. With a domain → resendConfigured.
RESEND_DOMAIN(empty)Catch-all domain for agent addresses, e.g. agents.pocketagent.to.
RESEND_WEBHOOK_SECRET(empty)Svix signing secret for inbound email.
TELEGRAM_BOT_TOKEN(empty)Real-time Telegram push (approval prompts, etc.). Empty → no-op.
VAPID_PUBLIC / VAPID_PRIVATE(empty)Web Push (VAPID) keys. Public key is exposed via /config; private stays server-side. Empty → web push no-ops.
VAPID_SUBJECTmailto:support@pocketagent.toWeb Push contact (mailto: or https:).
TRANSCRIBE_API_KEY(empty)Voice-note transcription (OpenAI-compatible).
TRANSCRIBE_BASE_URLhttps://api.groq.com/openai/v1Transcription base URL.
TRANSCRIBE_MODELwhisper-large-v3-turboTranscription model.

Browser ​

FlagDefaultWhat it gates
BROWSER_CDP_URL(empty)CDP endpoint for the agent's real browser. Empty → local Chromium (dev) then fetch-based fallback (browserConfigured).
BROWSERBASE_API_KEY / BROWSERBASE_PROJECT_ID(empty)Browserbase creds for sessions with anti-bot features.
BROWSERBASE_STEALTHtrueVerified stealth / residential proxies / captcha solving. Set false to force the plain connect path.

Billing & plans ​

FlagDefaultWhat it gates
ENFORCE_PLANSfalseWhether subscription-tier limits are actually enforced. Off → plans show in the UI but no limit is applied (plansEnforced).
STRIPE_SECRET_KEY(empty)Stripe billing. Empty → billing routes 503.
STRIPE_WEBHOOK_SECRET(empty)Stripe webhook signing secret.
STRIPE_PRICE_PLUS_MONTHLY / STRIPE_PRICE_PLUS_ANNUAL / STRIPE_PRICE_PRO_MONTHLY / STRIPE_PRICE_PRO_ANNUAL(empty)Recurring price ids. Secret + webhook + at least the Plus monthly price → stripeConfigured.
STRIPE_PRODUCT_SHIRTprod_…Waitlist merch one-time top-up product.
SHIRT_TOPUP_ENABLEDfalseReal Stripe merch top-up. Keep off until checkout.session.completed is enabled on the webhook, else a payment succeeds but is never credited.

Ops & other ​

FlagDefaultWhat it gates
NODE_ENVdevelopmentproduction switches the app to fail-closed behavior (isProduction).
CORS_ORIGINS(empty)CSV allowlist of browser origins. Empty + production → deny all cross-origin.
PORT8787HTTP port.
PUBLIC_BASE_URL(empty)Public base for links/webhooks.
DATABASE_URL(empty)Required — the app throws without it.
REDIS_URL(empty)Required in production — boot throws without it. Empty → dev-only in-memory fallback.
ADMIN_PASSWORD(empty)Enables the /admin ops console (adminConfigured). Empty → /admin returns 404. See Admin & Ops.
SEED_REVIEWER_EMAIL(empty)App/Play reviewer demo account — a mock agent, mock card, small demo balance; cash-out blocked.
REQUIRE_REFERRALfalseInvite-only gate: new users must enter a referral code (referralRequired).
ALERT_WEBHOOK_URL(empty)Slack/Discord-compatible incoming webhook for operational alerts. Empty → log only.
STATE_DIR(empty)Legacy / dead — the old JSON-state fallback. Postgres is now mandatory.

Derived flags ​

src/config.ts also exports computed booleans that the rest of the app branches on — they are not set directly, they are derived from the variables above:

reapConfigured, reapUserFunded, reapSandbox, openrouterConfigured, resendConfigured, relayConfigured, transcribeConfigured, browserConfigured, dbConfigured, redisConfigured, privyConfigured, isProduction, adminConfigured, referralRequired, plansEnforced, agentCardOnly, stripeConfigured, ensoConfigured, simulateConfigured, portfolioConfigured, smartAccountsConfigured, defiConfigured.

For example, defiConfigured is simply ensoConfigured (the router being present), and smartAccountsConfigured is SMART_ACCOUNTS && BUNDLER_RPC_URL. DeFi tools are hidden from the agent's brain entirely until defiConfigured is true.

A minimal production configuration ​

The smallest safe production boot needs:

bash
NODE_ENV=production
DATABASE_URL=postgres://…
REDIS_URL=redis://…
PRIVY_APP_ID=…           PRIVY_APP_SECRET=…
OPENROUTER_API_KEY=…
REAP_AUTH_SECRET=…       # or REAP_WEBHOOK_SECRET — a real signing secret is mandatory
CORS_ORIGINS=https://pocketagent.to
# money stays OFF until you deliberately enable it:
# ENABLE_WITHDRAWALS=false   (default)

Add provider keys (Reap, Enso, Stripe, Resend, VAPID, RPC) as you turn each capability on. Flip ENABLE_WITHDRAWALS=1 last, and only after the external gates (real Reap access, custody/money-transmission legal determination) are cleared.

Honest gaps ​

Documented so operators are not surprised:

  • Prod runs on tsx, not compiled — the esbuild bundle and start:compiled exist but are not wired into railway.json.
  • No migration system — ensureSchema() runs idempotent boot DDL per replica; there is no versioned, advisory-locked migration runner yet.
  • Connection-pool ceiling — pool max=16 and no PgBouncer caps practical scale at roughly 5–6 replicas.
  • No PITR / backup runbook — a hard blocker for real-money production.
  • .env.example is stale — only ~20 of ~50 variables are documented there; this page is the authoritative list.

For how an operator uses the live kill-switch, suspend, KYC queue, and treasury payout against this infrastructure, continue to Admin & Ops.

be everywhere — live here.