Skip to content

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_AUDIT versus TOKEN_SAFETY, and GO_ONCHAIN versus DEPLOY_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

ConventionWhy
Optional chaining on every fieldMessages persisted before your field existed will not have it, forever. There is no migration
No fetching on mountEverything needed comes in data. This keeps history replay free of network calls when a user scrolls up
Execute inline, manage by deep linkCreating 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 passes

Common mistakes

MistakeSymptom
No data field in the Mongoose schemaCard renders live, then is empty after reload
No case in agent.service.jsClassifies correctly, then returns generic text — exactly what LIMIT_ORDER does today
No EXIT edgeThe request hangs
Description too vagueA neighbouring intent wins
Description with no negativesYour intent steals its neighbours' traffic
Trusted the agent's extracted addressesA hallucinated value reaches a transaction builder
No optional chaining in the cardOld history crashes the render
Literal { in a templateLangChain missing-variable error

Reference implementations

IntentGood example of
TOKEN_SAFETYA clean read-only intent with caching
AUTOPILOTHallucination containment for a money-moving intent
SWAPRe-resolution plus a multi-step execution card
REVOKE_APPROVALSServer-side scan, client-side transactions
MODIFY_DAPPA session precondition (activeDappProjectId)
WORKFLOW / BATCHComposite intents

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