<?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: Qiushi</title>
    <description>The latest articles on DEV Community by Qiushi (@qiushiwu).</description>
    <link>https://dev.to/qiushiwu</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%2F3812196%2Fde926bc0-f990-406d-a420-1ff18c81f717.png</url>
      <title>DEV Community: Qiushi</title>
      <link>https://dev.to/qiushiwu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/qiushiwu"/>
    <language>en</language>
    <item>
      <title>Teaching an AI to Attend Your Teams Meeting: A Real-Time Voice Pipeline</title>
      <dc:creator>Qiushi</dc:creator>
      <pubDate>Sat, 14 Mar 2026 22:25:48 +0000</pubDate>
      <link>https://dev.to/qiushiwu/teaching-an-ai-to-attend-your-teams-meeting-a-real-time-voice-pipeline-130b</link>
      <guid>https://dev.to/qiushiwu/teaching-an-ai-to-attend-your-teams-meeting-a-real-time-voice-pipeline-130b</guid>
      <description>&lt;p&gt;Microsoft Teams has a lot of AI features now. Copilot can summarize meetings after the fact. It can generate action items. It can transcribe. What it cannot do — at least not in any form that worked for us — is let you summon an AI agent mid-meeting with a wake word and have a real conversation, with the AI hearing everyone, speaking back, and remembering context from the whole call.&lt;/p&gt;

&lt;p&gt;We wanted that. So we built it.&lt;/p&gt;

&lt;p&gt;The result is an open-source voice pipeline that integrates a Claude-backed AI agent into live Teams meetings. You say a wake word, the AI activates, hears your question in context, and responds in natural speech — all in real time, with echo cancellation so it doesn't confuse its own voice for input.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/QiushiWu95/teams-meeting-agent-public" rel="noopener noreferrer"&gt;teams-meeting-agent-public&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use the Teams API?
&lt;/h2&gt;

&lt;p&gt;Teams does have a calling API. You can add bots to meetings. But the meeting bot API is designed for structured integrations — transcription services, recording bots, meeting note-takers. The audio pipeline it exposes is not well-suited for a low-latency conversational agent that needs to hear all participants, do speaker identification, respond in under two seconds, and handle interruption.&lt;/p&gt;

&lt;p&gt;We also have specific constraints: we work across a Mac (where the Teams client runs) and a Linux GPU server (where we run inference). The GPU server is where we want the speech recognition — running faster-whisper on CUDA is significantly faster and more accurate than anything you can do on a Mac in real time. That means we need a bridge between two machines, with audio flowing across an SSH tunnel.&lt;/p&gt;

&lt;p&gt;The path of least resistance turned out to be: use PulseAudio virtual devices on the Linux side to intercept Teams audio, do all processing there, and build a custom WebSocket relay to coordinate everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mac (Teams client)                    Linux GPU Server
┌─────────────────────┐               ┌──────────────────────────────────┐
│                     │               │                                  │
│  Microsoft Teams    │◄──────────────│  teams_speaker (null-sink)       │
│  (speaker output)   │               │  teams_virtual_mic (null-sink)   │
│  (mic input)        │               │  teams_mic_input (virtual-source)│
│                     │               │                                  │
│  bridge.py          │◄─WebSocket────│  ws_relay.py (port 8765)         │
│  (wake word,        │───speak cmd──►│                                  │
│   transcript buf)   │               │  stt_pipeline.py                 │
│                     │               │  (faster-whisper + VAD)          │
│  OpenClaw Agent     │               │                                  │
│  (Claude + memory)  │               │  tts_pipeline.py                 │
│                     │               │  (Edge-TTS → PulseAudio)         │
└─────────────────────┘               │                                  │
         │                            │  speaker_id.py                   │
         └────────SSH tunnel──────────│  (ECAPA-TDNN voiceprints)        │
              (port 8765)             └──────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow for a single exchange:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Teams audio plays through &lt;code&gt;teams_speaker&lt;/code&gt; (a PulseAudio null-sink)&lt;/li&gt;
&lt;li&gt;STT pipeline captures &lt;code&gt;.monitor&lt;/code&gt; stream, runs VAD + speaker ID + whisper&lt;/li&gt;
&lt;li&gt;Transcript is sent via WebSocket to the Mac bridge&lt;/li&gt;
&lt;li&gt;Bridge detects wake word → buffers context → sends to OpenClaw agent over HTTP&lt;/li&gt;
&lt;li&gt;Agent generates reply → bridge sends &lt;code&gt;speak&lt;/code&gt; command back via WebSocket&lt;/li&gt;
&lt;li&gt;TTS pipeline synthesizes with Edge-TTS → streams to &lt;code&gt;teams_virtual_mic&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Teams hears the AI speaking through &lt;code&gt;teams_mic_input&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything except the agent LLM call runs locally. STT is on-device CUDA, TTS streams within 200ms of the first Edge-TTS chunk, and the whole round-trip from wake word to first spoken word is typically under two seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PulseAudio trick
&lt;/h2&gt;

&lt;p&gt;The whole system depends on a PulseAudio setup that most people haven't seen before. On the Linux server, we create three virtual audio devices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pactl load-module module-null-sink &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;sink_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;teams_speaker &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;sink_properties&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;device.description&lt;span class="o"&gt;=&lt;/span&gt;Teams_Speaker

pactl load-module module-null-sink &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;sink_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;teams_virtual_mic &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;sink_properties&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;device.description&lt;span class="o"&gt;=&lt;/span&gt;Teams_Virtual_Mic

pactl load-module module-virtual-source &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;source_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;teams_mic_input &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;master&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;teams_virtual_mic.monitor &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;source_properties&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;device.description&lt;span class="o"&gt;=&lt;/span&gt;Teams_Mic_Input
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;teams_speaker&lt;/code&gt; is a null-sink — audio goes in and plays to nothing. But null-sinks in PulseAudio automatically create a &lt;code&gt;.monitor&lt;/code&gt; source that exposes the audio as a readable stream. So by setting Teams' speaker output to &lt;code&gt;Teams_Speaker&lt;/code&gt;, we get &lt;code&gt;teams_speaker.monitor&lt;/code&gt; — a real-time PCM stream of everything Teams is playing, including all meeting participants. The STT pipeline reads from this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;teams_virtual_mic&lt;/code&gt; and &lt;code&gt;teams_mic_input&lt;/code&gt; work the same way in reverse. The TTS pipeline writes synthesized speech to &lt;code&gt;teams_virtual_mic&lt;/code&gt;. The monitor exposes it as a readable source (&lt;code&gt;teams_mic_input&lt;/code&gt;), which we set as Teams' microphone input. So when the AI "speaks", Teams hears it as a microphone signal.&lt;/p&gt;

&lt;p&gt;This is entirely transparent to Teams. It doesn't know it's talking to virtual devices. No API access required. No bot registration. The Linux server just appears to be a meeting participant with a very smart microphone.&lt;/p&gt;

&lt;p&gt;There's one complication: the virtual devices need to be created inside the Chrome Remote Desktop (CRD) PulseAudio session, not the system PulseAudio. CRD runs its own isolated PulseAudio daemon with a non-standard socket path. The startup script detects this automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PULSE_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SSH_ALIAS&lt;/span&gt;&lt;span class="k"&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;"cat /proc/&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;(pgrep -u &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;USER pulseaudio | tail -1)/environ 2&amp;gt;/dev/null &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
   | tr '&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s2"&gt;' '&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;' | grep PULSE_RUNTIME_PATH | cut -d= -f2"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the environment of the running PulseAudio process to find the socket path, then exports &lt;code&gt;PULSE_SERVER=unix:${PULSE_PATH}/native&lt;/code&gt; before every &lt;code&gt;pactl&lt;/code&gt; call. Everything else just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  STT pipeline: faster-whisper + VAD + echo cancellation
&lt;/h2&gt;

&lt;p&gt;The STT pipeline is where most of the interesting signal processing happens. It runs on the Linux server and has four responsibilities: capture audio, detect speech boundaries, transcribe, and suppress its own voice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audio capture&lt;/strong&gt; uses PulseAudio's &lt;code&gt;parec&lt;/code&gt; to stream raw PCM from &lt;code&gt;teams_speaker.monitor&lt;/code&gt; at 16kHz mono (the format faster-whisper expects). Audio comes in as continuous 30ms chunks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Voice activity detection&lt;/strong&gt; uses Silero VAD. The raw audio stream is chunked into 512-sample frames and fed to the model. The VAD runs fast enough that it doesn't add perceptible latency. Speech segments are accumulated until a silence gap triggers a transcription.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transcription&lt;/strong&gt; uses faster-whisper with &lt;code&gt;distil-large-v3&lt;/code&gt; on CUDA. Distil-large-v3 is a distilled version of Whisper large-v3 — comparable accuracy, roughly 5x faster. For Chinese-English mixed meetings (our primary use case), it handles code-switching without needing language hints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Echo cancellation&lt;/strong&gt; is the part that took the most tuning. Without it, the AI would transcribe its own TTS output, which creates feedback loops where it hears itself speaking and tries to respond. The solution is a &lt;code&gt;SpeakingState&lt;/code&gt; object that coordinates between the TTS and STT pipelines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SpeakingState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_speaking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_tail_suppress_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_speaking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_speaking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_tail_suppress_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ECHO_TAIL_SUPPRESS_SEC&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_suppressed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_speaking&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_tail_suppress_until&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When TTS starts playing, &lt;code&gt;set_speaking(True)&lt;/code&gt; is called. STT checks &lt;code&gt;is_suppressed()&lt;/code&gt; before processing any VAD-triggered segment. After TTS finishes, suppression continues for a configurable tail window (we use 0.8 seconds) to catch audio still draining through the PulseAudio buffer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Barge-in detection&lt;/strong&gt; is the other half of the interruption story. When a human speaks while the AI is talking, we want to stop the AI mid-sentence. This is done with energy-based detection rather than VAD, because VAD is too slow for a real-time interrupt trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Fast 200ms window vs slow 3.2s baseline
&lt;/span&gt;&lt;span class="n"&gt;fast_energy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
&lt;span class="n"&gt;slow_energy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3200&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fast_energy&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;slow_energy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;BARGE_IN_RATIO&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;trigger_interrupt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the fast window's energy spikes above a ratio of the slow baseline, it signals a barge-in. The TTS pipeline receives an interrupt command and stops playback immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speaker identification: voiceprint matching
&lt;/h2&gt;

&lt;p&gt;Not everything said in a meeting should reach the AI. You might want only the meeting host to be able to invoke the agent, or only people on your team. Speaker identification solves this.&lt;/p&gt;

&lt;p&gt;The implementation uses speechbrain's ECAPA-TDNN model — a speaker verification model that produces 192-dimensional speaker embeddings. For each audio segment, we extract an embedding and match it against a set of registered voiceprints using cosine similarity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;similarity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;voiceprint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SPEAKER_MATCH_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# 0.30 by default
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matched_speaker_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold of 0.30 is intentionally low — we'd rather have a false positive (recognizing an unknown speaker as known) than miss a legitimate user. For a trust boundary where only certain people can invoke the agent, you'd raise this.&lt;/p&gt;

&lt;p&gt;The model is language-agnostic. It works equally well for Chinese and English speakers, which matters for our meetings. Inference runs in under 10ms on CUDA, so it adds negligible latency to the transcription pipeline.&lt;/p&gt;

&lt;p&gt;Speaker identity flows into the WebSocket relay as a trust layer. Transcripts are tagged with either &lt;code&gt;verified&lt;/code&gt; (matched a known voiceprint) or &lt;code&gt;untrusted&lt;/code&gt; (unknown speaker). The bridge uses this to filter who can invoke the wake word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wake word detection and the bridge
&lt;/h2&gt;

&lt;p&gt;The bridge runs on the Mac. Its job is to sit between the Linux server and the OpenClaw agent, making routing decisions about what gets sent where.&lt;/p&gt;

&lt;p&gt;When a transcript arrives from the Linux server, the bridge checks for wake words using regex pattern matching. The wake word list is configurable — in our setup it includes "hey claude", "hey agent", and a few Chinese equivalents. The bridge also supports a "presentation mode" where the wake word requirement is relaxed and all transcripts flow through.&lt;/p&gt;

&lt;p&gt;When a wake word is detected, the bridge transitions to "engaged" mode: it buffers subsequent transcripts, accumulates context from multiple speakers, and flushes the buffer to the OpenClaw agent's HTTP API when there's a natural pause. This means the AI doesn't just hear one sentence — it gets a multi-turn context window of what was being discussed when it was summoned.&lt;/p&gt;

&lt;p&gt;The bridge also handles the response path. When OpenClaw generates a reply, the bridge sends a &lt;code&gt;speak&lt;/code&gt; command back to the Linux server's WebSocket relay:&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="nl"&gt;"cmd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"speak"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Here's the answer to your question..."&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;The TTS pipeline picks this up, synthesizes it, and plays it through the virtual microphone.&lt;/p&gt;

&lt;p&gt;Connection resilience is important here — meetings can last hours. The bridge implements automatic reconnection with exponential backoff and heartbeat pings to detect silent disconnections before they cause dropped transcripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  TTS pipeline: streaming synthesis
&lt;/h2&gt;

&lt;p&gt;Edge-TTS is a free, high-quality TTS service that produces natural-sounding speech. The limitation is that it doesn't stream — it waits for the full text to be synthesized before returning audio.&lt;/p&gt;

&lt;p&gt;We work around this with chunked generation. Edge-TTS internally streams MP3 data, and the &lt;code&gt;edge-tts&lt;/code&gt; Python library exposes this. We pipe the MP3 stream through ffmpeg to convert it to PCM on the fly, then write to PulseAudio as chunks arrive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# ffmpeg stdin
&lt;/span&gt;        &lt;span class="c1"&gt;# ffmpeg is already decoding and writing to PulseAudio
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is that the first audible audio plays within 200-300ms of the speak command arriving. For a meeting context where people are already talking and waiting for a response, this latency is barely perceptible.&lt;/p&gt;

&lt;p&gt;The TTS pipeline integrates with the same &lt;code&gt;SpeakingState&lt;/code&gt; used by STT. Before writing each chunk, it checks for an interrupt signal. If barge-in was detected while audio was being generated, playback stops mid-sentence and the pipeline sends an acknowledgment back to the bridge so the agent knows the response was cut short.&lt;/p&gt;

&lt;h2&gt;
  
  
  One-command startup
&lt;/h2&gt;

&lt;p&gt;The entire system — SSH tunnel, remote PulseAudio setup, both processes in tmux sessions — starts with a single script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./start_meeting.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script handles the full orchestration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check if SSH tunnel is already running on port 8765; create it if not&lt;/li&gt;
&lt;li&gt;SSH into the Linux server, detect the CRD PulseAudio socket path&lt;/li&gt;
&lt;li&gt;Load the three virtual audio devices (idempotent — skips if already loaded)&lt;/li&gt;
&lt;li&gt;Write a launcher script on the remote to avoid shell-escaping issues with the socket path&lt;/li&gt;
&lt;li&gt;Start &lt;code&gt;main_linux.py&lt;/code&gt; in a remote tmux session (&lt;code&gt;teams-voice&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Poll until the WebSocket is accepting connections&lt;/li&gt;
&lt;li&gt;Start &lt;code&gt;bridge.py&lt;/code&gt; in a local tmux session (&lt;code&gt;teams-bridge&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After startup, the output shows exactly what's running and what to configure in Teams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;══════════════════════════════════════════════════
  Teams Voice Agent — RUNNING
══════════════════════════════════════════════════
  Session key  : voice-meeting-20260313-1430
  Bridge tmux  : teams-bridge  (local)
  Remote tmux  : teams-voice   (on gpu-server)
  Tunnel       : localhost:8765 → gpu-server:8765

  ⚠️  Set Teams audio: Speaker=Teams_Speaker, Mic=Teams_Mic_Input
══════════════════════════════════════════════════
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only manual step is setting Teams' audio devices to the virtual ones. After that, everything is hands-free.&lt;/p&gt;

&lt;h2&gt;
  
  
  In practice
&lt;/h2&gt;

&lt;p&gt;In a typical meeting, the pipeline is completely silent until activated. STT is running continuously, speaker ID is tagging everyone, but nothing flows to the agent. The AI is listening but not present.&lt;/p&gt;

&lt;p&gt;When someone says "hey claude, can you look something up", the bridge catches the wake word, buffers the next few sentences of context, and sends the whole thing to the agent. The agent responds in under two seconds, the voice comes through everyone's speakers (since the AI is speaking through the virtual microphone), and then the pipeline goes quiet again.&lt;/p&gt;

&lt;p&gt;We use it primarily for knowledge retrieval during technical discussions — pulling up documentation, cross-referencing notes, summarizing what was decided earlier in the call. The agent has access to the OpenClaw memory system, so it can retrieve context from past meetings about the same project. This is the part that still surprises people in meetings: the AI not only answers the question but references a decision from last week's call.&lt;/p&gt;

&lt;p&gt;Barge-in works better than expected. If someone starts talking while the AI is mid-response, it stops within about half a second. There's a brief artifact from the audio that was already in the PulseAudio buffer, but it's not disruptive.&lt;/p&gt;

&lt;p&gt;The main limitation is that it requires Chrome Remote Desktop to be running on the Linux server. CRD creates the PulseAudio environment that the virtual devices live in. Without it, you'd need to adapt the startup script to work with whatever PulseAudio setup you have. The core pipeline code doesn't care — it just needs the right device names to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd do differently
&lt;/h2&gt;

&lt;p&gt;The current architecture puts all the signal processing on the Linux server, which makes sense for us but isn't universal. If you have a GPU-capable Mac or just want a simpler setup, the STT pipeline could run locally using MLX-Whisper — roughly the same accuracy on Apple Silicon, no remote server needed.&lt;/p&gt;

&lt;p&gt;Speaker identification is currently based on pre-registered voiceprints. A more robust approach would be to do diarization-style "who spoke when" identification without requiring enrollment, using something like pyannote. This would let the agent attribute meeting contributions even for people who haven't registered.&lt;/p&gt;

&lt;p&gt;The wake word system is regex-based, which works but is brittle to accents and speech recognition errors. A proper wake word model (like openWakeWord) would be more reliable, especially for non-English activations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The interesting engineering here isn't the AI part — Claude handles that. It's the audio plumbing: getting the right audio to the right place at the right time, without the AI confusing its own voice for input, without adding enough latency to make conversation awkward.&lt;/p&gt;

&lt;p&gt;PulseAudio virtual devices are genuinely powerful for this kind of audio routing. The null-sink + monitor pattern is a clean way to intercept audio streams without patching into any application's internal pipeline. More people should know it exists.&lt;/p&gt;

&lt;p&gt;The full source is at &lt;a href="https://github.com/QiushiWu95/teams-meeting-agent-public" rel="noopener noreferrer"&gt;github.com/QiushiWu95/teams-meeting-agent-public&lt;/a&gt;. The README has setup instructions for the hardware configuration we use (GPU server + Mac over Tailscale), but the pipeline itself should adapt to other setups with moderate effort.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://claw-stack.com/en/blog/teams-voice-agent" rel="noopener noreferrer"&gt;claw-stack.com&lt;/a&gt;. We're building an open-source AI agent runtime — check out the &lt;a href="https://claw-stack.com/en/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt; or &lt;a href="https://github.com/openclaw/openclaw" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>voice</category>
      <category>agents</category>
      <category>openclaw</category>
      <category>teams</category>
    </item>
    <item>
      <title>From Code Completion to Code Team: How We Turned Claude Code into an Engineering Department</title>
      <dc:creator>Qiushi</dc:creator>
      <pubDate>Tue, 10 Mar 2026 16:40:05 +0000</pubDate>
      <link>https://dev.to/qiushiwu/from-code-completion-to-code-team-how-we-turned-claude-code-into-an-engineering-department-1p62</link>
      <guid>https://dev.to/qiushiwu/from-code-completion-to-code-team-how-we-turned-claude-code-into-an-engineering-department-1p62</guid>
      <description>&lt;p&gt;We use Claude Code every day. It's excellent. It handles complex refactors, writes tests, navigates large codebases, and catches bugs we'd miss. But after months of running it as part of an autonomous multi-agent system, we noticed something: Claude Code is a powerful tool, but it's fundamentally passive. It waits for instructions, executes them, and stops. It doesn't monitor itself, plan ahead, review its own output, or remember what went wrong last time.&lt;/p&gt;

&lt;p&gt;That's not a criticism — it's a design choice. Claude Code is built to be a coding assistant, not an autonomous engineering agent. The question we kept asking was: what would it take to turn it into one?&lt;/p&gt;

&lt;p&gt;The answer became what we call the V2 architecture: a three-layer system that wraps Claude Code with monitoring, planning, and review. This post describes what we built and why each layer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The passive tool problem
&lt;/h2&gt;

&lt;p&gt;When you run Claude Code directly, the interaction model is: you give it a task, it works on it, it finishes (or gets stuck). There's no process watching whether it's still making progress, no structured plan it's executing against, and no second opinion on whether the output is actually correct.&lt;/p&gt;

&lt;p&gt;In practice, this creates three failure modes we hit repeatedly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stuck loops.&lt;/strong&gt; Claude Code sometimes gets into states where it's re-trying the same failing approach. Without external monitoring, the session just keeps running until you notice something is wrong — which, if you're running it autonomously overnight, might be hours later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No upfront plan.&lt;/strong&gt; For tasks with multiple steps or dependencies, jumping straight into code before having a clear implementation plan often leads to mid-task pivots that are expensive to recover from. The natural thing for a human engineer is to sketch the approach first. Claude Code doesn't do this by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No cross-review.&lt;/strong&gt; A model reviewing its own output has blind spots — the same reasoning that produced a bug often produces a rationale for why the bug is fine. A second model with a different training distribution catches different things.&lt;/p&gt;

&lt;p&gt;Each of these is solvable. Together they become the V2 architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  V2 architecture overview
&lt;/h2&gt;

&lt;p&gt;The system has three layers that operate around every Claude Code session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layer 1: Heartbeat (watchdog)
  └─ tmux session monitor
  └─ detects stuck/crashed → auto-recover

Layer 2: Skill-Driven Dev (planning)
  └─ SKILL.md written before code
  └─ implementation blueprint

Layer 3: Dual Review (verification)
  └─ Claude Code self-review
  └─ Gemini CLI cross-review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code still does the actual coding. The layers don't replace it — they wrap it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: Heartbeat
&lt;/h2&gt;

&lt;p&gt;Claude Code runs in a tmux session. The Heartbeat is a watchdog process that polls that session every 30 seconds and inspects the terminal output. It's looking for one thing: whether Claude Code's prompt is visible, which indicates it has finished and is waiting for input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SOCKET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TMPDIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/tmp&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/openclaw-tmux-sockets/openclaw.sock"&lt;/span&gt;
&lt;span class="nv"&gt;LAST5&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;tmux &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SOCKET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; capture-pane &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-J&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; claude-code:0.0 &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAST5&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"❯"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DONE"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"RUNNING"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;❯&lt;/code&gt; is Claude Code's shell prompt. If it's visible, the session is idle. If it's not visible for longer than the timeout threshold, something is wrong.&lt;/p&gt;

&lt;p&gt;When the Heartbeat detects a stuck session, it has three recovery strategies in order of escalation: send a gentle interrupt, close and restart the session with the same task context, or page the orchestrator (Orange) for human-in-the-loop intervention. Most stuck sessions resolve at step one.&lt;/p&gt;

&lt;p&gt;This sounds simple, and it is. But without it, autonomous coding sessions are brittle. Claude Code gets stuck on network errors, permission issues, or loops where it convinces itself it's making progress when it isn't. The Heartbeat converts these from silent failures into handled exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Skill-driven development
&lt;/h2&gt;

&lt;p&gt;Before any non-trivial task goes to Claude Code, the orchestrator writes a SKILL.md file. This is a structured implementation plan — the equivalent of a design doc — that Claude Code then executes against.&lt;/p&gt;

&lt;p&gt;The skill file structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;skills/&lt;/span&gt;
  &lt;span class="s"&gt;feature-name/&lt;/span&gt;
    &lt;span class="s"&gt;SKILL.md&lt;/span&gt;    &lt;span class="c1"&gt;# implementation plan + steps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical SKILL.md has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Goal&lt;/strong&gt; — what the task is and what done looks like&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reference style&lt;/strong&gt; — which existing code to model after&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outline&lt;/strong&gt; — the specific sections or steps, with the key technical details filled in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation steps&lt;/strong&gt; — ordered list of what to do and in what sequence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy/safety checklist&lt;/strong&gt; — things to verify before commit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the file you're reading right now in its original form — this blog post was written against its own SKILL.md.&lt;/p&gt;

&lt;p&gt;The planning step forces precision before any code is written. Ambiguous tasks get clarified at planning time, not mid-implementation. It also gives Claude Code a success criterion to check against rather than having to infer when it's done.&lt;/p&gt;

&lt;p&gt;The other benefit is reuse. Skills accumulate over time. When a similar task comes up again, the orchestrator can search the skill library for relevant patterns and adapt an existing plan rather than starting from scratch. Over time this is how the system builds institutional knowledge about how certain types of tasks should be approached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Dual review
&lt;/h2&gt;

&lt;p&gt;After Claude Code finishes and stages its changes, two reviews run before commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code self-review.&lt;/strong&gt; The first review uses Claude Code itself — but in a separate session, reviewing the diff rather than the code it just wrote. This catches straightforward issues: leftover debug output, incomplete implementations, test files that test the wrong thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini CLI cross-review.&lt;/strong&gt; The second review pipes the staged diff to Gemini:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff &lt;span class="nt"&gt;--cached&lt;/span&gt; | gemini &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Review this diff for: security issues, privacy leaks (IPs, emails, API keys), code quality. Output: PASSED or list of issues."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cross-review is the more important one. A different model with a different training distribution reliably catches things Claude Code's self-review misses — particularly security issues and privacy leaks. We've had Gemini catch hardcoded test credentials, internal hostnames that shouldn't be in public code, and logic errors that Claude Code's self-review described as intentional design decisions.&lt;/p&gt;

&lt;p&gt;The output format is strict: either &lt;code&gt;PASSED&lt;/code&gt; or a list of issues. If there are issues, the commit is blocked and the problems are sent back to Claude Code for remediation. The loop continues until Gemini passes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full V2 workflow
&lt;/h2&gt;

&lt;p&gt;Putting it together, the orchestrator's AGENTS.md describes a fixed sequence for every coding task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. memory_search → find relevant lessons and patterns from past work
2. Write SKILL.md (plan before code)
3. Launch Claude Code via tmux (with Heartbeat active)
4. Wait for Heartbeat signal: DONE
5. Gemini Review on staged diff
6. If issues: send back to Claude Code, loop
7. Commit
8. Update lessons/MEMORY.md with what was learned
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 1 and 8 are what give the system memory. Before starting, the orchestrator searches its vector memory for lessons from similar past tasks — prior decisions, failure modes, patterns that worked. After finishing, it writes what it learned back to memory. Over time this creates a feedback loop where the system gets measurably better at certain types of tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this isn't
&lt;/h2&gt;

&lt;p&gt;This isn't a fully autonomous engineering team. The orchestrator (Orange) still needs a human (Qiushi) to approve anything that touches production, involves financial operations, or represents a significant architectural decision. The V2 architecture automates the routine coding work; it doesn't automate judgment.&lt;/p&gt;

&lt;p&gt;It's also not a replacement for Claude Code — it's a harness for it. The coding quality still comes from Claude Code. The architecture just ensures that quality gets checked, that sessions don't fail silently, and that the system accumulates knowledge rather than starting fresh every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The principle
&lt;/h2&gt;

&lt;p&gt;The pattern here is one we've found ourselves returning to: AI tools are most powerful when they're not standalone, but when they're embedded in systems that monitor them, direct them, and check their output. Claude Code alone is a strong coder. Claude Code with a Heartbeat, a planning layer, and a cross-review step is closer to a reliable engineering workflow.&lt;/p&gt;

&lt;p&gt;The same principle applies to any capable but passive AI tool. The tool does the work. The system ensures the work is worth keeping.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://claw-stack.com/en/blog/claude-code-agent-swarm-v2" rel="noopener noreferrer"&gt;claw-stack.com&lt;/a&gt;. We're building an open-source AI agent runtime — check out the &lt;a href="https://claw-stack.com/en/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt; or &lt;a href="https://github.com/openclaw/openclaw" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>coding</category>
      <category>openclaw</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Memory System v2: Solving the Context Bloat Problem</title>
      <dc:creator>Qiushi</dc:creator>
      <pubDate>Mon, 09 Mar 2026 21:16:01 +0000</pubDate>
      <link>https://dev.to/qiushiwu/memory-system-v2-solving-the-context-bloat-problem-pg9</link>
      <guid>https://dev.to/qiushiwu/memory-system-v2-solving-the-context-bloat-problem-pg9</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/blog/persistent-memory-system"&gt;In our last post on building a persistent memory system&lt;/a&gt;, we described the MEMORY.md bloat problem: after six weeks, the file had grown to over 700 lines, and we fixed it by switching from inline content to pointer-based entries. The fix worked. MEMORY.md got compact, session startup improved, everything was fine.&lt;/p&gt;

&lt;p&gt;Then it bloated again.&lt;/p&gt;

&lt;p&gt;Four weeks later, MEMORY.md was back to 92,000 characters and 790 lines. The organizer pipeline kept writing new facts inline rather than deferring to per-topic files. Our byte-size limit wasn't being enforced consistently. The original fix had patched the symptom, not the cause.&lt;/p&gt;

&lt;p&gt;More troubling, we had started noticing that sessions were hitting context limits mid-task even when MEMORY.md was under control. The agent would read a few files, run a search, and then stall — not because it had run out of &lt;em&gt;memory&lt;/em&gt;, but because its context window was full of tool output from earlier in the same session.&lt;/p&gt;

&lt;p&gt;And there was a third problem we'd been tolerating: every time we ran &lt;code&gt;/new&lt;/code&gt; to start a fresh session, the agent lost all awareness of what it had just been doing. Our long-term memory system (v1) handled facts, preferences, and project knowledge well. But the &lt;em&gt;short-term&lt;/em&gt; working state — what task was in progress, what decisions were just made, what the next step was — vanished completely. The user had to manually remind the agent to update its memory files before resetting, or accept losing the context.&lt;/p&gt;

&lt;p&gt;Three problems, one theme: no systematic lifecycle for context at any timescale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring before fixing
&lt;/h2&gt;

&lt;p&gt;Before changing anything, we wrote &lt;code&gt;session-stats.py&lt;/code&gt; to analyze the last 15 sessions and understand where context was actually going. The output was clarifying.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Session context breakdown (15 sessions, chars):
┌──────────────────┬───────────┬───────────┬────────────┐
│ Category         │ Total     │ % of ctx  │ Avg/session│
├──────────────────┼───────────┼───────────┼────────────┤
│ Tool results     │ 1,842,300 │   82.5%   │   122,820  │
│ System prompt    │   268,100 │   12.0%   │    17,873  │
│ Assistant text   │    64,700 │    2.9%   │     4,313  │
│ User input       │    55,900 │    2.5%   │     3,727  │
└──────────────────┴───────────┴───────────┴────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most extreme session: 159,000 characters of tool results, 1,500 characters of user input and assistant text combined. The actual conversation was almost invisible in its own context window.&lt;/p&gt;

&lt;p&gt;System prompt was 17K chars per session on average. We knew MEMORY.md was loaded at startup, but seeing it account for 12% of total context across all sessions, including sessions where nothing memory-related happened, made the number concrete. The agent was paying 17K chars of context tax on every session, regardless of what it was doing.&lt;/p&gt;

&lt;p&gt;The two problems were now measurable: tool results bloating within a session, and MEMORY.md bloating across sessions. Both were solvable, and we had numbers to evaluate solutions against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solution 1: Context pruning
&lt;/h2&gt;

&lt;p&gt;The within-session problem is that tool outputs accumulate. The agent reads a file — that's 8K chars of context. Runs a search — another 4K. Edits a file, sees the diff — 2K. Reads the test output — 6K. After a moderately complex task, the context is mostly tool output from earlier steps that the agent no longer needs to reference.&lt;/p&gt;

&lt;p&gt;OpenClaw's &lt;code&gt;contextPruning&lt;/code&gt; feature handles this with a TTL-based approach: after a configurable time window, tool outputs beyond the most recent turn are replaced with a placeholder. The content is gone from the active context, but the agent can see that something happened.&lt;/p&gt;

&lt;p&gt;Our configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;contextPruning&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cache-ttl&lt;/span&gt;
  &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="na"&gt;minPrunableToolChars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
  &lt;span class="na"&gt;hardClearRatio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;ttl: 30&lt;/code&gt;, any tool result older than 30 seconds is eligible for pruning on the next turn. &lt;code&gt;minPrunableToolChars: 100&lt;/code&gt; prevents replacing tiny tool outputs that cost almost nothing. &lt;code&gt;hardClearRatio: 0&lt;/code&gt; means we never do a full wipe — we keep the most recent turn intact.&lt;/p&gt;

&lt;p&gt;The effect is that the agent operates with a sliding window of recent tool context rather than the full accumulated history. For tasks involving repeated file reads or search-iterate loops, this is the difference between hitting context limits at step 8 and finishing the task.&lt;/p&gt;

&lt;p&gt;One concern we had: would pruning break the agent's ability to reference earlier work? In practice, no. For most tasks, the agent either needs the output of the most recent tool call, or it needs a general fact that should be in memory rather than in an ephemeral tool result. If the agent needs to re-read a file it already processed, that's usually a sign the fact should have been written to memory, not cached in context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solution 2: MEMORY.md structural compression
&lt;/h2&gt;

&lt;p&gt;The 92K → compact migration required confronting a design question we'd avoided the first time: what exactly should MEMORY.md contain?&lt;/p&gt;

&lt;p&gt;Our v1 answer had been "recent activity, active projects, key contacts, and infrastructure notes," with a byte-size cap to keep it manageable. This was wrong. A byte-size cap is an incentive to compress content, but it doesn't prevent accumulation — it just makes each entry shorter before you run out of room and start bending the rules.&lt;/p&gt;

&lt;p&gt;The right answer is that MEMORY.md should contain &lt;em&gt;pointers&lt;/em&gt;, not &lt;em&gt;content&lt;/em&gt;. If you can answer the question "what is this file for?" with "it contains X," then MEMORY.md should not contain X — it should contain "see &lt;code&gt;memory/X.md&lt;/code&gt; for X." MEMORY.md is an index that tells the agent where to look, not a document that contains what the agent knows.&lt;/p&gt;

&lt;p&gt;With that definition, the target structure became obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Users&lt;/span&gt;
| handle | role | notes |
| --- | --- | --- |
| @orange | owner | ... |

&lt;span class="gu"&gt;## Projects&lt;/span&gt;
| name | status | detail file |
| --- | --- | --- |
| claw-stack | active | memory/entities/project-claw-stack.md |
| info-pipeline | active | memory/entities/project-info-pipeline.md |

&lt;span class="gu"&gt;## Infrastructure&lt;/span&gt;
| service | notes | detail file |
| --- | --- | --- |
| CF Workers | edge compute | memory/infra/cloudflare.md |

&lt;span class="gu"&gt;## Behavior rules&lt;/span&gt;
See AGENTS.md for current rules.

&lt;span class="gu"&gt;## Recent (last 5)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; 2026-03-09: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tables for structured facts (users, projects, infra). Pointers for everything else. Recent activity capped at five entries, rolling. Total target: under 5,000 characters.&lt;/p&gt;

&lt;p&gt;After the migration, MEMORY.md went from 92,000 characters to 2,900 characters — a 97% reduction. Session startup went from ~23K tokens of MEMORY.md context to ~700 tokens. Everything that was in MEMORY.md before is still searchable through QMD vector search; it's just in per-topic files now rather than inline.&lt;/p&gt;

&lt;p&gt;The migration script itself was about 150 lines of Python: read the current MEMORY.md, extract facts by category using Claude Haiku, write facts to appropriate per-topic files, generate the new pointer-based MEMORY.md. Running it took 20 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solution 3: Session handoff hooks
&lt;/h2&gt;

&lt;p&gt;The context pruning and MEMORY.md compression addressed the technical bloat problems. There was a third problem we'd been tolerating: when you run &lt;code&gt;/new&lt;/code&gt; to start a fresh session, you lose all the working context from the current session. What file were you editing? What was the next step? What did you just figure out about the bug you were debugging?&lt;/p&gt;

&lt;p&gt;The conventional response is "write better notes." We wanted to automate it.&lt;/p&gt;

&lt;p&gt;OpenClaw supports hooks that fire on specific commands. We wrote a &lt;code&gt;command:new&lt;/code&gt; hook that runs a session summarization pipeline before the new session starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Triggered on /new
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;session_handoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;claude_haiku&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MANIFEST.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  &lt;span class="c1"&gt;# file map for the memory system
&lt;/span&gt;        &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarize this session. Extract: current work state, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
               &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decisions made, lessons learned, entities updated. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
               &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Format as structured updates for memory files.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;apply_memory_updates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# updates MEMORY.md, TODO.md, entities, etc.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook runs synchronously with a 20-second timeout, then falls back to async if the transcript is too long to process quickly. In practice, most sessions process in 8–12 seconds.&lt;/p&gt;

&lt;p&gt;The key piece is &lt;code&gt;MANIFEST.md&lt;/code&gt;, a file that describes the memory system's structure: which files exist, what each one contains, and what kinds of updates go where. Without it, Haiku doesn't know that a project update should go to &lt;code&gt;memory/entities/project-X.md&lt;/code&gt; rather than into MEMORY.md directly. The MANIFEST is the schema documentation for the agent that maintains memory.&lt;/p&gt;

&lt;p&gt;After the handoff hook, &lt;code&gt;/new&lt;/code&gt; still starts a fresh context, but MEMORY.md now reflects the current session's outcomes. The next session starts knowing where you left off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decay prevention rules
&lt;/h2&gt;

&lt;p&gt;After rebuilding the system twice, we wrote explicit rules into &lt;code&gt;AGENTS.md&lt;/code&gt; to prevent the same problems from recurring:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard limits:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MEMORY.md must stay under 5,000 characters. If an update would push it over, write to a per-topic file and add a pointer instead.&lt;/li&gt;
&lt;li&gt;Never write commit hashes, code snippets, or raw error messages to MEMORY.md. These are either ephemeral (commit hashes, errors) or belong in per-topic files (code).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prohibited content:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lists of more than 5 items (use a per-topic file)&lt;/li&gt;
&lt;li&gt;Facts already present in another memory file (no duplication)&lt;/li&gt;
&lt;li&gt;"Temporary" notes (write to a TODO file, not to MEMORY.md)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Regular maintenance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After any session that touched more than 3 files, check whether per-topic files need updating&lt;/li&gt;
&lt;li&gt;When a project status changes, update the entity file, not the MEMORY.md table&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rules written into AGENTS.md become part of the system prompt, which means the organizer pipeline and the handoff hook both see them. They're not enforced by code, but explicit rules in the context are meaningfully better than informal conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measured outcomes
&lt;/h2&gt;

&lt;p&gt;The immediate results after deploying the v2 changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MEMORY.md size&lt;/td&gt;
&lt;td&gt;~92K chars (~23K tokens)&lt;/td&gt;
&lt;td&gt;~2.9K chars (~700 tokens)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session startup context tax&lt;/td&gt;
&lt;td&gt;~23K tokens&lt;/td&gt;
&lt;td&gt;~700 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool result share of context&lt;/td&gt;
&lt;td&gt;82.5%&lt;/td&gt;
&lt;td&gt;Pruned after 30s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Working state preserved across /new&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (automated)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The MEMORY.md reduction is a 97% cut. Every new session now starts with 22K fewer tokens of overhead, which means more room for the actual task. The context pruning configuration means tool results older than 30 seconds are replaced with placeholders, preventing the within-session accumulation that was causing stalls on multi-step tasks.&lt;/p&gt;

&lt;p&gt;Whether the handoff hook produces the right memory updates consistently is something we'll know after a few weeks of use. The architecture is right — the question is whether Haiku's judgment about what to update holds up at scale. We'll report back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned about memory
&lt;/h2&gt;

&lt;p&gt;The v1 blog post framed the bloat problem as a technical issue with a technical fix: enforce a byte-size limit, use pointers instead of inline content. That framing was correct but incomplete.&lt;/p&gt;

&lt;p&gt;The real problem is that memory management is an information architecture problem, not a storage problem. Every time we said "this fact might be relevant later, so put it in MEMORY.md," we were making a bad indexing decision. MEMORY.md was being used as a catch-all rather than as a specific layer in the architecture.&lt;/p&gt;

&lt;p&gt;The v2 system works not because we have better enforcement mechanisms (though the TTL pruning and size limits help) but because we're clearer about what each layer is for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Active context&lt;/strong&gt;: the current session's working state. Ephemeral. Pruned aggressively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MEMORY.md&lt;/strong&gt;: session orientation. The minimum context needed to start a session. Pointers only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-topic files&lt;/strong&gt;: depth on specific subjects. Loaded on demand. Where content lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector search&lt;/strong&gt;: fallback retrieval across all memory. For queries that don't know where to look.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a new fact arrives, the question isn't "should I remember this?" It's "which layer does this belong in?" Most facts don't belong in MEMORY.md. Getting that architecture right is what prevents bloat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical takeaways for agent developers
&lt;/h2&gt;

&lt;p&gt;If you're building something similar, the mistakes we made twice are worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enforce the index/content separation at write time, not retroactively.&lt;/strong&gt; A byte-size limit on MEMORY.md doesn't prevent bloat — it just makes bloat smaller before you exceed it. The real constraint is: no content in the index, only pointers. Check this on every write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure context distribution before you optimize.&lt;/strong&gt; We assumed MEMORY.md was the main problem. It was a problem. Tool results were a bigger problem. Running session-stats took a day to write and immediately surfaced the bigger issue. Measurement first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTL-based context pruning is low-risk and high-reward.&lt;/strong&gt; We were worried it would break agent behavior. It didn't. For most tasks, old tool results are noise, not signal. Prune them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A handoff hook is worth more than perfect note-taking.&lt;/strong&gt; Asking humans (or agents) to write end-of-session notes reliably is a losing strategy. Automate it. Even a rough extraction that takes 10 seconds is better than manual notes that don't get written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Document the memory system's schema for the agents that use it.&lt;/strong&gt; The MANIFEST.md pattern — a file that explains where things go — is what makes automated memory updates actually put things in the right place. Without it, every update becomes an ad-hoc decision about file placement.&lt;/p&gt;

&lt;p&gt;Memory systems for AI agents are still young enough that there's no established practice. These are the patterns that worked for us at our scale. Your scale, your access patterns, and your agent's task distribution will produce different constraints. But the underlying principle holds: agent memory is information architecture. Get the architecture right before you build the infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://claw-stack.com/en/blog/memory-system-v2" rel="noopener noreferrer"&gt;claw-stack.com&lt;/a&gt;. We're building an open-source AI agent runtime — check out the &lt;a href="https://claw-stack.com/en/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt; or &lt;a href="https://github.com/openclaw/openclaw" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>memory</category>
      <category>architecture</category>
      <category>contextwindow</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>Making iMessage Reliable with OpenClaw: 3 Problems and How We Fixed Them</title>
      <dc:creator>Qiushi</dc:creator>
      <pubDate>Mon, 09 Mar 2026 00:54:03 +0000</pubDate>
      <link>https://dev.to/qiushiwu/making-imessage-reliable-with-openclaw-3-problems-and-how-we-fixed-them-o8m</link>
      <guid>https://dev.to/qiushiwu/making-imessage-reliable-with-openclaw-3-problems-and-how-we-fixed-them-o8m</guid>
      <description>&lt;p&gt;OpenClaw can use iMessage as a communication channel — you text your AI agent, it texts you back. Sounds simple, but running it 24/7 on a Mac mini revealed three reliability issues that took weeks to fully diagnose. Here's what went wrong and how we fixed each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;OpenClaw's iMessage plugin works by watching &lt;code&gt;~/Library/Messages/chat.db&lt;/code&gt; via filesystem events (FSEvents). When a new message arrives, macOS writes to &lt;code&gt;chat.db&lt;/code&gt;, the watcher detects the change, and the gateway processes the message.&lt;/p&gt;

&lt;p&gt;In theory, this is instant. In practice, it breaks in three distinct ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: Messages Delayed Up to 5 Minutes When Idle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: You send a message, it shows "Delivered" on your phone, but the agent doesn't respond for 3-5 minutes. Then suddenly it processes everything at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root Cause&lt;/strong&gt;: macOS power management coalesces FSEvents for background processes. Even with &lt;code&gt;ProcessType=Interactive&lt;/code&gt; in the LaunchAgent plist and &lt;code&gt;caffeinate&lt;/code&gt; running, the kernel still batches vnode events on &lt;code&gt;chat.db&lt;/code&gt; during low-activity periods. The &lt;code&gt;imsg rpc&lt;/code&gt; subprocess watches the file, but macOS decides "this process hasn't been active, let's batch up those file notifications."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why It's Tricky&lt;/strong&gt;: The message is already in &lt;code&gt;chat.db&lt;/code&gt; — it's the &lt;em&gt;notification&lt;/em&gt; that's delayed, not the message itself. So everything works perfectly during active use, but fails silently when the machine is idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: A polling script that checks &lt;code&gt;chat.db&lt;/code&gt; every 15 seconds and &lt;code&gt;touch&lt;/code&gt;es the file when new rows appear, generating a fresh FSEvent:&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="cp"&gt;#!/usr/bin/env node
&lt;/span&gt;&lt;span class="c1"&gt;// imsg-poller.mjs — Polls chat.db for new messages and wakes FSEvents watcher&lt;/span&gt;


&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHATDB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;homedir&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Library/Messages/chat.db&lt;/span&gt;&lt;span class="dl"&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;INTERVAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 15 seconds&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getMaxRowid&lt;/span&gt;&lt;span class="p"&gt;()&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`/usr/bin/sqlite3 "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CHATDB&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" "SELECT MAX(ROWID) FROM message;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&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;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&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;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastRowid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getMaxRowid&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;lastRowid&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ERROR: Cannot read chat.db — check Full Disk Access&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`imsg-poller started. ROWID: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lastRowid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, interval: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;INTERVAL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;setInterval&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getMaxRowid&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;current&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;lastRowid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`New message (ROWID &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lastRowid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;), touching chat.db`&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;utimesSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CHATDB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`touch failed: &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;message&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="nx"&gt;lastRowid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;current&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;span class="nx"&gt;INTERVAL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why Node.js instead of bash?&lt;/strong&gt; We tried a bash version first, but launchd-spawned &lt;code&gt;/bin/bash&lt;/code&gt; processes don't inherit Full Disk Access (TCC). The &lt;code&gt;stat&lt;/code&gt; command works, but &lt;code&gt;sqlite3&lt;/code&gt; gets "authorization denied". Using &lt;code&gt;/opt/homebrew/bin/node&lt;/code&gt; works because it inherits FDA from the same TCC grant as the gateway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment&lt;/strong&gt;: Run as a LaunchAgent with &lt;code&gt;KeepAlive: true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;plist&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Label&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;ai.openclaw.imsg-poller&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/opt/homebrew/bin/node&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/path/to/imsg-poller.mjs&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;KeepAlive&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;HOME&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Users/youruser&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ThrottleInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plist&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Survives OpenClaw updates?&lt;/strong&gt; Yes — it's a standalone launchd job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Images Sent via iMessage Fail with "Path Not Allowed"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: The agent tries to send an image that was received via iMessage, but gets "Local media path is not under an allowed directory." The image exists at &lt;code&gt;~/Library/Messages/Attachments/...&lt;/code&gt; but OpenClaw's media sandboxing blocks it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root Cause&lt;/strong&gt;: OpenClaw's &lt;code&gt;buildMediaLocalRoots()&lt;/code&gt; function defines which directories are allowed for media file access. It includes the workspace, temp directories, and sandboxes — but not &lt;code&gt;~/Library/Messages/Attachments/&lt;/code&gt;. When the agent tries to forward or process an image received via iMessage, the path is rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: A patch script that adds the Messages attachment directory to the allowed roots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# patch-imessage-attachments.sh&lt;/span&gt;
&lt;span class="c"&gt;# Adds ~/Library/Messages/Attachments to allowed media roots&lt;/span&gt;
&lt;span class="c"&gt;# Re-run after every `npm update -g openclaw`&lt;/span&gt;

&lt;span class="nv"&gt;DIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/homebrew/lib/node_modules/openclaw/dist"&lt;/span&gt;

&lt;span class="nv"&gt;patched&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DIST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/ir-&lt;span class="k"&gt;*&lt;/span&gt;.js&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"buildMediaLocalRoots"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"Messages/Attachments"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="s1"&gt;'s|path.join(resolvedStateDir, "sandboxes")|path.join(resolvedStateDir, "sandboxes"),\n\t\tpath.join(os.homedir(), "Library/Messages/Attachments")|'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Patched: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;patched&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;patched &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;fi
done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Done. Patched: &lt;/span&gt;&lt;span class="nv"&gt;$patched&lt;/span&gt;&lt;span class="s2"&gt; files"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Run: openclaw gateway restart"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Survives OpenClaw updates?&lt;/strong&gt; No — the compiled JS files are overwritten. &lt;strong&gt;You must re-run this after every update.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: macOS Updates Silently Revoke Full Disk Access
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: iMessage stops working entirely. No messages received, no errors in the gateway log that make sense. The agent appears online but is deaf.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root Cause&lt;/strong&gt;: macOS system updates (and sometimes minor security patches) can reset TCC (Transparency, Consent, and Control) permissions. When this happens, the &lt;code&gt;imsg&lt;/code&gt; binary loses Full Disk Access, which means it can't read &lt;code&gt;~/Library/Messages/chat.db&lt;/code&gt;. The gateway logs show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;permissionDenied(path: "~/Library/Messages/chat.db",
  underlying: authorization denied (code: 23))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our logs, this happened on &lt;strong&gt;Feb 13&lt;/strong&gt; and &lt;strong&gt;Feb 24, 2026&lt;/strong&gt; — both times correlating with macOS updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: Manual, unfortunately.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the gateway error log:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"permissionDenied"&lt;/span&gt; ~/.openclaw/logs/gateway.err.log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;If you see &lt;code&gt;code: 23&lt;/code&gt;, go to:
&lt;strong&gt;System Settings → Privacy &amp;amp; Security → Full Disk Access&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Make sure &lt;code&gt;imsg&lt;/code&gt; (or Terminal / iTerm, whichever runs your gateway) has FDA enabled. Toggle it off and on if it looks correct but isn't working.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   /opt/homebrew/bin/imsg chats &lt;span class="nt"&gt;--limit&lt;/span&gt; 1
   &lt;span class="c"&gt;# Should return your most recent chat, not an error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Restart:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   openclaw gateway restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Survives OpenClaw updates?&lt;/strong&gt; Yes — TCC permissions are system-level. But macOS updates can reset them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Post-Update Checklist
&lt;/h2&gt;

&lt;p&gt;Every time you run &lt;code&gt;npm update -g openclaw&lt;/code&gt;, do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Re-apply patches (overwritten by update)&lt;/span&gt;
bash ~/.openclaw/autopatch/patch-imessage-attachments.sh

&lt;span class="c"&gt;# 2. Restart gateway&lt;/span&gt;
openclaw gateway restart

&lt;span class="c"&gt;# 3. Verify iMessage works&lt;/span&gt;
/opt/homebrew/bin/imsg chats &lt;span class="nt"&gt;--limit&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After macOS updates, also check Full Disk Access permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should OpenClaw Fix These Upstream?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem 1&lt;/strong&gt; (FSEvents coalescing) is a macOS kernel behavior — hard to fix in OpenClaw itself. The poller is the right workaround. OpenClaw could ship it as an optional component.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem 2&lt;/strong&gt; (attachment path) is a clear bug/oversight. &lt;code&gt;~/Library/Messages/Attachments/&lt;/code&gt; should be in the default allowed roots when the iMessage plugin is enabled. This is a one-line fix upstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem 3&lt;/strong&gt; (TCC reset) is Apple's problem. Nothing OpenClaw can do except maybe detect it and log a clearer error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Works on my machine" isn't enough for always-on agents.&lt;/strong&gt; These bugs only appear after days of continuous operation or after system updates. You need to run your agent 24/7 for weeks to find them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;macOS is not designed for headless servers.&lt;/strong&gt; Power management, TCC, FSEvents coalescing — they all assume a human is sitting in front of the screen. Running an AI agent on a Mac mini requires fighting the OS at every level.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep a patch directory.&lt;/strong&gt; We maintain &lt;code&gt;~/.openclaw/autopatch/&lt;/code&gt; with scripts and a README documenting every patch. When an update lands, we run them all. It's not elegant, but it's reliable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Log everything.&lt;/strong&gt; The poller logs every &lt;code&gt;touch&lt;/code&gt; it performs. The gateway logs every permission error. Without these, we'd still be debugging "why didn't my message go through?"&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://claw-stack.com/en/blog/imessage-reliability" rel="noopener noreferrer"&gt;claw-stack.com&lt;/a&gt;. We're building an open-source AI agent runtime — check out the &lt;a href="https://claw-stack.com/en/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt; or &lt;a href="https://github.com/openclaw/openclaw" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openclaw</category>
      <category>imessage</category>
      <category>macos</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Building a Tri-Modal Knowledge Engine for CTF Agents</title>
      <dc:creator>Qiushi</dc:creator>
      <pubDate>Sat, 07 Mar 2026 23:57:46 +0000</pubDate>
      <link>https://dev.to/qiushiwu/building-a-tri-modal-knowledge-engine-for-ctf-agents-49eb</link>
      <guid>https://dev.to/qiushiwu/building-a-tri-modal-knowledge-engine-for-ctf-agents-49eb</guid>
      <description>&lt;p&gt;When Librarian gets asked about tcache stashing, it needs to return something more useful than what a base Claude model knows. The model has a general understanding of heap exploitation — it can describe what tcache is, explain the concept of a stash unlink attack, gesture at the shape of an exploit. But it doesn't know the specific pwntools idiom your teammates used last week, or the exact GDB command that reveals the free list state in the version of libc pinned to the challenge binary. That gap — between general knowledge and actionable specifics — is what the knowledge engine exists to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just ask the LLM" isn't enough
&lt;/h2&gt;

&lt;p&gt;A base model's knowledge has two failure modes in CTF contexts.&lt;/p&gt;

&lt;p&gt;The first is staleness. CTF challenges often involve recent CVEs, updated tool versions, or techniques documented only in writeups from the past year. A model with a training cutoff doesn't know these. The second is precision. Knowing &lt;em&gt;that&lt;/em&gt; GTFOBins documents &lt;code&gt;nmap&lt;/code&gt; privilege escalation techniques is not the same as having the exact &lt;code&gt;--script=exec&lt;/code&gt; incantation ready to paste. In a time-limited competition, the difference between "the agent knows the theory" and "the agent has the exact command" can be the difference between a solve and a dead end.&lt;/p&gt;

&lt;p&gt;There's also a context budget problem. Librarian (Claude Haiku) is called once per challenge and has a fixed context window. You can't embed all of HackTricks in the prompt. You need targeted retrieval: the three most relevant things for this specific challenge, delivered quickly, in a format the agent can act on immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tri-modal architecture
&lt;/h2&gt;

&lt;p&gt;The knowledge base separates three fundamentally different kinds of retrieval into three separate stores.&lt;/p&gt;

&lt;h3&gt;
  
  
  Type A — Muscle Memory (SQLite + FTS5)
&lt;/h3&gt;

&lt;p&gt;Type A is for commands you want to copy and paste. The database (&lt;code&gt;ctf_knowledge.db&lt;/code&gt;) contains two tables.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;binaries&lt;/code&gt; holds ~2,739 structured records from GTFOBins and LOLBAS — one row per binary per exploitation method, indexed by name, platform (linux/windows), and function (shell, sudo, suid, download, etc.). These come from the GTFOBins YAML files and a LOLBAS JSON export. The schema is intentionally rigid: &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;platform&lt;/code&gt;, &lt;code&gt;function&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;. A query for &lt;code&gt;nmap&lt;/code&gt; + &lt;code&gt;sudo&lt;/code&gt; returns the exact command, not a description of what nmap can do.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tricks&lt;/code&gt; is a SQLite FTS5 full-text search table with ~4,155 records from HackTricks and PayloadsAllTheThings. The build pipeline walks all markdown files, extracts code blocks using regex, and records the surrounding header as context. PayloadsAllTheThings gets filtered: the Intruder, Wordlists, Files, and Images directories are skipped (those assets go to Type C instead), and code blocks longer than 20 lines are dropped — a deliberate choice to keep tricks copy-paste ready rather than turning the table into a script archive.&lt;/p&gt;

&lt;p&gt;FTS5 queries work by AND-ing the search terms together (&lt;code&gt;nmap AND sudo AND shell&lt;/code&gt;). When FTS5 fails — which happens with punctuation-heavy queries — the gateway falls back to a LIKE search on the first word.&lt;/p&gt;

&lt;h3&gt;
  
  
  Type B — Cortex (ChromaDB + BGE-M3)
&lt;/h3&gt;

&lt;p&gt;Type B is for methodology, concepts, and writeups. It uses ChromaDB with &lt;code&gt;BAAI/bge-m3&lt;/code&gt; embeddings: 1024-dimensional vectors, normalized, with Metal (MPS) acceleration on Apple Silicon.&lt;/p&gt;

&lt;p&gt;The build pipeline (&lt;code&gt;crawl_type_b.py&lt;/code&gt;) is a web crawler that reads target URLs from markdown configuration files, spiders up to 500 pages per site, and ingests text into the &lt;code&gt;methodology&lt;/code&gt; collection. Pages are chunked by double newline, capped at 1,500 characters per chunk, with chunks under 100 characters discarded. Raw HTML is cached locally so rebuilding the index after changing the embedding model doesn't require re-crawling.&lt;/p&gt;

&lt;p&gt;Currently indexed: 0xdf's blog (machine writeups, practical exploitation techniques) and the pwntools documentation. The ctf-wiki repository is cloned locally and available for ingestion but requires separate processing. One wrinkle: trafilatura — the primary extraction library — gets blocked by docs.pwntools.com, so the crawler falls back to urllib for that domain.&lt;/p&gt;

&lt;p&gt;A note on the embedding model: we upgraded from a 384-dimension model to BGE-M3 (1024 dimensions) mid-build. ChromaDB doesn't support mixed-dimension collections, so the upgrade required dropping and rebuilding the entire database. The build script handles this automatically, but it means every embedding model upgrade is a full rebuild.&lt;/p&gt;

&lt;h3&gt;
  
  
  Type C — Arsenal (JSON index)
&lt;/h3&gt;

&lt;p&gt;Type C is for local files: wordlists, web shells, and privilege escalation scripts. Rather than a database, it's a flat JSON index (&lt;code&gt;asset_index.json&lt;/code&gt;) mapping names and tags to absolute file paths.&lt;/p&gt;

&lt;p&gt;What's in the index: SecLists (password lists, directory wordlists, username lists, fuzzing payloads), PayloadsAllTheThings web shells (&lt;code&gt;.php&lt;/code&gt;, &lt;code&gt;.jsp&lt;/code&gt;, and others), and PEASS-ng pre-compiled binaries (&lt;code&gt;linpeas.sh&lt;/code&gt;, &lt;code&gt;winpeas.bat&lt;/code&gt;, &lt;code&gt;winPEASany.exe&lt;/code&gt;). The rockyou.txt wordlist is pre-decompressed and ready to use. Each entry carries a category, a tag list, and an absolute path — so when Operator needs to run &lt;code&gt;ffuf -w &amp;lt;path&amp;gt;&lt;/code&gt;, Librarian hands back the path, not an instruction to find the path.&lt;/p&gt;

&lt;p&gt;Tools like pwntools and ROPgadget are explicitly excluded. These are environment tools that Operator invokes directly; they're TypeD — present in the execution environment but not indexed here. Type C is for files you transfer or reference, not binaries you run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LibrarianGateway
&lt;/h2&gt;

&lt;p&gt;The gateway (&lt;code&gt;librarian_gateway.py&lt;/code&gt;) is the single interface to all three types. Its job is to route queries and apply automatic fallback and enhancement logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeA query:
  FTS5 search binaries + tricks
  ├─ hit  → return payloads
  └─ miss → fallback: run TypeB semantic search, label as theory (no ready payload)

TypeB query:
  ChromaDB semantic search
  ├─ hit  → extract keywords (words &amp;gt;4 chars, first 3) → run TypeA lookup
  │          return theory + concrete examples
  └─ miss → nothing

TypeC query:
  JSON substring/tag filter (name, tags, category)
  → return up to 5 matches with absolute paths
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TypeA→TypeB fallback is the most useful path in practice. When Operator asks Librarian for a precise command that doesn't exist in the SQLite database, the gateway doesn't just return nothing — it says "no payload found, but here's the methodology," giving Operator enough theory to reconstruct the approach from scratch.&lt;/p&gt;

&lt;p&gt;The TypeB→TypeA enhancement works in the opposite direction. After a semantic search returns methodology results, the gateway extracts keywords from the returned text and runs an FTS5 lookup to find concrete commands that illustrate the theory. This avoids the pattern where the agent understands the concept but has to guess the syntax.&lt;/p&gt;

&lt;p&gt;The keyword extraction is crude: take words longer than four characters, pick the first three, run FTS5. It works often enough to be useful but misses short but domain-specific terms like "ROP", "XSS", "SQL", or binary names like &lt;code&gt;nc&lt;/code&gt;. This is the part of the system most in need of improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build pipeline
&lt;/h2&gt;

&lt;p&gt;Building from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Type A: parse GTFOBins YAML + LOLBAS JSON + HackTricks MD + PayloadsAllTheThings MD&lt;/span&gt;
python3 TypeA/build_db.py

&lt;span class="c"&gt;# Type B: crawl configured sites, embed with BGE-M3, store in ChromaDB&lt;/span&gt;
python3 TypeB/crawl_type_b.py

&lt;span class="c"&gt;# Type C: walk SecLists / PayloadsAllTheThings / PEASS-ng, emit JSON index&lt;/span&gt;
python3 TypeC/build_asset_index.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type A builds in seconds. Type B is the slow one: BGE-M3 runs inference for every chunk, and crawling a 500-page site with 0.5s politeness delays takes a while. The raw HTML cache means that once crawled, rebuilding the vector index from cache is much faster than re-crawling.&lt;/p&gt;

&lt;p&gt;One dependency constraint worth noting: Python 3.14 breaks ChromaDB 1.5.1 due to a Pydantic compatibility issue. The project requires Python 3.10–3.13.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worked at BearcatCTF
&lt;/h2&gt;

&lt;p&gt;The clearest signal was category accumulation. By the time Operator reached the eighth cryptography challenge, Librarian had enough indexed context — from prior solves and its own sources — that its briefing was materially better calibrated than what it gave for the first challenge. Forensics showed the same pattern: binwalk and foremost appeared in early Librarian responses, and by challenge three, Operator was starting with the right tools rather than discovering them mid-attempt.&lt;/p&gt;

&lt;p&gt;Type C was effective for web challenges. When Operator needed to upload a reverse shell or fuzz a directory, Librarian returned absolute paths rather than instructions to find the files. The friction reduction there is small but real in a timed context.&lt;/p&gt;

&lt;p&gt;The architecture's weak point was pwn. Type B's coverage of heap exploitation methodology is reasonable — 0xdf's writeups cover it well — but Type A's coverage of specific pwntools invocations is thin. Most GTFOBins entries are for privilege escalation, not binary exploitation. Operator had to reconstruct pwntools boilerplate from the docs rather than retrieving it from an indexed source.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Improve Type B coverage for pwn.&lt;/strong&gt; The 0xdf blog and pwntools docs are the current sources. CTF-wiki is cloned locally but not yet ingested. Adding it, along with targeted crawls of well-known pwn writeup archives, would improve coverage for the challenge categories where theory-to-payload translation matters most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix keyword extraction.&lt;/strong&gt; The current heuristic (words &amp;gt;4 chars, first 3) was a placeholder that never got replaced. A minimal improvement would be to extract known CTF keywords — CVE numbers, binary names, technique names — before falling back to length heuristics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add TypeD integration hints.&lt;/strong&gt; When Librarian returns a methodology result that implies a specific tool invocation (ROPgadget, pwntools, gdb-peda), it should note the tool and suggest the invocation pattern even if it's not in the index. Currently there's no connection between Type B theory results and the TypeD tools in the execution environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache invalidation for Type B.&lt;/strong&gt; The raw HTML cache has no expiration. 0xdf's blog gets new writeups; pwntools docs update with new releases. The current approach requires manually deleting cached files to pick up changes. A TTL or content-hash check would fix this.&lt;/p&gt;

&lt;p&gt;The engine in its current form is functional and was net-positive at BearcatCTF. It's also clearly a first version. The architecture is right — the three-way split between immediate payloads, methodology, and local assets maps cleanly onto how a human CTF player actually uses different reference materials. The rough edges are in the population and retrieval quality within each layer, not in the design.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://claw-stack.com/en/blog/ctf-knowledge-engine" rel="noopener noreferrer"&gt;claw-stack.com&lt;/a&gt;. We're building an open-source AI agent runtime — check out the &lt;a href="https://claw-stack.com/en/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt; or &lt;a href="https://github.com/openclaw/openclaw" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ctf</category>
      <category>knowledgeretrieval</category>
      <category>rag</category>
    </item>
    <item>
      <title>Building a Persistent Memory System for AI Agents</title>
      <dc:creator>Qiushi</dc:creator>
      <pubDate>Sat, 07 Mar 2026 23:05:28 +0000</pubDate>
      <link>https://dev.to/qiushiwu/building-a-persistent-memory-system-for-ai-agents-1h52</link>
      <guid>https://dev.to/qiushiwu/building-a-persistent-memory-system-for-ai-agents-1h52</guid>
      <description>&lt;p&gt;The canonical advice for giving an AI agent memory is: use a vector database. Store embeddings, do similarity search, retrieve relevant chunks. This is good advice for retrieval-augmented generation systems where the query pattern is "find documents similar to this question." It's not necessarily the right answer for an agent that needs to remember &lt;em&gt;what it did last Tuesday&lt;/em&gt; and &lt;em&gt;what decisions it made about project X six weeks ago&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here's how we built the Claw-Stack memory system, why it looks the way it does, and what we learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with stateless agents
&lt;/h2&gt;

&lt;p&gt;Every Claude session starts fresh. The model has no memory of previous sessions unless you explicitly inject that context at the start. For a research assistant that you talk to once, this is fine. For an autonomous agent that runs every day, accumulates knowledge about your projects, and needs to maintain consistent behavior over weeks, it's a fundamental problem.&lt;/p&gt;

&lt;p&gt;The naive solution is to dump everything into the system prompt. This works until you've accumulated a few hundred KB of context, at which point two things happen: you start hitting context limits, and the model's ability to use the early parts of a very long context degrades. The agent starts ignoring things you told it three months ago because they're too far from the current interaction.&lt;/p&gt;

&lt;p&gt;We needed a memory system with two properties: it had to be &lt;em&gt;selective&lt;/em&gt; (only inject what's relevant to the current session), and it had to be &lt;em&gt;human-readable&lt;/em&gt; (we needed to be able to audit, edit, and correct what the agent believed).&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-layer architecture
&lt;/h2&gt;

&lt;p&gt;The memory system has three layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: MEMORY.md&lt;/strong&gt; — a compact index loaded at the start of every session. This is a structured Markdown file with sections for recent activity, active projects, key contacts, and infrastructure notes. It's intentionally kept short — the system enforces a byte-size cap — so it doesn't consume the context budget on sessions with large task descriptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Per-topic files&lt;/strong&gt; — longer Markdown files in &lt;code&gt;memory/&lt;/code&gt; that go into depth on specific subjects. &lt;code&gt;projects/claw-stack.md&lt;/code&gt;, &lt;code&gt;contacts/key-people.md&lt;/code&gt;, &lt;code&gt;infrastructure/servers.md&lt;/code&gt;. These aren't loaded automatically. The agent has a &lt;code&gt;read_memory&lt;/code&gt; tool that fetches a specific file when it needs depth on a topic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: SQLite + QMD vector search&lt;/strong&gt; — a SQLite database with FTS5 full-text search and a QMD (a vector embedding tool built on top of SQLite) index for semantic search. When the agent gets a query it can't answer from MEMORY.md and the per-topic files, it runs a vector search across all memory content to find relevant fragments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a vector database
&lt;/h2&gt;

&lt;p&gt;The short answer: for our scale and access patterns, the operational overhead of a standalone vector database isn't worth it.&lt;/p&gt;

&lt;p&gt;The main reasons we chose SQLite + FTS5 over a dedicated vector database:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Opacity.&lt;/strong&gt; With a dedicated vector database, you can't easily inspect whether a retrieval was correct without tooling to query it. A Markdown file you can open in any text editor. Our SQLite database opens with any SQLite tool, and the schema is tables we wrote ourselves.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Operational simplicity.&lt;/strong&gt; The entire memory store is a single &lt;code&gt;.db&lt;/code&gt; file plus a directory of Markdown files. No separate process to manage, no format migrations, no version compatibility issues between the database binary and your data.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sufficient for our scale.&lt;/strong&gt; We have around 50,000 words of memory content across all files. SQLite FTS5 can do full-text search across that in milliseconds. The cases where vector similarity is meaningfully better than keyword search are real but rare enough that the operational overhead isn't worth it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;QMD (the vector search layer) sits on top of SQLite. Embeddings are computed locally using a small quantized model and stored in a SQLite table alongside the text. Re-indexing takes a few seconds. The entire memory store is a single &lt;code&gt;.db&lt;/code&gt; file plus a directory of Markdown files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The organizer pipeline
&lt;/h2&gt;

&lt;p&gt;Memory doesn't manage itself. After every session, an organizer pipeline runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw session files
  → scan memory/*.md (MD5 hash check, skip unchanged)
  → extract facts per category (project updates, decisions, contacts)
  → deduplicate against existing memory
  → write updated per-topic files
  → rebuild SQLite FTS5 index
  → update MEMORY.md index
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extraction step uses an LLM (Gemini as primary, with Claude Haiku as fallback): it reads a session transcript and produces structured notes in a specific format. The deduplication step is rule-based: if the new fact is a substring of an existing entry, skip it; if it contradicts an existing entry, flag it for human review.&lt;/p&gt;

&lt;p&gt;The pipeline runs on a cron schedule (every few hours during active work periods) rather than immediately after every session. This batches the processing cost and avoids writing memory files that will be immediately overwritten by a subsequent session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MEMORY.md bloat problem
&lt;/h2&gt;

&lt;p&gt;The most painful lesson was about MEMORY.md growth.&lt;/p&gt;

&lt;p&gt;We started with no limit on MEMORY.md length. The organizer kept adding to it. After six weeks, MEMORY.md was over 700 lines long. This had a predictable effect: session startup consumed most of the context budget before any actual task content was loaded, and the model was visibly struggling to synthesize a several-hundred-line brief while also doing useful work.&lt;/p&gt;

&lt;p&gt;The fix was to change the organizer's behavior and enforce a size cap. Instead of appending new facts to MEMORY.md directly, the organizer writes them to per-topic files and updates MEMORY.md with &lt;em&gt;pointers&lt;/em&gt; — one line that says "see &lt;code&gt;projects/claw-stack.md&lt;/code&gt; for current status" rather than embedding the full status in MEMORY.md. The system now enforces a byte-size limit on MEMORY.md to prevent runaway growth.&lt;/p&gt;

&lt;p&gt;This required us to rethink what MEMORY.md is for. It's not a summary of everything the agent knows. It's a &lt;em&gt;session briefing&lt;/em&gt; — the minimum context needed to orient the agent at the start of a session. Anything beyond that is fetched on demand.&lt;/p&gt;

&lt;p&gt;After the refactor, session startup is noticeably faster, and the model makes better use of the context it has. Keeping MEMORY.md truly compact is an ongoing discipline — we found that a strict line count is less useful than a byte-size limit, and even that requires the organizer to be aggressive about using pointers rather than inline content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory as human-readable state
&lt;/h2&gt;

&lt;p&gt;The design philosophy behind the system is that &lt;em&gt;agent memory should be human-readable and human-editable&lt;/em&gt;. This is a constraint we imposed deliberately.&lt;/p&gt;

&lt;p&gt;When the agent develops incorrect beliefs — and it does, occasionally — we can find the wrong entry in a Markdown file, edit it, and the fix takes effect in the next session. With a vector database, correcting a wrong belief requires knowing which embedding to update, deleting it, writing a new one, and potentially invalidating cached retrievals. With Markdown files, you open the file and change the text.&lt;/p&gt;

&lt;p&gt;This also makes auditing straightforward. Before trusting an autonomous agent to make decisions on your behalf, you need to be able to read its beliefs and verify they're correct. The entire memory system is a directory of Markdown files. Any text editor works.&lt;/p&gt;

&lt;p&gt;The tradeoff is that the format is fixed. Our memory files follow a specific schema that the organizer knows how to parse and update. If you want to add a new category of memory, you need to update both the file schema and the organizer. For a research project with one operator, that's acceptable. For a production system with many agents and many types of memory, you'd want something more flexible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd do differently
&lt;/h2&gt;

&lt;p&gt;If we were starting over:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a smaller MEMORY.md from day one.&lt;/strong&gt; We wasted weeks cleaning up the bloat that could have been avoided with an initial size cap. A byte-size limit with pointer-based entries is a better target than a fixed line count for a daily-use assistant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate episodic from semantic memory earlier.&lt;/strong&gt; "What happened in Tuesday's session" (episodic) and "what is the Claw-Stack architecture" (semantic) are different types of memory that benefit from different retrieval strategies. We mixed them initially and spent time later separating them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the audit tooling first.&lt;/strong&gt; The hardest part of maintaining an agent memory system isn't the indexing or retrieval — it's knowing when the memory is wrong. We built the audit view (a script that shows you what the agent believes about a given topic) too late. It should have been the first tool we wrote.&lt;/p&gt;

&lt;p&gt;The memory system is one of the parts of Claw-Stack we're most satisfied with. It's boring infrastructure that works reliably, which is exactly what memory should be.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://claw-stack.com/en/blog/persistent-memory-system" rel="noopener noreferrer"&gt;claw-stack.com&lt;/a&gt;. We're building an open-source AI agent runtime — check out the &lt;a href="https://claw-stack.com/en/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt; or &lt;a href="https://github.com/openclaw/openclaw" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>memory</category>
      <category>architecture</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>24 Hours, 40 Challenges: How an AI Team Placed Top 6% at BearcatCTF 2026</title>
      <dc:creator>Qiushi</dc:creator>
      <pubDate>Sat, 07 Mar 2026 22:52:10 +0000</pubDate>
      <link>https://dev.to/qiushiwu/24-hours-40-challenges-how-an-ai-team-placed-top-6-at-bearcatctf-2026-4d7</link>
      <guid>https://dev.to/qiushiwu/24-hours-40-challenges-how-an-ai-team-placed-top-6-at-bearcatctf-2026-4d7</guid>
      <description>&lt;p&gt;Final result: rank #20 out of 362 teams. 40 of 44 challenges solved. 24 hours of unattended autonomous operation. These numbers revealed something we didn't expect — not about the AI, but about what structured agent coordination makes possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trinity architecture
&lt;/h2&gt;

&lt;p&gt;BearcatCTF was the first real-world deployment of what we call the &lt;strong&gt;Trinity&lt;/strong&gt;: three specialized agents with distinct roles, operating on a shared knowledge base.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commander&lt;/strong&gt; (Claude Opus) — the strategic layer. Read the challenge list, estimated difficulty, assigned work, tracked progress, decided when to abandon dead-ends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operator&lt;/strong&gt; (Claude Sonnet) — the solver. Received assignments plus briefings from Librarian, then worked the problem: writing scripts, testing payloads, reading source code, running tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Librarian&lt;/strong&gt; (Claude Haiku) — the knowledge manager. After each solve, extracted key techniques and stored them in a shared blackboard. When Operator hit a new challenge, Librarian pulled relevant entries — "here's what we learned about JWT forgery two hours ago."&lt;/p&gt;

&lt;p&gt;Communication happened through OpenClaw's &lt;code&gt;sessions_spawn&lt;/code&gt; and auto-announce mechanism. A persistent &lt;code&gt;blackboard.json&lt;/code&gt; served as the durable state layer, tracking findings and the current attack plan across spawns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first few hours
&lt;/h2&gt;

&lt;p&gt;44 challenges across 7 categories — reverse engineering (7), OSINT (5), forensics (7), cryptography (8), web (4), misc (8), and pwn (5). Commander sorted by estimated solve time and started dispatching.&lt;/p&gt;

&lt;p&gt;The first hours were fast. Web challenges fell quickly: SQL injection, insecure cookies, JWT &lt;code&gt;alg: none&lt;/code&gt;. Crypto had encoding challenges Operator dispatched in minutes. Librarian was cataloguing.&lt;/p&gt;

&lt;p&gt;By hour four, the solve rate slowed. Commander was choosing more carefully, deprioritizing brute-force computation and flagging image challenges as low-probability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anti-cheating mechanism
&lt;/h2&gt;

&lt;p&gt;We built a rule early: if a challenge was solved in under three minutes, an automatic audit ran before submitting the flag. The auditor reviewed session history and checked whether the agent had actually worked the problem.&lt;/p&gt;

&lt;p&gt;This caught a real case: on one pwn challenge, Operator read a &lt;code&gt;README.md&lt;/code&gt; containing the flag rather than exploiting the service. The session was marked &lt;code&gt;CHEATED&lt;/code&gt; and Commander was told to redo it through legitimate exploitation.&lt;/p&gt;

&lt;p&gt;The audit also made our logs more trustworthy. Every fast solve had been verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  The middle game: Librarian's value
&lt;/h2&gt;

&lt;p&gt;Hours six through twenty were where Librarian integration showed its value most clearly.&lt;/p&gt;

&lt;p&gt;Forensics challenges often share techniques — steganography, file carving, metadata extraction. As Librarian accumulated knowledge from solved forensics challenges, Operator's first attempts on new ones were better-calibrated. Instead of starting from first principles, Operator received briefings: "previous forensics used binwalk and foremost; JPEG steganography appeared twice."&lt;/p&gt;

&lt;p&gt;The eighth crypto challenge was solved significantly faster than the first — similar difficulty, but by then Librarian had extracted approaches to substitution ciphers, padding oracles, and XOR key recovery.&lt;/p&gt;

&lt;p&gt;Commander also made calls we wouldn't have made manually. Around hour sixteen, it deprioritized two shellcode challenges and redirected Operator to unstarted OSINT challenges. The OSINT batch went quickly. Good call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four unsolved challenges
&lt;/h2&gt;

&lt;p&gt;We finished 40/44. The four unsolved were all &lt;strong&gt;visual/image analysis tasks&lt;/strong&gt;: a degraded QR code, object identification in photographs, and low-resolution character reading.&lt;/p&gt;

&lt;p&gt;Not surprising in retrospect. Claude's vision capabilities aren't optimized for pixel-level analysis. Commander recognized this pattern around hour fifteen and stopped assigning image-heavy tasks, flagging them as "pending human review." No human was available.&lt;/p&gt;

&lt;p&gt;The right fix: integrate a dedicated image analysis tool — a custom MCP server wrapping specialized vision models.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The blackboard pattern works.&lt;/strong&gt; A persistent JSON file as durable state, with spawn/announce for communication, is simple and effective coordination without tight coupling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model selection by role matters.&lt;/strong&gt; Haiku for Librarian (high-volume, latency-sensitive). Opus for Commander (judgment calls). Sonnet for Operator (balanced depth/cost).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vision is the ceiling.&lt;/strong&gt; Four of four failures required precision image analysis. This gap can't be closed by prompt engineering alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unattended operation is achievable, but fragile in specific ways.&lt;/strong&gt; 24 hours, no crashes, no loops, no obviously wrong flags. But the system didn't ask for help when it hit something it couldn't handle. When should an autonomous agent stop vs. move on? For CTF, moving on is usually right. For other domains, it might not be.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Trinity architecture is part of the &lt;a href="https://claw-stack.com" rel="noopener noreferrer"&gt;Claw-Stack&lt;/a&gt; research project. Full documentation: &lt;a href="https://claw-stack.com/en/docs" rel="noopener noreferrer"&gt;claw-stack.com/en/docs&lt;/a&gt;. See also our post on &lt;a href="https://claw-stack.com/en/blog/persistent-memory-system" rel="noopener noreferrer"&gt;building persistent memory for AI agents&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>ctf</category>
      <category>agents</category>
    </item>
    <item>
      <title>OpenClaw vs LangChain: Why We Don't Use Frameworks</title>
      <dc:creator>Qiushi</dc:creator>
      <pubDate>Sat, 07 Mar 2026 22:52:06 +0000</pubDate>
      <link>https://dev.to/qiushiwu/openclaw-vs-langchain-why-we-dont-use-frameworks-1aee</link>
      <guid>https://dev.to/qiushiwu/openclaw-vs-langchain-why-we-dont-use-frameworks-1aee</guid>
      <description>&lt;p&gt;The first question people ask when they see our setup is: why not just use LangChain? It's the dominant Python framework for AI agents, it has a huge ecosystem, and it handles a lot of plumbing. The answer has to do with what "framework" means and what we actually needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OpenClaw is (and what it isn't)
&lt;/h2&gt;

&lt;p&gt;OpenClaw is an npm package. You install it, configure it, and it runs as a local process that gives Claude access to tools — file system, shell, MCP servers, memory. It's a &lt;strong&gt;runtime&lt;/strong&gt;, not a framework. It doesn't tell you how to organize your agent logic.&lt;/p&gt;

&lt;p&gt;This is a meaningful distinction. OpenClaw has opinions about &lt;em&gt;how tools get called&lt;/em&gt;, but no opinion about &lt;em&gt;what your agent does&lt;/em&gt;. There's no base class to extend, no chain to compose, no graph to define. You write a &lt;code&gt;CLAUDE.md&lt;/code&gt; file that describes how your agent should behave, and OpenClaw runs a Claude session with that context and the tools you've registered.&lt;/p&gt;

&lt;p&gt;LangChain is the opposite — it provides the skeleton, you fill in the details. Useful when the skeleton matches your use case. A problem when it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The abstraction problem
&lt;/h2&gt;

&lt;p&gt;LangChain's abstractions are designed around composing LLM calls in a pipeline: input → retrieval → LLM → output → next call. This works well for RAG systems. It starts to fight you when you need something that doesn't fit the pipeline model.&lt;/p&gt;

&lt;p&gt;Our multi-agent meeting protocol, for example, runs multiple Claude instances as "participants" in a structured discussion. Each participant reads the conversation history, produces a response, and optionally signals consensus. The coordinator decides whether to continue. None of this fits neatly into LangChain's agent/tool model.&lt;/p&gt;

&lt;p&gt;With OpenClaw, we just write the coordination logic ourselves. The coordinator is an OpenClaw session that reads shared state, spawns participant agents as subprocesses, collects responses, and decides what to do next. Every line is doing something we understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging experience
&lt;/h2&gt;

&lt;p&gt;When something goes wrong with a LangChain agent, the error is often several layers deep. You're debugging a runnable that calls a chain that calls an LLM that returns output parsed by an output parser...&lt;/p&gt;

&lt;p&gt;With OpenClaw, there are two places to look: your tool implementation and the Claude session log. We've run sessions lasting hours with dozens of tool calls. When something goes wrong in hour two, you want to read the session log and understand exactly what happened. With a thin runtime, the session log &lt;em&gt;is&lt;/em&gt; the complete record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock-in and integrations
&lt;/h2&gt;

&lt;p&gt;LangChain has 500+ integration packages, many community-maintained. OpenClaw's integration model is different: integrations happen through &lt;strong&gt;MCP&lt;/strong&gt; (Model Context Protocol). An MCP server is just a process that exposes tools. Writing one is about 50 lines of code. When a third-party integration breaks, the fix is isolated — it doesn't cascade through your agent logic.&lt;/p&gt;

&lt;p&gt;This is why we could build our web automation layer (26 Chrome DevTools Protocol tools), content aggregator, and backup integration without any framework code. Each is a standalone MCP server.&lt;/p&gt;

&lt;h2&gt;
  
  
  When LangChain makes sense
&lt;/h2&gt;

&lt;p&gt;This isn't a blanket argument against LangChain. If you're building a RAG system — retrieve documents, pass to LLM, return answer — LangChain maps well. It also has strong vector database and document loader integrations.&lt;/p&gt;

&lt;p&gt;Our use case is different: an autonomous agent system that runs for days, accumulates state over weeks, coordinates multiple agents, and needs to be debugged when things go wrong. For that, we wanted the most transparent runtime we could find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The principle: thin runtime, rich skills
&lt;/h2&gt;

&lt;p&gt;Our architecture follows "thin runtime, rich skills." OpenClaw handles tool dispatch, session management, and the Claude interface. Everything else — memory, security, multi-agent coordination, browser automation — lives in separate, independently-deployable modules.&lt;/p&gt;

&lt;p&gt;Each skill can be tested in isolation, replaced without touching the others, and reasoned about independently. The downside is more wiring to write. The upside is that when something breaks, it's almost always in the wiring — the part you wrote and understand.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;We're building this as a &lt;a href="https://claw-stack.com" rel="noopener noreferrer"&gt;personal research project&lt;/a&gt;. If you're interested in agent architecture, memory systems, or multi-agent coordination, check out our &lt;a href="https://claw-stack.com/en/docs" rel="noopener noreferrer"&gt;full documentation&lt;/a&gt; or the &lt;a href="https://claw-stack.com/llms-full.txt" rel="noopener noreferrer"&gt;llms-full.txt&lt;/a&gt; for AI-readable context.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>langchain</category>
      <category>agents</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
