In 2017 I wrapped a 1966 chatbot in a Promise and called it AI

· 10 min read

CONTENTS
  1. The problem
  2. What I tried
  3. A Promise around a 1966 program
  4. A part-of-speech tagger and eleven rules
  5. The loop around it
  6. Running it again next to a model
  7. What happened
  8. Where it landed

Google Home shipped in November 2016 and I wanted to know what was inside it. Not by buying one: by building one on a Raspberry Pi on the kitchen table and finding out which parts were hard. The assistant was called Edwin, and two npm modules carved out of it survive: a 1966 chatbot with a Promise around it, and eleven rules that turn "turn up the music" into {intent: "turn", context: "music"}.

Reading Edwin again in 2026, the loop is the one I run an agent inside of every day. The intent files are tool definitions, a step that asks for a missing field is the model asking for a parameter, and failReply is the model declining. I built all of that by hand and put a regex where the model goes, because there was nothing else to put there. So I had an agent run the whole thing again: the 2018 test suite under Node 24, sentences I had never tested, and a local model scored on the same sentences, on the same kind of machine, with nothing leaving the house. The rules still pass every test I wrote for them. The model gets 31 of 34, and it is the one that works on the sentences neither had seen.

The problem

I wanted the whole loop on hardware I owned. Talk to a microphone, have something happen (Sonos, lights, a text message), hear an answer, and be able to open any piece of it and change it. Edwin ran from January 2017 to October 2018 and got 235 commits. The README has instructions for sox on a Pi, the ALSA line to route audio to the headphone jack, and a Google Speech key, because speech-to-text was the one part I could not run locally and I wanted the rest to be mine.

Each stage I learned by building the smallest version that worked and publishing it. The mic and the speaker went first. Then I needed something to sit in the middle and answer, so I could hear the loop close before I had written a parser. That is where Eliza came in.

What I thought was hard, in 2017, was turning a sentence into an action and a thing. Everything after that, deciding what to ask when a word was missing, mapping "song" and "track" and "volume" onto the same handler, saying no when nothing matched, I treated as plumbing. It took a couple of afternoons and a folder of JSON.

That was backwards, and it took a model showing up to see it.

What I tried

A Promise around a 1966 program

eliza-as-promised is four commits on one day, 19 August 2017. The code is Norbert Landsteiner's 2005 JavaScript port of Weizenbaum's ELIZA, unchanged apart from eleven lines I added at the bottom:

ElizaBot.prototype.getResponse = function(statement) {
  var that = this;
  return new Promise(function(resolve, reject) {
    let elizaReply = that.transform(statement);
    if (that.quit) {
      resolve({final: that.getFinal()});
    } else {
      resolve({reply: elizaReply});
    }
  });
};

The reason was the .then() chain. The next day I published speak-with-eliza, whose whole README is one line: mic → Google Speech → eliza-as-promised → say → your ears. I wanted the "brain" to be an await in the middle of a pipeline, and the package.json still says what I thought that brain was: "Eliza psychotherapist in Node.js as a promised based AI", keyword AI.

What is inside is 116 keyword entries, each with decomposition patterns and reassembly templates, a table that flips pronouns (my → your, me → you) so your sentence can be handed back to you as a question, and a memory of the last 20 things you said that it pulls from when nothing else matches. Run with the random choice disabled, the same way Weizenbaum's 1966 paper reproduces its transcript, it does this:

>> Please tell me what's been bothering you.
<< My laptop keeps crashing
>> Your laptop keeps crashing ?
<< I think the agent broke the build
>> You say you think the agent broke the build ?
<< Everyone says AI will replace me
>> Really, everyone ?
<< You are just matching keywords
>> What makes you think I am just matching keywords ?

The last line is the rule for you are, with (2) filled in from my sentence and the pronouns flipped. It is not a comeback. It reads like one.

A part-of-speech tagger and eleven rules

sentence-intent came ten months later, 14 commits between 6 and 11 June 2018, and it was a real attempt at the problem rather than a wrapper. The one dependency is pos, a JavaScript port of FastTag, built on Eric Brill's rule set, which labels every word (VB, NN, DT, PRP). getIntent() then walks a fixed list of shapes and returns the first one that fits:

getIntent() {
  if (this.startsWithCanOrWill()) {           // "can you turn ..." → word 3
    this.intent = this.wordListLC[2]; ...
  }
  if (this.fristWordIsAVerb()) {              // "turn up the music" → "turn"
    this.intent = this.wordListLC[0]; ...
  }
  if (this.firstWordIsANounThenDeterminer()) { // "text the ..." → "text"
  ...
  if (this.hasAVerb()) {
    const firstVerb = this.getWordsByType('VB', true)[0];
    // please is a verb, lets not use it though
    if (firstVerb.toLowerCase() !== 'please') { ...
  }
  // seven more shapes, then: no intent
}

The context is the first noun after the intent word. There are 34 tests, all imperative, all about music, lights and texting, and every one of them is a sentence I would say. A second block of context rules that I could not get to pass is still in the file, commented out.

The loop around it

Edwin is where those two met, and reading it in 2026 was the part that made me want to write this. Each utterance ran once through a chain of twelve .then()s:

dialog.startConversation(this.state, rawInput)
  .then((state) => dialog.respondIfEmptyStatement(state))
  .then((state) => dialog.respondIfQuitInturrupt(state))
  .then((state) => dialog.setInitialIntent(state))         // sentence-intent
  .then((state) => dialog.setInitialImpliedContext(state))
  .then((state) => dialog.respondIfInvalidIntent(state))   // "I don't know how to turn that yet."
  .then((state) => dialog.setInitialTopic(state))
  .then((state) => dialog.setInitialContextFromModifiers(state))
  .then((state) => dialog.applyAnnotation(state))          // pull fields out of the sentence
  .then((state) => dialog.processConversationSteps(state)) // ask for what is missing, then run the module
  .then((state) => this.callback(state))

Intents were a folder of 13 JSON files. turn.json maps song, track, sonos and volume onto music, sends music to the music topic, and carries a failReply. A topic is a list of steps: each query names a field and a reply is what to ask if the field is empty, and the last step is a module to run.

{
  "id": "turn",
  "failReply": "I don't know how to turn that yet.",
  "contextModifiers": [
    { "context": "song",   "type": "map", "target": "music" },
    { "context": "volume", "type": "map", "target": "music" },
    { "context": "light",  "type": "phrase", "target": "lights" }
  ],
  "topicMap": [ { "context": "music", "topic": "music" } ]
}

In 2026 terms: the intent files are tool definitions, the modifiers are the argument schema, a step that queries a field is the model asking for a missing parameter, the module is the tool call, and failReply is the model declining. I built the loop by hand and put a regex where the model goes. Not because I chose to. There was nothing else to put there.

Running it again next to a model

The agent's job was the measuring. It cloned the four repos, ran the 2018 mocha suite under Node 24, wrote a script that pushed the 34 suite sentences and 11 new ones through sentence-intent, and scored gemma4 12B on ollama against the same expected answers. One prompt, temperature 0, JSON output, run twice: once free-form ("intent is one verb, context is one noun"), once with Edwin's 13 intents given as the only allowed list and an instruction to answer null if none fit. I picked the new sentences and read the misses.

What happened

sentence-intent (2018) gemma4 12B, free gemma4 12B, intent list
34-sentence suite 34/34 23/34 31/34
per sentence ~1 ms 463 ms 434 ms

Scores ignore case (Nate versus nate); the rules have the same problem on the new sentences further down. The rules win their own suite, and the three misses with the intent list say why that is not the point. For "turn on the kitchen light" the model said turn/light, where my 2018 expected answer was turn/kitchen. Kitchen is where, not what. The other two are "Send text to Nate" with and without a message after it: the model said send/Nate against my send/text, and I would take the model's answer. The test suite measured whether the code did what I wrote, not whether it understood anyone. Free-form, without the list, the model also drifted to send for "text me a message", which is the case for the list.

The 11 sentences the suite never saw:

sentence rules model, free model, intent list
Make it louder make / — turn / volume turn / volume
It is too quiet in here quiet / — adjust / volume stop / music
Lights off Lights / — turn / lights stop / lights
Kill the lights in the kitchen kill / lights turn off / lights turn / lights
I want to hear some jazz want / jazz play / music play / jazz
Louder Louder / — increase / volume stop / volume
Turn it down turn / — turn / volume turn / volume
Set a timer for ten minutes Set / timer set / timer stop / timer

The rules produced a complete pair on 3 of the 8, none with a verb Edwin knew. The free-form model got a usable pair on every row, and its vocabulary drifted (turn off, increase, adjust), which is the thing a fixed list is for. But given the list and told to answer null when nothing fit, the 12B model answered stop instead on 4 of the 11. "Louder" is not stop. "Set a timer" is not stop. That is the failReply problem from turn.json, still open, moved from the parser into the prompt. I did not try a larger model on it; that is the obvious next thing and I have not done it.

One smaller thing the run turned up: my own rules return Lights, Louder, What and Set with a capital, because the early shapes read from the lowercased word list and the fallback shapes read from the tagger's output in original case. Line 46 of Sentence.js calls .replace() and throws the result away. Edwin lowercased the intent one layer up, so it never showed. Same family of bug as the model's Nate.

Where it landed

Both modules install and run unchanged on Node 24 in 2026, which is more than I expected from a 2017 "engines": { "node": "7" }.

What I had right in 2017 was the shape of the thing: get an intent and its arguments out of a sentence, ask for what is missing, call a module, say no when nothing fits. That is the loop I now run an agent inside of every day. What I had wrong was which part was hard. I put the months into the parser and treated the loop as an afternoon of JSON. The parser is now one model call at temperature 0, and everything I called plumbing is what still needs a person: which intents exist, what the arguments are, what to ask when one is empty, and what "none of these" looks like.

The 34 tests are what I would do differently. Every sentence in that file is one I would say, so the parser learned me and nothing else. The eleven new sentences took five minutes to write and six of them came back with no context or the wrong verb.

What has not changed is where I want to run it. In 2017 the brain I could put on my own hardware was a 116-rule lookup table from 1966, and the one piece I had to send out of the house was speech. In 2026 the brain is a 12B model on a laptop, answering in under half a second. I am still tinkering with the same loop; the middle got filled in.

Open: the null case on a bigger local model, and Eliza's 20-statement memory against a context window, which is a different post.