<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: 22Gstudios</title>
    <description>The latest articles on Forem by 22Gstudios (@22gstudios).</description>
    <link>https://forem.com/22gstudios</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3903408%2Fb0a6b164-4d1a-414f-af9e-9fe082b49bcb.png</url>
      <title>Forem: 22Gstudios</title>
      <link>https://forem.com/22gstudios</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/22gstudios"/>
    <language>en</language>
    <item>
      <title>Why I'm not open sourcing my Android voice agent</title>
      <dc:creator>22Gstudios</dc:creator>
      <pubDate>Thu, 07 May 2026 02:08:13 +0000</pubDate>
      <link>https://forem.com/22gstudios/why-im-not-open-sourcing-my-android-voice-agent-30ab</link>
      <guid>https://forem.com/22gstudios/why-im-not-open-sourcing-my-android-voice-agent-30ab</guid>
      <description>&lt;p&gt;I built &lt;a href="https://x.com/22Gstudios/status/2051377769414791582" rel="noopener noreferrer"&gt;LetItDo&lt;/a&gt;, a voice agent for Android that finishes real tasks. WhatsApp messages, Spotify, grocery orders, the boring 47-tap routines.&lt;/p&gt;

&lt;p&gt;Every other day someone asks: are you going to open source it?&lt;/p&gt;

&lt;p&gt;The honest answer is no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source is not free distribution
&lt;/h2&gt;

&lt;p&gt;The most common reason indie devs open source: "I will get more users by going open source." It is rarely true.&lt;/p&gt;

&lt;p&gt;Open source attracts contributors and users who want a free tool. It does not magically attract people who pay. Linear, Notion, Superhuman, Cal.com all started closed. Cal.com later open sourced AFTER it had paying customers, as a marketing move with infrastructure already in place.&lt;/p&gt;

&lt;p&gt;If your product has zero distribution, open sourcing it gives you zero contributors and zero users with extra steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source kills monetization optionality
&lt;/h2&gt;

&lt;p&gt;The moment LetItDo is on GitHub:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Someone wraps it, hosts it, and charges for it&lt;/li&gt;
&lt;li&gt;Customers who would have paid me $5/month build their own from source&lt;/li&gt;
&lt;li&gt;Acquirers stop being interested&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The "AI makes everything copyable" argument
&lt;/h2&gt;

&lt;p&gt;People say: with Claude Code, anyone builds LetItDo in a week. Half right. AI compresses build time, does not eliminate moat.&lt;/p&gt;

&lt;p&gt;The remaining moats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Distribution and brand&lt;/li&gt;
&lt;li&gt;Specific UX choices&lt;/li&gt;
&lt;li&gt;The skill capture loop (data, not code)&lt;/li&gt;
&lt;li&gt;OEM-specific quirks (Vivo, Xiaomi, Samsung battery savers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open sourcing compresses competitor copy time from weeks to days. For an unvalidated product, worst possible time to give it away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general rule
&lt;/h2&gt;

&lt;p&gt;Open source AFTER you have a profitable product, AS marketing. Not BEFORE you have users, AS distribution. The latter never works the way founders hope.&lt;/p&gt;

&lt;p&gt;If you are an indie dev about to MIT-license a product you spent 60 hours on, ask: do I want to build a business or build a side project?&lt;/p&gt;

&lt;h2&gt;
  
  
  Try LetItDo
&lt;/h2&gt;

&lt;p&gt;Demo: &lt;a href="https://x.com/22Gstudios/status/2051377769414791582" rel="noopener noreferrer"&gt;https://x.com/22Gstudios/status/2051377769414791582&lt;/a&gt;&lt;br&gt;
Early access: &lt;a href="https://tally.so/r/jaGvx9" rel="noopener noreferrer"&gt;https://tally.so/r/jaGvx9&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have shipped indie products and made the open source decision differently, I want to hear why.&lt;/p&gt;

</description>
      <category>indiehacking</category>
      <category>business</category>
      <category>opensource</category>
      <category>startup</category>
    </item>
    <item>
      <title>I built a voice agent for Android in a weekend. Here's what actually worked</title>
      <dc:creator>22Gstudios</dc:creator>
      <pubDate>Tue, 05 May 2026 03:21:20 +0000</pubDate>
      <link>https://forem.com/22gstudios/i-built-a-voice-agent-for-android-in-a-weekend-heres-what-actually-worked-4akj</link>
      <guid>https://forem.com/22gstudios/i-built-a-voice-agent-for-android-in-a-weekend-heres-what-actually-worked-4akj</guid>
      <description>&lt;p&gt;Yesterday I posted this on X: &lt;a href="https://x.com/22Gstudios/status/2051377769414791582" rel="noopener noreferrer"&gt;https://x.com/22Gstudios/status/2051377769414791582&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;LetItDo is a voice agent for Android that actually finishes tasks. Solo, two and a half days, on top of an existing Auto.js fork called AutoX and Charm's Crush as the agent runtime. The architecture ended up being closer to what production agents like Perplexity Comet use than I expected, and the bugs that bit me were not the ones I planned for.&lt;/p&gt;

&lt;p&gt;I wanted my phone to do the boring stuff. Send a WhatsApp message to a contact. Open Spotify and play a song. Scroll Instagram and like a few posts. Stuff Siri and Google Assistant pretend to do but don't actually finish.&lt;/p&gt;

&lt;p&gt;Here's the honest writeup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this could even run on a phone
&lt;/h2&gt;

&lt;p&gt;Most agent runtimes are Python. Python is hostile to Android: no good way to ship the interpreter in your APK without dragging in 50MB+ of CPython and fighting NDK quirks. I needed the agent to run on the user's phone, not on some server they'd have to host.&lt;/p&gt;

&lt;p&gt;Charm's Crush is written in Go. Go cross-compiles to Android arm64 in one command. The whole runtime fits in a &lt;code&gt;libcrush.so&lt;/code&gt; native library that I bundle into the APK alongside AutoX. The agent runs entirely on-device. The only network call is to the user's chosen LLM API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Untethered, no laptop, no ADB at runtime
&lt;/h2&gt;

&lt;p&gt;LetItDo runs untethered. No USB cable, no ADB connection at runtime, no laptop hosting the agent. The closest research projects (AppAgent from Tencent, Mobile-Agent from Alibaba, DroidBot-GPT) all require the agent to live on a laptop and control the phone via ADB. Their architecture works fine for research demos but breaks the moment the user isn't sitting at a desk with a USB-C cable.&lt;/p&gt;

&lt;p&gt;LetItDo is a regular Android app the user installs once and uses with their voice. The only one-time setup is &lt;code&gt;adb shell pm grant com.letitdo.v7 android.permission.WRITE_SECURE_SETTINGS&lt;/code&gt; for the OEM-survival trick I'll cover below. After that, no ADB. The phone is the whole stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first wrong intuition: vision is the answer
&lt;/h2&gt;

&lt;p&gt;I copied the architecture from browser-harness, which is a small Python harness that connects an LLM to your real Chrome via CDP. It works because the LLM has vision. The agent calls &lt;code&gt;capture_screenshot&lt;/code&gt;, the host renders the PNG to the model, the model picks pixel coordinates, the harness calls &lt;code&gt;click_at_xy&lt;/code&gt;. There is one click primitive. No selectors. The whole loop is built around the assumption that the model can see.&lt;/p&gt;

&lt;p&gt;I tried to translate this to Android. Took screenshots. Sent them to qwen-plus. The model replied "I cannot see images." Because qwen-plus is text only.&lt;/p&gt;

&lt;p&gt;Production agents have already chosen the answer. Comet calls &lt;code&gt;Accessibility.getFullAXTree&lt;/code&gt; first, screenshots only as fallback. OpenAI Operator uses a hybrid (AX tree primary, vision for charts and captchas). Browser-harness leans vision because their LLM has eyes. I copied the wrong template. The right one is whatever Comet does, even if you have a vision model, because cheap-first cascade beats one-shot vision in cost, latency, and reliability.&lt;/p&gt;

&lt;p&gt;So LetItDo's interaction layer became a cascade, cheapest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;exact a11y text/desc/id (free, instant)&lt;/li&gt;
&lt;li&gt;substring a11y (textContains/descContains)&lt;/li&gt;
&lt;li&gt;fuzzy a11y tree (Levenshtein on dumped nodes, catches STT typos like Shawn → Shaun)&lt;/li&gt;
&lt;li&gt;OCR (Paddle, on-device, ~2s, fallback for Canvas/WebView)&lt;/li&gt;
&lt;li&gt;vision (multimodal LLM, opt-in, only when 1-4 fail)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each layer earns its slot maybe 1% of the time more than the layer above it. The cascade exists because no single layer is right for every surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that took 90 seconds and 11 round-trips to find
&lt;/h2&gt;

&lt;p&gt;I told the agent: "send hello to Shaun on WhatsApp." It opened WhatsApp. Then it tapped what looked like the right element. The chat did not open. It tapped again. It scrolled. It went into Settings somehow. After 11 round-trips and 90 seconds it gave up.&lt;/p&gt;

&lt;p&gt;The actual problem: WhatsApp's chat list has the contact name "Shaun" rendered as a TextView that is &lt;code&gt;clickable=false&lt;/code&gt;. The clickable element is a parent LinearLayout four levels up the tree. The avatar to the left of the name has content-description "Shaun picture" and IS clickable, but tapping it opens the profile preview, not the chat.&lt;/p&gt;

&lt;p&gt;When the agent fuzzy-matched "Shawn" (STT typo of Shaun) against the screen, OCR found the text glyph. The agent clicked at the glyph's bounding box center. Android's hit testing routed that to whichever clickable ancestor wanted it, and on Vivo's WhatsApp build that turned out to be the avatar's tap zone, not the row's. So we tapped the profile icon and opened a contact preview instead of the chat.&lt;/p&gt;

&lt;p&gt;The fix was a five-line walk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tap_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clickable&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find the text node. Walk up the parent chain. Stop at the first clickable ancestor. Click that, not the leaf. &lt;code&gt;AccessibilityService.performAction(ACTION_CLICK)&lt;/code&gt; fires on the row container. Chat opens. 12 seconds.&lt;/p&gt;

&lt;p&gt;This is the exact pattern Comet uses on the web. Their accessibility tree parser walks up from text nodes to clickable ancestors before reporting click targets to the model. I had to rediscover it for Android because I started from the wrong template.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other bug: bounds were always null
&lt;/h2&gt;

&lt;p&gt;The structured tree dump I had been shipping for two days was returning nodes without coordinates. Every "smoke test" I had run actually used a different code path (AutoX's UiObject, which has working &lt;code&gt;.bounds()&lt;/code&gt;) instead of raw AccessibilityNodeInfo (which doesn't). The function name is the same. The return shape is different.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong:&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;bounds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundsInScreen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// returns void, not Rect&lt;/span&gt;

&lt;span class="c1"&gt;// Right:&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;rect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;graphics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Rect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundsInScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getBoundsInScreen&lt;/code&gt; takes an out-parameter. Calling it bare returns nothing. Every node in my tree dump had cx and cy as null. None of my "tests" caught it because I was checking different stuff. The second I actually filtered for cx and got back zero results out of 220 nodes, the bug was obvious.&lt;/p&gt;

&lt;p&gt;This is a personal lesson, not a technical one. Smoke-test every helper on the device the day you write it, before you build anything on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OEM problem nobody talks about
&lt;/h2&gt;

&lt;p&gt;Vivo, Xiaomi, Oppo, OnePlus, Huawei phones aggressively kill background services to save battery. Android's accessibility service is one of the services they kill. So even when the user grants accessibility access to your app, the OEM's battery manager turns it off later. The app keeps running. Its permissions look fine in Settings. But &lt;code&gt;auto.service&lt;/code&gt; is null. Every script throws "Accessibility service is not started."&lt;/p&gt;

&lt;p&gt;This is also what kills Panda (an open-source Android voice agent in this space). Their issue tracker has #275 about Xiaomi/Huawei battery management as an unresolved roadmap item, plus a Reddit complaint that Android revokes Panda's permissions every few hours with no recovery.&lt;/p&gt;

&lt;p&gt;The fix is mildly nuclear:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request &lt;code&gt;WRITE_SECURE_SETTINGS&lt;/code&gt; via ADB once at install (&lt;code&gt;adb shell pm grant com.letitdo.v7 android.permission.WRITE_SECURE_SETTINGS&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Watchdog WorkManager fires every 15 minutes. Reads the secure setting &lt;code&gt;enabled_accessibility_services&lt;/code&gt;. If our component isn't in the list, write it back.&lt;/li&gt;
&lt;li&gt;Pre-flight check before each agent run. If the service isn't bound (verified via local TCP ping to our bridge), call &lt;code&gt;heal()&lt;/code&gt; which writes the setting and waits up to 5s for the system to rebind.&lt;/li&gt;
&lt;li&gt;Mid-flight retry. If the agent's run_script call fails AND &lt;code&gt;auto.service&lt;/code&gt; is null when the call returned, heal once and retry the same script.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In practice users see nothing. The accessibility service stays bound across OEM kill cycles. They speak a command, the agent runs, no setup ceremony.&lt;/p&gt;

&lt;p&gt;Is this hostile to Google's design? A little. Google bans Sova (another Android voice agent) from the Play Store specifically because Sova uses the accessibility API for "universal automation." LetItDo will probably never reach the Play Store either. Both apps have to live as sideloads. Sova self-hosts the APK. I'll do the same when I open early access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skill capture: the agent writes its own playbooks
&lt;/h2&gt;

&lt;p&gt;This is the part I'm most happy with.&lt;/p&gt;

&lt;p&gt;The first time the agent solves "turn on flashlight," it flails. It tries AutoX's &lt;code&gt;device.flash&lt;/code&gt; which doesn't exist on this device. It tries opening the quick settings panel and tapping the torch tile. It tries hardware key shortcuts. After about ten attempts it lands on &lt;code&gt;android.hardware.camera2.CameraManager.setTorchMode(cameraId, true)&lt;/code&gt; and the flashlight turns on.&lt;/p&gt;

&lt;p&gt;Crush has a built-in &lt;code&gt;write&lt;/code&gt; tool. The system prompt tells the agent: after a successful task, write a SKILL.md to the skills directory describing what worked. The agent does this on its own, unprompted past the system message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;---&lt;/span&gt;
&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;flashlight&lt;/span&gt;
&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Turn on/off the device flashlight using CameraManager.setTorchMode.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;Use&lt;/span&gt; &lt;span class="nx"&gt;when&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="nx"&gt;asks&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;turn&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt; &lt;span class="nx"&gt;flashlight&lt;/span&gt; &lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="nx"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt;

&lt;span class="nx"&gt;Turn&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="nf"&gt;importClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;android&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hardware&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;camera2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CameraManager&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;importClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;android&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSystemService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CAMERA_SERVICE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cameraId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCameraIdList&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTorchMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cameraId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nl"&gt;Gotcha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flash&lt;/span&gt; &lt;span class="nx"&gt;may&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;exist&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="nx"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="nx"&gt;Use&lt;/span&gt; &lt;span class="nx"&gt;CameraManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setTorchMode&lt;/span&gt; &lt;span class="nx"&gt;instead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crush's progressive disclosure injects all skill metadata into the system prompt at session start. When a relevant skill matches, the body gets loaded. Verified in the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO Skill turn summary component=skills
prompt_len=24 active_total=7
loaded_total=1 loaded_this_turn=[flashlight]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next "turn on flashlight" command: 14 seconds total, single round-trip, exact recipe replay. From 90s to 14s the second time.&lt;/p&gt;

&lt;p&gt;First time the skill loop closed end-to-end I sat there for a minute. The agent had spent 90 seconds flailing on "turn on flashlight" the first time. Wrote itself a SKILL.md when it finally got CameraManager.setTorchMode working. The next prompt, the agent loaded the skill, ran the cached recipe verbatim, finished in 14 seconds. From the outside it looks like nothing. But that's the thing improving itself, on a phone, without me touching it. After that I knew this was real.&lt;/p&gt;

&lt;h2&gt;
  
  
  AutoX is the other half of the stack
&lt;/h2&gt;

&lt;p&gt;If Crush is the agent brain, AutoX is the body. It's an Auto.js fork that's been quietly maintained for years. Out of the box it gave me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A bound AccessibilityService running in a separate &lt;code&gt;:script&lt;/code&gt; process. This is what lets us read and tap the UI tree.&lt;/li&gt;
&lt;li&gt;A Rhino JavaScript engine with full access to Android's Java APIs via &lt;code&gt;importClass&lt;/code&gt;. The agent writes JS that calls &lt;code&gt;android.hardware.camera2.CameraManager&lt;/code&gt; directly. No native bridge to maintain.&lt;/li&gt;
&lt;li&gt;A scripting surface (&lt;code&gt;text("Send").findOne()&lt;/code&gt;, &lt;code&gt;click(x, y)&lt;/code&gt;, &lt;code&gt;app.launch("com.spotify.music")&lt;/code&gt;, &lt;code&gt;device.width&lt;/code&gt;, &lt;code&gt;setClip("hi")&lt;/code&gt;, &lt;code&gt;http.get(url)&lt;/code&gt;) that already covers most automation primitives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots without MediaProjection.&lt;/strong&gt; This is the big one. The standard Android way to grab the screen is &lt;code&gt;MediaProjection&lt;/code&gt;, which pops up a "Start recording or casting?" dialog every single capture. That kills any voice-agent UX. AutoX's &lt;code&gt;auto.takeScreenshot()&lt;/code&gt; uses an accessibility-API path that doesn't trigger the prompt. The user grants accessibility once at install; nothing else interrupts them. Vision flows just work.&lt;/li&gt;
&lt;li&gt;Bundled Paddle OCR. ~2s per screen, on-device, no network. We use it as layer 4 of the cascade.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;:script&lt;/code&gt; process boundary that keeps accessibility crashes from killing the main app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without AutoX I'd have written all of this myself: the accessibility service binding, the JS-to-Android bridge, the screenshot capture without MediaProjection (which is its own sharp-edged research project), the gesture dispatcher. Probably two more weekends of pure scaffolding work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had to build vs what I got from AutoX and Crush
&lt;/h2&gt;

&lt;p&gt;LetItDo is mostly the glue between two existing projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crush gives the agent&lt;/strong&gt;: a working LLM loop, OpenAI-compatible multi-provider support (OpenAI, Anthropic, Google, DashScope, Groq, Cerebras, OpenRouter, local Ollama), the Agent Skills standard with progressive disclosure, conversation compaction so long sessions don't blow up the context window, sub-agent spawning, and the MCP tool calling protocol.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AutoX gives the phone&lt;/strong&gt;: a bound AccessibilityService, a Rhino JS engine with full access to Android's Java APIs, screenshots without MediaProjection prompts, on-device Paddle OCR, gesture dispatch, and a scripting surface that already covers most of what an automation agent needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I actually built&lt;/strong&gt;: the bridge that lets Crush's MCP tools call into AutoX's accessibility surface. The JS helpers the agent uses to discover and tap UI elements (&lt;code&gt;read_screen&lt;/code&gt;, &lt;code&gt;tap_text&lt;/code&gt; with walk-up-to-clickable, the fuzzy cascade). The OEM survival mechanism. The voice frontend, the result UI, the skill seeding, the on-device service watchdog. Two and a half days of glue and one critical insight (a11y tree first, vision second).&lt;/p&gt;

&lt;p&gt;If LetItDo is interesting, AutoX and Crush deserve most of the credit. I'm being explicit about this because it's the truth and because it tells you what's actually novel here: not the agent, not the phone control, but the combination plus the OEM trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest speed numbers
&lt;/h2&gt;

&lt;p&gt;Single-action tasks like "turn on flashlight" floor at 12-18s. Two LLM round-trips per task (decide → run_script → summarize), each ~5-7s on Qwen DashScope. The visible action itself is 30-50ms. The structural floor is the round-trip count. Persistent daemon between prompts saves ~2s of cold-start. Switching to Groq or Cerebras for sub-1s inference saves another 8s. Neither shipped yet.&lt;/p&gt;

&lt;p&gt;Multi-action tasks like Instagram engagement feel faster than single-action because the agent batches: one &lt;code&gt;run_script&lt;/code&gt; with a for-loop over 5 reels = 1 LLM round-trip for 5 actions. Visible activity hides the LLM wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  What hasn't shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Persistent Crush daemon between prompts. Right now every voice command spawns a fresh process. Could be ~0s cold-start with a long-running daemon listening on stdin or a socket.&lt;/li&gt;
&lt;li&gt;Vision pipeline with cost meter. Vision works (qwen3-vl-plus, gpt-4o, gemini-flash all forward image content correctly) but burns tokens. There's no usage display in the UI yet.&lt;/li&gt;
&lt;li&gt;Cross-prompt memory. Each Crush invocation is a fresh session. Saying "send hi to Shaun" then "make it three exclamation marks" doesn't work; the second prompt has no idea what "it" refers to.&lt;/li&gt;
&lt;li&gt;Play Store distribution. Same accessibility-policy reason Sova got banned will likely catch LetItDo. Sideload only.&lt;/li&gt;
&lt;li&gt;iOS. iOS has no equivalent of AccessibilityService for third-party apps. The whole architecture is non-portable.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If you want early access, the waitlist is here: &lt;a href="https://tally.so/r/jaGvx9" rel="noopener noreferrer"&gt;https://tally.so/r/jaGvx9&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open questions I'd like feedback on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Is "untethered Android voice agent" a category, or just a feature Google will eventually ship in Gemini? (Their Android AppFunctions API in Feb 2026 suggests yes.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Should LetItDo's skill library be private to the user (current state), community-shared (PR-style like browser-harness), or auto-synced via cloud for everyone's benefit (network effects but governance nightmares)?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vision is gated behind model selection. qwen3-vl-plus works, qwen-plus doesn't. Is the right answer to require vision for everyone, or is the a11y-tree-first design good enough that vision stays optional?&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you've got opinions, the comments or my DMs are open.&lt;/p&gt;

&lt;p&gt;The closing lesson if you're starting something similar: smoke-test every helper on the device the day you write it. The bug that wasted my Day 3 was a function that had been silently returning null bounds for 48 hours. None of my "tests" caught it because they all hit a different code path. Two days of debugging that should have been two minutes if I'd checked output once. Speed of iteration on a real device is the whole game.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>android</category>
      <category>automation</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Streak apps taught me one missed day undoes six weeks. The science says it doesn't, so I rebuilt my habits.</title>
      <dc:creator>22Gstudios</dc:creator>
      <pubDate>Wed, 29 Apr 2026 03:06:43 +0000</pubDate>
      <link>https://forem.com/22gstudios/streak-apps-taught-me-one-missed-day-undoes-six-weeks-the-science-says-it-doesnt-so-i-rebuilt-my-53ol</link>
      <guid>https://forem.com/22gstudios/streak-apps-taught-me-one-missed-day-undoes-six-weeks-the-science-says-it-doesnt-so-i-rebuilt-my-53ol</guid>
      <description>&lt;h2&gt;
  
  
  Five apps. One missed Tuesday. Quit.
&lt;/h2&gt;

&lt;p&gt;I deleted Streaks, Habitica, Way of Life, Fabulous, and Habit Coach AI in the same month. Not because they were bad apps. Because every single one of them used the same loop, and that loop was breaking me.&lt;/p&gt;

&lt;p&gt;Build a streak. Miss a day. Watch the counter go to zero. Feel like the last six weeks were a lie. Open the app the next morning and not check anything in, because what's the point now. Quietly delete it from the home screen a week later.&lt;/p&gt;

&lt;p&gt;It is wild how much guilt a single number can carry.&lt;/p&gt;

&lt;p&gt;The bit that finally got to me was the asymmetry. A run of forty good days felt like nothing, just background. One missed Tuesday felt like a personal indictment. The app was teaching me that progress is fragile and failure is permanent. That is the opposite of what I wanted to learn.&lt;/p&gt;

&lt;p&gt;So I went looking for the actual research on how habits form, expecting to find some nuance. What I found was much stranger than that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the research actually says
&lt;/h2&gt;

&lt;p&gt;The most cited paper in this space is Lally et al. (2010), &lt;em&gt;European Journal of Social Psychology&lt;/em&gt;. They followed 96 people forming new habits at University College London for 12 weeks. Two of their findings sit completely outside the worldview of every streak app I had ever used.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Habit formation follows an asymptotic curve, not a streak.&lt;/strong&gt; The average time for a new behaviour to feel automatic was 66 days, but the range was 18 to 254 days, depending on the habit and the person. The path to "automatic" is a curve that bends slowly, not a chain of identical links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing a single day had no statistically significant effect on the final outcome.&lt;/strong&gt; One miss does not break a habit. The curve barely moves. The thing that streak counters spend all their visual design screaming about, the broken chain, simply does not show up in the data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Read those two sentences again. Every single streak app I had used was built on a model the most cited paper in the field quietly contradicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;I built a small thing called Imperfectly around those two findings.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your personal Lally curve.&lt;/strong&gt; Instead of a streak, you see a curve fitted to your own check-ins. The shape of the curve is the progress, not the count. A missed day is a tiny dip, not a reset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An estimated day to automaticity.&lt;/strong&gt; A projected date based on your data so far, with the asymptotic curve doing the math. It moves up when you're consistent and slides a little when you're not. It never crashes to zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A soft message when you miss a day.&lt;/strong&gt; No fire emoji going dark. No "you broke your chain." Just a note that says, in effect, "the curve barely moved, keep going."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No streak counter. No gamification. No signup, no email required.&lt;/p&gt;

&lt;p&gt;You can try it here: &lt;a href="https://imperfectly.cc/" rel="noopener noreferrer"&gt;https://imperfectly.cc/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am genuinely curious about
&lt;/h2&gt;

&lt;p&gt;The thing I cannot tell from the inside is whether this design actually feels better, or whether it just relocates the anxiety. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does seeing a curve (instead of a streak) feel motivating?&lt;/li&gt;
&lt;li&gt;Or does the projected day to automaticity start to feel like a soft deadline, the same way a streak counter does?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it for a few days, I would love to hear which way it lands for you. The whole thing only works if it feels like permission, not pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference
&lt;/h2&gt;

&lt;p&gt;Lally, P., van Jaarsveld, C. H. M., Potts, H. W. W., &amp;amp; Wardle, J. (2010). How are habits formed: Modelling habit formation in the real world. &lt;em&gt;European Journal of Social Psychology&lt;/em&gt;, 40(6), 998-1009.&lt;/p&gt;

</description>
      <category>habits</category>
      <category>productivity</category>
      <category>psychology</category>
    </item>
  </channel>
</rss>
