Developer guide — AGENNTT

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.


Table of contents

  1. What this system is
  2. Architecture at a glance
  3. Layer model
  4. Data sources and capabilities
  5. Identity and sessions
  6. Ingestion pipeline
  7. SQLite schema
  8. Domain layer (tools.py)
  9. Cross-dataset reasoning
  10. Conversational agent stack
  11. HTTP API (webapp.py)
  12. MCP server (server.py)
  13. Fund analytics harness
  14. Batch jobs
  15. External integrations
  16. Environment variables
  17. Repository file map
  18. Test suite map
  19. Running locally
  20. Invariants and failure modes
  21. Known gaps and upstream defects
  22. Related repositories

1. What this system is

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:

All three transports call the same plain functions in tools.py. Neither transport may add business logic.


2. Architecture at a glance

                         ┌─────────────────────────────────────────┐
                         │              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   │
                         └─────────────────────────────────────────┘

End-to-end request flow (browser chat)

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

3. Layer model

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).


4. Data sources and capabilities

Declared in sources.py. Every tool checks capability before answering.

Per-source capabilities

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 on MF Central

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.

Refresh behaviour

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.

Dual-source merge

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():

Transactions merge similarly via sources.merged_transactions().


5. Identity and sessions

Person-first model (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.

PAN merge

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 (oidc.py, demo_oidc.py)

Authorization code + PKCE. Verified on callback:

Session 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.

InvestWell binding (binding.py)

Two-factor proof of PAN ownership:

  1. start(pan) — sends code to contact InvestWell already holds
  2. verify(attempt_id, code, dob?) — compares salted hash + optional DOB

No 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).

Sessions (session.py)

Rate limits (ratelimit.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).


6. Ingestion pipeline

Owner: sync.py

Triggers

Trigger Entry Blocks?
First InvestWell link webapp.py / bind.pysync.sync_client Yes (first link waits)
Nightly batch nightly.py No
MF Central collect mfcentral.store() No
Manual CLI python sync.py <PAN>

Pipeline steps

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")

Design constraints

Nightly exclusions (nightly.py)

Skips:

After client refresh: monitor.check() for fund ranking changes.


7. SQLite schema

Database: growthvine.db (override: GV_DB). Schema defined in sync.SCHEMA. Created by sync.connect().

Entity-relationship overview

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

Tables (detail)

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

holding

Per-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_snapshot

One 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_profile

Quiz result keyed on client_id. answers JSON retained for threshold replay.

plan

Goal-planning engine output. inputs = form sent to /calculate; results = engine response.

realized_gain

Per (client_id, year). Upstream summary often empty; totals computed in tax.py.

insight

Change signals from sync. signal_key + occurred_at UNIQUE for idempotency. Stores facts, never prewritten sentences.

conversation

Chat history. PAN-sensitive — never join to exports/logs. Clearable via DELETE /api/conversation.

linked_source

Records which portfolio sources connected. Capability logic lives in sources.py.

Indexes

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);

8. Domain layer (tools.py)

Plain functions (conn, client_id, ...) → dict. No MCP dependency. Read store only — never call InvestWell live.

Client-scoped read tools

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

Mutators and universe tools

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

Absence contract

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-only harness tools (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.


9. Cross-dataset reasoning

alignment.py — the point of the product

alignment.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:

category.py — rank context

Regular and Direct are disjoint ranked pools. Comparing a Regular holding against an unscoped average systematically flatters or misleads.

insights.py — change detection

Written by sync.detect_signals() only when something moved:

Empty list = quiet period, not failed check. Agent phrases facts; DB stores facts.


10. Conversational agent stack

Two answer paths

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 router

Ordered 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:

Supporting modules

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

Compliance boundary

In scope: describe what is true or happened
Out of scope: instruct, recommend, forecast

Grammatical distinction enforced in tests and verify.advice_flags().


11. HTTP API (webapp.py)

Starlette app. Identity from gv_session cookie only — no endpoint accepts client_id or PAN for data access.

Auth routes

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

Onboarding routes

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

Portfolio linking

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

Agent and data readers

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

Static and health

Method Path Purpose
GET / web/index.html SPA
GET /health Liveness probe

Demo mode adds /demo-idp/* routes via demo_oidc.py.


12. MCP server (server.py)

FastMCP wrapper over tools.py. Identity from GV_SESSION env var.

Always-loaded instructions

server.INSTRUCTIONS sent at MCP initialize:

  1. State as_of dates; never imply live pricing
  2. not_set means offer the step; never infer
  3. excludes lists what totals omit
  4. approximate: true on journey — coarse series
  5. Check if client holds a fund before ranking it
  6. Call get_my_sources before factual answers
  7. Describe, never direct (AMFI distributor, not RIA)
  8. Answer with facts first; limitations second

Detailed routing: get_client_guide() on demand.

Registered MCP tools (14)

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.

Transport

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.


13. Fund analytics harness

growthvine.py — Data API client

OAuth2 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:

fundgraph.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

14. Batch jobs

nightly.py

IW_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.py

GV_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.py

python audit.py

Self-check harness:


15. External integrations

┌──────────────┐     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)     │
└──────────────┘                └─────────────────┘

cas-tool endpoints (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.


16. Environment variables

Core

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

Growthvine Data API

Variable Purpose
GV_ID OAuth client ID
GV_SECRET OAuth client secret

InvestWell

Variable Default Purpose
IW_USER API username
IW_PASS API password
IW_BASE https://growthvinecapital.investwell.app Tenant URL

MF Central (cas-tool)

Variable Default Purpose
CAS_SERVICE_URL http://localhost:8000 cas-tool base
CAS_SERVICE_KEY Shared secret for document endpoint

OIDC

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

Binding code delivery

Variable Purpose
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD Email
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

Agent

Variable Purpose
ANTHROPIC_API_KEY Claude API (omit → intent.py fallback)

Operations

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.


17. Repository file map

Application core (34 modules)

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

CLI and batch (7 modules)

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

Tests (30 modules)

See §18 Test suite map.

Frontend and config

File Purpose
web/index.html Single-page browser client
mcp.json Cursor MCP configuration
run.ps1 Windows launcher
.env Local credentials (not committed)

Documentation

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

18. Test suite map

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.


19. Running locally

Demo mode (no credentials)

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.

Live mode

# .env with OIDC, IW_*, GV_*, CAS_SERVICE_*, ANTHROPIC_API_KEY
python webapp.py

MCP agent

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

Nightly refresh

IW_USER=... IW_PASS=... GV_ID=... GV_SECRET=... python nightly.py

20. Invariants and failure modes

From PM.md — the rules that must not regress.

The failure mode this project actually has

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

Security invariants

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

Conversational invariants

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

21. Known gaps and upstream defects

Not built yet

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

Upstream defects (ported faithfully, pinned by tests)

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

API traps (return HTTP 200 with wrong data)


22. Related repositories

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.


Quick reference: module dependency graph

                    ┌─────────────┐
                    │  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.