Appearance
Add an AI intent
Nine files across three repos, in order. Do them in this order — each step depends on the previous one.
Architecture context: agent graph, chat request path, action renderer.
Overview
Step 1 — declare the intent
bz-agent/src/utils/constant.ts
ts
export const AVAILABLE_INTENTS = {
…
MY_INTENT: {
name: 'MY_INTENT',
description:
'If the user wants to … (e.g. "do the thing", "the other phrasing"). ' +
'Do NOT use this for … — that is OTHER_INTENT.',
},
}The description is the classifier
There is no fine-tuned model and no embedding classifier. getInitSystemTemplate(intents) injects every name → description pair into the entry prompt and the LLM picks one. So:
- Include concrete example phrasings. Users do not phrase things the way you would.
- Include explicit negatives for neighbouring intents.
CONTRACT_AUDITversusTOKEN_SAFETY, andGO_ONCHAINversusDEPLOY_CONTRACTS, both needed them. - Every word competes for the model's attention against 26 other descriptions. Be specific, not long.
Step 2 — write the extraction prompt
bz-agent/src/template/myIntent.ts
ts
export const MY_INTENT_SYSTEM_TEMPLATE = `
You extract parameters for a … request.
Return:
- fieldA: …
- fieldB: …
If a required field is missing, ask for it.
`Double literal braces
LangChain treats {name} as a prompt variable. Any literal JSON or code sample inside a template needs , or rendering fails with a missing-variable error.
Step 3 — write the node
bz-agent/src/agent/node.ts
ts
export const myIntentNode = (options) => async (state) => {
const schema = z.object({
fieldA: z.string().nullable(),
fieldB: z.number().nullable(),
text: z.string(),
})
const llm = getLLM('extraction').withStructuredOutput(schema)
const result = await llm.invoke([
new SystemMessage(options.template),
...state.messages,
])
if (result.fieldA && result.fieldB) {
state.intent = AVAILABLE_INTENTS.MY_INTENT.name
state.text = 'no-text'
state.data = { fieldA: result.fieldA, fieldB: result.fieldB }
} else {
state.intent = 'follow_up'
state.text = result.text // the clarifying question
}
return state
}The follow_up convention is load-bearing
'no-text' tells bz-backend that the handler produces the reply. 'follow_up' makes the agent's own question the reply. This is why a partially-specified request asks rather than guessing — "swap some ETH" asks how much, it does not pick a number.
Use tier extraction (fast, temperature: 0) unless the task genuinely needs reasoning, in which case complex.
Step 4 — wire the graph
bz-agent/src/agent/graph.ts — three edits:
ts
// 1. add the node
.addNode('myIntentNode', myIntentNode({ template: MY_INTENT_SYSTEM_TEMPLATE }))
// 2. add the exit edge
.addEdge('myIntentNode', 'EXIT')
// 3. add the routing case, inside entryNode's conditional edges
case AVAILABLE_INTENTS.MY_INTENT.name:
return 'myIntentNode'Forgetting the exit edge hangs the graph
A node with no outgoing edge never reaches EXIT, so the request never returns.
If the intent needs no parameters (like CONVERSATION or SHOW_DAPP), you can route straight to 'EXIT' and skip steps 2 and 3 entirely.
Verify: npm run check-types in bz-agent.
Step 5 — declare the action and the data field
bz-backend/v1/models/blazMessage.js
js
export const BLAZ_ACTIONS = {
…
MY_INTENT: 'my_intent',
}
const schema = new Schema({
…
data: {
…
myField: Object, // ← required
},
})Missing the data field is the classic silent failure
Mongoose drops unknown fields on write with no error. The handler returns the payload, the SSE meta event carries it, the card renders — and then a page reload shows an empty card, because nothing was persisted. Nothing logs anything.
Step 6 — dispatch
bz-backend/v1/services/agent.service.js
js
switch (intent) {
…
case 'MY_INTENT':
return await handleMyIntent({ data, wallet, chainId, currency })
}Step 7 — write the handler
bz-backend/v1/services/myFeature.service.js
js
import { BLAZ_ACTIONS } from '../models/blazMessage.js'
import Token from '../../models/TokenModal.js'
import Network from '../../models/networkModal.js'
export async function handleMyIntent({ data, wallet, chainId }) {
// RE-RESOLVE everything the agent extracted
const network = await Network.findOne({ id: chainId })
if (!network) {
return { text: 'I could not find that network.', data: {}, action: BLAZ_ACTIONS.TEXT }
}
const token = await Token.findOne({ chainId, symbol: data.fieldA?.toUpperCase() })
if (!token) {
return { text: `I could not find ${data.fieldA} on ${network.name}.`,
data: {}, action: BLAZ_ACTIONS.TEXT }
}
const result = await doTheWork(token, data.fieldB)
return {
text: 'Here is what I found.',
data: { myField: result },
action: BLAZ_ACTIONS.MY_INTENT,
}
}This is where hallucination is contained
Never trust the agent's extracted values. Re-resolve token symbols against the Token collection and chain names against Network. A hallucinated token resolves to nothing and the user gets a question instead of a transaction.
For anything that spends money, follow the Autopilot pattern: let the LLM emit symbols and numbers only, resolve addresses server-side, then validate. An LLM must not be able to produce a signable payload.
Return the triple { text, data, action } — always all three, always with data keyed by the field name from step 5.
Step 8 — declare the frontend types
defi-dex/src/types/ai-chat.type.ts
ts
export type IAIAction =
| 'quotes'
| …
| 'my_intent'
export interface IChatMessage {
data?: {
…
myField?: IMyPayload
}
}Step 9 — render it
defi-dex/src/component/defi/swapAI/BotMessage.tsx
tsx
if (action === 'my_intent') {
return <MyIntentCard data={message.data?.myField} />
}Then create the component — swapAI/actions/MyIntentCard.tsx for a simple card, or its own subfolder if it owns a multi-step flow.
Card conventions
| Convention | Why |
|---|---|
| Optional chaining on every field | Messages persisted before your field existed will not have it, forever. There is no migration |
| No fetching on mount | Everything needed comes in data. This keeps history replay free of network calls when a user scrolls up |
| Execute inline, manage by deep link | Creating something belongs in chat (that is the moment of intent). Managing it belongs on a page, because a table is the right shape |
Exceptions to the no-fetch rule exist where on-chain state genuinely changes after the message was written — AutopilotCard and the CSIP views poll. Those are exceptions with a reason.
Verify: npm run build:app in defi-dex.
Verification checklist
□ bz-agent: npm run check-types passes
□ The intent classifies — try 5 different phrasings
□ A neighbouring intent does NOT steal it — try its phrasings too
□ Missing a required field produces a question, not a guess
□ The handler re-resolves tokens and chains against the database
□ A hallucinated token name produces a graceful message
□ The data field exists in the Mongoose schema (reload the page and check)
□ The card renders
□ Reloading the chat re-renders the card from persisted data
□ defi-dex: npm run build:app passesCommon mistakes
| Mistake | Symptom |
|---|---|
No data field in the Mongoose schema | Card renders live, then is empty after reload |
No case in agent.service.js | Classifies correctly, then returns generic text — exactly what LIMIT_ORDER does today |
No EXIT edge | The request hangs |
| Description too vague | A neighbouring intent wins |
| Description with no negatives | Your intent steals its neighbours' traffic |
| Trusted the agent's extracted addresses | A hallucinated value reaches a transaction builder |
| No optional chaining in the card | Old history crashes the render |
Literal { in a template | LangChain missing-variable error |
Reference implementations
| Intent | Good example of |
|---|---|
TOKEN_SAFETY | A clean read-only intent with caching |
AUTOPILOT | Hallucination containment for a money-moving intent |
SWAP | Re-resolution plus a multi-step execution card |
REVOKE_APPROVALS | Server-side scan, client-side transactions |
MODIFY_DAPP | A session precondition (activeDappProjectId) |
WORKFLOW / BATCH | Composite intents |