Appearance
Agent graph
bz-agent is a LangGraph state machine that turns a sentence into a structured intent. It has no chain access and executes nothing.
Entry: src/index.ts → src/app.ts · Port: 4000 · Graph: src/agent/graph.ts
Shape
Every leaf node has .addEdge('<node>', 'EXIT'). Only two nodes use conditional edges: entryNode (the router) and vectorNode.
entryNode — classification
src/template/entry.ts → getInitSystemTemplate(intents) builds a system prompt that injects every intent's name → description pair from AVAILABLE_INTENTS. The LLM picks one.
Intent descriptions are the classifier
There is no fine-tuned model and no embedding classifier. The description field on each intent is the training signal, which is why several of them carry explicit examples and "do NOT use this for…" clauses. Improving classification means editing a description, not retraining anything.
Model tier: extraction — fast, temperature: 0.
Intent nodes — extraction
One node per intent in src/agent/node.ts, plus four in their own files (bzTokenNode, createDappNode, modifyDappNode, goOnchainNode).
Each node is a small LLM call with a Zod schema for the fields that intent needs. For example swapNode returns { fromToken, toToken, chain, amount, text }.
The follow_up convention
Every node ends the same way:
ts
if (allRequiredFieldsPresent) {
state.intent = AVAILABLE_INTENTS.SWAP.name
state.text = 'no-text'
} else {
state.intent = 'follow_up'
state.text = 'How much ETH would you like to swap?'
}So a partially-specified request produces a clarifying question, never a guessed value. 'no-text' is the sentinel telling bz-backend that the handler should produce the reply, not the agent.
Routing table
entryNode's conditional edge is a switch. Notable cases:
| Intent | Routes to |
|---|---|
SWAP, BRIDGE, BUY, LIMIT_ORDER, SEND_TOKEN, DCA, TX_HISTORY, TX_INFO, NFT_INFO, PORTFOLIO, CHECK_BALANCE, PRICE_ALERT, SWITCH_CHAIN, CONTRACT_AUDIT, TOKEN_SAFETY, WALLET_CARD, REVOKE_APPROVALS, AUTOPILOT, WORKFLOW, MARKET_INTEL, BATCH | Their own node |
BUY_BZ_TOKEN | bzTokenNode |
CREATE_DAPP, MODIFY_DAPP, GO_ONCHAIN | Their own node |
SHOW_DAPP, DEPLOY_CONTRACTS | EXIT directly |
CONVERSATION | EXIT directly |
FALLBACK | vectorNode |
SYSTEM | EXIT |
| anything else | EXIT |
SHOW_DAPP, DEPLOY_CONTRACTS and CONVERSATION exit without an extraction node
They need no parameters beyond what the session already carries (activeDappProjectId) or are handled entirely by the backend. Adding a node for them would be dead weight.
Fallback and RAG
ts
.addConditionalEdges('vectorNode', (state) =>
state.intent === AVAILABLE_INTENTS.FALLBACK.name ? 'conversationNode' : 'EXIT'
)vectorNode (src/agent/vector.ts) does a semantic lookup against PGVector on PostgreSQL using STORE_SYSTEM_TEMPLATE. Two outcomes:
- The lookup resolves the question → set a real intent →
EXIT. - It does not → still
FALLBACK→conversationNodeanswers conversationally with the retrieved context.
The knowledge base is managed through src/controllers/store.controller.ts and src/routes/store.ts, with src/models/store.model.ts and @langchain/textsplitters for chunking.
Model tiers
src/utils/models.ts resolves a model per tier, not per vendor. Nodes ask for a tier; the concrete model is a deployment detail behind an env var, so the provider can change without touching a single node.
| Tier | Role | Env override | Used by |
|---|---|---|---|
extraction | Fast, deterministic, cheap | LLM_MODEL_EXTRACTION | entryNode and every intent node |
conversation | Richer prose for DeFi answers | LLM_MODEL_CONVERSATION | conversationNode |
complex | Most capable | LLM_MODEL_COMPLEX | dApp creation, contract audit |
All at temperature: 0.
Tiers are the abstraction; keep it that way
Reference a tier (getLLM('extraction')), never a model name. The whole point of the indirection is that swapping a provider is an env change. Concrete credentials are listed in environment variables.
The conversation node prefers the managed provider
getConversationLLM() builds a service-account-backed client for the managed conversation provider when all three of LLM_GOOGLE_PROJECT_ID, LLM_GOOGLE_CLIENT_EMAIL and LLM_GOOGLE_PRIVATE_KEY are set, constructing the credential object inline. Otherwise it falls back to the extraction provider.
The fallback is quieter than it looks
If any one of the three service-account env vars is missing, the node silently falls back to the extraction model instead of the conversation tier — a cheaper, terser model. A partially-configured environment therefore degrades answer quality with no error. Check all three together.
Compilation and checkpointing
src/agent/index.ts → getAgent() compiles the graph with a Redis checkpointer (src/utils/redisCheckPointer.ts, ioredis), keyed per session. That is what gives multi-turn context — a follow-up answer resumes the prior state rather than reclassifying from scratch.
HTTP surface
bz-backend calls exactly one endpoint:
POST {AGENT_URL}/chat/:sessionId
body: { input, chain, supported_chains, activeDappProjectId? }
→ { intent, data, text }Routes are in src/routes/{index,agent,store}.ts, controllers in src/controllers/, input validated with Joi (src/validations/). Auth middleware at src/middleware/auth.ts.
supported_chains is passed in so the agent can validate a chain name against what the platform actually supports, rather than accepting any string.
Persistence
| Store | Purpose |
|---|---|
MongoDB (@langchain/mongodb) | agent.model.ts — agent-side records |
PostgreSQL + PGVector (pg) | Knowledge base for RAG |
Redis (ioredis) | LangGraph checkpoints |
Chat history is not stored here — that is bz-backend's blazSession / blazMessage. The agent's Redis checkpoints are per-session graph state, not a transcript.
Templates
src/template/ — one file per intent, 23 files:
entry.ts qoute.ts (swap) transaction.ts
portfolio.ts nft.ts priceAlert.ts
switchChain.ts limitOrder.ts bzToken.ts
createDapp.ts modifyDapp.ts goOnchain.ts
contractAudit.ts tokenSafety.ts walletCard.ts
revokeApprovals.ts autopilot.ts workflow.ts
batch.ts marketIntel.ts conversation.ts
store.tsLiteral braces must be doubled in templates
LangChain treats {name} as a prompt variable. A template containing literal JSON needs , or the prompt fails to render with a missing-variable error.
Why the agent is execution-free
A deliberate blast-radius decision. A prompt injection can at worst produce a wrong intent object — which then fails token resolution in the backend, or produces a card the user declines.
Concretely: the agent has no RPC provider, no private key, no relayer ABI, and no HTTP client pointed at bz-backend. It is called and calls nothing back.
Operations
| Task | Command |
|---|---|
| Dev | npm run dev (tsx watch) |
| Build | npm run build (tsc → build/) |
| Start | npm start |
| Type check | npm run check-types |
| Deploy | ./scripts/deploy.gcp.sh deploy bz-agent — BUILD=yes, compiles on the VM |
| Restart script | defi-set/restart_agent.sh |
Logging is Winston (src/utils/logger.ts).
Adding an intent
Steps 1–4 of the nine-step checklist happen in this repo: add an AI intent.
Known limits
- Classification quality is prompt-quality. No evaluation harness exists; the only signal is the non-fallback rate.
- The non-fallback rate does not catch mis-classification. A message routed to the wrong intent counts as a success.
entryNodelogsstate.intentto stdout on every request — useful, and noisy in production logs.- The conversation-provider fallback silently downgrades the model. See the warning above.
LIMIT_ORDERhas a node and a template but no backend handler, so it extracts parameters that go nowhere.- A Redis flush loses multi-turn context for in-flight conversations.