Skip to content
Murali Krishnan A
All writing

22 Sept 2026

Tarsha, and what counted as an AI agent before LLMs existed

In 2019 I built a chatbot for my college's placement cell on Dialogflow, before anything like ChatGPT existed. Revisiting the code, the thing that stands out is how cleanly it split the job: a trained model recognized what you wanted, and code I wrote by hand decided what to do about it.

9 min read · dialogflow, nlp, ai

In my final year at NSS College of Engineering I built a chatbot for the placement cell, with two classmates, Mohamed Hazil and Anjana Shaji, under a team name we called RedMoon. We called her Tarsha. Every welcome message she ever sent said so, and so does the README. Going back into the repository to write this, I found a second name sitting right next to it that I'd completely forgotten.

A cartoon avatar of a woman in a dark blazer, used as the chatbot's profile picture in the chat widget
The actual avatar, still in the repository. The file is called layaAvatar.png.
const senders = {
  laya: "laya",
  user: "guest"
};

Two separate files, both independently landed on "laya." That's not a typo you make once, so I went looking in the actual git history instead of guessing. The commit that adds that sender object is titled, in full, "Created Tarsha." Same commit, same diff, laya: "laya" sitting three lines below the message that names her. The avatar file arrived the day after, also named after Laya. Whatever conversation settled on "Tarsha" happened before that first commit and never made it into the code two of us were writing in parallel - which means this wasn't drift over months, it was inconsistent from the very first commit onward. I'd rather leave that artifact in than tidy it into a cleaner story, the same reason I left the + 0.0001 in an old post about a different project.

This was 2019. GPT-2 existed as a research curiosity nobody outside a lab was building products on top of. If you wanted a piece of software that took a plain English sentence and did something useful with it, you built it out of parts, and one of the most common parts was Google's Dialogflow.

Before it was Dialogflow

The tool itself had already been renamed once by the time we touched it. It started life as api.ai, built by a small team best known for a voice assistant app called Speaktoit, and Google bought it in 2016 specifically for the natural-language engine underneath. By late 2017 it had been rebranded Dialogflow, folded into Google Cloud, and given the console we actually used - but the old name was still everywhere. Half the Stack Overflow answers I read in 2019 said api.ai, the npm package we imported is literally called dialogflow, and I distinctly remember explaining to a professor that "the thing that used to be api.ai" is what we'd built the bot on, because that was still the name more people recognized.

We picked it over the alternatives - Microsoft's LUIS, IBM Watson Assistant, the earlier version of what's now Amazon Lex - for reasons that had nothing to do with a benchmark. It had the most generous free tier for a project with a zero-rupee budget. The console let you build and test an intent entirely by typing example sentences, with no separate training step to run or model to deploy - you typed, it retrained itself in the background, and the test panel on the right just worked. And because Dialogflow was also how you built Actions for Google Assistant, its docs and its Stack Overflow answers were unusually deep for a student project to be leaning on. It wasn't the most powerful tool available. It was the one where a team of three could go from nothing to a working intent in an afternoon, and in our final year that was the entire decision.

This is a post about what that system actually was, using the real code, because I think the shape of it explains something about the current one that's easy to miss.

What an intent even is

If none of this is familiar yet: an "intent" is just a name for a thing a user might want. Not the exact sentence - the goal behind it. "How many students got placed this year", "how many students got placed", "placement count for this year" are three different sentences and one intent: the user wants a number.

Think of a waiter taking an order. "I'll have the salmon." "Could I get the salmon, please." "Salmon for me." Three sentences, one order. The waiter doesn't have three separate reactions memorized - they've learned to recognize the order underneath the sentence. An intent classifier is trying to do the same job: given a sentence, name the goal.

An "entity" is the specific detail layered on top. "Salmon, no onions" is still the salmon intent, with an entity attached: no onions. In Tarsha's case, "how many students were placed in the 2018 batch" is the same intent as a plain student-count question, plus a year entity.

Building the intent classifier without writing a classifier

Dialogflow's whole pitch was that you never wrote the classifier yourself. You wrote examples.

For each intent, you'd type in a handful of training phrases - "how many students placed", "total placements this year", "placement count" - and Dialogflow trained its own model on them, entirely on Google's infrastructure. You never saw weights, never touched a training loop. You gave it labeled data, the same way VADER's lexicon is labeled data, just collected through a form instead of a corpus.

Dialogflow console showing two training phrases for a GetStudentCount intent, with 'this year' automatically highlighted as a date-period entity
A recreation in a throwaway agent, same console we actually used. Type 'this year' into a training phrase and Dialogflow annotates it as a date-period entity on its own - no one taught it that, it shipped knowing what a year phrase looks like.

At runtime, your server made one call:

const dialogflow = require("dialogflow");
const uuid = require("uuid");

async function getResponse(message, projectId = "avanthika-dumlvv") {
  const sessionId = uuid.v4();

  const sessionClient = new dialogflow.SessionsClient();
  const sessionPath = sessionClient.sessionPath(projectId, sessionId);

  const request = {
    session: sessionPath,
    queryInput: {
      text: { text: message, languageCode: "en-US" }
    }
  };

  const responses = await sessionClient.detectIntent(request);
  const result = responses[0].queryResult;

  return {
    reply: result.fulfillmentText || "I didn't get it...",
    params: result.parameters
  };
}

That's the entire ML surface of the whole project. One function. You send raw text in, you get an intent name, a bag of extracted parameters, and a fulfillment string back. Everything else - everything - was ordinary code I wrote by hand.

The part Dialogflow couldn't do for us

Dialogflow's fulfillment text is meant to be the actual reply. Type it into their console and it's just a string: "You have 42 students placed this year." Fine, if the answer never changes. Ours changed every day, because it depended on a live database Dialogflow had never heard of.

So we cheated. Instead of a real answer, the fulfillment text held a placeholder:

Congratulations, you currently have <<STUDENTS,COUNT,PLACED_STUDENTS,PRESENT>>!
Dialogflow console's Text Response field for an intent, containing the placeholder string with STUDENTS, COUNT, PLACED_STUDENTS and PRESENT
Exactly where that string lived - Dialogflow's own fulfillment field, which has no idea it's holding four values a Node process is about to tear apart.

Four comma-separated values, in a fixed order: which entity, what to return about it, what to filter by, which academic year. And on our side, a small regex pulled every placeholder out of whatever Dialogflow sent back:

const stringToProgramReadable = string => {
  const extractor = new RegExp(`<<[^>>]*>>`, "g");
  const placeHolderArray = string.match(extractor) || [];
  return { hasPlaceholders: placeHolderArray.length > 0, placeHolderArray, string };
};

Each extracted placeholder then had to pass a validity check against a fixed, closed vocabulary before anything touched the database:

const structure = {
  entity: ["ACADEMIC_YEAR", "COMPANIES", "STUDENTS", "EVENTS", "PLACEMENTS"],
  require: ["COUNT", "NAMES", "BEGIN_AND_END_YEARS"],
  filter: ["ALL", "PLACED_STUDENTS", "FUTURE", "TODAY", "SELECTED_STUDENTS_BY_PLACEMENT_UID"],
  academicYear: ["PRESENT", "PLACEHOLDER"]
};

const isValid = (el, ar) => ar.includes(el);

And once validated, it hit a hand-written dispatcher, one switch per entity, each with its own nested switch for require and filter:

static async fetchData(fetcher, params, context) {
  switch (fetcher.entity) {
    case "ACADEMIC_YEAR": return this.fetchAcademicYear(fetcher, params, context);
    case "COMPANIES":     return this.fetchCompanies(fetcher, params, context);
    case "STUDENTS":      return this.fetchStudents(fetcher, params, context);
    case "EVENTS":        return this.fetchEvents(fetcher, params, context);
    case "PLACEMENTS":    return this.fetchPlacements(fetcher, params, context);
    default: return { valid: false, data: null };
  }
}

fetchStudents alone has its own switch on filter - ALL, PLACED_STUDENTS, SELECTED_STUDENTS_BY_PLACEMENT_UID - each one a different Mongo query shape, written out by hand. It's if-else ladders, nested inside if-else ladders, the entire way down. Dialogflow's model never touched the database. All it ever did was recognize which of five entities, three requirements and five filters the sentence was asking about. Everything after that decision was code I could read, test and be certain about.

Why the split mattered

Look at where the model's job ends in that diagram. It recognizes intent and pulls out entities - a narrow, closed classification problem, the same shape as VADER scoring a sentence or the calibrated ladder from the last post scoring one. Everything after that is deterministic code I wrote and could read back line by line. The model never decided what to query. It never wrote a database call. It picked one of five entities from a list, and handed off.

That's the thing I think is genuinely easy to lose sight of now. A modern LLM agent collapses both halves into the same model. You hand it a prompt and a set of tools, and the same next-token predictor that understood what you meant is also the thing deciding which tool to call and with what arguments, live, as text it's generating. That's enormously more general - Tarsha could only ever do the five things we'd enumerated for her, in the exact shapes we'd anticipated, and adding a sixth meant a deploy, not a conversation. But the generality comes from putting the "what do I do now" decision inside the same free-text generation process that can also just be wrong, in the open-ended way generation is wrong, rather than in a switch statement that can only ever go through cases you wrote yourself.

Reading TypeSafe's announcement of Jev - a model explicitly trained to output typed, calibrated decisions over a closed set of choices instead of prose - felt like reading a more capable, properly-trained version of exactly this. I wrote more about that shape here. Recognize intent with a trained model, but keep the acting part as something typed and enumerable, closer to a return value than to freely generated text. We split those two jobs by hand in 2019 because there was no alternative - Dialogflow could not have written our Mongo queries for us if we'd wanted it to. It's a strange thing to watch the industry spend a few years building agents that merge those jobs back together, run into exactly the failure mode you'd expect from that merge, and start reaching for ways to pull them back apart.

What I'd keep, and what I wouldn't

I wouldn't go back to hand-training an intent classifier through a web form, one training phrase at a time, or to a fixed academic-year enum that meant redeploying the whole server to add a new one. That part was genuinely tedious, and it's the part LLMs have made legitimately better - you can prototype the same chatbot today in an afternoon.

But I'd keep the boundary. The part of Tarsha that talked to the database never had room to invent a query that wasn't one of the ones I'd written. It couldn't hallucinate a sixth entity, because five was the entire list it was physically able to return. That wasn't a safety feature bolted on afterward. It was just what the architecture was - a narrow model deciding what, and code I trusted deciding how - and it's the same reason the <<STUDENTS,COUNT,PLACED_STUDENTS,PRESENT>> placeholder scheme still reads, seven years later, as more obviously correct than it has any right to for something two classmates and I put together in our final year.