Appearance
Chat request path
What happens between a user pressing Enter and a signable card appearing.
The five hops
Request
POST /api/v1/blaz/message/stream (SSE)
POST /api/v1/blaz/message (JSON, non-streaming)
{ message, wallet, chatId, chainId, currency }Routes: bz-backend/v1/routes/balz.route.js (the filename typo is in the source), mounted by v1/routes/index.js as router.use('/blaz', blazRoutes).
Other endpoints on the same router:
| Endpoint | Purpose |
|---|---|
GET /v1/blaz/chat/:chatId | One conversation's messages |
GET /v1/blaz/sessions | The wallet's chat list |
GET /v1/blaz/suggestions | Suggestion chips |
GET /v1/blaz/home-chips | Landing chips |
Identity is the wallet parameter — there is no session cookie or bearer token
Which is why the same wallet on a new origin sees the same history, and why the AI domain split required almost no backend change. It also means the wallet parameter is trusted for read scoping: anyone who knows an address can read its chat list. That is a real privacy consideration, mitigated only by addresses being pseudonymous.
Hop 1 — controller
v1/controllers/blaz.controller.js:
- Find or create a
blazSessionfor the wallet. Model:{ wallet, name, pending, activeDappProjectId }. - Persist the user's
blazMessage. - Call
getAgentResponse(...). - Persist the bot's
blazMessage. - Emit SSE.
Hop 2 — agent call
v1/services/agent.service.js → getAgentResponse() calls bz-agent:
POST {AGENT_URL}/chat/:sessionId
{ input, chain, supported_chains, activeDappProjectId? }
→ { intent, data, text }supported_chains lets the agent validate a chain name against reality. activeDappProjectId is what makes MODIFY_DAPP, SHOW_DAPP, GO_ONCHAIN and DEPLOY_CONTRACTS selectable at all.
Hop 3 — dispatch
The same function then switches on intent and calls a handler. Every handler returns the same triple:
js
{ text, data, action } // action ∈ BLAZ_ACTIONS| Intent group | Handler |
|---|---|
SWAP, BRIDGE, SEND_TOKEN, BUY, DCA | v1/services/defi.service.js — swapTokens, sendToken, buyToken, getDCAData |
PORTFOLIO, CHECK_BALANCE | services/portfolio.js (Zerion) |
TX_INFO, TX_HISTORY, NFT_INFO | services/ai-intents/ |
MARKET_INTEL | services/marketIntel.service.js |
BUY_BZ_TOKEN | v1/services/bzToken.service.js |
CONTRACT_AUDIT | v1/services/contractAudit.service.js |
TOKEN_SAFETY | v1/services/tokenSafety.service.js → scanToken |
WALLET_CARD | v1/services/walletCard.service.js → generateWalletCard |
REVOKE_APPROVALS | v1/services/approvals.service.js → scanApprovals |
AUTOPILOT | v1/services/autopilot.service.js → handleAutopilot (+ autopilotPolicy.service.js) |
CREATE_DAPP, MODIFY_DAPP, SHOW_DAPP | v1/services/dappBuilder.service.js |
GO_ONCHAIN, DEPLOY_CONTRACTS | v1/services/aiDeveloper.service.js |
CONVERSATION, FALLBACK | Pass the agent's text through |
This is where hallucination is contained
The handler does not trust the agent's extracted values. swapTokens re-resolves token symbols against the Token collection and chain names against Network, then fetches real quotes. A hallucinated token resolves to nothing and the user gets a question, not a transaction.
For Autopilot the containment is explicit: the LLM emits symbols and numbers only, the backend resolves addresses, and validatePolicy runs before anything can be signed.
Hop 4 — persistence
v1/models/blazMessage.js:
js
{
session: String,
senderType: 'user' | 'bot',
text: String,
action: enum(Object.values(BLAZ_ACTIONS)), // default TEXT
data: {
quotes: [Object], tradeInfo: Object, txn: Object,
portfolio: Object, txInfo: Object, txHistory: Object,
nftInfo: Object, dapp: Object, contract: Object,
audit: Object, tokenSafety: Object, walletCard: Object,
revokeApprovals: Object, autopilot: Object, options: Object,
…
}
}data is a bag of named optional fields, one per action shape. Adding an intent means adding a field here and to IChatMessage['data'] in defi-dex/src/types/ai-chat.type.ts.
Why a named bag rather than a discriminated union
Mongoose does not model discriminated unions on a subdocument well, and the frontend reads exactly one field per action anyway (message.data?.quotes for quotes, message.data?.autopilot for autopilot). The cost is that the schema grows a field per intent and nothing enforces that the right one is populated.
Hop 5 — SSE
Three event types:
| Event | Payload | Purpose |
|---|---|---|
meta | { action, data } | Arrives first, so the UI can render the card skeleton before any prose |
token | A text chunk | Streamed prose |
done | — | Stream complete |
Client: defi-dex/src/apis/ai.api.ts → sendAIMessageStream({ onToken, onMeta, onDone }).
Emitting meta before token is what makes the interaction feel fast — a quote card can appear while the accompanying sentence is still streaming.
Axios base URL
defi-dex/src/utils/axios.ts:
ts
baseURL = VITE_API_URL
|| (isLocalhost ? 'http://localhost:5000/api' : 'https://api.blazpay.com/api')Rendering
BotMessage.tsx switches on message.action. Full mapping: action renderer.
Chat surfaces
| Route | Site | Component |
|---|---|---|
/ | AI site | component/defi/swapAI/index.tsx |
/c/:id | AI site | Same |
/defi/blazai, /defi/blazai/:id | Defi site (legacy) | Same |
Use the route constants, never hardcode
CHAT_ROOT, chatPath(id) and isChatPath(pathname) live in src/config/site.ts. The chat used to hardcode /defi/blazai in handleActiveChat, newChat and SidebarBlazAi's active check — on the AI site those hit the catch-all and bounced users to defi.blazpay.com.
Disconnect handling
ts
// swapAI/index.tsx
queryClient.removeQueries({ queryKey: ['address'] })
// + reset messages, chatId, input, count
// + navigate(CHAT_ROOT)
// gated on a prevAddressRef connected → disconnected transitionNecessary because enabled: !!address only stops the chat-list query — React Query keeps serving the last data, so the previous wallet's history stayed visible after disconnect.
Effect declaration order matters
That effect must be declared after const queryClient. The dependency array evaluates at render time, so an earlier declaration throws a temporal dead-zone error.
Also guard localStorage.setItem(address!, …) — it was writing a literal "undefined" key for every guest.
The ?q= composer seed
The ⌘K palette navigates with ?q=<prompt> rather than routing to a page.
Key the effect on location.search, not on mount
The palette navigates while the chat is already mounted, so a mount-only effect never fires — leaving ?q= in the URL and an empty composer.
Strip the param with react-router navigate(path, { replace: true }), not history.replaceState. replaceState leaves the router's location stale, so selecting the same action twice registers as the same location and silently does nothing.
Legacy path
/api/defi/swap-ai/* (controllers/ai/chats.js, models/AIChat.js, AIMessages.js) is the previous chat implementation. There is also POST /defi/wit (wit.ai NLU) and a Rasa webhook. None of these is the current path; do not extend them.
Failure modes
| Symptom | Cause |
|---|---|
| Reply is generic text when an action was expected | The intent had no handler (e.g. LIMIT_ORDER), or extraction returned follow_up |
| Empty sidebar for a returning user | No wallet connected — history is wallet-keyed |
| Previous wallet's history visible after disconnect | The clear effect did not run |
?q= in the URL with an empty composer | The effect was keyed on mount instead of location.search |
User bounced to defi.blazpay.com from the AI site | A hardcoded /defi/blazai path |
| Conversation answers feel worse than expected | Partial conversation-provider config silently downgraded the model |