Skip to content

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:

EndpointPurpose
GET /v1/blaz/chat/:chatIdOne conversation's messages
GET /v1/blaz/sessionsThe wallet's chat list
GET /v1/blaz/suggestionsSuggestion chips
GET /v1/blaz/home-chipsLanding 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:

  1. Find or create a blazSession for the wallet. Model: { wallet, name, pending, activeDappProjectId }.
  2. Persist the user's blazMessage.
  3. Call getAgentResponse(...).
  4. Persist the bot's blazMessage.
  5. 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 groupHandler
SWAP, BRIDGE, SEND_TOKEN, BUY, DCAv1/services/defi.service.jsswapTokens, sendToken, buyToken, getDCAData
PORTFOLIO, CHECK_BALANCEservices/portfolio.js (Zerion)
TX_INFO, TX_HISTORY, NFT_INFOservices/ai-intents/
MARKET_INTELservices/marketIntel.service.js
BUY_BZ_TOKENv1/services/bzToken.service.js
CONTRACT_AUDITv1/services/contractAudit.service.js
TOKEN_SAFETYv1/services/tokenSafety.service.js → scanToken
WALLET_CARDv1/services/walletCard.service.js → generateWalletCard
REVOKE_APPROVALSv1/services/approvals.service.js → scanApprovals
AUTOPILOTv1/services/autopilot.service.js → handleAutopilot (+ autopilotPolicy.service.js)
CREATE_DAPP, MODIFY_DAPP, SHOW_DAPPv1/services/dappBuilder.service.js
GO_ONCHAIN, DEPLOY_CONTRACTSv1/services/aiDeveloper.service.js
CONVERSATION, FALLBACKPass 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:

EventPayloadPurpose
meta{ action, data }Arrives first, so the UI can render the card skeleton before any prose
tokenA text chunkStreamed prose
doneStream 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

RouteSiteComponent
/AI sitecomponent/defi/swapAI/index.tsx
/c/:idAI siteSame
/defi/blazai, /defi/blazai/:idDefi 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 transition

Necessary 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

SymptomCause
Reply is generic text when an action was expectedThe intent had no handler (e.g. LIMIT_ORDER), or extraction returned follow_up
Empty sidebar for a returning userNo wallet connected — history is wallet-keyed
Previous wallet's history visible after disconnectThe clear effect did not run
?q= in the URL with an empty composerThe effect was keyed on mount instead of location.search
User bounced to defi.blazpay.com from the AI siteA hardcoded /defi/blazai path
Conversation answers feel worse than expectedPartial conversation-provider config silently downgraded the model

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.