<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tosin okunniga</title>
    <description>The latest articles on DEV Community by Tosin okunniga (@teegoldz).</description>
    <link>https://dev.to/teegoldz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3901218%2F0b2cfc6d-dd6b-4de1-8014-35f8e603e096.png</url>
      <title>DEV Community: Tosin okunniga</title>
      <link>https://dev.to/teegoldz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/teegoldz"/>
    <language>en</language>
    <item>
      <title>Building My Own AI Coding Agent From Scratch: A Learning Journey</title>
      <dc:creator>Tosin okunniga</dc:creator>
      <pubDate>Mon, 27 Apr 2026 21:45:43 +0000</pubDate>
      <link>https://dev.to/teegoldz/building-my-own-ai-coding-agent-from-scratch-a-learning-journey-18ol</link>
      <guid>https://dev.to/teegoldz/building-my-own-ai-coding-agent-from-scratch-a-learning-journey-18ol</guid>
      <description>&lt;p&gt;&lt;em&gt;A €5 VPS. A Telegram bot. A Python script that kept growing. This is the story of what I learned by choosing to build rather than borrow, warts, dead ends, and all.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Decision
&lt;/h2&gt;

&lt;p&gt;In early 2025, AI coding tools were everywhere. Copilot in your editor, Cursor rewriting your files, Devin promising to replace entire sprints of work. The open-source side was just as active. OpenClaw had become the fastest-downloaded agent on GitHub, and anyone serious about local inference was buying a Mac Mini to run models at home.&lt;br&gt;
I was tempted by both options. But I kept asking myself an uncomfortable question: if I just install one of these, do I actually understand what it's doing? The model routing, the conversation management, the diff review loop, the fallback chains when an API goes down at 2am. Do I understand any of that?&lt;/p&gt;

&lt;p&gt;I didn't. And rather than shortcutting past that gap, I decided to sit in it for a while. Not because building from scratch is always the right call (it usually isn't), but because I wanted to learn by doing, even if what I built was rough around the edges.&lt;/p&gt;

&lt;p&gt;So I set some constraints. Cheap server. Telegram as the interface, since it's always open on my phone. Python, because I know it well. And a rule: no pretending the hard parts don't exist. I spun up a Hetzner VPS for a few euros a month, wired it to Telegram's bot API, and started writing.&lt;/p&gt;

&lt;p&gt;What followed was not a smooth arc from prototype to polished product. It was a series of painful discoveries, each one teaching me something I wouldn't have encountered by downloading somebody else's solution. The agent is still rough in places. It's not production-ready, and I'm not claiming it is. But it works, it's mine, and I understand every part of why.&lt;/p&gt;


&lt;h2&gt;
  
  
  It Started Embarrassingly Simple
&lt;/h2&gt;

&lt;p&gt;The first version was about 80 lines of Python. A Telegram message arrived, got forwarded to Anthropic's API, and the reply came back. No memory, no git integration, no diff review. Just a slightly expensive chat window with a bot icon.&lt;/p&gt;

&lt;p&gt;The first time I pointed it at a real repo, it was a complete mess. The bot misunderstood the task, edited the wrong file, and committed something that broke the build. I spent more time cleaning up after it than the fix would have taken manually. Not exactly the "AI takes over your workflow" moment I had imagined.&lt;/p&gt;

&lt;p&gt;That weekend prototype became the skeleton of everything that followed. What it also exposed, almost immediately, was a question I hadn't thought through: how exactly does an LLM &lt;em&gt;edit&lt;/em&gt; a file?&lt;/p&gt;


&lt;h2&gt;
  
  
  The Bash Nightmare
&lt;/h2&gt;

&lt;p&gt;The obvious answer is shell commands. Ask the model what to change, have it output a &lt;code&gt;sed&lt;/code&gt; invocation, run it through &lt;code&gt;subprocess.run()&lt;/code&gt;. I tried this. It was a disaster.&lt;/p&gt;

&lt;p&gt;The escaping problem came first. A &lt;code&gt;sed&lt;/code&gt; command that works in your terminal will randomly fail when an LLM constructs it and Python passes it to a subprocess. Special characters in the replacement string (backslashes, ampersands, quotes, newlines) follow escaping rules that vary between &lt;code&gt;sed&lt;/code&gt; on Linux and macOS, between single-quoted and double-quoted contexts, between inline and &lt;code&gt;-i&lt;/code&gt; mode. The model would produce something that looked correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/def old_function/def new_function/g'&lt;/span&gt; src/api.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it would either silently do nothing, silently corrupt the file, or throw an error whose message had no obvious connection to the actual problem.&lt;/p&gt;

&lt;p&gt;The second issue was semantic precision. &lt;code&gt;sed&lt;/code&gt; replaces text patterns, not code constructs. Tell it to update a function signature and it might replace every occurrence of that string across the entire file, including comments, docstrings, and a different function with a similar name. Or it would match nothing because the file used two spaces where the model expected one.&lt;/p&gt;

&lt;p&gt;I tried &lt;code&gt;awk&lt;/code&gt;. Same category of problems, steeper learning curve to debug.&lt;/p&gt;

&lt;p&gt;I tried having the model output a short Python script that would open the file and rewrite the relevant lines. This worked slightly better, right up until the editing script itself had a bug. At that point the error message was about the meta-code, not the original task. &lt;/p&gt;

&lt;p&gt;The deepest problem with all of these approaches is that bash is stateless in exactly the wrong way. A command either exits 0 or it doesn't. There's no introspection, no "here's what I was trying to do when this failed." When the agent ran &lt;code&gt;sed&lt;/code&gt; on the wrong file, or ran it twice, or ran it on a path that didn't exist yet, the only signal was silence or corruption.&lt;/p&gt;

&lt;p&gt;The breaking point was a session where the agent introduced a syntax error into a working file, failed to fix the bug I had asked it to fix, and then replied "Done!" with apparent confidence. I needed something better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aider&lt;/strong&gt; changed the dynamic entirely. Rather than generating shell commands that treat source files as raw text, Aider understands code as a structured thing. It reads the file, locates the right construct, applies the change, and writes back. When it fails, it fails with context rather than a corrupted file and a zero exit code. The tradeoff is that Aider is a subprocess with its own opinions, its own timeouts, and its own quirks. But its failure modes are legible, and legible failures are fixable. Silent failures are not.&lt;/p&gt;

&lt;p&gt;I learned something from this that applied everywhere else in the project: &lt;em&gt;the right tool is the one that fails in a way you can understand.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Model Routing Is a Product Problem
&lt;/h2&gt;

&lt;p&gt;With Aider handling file edits, the next question was which LLM to put behind it and what to do when that LLM has a bad day.&lt;/p&gt;

&lt;p&gt;I started with Anthropic exclusively, but API credits run out and models go down. I added Groq for the free tier. Then I found OpenRouter: a single API endpoint that proxies dozens of models. One API key, one client class, and you can switch between DeepSeek, Qwen, Mistral, and a dozen others by changing a string. For a small project running on a budget VPS, that flexibility matters.&lt;/p&gt;

&lt;p&gt;But "switch to OpenRouter" undersells the actual problem. In a production agent loop, models fail constantly and in different ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5xx errors from overloaded providers&lt;/li&gt;
&lt;li&gt;Rate limits that return an error after a 30-second wait&lt;/li&gt;
&lt;li&gt;Models that return 200 OK but produce malformed output&lt;/li&gt;
&lt;li&gt;Models that accept tool-calling syntax but don't actually execute tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A naive implementation treats any of these as fatal. A more careful one handles each differently. I built a layered fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Primary model
  → retry same model (up to 2x with exponential backoff on 5xx)
    → fallback model (e.g., DeepSeek → Claude Haiku)
      → Groq circuit breaker (back off for the remainder of the rate-limit window)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail that bit me early: not every model supports tool-calling in the structured sense that agentic loops require. Sending tool-use JSON to a model that doesn't understand it doesn't produce an error. It produces a garbled reply that silently breaks the task. The model registry tracks this explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MODELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deepseek&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openrouter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deepseek/deepseek-chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;supports_tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qwen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openrouter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qwen/qwen-2.5-coder-32b-instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;supports_tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;haiku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5-20251001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;supports_tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a task requires tool-calling and the selected model doesn't support it, the agent steps up to one that does without the user knowing or caring.&lt;/p&gt;

&lt;p&gt;The Groq circuit breaker deserves its own mention because it solved a genuinely painful problem. Groq's free tier is generous but has strict daily token limits. Early on, every background process (message classification, conversation compaction, post-task reflection) would independently call Groq until the daily quota was gone. By mid-afternoon, the agent was silent. A shared circuit breaker fixed this. When any one caller hits a rate limit, a global flag stops every other caller from trying during the same backoff window.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;groq_available&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;_GROQ_BACKOFF_UNTIL&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;groq_mark_rate_limited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_GROQ_BACKOFF_UNTIL&lt;/span&gt;
    &lt;span class="n"&gt;_GROQ_BACKOFF_UNTIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;retry_after_seconds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One failure, system-wide protection. Simple, and it works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Dispatcher: Knowing When Not to Code
&lt;/h2&gt;

&lt;p&gt;Once model routing was stable, a different problem surfaced. The agent couldn't tell the difference between a coding instruction and a casual message.&lt;/p&gt;

&lt;p&gt;Typing "hello" routed the bot to Aider, which would reply asking for files to add to its context. Typing "what did you change?" started a new job. Everything was a task.&lt;/p&gt;

&lt;p&gt;The fix was a lightweight dispatcher: a fast LLM call before any routing decision, with a strict two-output contract.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If the user is asking you to do coding work → output the single word: TASK
If the user is chatting, asking questions, or confirming → output a helpful reply
NEVER output both. "yes", "ok", "sure" are ALWAYS conversational.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract had to be that explicit. Early versions would classify a message correctly but then write a summary alongside it, something like "I'll get right on that. TASK", and the presence of the word TASK anywhere in the response would trigger a job with a nonsensical description.&lt;/p&gt;

&lt;p&gt;The dispatch chain runs Anthropic Haiku first (fast, cheap, good at classification), then Groq Llama as a backup, then a regex heuristic for obvious phrases (&lt;code&gt;hi&lt;/code&gt;, &lt;code&gt;thanks&lt;/code&gt;, &lt;code&gt;what did you do&lt;/code&gt;, &lt;code&gt;explain&lt;/code&gt;), and only if all of that fails does it default to treating the message as a task. Each fallback is cheaper and dumber than the one before it, but the chain as a whole handles the common cases reliably.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conversation Management: The Problem Nobody Blogs About
&lt;/h2&gt;

&lt;p&gt;Routing messages correctly solved one problem and revealed a deeper one. Conversations across different jobs were contaminating each other.&lt;/p&gt;

&lt;p&gt;A session from the previous day, where Aider had asked to add Flask files to its context, would leak into a completely different job the next morning. The agent would reference those files, ask for things unrelated to the current task, behave as though it didn't know which job it was working on. The bug was invisible until the output was obviously wrong.&lt;/p&gt;

&lt;p&gt;Three separate issues, three separate fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session bleed.&lt;/strong&gt; The conversation history wasn't cleared between jobs. Whatever noise Aider produced during the last session was still in context for the next one. Fix: call &lt;code&gt;convo_clear(user_id)&lt;/code&gt; when any new job starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context explosion.&lt;/strong&gt; Conversation history grows with every turn. After 40 turns you're feeding thousands of tokens of stale context to every new request, paying for tokens that actively make the output worse. Fix: compact old turns. When history exceeds 30 messages, the oldest half gets summarised into a single bullet-point block using a lightweight Groq call. The agent sees the essence of past context, not a wall of raw text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_compact_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;hist&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_conversations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;_COMPACT_AFTER&lt;/span&gt;
    &lt;span class="n"&gt;to_squash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hist&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;keep&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_call_summary_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_squash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_conversations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[Earlier conversation summary]&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;History contamination.&lt;/strong&gt; Aider's raw output is verbose and full of tool-call artefacts. Storing it verbatim means future prompts inherit noise instead of signal. Fix: when a job completes, store only the first line of its result, a one-sentence summary of what was done.&lt;/p&gt;

&lt;p&gt;All three together made the agent stop confusing its own past with whatever you're asking it to do right now.&lt;/p&gt;




&lt;h2&gt;
  
  
  Git Operations Needed Their Own Layer
&lt;/h2&gt;

&lt;p&gt;With coding tasks working reliably, git operations were the next thing to break. Cherry-picks, branch comparisons, selective file checkouts: all of them ran into the same wall.&lt;/p&gt;

&lt;p&gt;The executor deliberately blocks shell composition operators: &lt;code&gt;$()&lt;/code&gt;, &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;. Running arbitrary shell pipelines on a remote server is dangerous, and the controlled environment is worth the restriction. The problem is that every LLM's first instinct for "iterate over a list of files" looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;file &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; origin/branch&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;git checkout origin/branch &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model would generate this, the executor would reject it, and the task would fail with an unhelpful error message.&lt;/p&gt;

&lt;p&gt;Three changes fixed it, and all three were needed.&lt;/p&gt;

&lt;p&gt;First, a &lt;code&gt;_is_git_task()&lt;/code&gt; function detects when the approved plan is primarily git operations and routes away from Aider entirely. Aider doesn't handle &lt;code&gt;git checkout&lt;/code&gt;. It tries to open a chat session and ask for files to add.&lt;/p&gt;

&lt;p&gt;Second, a git-specific skill block gets injected into the system prompt with an explicit CRITICAL section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CRITICAL. No shell operators permitted.
  WRONG: git checkout $(git diff --name-only)
  WRONG: for file in $(...); do ...
  RIGHT: run "git diff --name-only" first. Read the output. Then run
         "git checkout origin/&amp;lt;branch&amp;gt; -- &amp;lt;file&amp;gt;" once per file as a separate step.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, &lt;code&gt;_inject_plan_steps()&lt;/code&gt; takes the approved plan steps and embeds them directly into the task message as numbered instructions. The agent doesn't have to infer what to do from a vague description. It gets explicit, ordered commands.&lt;/p&gt;

&lt;p&gt;Any one or two of those mechanisms alone wasn't enough. All three working together is what made git tasks actually reliable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Review Loop: Staying in Control
&lt;/h2&gt;

&lt;p&gt;All of this infrastructure exists to support one thing: a diff review loop that keeps you in control before anything gets committed.&lt;/p&gt;

&lt;p&gt;When the agent finishes a job, rather than committing immediately, it presents the changes with a structured review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A summary of what changed and why&lt;/li&gt;
&lt;li&gt;Any risks or unintended side effects it noticed&lt;/li&gt;
&lt;li&gt;A conventional commit message with type, scope, subject, body, and justification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From there you can approve, revert, or keep talking. Ask "why did you change this line?" and the agent answers using the actual diff as context. Give a follow-up instruction and it applies further changes on the same branch before showing you another diff. Only an explicit &lt;code&gt;approve &amp;lt;job_id&amp;gt;&lt;/code&gt; triggers the commit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:  fix the auth middleware
Bot:  📋 Plan #42 [5 steps] - reply "approve 42" to proceed
You:  approve 42
Bot:  Done. Here's what changed:
        Modified: src/auth/middleware.py, tests/test_auth.py
        feat(auth): replace session token storage to meet compliance requirements
        Risk: existing sessions will be invalidated on deploy
        Reply "approve 42" to commit, "revert 42" to cancel, or describe further changes.
You:  why did you touch line 47 in middleware.py?
Bot:  Line 47 contained the old token serialisation logic. The new approach signs
      the token with HMAC-SHA256 before storing it, which is what the compliance
      requirement specifies. I moved the signing step here so it applies to all
      token creation paths, not just the login endpoint.
You:  approve 42
Bot:  Committed. Reply "push 42" to push to origin.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because the agent does make mistakes. It changes more than it should, or gets the right thing but in the wrong place. Having a structured conversation about the diff before the commit is where those mistakes get caught, not after they've landed in the repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Memory: Not Repeating the Same Mistakes
&lt;/h2&gt;

&lt;p&gt;Over time I noticed a frustrating pattern. The agent would repeat errors that had already been fixed. It would use &lt;code&gt;pytest&lt;/code&gt; on a project that required &lt;code&gt;pytest -x --no-header&lt;/code&gt;. It would try to write a file without reading it first. Things I'd already debugged would reappear in the next session as if nothing had happened.&lt;/p&gt;

&lt;p&gt;The fix was persistent memory, and for this project that meant SQLite.&lt;/p&gt;

&lt;p&gt;The choice is almost boring to explain. SQLite is already on the server, requires no setup, never goes down, and costs nothing. I briefly looked at alternatives. Postgres is overkill for a single-user agent on a €5 VPS. A hosted database like Supabase adds a dependency, a credential to manage, and a recurring cost for data that has no business being remote. Redis would give me sub-millisecond reads on data I query a few times per task. That's solving a problem that doesn't exist.&lt;/p&gt;

&lt;p&gt;SQLite is just a file sitting next to the agent code. Backups are &lt;code&gt;cp&lt;/code&gt;. Inspection is &lt;code&gt;sqlite3 memory.db&lt;/code&gt;. That's exactly the right level of complexity here.&lt;/p&gt;

&lt;p&gt;Two tables, two jobs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lessons&lt;/code&gt;&lt;/strong&gt; accumulates knowledge over time. Repo-specific quirks, global patterns, things that went wrong and how they were fixed. A &lt;code&gt;hit_count&lt;/code&gt; column tracks how often each lesson gets injected into a prompt. Useful lessons naturally rise to the top. Stale ones fade without any manual curation. At the start of each job, the most relevant lessons go straight into the system prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;history&lt;/code&gt;&lt;/strong&gt; is a rolling log of the last 100 tasks: what was asked, what the outcome was, which model handled it. This gives the agent recent context without relying on in-memory state that disappears when the process restarts.&lt;/p&gt;

&lt;p&gt;After every completed task, a Groq reflection call pulls lessons from the outcome:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;REFLECT_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Extract concise, actionable lessons from this task outcome. Focus on:
- Mistakes made and how they were corrected
- Repo-specific conventions: test commands, build tools, file structure
- Patterns that worked and should be repeated
Return ONLY valid JSON: {&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;global_lessons&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: [...], &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo_lessons&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: [...]}
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent also reflects on conversations, not just task outcomes. After each exchange, a separate pass extracts facts about how the user likes to work: communication style, technical level, preferred tools. These shape how the dispatcher responds and how the agent communicates over time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploying Without Leaving Telegram
&lt;/h2&gt;

&lt;p&gt;Once the agent was useful enough that I was pushing changes to it regularly, a small friction point started adding up. Every push meant SSH-ing into the server, running &lt;code&gt;git pull&lt;/code&gt;, and restarting the process. It sounds minor. It stopped feeling minor around the sixth time in one evening.&lt;/p&gt;

&lt;p&gt;The obvious fix was GitHub Actions: a workflow that SSHes into the server on every push and restarts the service. But that means storing server credentials in GitHub Secrets, burning Actions minutes, and adding a whole CI layer to a personal tool that runs on €5 of compute. Not worth it.&lt;/p&gt;

&lt;p&gt;Instead I added a &lt;code&gt;/update&lt;/code&gt; command directly to the bot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;You: /update
Bot: Pulling latest code...
     git pull: 3 files changed, 47 insertions(+), 12 deletions(-)
     Restarting agent...
     Agent restarted. Now running: bf7e8f4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood it runs &lt;code&gt;git pull&lt;/code&gt; on the server repo and then &lt;code&gt;systemctl restart agent&lt;/code&gt;. About 15 lines of Python. No pipeline, no third-party credentials, no billing.&lt;/p&gt;

&lt;p&gt;The bot is already the authentication boundary. Any &lt;code&gt;/update&lt;/code&gt; request has already passed through the same check as &lt;code&gt;approve&lt;/code&gt;, &lt;code&gt;revert&lt;/code&gt;, and everything else. There's no reason to build a parallel deployment channel when the one you've already got works perfectly.&lt;/p&gt;

&lt;p&gt;The workflow now: push to GitHub, type &lt;code&gt;/update&lt;/code&gt; in Telegram, done. The server pulls and restarts in a few seconds. No SSH, no CI, no cost beyond the VPS.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Building It Taught Me
&lt;/h2&gt;

&lt;p&gt;Looking back, choosing to build rather than download produced a kind of understanding I couldn't have gotten any other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model routing is a product decision.&lt;/strong&gt; Picking the right model for each task (cheap and fast for classification, capable for code generation, free-tier for background reflection) is a real engineering problem with real cost implications. None of it is obvious until you've watched the loop break in three different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conversation state is where agents actually fall apart.&lt;/strong&gt; Every article about agents talks about prompts. Almost none of them talk about what happens to conversation history after 40 turns, or what happens when two sessions bleed into each other. That's where reliability actually lives, and it's completely invisible when it's working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The review loop is the point, not the workaround.&lt;/strong&gt; Having a back-and-forth about the diff before committing is more useful than just letting the agent commit directly. It makes mistakes. The conversation catches them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap infrastructure goes further than you'd think.&lt;/strong&gt; A €5 VPS, free-tier Groq, SQLite, and OpenRouter pay-per-token costs less per month than a single Claude Pro subscription. The economics are surprisingly good once you stop paying for abstraction layers you don't need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't shortcut the learning.&lt;/strong&gt; I now have a real working understanding of model fallback chains, conversation lifecycle management, tool-calling protocols, and diff review loops. Not from reading about them but from writing code that broke in those exact places and having to fix it. I'm still learning. But the gap between where I started and where I am now only exists because I chose to build.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;There's still a lot to figure out. The agent works well enough that I use it regularly, but I'm under no illusion that it's finished. If anything, building it has surfaced more questions than it's answered.&lt;/p&gt;

&lt;p&gt;Right now it needs to be told which repo a task belongs to. I'd like it to pick that up from context instead of always waiting to be told. Test integration is another gap: being able to run the test suite after a change and include the results in the diff review before asking for approval would make the whole loop a lot more trustworthy. I also want to explore webhook triggers so a GitHub push can kick off the agent directly without me needing to type anything in Telegram.&lt;/p&gt;

&lt;p&gt;Honestly, the list keeps growing the more I use it. Every time I run a task I notice something that could be better. That feels like a good sign.&lt;/p&gt;

&lt;p&gt;The code is messy in places. Some of the fallback logic is held together with pattern-matching and stubbornness. It's not production-ready and I'm not trying to pretend otherwise. But it's mine, I understand it, and that was the whole point of building it in the first place.&lt;/p&gt;




&lt;p&gt;Stack: Hetzner VPS (€4.51/month) · python-telegram-bot · Anthropic API · OpenRouter · Groq (free tier) · Aider-chat · SQLite&lt;br&gt;
Code: github.com/Teegold007/my-agents&lt;br&gt;
If you're building something similar or have questions about any of the decisions here, feel free to reach out.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>learning</category>
      <category>agentskills</category>
    </item>
  </channel>
</rss>
