Architecture reference for the Growthvine client agent. This document maps every module, data flow, API surface, and invariant in the repository. Read PM.md section 1 before changing behaviour; read this before changing structure.
For user-facing setup, see README.md.
tools.py)webapp.py)server.py)AGENNTT is a single-client financial agent: it answers one person's questions about their own money, grounded in data they have connected. It is not a generic chatbot and not a registered investment adviser.
Four kinds of data feed the agent:
| Kind | Source | Who has it |
|---|---|---|
| Risk profile | Collected here (10-question quiz) | Anyone who completes onboarding |
| Financial plan | Goal-planning engine, stored locally | Anyone who completes onboarding |
| Portfolio (full) | InvestWell aggregator API | Growthvine clients on the book |
| Portfolio (partial) | MF Central CAS via cas-tool | Anyone with RTA-registered contact |
The agent exposes this data through:
server.py) — for language models over stdio/HTTPwebapp.py + web/index.html) — for people in a browserbind.py, link_cas.py, sync.py) — for operators and testsAll three transports call the same plain functions in tools.py. Neither
transport may add business logic.
┌─────────────────────────────────────────┐
│ Entry points │
├─────────────┬─────────────┬─────────────┤
│ webapp.py │ server.py │ bind.py / │
│ + index │ (FastMCP) │ link_cas.py │
└──────┬──────┴──────┬──────┴──────┬──────┘
│ │ │
HTTP/cookie │ │ GV_SESSION │ CLI token
▼ ▼ ▼
┌─────────────────────────────────────────┐
│ Identity layer │
│ oidc.py accounts.py session.py │
│ binding.py ratelimit.py │
└──────────────────┬──────────────────────┘
│ client_id
▼
┌─────────────────────────────────────────┐
│ Conversational layer │
│ agent.py ──► Claude + bound tools │
│ │ fallback │
│ └──► intent.py (regex router) │
│ memory.py memo.py verify.py │
└──────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Domain layer │
│ tools.py alignment.py sources.py │
│ riskprofile.py planning.py tax.py │
│ watchlist.py insights.py category.py │
└──────────────────┬──────────────────────┘
│ read/write
▼
┌─────────────────────────────────────────┐
│ Persistence (SQLite) │
│ sync.py — schema owner │
│ growthvine.db (GV_DB) │
└──────────────────┬──────────────────────┘
▲
┌──────────────────┴──────────────────────┐
│ Ingestion writers │
│ investwell.py mfcentral.py sync.py │
│ casservice.py growthvine.py tax.py │
└─────────────────────────────────────────┘
User types question
→ POST /api/chat
→ session.resolve(cookie) → client_id
→ memory.recent() → conversation history
→ agent.reply(conn, client_id, message)
→ if ANTHROPIC_API_KEY:
Claude tool_runner + bound_tools (identity stripped)
→ tools.get_my_* / harness.* / get_category_ranking
→ verify.unmatched() logs invented numbers
else:
intent.answer() — regex router, no model
→ memory.append(user + assistant + used payloads)
→ JSON { text, used, suggestions }
→ browser renders answer + expandable tool receipts
| Layer | Modules | Responsibility |
|---|---|---|
| Transport | webapp.py, server.py, web/index.html |
HTTP routes, MCP registration, cookie/session wiring |
| Identity | oidc.py, demo_oidc.py, accounts.py, session.py, binding.py, ratelimit.py |
Sign-in, PAN binding, session tokens, rate limits |
| Conversation | agent.py, intent.py, memory.py, memo.py, verify.py |
Route questions, call tools, store history, log provenance gaps |
| Domain | tools.py, alignment.py, sources.py, riskprofile.py, planning.py, tax.py, watchlist.py, insights.py, category.py |
Business logic over stored data |
| Analytics | harness.py, fundgraph.py, growthvine.py |
Computed fund metrics, peer rankings, drawdown |
| Ingestion | sync.py, investwell.py, mfcentral.py, casservice.py |
Pull upstream, derive, store, emit signals |
| Batch | nightly.py, watch.py, monitor.py |
Scheduled refresh and fund watch |
| Support | transport.py, senders.py, demo.py, audit.py |
HTTPS enforcement, code delivery, demo fixtures, self-check |
Dependency rule: transport → identity → conversation → domain → SQLite. Ingestion writes SQLite; domain reads SQLite only (never calls InvestWell live).
Declared in sources.py. Every tool checks capability before answering.
| Capability | InvestWell | MF Central (CAS) |
|---|---|---|
holdings |
yes | yes |
cost_basis |
yes | yes |
transactions |
yes | only if detail CAS stored |
allocation |
yes (4 lenses) | derived from assetType |
journey |
yes (~50 points) | no |
realized_gains |
yes (by FY) | no |
sip_register |
yes | no |
returns (XIRR, etc.) |
yes | no |
look_through |
yes (scrip lens) | no |
Risk profile and plan are not in CAPABILITIES — they are collected by this
system and available to everyone regardless of portfolio source.
transactions is not statically declared for mfcentral. Whether a client
has a ledger is determined empirically by sources._mfcentral_has_transactions()
— checking whether stored txn rows exist with source='mfcentral'. This
reflects that MF Central is a per-pull consent choice (summary vs detail CAS).
See sources.MFCENTRAL_TRANSACTIONS and PM.md §2 for the full verification
trail.
| Source | Refresh | Meaning of stale data |
|---|---|---|
| InvestWell | automatic |
Nightly run (nightly.py) |
| MF Central | manual |
Only a fresh OTP pull updates it |
get_my_portfolio returns freshness with per-source age and refresh metadata.
When both InvestWell and MF Central are linked, the same fund can appear twice
(distributor-sold funds in both). Merge rules in sources.merged_holdings():
(ISIN or scheme_name, folio_no)Transactions merge similarly via sources.merged_transactions().
accounts.py)Order of onboarding (suggested, not gated):
sign in (OIDC)
→ risk profile (riskprofile.py)
→ financial plan (planning.py)
→ connect portfolio (InvestWell and/or MF Central)
client row is the account anchor. oidc_sub identifies the person; pan is
nullable until portfolio linking.
When someone signs up and proves a PAN that already has a nightly-synced row:
accounts.attach_pan(account_id, pan)
→ account row absorbs orphan synced row
→ direction: account ← orphan (never reverse)
→ risk_profile, plan, session stay on account row
Ownership rules:
| PAN row state | Link allowed? |
|---|---|
Owned by another account (oidc_sub set) |
No — takeover |
| Bound but unowned | Yes — merge into signed-in account |
| Bound, unowned, no account in CLI call | No — cannot distinguish duplicate signup from takeover |
oidc.py, demo_oidc.py)Authorization code + PKCE. Verified on callback:
state, nonce, aud, iss, JWT signature (JWKS)alg: none refusedSession cookie: gv_session, HttpOnly, SameSite=Lax (Lax required for
OIDC return navigation).
Demo mode: demo_oidc.py signs real RS256 tokens; only consent screen is fake.
binding.py)Two-factor proof of PAN ownership:
start(pan) — sends code to contact InvestWell already holdsverify(attempt_id, code, dob?) — compares salted hash + optional DOBNo destination argument on start — code cannot be redirected.
Delivery via senders.py (SMTP or HTTP SMS). Without configured sender,
start refuses (unless --dev prints code locally).
session.py)client_idNone (no oracle)GV_SESSION env var; browser uses cookieratelimit.py)Per-IP sliding window at HTTP edge:
| Scope | Protects |
|---|---|
auth:start |
Binding enumeration |
auth:verify |
Code brute force |
read |
Data API scraping |
chat |
Agent abuse |
Skipped when GV_DEMO=1. binding.py limits per-PAN; ratelimit.py limits
per-caller (IP).
Owner: sync.py
| Trigger | Entry | Blocks? |
|---|---|---|
| First InvestWell link | webapp.py / bind.py → sync.sync_client |
Yes (first link waits) |
| Nightly batch | nightly.py |
No |
| MF Central collect | mfcentral.store() |
No |
| Manual CLI | python sync.py <PAN> |
— |
fetch_all(iw, pan) # 8 concurrent InvestWell calls
→ derive(fetched) # lift holdings, txns, snapshot fields
→ store(conn, client_id) # idempotent writes per source
→ resolve_isins(gv) # ISIN → scheme_id cache
→ tax.backfill(conn, client_id)
→ detect_signals() # what changed since last sync
→ insights.record() # persist change facts
→ sources.link(conn, client_id, "investwell")
txnid PK on transactions; holdings replaced per source.startDate on portfolio call is a measurement window, not history filter.nightly.py)Skips:
After client refresh: monitor.check() for fund ranking changes.
Database: growthvine.db (override: GV_DB). Schema defined in sync.SCHEMA.
Created by sync.connect().
client (1) ──┬── holding (*)
├── txn (*)
├── client_snapshot (*)
├── risk_profile (0..1)
├── plan (0..1)
├── realized_gain (*)
├── insight (*)
├── watchlist (*)
├── linked_source (*)
├── conversation (*)
└── session (*)
binding_attempt (*) — ephemeral challenges
binding_log (*) — audit trail (pan_hash only)
isin_scheme_map (*) — global ISIN cache
fund_snapshot (*) — scheme-level ranking history
client| Column | Type | Notes |
|---|---|---|
id |
INTEGER PK | Account anchor |
pan |
TEXT UNIQUE | Nullable until linked |
pan_hash |
TEXT UNIQUE | Unsalted SHA-256 lookup key — not anonymisation |
oidc_sub |
TEXT UNIQUE | issuer:subject |
email |
TEXT | From OIDC, contact only |
iwell_code |
TEXT | InvestWell client code |
created_at |
TEXT | Row creation |
signed_up_at |
TEXT | First OIDC sign-in |
bound_at |
TEXT | PAN verified |
last_sync_at |
TEXT | Last successful sync |
sync_state |
TEXT | Last sync outcome |
portfolio_skipped_at |
TEXT | User declined linking |
holdingPer-source holdings. No PK (CAS may lack ISIN).
| Column | Notes |
|---|---|
source |
investwell | mfcentral |
isin |
Absent on ~13% of CAS schemes |
folio_no |
Same fund, different folio = different position |
scheme_id |
Resolved via Growthvine Data API |
xirr |
InvestWell only |
txn| Column | Notes |
|---|---|
txnid |
PK — stable upstream ID |
source |
investwell default; mfcentral for detail CAS |
txn_type |
SIP, PUR, RED, etc. |
client_snapshotOne row per (client_id, as_of, source). JSON columns:
| Column | Content |
|---|---|
allocation |
Four lenses: asset, category, equityCap, fund + look-through |
journey |
~50 point invested vs AUM series |
raw |
Untouched upstream portfolio response |
risk_profileQuiz result keyed on client_id. answers JSON retained for threshold replay.
planGoal-planning engine output. inputs = form sent to /calculate; results =
engine response.
realized_gainPer (client_id, year). Upstream summary often empty; totals computed in tax.py.
insightChange signals from sync. signal_key + occurred_at UNIQUE for idempotency.
Stores facts, never prewritten sentences.
conversationChat history. PAN-sensitive — never join to exports/logs. Clearable via
DELETE /api/conversation.
linked_sourceRecords which portfolio sources connected. Capability logic lives in sources.py.
CREATE INDEX conversation_client ON conversation (client_id, id);
CREATE INDEX idx_txn_client ON txn(client_id, nav_date);
CREATE INDEX idx_holding_client ON holding(client_id, source);
tools.py)Plain functions (conn, client_id, ...) → dict. No MCP dependency. Read store
only — never call InvestWell live.
| Function | Purpose | Key status values |
|---|---|---|
get_my_sources |
Linked sources, capabilities, missing meanings | ok |
get_my_portfolio |
Holdings, returns, allocation, peer ranks | ok, no_data |
get_my_concentration |
Look-through scrip/sector exposure | ok, unavailable |
get_my_transactions |
Merged ledger | ok, unavailable |
get_my_journey |
Invested vs value series | ok, no_data |
get_my_tax_position |
Realized by FY + unrealized headline | ok, unavailable |
get_my_plan |
Risk profile + goals | ok, not_set |
get_my_alignment |
Cross-dataset findings | ok, no_data |
get_my_insights |
Change signals | ok |
get_my_watchlist |
Followed + monitored funds | ok |
| Function | Identity required |
|---|---|
add_to_watchlist(conn, client_id, scheme_id) |
yes |
remove_from_watchlist(conn, client_id, scheme_id) |
yes |
get_category_ranking(category, gv, dir_plan, limit) |
no — public market data |
get_client_guide() |
no — routing table for agents |
Every function returns explicit status — never empty data that reads as "you have none":
| Status | Meaning |
|---|---|
ok |
Data present |
no_data |
No synced portfolio yet |
not_set |
User has not completed that step |
unavailable |
Source cannot provide this capability |
error |
Unexpected failure |
agent._harness_tools)Bound into Claude's tool list but not exposed via MCP directly:
| Tool | Wraps | Purpose |
|---|---|---|
top_by_metric(category, metric, limit) |
harness.top_by_metric |
Category ranking by return period |
peers_for_holding(scheme_name, metric) |
harness.peers_for_holding |
Where user's fund sits among peers |
fund_risk_metrics(scheme_name, years) |
harness.drawdown_and_rolling |
Drawdown + rolling returns |
Identity (conn, client_id) is closed over by agent.bind() and stripped
from tool schemas the model sees.
alignment.py — the point of the productalignment.check(conn, client_id) reads portfolio + risk profile + plan together.
Returns:
{
"status": "ok",
"findings": [
{
"kind": "equity_vs_risk_profile",
"severity": "significant",
"summary": "...", # human sentence
"observed": {...},
"expected": {...},
"datasets": ["risk_profile", "allocation"]
}
],
"not_checked": [
{"check": "single_security_concentration", "needs": "look-through data"}
]
}
Finding kinds (non-exhaustive):
| Kind | Compares |
|---|---|
equity_vs_risk_profile |
Allocation vs risk band |
small_cap_vs_risk_profile |
Small-cap weight vs profile |
required_vs_actual_sip |
Plan SIP vs transaction history |
plan_vs_current_portfolio |
Goal funding vs holdings |
near_goal_vs_equity |
Goal horizon vs equity exposure |
holding_vs_category |
Fund return vs category average |
single_security_concentration |
Look-through scrip weight |
plan_looks_miscalibrated |
Required SIP vs recorded income |
Rules:
findings + populated not_checked = "we did not look" ≠ "all clear"notable, income_verified: falsecategory.py — rank contextplan_type(scheme_name) → "0" Regular or "1" Directsize(gv, category, dir_plan) → category pool size (denominator for rank)stats(gv, category, dir_plan) → peer average returnsRegular and Direct are disjoint ranked pools. Comparing a Regular holding against an unscoped average systematically flatters or misleads.
insights.py — change detectionWritten by sync.detect_signals() only when something moved:
Empty list = quiet period, not failed check. Agent phrases facts; DB stores facts.
agent.reply()
│
├─ ANTHROPIC_API_KEY set?
│ YES → Claude Sonnet tool_runner
│ system_prompt(conn, client_id)
│ bound_tools (identity injected, stripped from schema)
│ memory.recent() for context
│ verify.unmatched() — log-only numeric check
│ NO → intent.answer() — regex router, template responses
│
└─ returns { status, text, used, suggestions, intent? }
intent.py — fallback routerOrdered regex patterns in INTENTS. First match wins. Handlers compose
tools.* calls and return { text, used }.
Intent categories:
| Intent | Example phrasings |
|---|---|
greeting |
hi, hello, namaste |
discovery |
what stands out, what do you think, anything I should know |
portfolio |
how am I doing, tell me about my portfolio |
alignment |
is my portfolio right for me |
plan |
am I on track, retirement goal |
fund |
how is my X fund doing, is it risky |
ranking |
best large cap funds (universe, not holdings) |
advice |
what should I buy, recommend me |
sell |
should I sell |
| ... | sip, tax, concentration, journey, transactions, sources, etc. |
Unmatched → status: "unmatched" + SUGGESTIONS list.
agent.py — Claude path| Constant | Value | Notes |
|---|---|---|
MODEL |
claude-sonnet-5 |
Cost/volume tradeoff |
MAX_TOOL_CALLS |
12 | Per turn |
EXPOSED |
10 get_my_* tools |
Client data |
UNIVERSE |
get_category_ranking |
Public fund data |
system_prompt() injects:
server.INSTRUCTIONS (compliance rules)| Module | Role |
|---|---|
memory.py |
SQLite conversation history; REPLAY_TURNS for prompt context |
memo.py |
Per-turn call cache — duplicate tool calls hit cache not API |
verify.py |
Post-answer numeric provenance check (log-only) |
fundgraph.py |
ISIN validation, plan option parsing, chunk helper |
In scope: describe what is true or happened
Out of scope: instruct, recommend, forecast
Grammatical distinction enforced in tests and verify.advice_flags().
webapp.py)Starlette app. Identity from gv_session cookie only — no endpoint accepts
client_id or PAN for data access.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/auth/login |
Redirect to OIDC provider |
| GET | /api/auth/callback |
Exchange code → session cookie |
| POST | /api/auth/logout |
Revoke session |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/onboarding |
Step state: risk_profile, plan, portfolio |
| GET | /api/questionnaire |
Risk quiz questions |
| POST | /api/riskprofile |
Submit quiz answers |
| POST | /api/plan |
Submit goal form → planning engine |
| Method | Path | Purpose |
|---|---|---|
| POST | /api/link/investwell/start |
Start binding challenge |
| POST | /api/link/investwell/verify |
Verify code + DOB |
| POST | /api/link/mfcentral/start |
Start CAS request |
| GET | /api/link/mfcentral/status |
Poll consent status |
| POST | /api/link/mfcentral/otp |
Submit OTP |
| POST | /api/link/mfcentral/qr |
QR validation path |
| POST | /api/link/mfcentral/collect |
Fetch and store CAS |
| POST | /api/link/mfcentral |
Legacy combined link |
| POST | /api/link/skip |
Skip portfolio linking |
| Method | Path | Maps to |
|---|---|---|
| POST | /api/chat |
agent.reply() |
| DELETE | /api/conversation |
memory.clear() |
| GET | /api/sources |
get_my_sources |
| GET | /api/portfolio |
get_my_portfolio |
| GET | /api/alignment |
get_my_alignment |
| GET | /api/plan |
get_my_plan |
| GET | /api/transactions |
get_my_transactions |
| GET | /api/journey |
get_my_journey |
| GET | /api/tax |
get_my_tax_position |
| GET | /api/concentration |
get_my_concentration |
| GET | /api/insights |
get_my_insights |
| GET | /api/watchlist |
get_my_watchlist |
| POST | /api/watchlist |
add_to_watchlist |
| Method | Path | Purpose |
|---|---|---|
| GET | / |
web/index.html SPA |
| GET | /health |
Liveness probe |
Demo mode adds /demo-idp/* routes via demo_oidc.py.
server.py)FastMCP wrapper over tools.py. Identity from GV_SESSION env var.
server.INSTRUCTIONS sent at MCP initialize:
as_of dates; never imply live pricingnot_set means offer the step; never inferexcludes lists what totals omitapproximate: true on journey — coarse seriesget_my_sources before factual answersDetailed routing: get_client_guide() on demand.
| Tool | Identity arg |
|---|---|
get_my_sources |
no — resolved from session |
get_my_portfolio |
no |
get_my_concentration |
no |
get_my_transactions |
no |
get_my_journey |
no |
get_my_tax_position |
no |
get_my_plan |
no |
get_my_alignment |
no |
get_my_insights |
no |
get_my_watchlist |
no |
add_to_watchlist(scheme_id) |
no |
remove_from_watchlist(scheme_id) |
no |
get_category_ranking(category, dir_plan, limit) |
no — public |
get_client_guide() |
no |
No tool accepts client_id, pan, or any identity parameter.
GV_SESSION=<token> python server.py # stdio (Cursor MCP)
GV_SESSION=<token> python server.py --http --port 8000
Configure in mcp.json for Cursor integration.
growthvine.py — Data API clientOAuth2 client credentials → api.growthvine.in/v2/*
Used for: ISIN→scheme_id, category listings, rankings, NAV history, compare.
Both APIs reject default Python user agents (403 / Cloudflare 1010).
harness.py — computed metrics| Function | Purpose |
|---|---|
top_by_metric(category, metric, limit, dir_plan, plan_option) |
Rank category by return period |
peers_for_holding(holding, metric) |
User's fund marked in peer set |
drawdown_and_rolling(scheme_id, years) |
Max drawdown + rolling return distribution |
max_drawdown(nav_series) |
Peak-to-trough |
rolling_returns(nav_series, window) |
Distribution not single number |
cagr(start, end, years) |
Annualised growth |
Metric sources:
| Periods | Source |
|---|---|
| 1Month, 6Month, 1Year, 3Year, 5Year | On category listing row |
| 7Year, 10Year, sinceInception | Bulk compare() call, chunked by 10 |
| Short periods without bulk | Per-fund fund_details(), capped at FANOUT_CAP=25 |
Plan option filtering:
investment_type: "G" → growth; "Y" → IDCWpeers_for_holding defaults unknown holding plan to growthfundgraph.py| Function | Purpose |
|---|---|
plan_option(name) |
"growth", "idcw", or "unknown" from plan name |
is_fund_isin(value) |
INF... prefix check |
chunks(items, size) |
Batch helper for API calls |
nightly.pyIW_USER=... IW_PASS=... GV_ID=... GV_SECRET=... python nightly.py
For each eligible client: sync.sync_client() → monitor.check().
Eligibility: bound + on InvestWell book + stale > 12h.
Measured: ~21s/client, ~109min for 307 clients sequential.
watch.pyGV_ID=... GV_SECRET=... python watch.py
Standalone fund ranking monitor. One lookup per scheme regardless of holder count.
Rank moves ≤ 3 places ignored (nightly recomputation noise).
audit.pypython audit.py
Self-check harness:
┌──────────────┐ HTTPS ┌─────────────────┐
│ AGENNTT │ ──────────────►│ InvestWell API │
│ investwell.py│ │ (aggregator) │
└──────────────┘ └─────────────────┘
┌──────────────┐ HTTPS ┌─────────────────┐
│ AGENNTT │ ──────────────►│ Growthvine │
│ growthvine.py│ │ Data API │
└──────────────┘ └─────────────────┘
┌──────────────┐ HTTPS ┌─────────────────┐
│ AGENNTT │ ──────────────►│ cas-tool │
│ casservice.py│ CAS_SERVICE │ (MF Central │
│ │ _KEY header │ crypto/OTP) │
└──────────────┘ └────────┬────────┘
│ MF Central API
▼
┌─────────────────┐
│ MF Central │
└─────────────────┘
┌──────────────┐ HTTPS ┌─────────────────┐
│ AGENNTT │ ──────────────►│ Goal Planning │
│ planning.py │ │ Engine │
└──────────────┘ └─────────────────┘
┌──────────────┐ HTTPS ┌─────────────────┐
│ AGENNTT │ ──────────────►│ OIDC Provider │
│ oidc.py │ │ (Google/demo) │
└──────────────┘ └─────────────────┘
┌──────────────┐ SMTP/HTTP ┌─────────────────┐
│ AGENNTT │ ──────────────►│ Email / SMS │
│ senders.py │ │ gateway │
└──────────────┘ └─────────────────┘
┌──────────────┐ HTTPS ┌─────────────────┐
│ AGENNTT │ ──────────────►│ Anthropic API │
│ agent.py │ │ (optional) │
└──────────────┘ └─────────────────┘
casservice.py)| Method | Path | Purpose |
|---|---|---|
| POST | /api/cas/request |
Start CAS pull |
| POST | /api/cas/verify-otp |
Submit OTP |
| POST | /api/cas/validate-qr |
QR path |
| GET | /api/cas/status/{ref} |
Poll status |
| GET | /api/cas/{ref}/document |
Fetch CAS (authenticated) |
MF Central credentials never live in AGENNTT.
| Variable | Default | Purpose |
|---|---|---|
GV_DB |
growthvine.db |
SQLite path |
GV_SESSION |
— | MCP session token |
GV_PORT |
8800 |
HTTP listen port |
GV_BASE_URL |
http://127.0.0.1:{port} |
OIDC redirect base |
GV_DEMO |
— | Demo mode (1 = skip rate limits) |
GV_INSECURE_COOKIE |
1 on HTTP |
Allow non-Secure cookies locally |
| Variable | Purpose |
|---|---|
GV_ID |
OAuth client ID |
GV_SECRET |
OAuth client secret |
| Variable | Default | Purpose |
|---|---|---|
IW_USER |
— | API username |
IW_PASS |
— | API password |
IW_BASE |
https://growthvinecapital.investwell.app |
Tenant URL |
| Variable | Default | Purpose |
|---|---|---|
CAS_SERVICE_URL |
http://localhost:8000 |
cas-tool base |
CAS_SERVICE_KEY |
— | Shared secret for document endpoint |
| Variable | Purpose |
|---|---|
OIDC_CLIENT_ID / GOOGLE_CLIENT_ID |
OAuth app ID |
OIDC_CLIENT_SECRET / GOOGLE_CLIENT_SECRET |
OAuth secret |
OIDC_ISSUER |
Provider issuer (default: Google) |
LOCAL_OIDC |
1 = local demo IdP when no real creds |
| Variable | Purpose |
|---|---|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD |
|
SMTP_FROM / SMTP_FROM_EMAIL, SMTP_FROM_NAME |
From address |
SMTP_TLS |
STARTTLS (default 1) |
SMS_URL, SMS_TEMPLATE, SMS_AUTH_HEADER |
HTTP SMS gateway |
| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY |
Claude API (omit → intent.py fallback) |
| Variable | Default | Purpose |
|---|---|---|
TRUSTED_PROXY_HOPS |
0 |
Proxy chain for client IP |
PLANNING_REPO |
— | Path to fund-planning-mod for contract tests |
Credentials are inline env vars only — never written to disk.
| File | One-line purpose |
|---|---|
accounts.py |
Person-centric account lifecycle, PAN merge |
agent.py |
Claude tool-calling loop + fallback |
alignment.py |
Cross-dataset checks (portfolio × risk × plan) |
binding.py |
InvestWell PAN 2FA binding |
casservice.py |
HTTP client to cas-tool |
category.py |
Category rank context, plan type scoping |
demo.py |
Demo personas, fake InvestWell/CAS |
demo_oidc.py |
Local RS256 OIDC issuer |
fundgraph.py |
ISIN/plan-option helpers |
growthvine.py |
Growthvine Data API client |
harness.py |
Computed fund metrics |
insights.py |
Sync change signal persistence |
intent.py |
Regex router + template answers |
investwell.py |
InvestWell aggregator client |
memo.py |
Per-turn API call cache |
memory.py |
Conversation history in SQLite |
mfcentral.py |
CAS parse/store |
monitor.py |
Fund ranking change detection |
oidc.py |
OIDC authorization code + PKCE |
planning.py |
Goal-planning engine proxy |
ratelimit.py |
Per-IP rate limits |
riskprofile.py |
10-question risk quiz |
senders.py |
Binding code delivery (SMTP/SMS) |
server.py |
FastMCP server |
session.py |
Hashed session tokens |
sources.py |
Source capability model + merge |
sync.py |
Schema owner, ingestion pipeline |
tax.py |
Realized gains by FY |
tools.py |
Agent-facing domain API |
transport.py |
HTTPS enforcement |
verify.py |
Post-answer numeric check |
watchlist.py |
Followed funds |
webapp.py |
Starlette HTTP app |
| File | Purpose |
|---|---|
bind.py |
CLI InvestWell binding → session token |
link_cas.py |
CLI MF Central onboarding |
nightly.py |
Overnight InvestWell refresh + monitor |
watch.py |
Standalone fund watch |
audit.py |
Self-check harness |
live_check.py |
Manual live API smoke |
e2e.py |
End-to-end integration smoke |
See §18 Test suite map.
| File | Purpose |
|---|---|
web/index.html |
Single-page browser client |
mcp.json |
Cursor MCP configuration |
run.ps1 |
Windows launcher |
.env |
Local credentials (not committed) |
| File | Purpose |
|---|---|
README.md |
User-facing setup and concepts |
PM.md |
Project memory — invariants, failure modes |
DEVELOPER.md |
This document |
docs/ |
Design specs, UX reviews, implementation plans |
Run all:
for t in test_*.py; do python $t; done
python audit.py
~301 unit tests, no network required (except e2e.py, live_check.py).
| Test file | Primary coverage |
|---|---|
test_accounts.py |
Account lifecycle, PAN merge |
test_agent.py |
Tool binding, fallback, prompt |
test_alignment.py |
Cross-dataset findings, planning contract |
test_binding.py |
Binding flow, DOB verification |
test_casservice.py |
cas-tool HTTP client |
test_category.py |
Plan type scoping, peer stats |
test_concentration.py |
Look-through concentration |
test_conversation.py |
Live agent regressions (golden questions) |
test_dual_source.py |
InvestWell + MF Central merge |
test_fundgraph.py |
Plan option parsing |
test_growthvine.py |
Data API client |
test_harness.py |
Peer ranking, drawdown, metrics |
test_insights.py |
Change signal persistence |
test_intent.py |
Regex routing, voice/tone guards |
test_investwell.py |
InvestWell client quirks |
test_memory.py |
Conversation storage |
test_memo.py |
Per-turn cache |
test_monitor.py |
Fund watch |
test_nightly.py |
Batch eligibility |
test_oidc.py |
OIDC verification |
test_profile.py |
Risk quiz + plan storage |
test_senders.py |
Code delivery |
test_session.py |
Session tokens |
test_sources.py |
Capability model, merge, freshness |
test_sync.py |
Ingestion pipeline |
test_tax_watchlist.py |
Tax + watchlist |
test_tools.py |
All get_my_* tools |
test_transport.py |
HTTPS enforcement |
test_verify.py |
Numeric provenance check |
test_web.py |
HTTP routes, demo mode |
test_conversation.py — every test is something the agent got wrong in live
testing, kept so it cannot regress.
python webapp.py --demo
# → http://127.0.0.1:8800
# Sign in as Asha (AAAAA1111A) → auto-linked InvestWell demo portfolio
Uses demo.py seeded store (.demo.db), demo_oidc.py issuer, fake
InvestWell/CAS services.
# .env with OIDC, IW_*, GV_*, CAS_SERVICE_*, ANTHROPIC_API_KEY
python webapp.py
python bind.py start ABCDE1234X
python bind.py verify <attempt_id> <code> --dob 1980-04-15
# prints GV_SESSION token
GV_SESSION=<token> GV_ID=... GV_SECRET=... python server.py
IW_USER=... IW_PASS=... GV_ID=... GV_SECRET=... python nightly.py
From PM.md — the rules that must not regress.
The code almost never errors. It answers confidently from data that isn't there.
| Rule | Implementation |
|---|---|
| Absence is a status, never a value | unavailable, not_set, no_data, not_checked — never 0 or [] |
| Silence reads as agreement | Blocked checks appear in not_checked |
| Unverifiable ≠ verified | Plan gate requires income; unverified plans capped at notable |
| Never invent numbers | Tax ST/LT split states what's missing |
| Prefer upstream figures | Income from planner summary, not raw form alone |
| Invariant | Where enforced |
|---|---|
| No tool/endpoint takes identity | audit.py, all tool signatures |
| Session fails closed uniformly | session.resolve() → same None |
| No session without binding/OIDC | No demo shortcut routes |
Secrets compared with compare_digest |
binding.py, audit.py |
| HTTPS for secrets in transit | transport.require_secure_url |
| Owned ≠ bound | accounts.attach_pan merge direction |
| PAN never in logs/errors | Code review + tests |
| Invariant | Where |
|---|---|
| Describe, never direct | server.INSTRUCTIONS, intent.py, tests |
| Answer first, limitations second | agent.system_prompt, tools._GUIDE |
Every figure traceable to used |
Browser receipts panel, verify.py |
| Ranking ≠ recommendation | intent._ranking disclaimer sentence |
| Gap | Status |
|---|---|
| CAS unattended refresh | Impossible — OTP required; honesty via freshness |
Detail CAS holdings (dtSummary) |
Parser not written; guard prevents wipe |
| Per-holding ST/LT tax split | Not exposed by any source; stated plainly |
| Look-through weekly cadence | Fetched every sync today |
| Postgres | Schema portable; SQLite deliberate for zero setup |
| Defect | Effect | Mitigation |
|---|---|---|
| Quiz Conservative band unreachable (score ≤ 3, min score 5) | Most cautious answers → "Moderately Conservative" | Test pins; fix is widen band to ≤ 6 |
Planning README wrong field name (goal_start_val_{gid}) |
₹0/month goals | planning.py builds correct keys |
| Goal named "Retirement" triggers different maths | Inflated required SIP | alignment.py plan credibility gate |
selectedDateFromtxnTypeIn codes required — subset drops txns silentlystartDate is measurement window, not history filtergraphPeriod accepts only sinceInception (case-sensitive)| Repository | Role |
|---|---|
| AGENNTT (this repo) | Store, tools, MCP, web, agent |
| MFcentral/cas-tool | MF Central consent flow, crypto, OTP, CAS retrieval |
| fund-planning-mod | Goal-planning engine source (api-goalplanning.growthvine.in) |
Cross-repo contract tests in test_alignment.py use PLANNING_REPO env var.
When absent, tests report unverified rather than pass silently.
┌─────────────┐
│ webapp.py │
│ server.py │
└──────┬──────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
agent.py tools.py oidc/accounts/
│ │ session/binding
▼ │
intent.py │
▼
┌────────────────────────┐
│ alignment.py │
│ sources.py │
│ riskprofile.py │
│ planning.py │
│ tax.py │
│ watchlist.py │
│ insights.py │
│ category.py │
└───────────┬────────────┘
▼
┌───────────┐
│ sync.py │ ◄── investwell.py
│ (SQLite) │ ◄── mfcentral.py
└───────────┘ ◄── growthvine.py
▲
nightly.py / bind.py / link_cas.py
Last updated: 2026-08-31. For UX review findings and golden test questions, see docs/conversational-agent-review-2026-08-31.md.