Appearance
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:
| Setting | Value | Notes |
|---|---|---|
| Builder | NIXPACKS | |
| Build command | npm run build | Builds the web client only (web/dist); the server is not bundled |
| Start command | npm start | Runs the server with tsx directly (TypeScript at runtime) |
| Health check | GET /health | 120s timeout |
| Restart policy | ON_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 prodThis 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_ORIGINSis 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 withcode: "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 / timeout | Value |
|---|---|
pg Pool max | 16 connections |
statement_timeout | 15s |
query_timeout | 15s |
idle_in_transaction_session_timeout | 15s |
| SSL | auto-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):
| Group | Tables |
|---|---|
| Identity & agents | users, agents, enrollments, agent_settings, chats, inbox, sent |
| Card ledger | ledger_agents (balance of record), reservations, mock_pans |
| Money ops | fund_ops, redeem_ops, transactions (unified ledger), idempotency, processed_events, kv |
| Human-in-loop & agent | approvals, agent_tasks, agent_memory, reminders, defi_positions |
| Growth & ops | events, cost_events, signals, gold_ledger, user_badges, referrals, waitlist, admin_audit |
| Push | push_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_eventstable). - 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:
- Per-user money locks serialize all of one user's money ops regardless of which replica serves them.
- 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
| Endpoint | Purpose |
|---|---|
GET /health | Deep 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 /config | Public, 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,/providerstraffic, so a log drain can parse and count them. - Alerting —
alert()POSTs toALERT_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 tocost_eventsso 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)
| Flag | Default | What it gates |
|---|---|---|
ENABLE_WITHDRAWALS | false | The master money gate. Gates all on-chain sends — withdraw, card funding, transfers, redeem, swaps, DeFi. When off, money paths validate and report only. |
ENABLE_SWAPS | false | Real Relay swap execution (signing the returned steps). Off like withdrawals. |
GASLESS_EVM | true | Try Privy gas sponsorship on EVM sends (gasless when a policy exists; otherwise pay gas from the wallet). Solana is always gasless. |
SMART_ACCOUNTS | false | The ERC-4337 smart-account path (Kernel + Pimlico bundler/paymaster). Needs BUNDLER_RPC_URL to be effective (smartAccountsConfigured). |
WITHDRAW_MAX_USD | 2000 | Max USD-equivalent per single transfer/withdraw/redeem. 0 = unlimited. Active even while withdrawals are off. |
WITHDRAW_DAILY_MAX_USD | 10000 | Max USD-equivalent out per user per rolling 24h. 0 = unlimited. |
WITHDRAW_DAILY_MAX_COUNT | 30 | Max 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
| Flag | Default | What it gates |
|---|---|---|
OFAC_COUNTRIES | OFAC default set | Comma-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_CLOSED | false | When 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_ONLY | false | Non-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_WALLETS | false | Non-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_FUNDED | false | Non-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)
| Flag | Default | What it gates |
|---|---|---|
REAP_API_URL | https://sg.sandbox.api.reap.global | Reap base URL. A URL matching /sandbox/i sets reapSandbox, which gates all checkout/enrollment simulation (X-Simulate-Checkout). |
REAP_VERSION | 2025-02-14 | API 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)
| Flag | Default | What 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
| Flag | Default | What 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
| Flag | Default | What it gates |
|---|---|---|
OPENROUTER_API_KEY | (empty) | The agent's brain. Non-empty → openrouterConfigured (required in production). |
OPENROUTER_MODEL | deepseek/deepseek-chat | Default model. |
OPENROUTER_BASE_URL | https://openrouter.ai/api/v1 | API base. |
ENSO_API_KEY | (empty) | DeFi intent router. Non-empty → ensoConfigured → defiConfigured. |
ENSO_BASE_URL | https://api.enso.build | Enso 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
| Flag | Default | What 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
| Flag | Default | What 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_SUBJECT | mailto:support@pocketagent.to | Web Push contact (mailto: or https:). |
TRANSCRIBE_API_KEY | (empty) | Voice-note transcription (OpenAI-compatible). |
TRANSCRIBE_BASE_URL | https://api.groq.com/openai/v1 | Transcription base URL. |
TRANSCRIBE_MODEL | whisper-large-v3-turbo | Transcription model. |
Browser
| Flag | Default | What 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_STEALTH | true | Verified stealth / residential proxies / captcha solving. Set false to force the plain connect path. |
Billing & plans
| Flag | Default | What it gates |
|---|---|---|
ENFORCE_PLANS | false | Whether 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_SHIRT | prod_… | Waitlist merch one-time top-up product. |
SHIRT_TOPUP_ENABLED | false | Real 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
| Flag | Default | What it gates |
|---|---|---|
NODE_ENV | development | production switches the app to fail-closed behavior (isProduction). |
CORS_ORIGINS | (empty) | CSV allowlist of browser origins. Empty + production → deny all cross-origin. |
PORT | 8787 | HTTP 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_REFERRAL | false | Invite-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 andstart:compiledexist but are not wired intorailway.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=16and no PgBouncer caps practical scale at roughly 5–6 replicas. - No PITR / backup runbook — a hard blocker for real-money production.
.env.exampleis 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.