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/tiercome only from verified auth context — never from the request body.- No
verify=Falseon prod HTTP clients; compare secrets withhmac.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.
| Module | Role |
|---|---|
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
.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.
| Var | Prod value | Notes |
|---|---|---|
ENV | prod | CUTOVER flips dev-header auth off. |
DEV_AUTH_ENABLED | false | CUTOVER the X-Dev-User-Id bypass is dev-only (R1.1). |
OPENAI_API_KEY | sk-… | LLM access. |
DATABASE_URL | postgresql+psycopg://… | Prod Postgres with pgvector. |
REDIS_URL | redis://… | Pending confirm-token store. |
ONFA_API_BASE | https://onfa.io | CUTOVER was localhost:81. |
ONFA_JWT_PUBLIC_KEY | prod RS256 public PEM | CUTOVER inline PEM wins, else secret/drnick_jwt_public.pem. |
ONFA_JWT_ISSUER / _AUDIENCE | onfa / drnick-agent | JWT iss / aud pins. |
CORS_ORIGINS | ["https://onfa.io"] | CUTOVER every embedding origin; never [] in prod with the widget. |
ADMIN_TOKEN | distinct prod secret | CUTOVER Bearer for /v1/admin/*; never the dev value. |
APP_VERSION | v4.0.0 | Shown in /v1/healthz. |
Auth model #
Three independent mechanisms — pick by caller:
| Mechanism | Header | Used 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 header | X-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 token | Authorization: Bearer <ADMIN_TOKEN> | Every /v1/admin/* route. Compared with hmac.compare_digest. |
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.
| Query param | Type | |
|---|---|---|
conversation_id | int | required; must belong to the user (else 404) |
message | string | required, min length 1 |
file_ids | int[] | optional; repeatable &file_ids=1&file_ids=2 |
{ "confirm_token": "…", "decision": "confirm", // or "cancel"
"code": "123456", // TOTP — required to execute
"codemail": null } // optional email OTP (P4.4e)
SSE event format #
GET /v1/chat/stream emits these event types (in event: / data: SSE frames):
| event | data |
|---|---|
tool | A tool call started/finished (e.g. user_balance). |
token | One streamed token delta of the final answer. |
confirm | A financial action awaiting confirmation: confirm_token, summary, summary_rows[] (k/v, optional gold), warning. Render a confirm card; never auto-fire. |
done | Turn 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.
{ "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}
KB manager
OpenAPI / Swagger #
FastAPI auto-docs are enabled (the app is constructed without disabling them):
/docs— Swagger UI (interactive)/redoc— ReDoc/openapi.json— machine-readable schema
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
| Command | Phase |
|---|---|
preflight | Readiness (§0) + Debt-F env gate (§3). |
deploy | Record anchor → compose up --build → alembic upgrade head → poll /v1/healthz → check /v1/admin/health probes. |
reindex | Snapshot KB → kb/reindex + poll → orphan cleanup → verify orphans=0. |
smoke | healthz + admin health + orphan check (+ chat smoke if SMOKE_JWT set). |
all | preflight → deploy → reindex → smoke (default). |
rollback | Restore 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).
<…>, 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.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:
- Readiness — CI green; ONFA Task-29 country claim live; prod RS256 public PEM available.
- Merge → main + tag the release (
APP_VERSION). - Debt-F .env cutover — set the CUTOVER vars from the secret manager.
- Vendor React offline — already done (
web/vendor/); no CDN at load. - Deploy + restart — restart clears the persona
@lru_cache(the #1 trap; skip = persona edits invisible). - Re-index KB + clear orphans — see automation above.
- 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. Orscripts/go-live.sh --target … rollback. - Persona: restart picks up restored files.
- .env: keep the prior prod
.envbacked 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).
| Path | What |
|---|---|
web/index.html | Landing page + embedded chat widget (#dn-root). |
web/loader.js | One-line embed: drops the iframe widget on any host page. See below. |
web/widget-frame.html | The isolated iframe document the loader injects (served from the DrNick origin). |
web/embed-example.html | Reference host page using the loader (follow-host theme). |
web/widget/widget.jsx | The widget UI: FAB → welcome → panel; SSE chat; 2-step confirm card + TOTP. |
web/widget/apiEngine.js | Backend wiring: conversation create, SSE stream, confirm POST, file upload. |
web/widget/markdown.js · lang.js | Markdown render (XSS-safe) · pure language helpers. |
web/widget/widget.css · themes.css · avatar.css | Styles, 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.py | Dev static server + reverse proxy (/v1, /dev → backend). Serves this docs.html too. |
web/docs.html | This 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 sendsAuthorization: Bearer. The token never leaks up to the host.
Troubleshooting #
| Symptom | Cause / fix |
|---|---|
| Persona / tool edits don't take effect | get_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/claim | Host-run backend missing REDIS_URL override → create_pending fails. Set both DATABASE_URL and REDIS_URL to localhost. |
onfa: down in /v1/admin/health | Expected 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 offline | Ensure 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.