Build an AI Agent from First Principles Before You Choose a Framework
I kept rebuilding the same agent inside different products. Stripping it back to one definition, three skills, one MCP tool and a visible loop showed me what an agent framework actually does.
Kingsley Ijomah
AI Adoption Lead
Updated

I kept rebuilding what felt like the same agent inside Claude Code, Codex and Google’s Antigravity.
Each version had instructions, tools and some way to keep working until the task was complete. Yet I still thought about the agent in the language of the product running it. A Claude agent. A Codex agent. A Google agent.
That framing made the platform feel like the architecture.
When I tried to describe the agent independently, a simpler idea emerged: an agent is a definition plus a loop. The definition says what it is allowed and expected to do. The loop gives the model a way to act, observe the result and decide what happens next. The platform is where that combination happens to run.
I then made the predictable mistake. I tried to prove the idea with two agents, agent-to-agent discovery, delegation envelopes, hop budgets and cycle detection. The design was defensible, but it was a poor way to learn the basics. Too many interesting problems arrived at once.
So I removed the second agent and built one small Technical Decision Agent instead. The finished project is available in the agent-from-first-principles GitHub repository. You can clone it, run it locally, inspect every boundary and replace each piece without first learning an agent framework.
The smallest complete agent was smaller than my first design
The agent has one job: take a technical question, gather evidence, compare options when necessary, and return a recommendation with a trade-off and a next step.
Its shape is deliberately plain:
agent-from-first-principles/
├── agent.yaml
├── AGENT.md
├── skills/
│ ├── research/SKILL.md
│ ├── compare-options/SKILL.md
│ └── recommend/SKILL.md
├── tools/
│ ├── mcp.json
│ └── knowledge_server.py
├── runtime/
│ ├── loop.py
│ └── server.py
└── Dockerfile
I now find it useful to read that tree as three separate layers:
- Definition:
agent.yaml,AGENT.md, skills and tool bindings. - Framework: the code that runs the model and tool loop.
- Runtime: the HTTP process and container that keep the agent available.
agent.yaml is a local convention in this project, not an external standard. It is the composition root that points to the model configuration, instructions, skills, MCP server and loop budget. That distinction matters. Standards already cover some edges of an agent, but they do not automatically define the whole thing.
This is the practical counterpart to my earlier look at the control points in the agentic stack. That article asked which part of the stack each vendor wants to own. This project asks a smaller question: what is the minimum I need to own before those choices make sense?
Skills tell the agent how to work
The agent has three skills: research, compare options and recommend.
Each skill is a directory containing a SKILL.md file with YAML metadata and Markdown instructions. That follows the Agent Skills specification, which requires a name and description and supports additional scripts, references and assets.
The distinction I wanted to make visible was not file format. It was responsibility.
A skill is procedure. A tool is capability.
The research skill tells the agent to gather evidence, separate retrieved facts from assumptions and acknowledge missing evidence. It does not perform a search itself. The compare-options skill gives the model criteria such as implementation effort, portability, operational burden and reversibility. The recommend skill asks for one recommendation, one meaningful trade-off and one reversible next step.
The runtime reads only the relevant skill bodies into the system message. A question containing alternatives activates the comparison skill. A simpler explanatory question does not. The router is intentionally deterministic because this project is meant to expose the mechanism. A larger system could let a model route skills or use a more sophisticated policy, but that would add another decision before the basic loop was clear.
MCP gives the agent a boundary for executable tools
The project includes one local MCP server named knowledge. It exposes one tool, lookup, which searches a few hard-coded facts about agent architecture.
That is not a useful knowledge system. It is useful as a visible protocol boundary.
The model receives a tool description and JSON schema. If it decides that evidence is needed, it returns a tool call. The runtime sends that request to the MCP server over standard input, receives the result over standard output, and adds the observation to the model conversation.
model requests lookup(query)
↓
runtime sends tools/call
↓
MCP server executes lookup
↓
runtime returns the observation
↓
model continues
The model does not execute the MCP tool. The application does. That is easy to miss when a framework reduces tool use to a decorator and a configuration object.
The current MCP project describes MCP as the open boundary between AI applications and external tools or data. The protocol is also still evolving. The educational server in this repository uses a small, hand-written stdio subset and the older 2025-06-18 initialisation handshake. The 2026-07-28 MCP release introduced a stateless core, self-describing requests and updated official SDKs.
So the repository should not be presented as a production MCP reference server. For production, I would use a current official SDK, schema validation, authentication where needed, bounded timeouts and proper observability. For learning, the small implementation lets you see the JSON-RPC messages rather than trusting that a library handled them.
The loop is the part I needed to see
Once the skills and MCP tool existed, the central loop became surprisingly ordinary:
while steps < max_steps:
response = model.complete(messages, tools)
if response.requests_tool:
result = mcp.call(response.tool)
messages.append(result)
continue
return response.answer
The actual runtime does a little more. It builds the system message from AGENT.md and the selected skills. It asks the MCP server for its tool schemas. It converts those schemas into the function format expected by the model API. It validates tool arguments, records a short trace and stops if the model uses the available step budget without returning an answer.
But the important movement is still:
model → tool
tool → observation
model → final answer
OpenRouter sits behind a small model adapter. The agent sends its conversation and tool definitions to OpenRouter’s chat completions endpoint. When the model returns a tool request, the loop executes it locally and sends the tool result back. This matches OpenRouter’s documented tool-calling flow: the model proposes the call, while the application remains responsible for running it.
The API key stays in OPENROUTER_API_KEY, outside the agent definition and Docker image. The model can be changed with OPENROUTER_MODEL. That does not make every model behaviour equivalent, but it keeps the substitution point explicit.
Changing the model should not require rewriting the skills or MCP server.
The container taught me that deployment is not persistence
The first Docker image ran the command-line agent. It accepted one question, produced one answer and exited. I then started it with docker run -d, expecting to call it later through a port.
The container processed its default question and stopped normally. A request to port 8000 reached an unrelated service on my machine and returned {"detail":"Not Found"}. The mistake was not really about Docker networking. I had confused running a command in the background with running a persistent service.
Docker documents a container as an isolated process, and a detached container exits when its root process exits. The fix was to add an HTTP server as the container’s main process, expose /health, /skills and /tasks, then map host port 8787 to the container’s port 8000. The Docker run reference describes the same process and port model.
The container now stays alive because the server stays alive. The agent loop runs for each task. The MCP subprocess is still short-lived: it starts for a request and closes afterwards.
This small failure clarified the third layer for me. The runtime does not make the agent intelligent. It makes the agent available.
Clone it and inspect the boundaries yourself
The repository is designed to be read as well as run:
git clone https://github.com/CodehanceHQ/agent-from-first-principles.git
cd agent-from-first-principles
Set an OpenRouter key and run the command-line version:
export OPENROUTER_API_KEY="sk-or-..."
python3 -m runtime.loop \
"Should I write my own agent loop or use a framework?"
Or build the persistent container:
docker build -t technical-decision-agent .
docker run -d \
--name technical-agent \
-p 8787:8000 \
-e OPENROUTER_API_KEY="$OPENROUTER_API_KEY" \
technical-decision-agent
Then send it a task:
curl http://localhost:8787/tasks \
-H "Content-Type: application/json" \
-d '{"question":"Should I write my own loop or use a framework?"}'
Do not stop at running it. Open runtime/loop.py and follow the messages. Change a skill and see how the system prompt changes. Replace the hard-coded knowledge tool with a file search or documentation lookup. Change the model. Break the MCP server and inspect the failure.
The point of the repository is not the Technical Decision Agent itself. It is the ability to explain every boundary after you have changed it.
LangGraph becomes useful when the missing machinery is real
This project deliberately has no agent framework. That is a learning choice, not a claim that frameworks have no value.
The LangGraph documentation describes it as a low-level orchestration framework and runtime for long-running, stateful agents. Its core capabilities include durable execution, persistence, streaming and human-in-the-loop control. Those features address problems the handwritten loop does not solve.
I would consider moving this agent to LangGraph when one or more of these conditions became real:
- A task must survive a process restart.
- The agent needs persistent state across turns.
- Different failure types need different retry paths.
- A human must inspect or alter state before execution continues.
- Branches and resumable workflows are becoming harder to reason about than the graph abstraction.
- The team needs framework-level tracing and operational tooling.
Until then, adding a graph can make a two-state loop harder to inspect without solving a problem the application has.
The useful experiment is not to delete the handwritten version. It is to add a second implementation:
runtime/loop.py # readable baseline
runtime/langgraph.py # framework-backed loop
Both should load the same agent.yaml, instructions, skills and MCP configuration. If the definition survives the change, the separation is doing useful work. If changing frameworks forces every part of the agent to change, the boundaries were only labels.
Adopt a framework when you can name the operational problem it solves.
This is a learning agent, not a production claim
The agent is intentionally incomplete. It has no user authentication, persistent task store, streaming response, rate limiting, formal evaluation suite, cost reporting or production tracing. Its knowledge tool contains a few fixed facts. Its skill selection rule is simple. The HTTP server uses the Python standard library.
Those omissions keep the central mechanism readable. They also define the next work.
If I wanted to extend the project without losing that clarity, I would do it in this order:
- Replace the demonstration MCP tool with one useful, read-only capability.
- Add tests that judge whether the agent used evidence correctly, not only whether the loop executed.
- Add task and trace identifiers.
- Add authentication and explicit tool permissions.
- Add persistent state only when a real task requires it.
- Rebuild the same behaviour in LangGraph and compare the two implementations.
That sequence preserves the lesson I nearly buried under the original multi-agent design.
Write the smallest loop you can understand. Add a framework when the missing machinery costs more than the abstraction.
Sources
Linked so you can inspect the implementation and check the technical boundaries.
- CodehanceHQ: Agent from First Principles repository. The runnable companion containing the agent definition, three skills, MCP client and server, OpenRouter adapter, HTTP runtime, Dockerfile and tests. It is an educational implementation rather than a production framework.
- Agent Skills: SKILL.md specification. Defines the skill directory, required YAML frontmatter, Markdown instruction body and progressive-disclosure model used by the repository’s three skills.
- OpenRouter: Tool and function calling. Documents the model-requested, application-executed tool loop used by the OpenRouter adapter. Support varies by model, so the selected model must support tools.
- Model Context Protocol project: 2026-07-28 specification release. Describes the current stateless core, self-describing requests and updated official SDKs. This matters because the repository’s hand-written teaching server targets an older initialisation flow.
- Docker: docker container run reference. Supports the explanation that a detached container is still governed by its root process and documents environment variables and port publishing used by the example.
- LangChain: LangGraph overview. Describes LangGraph’s focus on orchestration for long-running, stateful agents and its durable execution, persistence, streaming and human-in-the-loop capabilities. It is vendor documentation, so the article uses it to describe intended features rather than independent performance evidence.
Share this article
Pass it on to someone who might find it useful.
From theory to practice
See how the ideas become working systems.
I’m working on hands-on examples and videos that build real agentic workflows step by step. Join the list for new articles, practical material, and the first course updates when they’re ready.
Discussion
What did this make you think about?
Share what you have seen in practice, ask a question, or add a different perspective.
