DrNick Agent v4 — Dev & Deploy

Standalone personal AI agent for ONFA Fintech (FastAPI / Python / GPT-4o). Independent of PHP — talks to ONFA only two ways: an embeddable JS-loader + iframe widget (inbound, JWT-authed) and the ONFA REST API called as tools (outbound, service token).

Overview #

Every change follows PLAN → PATCH → VERIFY (see DRNICK_RULES_FOR_CLAUDE.md). Hard rules that matter most here:

  • user_id / tier come only from verified auth context — never from the request body.
  • No verify=False on prod HTTP clients; compare secrets with hmac.compare_digest.
  • The only path that moves money is POST /v1/chat/confirm — 2FA mandatory, every action.

Status (2026-06-08): M1–M5 + AdminPanel merged to main; remaining work is go-live only.

Architecture — 4 boundaries #

Every latent bug lives inside one of these or at a junction between them.

ModuleRole
auth/Identity gate: RS256 JWT verify (inbound) / dev header.
agent/Brain: orchestrator (ReAct+Reflect, streams straight into SSE), tools, intent.
tools/Action surface: registry (single source of truth), validator, executor.
bridge/ONFA integration: REST client (verify=True), read paths, cache.

Runtime stack: FastAPI + Postgres (pgvector for RAG/memory) + Redis (pending confirm tokens) + OpenAI.

Local development #

Option A — Docker (full stack)

docker compose up            # api :8000 · db (pgvector) :5432 · redis :6379
docker compose exec api alembic upgrade head   # run migrations

Option B — host-run backend + FE proxy

# backend on :8088 with dev-header auth ON
DEV_AUTH_ENABLED=true \
DATABASE_URL=postgresql+psycopg://drnick:drnick@localhost:5432/drnick \
REDIS_URL=redis://localhost:6379/0 \
uvicorn app.main:app --reload --port 8088

# standalone FE (serves web/, proxies /v1 + /dev → backend)
python3 web/serve.py                       # http://localhost:5173 → 127.0.0.1:8088
DRNICK_BACKEND_PORT=8000 python3 web/serve.py   # point at the docker api instead
Host-run gotcha: .env uses docker hostnames (db, redis). When running uvicorn on the host you must override both DATABASE_URL and REDIS_URL to localhost — missing REDIS_URL makes chat work but deposit/claim fail at create_pending (the confirm card never shows).

Quality gates

ruff check . && ruff format --check .   # lint + format
mypy app                                # types (strict)
pytest -q                               # tests (needs live PG+Redis; OpenAI for smoke)

Environment variables #

Dev defaults live in .env. Production template: .env.prod.example — keys marked CUTOVER are the Debt-F blockers and must change for prod.

VarProd valueNotes
ENVprodCUTOVER flips dev-header auth off.
DEV_AUTH_ENABLEDfalseCUTOVER the X-Dev-User-Id bypass is dev-only (R1.1).
OPENAI_API_KEYsk-…LLM access.
DATABASE_URLpostgresql+psycopg://…Prod Postgres with pgvector.
REDIS_URLredis://…Pending confirm-token store.
ONFA_API_BASEhttps://onfa.ioCUTOVER was localhost:81.
ONFA_JWT_PUBLIC_KEYprod RS256 public PEMCUTOVER inline PEM wins, else secret/drnick_jwt_public.pem.
ONFA_JWT_ISSUER / _AUDIENCEonfa / drnick-agentJWT iss / aud pins.
CORS_ORIGINS["https://onfa.io"]CUTOVER every embedding origin; never [] in prod with the widget.
ADMIN_TOKENdistinct prod secretCUTOVER Bearer for /v1/admin/*; never the dev value.
APP_VERSIONv4.0.0Shown in /v1/healthz.

Auth model #

Three independent mechanisms — pick by caller:

MechanismHeaderUsed by
ONFA JWT (RS256)Authorization: Bearer <jwt>The widget (real users). Verified server-side: RS256 pinned, iss/aud checked, requires exp,iss,aud,sub. Claims → AuthContext{user_id, tier, vip, country}.
Dev headerX-Dev-User-Id: <int>Local only. Active only when ENV=dev and DEV_AUTH_ENABLED=true (fail-safe default off). tier=free, vip=0.
Admin tokenAuthorization: Bearer <ADMIN_TOKEN>Every /v1/admin/* route. Compared with hmac.compare_digest.
Never pass user_id / tier in a request body — they are taken only from the verified context (R1.1). The bridge writes user_id last so a smuggled value can't reach ONFA.

User & chat API #

All paths are under /v1. user auth = JWT or dev header.

GET/v1/healthz no auth

Liveness. → { "status":"ok", "version":"<APP_VERSION>", "env":"prod" }

POST/v1/conversations user auth

Create a conversation for the authed user. → { "conversation_id": 42 }

GET/v1/chat/stream user auth SSE

Stream a chat turn (Server-Sent Events). Open with EventSource or fetch + ReadableStream.

Query paramType
conversation_idintrequired; must belong to the user (else 404)
messagestringrequired, min length 1
file_idsint[]optional; repeatable &file_ids=1&file_ids=2

Country for language routing comes from the JWT country claim or the CF-IPCountry header — not a query param. See SSE event format.

POST/v1/chat/confirm user auth money path

Consume a one-time confirm_token and execute the parked financial action — the ONLY money-moving endpoint. 2FA mandatory.

{ "confirm_token": "…", "decision": "confirm",  // or "cancel"
  "code": "123456",          // TOTP — required to execute
  "codemail": null }         // optional email OTP (P4.4e)

Token is single-use (atomic GETDEL in Redis); cancel clears without burning; expiry → ACTION_EXPIRED.

GET/v1/conversations   GET/v1/conversations/{id}/messages user auth

List conversations / load history (paginated, ownership-checked).

POST/v1/files user auth multipart

Upload one file (field file). MIME allowlist: png, jpeg, webp, gif, pdf, docx, xlsx · cap 10 MiB · magic-byte sniff · isolated per user. → { "file_id":1662, "filename":"…", "mime":"…", "size_bytes":… } · 413 too large · 415 unsupported.

POST/v1/files/{id}/parse   POST/v1/files/{id}/index user auth

parse: docs → text ({ "status":"parsed", "chars":… }, 422 on failure). index: embed + upsert into RAG namespace file:{id}. Images skip parse (read at chat-time via vision).

POST/v1/memory user auth

Write a memory item into namespace user:{id} (cross-user isolated, append-only).

POST/v1/auth/exchange JWT

Thin Bearer-JWT verify probe used by the widget handshake (M5-P2, Option A direct-Bearer — does not mint a second token).

GET/dev/chat dev only

Dev harness UI. Self-gates to ENV=dev — returns 404 in prod.

SSE event format #

GET /v1/chat/stream emits these event types (in event: / data: SSE frames):

eventdata
toolA tool call started/finished (e.g. user_balance).
tokenOne streamed token delta of the final answer.
confirmA financial action awaiting confirmation: confirm_token, summary, summary_rows[] (k/v, optional gold), warning. Render a confirm card; never auto-fire.
doneTurn complete; includes sources[] (RAG citations, ns kb:global).
error{ "code":"…", "message":"…" } — user-safe message.
event: tool
data: {"name":"user_balance","status":"ok"}

event: token
data: {"delta":"Số dư "}

event: confirm
data: {"confirm_token":"…","summary":"Ủy thác 100 OFT…","summary_rows":[{"k":"Nhận dự kiến","v":"~35.65 USDT","gold":true}],"warning":"…"}

event: done
data: {"sources":[{"namespace":"kb:global","source_ref":"05_product_zeninvest.md"}]}

Admin API #

All under /v1/admin. Bearer ADMIN_TOKEN · 503 if unconfigured · 401 if wrong. Read endpoints don't touch the money path.

GET/v1/admin/health

4 concurrent probes (each ~3s timeout), always HTTP 200; status in body.

{ "postgres":{"status":"ok","kb_chunks":148,"namespace":"kb:global"},
  "redis":   {"status":"ok","pending_count":0},
  "onfa":    {"status":"down","error":"…"},          // down in dev (no PHP)
  "killswitch":{"enabled":false,"error":null} }       // note: {enabled}, not {status}
GET/v1/admin/killswitch   POST/v1/admin/killswitch

Emergency suspend of all action tools (durable Postgres flag, survives restart, gates both dispatch + execute). POST body { "enabled": true }{ key, enabled, audited }. Every toggle audited.

GET/v1/admin/tool-flags   POST/v1/admin/tool-flags/{name}

Per-tool kill flags on the same gate (registry-keyed → 404 unknown tool; default ALLOW).

GET/v1/admin/audit   GET/v1/admin/tool-errors

Financial audit log (filter user_id/action_type/since, cursor before) · money-action failures.

GET/v1/admin/conversations   GET/v1/admin/conversations/{id}/messages

Cross-user conversation viewer (admin bypasses ownership; token-gated).

KB manager

GET/v1/admin/kb

Per-file: disk size/mtime ⋈ indexed chunk count → statussynced | stale | not_indexed | orphan.

GET/v1/admin/kb/{name}   PUT/v1/admin/kb/{name}

Read / write a knowbase .md file. .md-only (415), path-traversal blocked (400).

POST/v1/admin/kb/reindex   GET/v1/admin/kb/reindex/{job_id}

Kick a full re-index (background) → { job_id, status:"running" }; poll the job → { status:"done"|"failed", total_chunks }. Globs knowbase/*.md top-level only (REPLACE keyed on filename).

OpenAPI / Swagger #

FastAPI auto-docs are enabled (the app is constructed without disabling them):

In prod these expose the full surface — gate or disable them at the edge if that's a concern.

Go-live (automated) #

scripts/go-live.sh drives the deploy-box steps end-to-end. Runs on the staging/prod host (it drives docker compose + the local app + /v1/admin/*).

# dry-run the whole pipeline first (changes nothing)
scripts/go-live.sh --target staging --env-file .env --base-url http://localhost:8000 --dry-run all

# real staging run, then prod
scripts/go-live.sh --target staging --env-file .env all
scripts/go-live.sh --target prod    --env-file /path/.env.prod all
CommandPhase
preflightReadiness (§0) + Debt-F env gate (§3).
deployRecord anchor → compose up --buildalembic upgrade head → poll /v1/healthz → check /v1/admin/health probes.
reindexSnapshot KB → kb/reindex + poll → orphan cleanup → verify orphans=0.
smokehealthz + admin health + orphan check (+ chat smoke if SMOKE_JWT set).
allpreflight → deploy → reindex → smoke (default).
rollbackRestore documents/ to the anchor → rebuild → re-index.

Flags: --target staging|prod · --env-file · --base-url · --dry-run · --yes · --skip-build · --killswitch (suspend action tools during the deploy window).

The prod env gate HARD-FAILS on placeholder <…>, ENV≠prod, DEV_AUTH_ENABLED≠false, non-HTTPS ONFA_API_BASE, empty JWT PEM, CORS=[], or a too-short ADMIN_TOKEN. It will not deploy a misconfigured prod.
Human gates the script can't automate (it asks, it doesn't fake): merge → main + PR review · ONFA PHP country claim (Task 29) live · prod RS256 keypair generation · providing the secrets.

Orphan cleanup helper

scripts/clear_kb_orphans.py auto-detects orphan source_refs via kb_service.list_kb (no hardcoded list) and deletes them from kb:global. Needed because index_kb only REPLACES files it re-globs — files moved out of the glob keep stale chunks.

docker compose exec -T api python /app/scripts/clear_kb_orphans.py --dry-run   # list
docker compose exec -T api python /app/scripts/clear_kb_orphans.py             # delete

Runsheet (manual reference) #

Canonical: docs/DEPLOY-RUNSHEET-2026-06-08.md + docs/tasks/04b-DEPLOY-RUNBOOK-kb-restructure.md. Order:

  1. Readiness — CI green; ONFA Task-29 country claim live; prod RS256 public PEM available.
  2. Merge → main + tag the release (APP_VERSION).
  3. Debt-F .env cutover — set the CUTOVER vars from the secret manager.
  4. Vendor React offline — already done (web/vendor/); no CDN at load.
  5. Deploy + restart — restart clears the persona @lru_cache (the #1 trap; skip = persona edits invisible).
  6. Re-index KB + clear orphans — see automation above.
  7. Smoke — language (EN/VI/country), KB (ZenInvest≠Savings, withdrawal limits, USDO), widget, money path 2FA.

Rollback #

  • Code: revert the merge + restart. country=None / no-directive degrades to English default — safe.
  • KB: git checkout <anchor> -- documents/ → re-run reindex → clear new orphans. Or scripts/go-live.sh --target … rollback.
  • Persona: restart picks up restored files.
  • .env: keep the prior prod .env backed up before the cutover.

web/ layout #

The web/ tree is the standalone DrNick frontend — vanilla HTML/CSS + React-via-Babel (no build step), served static by serve.py (dev) or from the DrNick origin (prod).

PathWhat
web/index.htmlLanding page + embedded chat widget (#dn-root).
web/loader.jsOne-line embed: drops the iframe widget on any host page. See below.
web/widget-frame.htmlThe isolated iframe document the loader injects (served from the DrNick origin).
web/embed-example.htmlReference host page using the loader (follow-host theme).
web/widget/widget.jsxThe widget UI: FAB → welcome → panel; SSE chat; 2-step confirm card + TOTP.
web/widget/apiEngine.jsBackend wiring: conversation create, SSE stream, confirm POST, file upload.
web/widget/markdown.js · lang.jsMarkdown render (XSS-safe) · pure language helpers.
web/widget/widget.css · themes.css · avatar.cssStyles, dark/light theme tokens, presence avatar.
web/admin/AdminPanel UI (health / kill-switch / tool-flags / audit / KB) — token in sessionStorage → Bearer.
web/vendor/Offline React 18.3.1 / react-dom 18.3.1 / @babel/standalone 7.29.7 (no unpkg at load).
web/serve.pyDev static server + reverse proxy (/v1, /dev → backend). Serves this docs.html too.
web/docs.htmlThis page.

Open this guide locally at http://localhost:5173/docs.html (FE proxy) or directly via the api origin.

Embedding the widget #

One line on any ONFA page. The loader drops a floating cross-origin iframe loaded from the DrNick origin (CSS/JS isolated; all /v1 calls same-origin → no host CORS).

<script src="https://drnick.onfa.io/loader.js"
        data-origin="https://drnick.onfa.io" defer></script>

postMessage bridge (origin-pinned, allowlist by construction):

  • host → frame: { type:'drnick:theme', theme:'dark'|'light' } — re-skin to match host.
  • frame → host: { type:'drnick:resize', state:'fab'|'panel' } — grow/shrink the iframe.
  • auth: loader fetches /drnick/token (ONFA) → drnick:auth → widget sends Authorization: Bearer. The token never leaks up to the host.

Troubleshooting #

SymptomCause / fix
Persona / tool edits don't take effectget_persona() is @lru_cached + the tool registry is built at startup. Restart the process after persona/tool commits. KB/embeddings are live from Postgres (no restart needed).
Confirm card never appears on deposit/claimHost-run backend missing REDIS_URL override → create_pending fails. Set both DATABASE_URL and REDIS_URL to localhost.
onfa: down in /v1/admin/healthExpected in dev (no real PHP at ONFA_API_BASE). On prod with https://onfa.io it reads ok.
RAG answers stale (e.g. "ZenInvest = MTT Savings")Old monolith chunks are orphaned in kb:global. Re-index + run the orphan cleanup (§ go-live).
401 on /v1/admin/*Wrong/absent ADMIN_TOKEN Bearer. 503 means it isn't configured server-side.
Widget won't render offlineEnsure web/vendor/* is present (vendored React/Babel); the HTML no longer points at unpkg.

DrNick Agent v4 · ONFA Fintech · canonical sources: CLAUDE.md, docs/ROADMAP.md, docs/SESSION_STATE.md, docs/DEPLOY-RUNSHEET-2026-06-08.md.