It took me longer than I expected to write another blog post as I got extremely busy participating in all the hackathons possible. The good thing is that I got to deepen my skills using Eliza and I've now figured out how to bootstrap a simple project with it.
The recommended way from Eliza is to use their starter repo but I still found that repo extremely confusing and bloated for nothing. I believe that’s a recuring issue with Eliza, the team seem to want to have the project be as turnkey as possible to the detriment of cleaner, simpler code, but I disgress.
For this blog post, refer to my Github project to get the basics of the architecture. This project is Eliza stripped down to what’s really needed (in my opinion) and structured in a way that makes sense (again, in my opinion).
Note that the repo only has a dev method, I’ll make a post to deploy it.
First of all, as you can see, I’ve used bun monorepo to split the backend/frontend logic. That is much different than the Eliza Starter repo or even the Eliza Core repo where they actually define the frontend as a client within the Eliza project. In my opinion, having the client in your Eliza project is not a good separation of concern. Eliza is simply a backend and should be treated like so. If we wanted to make the architecture bigger, we could have a project for types so that we can share it within the projects.
Everything starts with the index.ts file in backend/eliza
. The file is relatively simple but does a few different things. The entry point is the startAgents
function, which will begin by adding clients. Clients in Eliza are basically entry points to your app. Typically, it would be an API, you can also add a Chat feature if you want, but I got rid of it as it felt useless since it was just a wrapper to make API calls. Now the API, Eliza comes with a DirectClient which is essentially an API to connect to your agent. I didn’t want to use the built-in one because it has tons of routes I don’t need and I also wanted to have the liberty to add my own routes and manage them the way I wanted. Using the DirectClient and then extending it wasn’t a solution I liked, so I completely rewrote it and while at it, I chose Elysia over the built-in DirectClient for several reasons:
Better performance (it's built on top of Bun)
More flexible routing system
Easier to extend with custom endpoints
Better TypeScript support
One crucial aspect of Eliza that deserves a deeper explanation is the agent lifecycle. While it might seem complex at first, it follows a straightforward pattern:
Database Initialization
const db = new PostgresDatabaseAdapter({
connectionString: process.env.POSTGRES_URL,
parseInputs: true
});
await db.init();
Agent Runtime Creation
Each agent gets its own runtime, which is the core of Eliza's architecture
The runtime manages the agent's state, handles message processing, and coordinates between different components
const runtime = new AgentRuntime({
databaseAdapter: db,
token, // API token for the model provider
modelProvider, // Which LLM to use
character, // Agent's personality and behavior
plugins: [], // Optional plugins for extended functionality
cacheManager // Handles caching of conversations and state
});
Client Registration
After initialization, the agent needs ways to communicate
In my implementation, I use a custom Elysia-based API client instead of Eliza's built-in DirectClient
The API client gets registered with the runtime and can then handle incoming requests
runtime.clients = await initializeClients(character, runtime);
directClient.registerAgent(runtime);
Graceful Shutdown
Often overlooked but crucial for production deployments
My implementation handles SIGINT and SIGTERM signals
Ensures proper cleanup of database connections and server shutdown
const shutdown = async () => {
await directClient.server.close();
await db.close();
};
As you can see, the backend resides in the api.ts file and really just sets up the routes for our app. In this case, the only app we have is the message handler. I then split the logic of handling the request in a controller (message-controller.ts), the controller then uses a service to execute the logic (message-service.ts). This is a pretty standard API logic architecture and gives us the flexibility we need. Going through this simple refactoring also allowed me to understand exactly what is happening when a message is sent. I will cover this in a later blog post (spoiler, it’s easier than you think), but you can see all the code if you want here. The only thing that is important to follow here is the start
and stop
function for the client, rest is not used within the framework.
Once the API client is started, it will be distributed and added to every specific agent. One important part of this is the Database and cache manager. For the cache manager, you can simply use the CacheManager from Eliza. I haven’t looked into it much, but it did what I wanted it to do. Perhaps there are optimizations to be done there, but didn’t run into limitations. For Database, Eliza recommends SQLite for testing and Postgres for prod. I ran into issues using SQLite with Bun so I just resorted to using Postgres. I use Supabase, even though Eliza has a plugin for it, I use the Postgres adapter from core as the plugin was not working properly, and I didn’t want to figure out why.
As you can see, the project in its current form is extremely simple and that’s what I wanted it to be. I wanted to understand exactly what everything does and why, and I felt like the examples or the starter repo were completely bloated. Building this simpler version also allowed me to understand exactly how the framework works.
Simply put, every agent will start its own AgentRuntime based on the character definition file.
Characters in Eliza define the personality and behavior of your agent. Here's a simplified example:
// eliza/character.ts
export const character: Character = {
name: 'MyAgent',
modelProvider: 'openai',
personality: {
traits: ['helpful', 'friendly'],
background: 'An AI assistant focused on...'
},
// Other configuration options
};
In our case, we kept it very simple with a single character with no plugin or client (will cover that in another post), but the important thing to note here is that you can run multiple agents at the same time, they will have their own AgentRuntime which allows every agent to do its own thing. They will typically all share a client to contact them (our API), but can also have their own clients (think Twitter, Telegram, etc.). They will all have their own plugins and actions too.
One important aspect to note is how resources are shared between agents. While each agent has its own runtime:
Database connections are pooled and shared
The API server handles requests for all agents (it basically reaches the proper agent based on the request)
Cache can be configured to be either shared or isolated
Plugins and other clients are on a per-agent basis (defined in the Character file)
I think going through a refactoring and cleaning of the code from the Eliza Core allowed me to fully wrap my head around the architecture and how it works. Even though the Eliza documentation is quite extensive, it feels like a lot of the key elements are barely explained and the starter project felt so bloated it was a bit hard to understand the different moving parts. I also feel like the team is not fully exploiting the power of Typescript for some parts of the project and could really benefit from this.
As usual, feel free to reach out if you have any questions!