I’ve recently been conceptualizing an AI Agent project called Skewr. My goal isn’t simply to quickly stack features together to build a demo. Instead, I am treating this as a systematic engineering exercise: using a runnable, scalable, and testable project to validate my understanding of agent architecture, state management, and cross-platform reusability.
Ultimately, Skewr will feature both a Dart CLI and a Flutter App. However, both of these will serve purely as "interfaces," while the core capabilities share the exact same underlying logic.
To prevent the project from becoming a tangled mess as it grows, I divided the system from day one into a three-layer architecture with four core modules: the UI Presentation Layer (comprising the independent Dart CLI and Flutter App modules), the Adapter Layer, and the Chat Core Layer.

The core principle behind this separation is straightforward: completely decouple what changes rapidly (UI/interaction) from what is most valuable (the Agent capabilities), ensuring each layer has clear responsibility boundaries and stable interfaces.
The Chat Core layer is the capability center of the entire system. It handles all the logic dictating "how the Agent should think and act": dialogue structure, context management, planning and decision-making, tool calling orchestration, error recovery strategies, and final output generation.
It deliberately avoids relying on any UI framework. It doesn't touch stdin/stdout, Widgets, or page routing. In other words, this layer answers only one question: Given the input and current state, what will the Agent do next, and in what format will it express the process and result?
I am designing the output here as an "event stream" rather than a one-time string return. This is because an Agent's behavior is inherently a process: streaming generation, tool execution, phase state changes, or even mid-way cancellations and retries. An event stream exposes this process information as a first-class citizen. Events might include "start generating," "output incremental content," "trigger tool call," "tool call finished with result," or "error occurred, suggesting recovery."
By doing this, the upper UI layer doesn't need to guess what the core is doing; it simply subscribes to the events and renders them.
The Adapter layer is an "engineering buffer zone" I intentionally introduced. Its role is to translate the Chat Core's event stream into State that the UI can directly consume, and to translate UI operations into command calls for the core. From the UI's perspective, it is the state management center; from the core's perspective, it's the input/output adapter.
Why do I need this layer? Because interactions vary wildly across different UIs. A CLI feels more like a REPL (read input, print output), while Flutter involves message lists, loading animations, buttons, and settings pages. If the UI directly consumes core events and assembles its own UI state, the logic will quickly diverge, making it nearly impossible to keep both ends consistent.