Introduction
Earlier this year, I went deep into the AI hole, and started building tools and programs around LLMs. One of those, was GaLt, a Discord bot that used the OpenAI API to generate text. It was a simple bot that I built in a few hours, and it was a lot of fun to build.
However, the more features I added (or I vibe coded), the larger the tech debt grew. The main index.ts file was a mess and kept getting bigger and bigger, and often times, I didn’t even know how parts of him worked. The inital working version was roughly ~322 lines of code, and by the end, it was around ~2,270 lines of code. Nightmare.
So, I decided to start over, and build a new bot, this time with a more modular and maintainable architecture. I called her Aigis, and she was a lot of fun to build.
A brief history of GaLt
When I first started building GaLt, I didn’t know a lot about using Typescript, nor did I know a lot about LLMs. I knew I didn’t want to pay for the OpenAI API, so I decided to use the free tier of the Google Gemini API, which let me use Gemini 2.0 Flash, without hitting the daily usage limits. This was great, I had a fast, free LLM, and I had the beginnings of a working bot. Users could mention the bot with their prompt, and would recieve a reply within a few seconds.
Here is a slightly altered and simplified version of the code that I used to build GaLt:
// Send message to Gemini and get response
async function askGemini(userMessage) {
const response = await ai.models.generateContent({
model: 'gemini-2.0-flash',
contents: [
{
role: 'user',
parts: [{ text: userMessage }],
},
],
config: {
systemInstruction:
'You are a helpful Discord bot called gaLt. Respond concisely in under 1500 characters.',
},
});
return response.text || 'No response generated.';
}
// Log in and confirm bot is ready
client.once('ready', () => {
console.log(`Logged in as ${client.user.tag}`);
});
// Handle incoming messages
client.on('messageCreate', async (message) => {
// Ignore bot messages and messages that don't mention us
if (message.author.bot || !message.mentions.has(client.user)) return;
// Get AI response from Gemini
const reply = await askGemini(message.content);
// Send response as embedded message
const embed = new EmbedBuilder()
.setColor(0x5865f2)
.setDescription(reply);
await message.reply({ embeds: [embed] });
});
// Start the bot
client.login(process.env.DISCORD_TOKEN);
As you can see, extremely simple. There’s a few bits missing, specifically error handling, logging and memory management. But, it was a good starting point.
GaLt originally didn’t have a running memory of what the user(s) had said to him, so he would forget everything after a few messages. My solution to this was to add every message (bot and user) to a JSON file, that would then get passed with each request to the Gemini API.
This was a good start, but as the JSON got bigger, and the responses got larger, more and more tokens were being sent. Once we hit ~10000 input tokens, Gemini 2.0 Flash would start to freakout, and go crazy, often imagining conversations all by itself.
So, I knew I needed to take a different approach. My next step was to rebuild GaLt, using Langchain and ChromaDB. Langchain would allow me to easily expose tools to the LLM and add a RAG-based memory system to the bot, with the assistance of ChromaDB. This was the final iteration of GaLt, which just ballooned in size with each feature. A small list of features added to GaLt over the course of a month or so included:
- Image generation using GPT-Image-1
- Cat gifs apologising for a long wait time
- A circuit breaker incase we ran out of free usage on the Gemini API, in which case we would fallback to the OpenAI API
- Metrics dashboard to track usage
- Web search using Tavily
I had no idea how any of those features worked. With every feature I added with the intention of improving user experience and fuctionality, the developer experience suffered. The codebase was a mess, and impossible to maintain.
The birth of Aigis
Aigis was born out of a desire to build a bot that was easy to maintain, easy to extend, and easy to understand. I knew I didn’t want to use Langchain, nor did I want to use ChromaDB. This bot had to be fast, modular, and easy to extend with new features and tools. And most importantly, I needed to know how every piece of it worked.
I landed on Vercel’s AI SDK, which made getting started a breeze. Here is a sample of code used to interface with the OpenRouter API:
import { generateText, stepCountIs } from "ai";
import { system } from "../system/system";
import { openrouter } from "../providers/openrouter";
export async function runAgent(
prompt: string,
model: string,
) {
const result = await generateText({
model: openrouter.chat(model),
system: system,
prompt: prompt,
toolChoice: "auto",
stopWhen: stepCountIs(10),
});
return result.text;
}
Really easy to use, and really easy to understand.
The most important decision choice I made was the project structure. Each part of the bot lives in its most relevant folder, and each file is usually just a single function. Anyone wishing to add new features or tools, or fix bugs can easily identify the code they need to change, and make their changes.
src/
├─ ai/
│ ├─ agent/agent.ts
│ ├─ embeddings/embeddings.ts
│ ├─ providers/openrouter.ts
│ ├─ system/SYSTEM.MD
│ └─ tools/...
├─ database/
│ ├─ client.ts
│ ├─ Dockerfile
│ └─ repositories/messageRepository.ts
├─ discord/bot.ts
├─ services/
│ └─ circuitBreaker/circuitBreaker.ts
└─ types/BotConfig.ts
Instead of handling memory with ChromaDB, I opted for a locally hosted PostgreSQL database + pgVector for vector similarity search. This just meant I wasn’t locked into ChromaDB, and I could easily switch to a different vector database if I needed to.
There were a few parts that I was able to lift straight from GaLt, such as the embed builder, and parts of bot.ts. This was a huge time saver, and allowed me to focus on the new features I wanted to add.
Moving forward
Building Aigis taught me that sometimes the best solution isn’t to add more features, it’s to step back and rebuild with intention. What started as frustration with a 2,270-line monolith became an opportunity to apply hard-won lessons about modularity, maintainability, and developer experience.
The journey from GaLt to Aigis wasn’t just about writing cleaner code; it was about understanding why that code matters. By choosing the Vercel AI SDK over Langchain, PostgreSQL over ChromaDB, and a folder structure that prioritizes clarity over convenience, I’ve built a bot that anyone can understand and maintain.
More importantly, Aigis is built to grow. New features can be added without multiplying complexity, bugs can be traced to their source without hours of hunting through thousands of lines, and the next developer (including future me) won’t have to reverse-engineer how things work.
If you’re building AI tools, the lesson is simple: choose your foundations wisely. The extra time spent on architecture early pays dividends when you’re debugging at 2 AM.