<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: s t</title>
    <description>The latest articles on DEV Community by s t (@s_t_ae6f13c69a431fda2ee4b).</description>
    <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4192034%2F3f875d9f-73c5-4140-a474-85279f133d70.png</url>
      <title>DEV Community: s t</title>
      <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/s_t_ae6f13c69a431fda2ee4b"/>
    <language>en</language>
    <item>
      <title>How a desktop pet finds out your AI agent has finished a task</title>
      <dc:creator>s t</dc:creator>
      <pubDate>Sun, 18 Oct 2026 22:11:31 +0000</pubDate>
      <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b/how-a-desktop-pet-finds-out-your-ai-agent-has-finished-a-task-7ca</link>
      <guid>https://dev.to/s_t_ae6f13c69a431fda2ee4b/how-a-desktop-pet-finds-out-your-ai-agent-has-finished-a-task-7ca</guid>
      <description>&lt;p&gt;CatCode is a pixel pet that lives on the Windows desktop and reacts when a coding agent finishes a task. This is a walkthrough of how that detection works, taken from the app's code (an Electron app). It is simpler than you might expect, and support differs by agent, so I will be specific about what is and is not covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  No log scraping, no process polling
&lt;/h2&gt;

&lt;p&gt;CatCode does not tail agent logs, inspect processes or read any agent's internal files. I checked: there is no such code. The whole mechanism is a &lt;strong&gt;drop folder&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.catcode/events/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that can write a small JSON file there can tell the pet a task is done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Codex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"task"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"update the UI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"finished the check"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire protocol: &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;task&lt;/code&gt; and &lt;code&gt;message&lt;/code&gt;. Missing values fall back to defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  The watcher
&lt;/h2&gt;

&lt;p&gt;The main process consumes those files with &lt;code&gt;fs.watch&lt;/code&gt;. Trimmed from &lt;code&gt;main.js&lt;/code&gt;:&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;consumeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EVENTS_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlinkSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...append to a per-day log (only the last 3 days are kept)&lt;/span&gt;
  &lt;span class="nf"&gt;celebrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Task complete\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;watchEvents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EVENTS_DIR&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;consumeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EVENTS_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;file&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EVENTS_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="nf"&gt;consumeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;);&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;Details that come straight from this code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Events that piled up while the app was closed are consumed on startup.&lt;/li&gt;
&lt;li&gt;There is a 150 ms delay before reading, so a half-written file is less likely to be read. It is a mitigation, not a guarantee: the writer uses a plain &lt;code&gt;writeFileSync&lt;/code&gt;, and a file that fails to parse is deleted anyway.&lt;/li&gt;
&lt;li&gt;Each event file is deleted after it is handled. The app keeps a short in-memory list of recent tasks and a three-day per-agent log for a small "agent report" window.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Claude Code: a real hook
&lt;/h2&gt;

&lt;p&gt;For Claude Code the trigger is its &lt;code&gt;Stop&lt;/code&gt; hook. The app has a tray item that writes the hook into &lt;code&gt;~/.claude/settings.json&lt;/code&gt; for you. The resulting entry has this shape (the path to the script is yours to set):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node C:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;catcode&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;tools&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;emit-event.js &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Claude Code&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;finished the task&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;emit-event.js&lt;/code&gt; is about 30 lines: it creates the folder if needed and writes &lt;code&gt;{source, task, message}&lt;/code&gt; as a timestamped &lt;code&gt;.json&lt;/code&gt; file. It also accepts JSON on stdin and uses &lt;code&gt;session_id&lt;/code&gt; as the task, if you call it without arguments.&lt;/p&gt;

&lt;p&gt;Two honest caveats. First, &lt;code&gt;Stop&lt;/code&gt; fires when Claude Code finishes responding, so "task finished" really means "the agent stopped and handed control back". Second, the hook command starts with &lt;code&gt;node&lt;/code&gt;, so Node.js needs to be on your PATH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Codex and Cursor: partial, and manual
&lt;/h2&gt;

&lt;p&gt;This is the part to read carefully. The code has &lt;strong&gt;no Codex-specific or Cursor-specific integration&lt;/strong&gt;. No config file for either is written, and nothing watches either tool. The names appear in the README, in the description, and in the tray's "test" button, which just plays a canned message.&lt;/p&gt;

&lt;p&gt;What exists is the generic path: if your tool has a "run a command when finished" mechanism, call the same script with a different source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node tools/emit-event.js &lt;span class="s2"&gt;"Codex"&lt;/span&gt; &lt;span class="s2"&gt;"update the UI"&lt;/span&gt; &lt;span class="s2"&gt;"finished the check"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it has no such hook, you can run that line by hand. The pet reacts identically. So: Claude Code has a tray item that writes the hook for you (or you add it by hand, as above), Codex and Cursor work only if you wire a completion command yourself, and I can't tell you from this code whether either tool offers a suitable hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pet does about it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;celebrate()&lt;/code&gt; sends the &lt;code&gt;happy&lt;/code&gt; state to the renderer, shows a speech bubble for 6 seconds, then restores whatever state the pet was in before.&lt;/p&gt;

&lt;p&gt;There is no sprite sheet. The pet is drawn procedurally: &lt;code&gt;Sprite.makeRenderer(28)&lt;/code&gt; creates a 28x28 offscreen canvas, and primitives such as &lt;code&gt;pdisc&lt;/code&gt;, &lt;code&gt;ptri&lt;/code&gt; and &lt;code&gt;pline&lt;/code&gt; plot pixels, which are then scaled up (110, 140 or 180 px depending on the size setting). A &lt;code&gt;pose()&lt;/code&gt; function maps the state to parameters. For &lt;code&gt;happy&lt;/code&gt;, the code makes the pet bounce, smile, stick out its tongue and wag its tail faster, while the renderer spawns sparks and hearts.&lt;/p&gt;

&lt;p&gt;The full state list is: &lt;code&gt;idle&lt;/code&gt;, &lt;code&gt;working&lt;/code&gt;, &lt;code&gt;waiting&lt;/code&gt;, &lt;code&gt;sleeping&lt;/code&gt;, &lt;code&gt;happy&lt;/code&gt;, &lt;code&gt;play&lt;/code&gt;, &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;pull&lt;/code&gt;. Note that &lt;code&gt;working&lt;/code&gt; (the laptop pose) is set by the Pomodoro focus timer or by hand from the tray, not by agent activity. The pet never knows a task is &lt;em&gt;in progress&lt;/em&gt;, only that one ended.&lt;/p&gt;

&lt;p&gt;Skins are small JSON files rather than images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ginger-cat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"species"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tabby"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"palette"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#f2954e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"shade"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#c26a2c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"belly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#fbe0c2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#b8531f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"eye"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#4fae5a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"nose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#d98c9a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#f0a9b3"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Species, pattern (&lt;code&gt;solid&lt;/code&gt;, &lt;code&gt;tabby&lt;/code&gt;, &lt;code&gt;patch&lt;/code&gt;), optional accessory and a palette drive the same drawing code, so a new colour scheme needs no art.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest, briefly
&lt;/h2&gt;

&lt;p&gt;It also does the desktop-pet things: eyes and head follow the cursor, Pomodoro focus and break timers, water and stretch reminders, user reminders, and skin import. Memes and images from the internet are off by default and need to be enabled in the tray. The privacy note lists the services the app contacts directly: ipapi.co and open-meteo.com for weather, plus meme/image services only if you opt in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;CatCode is a Windows app, and the source is closed (proprietary EULA). It costs $5: &lt;a href="https://chvdbox.gumroad.com/l/tlwwyz?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=catcode" rel="noopener noreferrer"&gt;CatCode on Gumroad&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The installer is not code-signed, so Windows SmartScreen will show an "unknown publisher" warning on first launch.&lt;/p&gt;

&lt;p&gt;Disclosure: I am the author of CatCode.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>windows</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Telegram's WhatsApp import drops your photos: why it happens and how to fix it</title>
      <dc:creator>s t</dc:creator>
      <pubDate>Sun, 18 Oct 2026 12:11:24 +0000</pubDate>
      <link>https://dev.to/s_t_ae6f13c69a431fda2ee4b/telegrams-whatsapp-import-drops-your-photos-why-it-happens-and-how-to-fix-it-3c3o</link>
      <guid>https://dev.to/s_t_ae6f13c69a431fda2ee4b/telegrams-whatsapp-import-drops-your-photos-why-it-happens-and-how-to-fix-it-3c3o</guid>
      <description>&lt;p&gt;You exported a WhatsApp chat, imported it into Telegram, and the text arrived. Every photo, video and voice message turned into a line of text like &lt;code&gt;&amp;lt;attached: IMG-20231104-WA0007.jpg&amp;gt;&lt;/code&gt;. The memories are gone and only placeholders remain.&lt;/p&gt;

&lt;p&gt;You did nothing wrong, and your export is fine. This is a known Telegram bug that has been open for over a year on Telegram's own tracker (&lt;a href="https://bugs.telegram.org/c/49513" rel="noopener noreferrer"&gt;bugs.telegram.org/c/49513&lt;/a&gt;), with the same complaint in &lt;a href="https://bugs.telegram.org/c/39114" rel="noopener noreferrer"&gt;other bug threads&lt;/a&gt;, r/Telegram and Superuser. The text transfers and the media is silently skipped.&lt;/p&gt;

&lt;p&gt;This post explains why it happens, and how to import a WhatsApp chat into Telegram with the original dates and the media actually attached: photos, videos, voice messages, round video notes and stickers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the import loses your media
&lt;/h2&gt;

&lt;p&gt;A WhatsApp export is a &lt;code&gt;.zip&lt;/code&gt; (or folder) containing a &lt;code&gt;_chat.txt&lt;/code&gt; transcript plus the media files. Inside the transcript each media item is only a reference, such as &lt;code&gt;IMG-20231104-WA0007.jpg (file attached)&lt;/code&gt;. The bytes live in separate image, video and audio files next to the transcript.&lt;/p&gt;

&lt;p&gt;Telegram's history import is a multi-step flow. The client is supposed to initialize the import, upload each referenced media file, then finalize so the server stitches the media back onto the right messages. From what I can tell, the problem is in the middle step: the text arrives, but the media never gets uploaded, so the server never receives the files and every attachment stays a text stub. Telegram has not confirmed the exact cause. This is my reading of the API flow, not an official statement.&lt;/p&gt;

&lt;p&gt;In one sentence: the media is in your export, but the app's import never gets it to Telegram's servers. Nothing you change on the WhatsApp side fixes that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: do the media upload step yourself
&lt;/h2&gt;

&lt;p&gt;Telegram exposes an official history-import API with the three calls the app is meant to use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;messages.initHistoryImport&lt;/code&gt; starts the import for a chat.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;messages.uploadImportedMedia&lt;/code&gt; uploads each photo, video, voice note, round video and sticker. This is the step that appears to be missing from the app's import.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;messages.startHistoryImport&lt;/code&gt; finalizes, so the server attaches every file to the correct message and keeps the original send dates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;tg-import&lt;/strong&gt; is a small Windows tool I wrote that drives all three calls. It reads a WhatsApp export (&lt;code&gt;.zip&lt;/code&gt; or folder) or a Telegram Desktop export (&lt;code&gt;result.json&lt;/code&gt;), matches each &lt;code&gt;&amp;lt;attached: ...&amp;gt;&lt;/code&gt; reference to its real file, and performs the upload step properly.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transfers photos, videos, voice messages, round video notes and stickers instead of placeholders.&lt;/li&gt;
&lt;li&gt;Preserves original dates (see the 1:1 limit below).&lt;/li&gt;
&lt;li&gt;Has a dry-run preview (&lt;code&gt;--dry&lt;/code&gt;) that shows what will be imported with no login and no upload.&lt;/li&gt;
&lt;li&gt;Resumes after a crash instead of re-uploading everything.&lt;/li&gt;
&lt;li&gt;Uploads in batches and handles Telegram's flood limits.&lt;/li&gt;
&lt;li&gt;Ships as a single Windows &lt;code&gt;.exe&lt;/code&gt;, so there is no Python to install.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Export your chat. In WhatsApp open the chat, choose Export chat, then &lt;strong&gt;Include media&lt;/strong&gt;. (Or export from Telegram Desktop as &lt;code&gt;result.json&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;Create a free &lt;code&gt;api_id&lt;/code&gt; / &lt;code&gt;api_hash&lt;/code&gt; at &lt;a href="https://my.telegram.org" rel="noopener noreferrer"&gt;my.telegram.org&lt;/a&gt;. These are your own credentials and nothing is sent to a third party.&lt;/li&gt;
&lt;li&gt;Preview first. Run tg-import with &lt;code&gt;--dry&lt;/code&gt; to see every message and media file it will import.&lt;/li&gt;
&lt;li&gt;Run the import against a private 1:1 chat. It does the init, upload and start sequence, batches uploads, and resumes if interrupted.&lt;/li&gt;
&lt;li&gt;Check the chat. Your media is there on the original dates, with Telegram's small "Imported" label.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Limits you should know before you try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Original dates only land correctly in a private 1:1 chat.&lt;/strong&gt; This is a Telegram server limitation, not a tool bug. Importing into a group or channel will not place messages on their original dates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk import through the user API is a gray area under Telegram's Terms of Service.&lt;/strong&gt; tg-import uses your own account and credentials, so it is your account and your own risk. The tool paces requests for flood limits, but it cannot remove the ToS question. If that risk is not acceptable to you, this is not the right tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You supply your own &lt;code&gt;api_id&lt;/code&gt; and &lt;code&gt;api_hash&lt;/code&gt;.&lt;/strong&gt; They are free from my.telegram.org.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;tg-import costs $9 on &lt;a href="https://chvdbox.gumroad.com/l/amycht?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tg-import" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt;. Run the &lt;code&gt;--dry&lt;/code&gt; preview first: no login, no upload.&lt;/p&gt;

&lt;p&gt;Disclosure: I am the author of tg-import.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>whatsapp</category>
      <category>python</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
