This guide covers everything you need to run your own instance of three.ws — from a one-click Vercel deploy to a fully self-hosted setup on any Node.js infrastructure.
three.ws is a full-stack platform. A complete deployment has six moving parts:
| Layer | What it does | Recommended provider |
|---|---|---|
| Frontend | Vite-built multi-page SPA (static files) | Vercel / any CDN |
| API routes | Node.js serverless functions in /api/ |
Vercel / any Node.js host |
| Database | PostgreSQL — agents, users, widgets, keys | Neon (serverless Postgres) |
| Rate limiting / cache | Per-IP and per-user rate limits, session caching | Upstash Redis |
| Object storage | GLB files, generated thumbnails | Cloudflare R2 (recommended) or AWS S3 |
| IPFS (optional) | Decentralized asset pinning for on-chain registration | Pinata or Web3.Storage |
None of the optional layers (Redis, IPFS, blockchain relayer) are required to run a working instance. The platform degrades gracefully — Redis missing means in-process rate limiting per serverless instance; IPFS missing means on-chain registration is unavailable; the relayer missing means ERC-7710 delegation redemption is disabled.
The fastest route from zero to running:
- Click the button → connect your GitHub account → Vercel forks the repo to your account.
- Fill in the environment variables (see the Required environment variables section below).
- Click Deploy. The build takes about 3 minutes.
- After deploy: add your custom domain, configure DNS, point your database.
That's it. You'll have a live instance at https://your-project.vercel.app.
Copy .env.example to .env.local for local work, and add the same variables to your Vercel project under Settings → Environment Variables (both Preview and Production environments).
cp .env.example .env.local# Public origin — no trailing slash
PUBLIC_APP_ORIGIN=https://yourdomain.com
# PostgreSQL (Neon serverless — HTTPS connection string)
DATABASE_URL=postgres://user:pass@ep-xxx.neon.tech/neondb?sslmode=require
# JWT signing key — also used (via HKDF) to derive the AES-256-GCM key that
# encrypts agent wallet private keys. Rotate by appending a new kid, never
# by removing the old one mid-rotation.
# Generate: openssl rand -base64 64
JWT_SECRET=
JWT_KID=k1
# Password hashing cost (bcryptjs rounds — 11 is a good default)
PASSWORD_ROUNDS=11ANTHROPIC_API_KEY=sk-ant-xxxxx
# Optional overrides
CHAT_MODEL=claude-sonnet-4-6
CHAT_MAX_TOKENS=1024Without ANTHROPIC_API_KEY, the chat API falls back to client-side pattern matching — agents still respond, but without the full LLM backend.
three.ws uses an S3-compatible interface. Cloudflare R2 is recommended because it has zero egress fees — even a viral spike only costs you storage and requests.
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=3d-agent-avatars
# Public CDN base URL for the bucket (custom domain or provider default)
S3_PUBLIC_DOMAIN=https://cdn.yourdomain.comFor AWS S3, leave S3_ENDPOINT empty (the SDK defaults to s3.amazonaws.com) and set S3_BUCKET to your bucket name.
For Cloudflare R2: get your account ID and generate R2 API tokens from the Cloudflare dashboard. Set S3_ENDPOINT to https://<account-id>.r2.cloudflarestorage.com.
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxxWithout Redis, rate limits run in-memory per serverless instance. This is fine for low traffic; for production, use Upstash so limits are enforced consistently across all instances.
# Avatar builder — origin where the in-browser avatar builder iframe is hosted.
# Defaults to http://localhost:5173 in dev. Production deployments should host the
# character-studio/ workspace (it is a separate Vite/React app) and point this env
# var at its origin. Same export contract, same humanoid rig, open-source
# and self-hostable.
VITE_CHARACTER_STUDIO_URL=https://studio.three.ws
# Photo-to-avatar pipeline
# Get key at https://avaturn.me/developer
AVATURN_API_KEY=
AVATURN_API_URL=https://api.avaturn.me
VITE_AVATURN_EDITOR_URL=https://editor.avaturn.me/
VITE_AVATURN_DEVELOPER_ID=# Privy — social + wallet login (get from https://dashboard.privy.io)
VITE_PRIVY_APP_ID= # client-side
PRIVY_APP_ID= # server-side (for verifying identity tokens)# Enable the server-side relayer that pays gas for redeemDelegations
PERMISSIONS_RELAYER_ENABLED=false
# Hex private key of the relayer EOA — NOT the user's key
# Generate: node -e "const {Wallet} = require('ethers'); const w = Wallet.createRandom(); console.log(w.privateKey, w.address)"
AGENT_RELAYER_KEY=0x...
AGENT_RELAYER_ADDRESS=0x... # checksummed address derived from AGENT_RELAYER_KEY
# Per-chain RPC URLs (override public RPCs with Alchemy/Infura for production)
RPC_URL_84532=https://sepolia.base.org # Base Sepolia
RPC_URL_11155111=https://rpc.sepolia.org # Ethereum SepoliaRequired only if you want on-chain ERC-8004 registration (which pins the agent manifest to IPFS before writing to the registry).
# Pinata (preferred) — get JWT from https://app.pinata.cloud/keys
PINATA_JWT=
# Web3.Storage (fallback)
WEB3_STORAGE_TOKEN=- Go to neon.tech → New Project
- Choose a region close to your Vercel deployment
- Copy the connection string — it looks like
postgres://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb?sslmode=require - Set it as
DATABASE_URLin your env
The free tier handles early traffic comfortably. When you scale, switch to the pooled connection string (append ?pgbouncer=true or use the pooled endpoint in the Neon dashboard) to handle many concurrent serverless connections.
One command provisions the entire schema — core tables, indexer/delegation state, and every incremental migration — in dependency order. It is idempotent: safe to re-run any time, a no-op for anything already applied.
npm run db:bootstrapThis runs, in order:
scripts/apply-schema.mjs— core schema (agents, users, avatars, agent_identities, sessions, widgets, usage_events, …)scripts/apply-indexer-schema.js— ERC-8004 indexer statescripts/apply-delegations-schema.js— ERC-7710 delegation tablesscripts/apply-migrations.mjs --apply— every incremental migration underapi/_lib/migrations/(agent_custody_events, forge_creations, x_triggers, pump_coin_intel, club_tips, unstoppable_*, …)
Do not skip step 4. The base schema alone does not create the migration-defined tables. A database that has only had
apply-schema.mjsrun against it will throwrelation "…" does not existfor dozens of routes (forge, custody, club tips, x-triggers, the unstoppable agent, …).npm run db:bootstrapruns all four steps; running them piecemeal is what leaves a half-provisioned database.
Each step reads DATABASE_URL from .env.local then .env then the process environment. If you get a connection error, verify the URL includes ?sslmode=require — Neon requires SSL.
To preview pending migrations without writing, run npm run db:status.
| Table | Purpose |
|---|---|
agents |
Agent metadata, manifest CID, owner wallet address |
widgets |
Widget configs, visibility settings, embed URLs |
users |
User accounts, wallet addresses, hashed passwords |
api_keys |
SHA-256 hashed API keys with scopes |
agent_actions |
Per-agent activity log (every tool call) |
usage_events |
All tool calls — feed your analytics dashboards |
plan_quotas |
Per-plan daily caps for rate limiting |
sessions |
Active sessions (refresh tokens stored hashed) |
indexer_state |
ERC-8004 on-chain crawl progress per chain |
agent_delegations |
ERC-7710 permission grants |
three.ws stores GLB model files and generated thumbnails in your S3 bucket. The API uses the @aws-sdk/client-s3 package, which works with any S3-compatible endpoint.
# 1. Create an R2 bucket in the Cloudflare dashboard
# Dashboard → R2 → Create bucket → name it (e.g. "3d-agent-avatars")
# 2. Generate R2 API credentials
# Dashboard → R2 → Manage R2 API Tokens → Create API token
# Permission: Object Read & Write on your bucket
# 3. Apply the CORS policy
aws s3api put-bucket-cors \
--bucket 3d-agent-avatars \
--cors-configuration file://cors.json \
--endpoint-url https://<account-id>.r2.cloudflarestorage.comR2 is S3-compatible — the same SDK, different endpoint. Set S3_ENDPOINT to your R2 account endpoint and the rest works identically to AWS.
For public serving: configure a custom domain on your R2 bucket (Cloudflare dashboard → R2 → your bucket → Settings → Custom Domains). Set that as S3_PUBLIC_DOMAIN. Zero egress, globally cached.
# Create bucket
aws s3 mb s3://your-bucket-name --region us-east-1
# Apply CORS policy (required so browsers can load GLBs cross-origin)
aws s3api put-bucket-cors \
--bucket your-bucket-name \
--cors-configuration file://cors.jsonThe cors.json at the repo root has the required policy. It allows GET requests from your production domain, localhost, and any partner embed domains you add.
To add an embed origin: add it to the origin array in cors.json and redeploy.
Upstash offers a free-tier serverless Redis with an HTTPS REST API — no connection pooling issues with serverless functions.
# 1. Create a Redis database at https://upstash.com
# 2. Choose the same region as your Vercel deployment
# 3. Copy the REST URL and REST token to your env varsRedis is used for:
- Rate limiting — per-IP and per-user limits enforced consistently across instances
- Session caching — reduces database round-trips for auth checks
- IPFS pin status — caches pin results to avoid redundant API calls
Without Redis the platform still works — rate limits run in-memory per serverless instance (adequate for low traffic, inconsistent across scale-outs).
- Node.js 24.x
- A browser with WebGL 2.0 (Chrome, Firefox, or Edge 90+)
- A Neon database (or any Postgres instance) for the API routes
git clone https://github.com/nirholas/three.ws.git
cd 3d-agent
npm install
# Copy and fill in env vars
cp .env.example .env.local
# Edit .env.local — minimum: DATABASE_URL, JWT_SECRET, ANTHROPIC_API_KEY
# Apply the full schema (core + indexer + delegations + migrations)
npm run db:bootstrap
# Start the dev server
npm run dev
# App: http://localhost:3000
# API routes: http://localhost:3000/api/*The dev server runs on port 3000 with Vite HMR. Changes to any source file reflect instantly without a full reload.
| Script | What it does |
|---|---|
npm run dev |
Dev server with HMR on port 3000 |
npm run build |
Production build to dist/ |
npm run clean |
Remove dist/ |
npm run deploy |
Build + vercel --prod |
npm run verify |
Prettier check (exits 1 if formatting drift) |
npm run format |
Auto-format all files with Prettier |
npm run test |
Run test suite (test/gen_test.js) |
# Build the full app (SPA + API routes)
npm run build
# Output: dist/
# Build the embeddable web component (CDN-distributable)
TARGET=lib npm run build
# Output: dist-lib/The Vite config handles multi-page routing, asset hashing, and the PWA manifest. Every JS file in dist/assets/ is content-hashed, so you can serve them with long cache headers safely — Vercel does this automatically (max-age=604800).
The vercel.json at the root configures routing, headers, and background cron jobs.
/agent/:id/edit → /agent-edit.html
/agent/:id/embed → /agent-embed.html (frame-ancestors: * header)
/agent/:id → /agent-home.html
/dashboard → /dashboard/index.html
/w/:id → widget page (server-side)
/a/:chainId/:id → on-chain agent page
The embed routes explicitly set frame-ancestors * so the embed iframe can be hosted on any domain. Other routes omit this header (defaulting to same-origin framing only).
Vercel runs four cron jobs automatically once deployed:
| Cron | Schedule | Purpose |
|---|---|---|
/api/cron/erc8004-crawl |
Every 15 min | Index new on-chain agent registrations |
/api/cron/index-delegations |
Every 5 min | Index ERC-7710 delegation events |
/api/cron/run-dca |
Every hour | Execute scheduled DCA strategies |
/api/cron/run-subscriptions |
Every hour | Process recurring subscriptions |
Crons only run in production deployments (not preview). No action needed — Vercel picks them up automatically from vercel.json.
Assets under /assets/* are served with a 7-day cache header. Versioned CDN bundles under /agent-3d/x.y.z/* are served with a 1-year immutable cache. Both are configured in vercel.json route headers.
On Vercel: Settings → Domains → Add Domain.
HTTPS is provisioned automatically via Let's Encrypt — no action needed.
After adding your domain:
- Configure DNS:
- A record:
76.76.21.21 - CNAME:
cname.vercel-dns.com
- A record:
- Update
PUBLIC_APP_ORIGINin your environment variables to your domain (no trailing slash) - Update
cors.jsonto include your domain in the allowed origins array - Redeploy for the CORS change to take effect
You probably don't need to do this. The canonical ERC-8004 registry contracts are already deployed at the same addresses on every major EVM chain. The platform is pre-configured to use them.
- IdentityRegistry (mainnet):
0x8004A169FB4a3325136EB29fA0ceB6D2e539a432- IdentityRegistry (testnet):
0x8004A818BFB912233c491871b3d84c89A494BD9eOnly deploy your own if you need a private registry, are auditing the contracts, or are running an isolated test environment.
cd contracts
# Install Foundry if not already installed
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Configure env
cp .env.example .env
# Fill in DEPLOYER_PK and BASESCAN_API_KEY
# Build and test
forge build
forge test -vv # 25 tests across three contracts
# Dry run first
forge script script/Deploy.s.sol --rpc-url https://sepolia.base.org
# Deploy + verify on Base Sepolia
source .env
forge script script/Deploy.s.sol:Deploy \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--private-key $DEPLOYER_PK \
--broadcast \
--verifyAfter deploy, copy the three contract addresses printed in the console output into src/erc8004/abi.js under the REGISTRY_DEPLOYMENTS[84532] entry.
To allow-list a validator address on your ValidationRegistry:
cast send $VALIDATION_REGISTRY \
"addValidator(address)" $VALIDATOR_ADDR \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--private-key $DEPLOYER_PKVercel is not required. Any Node.js host that can serve static files and run serverless/edge functions works.
If you only need the 3D viewer without agent features or API routes:
npm run build
# Serve dist/ with any static file serverserver {
listen 80;
server_name 3d.yourdomain.com;
root /var/www/3d-agent/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 7d;
add_header Cache-Control "public, immutable";
}
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET";
}FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80docker build -t 3d-agent .
docker run -p 8080:80 3d-agentFor the full backend (API routes, database, crons) on non-Vercel infrastructure, deploy the functions under /api/ as a Node.js server. Each file in /api/ exports a default function compatible with the Vercel Functions signature — any adapter (e.g. @vercel/node locally, or a thin Express wrapper) works.
- Analytics: enable in the Vercel dashboard under Settings → Analytics
- Logs:
vercel logs --prodor the dashboard log viewer - Function duration: visible per-invocation in the dashboard; serverless functions have a 10-second default timeout (configurable in
vercel.json)
Neon's dashboard has query monitoring, connection graphs, and slow query logs. For production, watch the usage_events table — every tool call writes a row, so you can build dashboards directly in SQL or stream to your analytics stack.
Add Sentry by setting SENTRY_DSN in your environment and importing the Sentry SDK in your API entrypoints. No changes to vercel.json needed.
JWT_SECRET signs all session tokens. Rotate it by:
- Adding a new
JWT_KIDand correspondingJWT_SECRETin Vercel environment variables - Waiting for existing tokens to expire (default session duration)
- Removing the old key
Never remove the old key while tokens signed with it are still live — users will be silently logged out.
The app ships as a Progressive Web App. The service worker caches static assets (fonts, images, the model-viewer JS) and recently viewed GLB files so the viewer works offline.
The PWA manifest is generated by vite-plugin-pwa. Icons are generated from the source SVG by:
node scripts/generate-pwa-icons.mjsRe-run this script if you change the app icon.
After deploying, run through these checks to verify the instance is healthy:
| Flow | Check |
|---|---|
| Viewer | Load the app and drag-drop a GLB file |
| Wallet sign-in | Connect MetaMask — SIWE challenge + verify |
| Avatar creation | Navigate to /create — avatar builder iframe loads |
| Agent page | Visit /agent/:id — 3D viewer with chat overlay |
| Embed | Check /agent/:id/embed loads without auth, can be iframed |
| Dashboard | Navigate to /dashboard — shows your agents |
| API health | GET /api/agents returns JSON (may be empty array) |
| Auth metadata | GET /.well-known/oauth-authorization-server returns valid JSON |
| MCP endpoint | POST /api/mcp with {"jsonrpc":"2.0","id":1,"method":"tools/list"} and a bearer API key |
A full automated smoke test report is in docs/SMOKE_TEST.md.
NODE_OPTIONS=--max-old-space-size=4096 npm run buildThe model URL must either be on the same domain as the app, or the hosting server must include Access-Control-Allow-Origin headers. If the model is in your S3 bucket, verify the CORS policy was applied with aws s3api get-bucket-cors --bucket your-bucket-name.
Verify the vercel.json routing. The catch-all { "src": "/(.*)", "dest": "/$1" } at the bottom must exist and the more-specific routes above it must match your build output.
Check that DATABASE_URL includes ?sslmode=require — Neon rejects connections without SSL. Also confirm the Neon project is in the active (not suspended) state.
Crons only fire in production deployments, not previews. Verify you deployed with vercel --prod or triggered a production deploy from the dashboard.
Upstash REST API uses HTTPS — no ports to open. If you see connection errors, verify UPSTASH_REDIS_REST_URL starts with https:// and the token is correct. Rate limiting falls back to in-memory if Redis is unreachable (it will not crash the API).