Earlier this year, I tried to build something deliberately ordinary: a streaming chat, two tools, and memory that survived the browser session.
I was not trying to build a synthetic employee or an autonomous company. I wanted a useful product feature.
The first model response appeared quickly. By the end of the weekend, I also had tool calls and persisted messages. A few days later, the code technically worked and I could no longer explain where the application ended and the agent stack began.
Provider response types had reached the UI. Cancellation lived in a component. Tool execution knew about the conversation store. Memory assumed the message shape of one SDK. Replacing any one piece meant following imports through the entire application.
On the fifth day, I deleted it.
That was the most useful part of the project.
The model call was not the architecture
Most agent demos optimize for the first visible success: send a prompt and stream an answer. That is a reasonable demo because it proves the API works.
It is also where the architectural trouble can hide.
The application still has to answer less impressive questions. What ends a stream? What happens when the user navigates away? Can a failed provider return through the same path as a successful provider? Who owns the tool result? Can the UI render a conversation without importing the model SDK? Can memory be replaced without rewriting the runtime?
My first implementation had answers to all of these questions, but the answers were accidental. They emerged from whichever library I had wired in first.
At one point I spent two days debugging a framework pipeline and wrote no product code. The framework was not solely to blame. I had let its internal vocabulary become mine, so every problem had to be understood on its terms before I could fix it.
The mistake was not choosing the wrong library. The mistake was allowing an implementation choice to become an application contract.
I stopped designing around providers
The second version began with a deliberately small seam:
interface AdapterFactory {
createSource(request: AdapterRequest): {
stream(): AsyncIterableIterator<StreamChunk>
abort(): void
}
}
This is not exciting code. That is the point.
The application receives meaningful chunks and an explicit way to stop the work. It does not need the response object from OpenAI, Anthropic, Gemini, Ollama, or a local test double. A provider can expose additional metadata, but the rest of the system cannot silently depend on its private shape.
Writing the interface was easy. Writing down the invariants was more valuable:
- a stream must end explicitly in success, failure, or caller cancellation;
- calling
abort()must be safe at any point; - an adapter must not mutate the request messages;
- tool calls must arrive as complete, usable events;
- configuration must be visible at construction rather than hidden in global state.
These constraints turned “provider support” from a list of logos into something testable. A new adapter either satisfies the contract or it does not.
The same problem existed outside the model
Once I saw the provider coupling, I found the same pattern everywhere.
A tool is a function the model can call, but it should not need to understand the runtime loop. Memory stores and vector retrieval have different jobs, so combining them behind one vague abstraction makes both harder to replace. A skill can describe behavior without owning execution. A runtime can coordinate the pieces without deciding how the UI looks.
I ended up with six boundaries: adapter, tool, memory, retriever, skill, and runtime.
I did not choose six because it sounded elegant. Each one exists because I needed to answer the same practical question: who owns this behavior, and what should be able to replace it?
When a behavior does not have a clear owner, it eventually leaks into the most convenient layer. In agent applications, that layer is often the UI component or the provider integration. Both are expensive places to hide orchestration.
Headless UI only works when behavior is explicit
I also stopped treating “headless” as a styling feature.
A serious chat interface has behavior that should survive a renderer change: ordered messages, streaming progress, cancellation, tool status, errors, retries, and session state. It also has decisions that must remain with the product: layout, focus, announcements, motion, wording, and the visual treatment of risk.
If a component library owns the first group and quietly dictates the second, replacing it becomes a redesign. If every component reimplements the first group, the behavior fragments.
The boundary I wanted was a shared interaction model with renderer-specific presentation. That makes it possible to keep the same conversation behavior across React, a terminal, or another UI binding while letting each surface remain itself.
It costs more thought than importing a complete chat window. It also means the product does not have to look or behave like the framework that happened to arrive first.
Contracts did not remove complexity
The rebuild did not make hard problems disappear.
It moved them into places where I could see and test them.
Cancellation became part of the adapter contract instead of an AbortController buried inside a component. Terminal states became explicit events instead of a spinner that sometimes stopped. Provider-specific data became optional metadata instead of an unofficial dependency. Memory semantics were documented instead of inferred from one implementation.
There are costs to this approach:
- more decisions must be made before the happy path looks polished;
- contracts can be too small and require extensions;
- a stable boundary needs versioning and migration discipline;
- “plug-and-play” has to be proven by tests, not claimed in a README.
I prefer those costs to discovering six months later that changing a provider also means changing the runtime, memory, and UI.
The project became a kit, not another framework
I turned the second implementation into AgentsKit, an open-source JavaScript project built around those contracts. The core is intentionally small and has no runtime dependencies. The surrounding packages can be adopted independently.
The most important test is not whether someone uses the entire ecosystem. It is whether they can use one piece and later remove it without negotiating with the rest of the stack.
That is also why I do not want the project to begin with “install everything.” A healthier invitation is: find the boundary that is hurting, replace that one, and keep the rest of your application.
A test for your own agent architecture
You do not need AgentsKit to apply the lesson.
Take the provider, memory store, or chat renderer in your current application and imagine replacing it tomorrow. List every file that would change.
If the answer crosses the UI, runtime, persistence, and tool layers, the implementation has probably become your architecture.
Give that dependency a narrow contract. Write down its terminal behavior, cancellation behavior, errors, and ownership rules. Add a test double that satisfies the same interface. Then perform the replacement, even if only in a test.
The first agent demo is supposed to be easy. The useful architecture begins when the second implementation is also easy.
I created and maintain AgentsKit, and the public contracts, tests, trade-offs, and source are available at https://github.com/AgentsKit-io/agentskit. I would be particularly interested in examples that break these boundaries; those are more valuable than another compatibility badge.