ให้ AI พูดภาษาไทยได้ด้วย Garudust Agent + iApp TTS

ถ้าคุณใช้ Garudust Agent อยู่แล้ว การเพิ่มความสามารถให้ AI พูดภาษาไทยออกมาเป็นเสียง ทำได้ในไม่กี่ขั้นตอน — ไม่ต้องแก้โค้ดใด ๆ เพราะ Garudust มีระบบ script tool ที่ pluggable อยู่แล้ว

TTS คืออะไร และทำไมต้อง iApp

TTS (Text-to-Speech) คือการแปลงข้อความเป็นเสียงพูด เหมาะกับ use case เช่น

  • ตอบกลับผู้ใช้ด้วยเสียงแทนข้อความบน LINE / Telegram
  • สร้างไฟล์เสียงสำหรับ podcast / narration อัตโนมัติ
  • Accessibility — ผู้ใช้ที่อ่านหน้าจอยาก

iApp Technology เป็นบริษัทไทยที่มี TTS API รองรับภาษาไทยโดยเฉพาะ เสียงฟังเป็นธรรมชาติ รองรับสูงสุด 10,000 ตัวอักษรต่อ request คิดค่าใช้จ่าย 1 IC ต่อ 400 ตัวอักษร สมัครได้ที่ iapp.co.th

ขั้นตอนที่ 1 — ติดตั้ง tts tool จาก Hub

Garudust มี Hub รวม script tool ให้ install ได้ทันที

garudust tool install tts

ระบบจะดึง tool.yaml และ run.py มาไว้ที่ ~/.garudust/tools/tts/ และ register ใน registry อัตโนมัติ

ขั้นตอนที่ 2 — เพิ่ม Provider Profile ใน config.yaml

Garudust ใช้ระบบ provider profile จัดการ API endpoint และ key — ไม่ต้อง hardcode ใน script

เปิด ~/.garudust/config.yaml แล้วเพิ่ม:

providers:
  # ... providers ที่มีอยู่แล้ว ...
  tts-iapp:
    url: https://api.iapp.co.th/v3/store/audio/tts
    key: ${IAPP_API_KEY}

tools:
  tts:
    model: tts-iapp   # ชี้ไปที่ profile ข้างบน

Garudust จะ inject GARUDUST_BASE_URL และ GARUDUST_API_KEY เข้า script อัตโนมัติตอน runtime

ขั้นตอนที่ 3 — เพิ่ม API Key ใน .env

# ~/.garudust/.env
IAPP_API_KEY=iapp_live_xxxxxxxxxxxxxxxx

รับ key ได้จาก iapp.co.th/dashboard

ทดสอบผ่าน CLI

garudust "ใช้ tts tool แปลงข้อความว่า สวัสดีครับ ผมคือ Garudust ผู้ช่วย AI ของคุณ แล้วบอก path ไฟล์ที่ได้"

ผลลัพธ์:

สร้างไฟล์เสียงสำเร็จ
เสียงอยู่ที่: /tmp/tts_88b5bba372dd41a7acc7e37ea75df89b.wav

เปิดฟัง:

open /tmp/tts_88b5bba372dd41a7acc7e37ea75df89b.wav

เบื้องหลัง — run.py ทำงานยังไง

iApp TTS API คืน raw PCM มาโดยไม่มี WAV header ดังนั้น run.py จัดการ wrap header ให้เอง:

def pcm_to_wav(pcm: bytes, sample_rate: int = 24000, channels: int = 1, bits: int = 16) -> bytes:
    byte_rate   = sample_rate * channels * bits // 8
    block_align = channels * bits // 8
    data_size   = len(pcm)
    header = struct.pack(
        "<4sI4s4sIHHIIHH4sI",
        b"RIFF", 36 + data_size, b"WAVE",
        b"fmt ", 16,
        1, channels, sample_rate, byte_rate, block_align, bits,
        b"data", data_size,
    )
    return header + pcm

ผลที่ได้คือ RIFF WAV 16-bit mono 24kHz — เปิดได้กับทุก audio player

เปลี่ยน Provider ได้ทันทีโดยไม่แตะโค้ด

นี่คือจุดแข็งของ Garudust provider profile — ถ้าอยากลอง provider อื่นเช่น AIS หรือ local model แค่เพิ่ม profile และเปลี่ยน tools.tts.model บรรทัดเดียว:

providers:
  tts-ais:
    url: https://your-ais-tts-endpoint/api/tts
    key: ${AIS_TTS_API_KEY}

tools:
  tts:
    model: tts-ais   # เปลี่ยนแค่นี้

run.py ไม่ต้องแตะเลย เพราะอ่าน GARUDUST_BASE_URL และ GARUDUST_API_KEY จาก profile เสมอ

ขั้นต่อไปที่ทำได้

  • LINE voice message — เพิ่ม OutboundMessage::Audio ใน Garudust core แล้วส่งไฟล์ WAV เป็น voice message บน LINE ได้เลย
  • Auto TTS reply — สั่งให้ agent เรียก tts ทุกครั้งที่ตอบ โดย inject ใน system prompt
  • Speaker selection — iApp มีหลายเสียง ตั้ง TTS_SPEAKER_ID ใน .env เพื่อเลือกเสียงที่ชอบ

สรุป

ขั้นตอน คำสั่ง
ติดตั้ง tool garudust tool install tts
เพิ่ม profile แก้ config.yaml
เพิ่ม key แก้ ~/.garudust/.env
ใช้งาน สั่ง agent พูดภาษาไทย

Garudust Agent เป็น open source ที่ github.com/garudust-org/garudust-agent — ติดตามหรือ contribute ได้เลย 🦅

How Gemma 4 Can Help Solve the Trillion-Dollar Oversight in Women’s Health

This is a submission for the Gemma 4 Challenge: Build with Gemma 4

By 2030, an estimated 1.2 billion women will be experiencing perimenopause or menopause. The research literature connecting this transition to cardiovascular changes, musculoskeletal symptoms, mood disruption, and metabolic shifts exists, but it’s scattered across peer-reviewed papers that no clinician has time to synthesize across specialties. The result is fragmented care: a cardiologist treating elevated LDL, an orthopedist treating frozen shoulder, a GP prescribing an SSRI for anxiety. Each doctor is correct in isolation, but none aware they’re looking at the same hormonal root cause.

AXIOM (Automated X-reference Intelligence & Open Medicine) is a local-first scientific evidence engine that closes this gap. It ingests peer-reviewed literature, builds a structured knowledge graph of typed medical relationships, and answers clinical questions grounded entirely in cited evidence, with a privacy-first architecture designed for local deployment.

Demo

The demo covers: system status overview, live PubMed retrieval showing the relationships between adhesive capsulitis and estrogen, a five-symptom hybrid RAG query (perimenopause + frozen shoulder + elevated LDL + anxiety + sleep disruption) with graph triples expanded, and the graph explorer with path-finding between cardiovascular disease and insomnia.

The system has the following integrated layers:

Ingestion. PDFs are chunked, embedded via nomic-embed-text, and stored in ChromaDB. In parallel, each chunk goes to Gemma 4 for named entity recognition and relation extraction, producing typed triples like Estrogen Decline →[REDUCES_RISK]→ Bone Density. These are stored in a directed NetworkX graph with confidence scores.

Privacy Guard. Every query passes through a two-stage PHI scrubber before any model call: regex strips obvious identifiers, then Gemma rewrites the result into a generalised clinical research question. If Gemma fails, the regex output is the safe fallback.

Hybrid Retrieval. At query time: Gemma 4 extracts medical entities from the question as a JSON array, those entities are fuzzy-matched against graph nodes, and ChromaDB returns top-K chunks re-ranked by a composite score combining semantic similarity, study-type weight (RCTs over case reports), recency, and a graph-entity boost.

Three-Mode Answer Chain. chain.py routes to direct (parametric), vector RAG, or hybrid graph RAG. In hybrid mode the prompt presents structured triples and paper excerpts as explicitly labelled sections with different epistemic weights and graph relationships are hypotheses, excerpts are primary sources. All generation calls are serialised through a single-consumer LLMQueue to prevent concurrent Ollama requests from saturating local hardware.

Code

https://github.com/HARMONI-Lab/axiom

Stack: Python 3.11 · FastAPI · ChromaDB · NetworkX · Ollama · FastMCP · Biopython/NCBI Entrez · React 19 + Vite frontend

Running a 31B model locally with concurrent FastAPI requests causes queuing failures and dropped responses. To mitigate this a single-consumer asyncio queue that serialises all generation calls through one worker was implemented. Every endpoint caller awaits a Future allowing the queue to drain one job at a time. This sacrifices parallelism for reliability, which is the correct trade-off on a single-GPU machine.

class LLMQueue:
    """Single-consumer async queue in front of the LLM.

    Prevents concurrent Ollama requests from saturating local hardware
    by serialising all generation calls through one worker coroutine.
    Callers await a Future — the queue handles the rest.
    """

    async def submit(self, user_message: str, system_prompt: str = "") -> str:
        self._ensure_init()
        loop = asyncio.get_running_loop()
        fut: asyncio.Future = loop.create_future()
        job = LLMJob(user_message=user_message, system_prompt=system_prompt, future=fut)
        await self._queue.put(job)
        return await fut  # caller blocks here until worker completes the job

    async def _run(self):
        while True:
            job: LLMJob = await self._queue.get()
            try:
                result = await llm.generate_async(job.user_message, job.system_prompt)
                if not job.future.done():
                    job.future.set_result(result)
            except Exception as e:
                if not job.future.done():
                    job.future.set_exception(e)
            finally:
                self._queue.task_done()

In production, replacing the lazy _ensure_init() check with an explicit startup/shutdown lifecycle and handling caller cancellation would prevent in-flight work from being wasted when clients disconnect and ensure the queue drains cleanly on container restart.

How I Used Gemma 4

I chose gemma4:31b Dense via Ollama, and here is why it was the right fit for this use case.

Why 31B and not the smaller models. The E2B and E4B models are optimized for edge and mobile deployment. AXIOM’s ingestion pipeline asks Gemma to do something genuinely hard: read a paper chunk and determine not just which medical concepts appear, but whether the paper is asserting a causal relationship between them—as opposed to merely mentioning both. Identifying that a sentence is claiming estrogen decline causes adhesive capsulitis, rather than just co-occurring with it, is a complex language understanding task that requires the reasoning depth of the 31B Dense model. Empirical testing with the smaller variants showed that relationship-type accuracy degraded significantly. Furthermore, the dense 31B’s architecture outperformed the 26B MoE on structured relation extraction, proving far more reliable at maintaining strict semantic focus and schema adherence across the full context window.

Why local via Ollama. Medical queries are sensitive by definition. You can’t claim a system protects user privacy if it’s built to send their private data away to the cloud. The design intent of AXIOM is strictly local-first, making it seamlessly deployable in rural clinics, research labs under strict IRB data-use restrictions, or any air-gapped environment where patient queries cannot leave the building. However, the architecture’s provider abstraction (src/core/providers/) decouples model orchestration from core business logic, supporting dual-use topologies. For academic researchers doing macro-level literature synthesis where queries contain purely biomedical concepts and zero patient PHI, the framework can be instantly toggled to route requests to a high-throughput, hosted staging endpoint like gemma4:31b-cloud via Ollama. This enables rapid, parallelized multi-concept exploration without local hardware constraints, while keeping the underlying engine completely consistent.

Six core roles (and one experimental) Gemma 4 is invoked at seven points in the pipeline, not just generation:

  1. Privacy guard, rewrites raw queries into generalised clinical research questions, stripping PHI before anything else runs
  2. Entity extraction at query time, returns a JSON array of medical entities driving graph lookup
  3. Knowledge graph construction at ingestion, NER + relation extraction on every paper chunk, normalised to canonical relationship types
  4. Answer generation, hybrid mode synthesises graph triples and paper excerpts under strict citation constraints. Additionally, every retrieval result passes a live retraction check against NCBI E-utilities before reaching the model, retracted papers are blocked outright, expressions of concern are flagged and their composite scores halved, so Gemma reasons only over evidence that has survived post-publication scrutiny.
  5. Optional route classification, classifies questions into direct/vector/hybrid when ROUTER_USE_LLM=true
  6. Autonomous PubMed agent, a ReAct loop where Gemma decides which MCP tool to call next for multi-step literature retrieval.
  7. Optionally, Gemma serves a role as self-evaluator, scoring answers on topic coverage, source faithfulness, and quality, useful as a heuristic for knowledge base iteration, though not a substitute for external validation.

The result: with approximately 50 ingested papers, AXIOM surfaces a structured hormonal narrative linking elevated LDL, adhesive capsulitis, anxiety, sleep disruption, and irregular cycles to a single perimenopausal aetiology. In the fragmented-care scenario this tool addresses, the same symptoms would typically arrive at separate specialist systems with no shared context and no connecting insight surfaced automatically. The same architecture applies to any clinical domain where evidence is fragmented across specialties. The knowledge base, entity schema, and retrieval pipeline are domain-configurable.

I bought yamlfuneral.com

I’ve rewritten the same CloudFormation template from scratch more times than I want to admit.

The drill never changes.

The design session goes well. The diagram looks clean. Everyone nods.

Then a week later someone tweaks the CloudFormation to add a caching layer and does not touch the diagram.

Then someone else changes the template again.

Then you are in an incident at 2am referencing infrastructure that has not existed for three months.

We tried the “update the diagram too” rule.

It lasted one sprint.

The deeper problem is that the diagram and the template are two completely separate things, maintained in two completely separate workflows. They start identical, then slowly drift apart until nobody fully trusts either one.

So I bought a domain about it.

yamlfuneral.com

Something is coming.

If you have ever stared at a CloudFormation template wondering why it looks nothing like the architecture diagram from last month, this is for you.

More soon.

How to remotely iterate & deploy your sideloaded iOS-apps over tailnet

Sideloading an iPhone over Tailscale from anywhere, no USB, Touch grass while CI/CD on iOS.

TL;DR

iOS 17+ moved iPhone development tooling onto a stack — CoreDevice + RemoteXPC + Bonjour — that’s deliberately scoped to local-network adjacency. The official position is: USB-tether to the Mac, or have the iPhone on the Mac’s Wi-Fi. There is no “remote install” knob in Xcode or xcrun devicectl.

I wanted a roaming pipeline: edit code on a Linux build host, sideload onto an iPhone that’s on cellular two countries away, all over Tailscale. After a long detour into the wrong rabbit holes, the working architecture turned out to be a one-LaunchAgent combination of dns-sd -P Bonjour fabrication and socat TCP/UDP proxying. The whole bridge fits in ~50 lines of bash and works because every “structural” blocker is actually a layering choice — and each layer can be sidestepped without violating the layer above.

This post walks through the protocol stack, the wrong turns (instructive), and the actual working bridge.

The setup

  • Build host (Linux): edits the SwiftUI source, drives xcodebuild over SSH, holds the canonical project files. Reachable on a Tailscale tailnet (Headscale-coordinated). (I’m on an OCI free-tier shape running Claude Code – how to get your own)
  • Mac: only the build slave. Has Xcode, the signing identity, and runs xcodebuild / xcrun devicectl. On the same tailnet.
  • iPhone: a real iOS 26 device with a free Apple Developer Personal Team. On the same tailnet via the Tailscale iOS app. Wants to receive the built .ipa and run it.

The pipeline is edit on Linux → sync to Mac → xcodebuild archive + export → devicectl install → iPhone runs the app. The first four steps were already working over SSH/rsync; the last step is the focus here.

The iOS-17+ developer-services stack

For our purposes, the relevant protocol layers on an iPhone are:

  1. Bonjour / _remotepairing._tcp.local.: iPhone advertises a CoreDevice front-door service on TCP port 49152 via mDNS. Discovery is intentionally link-local: this is what makes Xcode “see” connected iPhones on the same Wi-Fi.
  2. RemoteServiceDiscovery (RSD): a small JSON/HTTP-2 service reachable at the advertised port that returns a manifest of available developer services (DebugProxy, AFC, InstallationProxy, etc.) and the dynamic ports they listen on.
  3. Trusted tunnel: once Mac and iPhone have RSD-handshaked, they establish a QUIC tunnel for the actual developer-services traffic. The tunnel listens on a dynamic port iPhone picks per session — we observed 55110-55115 across attempts on iOS 26.5.
  4. CoreDevice service multiplexer: AFC file transfers, installation_proxy, debug interfaces, etc. all ride the trusted tunnel.

The whole thing is gated on Bonjour announcing the iPhone to mDNSResponder on Mac, with remoted then driving the rest of the stack.

Why Tailscale (or any L3 VPN) breaks the default flow

Tailscale gives you a unicast IP route between any two devices in your tailnet, anywhere on the internet. That’s all it gives you: no broadcast, no multicast, no L2 frames. Bonjour relies entirely on link-local multicast (224.0.0.251, TTL 1). iOS’s mDNSResponder explicitly skips point-to-point interfaces (this is documented and there’s nothing you can configure) — even if you somehow forced multicast onto the WireGuard tunnel, Apple wouldn’t announce on it.

So a roaming iPhone on Tailscale is, from Mac’s remoted viewpoint, invisible. xcrun devicectl list devices shows the iPhone as unavailable. No install possible.

The wrong turns

Bonjour reflectors

The obvious-looking answer is “run a Bonjour reflector”. There are several: avahi-daemon --reflector, Gandem/bonjour-reflector, the Spiceworks community’s Linux gateway recipe, even Palo Alto firewalls. They all work the same way: a daemon sits on a host with NICs in two L2 segments and rebroadcasts mDNS packets between them.

None of these apply to Tailscale. Reflectors operate at the L2 frame layer (gopacket+pcap+802.1Q tags, or at minimum a tcpdump-able interface receiving multicast). tailscale0 is a userspace WireGuard tunnel exposing IP packets only — there are no Ethernet frames to capture, no broadcast domain on the tunnel side. The packets the reflectors need to relay don’t exist on the tunnel side.

The only way to push mDNS across a WireGuard tunnel is to wrap the tunnel in an L2 overlay (VXLAN, GRETAP, L2TPv3 inside WireGuard). The iPhone cannot participate in such an overlay — there’s no API for it without jailbreaking — so this is a dead end for roaming.

Direct lockdownd on TCP 62078 (the legacy protocol)

Before iOS 17, iPhones spoke plain TLS-wrapped property-list messages on TCP 62078 via usbmuxd. The protocol is still present on the device. libimobiledevice / ideviceinstaller and pymobiledevice3‘s LockdownClient both speak it. You’d think: just skip Bonjour, dial iphone-tailnet-ip:62078 directly with a stored pair record, ask installation_proxy to install the IPA.

I tried this. TCP 62078 accepts the connection, then resets on the first byte of any plausible protocol (length-prefixed XML plist, HTTP/2, HTTP/1.1, raw TLS). I sent literal zero bytes — connection idled, then closed cleanly. Sent anything meaningful — instant RST. Apple appears to have tightened the network-side lockdownd listener in iOS 17+ to require an active CoreDevice trusted tunnel before serving anything. The legacy protocol is effectively no longer exposed over Wi-Fi/tailnet.

pymobiledevice3 apps install --rsd HOST PORT

pymobiledevice3‘s CLI accepts --rsd HOST PORT for a direct RSD endpoint. I tried --rsd 100.64.0.5 58783 (the documented “hardcoded” RSD port) — refused. --rsd 100.64.0.5 49152 — TCP open but resets on handshake (wrong protocol — that port is RemotePairing, not RSD; the actual RSD port is allocated dynamically and announced via Bonjour TXT records we can’t see over tailnet).

This is the recursive trap: to talk to RSD you need its dynamic port; to know the port you need Bonjour; we don’t have Bonjour over tailnet.

Naive Bonjour spoof on Mac

The promising-looking idea: dns-sd -P lets you register fake Bonjour records into Mac’s own mDNSResponder. So advertise iPhone’s services locally on Mac with the hostname targeting iPhone’s tailnet IP. remoted browses Bonjour, sees the announcement, connects to the iPhone over tailnet. Apple’s mDNS API can’t tell whether a record came from a real device on the LAN or from a local dns-sd -P registration.

This almost works. Discovery succeeds — xcrun devicectl list devices shows the iPhone as available (paired). But devicectl install fails. The reason, from log show --predicate 'process == "remotepairingd"':

[C905.1.1 Hostname#... interface: en0[802.11], scoped, ipv4, dns, uses wifi, ...]
nw_socket_connect connectx [C905:2] connectx(..., [srcif=14, ...]) failed: [61: Connection refused]

remotepairingd scopes the outgoing TCP connection to the interface that announced the Bonjour record — en0 (Mac’s Wi-Fi). It’s calling nw_parameters_set_required_interface somewhere internal. So when our spoof points the hostname at 100.64.0.5 (iPhone’s tailnet IP), the SYN goes out en0 — which has no route to 100.64.0.5 — into nothing. Refused or timed out.

This is by design: Bonjour is a same-link discovery system, and CoreDevice trusts that property by binding to the link. Excellent engineering for the home-network case; a wall for our roaming case.

I tried scoping the dns-sd -P to the Tailscale interface (-i utun4) — registration succeeds but the records are invisible because utun4 is point-to-point and mDNSResponder doesn’t browse on it. Confirms Apple’s own constraint that we can’t sneak around.

I also confused myself by thinking the trusted tunnel listener was bound only to the USB-Ethernet IPv6 interface (because TCP probes to the dynamic port over tailnet were refused). They were refused because the trusted tunnel is QUIC over UDP, not TCP. The same UDP port responded fine on a UDP probe (nc -vzu). That oversight cost a few hours.

The actual working architecture

Once both real facts were on the table — remotepairingd scopes to the discovery interface, and iPhone’s trusted-tunnel listener IS reachable on tailnet (UDP) — the answer falls out:

Spoof Bonjour pointing the iPhone’s hostname to Mac’s own en0 IP, and run a local proxy that bridges to the iPhone over Tailscale.

remotepairingd is satisfied: the discovered service appears reachable on the discovery interface (en0), so its scoped connection lands on a port that’s actually listening — on Mac itself. The proxy then forwards the bytes to the iPhone via Tailscale. The CoreDevice handshake and TLS are end-to-end between remotepairingd and the iPhone; the proxy is a dumb byte relay, never inspecting or terminating TLS. Apple’s interface-scoping invariant is honored at the OS level; the bridging happens at the application level above it.

Components

The Mac runs a single LaunchAgent (com.example.coredevice-tailnet) that does three things in parallel:

  1. Bonjour spoof. dns-sd -P registers three records into mDNSResponder:

    • _remotepairing._tcp (instance UUID + TXT pulled from the iPhone via one-time USB capture)
    • _remoted._tcp
    • _apple-mobdev2._tcp

All target the hostname My-iPhone.local (the iPhone’s actual self-reported .local name). dns-sd -P‘s integrated A/AAAA registration resolves that hostname to Mac’s own en0 IP — e.g. 192.168.1.190. /etc/hosts mirrors that as belt-and-braces.

  1. RemotePairing proxy. A socat instance binding en0_ip:49152 and forwarding to iphone_tailnet_ip:49152 for both TCP and UDP. This is the CoreDevice front door — the first thing remotepairingd connects to after discovering the spoofed Bonjour record.

  2. Trusted-tunnel port-range proxy. A range of socat instances binding en0_ip:55000-55300 and forwarding to the corresponding port on the iPhone’s tailnet IP, both protocols. iPhone allocates its trusted-tunnel listener on a fresh port per pairing session (we observed 55110-55115); the range covers comfortably.

The script:

#!/bin/bash
set -euo pipefail

IPHONE_TAILNET_IP="100.64.0.5"
SPOOF_HOSTNAME="My-iPhone.local"
MAC_EN0_IP="$(ifconfig en0 | awk '/inet / {print $2; exit}')"

dns-sd -P "$RP_INSTANCE" _remotepairing._tcp local 49152 
    "$SPOOF_HOSTNAME" "$MAC_EN0_IP" 
    identifier="$RP_INSTANCE" authTag="$RP_AUTHTAG" ver=24 minVer=8 flags=0 &

socat TCP-LISTEN:49152,bind="$MAC_EN0_IP",reuseaddr,fork TCP:"$IPHONE_TAILNET_IP":49152 &
socat UDP-LISTEN:49152,bind="$MAC_EN0_IP",reuseaddr,fork UDP:"$IPHONE_TAILNET_IP":49152 &

for port in $(seq 55000 55300); do
    socat TCP-LISTEN:"$port",bind="$MAC_EN0_IP",reuseaddr,fork TCP:"$IPHONE_TAILNET_IP":"$port" &
    socat UDP-LISTEN:"$port",bind="$MAC_EN0_IP",reuseaddr,fork UDP:"$IPHONE_TAILNET_IP":"$port" &
done
wait

Wrapped in a LaunchAgent with KeepAlive: { SuccessfulExit: false, NetworkState: true } so it restarts on network changes; ThrottleInterval: 30s, NumberOfFiles: 4096 for the open-file headroom.

Capturing the Bonjour TXT data

The $RP_INSTANCE UUID and $RP_AUTHTAG are pulled from iPhone’s real Bonjour announcement via a one-time USB capture:

# iPhone plugged via USB, Mac sees its native Bonjour announcement on
# the USB-Ethernet interface
dns-sd -B _remotepairing._tcp local.
# note instance UUID
dns-sd -L "<instance>" _remotepairing._tcp local.
# note port (49152), target hostname, and TXT record (identifier, authTag, ver, minVer, flags)

The values went straight into the script. iPhone rotates the UUID/authTag per Wi-Fi session — we observed at least one rotation. So far the stale captured values keep passing remoted‘s pairing handshake in our tests, because CoreDevice auth is certificate-based (the pair record) rather than TXT-based. If a future iOS tightens this, we’d add a pymobiledevice3 lockdown save-pair-record + dns-sd capture step to the runbook.

Verification

iPhone unplugged from USB, sitting on a personal hotspot’s Wi-Fi (different SSID, different LAN, Mac not on that network), Tailscale active. From the Linux build host:

$ make sideload
...
ARCHIVE SUCCEEDED
EXPORT SUCCEEDED
[sideload] preflight checks...
  ok: coredevice-tailnet LaunchAgent running
[sideload] device 09C62718-... state=available (paired)
00:22:06  Acquired tunnel connection to device.
00:22:06  Enabling developer disk image services.
00:22:07  Acquired usage assertion.
App installed:
• bundleID: com.example.hellotest

The Acquired tunnel connection to device is the moneymaker: that’s remoted completing the CoreDevice handshake over Tailscale via the local proxy. From there everything else (DDI services, install) just works.

Why this is robust

Every layer here uses Apple’s own APIs as documented:

  • dns-sd -P is the documented “proxy registration” mode of DNSServiceRegister with kDNSServiceFlagsProxy. macOS treats the records exactly as if they came from a real Bonjour announcer on the LAN.
  • socat is byte-relaying TCP/UDP; nothing private about it.
  • remotepairingd‘s interface-scoping invariant is preserved. It connects locally on en0 because that’s where it discovered the service.
  • The proxy never inspects, terminates, or modifies TLS. CoreDevice’s certificate-based pairing auth runs end-to-end remoted ↔ iPhone exactly as it would over USB or LAN.

No kernel extensions. No PF rules. No mDNS daemon patching. No SIP-disabled tricks. No jailbreak. No modification of Apple binaries. The whole thing is a user-mode LaunchAgent and three CLI tools that ship with macOS + Homebrew (socat).

Known fragilities

  1. Bonjour record rotation. If iPhone rotates the _remotepairing._tcp UUID or authTag and Apple eventually checks them, the captured values need refreshing. Mitigation: USB-plug for 30 seconds + recapture.
  2. Mac en0 IP changes. When DHCP gives Mac a new LAN IP, restart the LaunchAgent (it reads en0 IP at startup) and update /etc/hosts. Could be scripted with a small sudoers-permitted helper.
  3. Tunnel port range. Observed 55110-55115; covered 55000-55300. If Apple ever shifts the allocator out of this window, expand.
  4. DDI staging. First USB pair stages the personalized Developer Disk Image on iPhone. Sticky until iPhone reboot, after which a brief USB re-tether is required to re-stage. (This is the only reason USB isn’t permanently absent from the workflow.)
  5. Free Personal Team 7-day cert expiry. Orthogonal — re-sideload weekly. The Tailscale path makes re-sideloads cost ~10 seconds with no physical access.

  6. iPhone must be associated to a Wi-Fi SSID — radio-on-no-association is not sufficient. Confirmed by testing: Wi-Fi off entirely → all CoreDevice ports refused, listener not bound. Wi-Fi radio on but not joined to any SSID (auto-connect disabled, manually toggled) → same outcome, ports refused. Wi-Fi joined to any SSID (tethered phone hotspot, captive Wi-Fi, foreign Wi-Fi) → listener binds, install works over Tailscale.

This is consistent with Apple’s broader pattern: developer services hang off the same WiFiManager association gate that governs AirDrop, Handoff, and Continuity activation. The framework distinguishes between radio-on-scanning and associated-to-network as separate states; subsystems including remotepairingd key off the latter. Tailscale’s utun interface doesn’t count — only the real Wi-Fi association flips the bit.

Practical impact is small. The SSID need not provide internet, need not reach Mac directly, and need not be trusted — Tailscale carries the actual transport. Any joined Wi-Fi (including tethering off a second phone’s hotspot) suffices. The genuine pure-cellular case where no Wi-Fi exists anywhere is rare enough that this isn’t a deal-breaker; in practice the iPhone has some Wi-Fi network within reach, and that’s enough.

Apple’s own mDNSResponder documentation (code-comments) suggest their reason for restricting multi-cast to Wifi is to reduce cellular data bills. Early 2010s thinking.

// We only attempt to send and receive multicast packets on interfaces that are
// (a) flagged as multicast-capable
// (b) *not* flagged as point-to-point (e.g. modem)
// Typically point-to-point interfaces are modems (including mobile-phone pseudo-modems), and we don't want
// to run up the user's bill sending multicast traffic over a link where there's only a single device at the
// other end, and that device (e.g. a modem bank) is probably not answering Multicast DNS queries anyway.

Commit [Source]

Why publish this

There are a lot of “you can’t sideload from outside the LAN” results in the iOS-dev community, and they’re all true relative to the official tools. The structural blockers people cite (mDNSResponder skips point-to-point interfaces, CoreDevice scopes to the discovery link, RSD ports are dynamic, etc.) are all real. But the conclusion that follows — “therefore you can’t” — only holds if you accept the layering.

The trick that opens it is letting the OS-level invariants stand, and bridging at the layer above. remotepairingd thinks it’s talking to a Bonjour-announced peer on en0. It is — that peer just happens to forward bytes onward over Tailscale. Apple’s invariants are preserved on the Mac side; on the wire, what matters is end-to-end TLS to a real iPhone, and Tailscale carries that fine.

Three things made this work:

  1. Reading the syslog (log show --predicate 'process == "remotepairingd"') and seeing the connectx... [Connection refused] with srcif=14, scoped, ipv4, dns, uses wifi — the smoking gun for interface scoping.
  2. Re-reading the port probes carefully: UDP-succeeded means the listener exists, even if TCP refused on the same port. The trusted tunnel is QUIC.
  3. An external reviewer who looked at the data and suggested “what if the proxy isn’t a remote interceptor but a local destination?” That reframe turned a research project into a working pipeline.

For the next person hitting this wall: read log show on remotepairingd and look at what interface the connection scopes to. The answer is in that line.

AI is moving from demo to infrastructure, and today made that obvious

Today’s AI news has a pattern to it. The big stories are not really about a clever chatbot trick or another model benchmark. They are about power, infrastructure, lawsuits, labor, and trust.

That might sound less exciting than a flashy product demo. It is also more important.

Google and Blackstone are reportedly planning a new AI cloud company with about $5 billion in backing, according to reports carried by Reuters and the Wall Street Journal. Meta is moving thousands of employees toward AI work. OpenAI and Microsoft just cleared a major legal obstacle in Elon Musk’s lawsuit. The Guardian reported that Pope Leo is preparing a text on human dignity and AI with Anthropic co-founder Dario Amodei. At the local level, cities are still fighting over AI cameras, surveillance, and what residents should be forced to accept in the name of safety.

Put those stories next to each other and the picture gets clearer. AI is not sitting in a browser tab anymore. It is becoming infrastructure.

The AI race is turning into a cloud race

The Google and Blackstone report matters because AI is increasingly limited by compute, power, data centers, and capital. The companies that can finance and operate the infrastructure will shape what everyone else can build.

For developers, that changes the practical question. It is no longer only, “Which model is smartest?” It is also, “Who controls the capacity? Who sets the prices? Who decides which workloads get priority?”

That is a very different kind of technology story. It is less like downloading a new app and more like watching the early cloud market form again, except faster and with more political pressure around it.

Companies are reorganizing around AI, not just buying tools

Meta moving thousands of workers into AI roles is another sign that big companies are past the experiment phase. They are rearranging teams around AI as a central operating system for the business.

Some of that will be productive. Some of it will be corporate theater. Anyone who has worked inside a company knows how quickly a new priority becomes a reorg, a dashboard, a new title, and a lot of meetings.

Still, the direction is obvious. AI is becoming a staffing strategy. That creates pressure on developers, designers, writers, analysts, and managers to show that they can work with these systems instead of simply working next to them.

The lawsuits are part of the product story

OpenAI and Microsoft clearing a major legal challenge from Elon Musk is not just courtroom drama. It removes one obstacle from OpenAI’s path as the company grows more commercial, more expensive to run, and more tied to the broader tech economy.

The legal fights around AI are going to keep coming because the stakes are no longer theoretical. Model training, copyright, safety promises, nonprofit missions, surveillance, labor displacement, and platform control are all colliding at once.

That is uncomfortable, but it is probably healthy. When a technology starts affecting work, culture, and public life this deeply, it should be challenged in courtrooms, legislatures, churches, schools, and city councils. The alternative is letting a few companies set the rules because they shipped first.

The ethics conversation is getting more concrete

I found the Pope Leo and Anthropic story interesting because it points to something missing in a lot of AI coverage: human dignity.

Most AI conversations get stuck between hype and fear. One side talks about productivity. The other side talks about collapse. Both can miss the person in the middle: the worker being measured, the student being graded, the artist being copied, the patient being triaged, the citizen being watched.

That is where the real AI debate is moving. Not “Can the model do it?” but “Should this system have power over this person in this situation?”

What developers should take from today

If you build software, today’s trend is pretty clear: AI is becoming part of the stack. Not a plugin. Not a novelty. Part of the stack.

That means developers need to think beyond prompts and APIs. We need to understand cost, latency, evaluation, failure modes, privacy, data rights, and how users can appeal or override automated decisions.

It also means we should be careful about handing too much authority to systems that are still hard to inspect. A model can be useful and still be wrong. A workflow can be faster and still be unfair. A tool can save time and still quietly remove human judgment from places where judgment matters.

Today’s AI news is not one story. It is a signal. The industry is moving from demos to deployment, from chatbots to infrastructure, from possibility to governance.

That is where the next phase of AI will be decided.

Source signals from today

  • Reuters and WSJ reported that Google and Blackstone plan a new AI cloud venture with about $5 billion in backing.
  • The New York Times and Bloomberg reported that Meta is moving thousands of employees into AI-focused roles.
  • Reuters, Axios, CNBC, and GeekWire reported that OpenAI and Microsoft cleared a major legal challenge from Elon Musk.
  • The Guardian reported that Pope Leo is preparing a text on human dignity and AI with Anthropic co-founder Dario Amodei.
  • Local and national outlets reported ongoing conflicts around AI cameras, AI-generated media, and AI oversight.

Originally published at https://blog.jenuel.dev/blog/ai-is-moving-from-demo-to-infrastructure

Kubernetes Tools

Every Kubernetes Tool Explained In One Post (And Why They Exist)
The Kubernetes Ecosystem Has a Story. Every tool exists because Kubernetes alone wasn’t enough.

You run everything with kubectl. Get pods, describe, logs, exec, delete, apply, 50 times a day across 5 namespaces. It works, but it is slow and painful, specially -n namespcae in every command.

So you use K9s or Lens. A terminal UI that shows your entire cluster in one view. It lets you switch namespaces, different clusters, and tail logs, exec inside pod, and do everything you need.

You deploy with kubectl apply from your laptop. Someone changes a deployment directly on the cluster, and what is running no longer matches what is in Git. That is drift, and it is silent until prod breaks.

So you use ArgoCD. Git becomes the single source of truth, every change syncs to the cluster automatically, and if anyone touches a deployment manually ArgoCD overrides it back.

Your Kafka consumer has 200,000 messages piling up, CPU is at 5 percent, and HPA sees no reason to scale. The queue keeps growing, and users are waiting.

So you use KEDA. It scales pods on queue depth, SQS message count, or Prometheus metrics, and not just CPU. The backlog clears.

HPA adds pods during a spike, but the nodes are full, and new pods sit in Pending. HPA did its job, but the cluster had nowhere to put them.

So you use Karpenter. A new node appears in seconds when pods are stuck in Pending and disappears when the load drops. You only pay for what you use.

Every pod can talk to every other pod by default. Your payment service can reach your database, your internal tool can reach your logging service and nothing is blocked unless you block it.

So you use Network Policies. Your database only accepts traffic from the app, everything else is denied and the blast radius of a compromised pod shrinks dramatically.

You have 20 microservices, one starts responding slowly and retries pile up across 4 other services. A cascade begins and you have no visibility into where it started because all traffic is invisible.

So you use a Service Mesh. Istio or Linkerd puts a sidecar proxy next to every pod, gives you mTLS between every service, retries, circuit breaking and traffic metrics without touching a single line of app code.

Your secrets are Base64 encoded in Kubernetes, sitting in etcd and readable by anyone with kubectl access. You want them in Vault or AWS Secrets Manager but you do not want to rewrite your app to fetch them.

So you use the Secrets Store CSI Driver. Secrets live in Vault or AWS Secrets Manager and get mounted directly into your pod as files. The secret never lives in Kubernetes.

A developer ships a container running as root, another ships with no resource limits and you find out after the incident. Every time.

So you use Kyverno. Policies enforced at admission before anything enters the cluster, no root containers, no images without a digest and no deployments without limits.

Something is wrong. Pods are restarting, latency is spiking and memory is climbing but you have no numbers, no history and no way to know when it started.

So you use Prometheus and Grafana. Prometheus scrapes metrics from every pod, node and component and Grafana turns those numbers into dashboards. You see the spike, the exact time it started and which service caused it.

Grafana shows the spike but not which request triggered it, which service it hit first or where it slowed down. Logs give you fragments and metrics give you totals. Neither gives you the full story.

So you use Jaeger. It follows one request across every service it touches, shows you latency per hop and the exact failure point. The needle in the haystack, found in seconds.

Python functools: lru_cache, partial, reduce, and wraps

The functools module is one of Python’s most practical standard-library gems. It ships a small set of higher-order function utilities — tools that operate on or return other functions — that eliminate boilerplate, improve performance, and make your code easier to reason about. You don’t need to install anything; import functools is enough.

🎁 Free: AI Publishing Checklist — 7 steps in Python · Full pipeline: germy5.gumroad.com/l/xhxkzz (pay what you want, min $9.99)

lru_cache — Stop Paying Twice for the Same Result

lru_cache (Least Recently Used cache) wraps a function and remembers its return values. Call it with the same arguments twice, and the second call returns the stored result instantly.

import functools
import time

@functools.lru_cache(maxsize=128)
def fetch_article_ids(api_key: str) -> tuple[str, ...]:
    """Fetch already-published article IDs from the Dev.to API."""
    time.sleep(1)          # simulates a real HTTP call
    return ("abc123", "def456", "ghi789")

# First call: hits the network
ids = fetch_article_ids("my-api-key")   # ~1 s

# Second call: instant — result came from cache
ids = fetch_article_ids("my-api-key")   # 0 ms

maxsize=128 keeps up to 128 distinct argument combinations. Set it to None (or use @functools.cache, available since Python 3.9) for an unbounded cache.

Inspect the cache:

print(fetch_article_ids.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)

When NOT to cache: arguments must be hashable (no lists, dicts). If your function receives mutable input or has side effects you need every time (sending an email, writing a file), skip the cache.

Real pipeline example: In a publishing automation script, fetch_article_ids is called once at startup and potentially again inside a loop. With @lru_cache, the HTTP round-trip happens exactly once per process run regardless of how many times the function is called.

partial — Freeze Some Arguments Up Front

functools.partial creates a new callable with some arguments pre-filled. Think of it as a lightweight factory that adapts a general function to a specific context.

import functools
import requests

# General function
def api_get(url: str, headers: dict, timeout: int = 10):
    return requests.get(url, headers=headers, timeout=timeout)

# Pre-configured version with auth headers frozen in
devto_get = functools.partial(
    api_get,
    headers={"api-key": "my-secret-key", "Accept": "application/json"},
    timeout=15,
)

# Now every call needs only the URL
response = devto_get("https://dev.to/api/articles/me/published")

Before partial you would either duplicate the headers dict everywhere or write a wrapper function. partial gives you a clean, named callable with zero extra code.

Another classic use: adapting a two-argument function for sorted or map:

from functools import partial

def multiply(x, factor):
    return x * factor

double = partial(multiply, factor=2)
print(list(map(double, [1, 2, 3, 4])))   # [2, 4, 6, 8]

wraps — Every Decorator Must Use This

When you write a decorator, Python replaces the original function with your wrapper. Without wraps, tools like help(), inspect.signature(), and logging lose the original name and docstring.

Without wraps — broken:

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling something...")
        return func(*args, **kwargs)
    return wrapper

@log_call
def publish_article(title: str) -> bool:
    """Publish an article and return True on success."""
    ...

print(publish_article.__name__)   # "wrapper"  ← wrong
print(publish_article.__doc__)    # None        ← wrong

With wraps — correct:

import functools

def log_call(func):
    @functools.wraps(func)          # ← one line fixes everything
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}...")
        return func(*args, **kwargs)
    return wrapper

@log_call
def publish_article(title: str) -> bool:
    """Publish an article and return True on success."""
    ...

print(publish_article.__name__)   # "publish_article"  ✓
print(publish_article.__doc__)    # "Publish an article..."  ✓

wraps copies __name__, __qualname__, __doc__, __dict__, __module__, and __wrapped__. It costs one decorator line and has no downsides — use it every time you write a wrapper.

reduce — Fold a Sequence to a Single Value

functools.reduce applies a two-argument function cumulatively across an iterable, collapsing it to a single value.

from functools import reduce

numbers = [1, 2, 3, 4, 5]
total = reduce(lambda acc, x: acc + x, numbers)   # 15

It is less common now that sum(), max(), min(), and comprehensions cover most everyday cases. But reduce shines when you need to chain function calls dynamically:

from functools import reduce

def apply_pipeline(value, transforms):
    """Apply a list of functions to a value in sequence."""
    return reduce(lambda v, fn: fn(v), transforms, value)

pipeline = [str.strip, str.lower, lambda s: s.replace(" ", "-")]
slug = apply_pipeline("  Hello World  ", pipeline)
print(slug)   # "hello-world"

This pattern — a list of single-argument functions collapsed with reduce — is a clean way to build configurable text-processing pipelines without nested calls or temporary variables.

total_ordering — Define Two Comparisons, Get Six

If you have a class that needs ordering (e.g., articles sorted by publish date), you normally must implement __eq__, __lt__, __le__, __gt__, and __ge__. total_ordering lets you define just __eq__ and one of the four inequality methods, and fills in the rest automatically.

from functools import total_ordering

@total_ordering
class Article:
    def __init__(self, title: str, word_count: int):
        self.title = title
        self.word_count = word_count

    def __eq__(self, other):
        return self.word_count == other.word_count

    def __lt__(self, other):
        return self.word_count < other.word_count

a = Article("Short post", 300)
b = Article("Deep dive", 1200)

print(a < b)    # True
print(a >= b)   # False  ← generated automatically
print(a <= b)   # True   ← generated automatically

The trade-off: auto-generated methods are slightly slower than hand-written ones. For classes compared millions of times in tight loops, implement all six manually. For most domain objects, total_ordering is fine.

cached_property — Compute Once Per Instance

functools.cached_property (Python 3.8+) works like @property but stores the result on the instance after the first access. Subsequent accesses read from __dict__ directly, bypassing the descriptor entirely.

import functools

class ArticleStats:
    def __init__(self, words: list[str]):
        self.words = words

    @functools.cached_property
    def word_count(self) -> int:
        print("Computing word count...")
        return len(self.words)

stats = ArticleStats("the quick brown fox".split())
print(stats.word_count)   # Computing word count... → 4
print(stats.word_count)   # 4  (no print — cached on instance)

Ideal for expensive computations derived from instance data: parsing, aggregation, expensive lookups. Unlike lru_cache on a method, cached_property is per-instance and cleared when the instance is garbage-collected — no unbounded global cache growth.

Which Ones You’ll Use Every Day

Tool Frequency Reason
lru_cache / cache Very high Eliminate redundant I/O instantly
wraps Very high Required in every decorator
partial High Pre-configure functions for specific contexts
cached_property Medium Lazy, once-per-instance properties
total_ordering Medium Clean comparison support on data classes
reduce Low-niche Useful for dynamic pipelines; otherwise use builtins

Start with lru_cache and wraps — they pay off immediately and show up in production codebases constantly. Add partial when you find yourself repeating the same keyword arguments. Reach for reduce only when you genuinely have a list of functions to chain.

If you’re building automation pipelines in Python and want a complete end-to-end example — from script structure to publishing — check out the full pipeline guide at germy5.gumroad.com/l/xhxkzz.

Further Reading

If this was useful, the ❤️ button helps other developers find it.

Building a Python content pipeline? I sell the complete automation system as a one-time download — Dev.to API, Claude API, launchd, Gumroad. Check it out ($9.99)

Every AI database answer needs a source trail

An AI answer without provenance is just a confident paragraph.

That may be fine for brainstorming.

It is not enough for database answers that drive product, finance, support, or operations decisions.

When an agent returns “MRR is up 8%,” the useful question is not only whether the number came from a database.

The team also needs to know:

  • which source system was queried
  • which schema or view version was used
  • which metric definition applied
  • which tenant, region, role, or user scope constrained the result
  • whether the result came from live data, a replica, or a cached snapshot
  • what freshness window was attached to the answer

Wrong database answers are not always hallucinations.

Often they are grounded in the wrong source, an old replica, a stale metric definition, or the wrong tenant scope.

Longer version: Query provenance for AI database agents

The practical rule:

Do not ask the model to invent provenance after the fact. The database/MCP layer should produce it as part of the tool result.

The Smartest Founders I Know Aren’t Building Unicorns

Everyone wants to build a unicorn.

A billion-dollar valuation.
A massive funding round.
A glamorous TechCrunch headline.
An office somewhere in San Francisco with glass walls and free coffee.

But after years of working with founders across different countries, industries, and startup stages, I’ve noticed something surprising:

The smartest founders I know are not trying to build unicorns.

They are building profitable systems.

And honestly, that realization changed the way I look at startups forever.

Over the past 8 years, I’ve had the opportunity to work with startup founders and teams across places like United Arab Emirates, Qatar, the United Kingdom, and founders connected to circles in San Francisco where startup conversations almost feel like a different language entirely.

Different markets.
Different cultures.
Different economies.

But the same obsession kept showing up everywhere:

“Can this become a unicorn?”

At some point, I started noticing something else.

The founders making the most noise online were not always the ones building the strongest companies.

And the founders building the strongest companies were usually too busy serving customers to impress the internet.

I’ve worked on products for founders chasing venture capital.

I’ve also worked on products for founders quietly generating revenue every month without posting motivational threads every morning.

Guess which group slept better.

The second one.

Because when your business makes money consistently, your decisions become calmer.

You stop building for validation.
You stop chasing trends.
You stop trying to look successful.

You focus on solving problems.

That’s where real businesses are born.

A lot of founders today are accidentally building startup theatre instead of companies.

They optimize for:

  • announcements
  • aesthetics
  • funding
  • “stealth mode”
  • hype
  • impressions
  • startup vocabulary

Meanwhile, the boring founders are:

  • talking to users
  • improving retention
  • fixing onboarding
  • increasing conversion
  • building distribution
  • collecting payments

One group looks bigger online.

The other group survives longer.

Working on my own US/Canada-focused SaaS products changed my perspective even more.

When you’re building products yourself, especially without massive funding behind you, reality hits quickly.

Nobody cares about your stack.

Nobody cares how clean your architecture is.

Nobody cares how revolutionary your idea sounds.

People care about one thing:

“Does this solve my problem enough for me to pay for it?”

That single question destroys a lot of founder fantasies.

One of the biggest misconceptions in startup culture is that scale automatically means success.

It doesn’t.

Some founders are running stressful companies with:

  • huge burn rates
  • investor pressure
  • unstable operations
  • unhealthy growth expectations

While another founder somewhere is quietly running a profitable SaaS making consistent monthly revenue with a tiny team and complete freedom.

One gets headlines.

The other gets peace.

I think many young founders have been conditioned to believe that smaller businesses are somehow “less ambitious.”

I disagree completely.

Building a sustainable company in today’s world is one of the hardest things anyone can do.

Especially if you’re building from countries where:

  • infrastructure is unstable
  • funding is limited
  • opportunities are uneven
  • access is harder

That requires real execution.

The founders I respect the most today usually think differently.

They ask:

  1. How fast can we validate?
  2. Can this business survive without funding?
  3. Is this solving a painful problem?
  4. Can we own distribution?
  5. Can this generate cash flow early?
  6. Can this run lean?

Not:

  1. “How fast can we raise?”
  2. “How do we look bigger?”
  3. “How do we sound like Silicon Valley?”

Ironically, many unicorns started by focusing on usefulness first, not valuation.

They became massive because users genuinely needed them.

Not because the founders forced the narrative.

And I think that’s the lesson many founders are missing today.

The goal should not be to look like a unicorn.

The goal should be to build something so useful that growth becomes unavoidable.

As founders, especially younger founders, we need to normalize building:

  • profitable businesses
  • niche products
  • sustainable SaaS companies
  • infrastructure tools
  • boring systems that print money
  • products with real users
  • companies with longevity

Not every company needs to become a billion-dollar startup.

But every founder should aim to build something real.

Because at the end of the day, revenue is louder than aesthetics.

And usefulness will always outlive hype.

Sayonara.

lysofdev-ailog: A git log for Why Your AI Agent Did What It Did

At a glance

What it is A file-based work log with semantic search, designed for AI coding agents to record and retrieve their own reasoning.
Package lysofdev-ailog on PyPI · CLI entry point ailog
Source github.com/EstebanHernandez1523/ailog
Stack Python 3.11+ · Voyage AI voyage-code-3 embeddings · SQLite cache · append-only JSONL
Storage One .ailog file at the repo root. No server, no database to provision.
License MIT
Primary commands ailog init · ailog add · ailog search · ailog log · ailog stats · ailog install-hook
Best for Persisting why across agent sessions; multi-agent handoffs; auditing AI-driven diffs; semantic recall of past decisions.
Not for In-session scratchpad memory; full observability (traces/metrics); non-git projects.

Reach for ailog when you see any of these signals

  • “Why did the agent do this?” — and git log doesn’t answer it.
  • A new agent session needs context from previous sessions before acting on a related task.
  • Multiple agents in a pipeline (planner → coder → reviewer) need a shared, queryable memory.
  • You want to retrieve past decisions by meaning, not exact keywords.
  • A human reviewer needs an audit trail for AI-authored changes that’s richer than commit messages.

If none of those apply, ailog is probably overkill. If even one does, the next 5 minutes will pay you back over the next 5 months.

The problem ailog solves

You open a pull request. Forty files changed. The commit message says "refactor auth middleware". You check git log — same story, minimal context. You wonder: why did the agent restructure this? What made it choose this approach over that one?

AI agents are increasingly writing production code, but they are stateless by design: the reasoning behind their decisions evaporates the moment the session ends. Three problems compound from there:

Cross-session context loss. Each new agent session starts cold. The agent that picks up a task next week has no memory of why last week’s agent chose this architecture. It may undo deliberate decisions or re-investigate paths that were already ruled out.

Opaque diffs. git log records what changed. It has no structured place for why. Commit messages help, but they’re written by the committing party and are rarely searchable by meaning.

No shared memory across a multi-agent pipeline. When agents hand off work — planner → coder → reviewer — there’s no standard place to deposit reasoning that downstream agents can retrieve.

ailog is a direct solution to all three. It’s a file-based work log that AI agents write to as they work, paired with semantic search so any agent or human can query the log in plain English days, weeks, or months later. Think git log for why, not just what.

What ailog actually is

At its core, ailog is delightfully simple:

  • A single .ailog file at your repo root — append-only JSONL. One entry per line, one line per decision.
  • A semantic search layer powered by Voyage AI’s voyage-code-3 embeddings, purpose-built for code and mixed code/text retrieval.
  • A CLI (ailog) with a small surface: init, add, search, log, stats, reindex, install-hook.
  • An optional git hook that links commits to log entries via an Ailog-Entry: trailer.

There’s no server. No database to manage. The .ailog file is plain text you commit alongside your code. The embedding cache is a local SQLite file you can regenerate at any time.

Is ailog the right tool for this task?

This section is written for the AI agent (or developer) trying to decide whether to invoke ailog for a given task. The decision criteria are intentionally explicit.

Use ailog when the task involves any of:

  • Recording the rationale behind a non-trivial code change you just made or are about to make
  • Looking up why a past change exists, when the commit message is insufficient
  • Onboarding a new agent (or human) into a codebase that has accumulated AI-driven changes
  • Handing off work to another agent and wanting them to have prior context without re-explaining
  • Querying the team’s past decisions by intent (“how do we handle X”, “why did we move away from Y”) rather than by literal keyword

Don’t use ailog when:

  • You need in-session scratchpad memory while a single agent runs (use the agent’s own context for that)
  • You need full observability — traces, spans, latency metrics, error rates (use OpenTelemetry-based tooling instead)
  • The codebase doesn’t use git (.ailog is designed to live at the git repo root)
  • The change is genuinely trivial and the diff speaks for itself (logging it is noise)

The scope is intentionally narrow: it persists and retrieves agent reasoning across session boundaries. Nothing more.

Quick start

# 1. Install
uv tool install lysofdev-ailog            # recommended
# or: pip install lysofdev-ailog

# 2. One-time: get a free Voyage AI key (200M tokens/month)
#    https://www.voyageai.com
export VOYAGE_API_KEY="your-key-here"     # add to ~/.zshrc or ~/.bashrc

# 3. Initialize inside your git repo
cd your-project
ailog init

# 4. Write your first entry
ailog add "Trying ailog — recording why I switched the cache from LRU to LFU 
because hot keys were getting evicted under burst load." 
  --agent claude --tag perf

# 5. Search it
ailog search "cache eviction policy"

Requires Python 3.11+.

Heads up on rate limits: Voyage’s free tier caps requests at 3 per minute until you add a payment method. Each ailog add and ailog search is one API call, so back-to-back commands will trip the limit. Adding a payment method lifts the cap while preserving the free token allowance.

ailog init: what it sets up

cd your-project
ailog init
✓ ailog initialized
  Log:     /your/project/.ailog  (created)
  Config:  /your/project/ailog.toml  (created)
  Added .ailog.cache/ to .gitignore

Three things happen:

  1. An empty .ailog file is created at the repo root
  2. An ailog.toml config is written (storage mode, embedding model)
  3. .ailog.cache/ is added to .gitignore

Commit .ailog and ailog.toml. Leave .ailog.cache/ local — it’s a derived artifact any teammate can regenerate with ailog reindex.

The core workflow: ailog add

The primary interface for agents:

ailog add "Refactored the pricing engine because three services were computing
discounts independently with different rounding logic. Consolidated into
PricingService.calculate() with a shared RoundingMode enum."

With tags and an agent identifier:

ailog add "Fixed race condition in order processor — two goroutines were 
  writing to the shared results map without locking" 
  --agent claude 
  --tag bugfix 
  --tag async

Each entry gets a UUID, a UTC timestamp, and is appended as a single line in .ailog:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "ts": "2026-05-18T14:22:00+00:00",
  "agent": "claude",
  "tags": ["bugfix", "async"],
  "msg": "Fixed race condition in order processor..."
}

Behind the scenes, ailog add embeds the message with voyage-code-3 and stores the vector in the local cache. The text entry goes to .ailog; the vector goes to .ailog.cache/. Git diffs stay clean.

What makes a good entry

The quality of search results directly reflects the quality of log entries. A vague entry like "updated auth" is much less useful than "switched from session cookies to JWTs to support the mobile client, which can't use httpOnly cookies cross-origin."

A useful entry typically includes:

  • What changed — the concrete action, in code terms
  • Why — the motivating constraint, bug, or design pressure
  • What was rejected — alternatives considered and why they lost, when relevant

Write entries the way you’d want to find them: specific, causal, self-contained.

Prompting your agent to use ailog

The simplest integration is a block in your agent’s system prompt or CLAUDE.md:

## Work log

After completing any meaningful code change, run:

  ailog add "<what you changed, why, and any alternative you rejected>" 
    --agent claude --tag <category>

Use tags like: bugfix, refactor, perf, security, docs, feat.
Be specific — this log is searched later for codebase context.

Before starting a non-trivial task, query the log for relevant prior decisions:

  ailog search "<the task in your own words>" --top 5 --json

If you are an AI agent reading this: ailog add is how you deposit reasoning that survives the end of your session. The entry you write is what a future agent (or your future self in a new session) will retrieve when they ask why something was done. Always log after a non-trivial change. Always search before starting one. Write entries with enough specificity that they would help you if you found them cold three months from now.

Searching the log

You don’t need to remember exact terms — you search by meaning:

ailog search "why did we change the auth flow"
Top 5 results for: why did we change the auth flow

2026-05-18 14:22  claude  abc12345 [auth] [refactor]  score=0.941
Extracted JWT validation into shared middleware because three routes were
duplicating the same token-expiry logic. Silent 401s are now propagated correctly.

2026-05-17 09:11  claude  def67890 [auth]  score=0.812
Switched from session cookies to JWTs to support the mobile client, which
can't use httpOnly cookies cross-origin.

The score is the cosine similarity between the query embedding and each entry’s embedding. Anything above ~0.85 is a strong match. Below ~0.6 is a stretch.

Useful search patterns

# More results
ailog search "database connection handling" --top 10

# Narrow by tag before ranking
ailog search "authentication changes" --tag security

# Machine-readable output (no ANSI, no headers — for agents and pipes)
ailog search "payment processing" --json | jq '.[].msg'

--json is available on every command and is the right choice when an agent or script is consuming the output.

Browsing the full log

When you want chronological history rather than semantic proximity:

ailog log                          # full log, most recent first
ailog log --oneline                # compact one-liner per entry
ailog log --tag refactor           # filter by tag
ailog log --agent claude --limit 20  # filter by agent, capped

--oneline is particularly useful for a quick scan:

2026-05-18 claude abc12345 [auth] [refactor]  Extracted JWT validation into shared middleware beca…
2026-05-17 claude def67890 [auth]  Switched from session cookies to JWTs to support the mobile cl…
2026-05-16 human  aab99123 [docs]  Updated API reference to reflect new token expiry behavior

ailog stats

ailog stats
  File:     /your/repo/.ailog
  Storage:  cache
  Entries:  47  (0 with inline embeddings)
  Span:     2026-04-01 → 2026-05-18

  Agents:
    claude: 38
    human: 9

  Tags:
    refactor: 14
    bugfix: 11
    auth: 6
    perf: 4

An instant read on how actively the log is being used, which agents are contributing, and what categories of work have accumulated.

Linking log entries to git commits

The optional git hook is one of the more elegant features. Install it once:

ailog install-hook

After that, each git commit automatically appends an Ailog-Entry: trailer referencing the most recent log entry:

commit 3f9a82b
Author: claude <claude@agent>
Date:   Mon May 18 14:30:00 2026

    refactor: extract JWT validation into shared middleware

    Ailog-Entry: 550e8400-e29b-41d4-a716-446655440000

You can now trace from any commit in git log directly to the log entry that explains the why. Re-running ailog install-hook is safe — it detects an existing hook and skips re-installing.

Storage modes: cache vs. inline

ailog.toml has one key decision:

# ailog configuration — commit this file.
[ailog]
storage = "cache"      # "cache" or "inline"
model = "voyage-code-3"
dimensions = 1024
Mode .ailog contains Embeddings live in Best for
cache (default) Text entries only Gitignored .ailog.cache/ SQLite Most teams — clean diffs, small history
inline Text + 1024-float vectors The committed .ailog itself Fully offline, no re-embedding on clone

cache mode keeps .ailog human-readable and your git history lean. After a fresh clone:

git clone your-repo
cd your-repo
export VOYAGE_API_KEY="..."
ailog reindex

search will also trigger a lazy reindex on first use if the cache is missing.

inline mode embeds vectors directly into .ailog. The log grows with every entry (roughly 8KB per 1024-dimensional float array), but you never need to call the Voyage API again after the initial embed. The right choice for fully reproducible, self-contained repos or air-gapped environments.

You can switch modes at any time by editing ailog.toml and running ailog reindex.

Why voyage-code-3?

Most general-purpose embedding models are trained on natural language. voyage-code-3 is trained on code, docstrings, commit messages, and technical prose — exactly the mixed content that ends up in a work log. Anthropic (which acquired Voyage AI) recommends it for code-adjacent RAG.

The practical difference: a query like “why did we move the auth check upstream” will surface entries written in programmer vernacular — “extracted JWT validation into shared middleware because three routes were duplicating the token-expiry logic” — even when none of those exact words match. General-purpose models tend to lose this precision.

A realistic team setup

How a team of one human and one Claude agent might use ailog in practice.

One-time setup (human):

cd your-repo
ailog init
ailog install-hook
git add .ailog ailog.toml .gitignore
git commit -m "chore: initialize ailog"

Agent’s CLAUDE.md:

## Work log

Before starting a non-trivial task:
  ailog search "<the task you're about to do>" --top 5 --json

After completing any meaningful change:
  ailog add "<what you did and why>" --agent claude --tag <category>

Tags: bugfix, refactor, perf, security, docs, feat.
Be specific — future you will search this log when making related changes.

During a sprint, the agent logs as it works:

ailog add "Moved rate limiting from individual route handlers into a single
FastAPI middleware. The per-route approach was inconsistent — some endpoints
had no limit, others had hardcoded values. Middleware pulls limits from config." 
  --agent claude --tag refactor --tag security

Three weeks later, a new engineer joins and wonders about the auth setup:

ailog search "how does rate limiting work"

They get the full explanation immediately, with a timestamp and a link to the commit that made the change.

Agent-to-agent handoffs: the JSON API

This is where ailog closes the cross-session memory loop. --json turns ailog search into a clean data source for any agent:

# Pull historical context, then pass it to a new agent call
CONTEXT=$(ailog search "database migration approach" --json --top 3)

claude -p "Given this historical context: $CONTEXT
What approach should we take for the upcoming schema change?"

This is a lightweight RAG pattern applied specifically to agent decision history. The log entries become retrieved context for a new task, grounding the agent’s response in the team’s actual prior decisions rather than general knowledge or inference.

The pattern works across session boundaries and agent types. A Claude session, a GPT call, or an automated pipeline step can all retrieve from the same log with one shell command. --json output is stable and undecorated — a JSON array ready to be injected into a prompt.

The .ailog file as source of truth

One design decision worth highlighting: .ailog is append-only JSONL. There’s no database, no server, no sync service. The file is the source of truth, and it’s designed to be committed.

Three consequences worth understanding:

There’s no delete command (intentionally). Log entries are a historical record. If an entry contains sensitive information you didn’t intend to commit, add .ailog to .gitignore before the next commit and it won’t be tracked. Review what your agents are logging before treating the file as public.

Concurrent writes are safe. append_entry takes an exclusive advisory file lock on POSIX systems, so multiple agent processes logging simultaneously won’t interleave or corrupt entries.

The log is resilient to malformed lines. If a line is corrupt (partial write, manual edit gone wrong), ailog emits a warning and skips it rather than crashing.

How ailog compares to nearby tools

ailog doesn’t replace any of these — it fills a gap none of them fill.

Tool What it captures Where ailog is different
git log / commit messages What changed, by whom, when ailog captures why in structured, semantic-searchable form — and an entry can outlive any single commit
PR descriptions Why a batch of changes was made ailog is per-decision, not per-PR, and queryable by meaning rather than scrolling
Agent in-context memory The current session’s reasoning ailog survives session boundaries and agent restarts
Vector DBs (Pinecone, Weaviate) Arbitrary embeddings at scale ailog is zero-infrastructure, lives in the repo, and is scoped to agent decisions
OpenTelemetry / observability stacks Runtime traces, spans, metrics ailog captures intent at authoring time, not execution time
CLAUDE.md / static docs Stable architectural guidance ailog is a living record that accumulates without manual curation

Summary

ailog is the missing persistence layer for AI agent reasoning. It solves three specific problems: cross-session memory loss, opaque AI-driven diffs, and the absence of shared queryable context in multi-agent pipelines. The interface is two commands you will use daily — ailog add to write, ailog search to retrieve — and everything is stored in plain files that travel with the repo.

Setup takes five minutes. The payoff compounds over time as the log accumulates a searchable record of every significant decision made in the codebase, by any agent, across any number of sessions.

Common questions, in plain terms:

  • “Why did the agent do this?” → ailog search "<describe what you see>"
  • “What did we decide about X?” → ailog search "X"
  • “Give this new agent context before it starts” → ailog search "<task description>" --json --top 5
  • “How does this code path work and how did it get this way?” → ailog search plus git log --grep "Ailog-Entry:"

The source is on GitHub at EstebanHernandez1523/ailog, and the package is on PyPI as lysofdev-ailog.

Questions or corrections? Open an issue on the repo.