Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
20 KiB
Workspace
Overview
pnpm workspace monorepo using TypeScript. Each package manages its own dependencies.
Stack
- Monorepo tool: pnpm workspaces
- Node.js version: 24
- Package manager: pnpm
- TypeScript version: 5.9
- API framework: Express 5
- Database: PostgreSQL + Drizzle ORM
- Validation: Zod (
zod/v4),drizzle-zod - API codegen: Orval (from OpenAPI spec)
- Build: esbuild (CJS bundle)
Structure
artifacts-monorepo/
├── artifacts/ # Deployable applications
│ └── api-server/ # Express API server
├── lib/ # Shared libraries
│ ├── api-spec/ # OpenAPI spec + Orval codegen config
│ ├── api-client-react/ # Generated React Query hooks
│ ├── api-zod/ # Generated Zod schemas from OpenAPI
│ └── db/ # Drizzle ORM schema + DB connection
├── scripts/ # Utility scripts (single workspace package)
│ └── src/ # Individual .ts scripts, run via `pnpm --filter @workspace/scripts run <script>`
├── pnpm-workspace.yaml # pnpm workspace (artifacts/*, lib/*, lib/integrations/*, scripts)
├── tsconfig.base.json # Shared TS options (composite, bundler resolution, es2022)
├── tsconfig.json # Root TS project references
└── package.json # Root package with hoisted devDeps
TypeScript & Composite Projects
Every package extends tsconfig.base.json which sets composite: true. The root tsconfig.json lists all packages as project references. This means:
- Always typecheck from the root — run
pnpm run typecheck(which runstsc --build --emitDeclarationOnly). This builds the full dependency graph so that cross-package imports resolve correctly. Runningtscinside a single package will fail if its dependencies haven't been built yet. emitDeclarationOnly— we only emit.d.tsfiles during typecheck; actual JS bundling is handled by esbuild/tsx/vite...etc, nottsc.- Project references — when package A depends on package B, A's
tsconfig.jsonmust list B in itsreferencesarray.tsc --builduses this to determine build order and skip up-to-date packages.
Root Scripts
pnpm run build— runstypecheckfirst, then recursively runsbuildin all packages that define itpnpm run typecheck— runstsc --build --emitDeclarationOnlyusing project references
Environment Variables & Secrets
Automatically provisioned (do not set manually)
| Secret | Purpose |
|---|---|
AI_INTEGRATIONS_ANTHROPIC_BASE_URL |
Replit AI Integrations proxy base URL for Anthropic |
AI_INTEGRATIONS_ANTHROPIC_API_KEY |
Replit AI Integrations proxy API key (dummy value, auto-managed) |
AI_INTEGRATIONS_GEMINI_BASE_URL |
Replit AI Integrations proxy base URL for Gemini (localhost:1106/...) |
AI_INTEGRATIONS_GEMINI_API_KEY |
Replit AI Integrations proxy key for Gemini (dummy value, auto-managed) |
DATABASE_URL |
PostgreSQL connection string (Replit-managed) |
SESSION_SECRET |
Express session secret (Replit-managed) |
Required secrets (set via Replit Secrets tab)
| Secret | Description | Example |
|---|---|---|
LNBITS_URL |
Base URL of your LNbits instance | https://legend.lnbits.com |
LNBITS_API_KEY |
Invoice/Admin API key from your LNbits wallet | a3f... |
TIMMY_NOSTR_NSEC |
Timmy's persistent Nostr private key (run bash scripts/generate-timmy-nsec.sh to generate) |
nsec1... |
Note: If
LNBITS_URLandLNBITS_API_KEYare absent,LNbitsServiceautomatically runs in stub mode — invoices are simulated in-memory and can be marked paid viasvc.stubMarkPaid(hash). This is intentional for development without a Lightning node.
Cost-based work fee pricing
| Secret | Description | Default |
|---|---|---|
HAIKU_INPUT_COST_PER_1K_TOKENS |
Haiku input cost per 1K tokens (USD) | 0.0008 |
HAIKU_OUTPUT_COST_PER_1K_TOKENS |
Haiku output cost per 1K tokens (USD) | 0.004 |
SONNET_INPUT_COST_PER_1K_TOKENS |
Sonnet input cost per 1K tokens (USD) | 0.003 |
SONNET_OUTPUT_COST_PER_1K_TOKENS |
Sonnet output cost per 1K tokens (USD) | 0.015 |
DO_MONTHLY_COST_USD |
Monthly DO infra cost amortised per request | 100 |
DO_MONTHLY_REQUEST_VOLUME |
Expected monthly request volume (divisor) | 1000 |
ORIGINATOR_MARGIN_PCT |
Margin percentage on top of cost | 25 |
OUTPUT_TOKENS_SHORT_EST |
Estimated output tokens for short requests | 200 |
OUTPUT_TOKENS_MEDIUM_EST |
Estimated output tokens for medium requests | 400 |
OUTPUT_TOKENS_LONG_EST |
Estimated output tokens for long requests | 800 |
WORK_SYSTEM_PROMPT_TOKENS_EST |
Work model system-prompt size in tokens | 50 |
SHORT_MAX_CHARS |
Max chars for "short" request tier | 100 |
MEDIUM_MAX_CHARS |
Max chars for "medium" request tier | 300 |
EVAL_FEE_SATS |
Fixed eval invoice amount | 10 |
BTC_PRICE_USD_FALLBACK |
BTC/USD price fallback if CoinGecko is unreachable | 100000 |
EVAL_MODEL |
Anthropic model used for evaluation | claude-haiku-4-5 |
WORK_MODEL |
Anthropic model used for work execution | claude-sonnet-4-6 |
Work fee flow: estimate tokens → fetch BTC price from CoinGecko (60s cache) → (token_cost + DO_infra) × (1 + margin%) → convert USD → sats.
After work runs, actual token counts and raw Anthropic spend are stored in jobs as actual_input_tokens, actual_output_tokens, actual_cost_usd.
Honest accounting and automatic refunds
After every job completes, Timmy re-prices the job using actual token counts, adds DO infra amortisation and margin, converts back to sats using the same BTC price locked at invoice time, and computes the overpayment:
actual_amount_sats = ceil((actual_cost_usd + DO_infra) × (1 + margin%) / btc_price_usd × 1e8)
refund_amount_sats = max(0, work_amount_sats - actual_amount_sats)
The costLedger in GET /api/jobs/:id shows all figures side-by-side. If refundState = "pending" and refundAmountSats > 0, the user submits a BOLT11 invoice for that exact amount to POST /api/jobs/:id/refund to receive the Lightning payment. The endpoint is idempotent — subsequent calls return 409 with the original refundPaymentHash.
Node bootstrap secrets (for POST /api/bootstrap)
| Secret | Description | Default |
|---|---|---|
BOOTSTRAP_FEE_SATS |
Startup fee in sats the user pays | 10000 (dev) |
DO_API_TOKEN |
Digital Ocean personal access token | absent = stub mode |
DO_REGION |
DO datacenter region | nyc3 |
DO_SIZE |
DO droplet size slug | s-4vcpu-8gb |
DO_VOLUME_SIZE_GB |
Block volume to attach in GB (0 = none) |
0 |
TAILSCALE_API_KEY |
Tailscale API key for generating auth keys | optional |
TAILSCALE_TAILNET |
Tailscale tailnet name (e.g. example.com) |
required with above |
Note: If
DO_API_TOKENis absent,ProvisionerServiceautomatically runs in stub mode — provisioning is simulated with fake credentials and a real SSH keypair. SetDO_API_TOKENfor real node creation.
Packages
artifacts/api-server (@workspace/api-server)
Express 5 API server. Routes live in src/routes/ and use @workspace/api-zod for request and response validation and @workspace/db for persistence.
- Entry:
src/index.ts— readsPORT, starts Express - App setup:
src/app.ts— mounts CORS, JSON/urlencoded parsing, routes at/api - Routes:
src/routes/index.tsmounts sub-routers;src/routes/health.tsexposesGET /health(full path:/api/health) - Depends on:
@workspace/db,@workspace/api-zod pnpm --filter @workspace/api-server run dev— run the dev serverpnpm --filter @workspace/api-server run build— production esbuild bundle (dist/index.cjs)- Build bundles an allowlist of deps (express, cors, pg, drizzle-orm, zod, etc.) and externalizes the rest
lib/db (@workspace/db)
Database layer using Drizzle ORM with PostgreSQL. Exports a Drizzle client instance and schema models.
src/index.ts— creates aPool+ Drizzle instance, exports schemasrc/schema/index.ts— barrel re-export of all modelssrc/schema/<modelname>.ts— table definitions withdrizzle-zodinsert schemas (no models definitions exist right now)drizzle.config.ts— Drizzle Kit config (requiresDATABASE_URL, automatically provided by Replit)- Exports:
.(pool, db, schema),./schema(schema only)
Production migrations are handled by Replit when publishing. In development, we just use pnpm --filter @workspace/db run push, and we fallback to pnpm --filter @workspace/db run push-force.
lib/api-spec (@workspace/api-spec)
Owns the OpenAPI 3.1 spec (openapi.yaml) and the Orval config (orval.config.ts). Running codegen produces output into two sibling packages:
lib/api-client-react/src/generated/— React Query hooks + fetch clientlib/api-zod/src/generated/— Zod schemas
Run codegen: pnpm --filter @workspace/api-spec run codegen
lib/api-zod (@workspace/api-zod)
Generated Zod schemas from the OpenAPI spec (e.g. HealthCheckResponse). Used by api-server for response validation.
lib/api-client-react (@workspace/api-client-react)
Generated React Query hooks and fetch client from the OpenAPI spec (e.g. useHealthCheck, healthCheck).
artifacts/api-server — Timmy API endpoints
Payment-gated job flow
BASE="https://${REPLIT_DEV_DOMAIN}" # or http://localhost:8080 in dev
# 1. Create a job (returns eval invoice)
curl -s -X POST "$BASE/api/jobs" \
-H "Content-Type: application/json" \
-d '{"request": "Write a haiku about lightning payments"}'
# → {"jobId":"…","evalInvoice":{"paymentRequest":"lnbcrt10u1…","amountSats":10}}
# 2. Poll status (returns eval invoice while unpaid)
curl -s "$BASE/api/jobs/<jobId>"
# 3. (Stub mode only) Mark eval invoice paid
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
# 4. Poll again — auto-advances to awaiting_work_payment, returns work invoice
curl -s "$BASE/api/jobs/<jobId>"
# 5. (Stub mode only) Mark work invoice paid
curl -s -X POST "$BASE/api/dev/stub/pay/<workPaymentHash>"
# 6. Poll again — auto-advances to complete, returns result
curl -s "$BASE/api/jobs/<jobId>"
# → {"jobId":"…","state":"complete","result":"…"}
Job states: awaiting_eval_payment → evaluating → awaiting_work_payment → executing → complete | rejected | failed
Lightning-gated node bootstrap
Pay a one-time startup fee → Timmy auto-provisions a Bitcoin full node on Digital Ocean.
# 1. Create bootstrap job (returns invoice)
curl -s -X POST "$BASE/api/bootstrap"
# → {"bootstrapJobId":"…","invoice":{"paymentRequest":"…","amountSats":10000},
# "message":"Stub mode: simulate payment with POST /api/dev/stub/pay/<hash>…"}
# 2. (Stub mode) Simulate payment
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
# 3. Poll status — transitions: awaiting_payment → provisioning → ready
curl -s "$BASE/api/bootstrap/<bootstrapJobId>"
# 4. When ready, response includes credentials (SSH key delivered once):
# {
# "state": "ready",
# "credentials": {
# "nodeIp": "…",
# "tailscaleHostname": "timmy-node-xxxx.tailnet.ts.net",
# "lnbitsUrl": "https://timmy-node-xxxx.tailnet.ts.net",
# "sshPrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----…" ← null on subsequent polls
# },
# "nextSteps": [ "SSH in", "Monitor sync", "Run lnd-init.sh", "Configure sweep" ]
# }
Bootstrap states: awaiting_payment → provisioning → ready | failed
Key properties:
- Stub mode auto-activates when
DO_API_TOKENis absent — returns fake credentials with a real SSH keypair - SSH key delivered once then cleared from DB; subsequent polls show
sshKeyNote - Tailscale auth key auto-generated if
TAILSCALE_API_KEY+TAILSCALE_TAILNETare set - Bitcoin sync takes 1–2 weeks;
readymeans provisioned + bootstrapped, not fully synced
Free demo endpoint (rate-limited: 5 req/hour per IP)
curl -s "$BASE/api/demo?request=Explain+proof+of+work+in+one+sentence"
# → {"result":"…"}
Dev-only stub payment trigger
POST /api/dev/stub/pay/:paymentHash — marks a stub invoice paid in-memory.
Only available in development (NODE_ENV !== 'production').
scripts (@workspace/scripts)
Utility scripts package. Each script is a .ts file in src/ with a corresponding npm script in package.json. Run scripts via pnpm --filter @workspace/scripts run <script>. Scripts can import any workspace package (e.g., @workspace/db) by adding it as a dependency in scripts/package.json.
Pre-funded session mode (Mode 2)
Pay once, run many requests. Balance is debited at actual cost per request — no per-job invoices.
BASE="http://localhost:8080"
# 1. Create a session (returns deposit invoice)
curl -s -X POST "$BASE/api/sessions" \
-H "Content-Type: application/json" \
-d '{"amount_sats": 500}'
# → {"sessionId":"…","state":"awaiting_payment","invoice":{"paymentRequest":"…","amountSats":500,"paymentHash":"…"}}
# 2. (Stub mode only) Pay deposit
curl -s -X POST "$BASE/api/dev/stub/pay/<paymentHash>"
# 3. Poll session — auto-advances to active, issues macaroon
curl -s "$BASE/api/sessions/<sessionId>"
# → {"sessionId":"…","state":"active","balanceSats":500,"macaroon":"…","minimumBalanceSats":50}
# 4. Submit requests (use macaroon as Bearer token)
curl -s -X POST "$BASE/api/sessions/<sessionId>/request" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <macaroon>" \
-d '{"request":"What is a satoshi?"}'
# → {"requestId":"…","state":"complete","result":"…","debitedSats":178,"balanceRemaining":322}
# 5. Top up when balance is low (session auto-pauses below 50 sats)
curl -s -X POST "$BASE/api/sessions/<sessionId>/topup" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <macaroon>" \
-d '{"amount_sats": 500}'
Session states: awaiting_payment → active ⇄ paused (low balance) → expired (24h TTL, refreshed on each request)
Key properties:
- No per-job invoices — balance is debited at actual compute cost (eval + work tokens + DO infra + margin)
- Rejected requests still incur a small eval fee (Haiku model only)
- Macaroon auth — 32-byte hex token issued on activation; required as
Authorization: Bearerheader - Pause on low balance — session auto-pauses when balance < 50 sats; pay topup invoice to resume
- TTL — sessions expire 24 hours after last successful request
- Deposit limits — 100–10,000 sats; env vars:
SESSION_MIN_DEPOSIT_SATS,SESSION_MAX_DEPOSIT_SATS,SESSION_MIN_BALANCE_SATS,SESSION_EXPIRY_HOURS
DB tables: sessions (state machine, balance, macaroon), session_requests (per-request token + cost accounting)
Pushing to Gitea
Gitea runs on the local Mac behind a bore tunnel. The bore port changes every session.
One-time setup
bash scripts/push-to-gitea.sh <PORT> # saves port to .bore-port, then pushes
Every subsequent push this session
bash scripts/push-to-gitea.sh # reads port from .bore-port automatically
When bore restarts (new port)
Bore assigns a new random port on each restart. You must pass it once — after that .bore-port remembers it:
bash scripts/push-to-gitea.sh <NEW_PORT> # overwrites .bore-port, then pushes
How to find the bore port: The port is shown in the Mac terminal where bore is running:
bore local 3000 --to bore.pub
→ "listening at bore.pub:NNNNN"
Credentials (GITEA_TOKEN): The script never hard-codes a token. Set it one of two ways:
# Option A — env var (add to shell profile for persistence)
export GITEA_TOKEN=<your-token>
# Option B — gitignored credentials file (one-time setup)
echo <your-token> > .gitea-credentials
Get your token from Gitea → User Settings → Applications → Generate Token.
Rules:
- Always create a branch and open a PR — never push directly to
main(Gitea enforces this) - The
.bore-portand.gitea-credentialsfiles are gitignored — never committed
Gitea repos
replit/token-gated-economy— TypeScript API server (this repo)perplexity/the-matrix— Three.js 3D world frontend
Deployment
Canonical deployment config — artifact.toml (not .replit)
The API server's authoritative deployment configuration lives in
artifacts/api-server/.replit-artifact/artifact.toml. This file controls the
production build command and the run command for the always-on VM deployment.
deploymentTarget = "vm"
buildCommand = "pnpm --filter @workspace/api-server run build"
runCommand = "node artifacts/api-server/dist/index.js"
The root .replit file may show an older deploymentTarget = "autoscale" and
run = "dist/index.cjs" — these are legacy entries left from when Replit
platform protection blocked agent edits. artifact.toml is the source of
truth; .replit entries for this artifact should be ignored.
Hermes Gitea (backup / deployment source)
All workspace code is mirrored to a self-hosted Gitea instance on hermes backed by PostgreSQL. This is the second git remote — independent of the Mac Tailscale Gitea — so no single machine holds all the code.
| Item | Value |
|---|---|
| Web UI | http://143.198.27.163:3000/admin/timmy-tower |
| SSH (git) | ssh://git@143.198.27.163:2222 |
| DB backend | PostgreSQL (gitea DB on hermes) |
| Admin user | admin |
| Token store | /root/.gitea-replit-token on VPS |
Push from Replit (any session):
bash scripts/push-to-hermes.sh
This script fetches the API token from the VPS over SSH (never stored in git), adds the hermes remote, and pushes all branches + tags.
Postgres backup — the Gitea metadata lives in the gitea PostgreSQL DB. Backup with:
# On hermes
sudo -u postgres pg_dump gitea > /root/gitea-backup-$(date +%Y%m%d).sql
The bare git objects live in /var/lib/gitea/repositories/ and can be backed up with rsync or tar.
VPS deployment (hermes — 143.198.27.163)
The production instance runs on the user's VPS via systemd, outside Replit:
| Item | Value |
|---|---|
| URL | http://143.198.27.163/ |
| Service | systemctl status timmy-tower |
| Deploy dir | /opt/timmy-tower/ |
| Env file | /opt/timmy-tower/.env |
| DB | postgres://timmy:...@localhost:5432/timmy_tower |
| Nostr npub | npub1e3gu2j08t6hymjd5sz9dmy4u5pcl22mj5hl60avkpj5tdpaq3dasjax6tv |
| AI backend | OpenRouter (https://openrouter.ai/api/v1) via Anthropic SDK compat layer |
To redeploy after a build:
# From Replit — rebuild and copy bundle
pnpm --filter @workspace/api-server run build
cat artifacts/api-server/dist/index.js | ssh root@143.198.27.163 "cat > /opt/timmy-tower/index.js"
ssh root@143.198.27.163 "systemctl restart timmy-tower"
External packages that must be present in /opt/timmy-tower/node_modules/:
nostr-tools(^2.23.3)cookie-parser(^1.4.7)
These are externalized by esbuild (not in the allowlist in build.ts).
Roadmap
Nostr integration
Nostr (NIP-04/NIP-44 encrypted DMs) is planned as the delivery layer for the bootstrap flow and beyond:
- Node credential delivery — provisioned node generates a Nostr keypair during cloud-init and publishes credentials as an encrypted DM to the user's pubkey; eliminates the current HTTP one-time SSH key polling mechanism
- Job status events — job state transitions (
awaiting_payment → complete) published as Nostr events; clients subscribe instead of polling - Node identity — each Timmy node gets a persistent Nostr identity (npub) for discovery and communication
Until Nostr is wired in, the current HTTP polling + one-time SSH key delivery serves as the POC placeholder.