Building a Dialogflow chatbot platform before LLMs
In 2018 I built a Dialogflow chatbot platform with webhook fulfillment. The vocabulary of agents has changed. The architecture mostly has not.
From 2018 · 3 min read · Published September 18, 2026
Everyone is building agents now. I built my first real agent backend, a Dialogflow chatbot platform, in December 2018, and rereading it this year was a strange experience. The words are all different. The shape is almost identical.
The system
The project was a fulfillment API for Dialogflow agents with an admin portal on top. Hapi on the backend, TypeScript throughout, deployed with the Serverless Framework. It supported multiple bots, each belonging to an organization, and users were attached to a bot for their session.
Two entry points did most of the work.
A passthrough endpoint took a user's text, looked up which bot they were talking to, and forwarded the query to that bot's Dialogflow agent. It sat behind JWT auth, and each bot carried its own Dialogflow project configuration, so one API served many independent agents.
A fulfillment webhook received Dialogflow's callbacks when an intent matched and ran our code to produce the answer.
Dialogflow intents were tools
Here's the intent routing for the Google Assistant integration, lightly trimmed:
export const googleAssistantHandler = async (agent: WebhookClient) => {
const intentMap = new Map()
intentMap.set('Signup', SignUp)
intentMap.set('Default Fallback Intent', fallback)
intentMap.set('Invocation - Explicit', SignIn)
intentMap.set('Invocation - Explicit - yes', getLocationPermission)
return agent.handleRequest(intentMap)
}Squint and that's a tool registry. A name, a handler, and a model (Dialogflow's NLU, in this case) deciding which one to call. The difference is where the intelligence lived. In 2018, a human wrote training phrases for every intent, and the model only classified. Today the model reads a tool description and decides for itself.
Some handlers even did what we'd now call asking for permission before acting:
export const getLocationPermission = (agent: WebhookClient) => {
const conv = agent.conv()
const location = get(conv, 'request.device.location.coordinates', '')
if (location === '') {
conv.ask(
new Permission({
context: 'To find spots near you',
permissions: 'DEVICE_PRECISE_LOCATION',
}),
)
agent.add(conv)
} else {
conv.ask('here are the spots')
agent.add(conv)
}
}Adapting frameworks to runtimes
One small trick from that codebase I still use. Google's dialogflow-fulfillment library expected an Express-style request and response. We were on Hapi, running in Lambda. Rather than fight it, we handed it a fake response object that just captured what it wanted to send:
const response = { body: '', statusCode: 200 }
const agent = new WebhookClient({
request: { body: request.payload },
response: {
json: (payload: any) => { response.body = payload },
status: (code: number) => { response.statusCode = code },
},
})The library thinks it's talking to Express. We get a plain object to return however our runtime wants. Any time a library assumes a framework, a tiny adapter like this beats rewriting either side.
What changed, and what didn't
What changed: we no longer write training phrases, slot schemas, or fallback trees by hand. Models generalize from a description. That removed most of the tedious work.
What didn't change:
- Routing is still the core problem. Choosing the right handler for an ambiguous request was the hard part in 2018 and it's the hard part now.
- Fallbacks still matter. Our 2018 fallback said "I didn't understand, can you try again?" Modern agents need the same honesty when they're out of their depth.
- Multi-tenancy is still the real product. One API, many bots, each with its own configuration and users. That's what turned a demo into a platform.
Related: An MCP gateway with constant context cost: 116 tools behind 7 · AI agent guardrails: read-only by construction