<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/feed.xslt"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
        <title>Stavros&#39; Stuff</title>
        <link>https://www.stavros.io/</link>
        <atom:link href="https://www.stavros.io/feed/" rel="self"></atom:link>
        <description>Latest posts on Stavros&#39; Stuff.</description>
        <language>en-us</language>
        <author>
            <name>Stavros Korokithakis</name>
        </author>
        
        
        <item>
            <title>How I write software with LLMs</title>
            <link>https://www.stavros.io/posts/how-i-write-software-with-llms/</link>
            <guid>https://www.stavros.io/posts/how-i-write-software-with-llms/</guid>
            <description>
                &lt;div class=&#34;pull-quote&#34;&gt;I don&#39;t care for the joy of programming&lt;/div&gt;&lt;p&gt;Lately I&amp;#8217;ve gotten &lt;em&gt;heavily&lt;/em&gt; back into making stuff, and it&amp;#8217;s mostly because of LLMs.
I thought that I liked programming, but it turned out that what I like was making things, and programming was just one way to do that.
Since LLMs have become good at programming, I&amp;#8217;ve been using them to make stuff nonstop, and it&amp;#8217;s very exciting that we&amp;#8217;re at the beginning of yet another entirely unexplored frontier.&lt;/p&gt;
&lt;p&gt;There&amp;#8217;s a lot of debate about LLMs at the moment, but a few friends have asked me about my specific workflow, so I decided to write it up in detail, in the hopes that it helps them (and you) make things more easily, quickly, and with higher quality than before.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;ve also included a real (annotated) coding session at the end.
You can go there directly if you want to skip the workflow details.&lt;/p&gt;
&lt;h2&gt;The benefits&lt;/h2&gt;
&lt;p&gt;For the first time ever, around the release of Codex 5.2 (which feels like a century ago) and, more recently, Opus 4.6, I was surprised to discover that I can now write software with LLMs with a very low defect rate, probably significantly lower than if I had hand-written the code, without losing the benefit of knowing how the entire system works.
Before that, code would quickly devolve into unmaintainability after two or three days of programming, but now I&amp;#8217;ve been working on a few projects for weeks non-stop, growing to tens of thousands of useful lines of code, with each change being as reliable as the first one.&lt;/p&gt;
&lt;p&gt;I also noticed that my engineering skills haven&amp;#8217;t become useless, they&amp;#8217;ve just shifted:
I no longer need to know how to write code correctly at all, but it&amp;#8217;s now massively more important to understand how to architect a system correctly, and how to make the right choices to make something usable.&lt;/p&gt;
&lt;p&gt;On projects where I have no understanding of the underlying technology (e.g. mobile apps), the code still quickly becomes a mess of bad choices.
However, on projects where I know the technologies used well (e.g. backend apps, though not necessarily in Python), this hasn&amp;#8217;t happened yet, even at tens of thousands of SLoC.
Most of that must be because the models are getting better, but I think that a lot of it is also because I&amp;#8217;ve improved my way of working with the models.&lt;/p&gt;
&lt;p&gt;One thing I&amp;#8217;ve noticed is that different people get wildly different results with LLMs, so I suspect there&amp;#8217;s some element of how you&amp;#8217;re talking to them that affects the results.
Because of that, I&amp;#8217;m going to drill very far down into the weeds in this article, going as far as posting actual sessions, so you can see all the details of how I develop.&lt;/p&gt;
&lt;p&gt;Another point that should be mentioned is that I don&amp;#8217;t know how models will evolve in the future, but I&amp;#8217;ve noticed a trend:
In the early days of LLMs (not so much with GPT-2, as that was very limited, but with davinci onwards), I had to review every line of code and make sure that it was correct.
With later generations of LLMs, that went up to the level of the function, so I didn&amp;#8217;t have to check the code, but did have to check that functions were correct.
Now, this is mostly at the level of &amp;#8220;general architecture&amp;#8221;, and there may be a time (next year) when not even that is necessary.
For now, though, you still need a human with good coding skills.&lt;/p&gt;
&lt;h2&gt;What I&amp;#8217;ve built this way&lt;/h2&gt;
&lt;p&gt;I&amp;#8217;ve built quite a few things recently, and I want to list some of them here because a common criticism of LLMs is that people only use them for toy scripts.
These projects range from serious daily drivers to art projects, but they&amp;#8217;re all real, maintained projects that I use every day:&lt;/p&gt;
&lt;h3&gt;Stavrobot&lt;/h3&gt;
&lt;p&gt;The largest thing I&amp;#8217;ve built lately is &lt;a href=&#34;https://github.com/skorokithakis/stavrobot&#34;&gt;an alternative to OpenClaw that focuses on security&lt;/a&gt;.
I&amp;#8217;ve wanted an LLM personal assistant for years, and I finally got one with this.
Here, most people say &amp;#8220;but you can&amp;#8217;t make LLMs secure!&amp;#8221;, which is misunderstanding that security is all about tradeoffs, and that what my agent tries to do is maximize security for a given amount of usability.
I think it succeeds very well, I&amp;#8217;ve been using it for a while now and really like the fact that I can reason exactly about what it can and can&amp;#8217;t do.&lt;/p&gt;
&lt;p&gt;It manages my calendar and intelligently makes decisions about my availability or any clashes, does research for me, extends itself by writing code, reminds me of all the things I used to forget and manages chores autonomously, etc.
Assistants are something that you can&amp;#8217;t really explain the benefit of, because they don&amp;#8217;t have &lt;em&gt;one&lt;/em&gt; killer feature, but they alleviate a thousand small paper cuts, paper cuts which are different for each person.
So, trying to explain to someone what&amp;#8217;s so good about having an assistant ends up getting a reaction of &amp;#8220;but I don&amp;#8217;t need any of the things you need&amp;#8221; and misses the point that everyone needs different things, and an agent with access to tools and the ability to make intelligent decisions to solve problems is a great help for anyone.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;m planning to write this up in more detail soon, as there were some very interesting challenges when designing it, and I like the way I solved them.&lt;/p&gt;
&lt;h3&gt;Middle&lt;/h3&gt;
&lt;p&gt;Maybe my naming recently hasn&amp;#8217;t been stellar, but this is a &lt;a href=&#34;/posts/i-made-a-voice-note-taker/&#34;&gt;small pendant that records voice notes&lt;/a&gt;, transcribes them, and optionally POSTs them to a webhook of your choice.
I have it send the voice notes to my LLM, and it feels great to just take the thing out of my pocket at any time, press a button, and record a thought or ask a question into it, and know that the answer or todo will be there next time I check my assistant&amp;#8217;s messages.&lt;/p&gt;
&lt;p&gt;It&amp;#8217;s a simple thing, but the usefulness comes not so much from &lt;em&gt;what&lt;/em&gt; it does, but from &lt;em&gt;the way&lt;/em&gt; it does it.
It&amp;#8217;s always available, always reliable, and with zero friction to use.&lt;/p&gt;
&lt;h3&gt;Sleight of hand&lt;/h3&gt;
&lt;p&gt;I&amp;#8217;m planning to write something about this too, but this one is more of an art piece:
It&amp;#8217;s a ticking wall clock that ticks seconds irregularly, but is always accurate to the minute (with its time getting synced over the internet).
It has various modes, one mode has variable tick timing, from 500 ms to 1500 ms, which is delightfully infuriating.
Another mode ticks imperceptibly more quickly than a second, but then pauses for a second randomly, making the unsuspecting observer question their sanity.
Another one races to :59 at double speed and then waits there for thirty seconds, and the last one is simply a normal clock, because all the irregular ticking drives me crazy.&lt;/p&gt;
&lt;h3&gt;Pine Town&lt;/h3&gt;
&lt;p&gt;&lt;a href=&#34;https://pine.town&#34;&gt;Pine Town&lt;/a&gt; is a whimsical infinite multiplayer canvas of a meadow, where you get your own little plot of land to draw on.
Most people draw&amp;#8230; questionable content, but once in a while an adult will visit and draw something nice.
Some drawings are real gems, and it&amp;#8217;s generally fun scrolling around to see what people have made.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;ve made all these projects with LLMs, and have never even read most of their code, but I&amp;#8217;m still intimately familiar with each project&amp;#8217;s architecture and inner workings.
This is how:&lt;/p&gt;
&lt;h2&gt;The harness&lt;/h2&gt;
&lt;p&gt;For the harness, I use &lt;a href=&#34;https://opencode.ai&#34;&gt;OpenCode&lt;/a&gt;.
I really like its features, but obviously there are many choices for this, and I&amp;#8217;ve had a good experience with &lt;a href=&#34;https://pi.dev&#34;&gt;Pi&lt;/a&gt; as well, but whatever harness you use, it needs to let you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use multiple models from different companies.
Most first-party harnesses (Claude Code, Codex CLI, Gemini CLI) will fail this, as companies only want you to use their models, but this is necessary.&lt;/li&gt;
&lt;li&gt;Define custom agents that can autonomously call each other.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There are various other nice-to-haves, such as session support, worktree management, etc, that you might want to have depending on your project and tech stack, but those are up to you.
I&amp;#8217;ll explain the two requirements above, and why they&amp;#8217;re necessary.&lt;/p&gt;
&lt;h3&gt;Multiple models&lt;/h3&gt;
&lt;p&gt;You can consider a specific model (e.g.
Claude Opus) as a person.
Sure, you can start again with a clean context, but the model will mostly have the same opinions/strengths/weaknesses as it did before, and it&amp;#8217;s very likely to agree with itself.
This means that it&amp;#8217;s fairly useless to ask a model to review the code it just wrote, as it tends to mostly agree with itself, but it also means that getting a &lt;em&gt;different&lt;/em&gt; model to review the code will lead to a big improvement.
Essentially, you&amp;#8217;re getting a review from a second set of eyes.&lt;/p&gt;
&lt;p&gt;Different models will have different strengths and weaknesses here.
For example (and this is very specific to today&amp;#8217;s models), I find Codex 5.4 pretty nitpicky and pedantic.
This isn&amp;#8217;t something I want when I want to get code written, but it definitely is something I want for a review.
The decisions Opus 4.6 makes correlate quite well with the decisions I would have made, and Gemini 3 Flash (yes, Flash!) has even been very good at coming up with solutions that other models didn&amp;#8217;t see.&lt;/p&gt;
&lt;p&gt;Everyone has a different opinion on what model suits them for which job, and models tend to alternate (e.g. I used Codex as my main model back in November, switching back to Opus later).
To get the best results, you need a mix of all of them.&lt;/p&gt;
&lt;h3&gt;Agents that call each other&lt;/h3&gt;
&lt;p&gt;The workflow I use consists of different agents, and if the harness doesn&amp;#8217;t have the ability to let agents talk to each other, you&amp;#8217;ll be doing a lot of annoying ferrying of information between LLMs.
You probably want to cut down on that, so this is a very useful feature.&lt;/p&gt;
&lt;h2&gt;My workflow&lt;/h2&gt;
&lt;p&gt;My workflow consists of an architect, a developer, and one to three reviewers, depending on the importance of the project.
These agents are configured as OpenCode agents (basically skill files, files with instructions for how I want each agent to behave).&lt;/p&gt;
&lt;p&gt;I use multiple agents (rather than using just one for everything) for three reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It allows me to use an expensive model (Opus) for planning and generating a detailed plan but a cheap one (Sonnet) for the actual writing of the code.
That just saves on tokens versus having Opus do the whole thing.&lt;/li&gt;
&lt;li&gt;It lets me use different models to review the code, which &lt;em&gt;does&lt;/em&gt; increase quality because different models will catch different things when reviewing.&lt;/li&gt;
&lt;li&gt;It lets me use agents with different capabilities (e.g. one might have read-only access to the code, whereas the other might have write access to).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I don&amp;#8217;t think using two agents with the same model and the same capabilities makes much sense, as I think it&amp;#8217;s something like one person pretending to wear different hats, but I haven&amp;#8217;t studied this extensively.&lt;/p&gt;
&lt;p&gt;I also tend to write the skill files by hand, as I find it doesn&amp;#8217;t really help if you ask the LLM to write a skill.
It would be like asking someone to write up instructions on how to be a great engineer and then gave them their own instructions and said &amp;#8220;here&amp;#8217;s how to be a great engineer, now be one&amp;#8221;.
It obviously won&amp;#8217;t really make them better, so I try to write the instructions myself.&lt;/p&gt;
&lt;p&gt;If you want to try this yourself, you can &lt;a href=&#34;/posts/how-i-write-software-with-llms/agents.zip&#34;&gt;download my agent files&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;The architect&lt;/h3&gt;
&lt;p&gt;The architect (Claude Opus 4.6, currently) is the only agent I interact with.
This needs to be a very strong model, typically the strongest model I have access to.
This step doesn&amp;#8217;t consume too many tokens, as it&amp;#8217;s mostly chat, but you want this to be very well-reasoned.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;ll tell the LLM my main goal (which will be a very specific feature or bugfix e.g. &amp;#8220;I want to add retries with exponential backoff to Stavrobot so that it can retry if the LLM provider is down&amp;#8221;), and talk to it until I&amp;#8217;m sure it understands what I want.
This step takes the most time, sometimes even up to half an hour of back-and-forth until we finalize all the goals, limitations, and tradeoffs of the approach, and agree on what the end architecture should look like.
It results in a reasonably low-level plan, with a level of detail of individual files and functions.
For example, tasks might be &amp;#8220;I&amp;#8217;ll add exponential backoff to these three codepaths of these two components in this file, as no other component talks to the LLM provider&amp;#8221;.&lt;/p&gt;
&lt;p&gt;I know that some people in this step prefer to have the LLM write out the plan to a file, and then they add their feedback to that file instead of talking to the LLM.
This is a matter of personal preference, as I can see both approaches working equally well, so feel free to do the reviews that way if it suits you more.
Personally, I prefer chatting to the LLM.&lt;/p&gt;
&lt;p&gt;To clarify, in this step I&amp;#8217;m not &lt;em&gt;just&lt;/em&gt; prompting, I&amp;#8217;m shaping the plan with the help of the LLM.
I still have to correct the LLM a lot, either because it&amp;#8217;s wrong or simply because it&amp;#8217;s not doing things the way I&amp;#8217;d do them, and that&amp;#8217;s a big part of my contribution, as well as the part I get joy from.
This direction is what lets me call projects &lt;em&gt;mine&lt;/em&gt;, because someone else using the same LLM would have come up with a different thing.&lt;/p&gt;
&lt;p&gt;When I&amp;#8217;m satisfied that we&amp;#8217;ve ironed out all the kinks (the LLM is very helpful at this, asking questions for what it doesn&amp;#8217;t know yet and giving me options), I can finally approve the plan.
I&amp;#8217;ve asked the architect to not start anything until I actually say the word &amp;#8220;approved&amp;#8221;, as a few models tend to be overeager and go off to start the implementation when &lt;em&gt;they&lt;/em&gt; feel like they understood, whereas I want to make sure &lt;em&gt;I&amp;#8217;m&lt;/em&gt; confident it understood.&lt;/p&gt;
&lt;p&gt;Then, the architect will split the work into tasks, and write each task out into a plan file, usually in more detail (and at a lower level) than our chat, and call the developer to start work.
This gives the developer concrete direction, and minimizes the high-level choices the developer can make, as the choices have already been made for it.&lt;/p&gt;
&lt;h3&gt;The developer&lt;/h3&gt;
&lt;p&gt;The developer can be a weaker, more token-efficient model (I use Sonnet 4.6).
The plan shouldn&amp;#8217;t give it much leeway into what it can do, and its job is strictly to implement the changes in the plan.
When it&amp;#8217;s done, it reports back to the architect, who calls the reviewers to review the work.&lt;/p&gt;
&lt;h3&gt;The reviewers&lt;/h3&gt;
&lt;p&gt;Each reviewer will independently look at the plan and diff of the feature that was just implemented, and critique it.
For this step, I will always use at least Codex, sometimes I&amp;#8217;ll add Gemini, and on important projects I&amp;#8217;ll add Opus as well.&lt;/p&gt;
&lt;p&gt;This feedback goes back to the architect, who judges whether the feedback should be addressed, or if it&amp;#8217;s inconsequential enough that it can be skipped.
I&amp;#8217;ve found that Opus is very good at choosing the right feedback to implement, sometimes ignoring feedback because it&amp;#8217;s too pedantic (i.e. hard to implement and unlikely to be a problem in practice).
Obviously, when I use objective assessments like &amp;#8220;very good&amp;#8221;, I really mean &amp;#8220;I agree with it a lot&amp;#8221;.&lt;/p&gt;
&lt;h3&gt;The overall approach&lt;/h3&gt;
&lt;p&gt;This way of working means that I still know every choice that was made above the function level, and can use that knowledge in subsequent runs.
I often notice the LLM recommend things that might be good in another codebase, but either won&amp;#8217;t work or are suboptimal in my codebase, which shows that the LLM has some blind spots when researching the code.
I will often say &amp;#8220;no, you should do this using Y&amp;#8221;, at which point the LLM realizes that Y actually exists in the code and is a better way than the one it recommended.&lt;/p&gt;
&lt;h3&gt;Failure modes&lt;/h3&gt;
&lt;p&gt;On the flip side, when I&amp;#8217;m not familiar enough with the technology to be on top of the architecture, I tend to not catch bad decisions that the LLM makes.
This leads to the LLM building more and more on top of those bad decisions, eventually getting in a state where it can&amp;#8217;t untangle the mess.
You know this happens when you keep telling the LLM the code doesn&amp;#8217;t work, it says &amp;#8220;I know why! Let me fix it&amp;#8221; and keeps breaking things more and more.&lt;/p&gt;
&lt;p&gt;That&amp;#8217;s a real failure mode that has happened to me too many times now, which is why I ended up with this workflow.
For this reason, I try to understand things as much as I can while planning, even if I&amp;#8217;m unfamiliar with the specific technology.
If I manage to steer the LLM well, it saves a lot of trouble later on.&lt;/p&gt;
&lt;h2&gt;A real session&lt;/h2&gt;
&lt;p&gt;Here&amp;#8217;s an annotated transcript from a real session where I add email support to Stavrobot.
I&amp;#8217;ve trimmed the tool calls and verbose bits, but the conversation and decision-making process is exactly as it happened.&lt;/p&gt;
&lt;p&gt;I start by telling the LLM what I&amp;#8217;d like to implement, at a very high level.
Sometimes I&amp;#8217;ll give it more detail, especially if I already have an idea of how I want the implementation done.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  I&amp;#39;d like to add email support to this bot. Let&amp;#39;s think through
     how we would do this.
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The bot reads the code and finds all the relevant bits, and asks some questions.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Good overview acquired. Here&amp;#39;s what I see and the questions we
     need to resolve:

     ## Current pattern

     All channels follow the same flow: inbound webhook/listener →
     enqueueMessage(message, source, sender, attachments) → LLM
     processes → LLM calls send_*_message tool to reply. Channels are
     either in-process (Telegram webhooks, WhatsApp via Baileys) or
     separate containers (Signal bridge). Email would be a new channel
     following this same pattern.

     ## Key design questions

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;1.&lt;/span&gt; Inbound — how does email arrive?
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;IMAP polling (simplest — poll a mailbox on a timer, works
          with any provider)
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Webhook from a transactional email service (SendGrid/Mailgun
          inbound parse — requires that service)
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Run an SMTP server (most complex, requires DNS/MX setup)

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;2.&lt;/span&gt; Outbound — how do we send?
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;SMTP client (works with any provider — Gmail, Fastmail,
          self-hosted)
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Transactional email API (SendGrid/Mailgun API)

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;3.&lt;/span&gt; What&amp;#39;s your actual use case? This affects several decisions:
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Full bidirectional conversation (reply to emails, maintain
          threads)?
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;One-way notifications from the bot?
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Processing incoming emails (e.g., forwarded emails,
          digests)?

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;4.&lt;/span&gt; Architecture — separate container or in-process?
        Signal uses a bridge container because signal-cli is a heavy
        Java process. Email libraries are lightweight, so in-process
        (like Telegram) is viable. But the AGENTS.md rule about not
        adding runtimes to the app container doesn&amp;#39;t apply here since
        it&amp;#39;s just TypeScript/Node.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;5.&lt;/span&gt; Email-specific concerns:
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;How to handle HTML emails? Strip to plain text? Pass a
          markdown conversion to the LLM?
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Thread tracking — match replies to conversations by
          In-Reply-To/References headers, or treat each email
          independently?
&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Attachments — needed?

     What&amp;#39;s the scenario you&amp;#39;re envisioning? That&amp;#39;ll narrow this down
     fast.
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;In this session, I came with just a bit of an idea that hadn&amp;#8217;t been thought through yet.
The LLM helps by asking specific questions, where I decide which way I want the implementation to go:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  1. Email will arrive from a webhook, yes. I will create a
     Cloudflare Email worker that will forward the entire body of the
     email via webhook. It&amp;#39;ll upload a JSON version of the message:

     {&amp;quot;from&amp;quot;:&amp;quot;me@mydomain.com&amp;quot;, etc}

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;2.&lt;/span&gt; SMTP client

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;3.&lt;/span&gt; Full bidirectional conversation, yes. This will be the same as
     how interlocutors work with the other transports, if someone sends
     an email to the bot, it will be ignored unless they are set up as
     an interlocutor. The owner can also send emails or forward emails
     for processing.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;4.&lt;/span&gt; In-process, since this will be a webhook.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;5.&lt;/span&gt; Markdown conversion, yes. Let&amp;#39;s treat each email independently
     for now, and then we can change that if necessary. Attachments are
     needed, they should work like any other platform, the agent will
     receive them as files.

     The scenario I&amp;#39;m envisioning is both being able to correspond with
     people over email via subagents (like the bot currently does with
     Signal/Telegram/Whatsapp) and the owner being able to forward
     emails (e.g. a confirmation email for a booking) for the bot to
     process and ingest the info. Does this make sense? What edge cases
     can you foresee, or is there something you&amp;#39;re worried about?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The LLM shapes the plan, giving it more detail:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: This makes sense and fits cleanly into the existing channel
     pattern. Here are my observations and the edge cases I see:

     ## Concerns and edge cases

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;1.&lt;/span&gt; MIME parsing is non-trivial. Plain text emails are easy, but
        real-world emails are multipart MIME (HTML+text alternatives,
        inline images, attachments, nested multipart parts, various
        character encodings). We should use mailparser (from the
        Nodemailer project) to parse the raw RFC 2822 message
        server-side rather than trying to handle this in the worker.
        This means the worker payload can be minimal — just from, to,
        and raw.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;2.&lt;/span&gt; Webhook authentication. The endpoint must be public (Cloudflare
        can&amp;#39;t do a browser login), but we need to prevent anyone from
        POSTing fake emails. Simplest approach matching the Telegram
        pattern: generate a random secret on startup, but since we
        can&amp;#39;t call back to Cloudflare to register it, we&amp;#39;d configure a
        shared secret in config.toml and have the worker send it as a
        header. The endpoint validates it.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;3.&lt;/span&gt; Outbound emails need a subject line. Every other channel is
        subject-less, but email requires one. The send_email tool needs
        a subject parameter that the LLM provides. This is a new thing
        for the agent to handle.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;4.&lt;/span&gt; HTML-only emails. Some emails have no plain text part at all.
        mailparser can extract textAsHtml which we&amp;#39;d convert to
        markdown. The html-to-text library handles this well, or
        mailparser itself provides a .text property that does basic
        HTML stripping.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;5.&lt;/span&gt; From address identity. For outbound SMTP, the from address must
        match what the SMTP server allows. This is a config value. If
        someone emails bot@mydomain.com, the reply should come from
        that address.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;6.&lt;/span&gt; Forwarded emails for processing. When the owner forwards an
        email, the original sender&amp;#39;s address will be in the forwarded
        body, not in the from field (which will be the owner&amp;#39;s
        address). The LLM will need to understand this contextually —
        no special code needed, the forwarded content will just be in
        the message body. This should work naturally.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;7.&lt;/span&gt; Attachments on outbound. The existing send_*_message tools
        support attachments via file paths. send_email would need the
        same, using nodemailer to attach files. This is
        straightforward.

     ## Proposed design

     Worker simplification:
       // Only send what we need — parse everything from raw
       // server-side
       const payload = {
         from: message.from,
         to: message.to,
         raw: rawEmail
       };
       // Add auth header
       headers: {
         &amp;quot;Authorization&amp;quot;: &amp;quot;Bearer &amp;lt;secret&amp;gt;&amp;quot;,
         &amp;quot;Content-Type&amp;quot;: &amp;quot;application/json&amp;quot;
       }

     Config (config.toml):
       [email]
       smtp_host = &amp;quot;smtp.example.com&amp;quot;
       smtp_port = 465
       smtp_user = &amp;quot;...&amp;quot;
       smtp_password = &amp;quot;...&amp;quot;
       from_address = &amp;quot;bot@mydomain.com&amp;quot;
       webhook_secret = &amp;quot;some-random-secret&amp;quot;

     Inbound flow:
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;POST /email/webhook (public route, validated by webhook_secret
         header)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Parse raw with mailparser → extract subject, text body (with
         HTML fallback → markdown), attachments
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Format message to LLM as: Subject: ...\n\n&amp;lt;body&amp;gt;
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Save attachments via saveAttachment()
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;enqueueMessage(message, &amp;quot;email&amp;quot;, senderEmailAddress,
         attachments)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Add &amp;quot;email&amp;quot; to GATED_SOURCES, add email handling to
         isInAllowlist()

     Outbound flow:
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;New send_email tool with params: to, subject, body
         (markdown), attachments (optional file paths)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Convert markdown body to HTML for the email (with plain text
         fallback)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Send via nodemailer SMTP transport
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Interlocutor identity service: &amp;quot;email&amp;quot;, identifier: email
         address

     New dependencies: mailparser, nodemailer, html-to-text (or we can
     rely on mailparser&amp;#39;s built-in text extraction and skip
     html-to-text initially — we can add it if the quality is
     insufficient).

     Files touched:
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/email.ts — new file: webhook handler, MIME parsing,
         inbound processing
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/email-api.ts — new file: SMTP sending via nodemailer
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/agent.ts — add send_email tool definition
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/config.ts — add EmailConfig interface and load it
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/index.ts — add /email/webhook route, whitelist it in
         isPublicRoute()
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/queue.ts — add &amp;quot;email&amp;quot; to GATED_SOURCES
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/allowlist.ts — add email channel handling
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;config.example.toml — add [email] section

     Explicitly not doing (YAGNI):
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Email threading / In-Reply-To headers (you said treat
         independently)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;CC/BCC handling
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Bounce handling
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Read receipts
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Rate limiting
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Email signature stripping (LLM can handle this contextually)

     Does this look right? Anything you&amp;#39;d change before I write it up
     as tasks?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I reply with a few things that I see it has missed.
This requires me to know the architecture well, and following this process keeps me up to date with all the changes at a moderately low level.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Make sure to update the README.md and config.example.toml as
     well. Also remember to change the email allowlist page to not
     validate E.164 for these
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The LLM updates the plan and asks any final questions.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Good, now I have the full picture. Here&amp;#39;s the updated agreement:

     ## Requirements

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;1.&lt;/span&gt; Inbound email via Cloudflare Email Worker webhook → POST
        /email/webhook (public, authenticated by shared secret)
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;2.&lt;/span&gt; Outbound email via SMTP (nodemailer) through a send_email tool
        (with to, subject, body, optional attachments)
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;3.&lt;/span&gt; Gated channel — email added to GATED_SOURCES, allowlist, and
        interlocutor identity system (service: &amp;quot;email&amp;quot;, identifier:
        email address)
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;4.&lt;/span&gt; Allowlist UI — new &amp;quot;Email allowlist&amp;quot; section in the settings
        page; validates email format (not E.164)
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;5.&lt;/span&gt; Allowlist data — Allowlist interface gains an email: string[]
        field; backend validation accepts email addresses
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;6.&lt;/span&gt; Owner config — owner.email in config.toml, auto-seeded into
        allowlist like the other channels
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;7.&lt;/span&gt; MIME parsing — use mailparser to extract subject, text body
        (HTML fallback), and attachments from the raw RFC 2822 message
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;8.&lt;/span&gt; Message format to LLM — Subject: &amp;lt;subject&amp;gt;\n\n&amp;lt;body&amp;gt; so the
        agent sees what the email is about
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;9.&lt;/span&gt; Config — new [email] section: smtp_host, smtp_port, smtp_user,
        smtp_password, from_address, webhook_secret
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;10.&lt;/span&gt; Update config.example.toml, README.md

     ## Constraints

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;In-process (no new container)
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Email is optional — if [email] is absent from config, no email
       functionality loads (like the other optional channels)
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Follow existing patterns exactly (webhook handling like
       Telegram, allowlist like WhatsApp/Signal, tool definition like
       other send tools)

     ## Success criteria

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Can receive an email via webhook, have it processed by the LLM,
       and the LLM can reply via send_email
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Allowlist UI works for email addresses
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Owner email auto-seeded into allowlist
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Builds and passes existing tests; new tests for the webhook
       handler and allowlist validation

     ## Non-goals / out of scope

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Email threading (In-Reply-To / References headers)
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;CC/BCC handling
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Bounce/delivery-status handling
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Read receipts
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Email signature stripping
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Rate limiting
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Markdown-to-HTML conversion for outbound body (plain text is
       fine for now — we can add this later)

     One question before I write up tasks: for outbound, should the
     body be sent as plain text only, or do you want HTML emails
     (converting the LLM&amp;#39;s markdown to HTML)? Plain text is simpler
     and works everywhere, but HTML looks nicer. I&amp;#39;d lean toward plain
     text for now and adding HTML later if desired.

     Also — the worker payload. I&amp;#39;d simplify it to just { from, to,
     raw } and parse everything server-side from raw. That way you
     don&amp;#39;t need to maintain parsing logic in the worker. Sound good?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Sometimes I&amp;#8217;ll remember something and tell the LLM before reading its message:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Add instructions to the config.example.toml for setting this up,
     including the actual Cloudflare worker to use
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It adapts by incorporating my concern and repeating its message.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Got it — the config.example.toml will include the worker code and
     setup instructions in comments. Let me update the plan with that
     and present the full task breakdown.

     Regarding my two open questions that you didn&amp;#39;t address:

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;1.&lt;/span&gt; Outbound body format — I&amp;#39;ll go with plain text for now (YAGNI).
        We can add markdown→HTML conversion later.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;2.&lt;/span&gt; Worker payload — I&amp;#39;ll simplify to { from, to, raw } with an
        auth header, and parse everything from raw server-side.

     ## Proposed plan directory

     misc/coding-team/email-support/

     ## Task breakdown

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;1.&lt;/span&gt; Config and dependencies — Add EmailConfig interface to
        config.ts, [email] section to config.example.toml (with
        Cloudflare worker setup instructions), install mailparser and
        nodemailer (plus their &lt;span class=&#34;ni&#34;&gt;@types/&lt;/span&gt; packages). Add owner.email to
        OwnerConfig.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;2.&lt;/span&gt; Allowlist — Add email: string[] to the Allowlist interface,
        update validateAllowlist, loadAllowlist (owner email
        auto-seed), getAllowlist, isInAllowlist,
        getOwnerIdentities. Add &amp;quot;email&amp;quot; to GATED_SOURCES in queue.ts.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;3.&lt;/span&gt; Allowlist UI and backend validation — Add the &amp;quot;Email allowlist&amp;quot;
        section to the settings page HTML/JS (with email format
        validation instead of E.164), update handlePutAllowlistRequest
        to validate and accept the email array, update saveAllowlist
        serialization (including notes key pruning and owner identity
        merging).

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;4.&lt;/span&gt; Inbound email — New src/email.ts: webhook handler that
        validates the secret, parses the raw email with mailparser,
        extracts subject + text body + attachments, saves attachments,
        and calls enqueueMessage. Add the /email/webhook route to
        index.ts and whitelist it in isPublicRoute.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;5.&lt;/span&gt; Outbound email — New src/email-api.ts: nodemailer SMTP
        transport setup and send function. Add send_email tool to
        agent.ts (params: recipient, subject, body, optional
        attachments), following the same pattern as the other send
        tools (interlocutor lookup, allowlist check).

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;6.&lt;/span&gt; README and tests — Update README.md to document email support.
        Add tests for the webhook handler validation and allowlist
        email validation in the existing test files.

     Does this look right? Say &amp;quot;approved&amp;quot; to proceed.
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I do go back and read its message, as it usually has good questions and I don&amp;#8217;t want to miss answering any of them.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Regarding your questions, 1. Plain text. 2. Yes, simplify as much
     as possible
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The LLM needs me to explicitly say the word &amp;#8220;approved&amp;#8221; to proceed.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Noted. The plan is as stated above. Say &amp;quot;approved&amp;quot; to proceed.
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Approved.
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I remember something while the LLM is working and stop it to ask:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  By the way, what will an email look like to the bot? Will it be
     able to see the subject as well as the body?
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Yes — the plan is to format the inbound message to the LLM as:

       Subject: Hello!

       Hi, this is the body of the email.

     So the subject is visible as a prefix, separated from the body by
     a blank line. This mirrors how you&amp;#39;d naturally read an email and
     gives the LLM enough context to understand what the email is about
     (especially useful for forwarded emails where the subject line
     carries meaning).

     For outbound, the send_email tool has a subject parameter, so the
     LLM will compose a subject line when replying.
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Excellent, please continue
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The LLM writes the plan, calls the developer, the reviewers, and eventually finishes.
I&amp;#8217;ve omitted all the background tool/agent calls here for brevity.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: All 6 tasks are implemented. Here&amp;#39;s what was done:

     New files:
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/email.ts — inbound webhook handler (validates Bearer
         token auth, parses raw RFC 2822 email with mailparser,
         extracts subject/body/attachments, checks allowlist, enqueues
         message)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/email-api.ts — outbound SMTP transport (nodemailer, plain
         text emails with optional attachments)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/email.test.ts — webhook auth tests

     Modified files:
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/config.ts — EmailConfig interface, email field on Config
         and OwnerConfig
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/allowlist.ts — email: string[] on Allowlist,
         case-insensitive matching, owner seeding
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/allowlist.test.ts — 12 new email allowlist tests
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/settings.ts — email allowlist UI section + backend
         validation (email format, not E.164)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/settings.test.ts — 9 new email backend validation tests
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/queue.ts — &amp;quot;email&amp;quot; added to GATED_SOURCES
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/agent.ts — createSendEmailTool (recipient resolution,
         allowlist gate, SMTP send)
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/agent-send-tools.test.ts — email send tool tests
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;src/index.ts — /email/webhook route + isPublicRoute entry +
         initializeEmailTransport at startup
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;config.example.toml — full [email] section with setup
         instructions and Cloudflare Worker code
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;README.md — email setup section, updated feature list and
         architecture blurb
&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;package.json / package-lock.json — mailparser, nodemailer,
         and their type packages

     Test results: 421 tests passing, TypeScript type-check clean.

     Worker change needed on your side: Update your Cloudflare Worker
     to the simplified version in config.example.toml — sends { from,
     to, raw } with Authorization: Bearer &amp;lt;secret&amp;gt; header, and point
     it at https://&amp;lt;publicHostname&amp;gt;/email/webhook.

     What would you like to do next?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I have an idea for improving the UX by allowing the bot to read incoming emails without configuring an outgoing SMTP server, in case the user wants to forward things like invoices or trip plans for it to read, but doesn&amp;#8217;t want the bot to be able to send email.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  What do we check here, regarding the email parameters? I&amp;#39;m
     wondering whether we can have inbound emails working even if the
     user doesn&amp;#39;t configure outbound emails. How hard would that be to
     implement?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The LLM scopes out effort.
If it&amp;#8217;s a small change, I&amp;#8217;ll usually do it as part of this session, otherwise I&amp;#8217;ll write it to a GitHub issue for me to work on at a later time.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Here&amp;#39;s the current situation:

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;EmailConfig requires all 6 fields (smtp host/port/user/password,
       fromAddress, webhookSecret) — they&amp;#39;re all non-optional
       strings/numbers.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;config.email gates everything: the webhook route, the SMTP
       transport initialization, and the send_email tool.

     So right now, if you only want inbound email (webhook), you&amp;#39;d
     still have to fill in all the SMTP fields with dummy values, which
     is ugly.

     The fix is straightforward — make the SMTP fields optional in
     EmailConfig. Then:

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;Webhook route: only needs webhookSecret → enabled whenever
       config.email exists.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;SMTP transport init: only runs when the SMTP fields are present.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;send_email tool: only registered when the SMTP fields are
       present.

     The change is small: make smtpHost, smtpPort, smtpUser,
     smtpPassword, and fromAddress optional in the interface, gate
     initializeEmailTransport and createSendEmailTool on their
     presence, and the webhook handler keeps working with just
     webhookSecret. Want me to do this?
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Yes please
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;More tool/agent calls elided here.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Done. Here&amp;#39;s what changed:

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;EmailConfig.webhookSecret is the only required field now. The 5
       SMTP fields (smtpHost, smtpPort, smtpUser, smtpPassword,
       fromAddress) are all optional.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;The webhook route works with just webhookSecret — inbound email
       is fully functional without any SMTP config.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;The SMTP transport and send_email tool only initialize when
       smtpHost is present.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;config.example.toml now clearly groups and labels the required
       vs optional fields.

     All 421 tests pass, type-check clean. What would you like to do
     next?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I QA the feature and come back with issues:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  I&amp;#39;ve sent an email, having specified owner.email, but the agent
     dropped it:

     [stavrobot] Dropping message: could not resolve target agent.
     source=email, sender=me@mydomain.com

     Also, the &amp;quot;to&amp;quot; seems truncated
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Found it. Issue 1 (dropped message): seedOwnerInterlocutor
     builds the ownerIdentitySet from Signal, Telegram, and WhatsApp
     identities, but never adds email. So
     isOwnerIdentity(&amp;quot;email&amp;quot;, &amp;quot;me@mydomain.com&amp;quot;) returns false, the
     owner bypass is skipped, and the message falls through to the
     allowlist check and interlocutor lookup — which probably also
     fails because there&amp;#39;s no interlocutor record with an email
     identity yet.

     The &amp;quot;To&amp;quot; truncation in the logs — that&amp;#39;s coming from the message
     format Subject: ...\n\nFrom: ...\nTo: .... That looks like the
     email body content being logged (the preview on line 181 of
     queue.ts truncates to 200 chars). The To: is from the email
     headers included in the body text by mailparser. The truncation is
     just the 200-char log preview cutting off — not an actual data
     issue. The full message is intact.

     So there&amp;#39;s really just one bug: seedOwnerInterlocutor doesn&amp;#39;t
     register owner.email as an owner identity.
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The LLM goes off and fixes the problem.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Fixed. The bug was that seedOwnerInterlocutor in database.ts
     built the owner identity set from Signal, Telegram, and WhatsApp
     but never added email. So
     isOwnerIdentity(&amp;quot;email&amp;quot;, &amp;quot;me@mydomain.com&amp;quot;) returned false, the
     owner bypass was skipped, and the message was dropped because
     there was no interlocutor record to route to either.

     The &amp;quot;To&amp;quot; truncation in the logs is just the 200-character message
     preview in queue.ts cutting off — the actual message data is
     intact.

     After rebuilding, your owner emails should route to the main agent
     correctly. Want to test it?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here I spot that it&amp;#8217;s missed a better way of implementing the feature, and I suggest that it changes its implementation:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  We have a list of sources, right? Why is that list not used for
     this, and we&amp;#39;ve hardcoded it instead?
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: You&amp;#39;re right. The owner identity seeding in
     seedOwnerInterlocutor has a separate hardcoded if block for each
     channel — Signal, Telegram, WhatsApp, and now email. Meanwhile
     OwnerConfig already has the channel names as field keys. Every
     time a new channel is added, someone has to remember to add
     another if block there, which is exactly how this bug happened.

     The fix would be to iterate over a shared list of channel names
     (or just iterate the OwnerConfig fields dynamically) instead of
     having per-channel if blocks. The same pattern applies to
     getOwnerIdentities in allowlist.ts, which also has per-channel
     blocks.

     However — OwnerConfig has a name field that isn&amp;#39;t a channel, and
     Telegram&amp;#39;s identifier needs special handling (it&amp;#39;s a numeric
     string converted to a number for the allowlist). So a fully
     generic loop isn&amp;#39;t completely trivial, but it&amp;#39;s still better than
     the current approach.

     The simplest version: define a list of channel keys (e.g.
     [&amp;quot;signal&amp;quot;, &amp;quot;telegram&amp;quot;, &amp;quot;whatsapp&amp;quot;, &amp;quot;email&amp;quot;]) in one place, and
     have seedOwnerInterlocutor loop over it. Want me to do this
     cleanup, or would you rather keep moving and revisit it later?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I have second thoughts about making the check generic, because of the special case.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  The special cases give me pause. The name is probably fine, as we
     can ignore, that, but the Telegram conversion is more special.
     What do you think?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The LLM thinks about it a bit and recommends something reasonable.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: The Telegram numeric conversion only matters in the allowlist
     (isInAllowlist compares numbers, getOwnerIdentities returns
     number[] for Telegram). But in seedOwnerInterlocutor, all
     identities are stored as strings in interlocutor_identities —
     even Telegram. So the seeding loop doesn&amp;#39;t actually need any
     special cases. It&amp;#39;s just:

       for (const channel of CHANNELS) {
         const value = ownerConfig[channel];
         if (value !== undefined) {
           identities.push({
             service: channel,
             identifier: channel === &amp;quot;email&amp;quot;
               ? value.toLowerCase()
               : value
           });
         }
       }

     ...though that introduces a special case for email lowercasing.
     Which is arguably also wrong — we should probably lowercase email
     everywhere it enters the system (allowlist seeding, inbound
     webhook, etc.) rather than in the identity loop.

     The more I think about it, the more I think the cleanup is worth
     doing for seedOwnerInterlocutor (where everything is strings
     anyway), but getOwnerIdentities in allowlist.ts has genuinely
     different return types per channel (string[] vs number[]), so
     forcing that into a generic loop would make it worse, not better.

     My recommendation: make the channel list a shared constant, use it
     in seedOwnerInterlocutor to loop, but leave getOwnerIdentities
     as-is since its per-channel type differences are inherent. That
     way the &amp;quot;forgot to add a new channel&amp;quot; bug class is eliminated for
     the seeding path, which is the one that just bit us.
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Ok, approved
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Done. The four per-channel if blocks are replaced with a loop
     over OWNER_CHANNELS defined in config.ts. Next time a channel is
     added, it just needs to be appended to that array. All 421 tests
     pass.

     Want to rebuild and test the email flow again?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I QA and check again.
I realize that emails work slightly differently than phone numbers, and that the bot now ignores my custom email addresses.
I talk to the LLM about adding this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  There is one wrinkle: Email whitelists are special, as someone
     might control an entire domain. Can we add asterisks to the email
     whitelist? For example, I&amp;#39;d like my (owner) email address to be
     &amp;quot;*@example.com&amp;quot;, rather than just &amp;quot;me@example.com&amp;quot;
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Makes sense — domain-level wildcards are a natural fit for email
     that doesn&amp;#39;t exist for phone-number-based channels.

     The allowlist matching is straightforward: entries like
     *@example.com match any sender whose domain matches. The changes
     would be in isInAllowlist (matching logic), the settings UI and
     backend validation (accept the *@domain pattern), and tests.

     One question: should owner.email also support *@example.com? I&amp;#39;d
     lean no — the owner is a specific person, and the owner identity
     is used for exact-match routing (bypassing the interlocutor
     lookup). If owner.email is *@example.com, then any email from
     that domain would be treated as the owner, which seems wrong.
     You&amp;#39;d probably want owner.email = &amp;quot;me@example.com&amp;quot; for routing,
     and *@example.com in the allowlist for letting others through.

     Does that distinction make sense, or do you actually want the
     owner identity to be domain-wide too?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The LLM misunderstood what I wanted, so I clarify with a concrete use case:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  No, the whole issue is that I use disposable email addresses
     &amp;quot;company@mydomain.com&amp;quot;, and forwarding those emails means that
     the email comes from the disposable email I used, so the bot
     drops them. I can&amp;#39;t easily send the emails from my main address
     without changing it on every single email, so I&amp;#39;d rather the bot
     treated the entire domain as one person (if the wildcard is set
     that way). What is the issue with this?
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: The issue I was worried about is that isOwnerIdentity does an
     exact string match —
     ownerIdentitySet.has(&amp;quot;email:me@mydomain.com&amp;quot;). If owner.email is
     *@mydomain.com, the set would contain
     &amp;quot;email:*@mydomain.com&amp;quot;, and an incoming email from
     company@mydomain.com wouldn&amp;#39;t match.

     But the fix is simple: make isOwnerIdentity understand domain
     wildcards for email, same as isInAllowlist. It&amp;#39;s a few extra
     lines in one function. No real downside — if you control the
     domain, you are the owner regardless of which address you used.

     So the full scope is:

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;1.&lt;/span&gt; isInAllowlist — email case checks for *@domain entries by
        comparing the sender&amp;#39;s domain.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;2.&lt;/span&gt; isOwnerIdentity — same domain-wildcard matching for email
        entries in the owner identity set.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;3.&lt;/span&gt; Settings UI + backend validation — accept *@domain.com as a
        valid email allowlist entry.
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;4.&lt;/span&gt; Tests for the new matching behavior.

     Want me to go ahead?
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I ask it to make sure it takes care around a caveat.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Please make sure that the local part (the wildcard) does not match
     the @ sign. I wouldn&amp;#39;t want someone getting smart with validations
     and sending email from something like
     &amp;quot;me@mydomain.com&amp;quot;@evildomain.com and bypassing my owner check
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Good catch. The matching logic will split on the last @ (or
     simply check that the sender&amp;#39;s domain portion after @ matches
     exactly, and that the sender address contains exactly one @). That
     way &amp;quot;me@mydomain.com&amp;quot;@evildomain.com doesn&amp;#39;t match
     *@mydomain.com.
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And ask for some documentation changes.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Please also clarify in the config.example.toml that the local
     part can contain an asterisk
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I have another usability idea:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  We should do proper matching, in case someone wants to match
     things like myusername+*@gmail.com, not just ignore the entire
     local part. Hopefully nobody&amp;#39;s local part will contain a
     legitimate asterisk
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And a security consideration:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  If you use a regex, you need to escape every single other char in
     the email address
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Done. The email allowlist now supports glob-style * wildcards:

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;*@mydomain.com — matches any address at the domain
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;myuser+*@gmail.com — matches any plus-tag for that user
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;exact@example.com — exact match only
&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;* — allow everyone

     The * is translated to [^@]* in the regex, so it cannot match
     across the @ boundary — &amp;quot;me@good.com&amp;quot;@evil.com won&amp;#39;t match
     *@good.com. This works in both the allowlist and the owner
     identity check.
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I ask for a clarification to catch a potential gotcha:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;Me:  Does this work both in the owner&amp;#39;s &lt;span class=&#34;sb&#34;&gt;`email = `&lt;/span&gt; field and the
     allowlist page/the allowlist itself?
&lt;/pre&gt;&lt;/div&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LLM: Yes, both:

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;1.&lt;/span&gt; owner.email in config.toml — the value is stored in
        ownerEmailEntries and matched via matchesEmailEntry in
        isOwnerIdentity. So owner.email = &amp;quot;*@mydomain.com&amp;quot; treats any
        address from that domain as the owner.

&lt;span class=&#34;w&#34;&gt;     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;2.&lt;/span&gt; Allowlist (data layer + settings UI) — isInAllowlist for email
        uses matchesEmailEntry against every entry in the allowlist. So
        adding *@example.com via the settings page allows anyone from
        that domain.

     Both paths use the same matchesEmailEntry helper, so the matching
     behavior is identical.
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The session continued for a bit with me doing more QA rounds, adding wildcard matching for email addresses, a question about SQLi, and catching a missing entry in the subagent allowlist.
The conversation went more or less as above, with me either catching an error or proposing an improvement, refining it with the LLM, and implementing it.&lt;/p&gt;
&lt;p&gt;The whole feature took about an hour, start to finish, and I ended the session there as I was satisfied that the feature works well.&lt;/p&gt;
&lt;h2&gt;Epilogue&lt;/h2&gt;
&lt;p&gt;That&amp;#8217;s the basic overview of my setup.
It&amp;#8217;s nothing extremely fancy, but it works very well for me, and I&amp;#8217;ve been really pleased with the reliability of the whole process.
I&amp;#8217;ve been running Stavrobot 24/7 for close to a month now, and it&amp;#8217;s been extremely reliable.&lt;/p&gt;
&lt;p&gt;If you have any feedback or just want to chat, get me on &lt;a href=&#34;https://bsky.app/profile/stavros.io&#34;&gt;Bluesky&lt;/a&gt;, or email me directly.&lt;/p&gt;

            </description>
            <pubDate>Tue, 10 Mar 2026 11:58:51 +0000</pubDate>
        </item>
        
        <item>
            <title>I made a voice note taker</title>
            <link>https://www.stavros.io/posts/i-made-a-voice-note-taker/</link>
            <guid>https://www.stavros.io/posts/i-made-a-voice-note-taker/</guid>
            <description>
                &lt;div class=&#34;pull-quote&#34;&gt;It&#39;s small and tiny and so cute&lt;/div&gt;&lt;p&gt;Have you ever always wanted a very very small voice note recorder that would fit in your pocket? Something that would always work, and always be available to take a note at the touch of a button, with no fuss? Me neither.&lt;/p&gt;
&lt;p&gt;Until, that is, I saw the &lt;a href=&#34;https://repebble.com/index&#34;&gt;Pebble Index 01&lt;/a&gt;, then I absolutely needed it right away and had to have it in my life immediately, but alas, it is not available, plus it&amp;#8217;s disposable, and I don&amp;#8217;t like creating e-waste. What was a poor maker like me supposed to do when struck down so cruelly by the vicissitudes of fate?&lt;/p&gt;
&lt;p&gt;There was only one thing I &lt;em&gt;could&lt;/em&gt; do:&lt;/p&gt;
&lt;p&gt;I could build my own, shitty version of it for $8, and that&amp;#8217;s exactly what I did.&lt;/p&gt;
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Like everyone else, I have some sort of undiagnosed ADHD, which manifests itself as my brain itching for a specific task, and the itch becoming unbearable unless I scratch it.
This usually results in me getting my &lt;!-- break --&gt;phone out, no matter where I am or who I&amp;#8217;m with, and either noting stuff down or doing the task, which some people perceive as rude, for inexplicable reasons that are almost certainly their fault.
Because, however, it has proved easier to just not get my phone out in polite company than convince everyone of how wrong they are, I just do the former now, but that makes the itch remain.&lt;/p&gt;
&lt;p&gt;Also, sometimes I&amp;#8217;m just in the middle of something, and an idea pops into my head for later pursuit, but I get distracted by a squirrel, a car going by, or the disturbing trend of the constant and persistent erosion of civil rights all over the world, and I forget the idea.&lt;/p&gt;
&lt;p&gt;The Pebble Index showed me that there&amp;#8217;s a better way, a device that&amp;#8217;s unobtrusive, available, and reliable enough that I could just press a button, speak into it, and know for sure that my sonorous voice would reach the bowels of my phone, where it would be stored safely until I was bored and wanted something to do.&lt;/p&gt;
&lt;p&gt;I didn&amp;#8217;t want to have to get my phone out, unlock it, open a voice recorder app, hold down a button, speak, wonder if it heard me, look at the button, realize I had already pressed it, press it again, say the thing again, press it again to stop, exit the app, lock my phone, and put it back into my pocket.&lt;/p&gt;
&lt;p&gt;I wanted to take a thing out, press a button, speak, release the button, done.&lt;/p&gt;
&lt;h2&gt;The solution&lt;/h2&gt;
&lt;p&gt;The initial thinking was that I&amp;#8217;d use a microcontroller (an ESP32 is my microcontroller of choice these days), a microphone, and a lithium battery, and that&amp;#8217;s basically all the hardware this needs!
Most of the heavy lifting would need to be done in software.
This would need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A way for the device to record audio onto some sort of persistent storage, for the case where you didn&amp;#8217;t have your phone close to you.&lt;/li&gt;
&lt;li&gt;A way for the device to sleep, consuming almost no power, until it was woken up by the button.&lt;/li&gt;
&lt;li&gt;A way to transfer the files from the device to the phone, for later listening.&lt;/li&gt;
&lt;li&gt;A battery indicator would be very nice, so I knew when to recharge it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Luckily, I know enough about electronics to know that LLMs would definitely know how to build something like that.&lt;/p&gt;
&lt;h2&gt;The hardware&lt;/h2&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;mic.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;mic-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The microphone and its board,&lt;br&gt;ill befitting a microdevice.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;Indeed, Claude confirmed my suspicions by saying that all I need is a microphone and an ESP32.
It recommended &lt;span data-expounder=&#34;esp32&#34;&gt;an ESP32-C6 but I went with an ESP32-S3&lt;/span&gt;, as it had an onboard charge controller and would be able to charge a lithium battery from USB, which is very handy when you&amp;#8217;re making a thing that runs on battery.
&lt;span data-expounded=&#34;esp32&#34;&gt;The ESP32 is a microcontroller, a little computer that&amp;#8217;s just really small.
The main difference of the S3 from the C6 is that the S3 is more capable, and has more power.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;I keep an assortment of random components around, so I had an ESP32-S3 board.
It&amp;#8217;s a no-name, crappy one from AliExpress, not a good, Seeed-branded one from AliExpress, but it would have to do.&lt;/p&gt;
&lt;p&gt;Unfortunately, I didn&amp;#8217;t have a MEMS microphone (which is basically an angelic grain of rice that can hear, with excellent quality), but I did have an electret mic, which is huge and bad quality and would sound like an old-timey radio, but it was there and it was ready and it was willing, and after a few beers it seemed like it was right, or at least right for right now.&lt;/p&gt;
&lt;p&gt;I also had a very thin LiPo battery, which would suit very well.
For the final device I&amp;#8217;d want a battery that&amp;#8217;s a tiny bit shorter, as this one was around 40% longer than the ESP32, but it would do great for now.&lt;/p&gt;
&lt;h3&gt;Hooking the stuff together&lt;/h3&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;underside.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;underside-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;All the components hooked up together.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;I quickly soldered everything together and recorded some audio.
It worked!
It worked and nobody was going to take that from me, even though it was crackly and the quality wasn&amp;#8217;t great.&lt;/p&gt;
&lt;p&gt;Unfortunately, at this stage I realized that the analog electret microphone consumes too much energy, even when sleeping, which is terrible on a device that would spend more time sleeping than the beauty from that fairytale, Sleepy the Dwarf.
To counteract that, I decided to use &lt;span data-expounder=&#34;mosfet&#34;&gt;a MOSFET&lt;/span&gt; to cut power to the mic when the device was asleep.
&lt;span data-expounded=&#34;mosfet&#34;&gt;A MOSFET is a little switch that you can turn on and off from a microcontroller, basically.&lt;/span&gt;&lt;/p&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;first.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;first-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;Box sizing attempts.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;Full disclosure here, before using the MOSFET to turn the mic on and off, I went down a multi-hour rabbit hole trying to design a latching circuit that would allow the ESP32 to turn itself off and consume almost no power.
Instead, it consumed a &lt;em&gt;lot&lt;/em&gt; of my time, without anything to show for it, because I didn&amp;#8217;t manage to make it work at all.&lt;/p&gt;
&lt;p&gt;The MOSFET for the mic worked fairly well, though, and the device didn&amp;#8217;t consume much power when asleep.
The real gains, however, were going to be had when the MEMS microphone I ordered arrived, as those use infinitesimal amounts of current when asleep, and have much better sound quality as well, as they are digital.&lt;/p&gt;
&lt;h3&gt;Problems&lt;/h3&gt;
&lt;p&gt;The analog microphone crackled and popped and took a while to stabilize after boot, which was unfortunate because I wanted the device to be ready as soon as the user pressed the button.
There was also a recording bug where the recording was missing a few milliseconds of audio every so often, which led to dropped phonemes and words sometimes sounding like other words because parts of them were dropped.&lt;/p&gt;
&lt;p&gt;All these problems were weird enough and hard enough to debug that I resolved to just wait for my digital MEMS microphone to arrive, which would solve them in one fell swoop, as it is digital and amazing.&lt;/p&gt;
&lt;h2&gt;The curious case of the listening device&lt;/h2&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;first-up.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;first-up-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;All boxed up.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;After the relatively easy part of connecting a few wires together, now came the hard part:
Designing a case for the whole thing that would fit without leaving much empty space, to make the device as small as possible.
This was very hard to do with this massive microphone that was as tall as everything else (including battery) combined.&lt;/p&gt;
&lt;p&gt;I initially tried to point the microphone downward while mounting it at the top, so it would take up the least amount of vertical space possible, but the PCB made that hard, as the microphone was soldered to it.
I ended up desoldering the mic from the PCB, trimming the PCB to make it shorter, and connecting the mic to it with wires.
That allowed me to make the case (and thus the device) smaller, but at what cost?
Nothing, turns out, because it worked great.&lt;/p&gt;
&lt;h2&gt;The Bluetooth script&lt;/h2&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;first-down.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;first-down-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The other side has a certain... je ne sais quoi.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;The device was working great, but I didn&amp;#8217;t want it tethered to my computer, I wanted to be able to take it out and about and show it the wonders of the world.
To do this, I needed Bluetooth.
Unfortunately, I have exactly zero idea how Bluetooth works, and would need to spend days or weeks figuring stuff out, but, luckily for me, I had a Claude subscription.&lt;/p&gt;
&lt;p&gt;It took a bit of back-and-forth, but I did manage to end up with a Python script that would connect to the pendant, download the audio files, and convert them from ADPCM to MP3, for expanded compatibility.
To maximize battery life, the way things worked was:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You pressed the button.&lt;/li&gt;
&lt;li&gt;If you held it down for more than half a second, the recording would &amp;#8220;count&amp;#8221;.&lt;/li&gt;
&lt;li&gt;If there was a recording made (i.e. if you held the button down long enough), it would be saved.&lt;/li&gt;
&lt;li&gt;Bluetooth would turn on and look for a phone or computer that&amp;#8217;s ready to receive.&lt;/li&gt;
&lt;li&gt;The device would send the file and go to sleep again.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This worked really well, the device was awake for a small amount of time (10 seconds), but it could be awoken at any time just by tapping the button.
At that point, it would transfer to the PC any files that were on the pendant, and go back to sleep.&lt;/p&gt;
&lt;p&gt;One downside was that transfers would take an inordinate amount of time, sometimes reaching 2 minutes for a 10-second clip.
OpenAI&amp;#8217;s Codex was really helpful here, finding a solution for fast BLE transfers that made sending files 100x faster than it was before.&lt;/p&gt;
&lt;h2&gt;The new microphone&lt;/h2&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;second.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;second-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;Look how much smaller a MEMS mic is.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;Because I&amp;#8217;m too impatient to wait for the slow boat from China, I ordered the same microphone locally.
I had to pay an arm and a leg in shipping and impatience fees, but it was worth it, because I finally had a MEMS mic!
It&amp;#8217;s so cute and tiny, I immediately found a spot for it over the board, added the switch, added a voltage divider for sensing battery voltage, and that was it!&lt;/p&gt;
&lt;p&gt;The new mic sounds fantastic, it sounds better than recording with your phone, for some odd reason that I&amp;#8217;m sure is all in my head.
What&amp;#8217;s more, it doesn&amp;#8217;t have the weird bugs that plagued me with the analog mic.&lt;/p&gt;
&lt;h2&gt;The second-gen case&lt;/h2&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;small.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;small-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;It&#39;s so tiny and cute omg!&lt;/span&gt;&lt;/div&gt;&lt;p&gt;With this smaller mic, I could now design a better case.
I designed the case you see on the right, which is the second generation.
There will be a third, when I receive the shorter battery, which means I will have a choice of either making the device longer but half as thick, or around 40% shorter.
I think I will go for longer but thinner, I&amp;#8217;d quite prefer to have a thin device in my pocket, even if it&amp;#8217;s long, than a stubby one that pokes out.&lt;/p&gt;
&lt;p&gt;Still, the new battery (and the new case) will mark the completion of this project and make me a very happy man.
For the second-gen case, I decided to jazz it up and add a red stripe around it, because it was easy to do and because I think it looks good.
Unfortunately, the feature I wanted most (fillets, i.e. rounded corners) wasn&amp;#8217;t possible due to the lack of empty space inside the case.
I hope the final device will have some more space for fillets, at least.&lt;/p&gt;
&lt;h2&gt;The Android app&lt;/h2&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;app.png&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;app-small.png&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The Android app.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;Once I was done with the device, it was time to make it more ergonomic:
I&amp;#8217;d need to create an Android app so I wouldn&amp;#8217;t have to wait to get to my PC.
I also knew I wanted note transcription, as it&amp;#8217;s really useful to be able to see what you said without having to listen to the audio again.&lt;/p&gt;
&lt;p&gt;Unfortunately again, I have no idea about Android development, only having written a small app years ago.
Fortunately, though, Claude turned out to be pretty good at it, and one-shotted this app that you see here.&lt;/p&gt;
&lt;p&gt;For the transcription, I used GPT-4o Transcribe, which is great and understands both English and Greek, languages I fail to speak in equal measure.
I have to say, it&amp;#8217;s pretty magical to speak into a little box and to see the audio already captured and transcribed on your phone.&lt;/p&gt;
&lt;p&gt;With the Android app, I could now test the device in real-world use.
One thing I noticed is that battery dies way too fast.
I suspect that has something to do with the cheap board, so I&amp;#8217;ve ordered an original Seeed Xiao board, and I hope that will fix the problem once and for all, as they advertise low power usage and they&amp;#8217;re a trustworthy brand.&lt;/p&gt;
&lt;p&gt;I also added a &amp;#8220;webhook&amp;#8221; convenience function to the Android app, so that the latter would be able to send the transcription to a server for further processing.&lt;/p&gt;
&lt;p&gt;The device is extremely reliable, which makes me a lot more likely to use it.
I know that, if I press the button, the audio will be recorded and stored, and nothing will happen to it, which makes for a very relaxed and calming experience.&lt;/p&gt;
&lt;p&gt;Before I continue, I want to say you can find all the files in this project (firmware, Android app, whatever else) in its GitHub repository:&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/skorokithakis/middle&#34;&gt;https://github.com/skorokithakis/middle&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That&amp;#8217;s right, I called it Middle, because it was the next thing after the Index.
I know it&amp;#8217;s a silly name, I don&amp;#8217;t care, don&amp;#8217;t use it, I&amp;#8217;m not changing it.&lt;/p&gt;
&lt;h2&gt;The AI agent&lt;/h2&gt;
&lt;p&gt;In the &amp;#8220;draw the rest of the fucking owl&amp;#8221; portion of this article, I realized I didn&amp;#8217;t want the notes to just go to my phone when LLMs exist.
I wanted an LLM to take the notes and do something with them, so I spent a few weeks writing an AI agent that&amp;#8217;s more useful than what currently exists.
The device&amp;#8217;s Android app sends the transcribed text to this AI, which processes it.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;m going to write another post about this, but basically, I wanted an AI personal assistant that could help with all the little chores in my life.
AI assistants are interesting because they&amp;#8217;re:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Very open-ended tools, and&lt;/li&gt;
&lt;li&gt;Highly personal.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This means that, when everyone inevitably asks &amp;#8220;what is it good for&amp;#8221;, I can&amp;#8217;t really give a good answer, because the answer is &amp;#8220;it takes care of all the little annoyances for me&amp;#8221;, but nobody has the same annoyances and can&amp;#8217;t really imagine what the bot does, so they don&amp;#8217;t engage with it.&lt;/p&gt;
&lt;p&gt;The amazing thing for AI assistants for me is the fact that they can string together multiple (otherwise small) tools to do something that&amp;#8217;s more valuable than the sum of its parts.
For example, I asked the agent to give me a daily briefing every morning, consisting of my todos for the day, my calendar events, whether any refund has hit my bank, and whether any packages are due to be delivered today.&lt;/p&gt;
&lt;p&gt;The agent also checks my gym bookings and asks me every morning if I do plan to go, or if I intend to cancel.
If I tell it to cancel, it does, but if I say I&amp;#8217;ll go, it sets an alarm for a few minutes before, which I&amp;#8217;m much more likely to see than my calendar&amp;#8217;s one.
It will also (entirely of its own volition) mention things like &amp;#8220;you have a gym booking today 7-8pm but you have a restaurant booking at 9pm and it&amp;#8217;ll take you more than an hour to shower and make it&amp;#8221;, which a regular calendar wouldn&amp;#8217;t be able to figure out.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;ve made it fantastically secure, everything is sandboxed and you can run it on your laptop without fear.
I use it constantly throughout the day for many little things, and the integration with the device takes the whole setup to another level.&lt;/p&gt;
&lt;p&gt;You can find the bot here:&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/skorokithakis/stavrobot&#34;&gt;https://github.com/skorokithakis/stavrobot&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Do let me know if you try it, it&amp;#8217;s like OpenClaw but won&amp;#8217;t steal your data and eat your firstborn.&lt;/p&gt;
&lt;h2&gt;Epilogue&lt;/h2&gt;
&lt;p&gt;If you have any ideas, feedback, flamebait, or whatever, you can &lt;a href=&#34;https://twitter.com/intent/user?screen_name=Stavros&#34;&gt;Tweet&lt;/a&gt; or &lt;a href=&#34;https://bsky.app/profile/stavros.io&#34;&gt;Bluesky&lt;/a&gt; me, or email me directly.&lt;/p&gt;

            </description>
            <pubDate>Fri, 27 Feb 2026 02:40:55 +0000</pubDate>
        </item>
        
        <item>
            <title>I made another little bedside clock</title>
            <link>https://www.stavros.io/posts/i-made-another-little-bedside-clock/</link>
            <guid>https://www.stavros.io/posts/i-made-another-little-bedside-clock/</guid>
            <description>
                &lt;div class=&#34;pull-quote&#34;&gt;I call it &#39;Bedtime&#39;&lt;/div&gt;&lt;p&gt;The other day I saw &lt;a href=&#34;https://www.youtube.com/watch?v=S1Q9PZ95SDM&#34;&gt;a video by someone who bought a $16 electronic clock&lt;/a&gt;, and it looked interesting, because that little clock had been taunting me for months by showing up constantly in my AliExpress recommendations.
I held off on buying it because what am I going to do with &lt;a href=&#34;/posts/do-not-be-alarmed-clock/&#34;&gt;yet another clock&lt;/a&gt;, but the video said that it has an ESP8266 inside, and that, with a little soldering and programming, you could run &lt;a href=&#34;https://esphome.io&#34;&gt;ESPhome&lt;/a&gt; on it!&lt;/p&gt;
&lt;p&gt;Obviously, I didn&amp;#8217;t need any more convincing, though I remembered this clock being listed for $6 for a while, and balked at the $16 the video mentioned.
I ordered &lt;a href=&#34;https://www.aliexpress.com/item/1005009145344721.html&#34;&gt;a different listing, which I found for $12&lt;/a&gt;, hoping it would be the same as the one from the video.&lt;/p&gt;
&lt;p&gt;Fairly serendipitously for this purchase, the &lt;a href=&#34;/posts/do-not-be-alarmed-clock/&#34;&gt;last bedside clock I made&lt;/a&gt; was showing its age a bit.
Mainly, the dimmest setting on its screen was bright enough to be annoying when I&amp;#8217;m in bed, and the screen has been burned-in quite a bit.
A new monochrome OLED screen is an easy fix, but I&amp;#8217;d prefer a color one (especially if it can be dimmed more), so hopefully I&amp;#8217;d be able to replace the Do Not Be Alarmed clock with this new one.&lt;/p&gt;
&lt;p&gt;The new clock arrived promptly, and I &lt;!-- break --&gt;got to work.&lt;/p&gt;
&lt;h2&gt;Work&lt;/h2&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;clock1.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;clock1-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;Dashing.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;The first thing I did was what any good engineer would do, I eschewed the company of my friends and shut myself in a room where I could work on my clock in peace.
The second thing I did was to open the clock up, as the video mentioned, &lt;em&gt;in no uncertain terms&lt;/em&gt;, that I would need to solder some headers if I were to ever program this clock.
Imagine my surprise and dismay when, upon opening up the device, I saw no headers or any accessible points to connect anything to program it with!
It turned out that what I had ordered wasn&amp;#8217;t the same as the one in the video, after all.&lt;/p&gt;
&lt;p&gt;I wasn&amp;#8217;t too sad about it, as I&amp;#8217;ve programmed ESP8266 modules often by just touching the pins to the microcontroller board, and the clock has an ESP-12F package soldered on it, so the pins are large enough to connect things to, so I got ready for the agony of trying to hold five wires firmly to the board while I press the Enter key on the keyboard with my nose.
At this point I was obviously cursing myself for trying to save $4 and not getting the &amp;#8220;good&amp;#8221; version, but it was what it was.&lt;/p&gt;
&lt;p&gt;While examining the (very simple) board, though, I flipped it over and saw what looked like a USB-TTL chip.
That chip turned out to be a USB-TTL chip indeed, which took me by surprise, because it meant I didn&amp;#8217;t even have to open the thing up!
Sure enough, connecting the USB-C port to the computer showed the clock as a USB device, and flashing ESPhome worked perfectly.&lt;/p&gt;
&lt;p&gt;Next step: The configuration.&lt;/p&gt;
&lt;h2&gt;The configuration&lt;/h2&gt;
&lt;p&gt;I used the configuration from the video as a starting point, as it already contained the pinout for the screen, the screen type and dimensions, and various other niceties.
The original configuration used Home Assistant, which I don&amp;#8217;t use, so I took those things out.&lt;/p&gt;
&lt;h3&gt;The visuals&lt;/h3&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;original.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;original-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The first draft version.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;I&amp;#8217;m terrible at any sort of visual design, so I can&amp;#8217;t really say that I designed things with any intent, I mostly stumbled onto a design that didn&amp;#8217;t seem terrible, and used that.
I went through a few iterations, with the time first being centered and the date below it, then with the time being shown diagonally (as in the photo on the right), and finally copying the face of my Xiaomi watch and arriving at the final design.&lt;/p&gt;
&lt;p&gt;Usually I&amp;#8217;ll open Inkscape and try to mock up something to see how the colors, layout, separators, and other visual elements fit together, but here I didn&amp;#8217;t really do any of that, and just iterated on the display directly.
It took a bit of positioning elements around, but I think the end result is pretty good.&lt;/p&gt;
&lt;p&gt;One serendipitous thing that I did was add a synthwavy-ground-grid type of thing, which gave the face a really nice touch.
I initially added it as somewhat of a test, but I liked it so much I ended up keeping it in the final version.&lt;/p&gt;
&lt;h3&gt;The time&lt;/h3&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;clock2.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;clock2-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The final look.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;The time and date were very easy to add.
ESPhome supports NTP natively, so getting the correct time over the network was just a matter of adding the appropriate NTP servers in a small stanza.&lt;/p&gt;
&lt;p&gt;This way, the time keeps updating with daylight savings time as well, following the timezone properly.
This is actually &lt;em&gt;a lot&lt;/em&gt; of work to do manually, as I found out when I made the Do Not Be Alarmed clock, and the fact that ESPhome does it for me is fantastic and saves a ton of work.&lt;/p&gt;
&lt;p&gt;It basically just worked.
The last thing to add was to show various sensor values that are relevant to a bedside clock.&lt;/p&gt;
&lt;h3&gt;The sensors&lt;/h3&gt;
&lt;p&gt;My house is instrumented to use Zigbee (with Zigbee2MQTT), and some WiFi sensors that publish to MQTT on specific topics.
ESPhome supports MQTT natively, and it can subscribe to topics, listen for updates, and update the screen whenever a topic gets published to.&lt;/p&gt;
&lt;p&gt;Usually, when I wake up, I want to know what time it is, what date, what the temperature outside is, in case I need to go out, and maybe what the humidity inside the flat is.
I use Zigbee Sonoff or Aqara temperature/humidity sensors, so I wanted the values read from those.
I also have a makeshift CO2 sensor in the bedroom (I made that using an ESP8266 with ESPhome and an SCD41 CO2 sensor), and I wanted to see the CO2 value so I know whether I should open a door a bit wider when it gets too high.&lt;/p&gt;
&lt;p&gt;These were the values I mainly wanted to display, so I added subscriptions in the MQTT integration to these sensors&amp;#8217; topics, and instructed ESPhome to parse the values they sent.
Then, I positioned the elements that I wanted to show the values around the screen.&lt;/p&gt;
&lt;p&gt;At this point, there was a small issue:
When the clock is restarted (which is, admittedly, rarely), the sensor values show 0, or NA, until the sensor wakes up and sends a reading, which might happen every ten minutes or so.
I wanted the clock to show the last value quickly, so I enabled &amp;#8220;retain message&amp;#8221; in the Zigbee2MQTT options for the sensors I was interested in.
&amp;#8220;Retain&amp;#8221; is an MQTT flag that tells the server to remember the latest message in that specific topic, and send it to you when you subscribe, no matter how long ago it was actually sent.
This fixed the issue, and now the clock shows the last values on bootup, which is perfect.&lt;/p&gt;
&lt;h3&gt;The dimming&lt;/h3&gt;
&lt;p&gt;And now we get to the most important part of the whole project:
The dimming!&lt;/p&gt;
&lt;p&gt;This clock is, indeed, much better at dimming. You can dim it very very low before turning the backlight off altogether, but it does get hard to read at very low brightnesses.
I ended up using a brightness of about 6%, and a max brightness of 60%, and it&amp;#8217;s perfect.&lt;/p&gt;
&lt;p&gt;The way min/max works is that the clock starts dimming from 60% to 6% for about an hour before the time when I usually go to bed.
Then, it stays dim throughout the entire night, and starts brightening again around the time I wake up, and takes an hour to go back to 60% brightness.&lt;/p&gt;
&lt;p&gt;This way, the clock is dim throughout the night when I&amp;#8217;m asleep, but bright in the day, when I might need to look at it in well-lit conditions.&lt;/p&gt;
&lt;p&gt;This works really well and is a massive improvement on my last clock, and the nice, colorful screen doesn&amp;#8217;t hurt, either.&lt;/p&gt;
&lt;p&gt;With that, the clock was complete!&lt;/p&gt;
&lt;h2&gt;Epilogue&lt;/h2&gt;
&lt;p&gt;This was a very simple build, and it only took an hour or two, but it was lots of fun and I absolutely love the final result.
I also &lt;em&gt;really&lt;/em&gt; like the fact that I get a &lt;em&gt;lot&lt;/em&gt; of use out of something like this.
I look at my bedside clock a lot, and having a better clock is a massive quality of life upgrade.
I&amp;#8217;ve been looking for a better clock for ages, but never found one that ticked all the boxes, until this one came along.&lt;/p&gt;
&lt;p&gt;I heavily recommend buying one, you can use my config to program it with, and customize it to your liking:&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/skorokithakis/esphome-configs&#34;&gt;https://github.com/skorokithakis/esphome-configs&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;There&amp;#8217;s also a version with an ESP32, which is much more powerful, but I don&amp;#8217;t know if the pinouts have been figured out yet, so I don&amp;#8217;t know how easy it would be to program.
However, if you do get one of these clocks, I&amp;#8217;ll be looking forward to anything you make with it, if you want to share!&lt;/p&gt;
&lt;p&gt;As always, if you have any questions or feedback, please let me know!
You can find me on &lt;a href=&#34;https://x.com/intent/user?screen_name=Stavros&#34;&gt;X&lt;/a&gt; or &lt;a href=&#34;https://bsky.app/profile/stavros.io&#34;&gt;Bluesky&lt;/a&gt;, or email me directly.&lt;/p&gt;
&lt;p&gt;P.S. I bought another one of these, this time I did get the &amp;#8220;original&amp;#8221; GeekMagic Ultra.
As the video said, the only difference is that this one doesn&amp;#8217;t have a USB TTL chip, so you can&amp;#8217;t program it just by plugging it in to USB-C, so yes, I wouldn&amp;#8217;t recommend this over the one I linked above.&lt;/p&gt;

            </description>
            <pubDate>Thu, 25 Dec 2025 18:04:52 +0000</pubDate>
        </item>
        
        <item>
            <title>I converted a rotary phone into a meeting handset</title>
            <link>https://www.stavros.io/posts/i-converted-a-rotary-phone-into-a-meeting-handset/</link>
            <guid>https://www.stavros.io/posts/i-converted-a-rotary-phone-into-a-meeting-handset/</guid>
            <description>
                &lt;div class=&#34;pull-quote&#34;&gt;The meeting stakes are high when you can get hung up on&lt;/div&gt;&lt;p&gt;As you may remember, or completely not know, I have a &lt;a href=&#34;/posts/irotary-saga/&#34;&gt;bit of a fascination with old rotary phones&lt;/a&gt;.
Occasionally, when people learn about this fascination, they donate their old rotary phones to me, so I have ended up with a small collection.&lt;/p&gt;
&lt;p&gt;The other thing I have a fascination with is meetings.
Well, I say &amp;#8220;fascination&amp;#8221;, but it&amp;#8217;s more of a burning hatred, really.
One day, a few months ago, I was in one such meeting, as I have been every day since, and I jokingly pretended to get irate about something.&lt;/p&gt;
&lt;p&gt;One of my coworkers laughed and said &amp;#8220;I bet if this were a phone call, you&amp;#8217;d slam the phone down right now&amp;#8221;, and a dread spread over me.
Why &lt;em&gt;didn&amp;#8217;t&lt;/em&gt; I have a phone handset I could slam down?
Had I really become a corporate husk of my former, carefree self, puppeteered by &lt;!-- break --&gt;the vicissitudes of capitalism?
&amp;#8220;No&amp;#8221;, I decided, &amp;#8220;because that sentence doesn&amp;#8217;t even make sense&amp;#8221;.&lt;/p&gt;
&lt;p&gt;I did, however, have a phone I could use for this project, 30% of the knowledge required, and 100% of the underestimation of how hard the other 70% would be.
Armed with all these numbers, I quickly started to try to figure out how I could do this.&lt;/p&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;phone.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;phone-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The phone I used (artist&#39;s depiction).&lt;/span&gt;&lt;/div&gt;&lt;h1&gt;The phone&lt;/h1&gt;
&lt;p&gt;The phone I used is an old Siemens rotary phone, pictured in the image to the right.
That image is actually not a photo of the phone, but ChatGPT&amp;#8217;s best attempt at one, because I&amp;#8217;m too lazy to try to find where I put the phone to take a photo of it.
Rest assured, though, the image is almost exactly what the phone looks like, except with a bit more 8 and a bit less 3.&lt;/p&gt;
&lt;p&gt;The good thing about these old phones is that nothing is soldered to anything else, which makes it possible to modify them without making any permanent changes to the phone, something which I really wanted to avoid. I don&amp;#8217;t really think it matters much, but I don&amp;#8217;t like breaking/altering these old phones at all. I prefer to make reversible changes where I can, and luckily the phones allow that.&lt;/p&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;connector.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;connector-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The connector I made.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;The phone&amp;#8217;s board has these metal tabs that stick out, and the cables have a corresponding connector that opens up around the tab, making decent electrical contact and using friction to make sure they don&amp;#8217;t slip off.
Since I didn&amp;#8217;t want to make any permanent changes to the phone, I didn&amp;#8217;t want to remove these tabs, or to solder anything onto them.
I just wanted to connect a cable to them in the easiest way possible.&lt;/p&gt;
&lt;p&gt;To do that, I designed and 3D-printed a very small connector (pictured in the photo on the right, the small, purple piece of plastic), which I used to hold a wire on top of the tab.
This worked fairly well, the cable made good contact, and was relatively stable, as long as you didn&amp;#8217;t pull on it at all.&lt;/p&gt;
&lt;p&gt;With the connections out of the way, I could move on to the electronics themselves, and where they&amp;#8217;d go.
Another benefit of these old phones are that they&amp;#8217;re much larger than the circuits inside them, which means that the interior is very, very roomy, with lots of space for all the extra bits I wanted to use.&lt;/p&gt;
&lt;h1&gt;The extra bits I wanted to use&lt;/h1&gt;
&lt;p&gt;Since I wanted to be able to use the phone as a meeting handset, I figured I needed something that would act as a keyboard/soundcard combo.
The keyboard would be responsible for actually &amp;#8220;hanging up&amp;#8221; the meeting, ie sending the appropriate keystrokes to the active window to exit whatever meeting I happened to be on.
The soundcard would expose a microphone/speaker combo, which could be used as an input and output device for the meeting software to play and record sound through the phone&amp;#8217;s handset.
I figured, since I&amp;#8217;m at it, I might as well make the rotary dial work too, and have the keyboard type whatever number I dialed, just because I could.&lt;/p&gt;
&lt;p&gt;To do all this, I needed a capable microcontroller, and I had just the thing:
The RP2040 by Raspberry Pi is plenty powerful enough to be used as a sound card, and can be made to show up to the computer as a USB device.&lt;/p&gt;
&lt;h1&gt;The software&lt;/h1&gt;
&lt;p&gt;Of course, if the RP2040 was to act as a USB device that&amp;#8217;s a combination sound card/keyboard, I&amp;#8217;d have to spend a lot of time learning about USB devices, sound cards, keyboards, and how the RP2040 works, which is a prospect which I relished with considerable gusto, said no one ever, and especially not me.
Instead, we have LLMs now, and we can make them do the dirty work we don&amp;#8217;t want to do, like program stuff for our inane side-projects.
This is literally why LLMs were created and nobody can convince me otherwise, so I decided to help Claude Opus 4.1 (the best coding model at the time) progress on its goal towards self-actualization, and got to work.&lt;/p&gt;
&lt;p&gt;Unfortunately, Opus was only available on the $200/mo subscription, which was a bit too much for a silly side-project, so I decided to use the API instead for a few hours of coding.
I mean, it&amp;#8217;s one LLM, Michael.
What could it cost?
Ten dollars?&lt;/p&gt;
&lt;p&gt;I asked Claude to write some code to turn the RP2040 into a sound card using TinyUSB, I tested it and told Claude about the way in which it didn&amp;#8217;t work, it wrote more code, and so on.
Half an hour and fifty dollars later, I realized I had spent fifty dollars on this, and that this was not sustainable because, if anything, the code was getting more and more buggy the more Claude fixed it.
It was time for plan B.&lt;/p&gt;
&lt;h1&gt;Plan B&lt;/h1&gt;
&lt;p&gt;Plan B is shameful, as it contains an element of me accepting defeat, but I guess it&amp;#8217;s actually Claude that was defeated.
Be that as it may, I decided that the RP2040 sound card approach was a dead-end, as I didn&amp;#8217;t know anything about the RP2040 or about sound cards, and that I&amp;#8217;d have to change my tactics.
I&amp;#8217;d use a USB hub with two separate devices, one sound card and one keyboard, and the hub would join them and allow them to use a single USB cable to connect to the host computer.&lt;/p&gt;
&lt;p&gt;I could still use the RP2040 as a keyboard, so I connected it to the phone&amp;#8217;s hook and rotary dial, and wrote some code to measure the pulses and send keystrokes if the handset was placed on the hook.
After verifying that this worked properly, I moved on to the second, and by far the hardest, part of my plan, finding an off-the-shelf sound card.&lt;/p&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;original-soundcard.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;original-soundcard-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The sound card I bought, fully clothed.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;I ran to my trusty shopping website, Amazon (the AliExpress of the US), but all the USB sound cards there were a bit more expensive than they needed to be, so then I went straight to the source, AliExpress.
There, I found exactly what I needed:
A USB sound card for $1.69, and the sexual reference was not lost on me.
Well done, AliExpress.&lt;/p&gt;
&lt;p&gt;When the sound card arrived, I tested it on my computer, saw that it worked fine, and disassembled it.
I removed the two 3.5mm jack connectors and soldered pins to them instead, with the intention that the phone&amp;#8217;s connectors would slide over the pins instead of the metal tabs of the phone.
Indeed, this worked beautifully, and the handset made a very solid connection with the sound card.
I plugged the latter into my computer and confirmed that I could both listen to and record from the handset.&lt;/p&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;soundcard.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;soundcard-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The soundcard, wrapped in electrical tape.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;I then desoldered the USB connector and soldered four wires onto where it used to be, to save space.
I soldered the other side of those wires to the hub, where I desoldered the corresponding connector from, and tested to see if this worked.
Amazingly, it did! The computer recognized the sound card, and audio worked fine with the handset.
I connected the RP2040 keyboard to the hub as well, and confirmed that that, too, worked well, sending various keystrokes when hung up (Ctrl+Shift+E for Zoom, by Ctrl+W for Meet, &lt;code&gt;rm -rf /&lt;/code&gt; for Teams, etc).&lt;/p&gt;
&lt;p&gt;To clarify, the RP2040 doesn&amp;#8217;t actually know which software you&amp;#8217;re using for the call, it just sends all the keystrokes, one after the other.&lt;/p&gt;
&lt;h1&gt;Connecting it all together&lt;/h1&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;guts.jpg&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;guts-small.jpg&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;The insides&lt;/span&gt;&lt;/div&gt;&lt;p&gt;On the image to the right, you can see everything connected together. The red square to the right of the phone is the USB hub, with wires coming out of it and going to the sound card, which has been connected to the handset. On the other end, a short USB cable leads to the RP2040, which has been connected to the hook and rotary dial.&lt;/p&gt;
&lt;p&gt;It took a bit of trial and error to find the hook connectors, but nothing too terrible. The hook is a simple switch, so the detection happens with a GPIO pin that gets pulled low whenever the phone is hung up.
The rotary dial is similarly a second switch, one that opens and closes very quickly, a number of times equal to the number you just dialed. The software on the RP2040 just counts these opens and closes, waits a few milliseconds to see if there are any more of them, and, if not, simulates a keyboard typing the number it counted.&lt;/p&gt;
&lt;p&gt;Here&amp;#8217;s a video of the whole thing, including the hanging up money shot:&lt;/p&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;aligncenter&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;video width=&#34;100%&#34; controls&gt;&lt;source src=&#34;meeting-phone-demo.mp4&#34; type=&#34;video/mp4&#34;&gt;Your browser does not support the video tag.&lt;/video&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;I love it.&lt;/p&gt;
&lt;h2&gt;Epilogue&lt;/h2&gt;
&lt;p&gt;I hope this post made sense, it was a bit stream-of-consciousness but this was a pretty simple build, with nothing really too involved or complicated. The most complicated part was probably the RP2040 keyboard emulation, and even that was pretty simple, because the LLM did it on its own.&lt;/p&gt;
&lt;p&gt;You can find the code on GitHub:&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/skorokithakis/dialogue/&#34;&gt;https://github.com/skorokithakis/dialogue/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you have any feedback, questions, or hate mail, you can &lt;a href=&#34;https://bsky.app/profile/stavros.io&#34;&gt;find me on Bluesky&lt;/a&gt;, or email me directly.&lt;/p&gt;

            </description>
            <pubDate>Tue, 11 Nov 2025 01:48:23 +0000</pubDate>
        </item>
        
        <item>
            <title>Switch to Jujutsu already: a tutorial</title>
            <link>https://www.stavros.io/posts/switch-to-jujutsu-already-a-tutorial/</link>
            <guid>https://www.stavros.io/posts/switch-to-jujutsu-already-a-tutorial/</guid>
            <description>
                &lt;div class=&#34;pull-quote&#34;&gt;If you don&#39;t like Jujutsu, you&#39;re wrong&lt;/div&gt;&lt;p&gt;As all developers, I&amp;#8217;ve been using git since the dawn of time, since its commands were an inscrutable jumble of ill-fitting incantations, and it has remained this way until today.
Needless to say, I just don&amp;#8217;t get git.
I never got it, even though I&amp;#8217;ve read a bunch of stuff on how it represents things internally.
I&amp;#8217;ve been using it for years knowing what a few commands do, and whenever it gets into a weird state because I fat-fingered something, I have my trusty alias, &lt;code&gt;fuckgit&lt;/code&gt;, that deletes the &lt;code&gt;.git&lt;/code&gt; directory, clones the repo again into a temp folder, and moves the &lt;code&gt;.git&lt;/code&gt; directory from that into my directory, and I&amp;#8217;ve managed to eke out a living for my family this way.&lt;/p&gt;
&lt;p&gt;Over the past few years, I&amp;#8217;ve been seeing people rave about &lt;a href=&#34;https://github.com/jj-vcs/jj&#34;&gt;Jujutsu&lt;/a&gt;, and I always wanted to try it, but it never seemed worth the trouble, even though I hate git.
I idly read a few tutorials, trying to understand how it works, but in the end I decided it wasn&amp;#8217;t for me.&lt;/p&gt;
&lt;p&gt;One day I randomly decided to try again, but this time I asked Claude how to do with Jujutsu whatever operation I wanted to do with git.
That&amp;#8217;s when the mental model of jj clicked for me, and I finally understood everything, &lt;em&gt;including how git works&lt;/em&gt;.
I never thought a VCS would spark joy in me, but here we are, and I figured maybe I can write something that will make jj click for you as well.&lt;/p&gt;
&lt;p&gt;It also doesn&amp;#8217;t hurt that Jujutsu is completely interoperable with git (and thus with providers like GitHub), and I can have all the power of Jujutsu locally on my git repos, without anyone knowing I&amp;#8217;m not actually using git.&lt;/p&gt;
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;The problem I had with the other tutorials, without realizing it, was that&lt;!-- break --&gt; there was a fundamental tension between two basic things:
The best way to explain jj to someone who knows git is to use all the git terms they already know (because that makes it easy for them), but also to tell them to think about the git terms they know differently (because otherwise they&amp;#8217;ll form the wrong mental model).
You can&amp;#8217;t really explain something by saying &amp;#8220;a jj commit is like a git commit, except where it&amp;#8217;s not&amp;#8221;, so I&amp;#8217;ll try to do things a bit differently.&lt;/p&gt;
&lt;p&gt;This will be a short post (or, at least, not as long as other jj tutorials), I&amp;#8217;ll explain the high-level mental model you should have, and then give a FAQ for how to do various git things with jj.&lt;/p&gt;
&lt;h2&gt;Warnings&lt;/h2&gt;
&lt;p&gt;Just a disclaimer before we start, this is going to be far from an exhaustive reference.
&lt;strong&gt;I&amp;#8217;m not an expert in either git or Jujutsu&lt;/strong&gt;, but I know enough to hopefully make jj click for you enough to learn the rest on your own, so don&amp;#8217;t be too annoyed if I omit something.&lt;/p&gt;
&lt;p&gt;Also, you&amp;#8217;re going to read here some things about the way Jujutsu likes doing things that will offend you to your very core, and your first reaction will be &amp;#8220;madness, this cannot possibly work&amp;#8221;.
When you think this, I want you to relax, it&amp;#8217;s fine, it does work, it just means I haven&amp;#8217;t managed to make the whole thing click together for you yet.
Just read on.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;m not going to show you &lt;em&gt;any&lt;/em&gt; Jujutsu commands here.
I might refer to them by name, but I want you to understand the mental model enough to go look stuff up on your own, Jujutsu only has, like, three commands you&amp;#8217;re going to use for everything anyway (yes, you can do everything you do with git with them).&lt;/p&gt;
&lt;p&gt;(By the way, if you&amp;#8217;re going to be trying things out while reading this post, definitely get &lt;a href=&#34;https://terminaltrove.com/jjui/&#34;&gt;jjui&lt;/a&gt;, it lets you visually work with the repository in a way that makes everything &lt;em&gt;much&lt;/em&gt; easier to understand.)&lt;/p&gt;
&lt;h2&gt;The high-level mental model you should have&lt;/h2&gt;
&lt;p&gt;First of all, all the basic git things you&amp;#8217;re already familiar with are there in jj:
Commits, branches, operations on those, all those things carry over, with some small differences.&lt;/p&gt;
&lt;p&gt;The main difference is in the general way the two work, jj simplifies git&amp;#8217;s model a lot by getting rid of some inconsistencies, and makes it much easier to understand what&amp;#8217;s going on &amp;#8220;under the hood&amp;#8221;, because the &amp;#8220;under the hood&amp;#8221; is now so much smaller and simpler, that it can just be over the hood.&lt;/p&gt;
&lt;h3&gt;git&lt;/h3&gt;
&lt;p&gt;The mental model that you probably have with git is something like an assembly line.
You take a bunch of components, you form them into a widget, you put the widget into a box, you write &amp;#8220;General bug fixes&amp;#8221; onto the box, seal it, and send it off, never to be seen again by anyone.&lt;/p&gt;
&lt;p&gt;That&amp;#8217;s what git thinks of as a commit.
You have some work that is The Thing You&amp;#8217;re Working On Now, and then at some point that&amp;#8217;s kind of done, you select which pieces of that work you want to immortalize, and you commit them, freezing them in time forever from then on.
(I know you can edit commits, but this is largely git&amp;#8217;s mental model, commits are immutable).&lt;/p&gt;
&lt;h3&gt;Jujutsu&lt;/h3&gt;
&lt;p&gt;Jujutsu, in contrast, is more like playing with Play-Doh.
You take a lump, cut it into two, shape one piece into something, give it a name, change your mind, give it another name, take a bit of the second piece and stick it on the first piece, and generally go back and forth all around your play area, making changes.&lt;/p&gt;
&lt;p&gt;Jujutsu wants you to be able to go back to an old commit, change it (gasp!), go to another branch (three commits back from that HEAD), change that commit too, move whole branches of your tree to other parts of it, whatever you want.
Your worktree in Jujutsu is a free-for-all where you can rearrange things as you like.&lt;/p&gt;
&lt;h3&gt;Recap&lt;/h3&gt;
&lt;p&gt;Basically, in git, you manipulate the code, put it in a commit, and you&amp;#8217;re largely done.
In Jujutsu, the commits themselves are &lt;em&gt;also&lt;/em&gt; the object of manipulation.
This isn&amp;#8217;t the most natural workflow in git, as git makes it much harder than jj does, but maybe this is the workflow you already have in git (with extensive squashing/rebasing/amending).
In that case, grasping the Jujutsu workflow will probably be easier, and will make things easier for you.&lt;/p&gt;
&lt;h2&gt;Madness, this cannot possibly work&lt;/h2&gt;
&lt;p&gt;Yes yes, nobody wants their commits changing from under them, that&amp;#8217;s why Jujutsu doesn&amp;#8217;t let you easily change commits that have been pushed to a remote, you can relax now.&lt;/p&gt;
&lt;p&gt;However, if you spend a moment thinking about what I said above, you&amp;#8217;ll probably realize that a few things need to be different from git for this to work (and they are):&lt;/p&gt;
&lt;h3&gt;Commits have to be mutable.&lt;/h3&gt;
&lt;p&gt;Indeed, Jujutsu commits are mutable (until you push them).
Right now you&amp;#8217;re thinking of commits as something that can&amp;#8217;t change, but this is one of the things you need to accept.
You can (and will) go back to a previous commit (that you haven&amp;#8217;t yet pushed) to fix a bug in it that you just hit, and it&amp;#8217;s as simple as checking out (jj calls it &lt;code&gt;edit&lt;/code&gt;ing) that commit and making the change.
&lt;em&gt;You don&amp;#8217;t have to commit again!&lt;/em&gt;
Jujutsu does whatever it needs to do under the hood when you run the &lt;code&gt;jj&lt;/code&gt; command, to you it just looks like your edits are automatically persisted in the commit, in real time.&lt;/p&gt;
&lt;p&gt;To clarify, Jujutsu doesn&amp;#8217;t create new commits while this goes on, you just see one &amp;#8220;open&amp;#8221; commit that you keep making changes to your code in.&lt;/p&gt;
&lt;h3&gt;If I can just go into a commit and edit it and jj auto-saves, there must not be a staging area.&lt;/h3&gt;
&lt;p&gt;Indeed, there is no staging area like git has.
git splits code to either be in the repo (in a commit), or outside it (staged/unstaged).&lt;/p&gt;
&lt;p&gt;Jujutsu doesn&amp;#8217;t have that, &lt;em&gt;you are always in a commit&lt;/em&gt;.
This is important: In git, you&amp;#8217;re outside a commit until you create one.
In Jujutsu, you are &lt;em&gt;always inside a commit&lt;/em&gt;.
Nothing is ever outside a commit, &amp;#8220;outside a commit&amp;#8221; isn&amp;#8217;t a thing in Jujutsu.&lt;/p&gt;
&lt;p&gt;Even the very &lt;code&gt;commit&lt;/code&gt; command in Jujutsu is an alias that adds a message to the commit you&amp;#8217;re on, and then creates a new (empty) one that you&amp;#8217;ll now be working on.
Even when you create a new repo, you start in a commit.&lt;/p&gt;
&lt;p&gt;This is &lt;em&gt;the most important difference between jj and git&lt;/em&gt;, and the one thing you should think a bit about, as it enables many really interesting workflows.&lt;/p&gt;
&lt;p&gt;Always being in a commit means that yes, you will have commits that are half-finished work.
Maybe lots of them!
I usually indicate this in the commit message, to remind myself.&lt;/p&gt;
&lt;h3&gt;So commits might not have a commit message?&lt;/h3&gt;
&lt;p&gt;You are impressively perceptive for a hypothetical straw man in whose mouth I&amp;#8217;m putting words.
Exactly, commits might not have a commit message.
They start out blank, and you can add a commit message at any point, whenever you have an idea of what that commit will do.
It might be when you start working on it, it might be half-way through, or it might be at the end.
Personally, I usually add the message at the end, but that&amp;#8217;s just preference.&lt;/p&gt;
&lt;h3&gt;So there&amp;#8217;s no stashing either?&lt;/h3&gt;
&lt;p&gt;Yes, since everything is always in a commit, there&amp;#8217;s nothing to stash.&lt;/p&gt;
&lt;p&gt;In git, if you have some uncommitted changes and want to check out an old commit, you need to stash them first.
In Jujutsu, since all your changes are automatically persisted in a commit at all times, you can have some new changes (which, if this were git, would be uncommitted), you can check out (or &lt;code&gt;edit&lt;/code&gt;) an older commit, then come back to your new changes in the latest commit, and they&amp;#8217;ll all be there.&lt;/p&gt;
&lt;h3&gt;But then branches need to be lightweight.&lt;/h3&gt;
&lt;p&gt;If you&amp;#8217;re going to be jumping around the tree all the time, making commits and branches, they can&amp;#8217;t require names.
Jujutsu lets you create branches by just creating a commit, you don&amp;#8217;t need to name the branch.
In Jujutsu (and in git!), branches are simply two or more commits with the same parent, it&amp;#8217;s just that git artificially makes you think of branches as special, because it makes you name them.&lt;/p&gt;
&lt;p&gt;In Jujutsu, creating a branch is as simple as checking out the commit you want to branch from, and creating a new commit on top of it.
This is one thing Jujutsu simplifies over git.
In git, branches are a fairly heavy thing, you have to name them, you have the mental model of &amp;#8220;being&amp;#8221; on the branch, and your workflow is centered around them.
In Jujutsu, you just&amp;#8230; add a new commit, and if that commit has siblings, well, that&amp;#8217;s now a branch.&lt;/p&gt;
&lt;h2&gt;Conflicts&lt;/h2&gt;
&lt;p&gt;I haven&amp;#8217;t talked about conflicts much, because, unlike git, in practice they haven&amp;#8217;t really been anything special.
Jujutsu doesn&amp;#8217;t stop the world at all, it doesn&amp;#8217;t even particularly complain, it just marks a commit as conflicted, but &lt;em&gt;you can continue working on other places in the worktree&lt;/em&gt; and then later come back at your leisure and fix that commit&amp;#8217;s conflicts!&lt;/p&gt;
&lt;p&gt;Whereas in git you have to quit what you&amp;#8217;re doing and fix the conflicts &lt;strong&gt;right now&lt;/strong&gt;, jj is more &amp;#8220;by the way, when you have some time, let me know what this commit should look like*.
The changes also cascade to all subsequent commits, which is fantastic.
You only fix conflicts once, and jj takes care of the rest.&lt;/p&gt;
&lt;h2&gt;Snapshots&lt;/h2&gt;
&lt;p&gt;Under the hood, jj automatically and transparently commits whatever you&amp;#8217;re working on when you invoke the jj command (it can also be configured to do it on its own whenever a file in the repo changes).
This is safe, as these intermediate changes won&amp;#8217;t be pushed anywhere, but this means that &lt;strong&gt;you get snapshots for free!&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;If you&amp;#8217;ve ever had Claude get to a working solution, but then trip over itself and mess it up, jj can help, you can use the oplog to go back to the way your repo looked a few minutes ago, &lt;strong&gt;even if you didn&amp;#8217;t explicitly commit anything!&lt;/strong&gt;
Even using the &lt;code&gt;status&lt;/code&gt; or &lt;code&gt;log&lt;/code&gt; command to look at stuff will take a snapshot of your repo, allowing you to return to it if something goes wrong.
No more losing unstaged changes, ever!&lt;/p&gt;
&lt;p&gt;This has saved my ass a few times already.&lt;/p&gt;
&lt;h2&gt;Questions and answers&lt;/h2&gt;
&lt;p&gt;By now you probably have lots of questions, I&amp;#8217;ll try to answer some of them here.
If you have more questions, just send them to me and I&amp;#8217;ll add them here, along with the answer.&lt;/p&gt;
&lt;h3&gt;How do I branch off main?&lt;/h3&gt;
&lt;p&gt;You don&amp;#8217;t really branch off main, in that you usually won&amp;#8217;t need to create &lt;em&gt;two&lt;/em&gt; commits off main, you&amp;#8217;ll only create one.&lt;/p&gt;
&lt;h4&gt;git&lt;/h4&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;git-branch.png&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;git-branch-small.png&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;This is how git thinks of branches. You create a branch, and you&#39;re now in a special branch bit.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;In git, we branch off of main, and now our mental model is that &amp;#8220;we&amp;#8217;re in that branch&amp;#8221;.
In reality, if you look at the graph on the right, it&amp;#8217;s all still just a line, we&amp;#8217;ve just made a mental &amp;#8220;bend&amp;#8221; in the graph to tell ourselves that we&amp;#8217;re on a branch.&lt;/p&gt;
&lt;p&gt;As far as the graph is concerned, though, nothing special really actually happened, we just added more commits.
The only real difference is that &amp;#8220;main&amp;#8221; stops at the third commit, whereas &amp;#8220;my branch&amp;#8221; stops at the sixth commit.
Other than that, the entire history is just one line.&lt;/p&gt;
&lt;h4&gt;Jujutsu&lt;/h4&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;jj-branch.png&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;jj-branch-small.png&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;This is how jj thinks of branches. The two graphs are equivalent, but you aren&#39;t anywhere special, `main` is just farther back.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;Jujutsu, on the other hand, doesn&amp;#8217;t care &lt;em&gt;what&lt;/em&gt; you think.
It only cares what parents, children, and siblings commits have.&lt;/p&gt;
&lt;p&gt;There are two reasons you might want to branch:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;History legitimately diverges into multiple directions, or&lt;/li&gt;
&lt;li&gt;You want to communicate to other people (or to yourself) that this part of the history is different (e.g. it contains some feature).
This is also the case when you want to create a new branch so you can open a PR for it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;To Jujutsu, this repo&amp;#8217;s history is a straight line, so there is no actual &amp;#8220;branching&amp;#8221;. The only reason to have branches here is communication, so Jujutsu asks you to label the commits that you want on the branches yourself.
You can see these tags on the example on the right, and it&amp;#8217;s the same as the git example above.
There are still three commits in &lt;code&gt;main&lt;/code&gt;, and three more in &lt;code&gt;my branch&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Jujutsu calls these labels &amp;#8220;bookmarks&amp;#8221;, and they correspond to whatever git uses to tag branches.
Bookmarks are what you&amp;#8217;ll tag your commits with to tell git what your branches are.&lt;/p&gt;
&lt;div class=&#34;clearfix&#34;&gt;&lt;/div&gt;&lt;div class=&#34;alignright&#34;&gt;&lt;div class=&#34;photo-container&#34;&gt;&lt;a href=&#34;legit-branch.png&#34; data-lightbox=&#34;gallery&#34;&gt;&lt;img src=&#34;legit-branch-small.png&#34;&gt;&lt;/a&gt;&lt;/div&gt;&lt;span class=&#34;caption&#34;&gt;Here, the graph has actually branched, because `main` has one extra commit.&lt;/span&gt;&lt;/div&gt;&lt;p&gt;Continuing the earlier example, if we create a second commit off main, even if that&amp;#8217;s a merge commit (a commit with two parents) that&amp;#8217;s when the tree actually diverges.
In the graph on the right, the commit where we branched off now is a parent to two commits, and history is no longer linear.
This isn&amp;#8217;t special, it&amp;#8217;s just how things are, but this is what&amp;#8217;s actually a real &amp;#8220;branch&amp;#8221; to Jujutsu.&lt;/p&gt;
&lt;p&gt;The way that git does things, ie creating a branch without history actually diverging, is just for us humans and our communication needs.&lt;/p&gt;
&lt;p&gt;Jujutsu doesn&amp;#8217;t require you to name its branches.
You can happily work without any branch names at all, and you can easily see what branch is for what from the commit descriptions.
You &lt;em&gt;can&lt;/em&gt; name them, if you prefer, but you don&amp;#8217;t &lt;em&gt;have to&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;This sounds a bit alien right now, but it&amp;#8217;s actually a really nice way to work.&lt;/p&gt;
&lt;p&gt;I&amp;#8217;m worried I&amp;#8217;ve lost you here, but it doesn&amp;#8217;t matter.
You&amp;#8217;ll understand all of this easily when you play around with the tree a bit in jjui.&lt;/p&gt;
&lt;h3&gt;How do I add a commit message?&lt;/h3&gt;
&lt;p&gt;You can add a commit message at any time to the current, using the &lt;code&gt;describe&lt;/code&gt; command.
You can do this at any time, you can even go back to other commits and amend their messages (again with the &lt;code&gt;describe&lt;/code&gt; command).&lt;/p&gt;
&lt;h3&gt;How do I choose which of my changes to commit?&lt;/h3&gt;
&lt;p&gt;You don&amp;#8217;t!
Everything is already in a commit!
What you do is you interactively select some of the changes in the current commit (whether this commit is blank/new or an old commit, it doesn&amp;#8217;t matter), and you &lt;code&gt;split&lt;/code&gt; that commit into two.&lt;/p&gt;
&lt;p&gt;Jujutsu can also do this automatically!
If you have a commit with a bunch of small changes to various files, jj can &lt;code&gt;absorb&lt;/code&gt; these changes into the closest ancestor commit where each thing changed.
This is pretty magical, as you can add a few one-liner bugfixes here and there, and jj will just automatically include them in the commits where those lines were touched.&lt;/p&gt;
&lt;h3&gt;How do I check out a commit?&lt;/h3&gt;
&lt;p&gt;Without getting too much into specifics, you just &lt;code&gt;edit&lt;/code&gt; the commit you want.
This checks it out and you can make changes to it, however keep in mind that, if the commit was previously pushed to a remote, jj will give you a warning that you shouldn&amp;#8217;t change commits you&amp;#8217;ve pushed.&lt;/p&gt;
&lt;p&gt;jjui will make navigation around the repo really easy, so use it for checking out commits as well.&lt;/p&gt;
&lt;h3&gt;How do I cherry-pick a commit onto another branch?&lt;/h3&gt;
&lt;p&gt;You just&amp;#8230; move it. In jjui, go to the commit you want to move, press r (for &lt;code&gt;rebase&lt;/code&gt;), go to the commit you want to move it after, press enter, and that&amp;#8217;s it.&lt;/p&gt;
&lt;h3&gt;How do I reset soft/hard?&lt;/h3&gt;
&lt;p&gt;There isn&amp;#8217;t really a soft reset, as there isn&amp;#8217;t a staging area for your changes to be reset in.
Simply check out (&lt;code&gt;edit&lt;/code&gt;) the commit you want to edit, that&amp;#8217;s a soft reset in Jujutsu.&lt;/p&gt;
&lt;p&gt;For a hard reset (ie to throw away a commit), you &lt;code&gt;abandon&lt;/code&gt; that commit.
jjui will, again, make it much easier to do this.&lt;/p&gt;
&lt;h3&gt;What if I make a mistake?&lt;/h3&gt;
&lt;p&gt;No matter what you do, you can &lt;code&gt;undo&lt;/code&gt; it.
Not just changes, but any jj operation, you can undo rebases, pulls, anything.&lt;/p&gt;
&lt;p&gt;You can also use the oplog (again, jjui makes this really easy) to go back to how the whole repo looked at any point in time.
Don&amp;#8217;t be afraid to try things, with jj it&amp;#8217;s really easy to undo any mistake.&lt;/p&gt;
&lt;h3&gt;How do I amend a commit?&lt;/h3&gt;
&lt;p&gt;Simply &lt;code&gt;edit&lt;/code&gt; it and make the changes you want.&lt;/p&gt;
&lt;h3&gt;How do I move unstaged changes from one branch to another?&lt;/h3&gt;
&lt;p&gt;There are no unstaged changes in jj.
All changes are in a commit, if you want to move the changes in your current commit to another branch, simply move your current commit to the target branch by rebasing.
I can never remember what &amp;#8220;rebase X onto Y&amp;#8221; does, so just move the commit with your changes to be a child of your branch&amp;#8217;s tip (again, use jjui for this).&lt;/p&gt;
&lt;h3&gt;How do I open a PR on GitHub?&lt;/h3&gt;
&lt;p&gt;To do that, you need to push a new branch.
Go to the commit you want to push, then probably create a new one on top of that (I tend to create a new commit when I&amp;#8217;m done with an old one, just so I&amp;#8217;m remember I&amp;#8217;m done, but this is personal preference).
Then, bookmark that commit with the branch name you want to give your PR, and push the commit along with the bookmark.&lt;/p&gt;
&lt;p&gt;That&amp;#8217;s all, now you can open the PR.&lt;/p&gt;
&lt;p&gt;Here, jj exposes the low-level operations much more than git:
You need to move the bookmark on your own to the commit you want to push (git does that automatically for you), and you need to push the bookmark manually as well.
This is very helpful for understanding how things work under the hood, but usually you&amp;#8217;ll set a jj alias to do this in one step.&lt;/p&gt;
&lt;p&gt;Personally, I have an alias (which I&amp;#8217;ll include below) to find the bookmark name, move it to the latest commit, and push.&lt;/p&gt;
&lt;h2&gt;My aliases&lt;/h2&gt;
&lt;p&gt;Here&amp;#8217;s my alias config:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;[aliases]&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;init&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;[&amp;quot;git&amp;quot;, &amp;quot;init&amp;quot;, &amp;quot;--colocate&amp;quot;]&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;ps&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;[&amp;quot;util&amp;quot;, &amp;quot;exec&amp;quot;, &amp;quot;--&amp;quot;, &amp;quot;bash&amp;quot;, &amp;quot;-c&amp;quot;, &amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;set -e&lt;/span&gt;

&lt;span class=&#34;c1&#34;&gt;# Check if current commit has both description and changes&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;has_description&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;$(jj log -r @ --no-graph --color never -T &amp;#39;description&amp;#39; | grep -q . &amp;amp;&amp;amp; echo &amp;quot;yes&amp;quot; || echo &amp;quot;no&amp;quot;)&lt;/span&gt;
&lt;span class=&#34;c1&#34;&gt;# Use &amp;#39;empty&amp;#39; template keyword to check if commit has changes&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;has_changes&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;$(jj log -r @ --no-graph --color never -T &amp;#39;empty&amp;#39; | grep -q &amp;quot;false&amp;quot; &amp;amp;&amp;amp; echo &amp;quot;yes&amp;quot; || echo &amp;quot;no&amp;quot;)&lt;/span&gt;

&lt;span class=&#34;na&#34;&gt;if [ &amp;quot;$has_description&amp;quot;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;&amp;quot;yes&amp;quot; ] &amp;amp;&amp;amp; [ &amp;quot;$has_changes&amp;quot; = &amp;quot;yes&amp;quot; ]&lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;; then&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;na&#34;&gt;echo &amp;quot;Current commit has description and changes, creating new commit...&amp;quot;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;na&#34;&gt;jj new&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;fi&lt;/span&gt;

&lt;span class=&#34;c1&#34;&gt;# Get the bookmark from the parent commit directly&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;bookmark&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;s&#34;&gt;$(jj log -r &amp;#39;ancestors(@) &amp;amp; bookmarks()&amp;#39; -n 1 --no-graph --color never -T &amp;#39;bookmarks&amp;#39; | sed &amp;#39;s/\\*$//&amp;#39; | tr -d &amp;#39; &amp;#39;)&lt;/span&gt;

&lt;span class=&#34;na&#34;&gt;if [ -z &amp;quot;$bookmark&amp;quot; ]; then&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;na&#34;&gt;echo &amp;quot;No bookmark found on parent commit&amp;quot;&lt;/span&gt;
&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;na&#34;&gt;exit 1&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;fi&lt;/span&gt;

&lt;span class=&#34;na&#34;&gt;echo &amp;quot;Moving bookmark &amp;#39;$bookmark&amp;#39; to parent commit and pushing...&amp;quot;&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;jj bookmark set &amp;quot;$bookmark&amp;quot; -r @-&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;jj git fetch&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;jj git push --bookmark &amp;quot;$bookmark&amp;quot; --allow-new&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;&amp;quot;&amp;quot;&amp;quot;]&lt;/span&gt;
&lt;span class=&#34;na&#34;&gt;cma&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s&#34;&gt;[&amp;quot;commit&amp;quot;, &amp;quot;-m&amp;quot;]&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This means I can &lt;code&gt;jj init&lt;/code&gt; to add jj to a git repo, and &lt;code&gt;jj cma &#34;message&#34;&lt;/code&gt; to describe the current commit and create a new one on top of it (that&amp;#8217;s what &lt;code&gt;commit&lt;/code&gt; does under the hood).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;jj ps&lt;/code&gt; is a convenience alias that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Looks backward in history&lt;/li&gt;
&lt;li&gt;Finds the last bookmark there (if this were git, this would be my branch name)&lt;/li&gt;
&lt;li&gt;Checks if the current commit has changes in it&lt;/li&gt;
&lt;li&gt;If it does, it creates a new commit&lt;/li&gt;
&lt;li&gt;Moves the bookmark to the parent commit (the one I was on before I ran the command)&lt;/li&gt;
&lt;li&gt;Fetches changes from upstream (to update my tree)&lt;/li&gt;
&lt;li&gt;Pushes the changes to the remote&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I use this a lot!&lt;/p&gt;
&lt;h2&gt;Epilogue&lt;/h2&gt;
&lt;p&gt;Jujutsu doesn&amp;#8217;t do anything that git can&amp;#8217;t do, but it removes so much friction that &lt;strong&gt;you&amp;#8217;ll actually end up doing things all the time&lt;/strong&gt; that git could do, but that were so fiddly with git that you never actually did them.&lt;/p&gt;
&lt;p&gt;Creating a branch for a minute just to try an idea out even though you&amp;#8217;re in the middle of some changes, going back to a previous commit to add a line you forgot, moving commits around the tree, all of these things are so easy that &lt;strong&gt;they&amp;#8217;re now actually your everyday workflow&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;With git, I never used to switch branches in the middle of work, because I was too worried that stashing multiple things onto the stack would eat my work.
I&amp;#8217;d never go back to a previous commit and amend it, because here be dragons.
I was extremely afraid of rebasing because I always got one conflict per commit and had to unconflict the same thing fifty times.&lt;/p&gt;
&lt;p&gt;Jujutsu gives you the confidence and understanding to do all of these things, and if you fuck something up (which I haven&amp;#8217;t yet, miraculously!) the oplog is right there to fix everything to how it was 30 seconds ago.&lt;/p&gt;
&lt;p&gt;I hope this tutorial made sense, but I&amp;#8217;m worried it didn&amp;#8217;t.
Please contact me on &lt;a href=&#34;https://x.com/intent/user?screen_name=Stavros&#34;&gt;Twitter&lt;/a&gt; or &lt;a href=&#34;https://bsky.app/profile/stavros.io&#34;&gt;Bluesky&lt;/a&gt;, or email me directly, if you have feedback or corrections.&lt;/p&gt;

            </description>
            <pubDate>Fri, 10 Oct 2025 21:13:48 +0000</pubDate>
        </item>
        
        
    </channel>
</rss>
