Sub-Agents vs Tools: ADK Multi-Agent Decision Framework

Stop Building Sub-Agents for Everything: A Decision Framework for ADK Multi-Agent Systems

Most multi-agent architecture diagrams look elegant. Clean boxes, directional arrows, specialised agents handling discrete domains. The problem? These diagrams optimise for whiteboard clarity, not production behaviour.

I’ve spent the last year helping SaaS teams across Canada and the US build agent systems on Google ADK. The pattern I see repeatedly: teams default to sub-agents because the architecture looks cleaner — then spend weeks debugging state passing failures, latency spikes, and cascading errors that wouldn’t exist if they’d used the right abstraction from the start.

The sub-agent vs tool decision isn’t cosmetic. It determines how state flows through your system, how errors propagate, how you scale, and how much latency you add per reasoning step. Get this wrong early, and you’re refactoring agent architecture later — which is significantly more disruptive than refactoring code because the behaviour is harder to test.

The Hidden Cost of Over-Architected Agent Systems

When teams first adopt ADK, there’s a natural pull toward the sub-agent pattern. It maps nicely to how we think about team structure — a billing agent, an infrastructure agent, a compliance agent. Clean separation of concerns. Independent reasoning domains.

But here’s what the architecture diagrams don’t show: every sub-agent call involves at least one additional LLM round trip. For a coordinator that orchestrates three sub-agents sequentially, you’ve added 3-4 LLM calls that wouldn’t exist if those tasks were tools. At 500ms per call, that’s 1.5-2 seconds of latency for the sub-agent coordination alone — before you even count the actual work.

I’ve seen teams build sub-agents for simple API lookups that should have been tools. A sub-agent to fetch project metadata. A sub-agent to check IAM bindings. Deterministic operations wrapped in reasoning overhead. The result: 2-3 LLM round trips for something that should be a function call returning structured data.

The opposite failure mode is equally common. A single agent with 30+ tools becomes unmanageable. The LLM context window fills with tool descriptions. Tool selection accuracy degrades. The agent starts calling the wrong tools or hallucinating tool capabilities that don’t exist.

Neither extreme works. Production systems need a principled framework for when to use each pattern.

My Framework: Reasoning Boundaries Determine Architecture

The decision framework I use with teams is simple: sub-agents handle independent reasoning; tools handle deterministic operations.

If the task requires multi-step reasoning, maintains its own memory, or involves complex decision trees — that’s a sub-agent. If the output is deterministic given the input, the task is self-contained, or you want to limit what the sub-task can access — that’s a tool.

Here’s how this plays out in ADK code. A coordinator with sub-agents looks like this:

coordinator = Agent(
    name="coordinator",
    model="gemini-2.0-flash",
    sub_agents=[billing_agent, infra_agent],
    tools=[get_project_metadata]
)

Notice get_project_metadata is a tool, not a sub-agent. It returns structured data. No reasoning required.

The agent-as-tool pattern wraps an agent call in a tool interface:

analysis_tool = AgentTool(
    agent=code_analysis_agent,
    description="Analyzes Terraform code for security misconfigurations"
)
main_agent = Agent(tools=[analysis_tool, deploy_tool])

This pattern works when you need contained reasoning that returns a structured result. The calling agent sees it as a black box. The code analysis agent does its multi-step work internally, but the main agent just gets back a report.

The critical distinction: sub-agents can access the coordinator’s state and participate in broader workflows. Agent-as-tool returns a result and exits. Choose based on whether you need ongoing collaboration or isolated computation.

Patterns I’ve Seen Break in Production

No retry logic between sub-agents. One sub-agent failure cascades to full pipeline failure with no graceful degradation. I’ve watched an entire document processing pipeline fail because a metadata extraction sub-agent timed out — and there was no fallback. The fix: sub-agents should return structured error responses, not raise exceptions that propagate to the coordinator.

Large context objects passed between sub-agents. Teams try to share state by passing entire conversation histories or document contents through the coordinator. This bloats context windows and causes mysterious failures when you hit token limits mid-workflow. Use structured references instead — pass document IDs, not documents. Let each sub-agent fetch what it needs.

Agent-as-tool without observability. The pattern reduces visibility by design — the wrapped agent is a black box. I’ve debugged systems where no one could explain what the analysis agent was actually doing internally. Without explicit logging inside the wrapped agent, you lose traceability. Add structured logging before you need it.

Memory isolation surprises. Sub-agent memory is isolated by default in ADK. Teams assume context flows automatically, then wonder why their infrastructure agent doesn’t remember what the billing agent just discovered. If you need shared context across sub-agents, you have to explicitly pass it through the coordinator.

The Trade-off Matrix

Factor Sub-Agent Agent as Tool Plain Tool
Latency Higher (1+ LLM calls) Higher (1+ LLM calls) Lowest
Independent testing Easy Easy Easiest
State access Coordinator state available Isolated N/A
Observability Good Requires explicit logging Full visibility
Use case Complex reasoning Contained reasoning Deterministic ops

Sub-agents are easier to test independently because each has a clear input/output contract. But they add latency and complexity. For time-sensitive workflows where you’re measuring response time in seconds, every unnecessary LLM call hurts.

Agent-as-tool gives you contained reasoning with a clean interface, but you trade observability. Plain tools are fastest but can’t reason.

Why This Matters for Platform Teams

This connects directly to the Automation pillar of the SCALE framework. Agent systems are infrastructure now. The architectural decisions you make about sub-agents vs tools compound as the system grows — affecting operational cost, debugging time, and end-user latency.

I’ve seen teams burn weeks refactoring agent architecture because they defaulted to sub-agents for everything. Agent behaviour is harder to test than code behaviour. When you change how reasoning flows through your system, you’re not just changing code — you’re changing emergent behaviour that’s difficult to verify with traditional testing.

Start with tools for deterministic operations and single-purpose tasks. Add sub-agents when you need independent reasoning that can’t be expressed as a function call. Use agent-as-tool when you need contained reasoning that returns a structured result to an orchestrator.

The decision framework isn’t complicated. But I rarely see teams apply it systematically before building. Most start with whatever pattern they saw in a tutorial, then refactor when production behaviour surprises them.

The architecture diagram isn’t the system. The latency, state flow, and error propagation are the system. Optimise for those.

Amit Malhotra is Principal GCP Architect at Buoyant Cloud Inc, helping B2B SaaS companies design production-ready platforms on GCP.

Work with a GCP specialist — book a free discovery call

Work with a GCP specialist — book a free discovery call → https://buoyantcloudtech.com

Pick the Right HTDemucs Model in Python — Query 800 MUSDB18-HQ Scores on Hugging Face (2026)

You’re about to ship stem separation in a product. Marketing pages say “state-of-the-art AI.” Your job is to pick an actual model_id string — htdemucs, htdemucs_ft, htdemucs_6s, or mdx_extra_q — and defend that choice to whoever reads the PR.

Listening tests on three Spotify tracks won’t cut it. Running MUSDB18 yourself takes a weekend and 22 GB of disk.

There’s a third option: stem-separation-benchmark-2026 — 800 rows of BSS Eval v4 scores (50 MUSDB18-HQ test tracks × 4 models × 4 stems), CC-BY-4.0, queryable in pandas from your laptop in under a minute.

This post shows how to turn that table into a model picker and a regression test — not how to re-run the benchmark (see Best Free AI Stem Splitters in 2026 for DIY mir_eval).

What You’ll Learn

  • ✅ Load the public leaderboard into pandas without downloading audio
  • ✅ Pick a model by stem priority (vocals vs drums) and latency budget (RTF)
  • ✅ Find worst-case tracks per model before they hit production
  • ✅ Assert median vocal SDR in CI so model upgrades don’t silently regress
  • ✅ Map picks to StemSplit API quality tiers (same weights)

Prerequisites

pip install datasets pandas

No GPU. No MUSDB18 download. The dataset ships metrics only (MUSDB18 audio can’t be redistributed).

What’s in the dataset?

Short answer: One Parquet config (metrics_only) with per-track, per-stem SDR/ISR/SIR/SAR plus wall_time_s, rtf, and host metadata from an Apple M4 Pro + PyTorch MPS run.

Column Use in your app
model_id Which weights to load or API tier to call
stem vocals, drums, bass, other
sdr_median Primary quality score (dB, higher = better)
rtf wall_time_s / duration_s — 0.07 ≈ 14× faster than realtime
track_id MUSDB18-HQ test folder name

Four models in v1: htdemucs, htdemucs_ft, htdemucs_6s, mdx_extra_q.

Note on htdemucs_6s: other scores look broken (~0.2 dB) because piano/guitar are split out of MUSDB’s other stem. Compare htdemucs_6s on vocals/drums/bass only, or wait for v1.1 where piano + guitar + other is summed before eval. See the dataset card.

For background on what SDR means in practice, how HTDemucs separates audio is a better read than repeating the metric tutorial here.

Load the leaderboard in three lines

Short answer: load_dataset → to_pandas() → groupby.

from datasets import load_dataset

ds = load_dataset(
    "StemSplitio/stem-separation-benchmark-2026",
    "metrics_only",
    split="results",
)
df = ds.to_pandas()

leaderboard = (
    df.groupby(["model_id", "stem"])["sdr_median"]
    .median()
    .unstack()
    .round(2)
)
print(leaderboard)

Example output (v1, May 2026):

stem          bass  drums  other  vocals
model_id
htdemucs      9.78  10.01   6.42    8.53
htdemucs_6s   9.11   9.54   0.22    8.66
htdemucs_ft  10.38  10.11   6.34    9.19
mdx_extra_q  11.42  11.49   7.67    9.04

Vocal-first apps: htdemucs_ft wins on vocals. Drum/bass-heavy apps (DJ tools, rhythm games): mdx_extra_q leads. Latency-sensitive previews: check RTF next.

Build a model picker function

Short answer: Filter by stem + max RTF, then idxmax on sdr_median.

import pandas as pd

VOCAL_MODELS = ["htdemucs", "htdemucs_ft", "htdemucs_6s", "mdx_extra_q"]


def load_benchmark() -> pd.DataFrame:
    from datasets import load_dataset

    return load_dataset(
        "StemSplitio/stem-separation-benchmark-2026",
        "metrics_only",
        split="results",
    ).to_pandas()


def pick_model(
    df: pd.DataFrame,
    *,
    stem: str = "vocals",
    max_rtf: float | None = 0.10,
    candidates: list[str] | None = None,
) -> str:
    """Return model_id with best median SDR for `stem` under `max_rtf`."""
    subset = df[df["stem"] == stem]
    if candidates:
        subset = subset[subset["model_id"].isin(candidates)]
    if max_rtf is not None:
        subset = subset[subset["rtf"] <= max_rtf]
    if subset.empty:
        raise ValueError(f"No model satisfies stem={stem!r}, max_rtf={max_rtf}")

    scores = subset.groupby("model_id")["sdr_median"].median()
    return scores.idxmax()


if __name__ == "__main__":
    df = load_benchmark()
    print("Vocal model (RTF ≤ 0.10):", pick_model(df, stem="vocals"))
    print("Drums model (no RTF cap):", pick_model(df, stem="drums", max_rtf=None))

On the current data, pick_model(..., stem="vocals", max_rtf=0.10) returns htdemucs_ft. Drop the RTF cap on drums and you get mdx_extra_q.

Wire this into your config:

# settings.py
STEM_MODEL = {
    "vocals": pick_model(df, stem="vocals", max_rtf=0.08),
    "drums": pick_model(df, stem="drums", max_rtf=None),
}

That’s a defensible default you can paste into a design doc with a link to the dataset.

Find your worst-case tracks before users do

Short answer: Sort by sdr_median ascending per model — the tail is where separation breaks.

def worst_tracks(df: pd.DataFrame, model_id: str, stem: str, n: int = 5) -> pd.DataFrame:
    return (
        df[(df["model_id"] == model_id) & (df["stem"] == stem)]
        .nsmallest(n, "sdr_median")[["track_id", "sdr_median", "rtf", "duration_s"]]
    )


df = load_benchmark()
print(worst_tracks(df, "htdemucs_ft", "vocals"))

If your product targets metal or dense mixes, cross-check whether your genre’s failure modes show up in these track names — then add those clips to your own QA set. The HF table won’t replace genre-specific testing, but it surfaces landmines faster than random uploads.

Regression-test model quality in CI

Short answer: Assert median vocal SDR from the public table stays above a floor — catches bad deploys without running inference in GitHub Actions.

You are not running Demucs in CI. You’re pinning expectations to the published benchmark so nobody ships htdemucs where product spec says htdemucs_ft.

# tests/test_model_policy.py
import pytest
from picker import load_benchmark  # module from previous section

VOCAL_SDR_FLOOR_DB = 9.0  # below htdemucs_ft median; adjust with product input


def test_vocal_model_meets_benchmark_floor():
    df = load_benchmark()
    vocal_median = (
        df[(df["model_id"] == "htdemucs_ft") & (df["stem"] == "vocals")]["sdr_median"].median()
    )
    assert vocal_median >= VOCAL_SDR_FLOOR_DB, (
        f"htdemucs_ft vocal median {vocal_median:.2f} dB < floor {VOCAL_SDR_FLOOR_DB}"
    )


def test_picker_respects_rtf_budget():
    df = load_benchmark()
    model = pick_model(df, stem="vocals", max_rtf=0.10)
    rtf_p95 = df[(df["model_id"] == model) & (df["stem"] == "vocals")]["rtf"].quantile(0.95)
    assert rtf_p95 <= 0.10, f"{model} p95 RTF {rtf_p95:.3f} exceeds budget"

Cache the dataset in CI (HF_HOME or datasets cache dir) so pulls don’t hit rate limits every run.

Map picks to StemSplit API tiers

Short answer: Hosted StemSplit runs the same weights — no separate stemsplit_api row in the dataset because numbers would duplicate.

Your picker result StemSplit tier Benchmark row
Speed / bulk preview FAST htdemucs
Vocal isolation default BALANCED htdemucs_ft
Piano + guitar stems BEST (6-stem) htdemucs_6s

If you’d rather not operate Demucs + ffmpeg yourself, the API uses the same models — see building a vocal remover with the StemSplit API for the async job flow.

When you still need your own benchmark

The HF table answers: “Which open-source Demucs variant is best on MUSDB18-HQ?”

It does not answer:

Use the public dataset for model ID selection and CI floors. Layer your own golden files on top for product QA.

Wrapping Up

If you build something on top of this table (dashboard, CLI, fine-tune report), drop a link in the comments — the dataset is CC-BY-4.0.

Citation:

@misc{stemsplit_benchmark_2026,
  title  = {StemSplit Stem-Separation Benchmark 2026},
  author = {StemSplit},
  year   = {2026},
  url    = {https://huggingface.co/datasets/StemSplitio/stem-separation-benchmark-2026}
}

Frameworks matter more in the age of AI

About three months ago, I saw a post on X claiming that AI has ended framework wars. The more I thought about it, the more I realised the opposite is true.

If AI truly ended framework innovation, it would be a disadvantage to software engineering.

AI has made it easier for developers to experiment with different tools and ecosystems. Today, switching between React, Vue, Svelte, Laravel, Django, or Solid is easier than ever because AI reduces the friction of learning syntax and boilerplate but framework wars were never really about syntax.

Frameworks are structured systems of abstractions, conventions, rendering models, state management patterns, and architectural decisions. They exist because software complexity keeps increasing.

AI adds to that complexity. It can generate components, scaffold APIs, and suggest implementations but it cannot replace the architectural guarantees frameworks provide. Once applications become stateful, distributed, asynchronous, and context-heavy, structure becomes more important than code generation itself.

The real competition between frameworks is no longer about developer experience alone. It is about which abstractions can support emerging problems such as AI orchestration, shared client-server context, asynchronous rendering, concurrent interactions, browser-native agents, speech synthesis and streaming interfaces.

Let’s examine some of the emerging problems one by one.

Client-server unification

One of the oldest problems in web development is the separation between the client and the server.

Even today, most frameworks still treat them as different environments with different execution models, different contexts, and different rendering pipelines.

React, Vue, Svelte, Solid, and others attempt to bridge this gap but the boundary still exists. They all force you to fetch on one side and synchronise it response on another, leading to context duplication.

AI applications rely heavily on context like conversation history, memory, tool state, user intent, streaming data, orchestration state, and agent coordination. Yet we are forced to duplicate and manage all of those complexity on the client and the server.

The web needs frameworks capable of treating the client and server as a continuous execution environment instead of two disconnected worlds communicating through fragile synchronization patterns. This is not something AI solves automatically. It is a framework problem.

Asynchronous Rendering

Existing approaches to asynchronous rendering still struggle with AI-native orchestration patterns.

Many frameworks support asynchronous data fetching and partial interactivity, but true asynchronous rendering, where rendering itself behaves as a first-class asynchronous process, remains limited and it is extremely important in the AI era because AI systems are inherently asynchronous.

We need agents to call tools, subagents to delegate tasks and sync operations with ease.

Traditional UI architectures struggle with this model because they were designed around deterministic interaction flows.

As a result, developers end up writing excessive coordination logic for state synchronization, loading orchestration, race-condition handling, streaming workarounds, nested async effects and concurrency patches

The complexity spreads everywhere not because AI is flawed but because most frontend architectures were never designed for AI-native execution models.

A truly asynchronous rendering system would allow interfaces themselves to participate in orchestration flows instead of merely reacting to completed operations.

That changes everything.

It means agents and subagents can become naturally embedded into web interfaces instead of existing as sidebars, terminals, or isolated chat windows. The browser stops being a passive renderer and becomes an orchestration environment.

Speech Synthesis

Speech systems expose another major gap in modern web frameworks.

Running speech-to-text or text-to-speech over the network is expensive. Latency, scalability, bandwidth, privacy and infrastructure costs quickly become difficult to manage.

Browsers already provide speech capabilities through the Speech Synthesis API but the developer experience is inconsistent and difficult to control reliably.

Events such as onboundary and onmark, which are critical for synchronised speech interactions behave inconsistently across browsers.

That inconsistency makes it difficult to build reliable speech-driven interfaces. And again, this is not an AI problem. It is a framework problem.

What developers need is a client-side architecture capable of treating speech as a first-class interactive system instead of a disconnected browser feature.

Once speech becomes properly integrated into rendering and interaction systems, browser-native AI agents become more practical. Interfaces stop feeling like static pages with chat overlays. They start behaving like responsive conversational systems.

Framework Wars Are Necessary

AI did not end framework wars. If anything, it made them more important.

Every new layer of complexity creates demand for better abstractions, better execution models, and better ways to structure systems. That is exactly what frameworks are for.

Discouraging framework innovation will not simplify AI development. It will only leave developers with complex problems and fewer architectural tools capable of solving them.

Frameworks remain one of the most effective ways we have to turn powerful capabilities into scalable, maintainable, and reliable systems.

The future of AI on the web will not be determined only by models. It will also be determined by the frameworks capable of making those models programmable, interactive, asynchronous, and composable at scale. So framework wars are not over. The next generation has begun.

Have you experienced any of these challenges while building AI applications or modern web systems?

I would love to hear your perspective in the comments.

I Built a Content Agent That Remembers Everything — Now I Can’t Ghost It

Most AI tools for creators have the memory of a goldfish. You tell them your niche, your audience, your style — and next session, they ask again. I got tired of that loop, so I built ContentPilot: a content strategy agent that retains everything a creator shares, detects what actually works from their performance data, and adapts over time. The uncomfortable part? Once it learns enough about you, its recommendations are better than the generic ones you’d get from a human strategist who met you yesterday.

This article is the story of how I wired persistent agent memory and intelligent model routing into a single FastAPI process, what design decisions made the system actually useful, and why the most interesting feature turned out to be one I didn’t plan.

How it hangs together

ContentPilot is a monolithic Python backend (FastAPI) with a React frontend. The core loop is simple: a creator talks to the agent, the agent recalls relevant memories, generates a response using the right LLM for the task, then extracts and stores any new facts before responding.

Under the hood, three systems do the real work:

  • Hindsight — a persistent memory layer from Vectorize that stores, recalls, and synthesizes creator knowledge across sessions. It’s not a vector database I query with embeddings. It’s an opinionated memory system with its own retain, recall, and reflect primitives.
  • cascadeflow — a runtime routing layer that selects which LLM to use for each call based on task complexity, and tracks cost and latency per request.
  • A 5-stage agent pipeline — classify → recall → synthesize → generate → retain — where each stage has a dedicated model tier and purpose.

There is no database. No PostgreSQL, no SQLite, no Redis. Hindsight is the persistence layer. That was a deliberate decision: the memory system isn’t a feature bolted onto the product — the memory system is the product.

ContentPilot’s architecture — a monolithic FastAPI backend with Hindsight as the only persistence layer and cascadeflow routing across three LLM providers. The frontend talks to the API Router, which feeds into the 5-stage pipeline. cascadeflow and Hindsight sit as sibling services below the pipeline — one choosing the model, the other providing memory. There is no database tier because the memory system serves that role entirely.

The pipeline: five stages, three model tiers

Every user message runs through five stages. This isn’t over-engineering — each stage exists because lumping everything into one LLM call produced inconsistent results and made cost management impossible.

# Stage 1: Classify (cheapest model — ~$0, ~120ms)
# Determines task type, complexity score, and memory topics
classification = classify(message)  # llama-3.1-8b-instant

# Stage 2: Recall (no LLM cost — Hindsight API)
memories = recall(message, classification["topics"])

# Stage 3: Synthesize (standard model — ~$0.0003)
# Pattern detection with confidence scores
patterns = synthesize(memories)  # llama-3.3-70b

# Stage 4: Generate (routed by cascadeflow)
# Model selected based on complexity score
response = generate(message, memories, patterns)  # gpt-4o-mini or claude-sonnet

# Stage 5: Retain (cheapest model again)
# Extract key facts, tag them, store in Hindsight
retain(message, response)  # llama-3.1-8b-instant

The classification stage runs on the cheapest model available — Groq’s llama-3.1-8b-instant, which costs effectively nothing and responds in ~120ms. Its only job is to produce a complexity score between 0 and 1 and identify what memory topics to query. That score drives cascadeflow’s routing logic:

def select_model(classification: dict) -> tuple[str, str]:
    """Returns (model_tier, human_readable_reason)."""
    task_type = classification["task_type"]
    complexity = classification["complexity"]

    if complexity < 0.3:
        return "standard", f"Low complexity ({complexity:.2f}) — standard model sufficient"
    elif complexity < 0.6:
        if task_type in ("strategy", "analysis", "performance_review"):
            return "enhanced", f"Strategic task at moderate complexity ({complexity:.2f}) — enhanced reasoning needed"
        return "standard", f"Non-strategic task at moderate complexity ({complexity:.2f}) — standard sufficient"
    elif complexity < 0.8:
        return "enhanced", f"High complexity ({complexity:.2f}) — escalating to enhanced model"
    else:
        return "premium", f"Maximum complexity ({complexity:.2f}) — premium model for best strategic output"

Simple questions (“what should I post today?”) hit Groq at $0.0003. Multi-factor strategy requests (“analyze my last month’s performance and redesign my content mix”) escalate to OpenAI or Anthropic. Every routing decision includes a human-readable reason that gets exposed to the frontend.

Here’s what a typical 12-request session looks like with and without routing:

Without routing (all gpt-4o-mini) With cascadeflow routing
Simple Q&A (8 requests) 8 × $0.003 = $0.024 8 × $0.0003 = $0.0024
Strategy tasks (3 requests) 3 × $0.003 = $0.009 3 × $0.003 = $0.009
Complex strategy (1 request) 1 × $0.003 = $0.003 1 × $0.01 = $0.01
Classify + Retain overhead $0 24 × $0.0001 = $0.0024
Total $0.036 $0.014
Quality on complex tasks Medium Premium (Claude)

The result: ~61% cost reduction, and the hardest questions get a better model than before. 80% of requests stay on the cheapest tier, and the user sees exactly why the system chose to spend more when it did.

Each user message flows through five stages — classify, recall, synthesize, generate, retain — each with a dedicated model and cost profile. Only one stage (generate) uses a variable-cost model. The rest are fixed-cheap. The classify and retain stages both use the 8b model at essentially zero cost. Recall hits Hindsight with no LLM cost. Synthesize runs pattern detection on the 70b model. Generate is the only stage where cascadeflow routes to a more expensive model based on complexity.

The core story: memory that compounds

The part that surprised me was how much the agent’s usefulness compounds over time. Session 1, you get generic advice. By session 5, after feeding it 20 posts of performance data, the agent doesn’t just remember your numbers — it synthesizes patterns with confidence scores.

Here’s how Hindsight’s memory bank gets configured:

self._hindsight_call(
    "create_bank",
    bank_id=MEMORY_BANK_ID,
    name="Content Strategist",
    mission=(
        "You are a content strategy memory system. Track creator profiles, "
        "content performance, audience insights, and strategy preferences. "
        "Prioritize actionable patterns: what content formats work best, "
        "optimal posting times, audience demographics, and growth trends. "
        "Always connect past performance data to future recommendations."
    ),
    disposition={
        "skepticism": 2,
        "literalism": 3,
        "empathy": 4,
    },
)

That disposition object is interesting. The Hindsight docs describe it as shaping how the memory system interprets and weighs incoming information. Low skepticism means it trusts the data you feed it. High empathy means it weighs creator context and intent heavily. In practice, this means when a creator says “my audience loves tough love,” Hindsight doesn’t just store that as a string — it influences how future reflect() calls contextualize performance data.

Every conversation triggers a retain cycle. The agent doesn’t store the raw chat — it runs a cheap extraction pass first:

def _extract_key_info(self, user_msg: str, assistant_msg: str) -> str:
    extraction_prompt = f"""Extract the key facts worth remembering from this exchange.
Focus on: creator profile info, content preferences, performance data, goals,
audience details, platform preferences, and any specific strategies discussed.

User said: {user_msg}
Assistant responded: {assistant_msg[:500]}

Return ONLY the key facts as a bulleted list. If there are no notable facts, return "NONE"."""

    response = self.groq_client.chat.completions.create(
        model="llama-3.3-70b-versatile",
        messages=[{"role": "user", "content": extraction_prompt}],
        max_tokens=300,
        temperature=0.1,
    )
    return response.choices[0].message.content.strip()

This is a double LLM call pattern — one for the response, one for the memory extraction — and it’s worth the extra $0.0003. Without it, the memory bank fills with conversational noise. With it, it accumulates structured, retrievable facts: engagement rates, content format preferences, audience behavior observations.

The synthesis stage then takes those individual facts and produces confidence-scored patterns:

{
  "pattern": "conflict hooks outperform neutral hooks",
  "confidence": 0.87,
  "supporting_posts": 14,
  "contradicting_posts": 2,
  "trend": "strengthening"
}

Confidence scores strengthen as more data flows through the retain→synthesize loop. Individual performance metrics get retained with a performance tag. Once 5+ posts accumulate, the synthesizer detects a pattern at 0.65 confidence. As 9 more posts arrive and get re-synthesized, confidence climbs to 0.87. The agent doesn’t just remember — it becomes more certain about what works, and it tells you exactly how certain it is.

Results: same question, different session, different answer

The concrete payoff shows up when you ask the same question twice — once cold, once after the agent has accumulated context.

Session 1 (empty memory):

“What should I post this week?”

Agent: Generic fitness content advice. “Post 3-4 reels per week. Use trending audio. Mix educational and entertaining content.”

Session 5 (20 posts of data accumulated):

“What should I post this week?”

Agent: “Based on 14 posts (confidence: 0.87), your conflict hooks drive 3.2x engagement vs neutral openers. Your audience peaks 6-9 PM EST. Use your power-word vocabulary — DESTROYS, FAILING, STOP — they’re your signature. Avoid context-before-tension openings, which cause a 40% drop at the 15-second mark. Here are 3 specific reel ideas using your top-performing pattern…”

The second response references specific vocabulary, cites confidence scores, and names a retention issue tied to a specific timestamp. None of that was hardcoded. It came from Hindsight’s recall() pulling 8 hook-pattern memories and 3 audience-behavior insights, then the synthesizer turning them into scored patterns that the generator used as context.

Strategy recommendations are tracked as outcomes — validated strategies get higher confidence, and failed ones trigger alternatives. A recommendation (“use contradiction hooks”) is stored via Hindsight’s retain() with a strategy_outcome tag. When the creator posts using that advice and reports results, the outcome (“+38% retention”) is retained with the same tag. Validated outcomes feed back into future recommendations with higher confidence scores, closing the learning loop.

The use case I didn’t anticipate: Creator DNA as an auto-responder

Here’s what caught me off guard. As the memory bank grew, the agent didn’t just know what the creator should post — it knew how they talk.

Think about what happens when a fitness creator or a public speaking coach puts “DM me for help” in their bio. Hundreds of DMs pour in. Some are serious prospects. Some are just curious. Some are tire-kickers. The creator can’t reply to all of them personally, and a generic chatbot sounds nothing like them.

ContentPilot’s memory bank accumulates what I call Creator DNA — tone, vocabulary, pacing, catchphrases, controversy tolerance, storytelling patterns. After enough interactions, the agent can field those inbound DMs in the creator’s voice. It can differentiate a serious prospect from a casual browser, answer FAQs the way the creator would phrase it, and flag high-intent leads for personal follow-up. The creator’s audience gets a response that sounds right because the agent has internalized months of conversation context.

This isn’t a prompt template. The Creator DNA profile is built from observed patterns stored in Hindsight with a creator_dna tag — things like “uses provocative, confrontational tone,” “short punchy sentences,” “favors problem-agitate-solve structure.” When the agent generates a DM response, it recalls that profile and mirrors it. The creator’s followers can’t tell the difference because the context isn’t manufactured — it’s extracted from real interactions.

How does the memory system learn how a person speaks? Through the same retain→recall→reflect cycle that handles everything else. Every time the creator chats with ContentPilot, the extraction pass picks up stylistic signals alongside factual ones. Over 20+ sessions, the vocabulary list grows, the tone profile sharpens, the storytelling patterns emerge. Hindsight’s reflect() synthesizes these into a coherent profile that gets richer each session. The key insight is that voice and style are just another category of memory — they don’t need a separate system. They live in the same bank, tagged differently, and get recalled when the task requires them.

Three-tier graceful degradation

One design decision I’m genuinely proud of: the system works at three levels of availability.

Tier 1: Full stack — Hindsight + Groq + cascadeflow. Full memory, intelligent routing, cost tracking.
Tier 2: Groq only — Hindsight unavailable. The agent stores memories locally in an in-memory list, serves responses without persistent recall, and still functions as a capable strategist.
Tier 3: No API keys at all. The agent returns hardcoded but plausible strategy responses and still retains memories locally for the session.

if not self.groq_client:
    fallback_response = self._demo_response(message)
    self._retain_memory(
        content=f"Demo-mode conversation: creator asked '{message}'.",
        context="content strategy session",
    )
    return {"response": fallback_response, "metrics": {...}}

This isn’t heroic engineering. It’s ~30 lines of fallback paths. But it means the system never shows a blank screen or a stack trace to a creator. The worst case is “slightly less personalized advice” — which is still better than most tools on a good day.

Lessons learned

1. Memory extraction is worth the double-call cost. Storing raw conversations in a memory bank creates noise that degrades recall quality fast. Running a cheap extraction pass ($0.0003 per message) before retaining produces dramatically better recall results a week later. The key is keeping the extraction model’s temperature near zero — you want facts, not creative interpretation.

2. Classification-based routing pays for itself immediately. Before adding the 5-stage pipeline, every request hit the same model. With the classifier, 80% of requests stay on Groq’s cheapest tier. The total cost per session dropped ~61% with no user-perceptible quality difference on simple queries. cascadeflow made this straightforward — the routing logic is ~20 lines of Python, and every decision is traceable.

3. Hindsight’s reflect() does something different from recall(). I initially treated reflect() as a fancier recall. It’s not. recall() retrieves relevant memories. reflect() synthesizes them — it’s the difference between “here are 14 data points about hook performance” and “conflict hooks outperform neutral hooks by 3.2x with increasing confidence.” The synthesize stage in my pipeline exists because I realized reflect() was doing the most valuable work and I wanted to control when it ran.

4. Thread-per-memory-call is ugly but correct for async FastAPI + sync clients. Hindsight’s Python client uses synchronous HTTP under the hood. Calling it inside an async def endpoint blocks Uvicorn’s event loop. I wrapped every Hindsight call in a daemon thread with an 8-second timeout. It’s not elegant, but it hasn’t caused a single production issue, and the alternative — rewriting the client to be async — wasn’t worth the time.

5. The agent’s best feature was emergent. I built ContentPilot to give content strategy advice. The Creator DNA auto-responder capability — fielding DMs in the creator’s voice, qualifying leads, mirroring their tone — wasn’t in any spec. It fell out of the memory system working well enough that stylistic patterns became queryable context. The lesson: if your agent memory is good enough, features you didn’t plan for become possible without additional code.

ContentPilot uses Hindsight for persistent agent memory and cascadeflow for runtime LLM routing. The full source is available on GitHub.

TTY & PTY Explained 🖨️🐧

A Teletypewriter Image

The meaning of TTY

TTY stands for ‘Teletypewriter’ (or ‘Teleprinter’), which is a physical device used to send and receive typed messages from a communication channel.
Back in the day teletypewriters were mainly used to communicate with a computer to write stuff on a piece of paper or for dialing the phone.
A Teletypewriter Image

In computing, however, TTY does not refer to this physical device; because they serve a similar purpose – receiving and sending textual messages, they both share the same name.

What is TTY?

TTY is used as an interface for the user to communicate with the computer’s operating system.
In Linux, you can use the tty command in order to get the current tty file your terminal uses to receive user input and display them.

$ tty
/dev/tty1  # An example of a possible output

📝 Note:
You may get two kinds of outputs /dev/pts/X or /dev/ttyX (where X is a non negative number).
Why there are two kinds of files and what is the difference? We will
uncover that shortly.

What is /dev/tty1?

Every /dev/ttyX file is a special file which acts as a buffer containing input data. It is displayed as a simple file on the file system but its actually a special kind of file named Character Device – a file used as an interface for a device, such as printers, headphones, serial ports and in our case, terminal devices used to display information.

What happens when I enter some text

Let’s say you open bash and start typing something.
In this example we assume that stdin and stdout are attached to a terminal session, for example – /dev/tty1.

  • Input flow: The written text is caught by the OS and passed to the terminal driver (a part in the OS which handles input and output) which then writes the input data to the TTY file – /dev/tty1.
  • Output flow: Bash reads the data from its stdin file, that is the tty file – /dev/tty1, and sends the corresponding data to stdout (which is also /dev/tty1) where the OS and the terminal driver process it to showcase the information on the screen.
Input Flow - Reading typed data
[User Input] ---> [OS & Terminal Driver] ---> [/dev/tty1] ---> [Bash Shell (stdin)]
Output Flow - Displaying the result
[Bash Shell (stdout)] ---> [/dev/tty1] ---> [OS & Terminal Driver] ---> [Physical Terminal]

📝 Note:
If you were to check what bash writes to, you’ll sometimes see that it writes to /dev/tty and not to a specific tty file like ‘/dev/tty1’. How is that?
Well, /dev/tty refers to the controlling terminal of the current process, thats why the output is still displayed to the same terminal.

Pseudoterminals

Aside from tty, there is another way to achieve a similar IO channel, called “Pseudoterminals” (‘pty’ in short).
Pseudoterminals are built from two ‘virtual devices’, a pseudoterminal master and a pseudoterminal slave.
The pseudoterminal slave and master act as a bidirectional pipe – data can be transferred in either direction.
Interestingly, the pseudoterminal slaves appears just like a standard terminal and provides an identical interface to a physical terminal.

What is the difference between TTY and PTY?

The core difference between pty and tty is that pty achieves IO communication with another software, where tty achieves IO communication with a connected hardware.
PTY may be created by any program at any time while tty is created by the operating system where a new hardware is connected.

Where is PTY used?

Pseudoterminals are mainly used for two purposes:

  • Help operate terminal-oriented programs (like vi, ssh, tmux etc..) on another host.
  • As a way to grant duplex communication between a pair of processes (We will focus less on this).

As a simple example, lets take sshd (ssh server). When a user remotely connects to a ssh server running on another host, it forks and creates a new pseudoterminal slave for this specific session. That pseudoterminal slave acts just like a terminal – getting user input through the internet and sending back corresponding data.
A simple diagram to visualize it:

[Remote Client] ---> (Network) ---> [sshd (Master)] <---> [/dev/pts/X (Slave)] <---> [Bash Shell]

Pseudoterminals slaves will appear under /dev/pts/ in your linux filesystem (pts stands for ‘pseudoterminal slave’). So by running the tty linux commnand you may see a physical terminal or a pseudoterminal like the following:

Output of the command tty

In conclusion, Both tty and pty grant an interface for duplex communication. TTY is created automatically by the OS and connects to a connected hardware where PTY may be created by any program to achieve communication with another software.
This article hopes to make the difference more clear and user friendly, those who seek to learn the subject more deeply, may read chapters 62 and 64 on the great book ‘The Linux Programming Interface by Michael Kerrisk’.
Thank you for reading!

Skills are the New SDK Layer for AI-Assisted Development

AI coding assistants like Codex, Claude, Gemini, and Cursor are the first stop for developers when they start building.

This changes the way developers experience dev tools and platforms.

For years, the platform interface was mostly human-readable: docs, quick-starts, API references, sample apps, and console dashboards. Developers read the docs, copied credentials, installed packages, and translated setup instructions into code. Now it’s as simple as:

npx skills add github.com/AgoraIO/skills --skill agora

Load the skill into your project and prompt your coding assistant. Just ask for a real-time voice agent:

Build a voice AI tutor that can help me study for my US history exam.

That’s a different kind of request. The developer isn’t asking to learn a platform step by step. They’re looking to the AI agent to reason through architecture, choose the right pieces, configure the environment, and produce a working baseline.

The challenge is, most coding assistants still lack the operational context needed to do that reliably.

The Context Problem

AI coding models are strong generalists. They can scaffold apps, debug errors, write API clients, and refactor large codebases. But they’re only as useful as the context they can access.

When the task involves real-time infrastructure, missing context becomes expensive. Voice AI is a good example. A voice agent requires a multitude of things:

  • Real-time audio transport
  • Speech detection and interruption handling
  • Streaming transcription
  • Low-latency model responses
  • Text-to-speech playback
  • Client and server state coordination
  • Device permissions and media handling
  • Security tokens
  • Recording, signaling, or multi-user workflows

The assistant might know pieces of that stack, but the hard part is knowing how they fit together for a specific platform and use case.

Without that context, the model guesses. It may choose the wrong product, reference stale SDK methods, skip token generation, or produce technically valid fragments that never become a runnable app.

That isn’t a model intelligence problem. It’s a context distribution problem.

Documentation Was Designed for Humans

Traditional docs assume a human is navigating.

A developer can skim, compare options, follow links, notice warnings, and infer which path applies to their project. Coding agents don’t consume docs the same way. They work through compressed context and procedural instructions. They need the relevant parts surfaced at the moment they’re acting.

That means platforms need a new layer of developer experience:

  • Human-readable docs for learning and reference
  • SDKs and APIs for implementation
  • Sample apps for working baselines
  • Agent-readable instructions for AI-assisted workflows

That last layer is what skills provide.

A skill isn’t a replacement for docs, it’s a way to package platform knowledge so an AI coding assistant can use it while making implementation decisions.

What Skills Add

Skills give agents the kind of context developers usually pick up through docs, support threads, sample repos, and trial and error.

For a platform skill, that can include:

  • Which product maps to which use case
  • Which repos are current
  • How credentials should be created and stored
  • Which auth flows are safe for local demos versus production
  • How to start from a working sample
  • Which architecture patterns fit common scenarios
  • How to troubleshoot predictable setup failures

In practice, that makes the assistant less likely to generate disconnected snippets and more likely to guide the developer to a known-good baseline.

That baseline matters. Once an app runs, the assistant can help customize it. Before that, the assistant is often just producing plausible code against an uncertain target.

For voice AI, that first stable loop matters more than feature depth: get audio flowing, confirm the agent responds naturally, then customize prompts, tools, and model choices one step at a time.

Why Voice AI Shows the Pattern Clearly

Voice AI exposes the limits of generic code generation because it’s a systems problem.

The developer isn’t only asking for a UI or an API call. They’re asking for a live audio loop where transport, inference, auth, device state, and latency all have to work together.

An AI tutor, for example, raises immediate architectural questions:

  • Should orchestration happen client-side or server-side?
  • Does the experience need barge-in interruption?
  • Which model provider handles inference?
  • Is transcription streamed or batched?
  • Does the app need mobile support?
  • Will it later support avatars, telephony, or physical devices?
  • How should production token generation be handled?

A generic assistant may not know which questions matter. A platform skill can teach it the decision tree.

That’s the real value: not just faster scaffolding, but better implementation judgment.

Agora Skills as a Proof Point

Agora Skills packages all of Agora’s platform into a perfect context for AI coding assistants.

It helps agents understand how to build with Agora’s Conversational AI Engine, RTC, signaling, recording, token infrastructure, sample apps, and CLI tooling. Instead of asking the model to infer those details, the skill gives it current instructions it can act on.

With the skill installed, developers can ask their assistant to build a voice agent demo and actually have it work. AI assistants can easily navigate project setups. From cloning the right sample, to retrieve credentials and configuring environment variables, it handles everything needed to run the demo locally.

The mark of success isn’t whether the assistant can generate code, that’s table stakes. The benchmark is whether a developer can go from zero to talking to a real-time voice agent without manually navigating the infrastructure complexity first.

The New Platform Surface Area

As AI-assisted development becomes normal, platform teams need to think beyond documentation as a website.

The platform surface area now includes everything an agent needs to build correctly:

  • Product selection guidance
  • Setup and provisioning workflows
  • Current sample paths
  • Auth and token rules
  • Common architecture patterns
  • Failure recovery steps
  • Clear boundaries between demo code and production requirements

That knowledge can’t live only in scattered docs pages and tribal memory. It needs to be packaged in a way agents can load, reason over, and execute against.

Skills are becoming that layer.

They make platform knowledge operational. They reduce the gap between “the API exists” and “the assistant can help me ship with it.” And for complex domains like real-time Voice AI, that gap is where most of the developer friction lives.

Resources

Agora Skills is available on Skills.sh skills.sh/agoraio/skills/agora and GitHub github.com/AgoraIO/skills.

For Claude Code, you can install Agora Skills from the plugin marketplace:

/plugin marketplace add AgoraIO/skills
/plugin install agora@agora-skills

The interesting element is how quickly it gets you to a working system that you can actually talk to. Looking forward to seeing all the cool Voice AI projects people can build, drop a comment below to share with everyone.

Happy building!

Why Real-Time Analytics Eventually Changes Your Database Architecture

A lot of systems begin with a single database.

Usually PostgreSQL.

And honestly, in the beginning, that works perfectly fine.

The application stores:

  • users
  • payments
  • inventory
  • authentication
  • operational state

Dashboards query the same database.

Analytics queries also run directly on PostgreSQL.

Everything feels simple.

The Problem Usually Starts Slowly

At first, analytical queries are small.

Maybe:

  • daily reports
  • lightweight aggregations
  • small dashboards

Nothing too serious.

But over time, systems start generating:

  • more events
  • more metrics
  • more logs
  • more historical records
  • more observability data

And analytical workloads start behaving very differently from transactional workloads.

For example:

SELECT
    service_name,
    avg(response_time_ms)
FROM metrics
WHERE timestamp >= now() - INTERVAL 30 DAY
GROUP BY service_name;

This is a very different kind of workload from:

UPDATE inventory
SET stock = stock - 1
WHERE product_id = 101;

One is trying to preserve operational correctness.

The other is trying to analyze huge amounts of historical data.

And eventually those workloads start colliding.

PostgreSQL Slowly Becomes Responsible for Everything

This is where things usually start getting interesting.

A lot of systems unintentionally turn PostgreSQL into:

  • the transactional database
  • the reporting database
  • the analytics database
  • the observability database

all at the same time.

And honestly, modern PostgreSQL is capable enough that this can work surprisingly well for a while.

Until:

  • dashboards become heavier
  • retention windows grow
  • analytical scans become larger
  • observability traffic increases
  • aggregations become expensive

Now suddenly the same database handling:

  • payments
  • authentication
  • users
  • inventory

is also handling large analytical workloads.

And this is usually where architectural pressure starts building.

The Real Problem Is Workload Isolation

This is honestly the biggest lesson.

The issue is usually not:

“PostgreSQL is slow.”

The issue is:

transactional workloads and analytical workloads optimize for completely different things.

Transactional systems care heavily about:

  • consistency
  • operational latency
  • updates
  • row-level modifications
  • business correctness

Analytical systems care heavily about:

  • large scans
  • aggregations
  • compression
  • historical analytics
  • query throughput

Those are fundamentally different workload patterns.

And eventually trying to optimize one database perfectly for both becomes painful.

Why Observability Changes Everything So Quickly

One thing I find interesting is how fast observability workloads expose architectural limitations.

Because observability systems continuously generate:

  • logs
  • metrics
  • traces
  • events

And these workloads grow aggressively over time.

Now imagine running:

  • large aggregations
  • historical scans
  • high-cardinality queries
  • real-time dashboards

on the same database handling:

  • authentication
  • inventory
  • operational business logic
  • transactional traffic

At smaller scale this may still work.

At larger scale:

  • query contention increases
  • operational latency becomes sensitive
  • workload isolation becomes harder

And eventually systems start evolving toward separation.

This Is Usually When Analytical Databases Start Appearing

At some point, many systems evolve toward something like this:

Application
    ↓
PostgreSQL
    ↓
CDC / Kafka / Airbyte
    ↓
ClickHouse / OLAP DB
    ↓
Analytics / Dashboards / Observability

This pattern has become extremely common in modern analytical systems.

And honestly, the reason is pretty simple:

PostgreSQL remains responsible for operational correctness.

ClickHouse becomes responsible for analytical scale.

Each system handles the workload it was actually designed for.

Not All Analytical Data Needs PostgreSQL First

One important thing though:

Not all analytical data even originates from PostgreSQL.

A lot of observability workloads:

  • logs
  • metrics
  • traces
  • telemetry events

often flow directly into ClickHouse/OLAP DB through streaming pipelines.

Something like:

Applications / Services
        ↓
Kafka / Streaming Pipelines
        ↓
ClickHouse / OLAP DB

In many systems, PostgreSQL stores the business data while ClickHouse directly handles logs, metrics, events, and analytical workloads.

And honestly, this makes a lot of sense.

Because analytical systems are usually optimized for:

  • append-heavy ingestion
  • historical querying
  • event-style workloads

not transactional business operations.

Why Not Just Use ClickHouse for Everything?

This is another common misunderstanding.

ClickHouse is incredible for analytical workloads.

But transactional systems still require things like:

  • frequent updates
  • operational consistency
  • transactional guarantees
  • row-level modifications
  • business-critical correctness

Those are not the primary design goals of analytical databases.

You generally do not want your:

  • authentication system
  • payment workflows
  • inventory updates
  • operational application state

depending entirely on analytical database behavior.

Why CDC Pipelines Become So Important

One reason this architecture became so practical is CDC (Change Data Capture).

Instead of repeatedly exporting data manually, systems continuously stream changes from PostgreSQL into analytical systems using:

  • Kafka
  • Debezium
  • Airbyte
  • streaming pipelines

That means:

  • operational systems continue working normally
  • analytical systems receive near real-time data
  • workloads stay separated cleanly

And analytical queries no longer compete directly against transactional traffic.

Don’t Rush Into Multi-Database Architectures

One important thing though:

Most systems do not need Kafka + ClickHouse pipelines on Day 1.

Honestly, many applications can scale surprisingly far with PostgreSQL alone using:

  • proper indexing
  • query optimization
  • read replicas
  • partitioning
  • extensions like Citus

The goal is not to introduce more infrastructure as early as possible.

The real signal usually appears when analytical workloads start affecting operational user experience.

That is often when workload separation starts becoming worth the additional architectural complexity.

Because systems like:

  • CDC pipelines
  • Kafka
  • analytical databases

also introduce operational overhead of their own.

And good architecture is usually about introducing complexity only when the workload actually demands it.

The Bigger Engineering Lesson

Most systems do not start with multiple databases.

They evolve into them as workloads grow.

Transactional workloads and analytical workloads behave very differently at scale.

And eventually systems start separating:

  • operational correctness
  • analytical querying
  • observability workloads
  • historical analytics

into infrastructure optimized for each workload.

Final Thought

A lot of modern systems do not start with multiple databases.

They evolve into them.

Because transactional workloads and analytical workloads eventually want very different things from the same infrastructure.

And real-time analytics is often the thing that forces that architectural separation to happen.

Post-mortems and RCAs: why you should be doing them

The hard part of incidents isn’t surviving them. It’s making them count.

Most teams handle the acute phase reasonably well. Something breaks, people swarm, the symptoms get suppressed, the dashboards go green. The team is exhausted but relieved. The natural instinct is to move on. There’s a backlog waiting and nobody wants to spend another hour on something that’s already over.

This is where the lesson gets thrown away.

Post-mortem vs RCA

A quick terminology note, because these get used interchangeably and they shouldn’t.

A post-mortem is the document and the conversation that follow an incident. It captures the timeline, the response, the impact, what worked, what didn’t, and what the team commits to changing as a result. It is fundamentally a learning artifact.

A Root Cause Analysis is a technique (typically the “five whys” or a variant) used inside the post-mortem to push past the symptom and find the underlying conditions that allowed the incident to happen. RCA is one of the tools you use to fill in the most important section of the post-mortem.

You can run an RCA without writing a post-mortem (and many teams do, badly, in Slack threads that disappear). You can write a post-mortem without doing real RCA (and many teams do, ending up with documents that say things like “the database fell over” without ever asking why). The combination is what actually produces learning.

Blameless, or worthless

The single most important property of a post-mortem is that it is blameless. Not “blameless except for this one obvious case.” Not “blameless but we’ll note who was on call.” Blameless.

The reason is mechanical, not moral. The moment people believe a post-mortem might be used to assign blame, the information dries up. Engineers stop volunteering what they were actually thinking when they shipped the change. Operators stop admitting they didn’t understand the runbook. The document becomes a careful work of fiction, optimized to protect its authors, and the real causes go uninvestigated.

You cannot fix a system you cannot see clearly. Blame guarantees you cannot see clearly.

The framing that works is the one John Allspaw articulated at Etsy more than a decade ago: assume everyone involved acted reasonably given the information they had at the time. If their actions led to an incident, the interesting question is what made those actions look reasonable. That question is almost always answered by something about the system — missing signals, unclear ownership, confusing tooling, inadequate testing — and those are things you can actually fix.

Actionable, or it didn’t happen

A post-mortem that ends with “the team will be more careful next time” is not a post-mortem. It is a feeling, written down.

The output of a useful post-mortem is a list of action items that are concrete, owned, and tracked. “Add alerting on queue depth above 10,000, owned by the platform team, target this sprint” is an action item. “Improve our monitoring” is not.

The better lists go a step further and ask: is this specific to this incident, or does it represent a class of problem? A misconfigured retry policy is a specific bug. A pattern where retry policies are easy to misconfigure and hard to test is a class. The class is where the leverage is. Fixing it prevents not just this incident but the dozen variants you haven’t had yet.

Where TDD comes in

Here is where the discipline gets concrete.

Test-Driven Development, at its core, is a feedback loop: you write a failing test that describes the behavior you want, then write the code that makes it pass. The test is the specification, the safety net, and the regression check, all in one artifact.

The same loop applies, almost perfectly, to incidents. The behavior you want is “this specific failure mode does not happen.” You write a test that reproduces the failure or, if reproducing the exact failure is impractical, a test that exercises the condition that allowed it. You watch the test fail. Then you fix the underlying issue and watch the test pass. That test now lives in your pipeline forever.

This is the practice sometimes called bug-fix-by-test, and it converts a one-time lesson into permanent organizational knowledge. The next engineer who tries to reintroduce the problem (and someone will, six months from now, while refactoring something unrelated) gets stopped by a failing test with a clear name and a link back to the post-mortem that produced it.

Without this step, the lesson lives in the heads of the people who were on the call. Those people leave. The lesson goes with them. The same incident happens again in eighteen months and the new team wonders why nobody saw it coming.

The pipeline as institutional memory

This is where post-mortems connect back to the broader argument about Continuous Delivery. The pipeline is not just a verification system for new changes. It is the place where every lesson the organization has ever learned gets enforced automatically, on every commit, regardless of who made it.

Every post-mortem that produces a test makes the pipeline a little smarter. Over years, the accumulated tests become a kind of institutional memory that does not depend on anyone remembering anything. A junior engineer who joins next year inherits all the scars without having to feel them, because the scars are encoded as tests that fail when they should.

This is how teams actually get better over time, as opposed to claiming to. Without the test, the post-mortem is a story you tell yourself about being a learning organization. With the test, you are one.

The minimum viable practice

If you aren’t doing this today, you don’t need to roll out a perfect SRE program to start. The minimum viable practice is small:

After every incident worth the name, hold a meeting. Write a document. Run a five whys. Identify two or three concrete action items, with owners and dates. For at least one of them, write a test that would have caught the problem. Merge the test. Move on.

That is the whole loop. It is not complicated. The hard part is doing it consistently, including the times when everyone is tired and the next incident is already starting.

But every time you do, the system gets a little harder to break in the same way twice. Which, in the end, is the only definition of “more reliable” that actually means anything.

Great Little Software: Rackula

“There’s a new kind of coding I call ‘vibe coding’, where you fully give in to the vibes, embrace exponentials, and forget that the code even exists. … I’m building a project or webapp, but it’s not really coding — I just see stuff, say stuff, run stuff, and copy paste stuff, and it mostly works.”
— Andrej Karpathy, February 2025

One morning I woke up excited to spend some time on my side-project. I finally had the energy. I’d spent the last week in selfhosted communities and setting up my tiny Intel NUC. Finally I felt that I had an idea how to proceed with the project. I opened OpenCode and 30 minutes after I wanted to throw my project, LLMs and a laptop out of the window and change career to something that doesn’t involve saying “NONONO, not like this” to a very annoying program.

I looked at the code and I felt disconnected from it. I knew what it was doing, I reviewed it all, but it felt foreign. The flow was gone and I spent the next few days remembering how to code by hand. I’m happy to report that I’ve produced the world’s most over-engineered counter application: with in-memory storage and APIs for increasing and descreasing the count.

Vibe coding ravaged developer communities deliberately and exponentially speeding up copying from Stack Overflow without understanding. But there is a reason I have to rely on AI assisted development and, while my LLM detox has been extremely beneficial for mental health, I sought out to find better ways to write software with AI.

And I think Gareth Evans, creator of Rackula, has a lot of experience to share on the topic.

Rackula. The Beginning.

It started in the end of 2025. In Gareth’s own words:

Originally I was looking to help my Dad organize his super messy server rack, which had several big heavy audio amplifiers and networking equipment. I wanted to think about the layout of the rack and the equipment in a more organized way, without moving these awkward and heavy things around more than once. My Dad is a civil engineer, so loves diagrams and planning. I went looking for a tool that would help me visualize the layout of the rack and the equipment. Surprisingly, there were none that met my needs: easy to use, exports images and doesn’t lock in data.

Oh, the allure of the side projects, just like sirens they deceive us with their alledged simplicity and promises of good time. And LLMs only add oil to the fire:

If you recall that Nov-Dec 2025 was right when LLM-based tools were really beginning to show that they could be used to build software in a tangible way. So, like many others I was playing with Claude Code just to see what it could do. I initially wanted a way to visualize the rack layout and equipment arrangement and move the items by dragging them around. I also wanted to be able to save it as a format that could be easily shared and imported. It also needed to be able to export the layout as an image.

I also specifically remember reading Harper Reed’s My LLM codegen workflow atm blog post about how he uses AI to brainstorm and generate the specifications that are then used to prompt the LLM to generate code. So, with the need in mind I spent some time building out my spec and then quite rapidly had a working prototype up and running.

The Good, The Bad and Safari

Writing code is easy, when you know what to write, with or without LLMs.
Early on Gareth found out that defining the data model and schema was unexpectedly hard: “I realized (eventually) that a well-defined schema was crucial for building a scalable and maintainable system.”

I arrived to the same conclusion. LLMs are horrible at designing the software from scratch, but given a starting point and some guardrails it becomes a lot easier to wrangle overly eager coding agents.

I think there’s a lot of good materials out there to get inspired by. Rackula’s schema, for example, has been inspired by NetBox:
the code structure couldn’t be more different, but the conceptual model of what a rack is, what a device is, what properties they have, how they’re related are very alike.

And then there’s the scope creep. When possibilities are endless, choosing what not to build becomes a problem. Or as Gareth put it:
“Ideas are easy, it is much harder to limit scope creep. What began as a simple idea quickly grew into a complex system with many moving parts. Initially I wanted to do everything – keep tons of detail about every item”

He also said that if he’d done it all over again, he’d been more diligent about intentionally limiting scope: “I definitely burned some cycles on features that were not necessary for the initial functionality. I had a ton of ideas around modelling networking and power connectivity, which while valuable, were not critical to the core app.”

Rome hasn’t been built in a day and, judging by 1040 commits, Rackula has been through a lot of iterations. And to a big part that can be attributed to browser quirks:

The architecture of Rackula is focused on SVGs in the browser, and dragging and dropping them. Sounds simple, right? Sure, until you try and get consistent behaviour across browsers. Each major browser has its own ideas of how to handle SVGs and drag/drop, leading to inconsistent results. This meant that significant time was spent digging into how one browser’s implementation would affect the user experience in another. I have a GitHub issue label “damnit/safari” for a whole slew of Safari/webkit bugs and inconsistencies.

Even the name of the project has changed in the process:

I initially named it Rackarr, in sort of a playful reference to the *arr suite (Sonarr, Radarr, etc.) and then introduced it to the Reddit community. There was an almost immediate reversion to the name, because it is not truly related to the *arr suite.

It’s all about UI

The core philosophy of Rackula is that it must be intuitive. In creator’s own words:

I’m not sure if I’m there yet, but I wanted it to be a tool that my parents could use: which is a high bar for usability.

The process has definitely been long and non-linear:

I am not a designer, but do feel like I know when software makes sense (or doesnt). So, my initial layouts were kind of thrown together and would often feel clunky or inconsistent. This led me down a rabbithole of looking at how other canvas-based apps handled UI design and how to make my app feel more fluid. There are still many, many things I would improve if I had more time.

I’m sure that there’s more to it, but I do think that the effort placed into Rackula was well worth it.
I, for one, could figure it out and loved the little easter egg of “banana for a scale” I found in the settings:

Racky McRackface server rack diagram with a banana SVG next to it

AI vs Humans

I am genuinely impressed by Rackula’s quality and I can’t help but wonder how much of that can be attributed to Gareth and how much to AI.
And so I asked him for his secret sauce in the hope to apply it to my own projects. Here’s what he shared:

As mentioned earlier, (I used AI) extensively, based on Harper Reed and others who espoused AI-first approaches particularly using Test Driven Development (TDD). AI (Claude Code) was used to define the specifications through interative feedback, and then from there define a blueprint and prompt plan for the tests which eventually define the code that makes up the app. If you recall, Anthropic had doubled the usage limits over the Christmas break: this was the gasoline thrown on the fire for me. I was running 5+ agents in parallel churning out tests, code, and definitely could not have done this without AI.

His process sounds a lot like Kaizen to me, or it’s derivative, The Toyota Way: the code generation itself might be done by a machine, but the human is in control, obligated to pull the cord and stop the production when the quality drops.

But what’s even most important, I think, is to build the right thing and for the right reasons.
I asked Gareth about his ambitions for Rackula and, despite the project’s name, world domination is out of the roadmap:

Well, before this I had never had a project get really any traction. So, at first the dream was “someone clicks on my thing”, and now a few months down the road its at over 1k stars on GitHub. So now, my wildest dream is to have Rackula become a go-to tool for the several communities that use it. Really, what I want is for it to get to the point where it takes on a life of its own. I have already seen several individual contributors putting in PRs and helping shape the project, and I believe there’s a lot of potential for Rackula to grow and become a valuable tool for the community.

I think this way, where creative process remains in the hands of us, humans, is the way towards sustainable AI assited development.
I also believe that self hosting AI models is an important milestone for that. And if somewhere along the way you get inspired to build a server rack – give Rackula a try.

It’s a great little software.

Why Your Python Dead Code Detector Is Wrong About FastAPI, SQLAlchemy, and Half Your Codebase

Static analysis tools that only search for direct calls produce false positives. Here is what reachability-aware dead code detection looks like — and why getting it right matters more than ever in AI-assisted development.

Most dead code detectors answer the same question the same way: find the symbol, search for calls, report if nothing is found.

That works well for simple scripts. It breaks fast in real Python applications.

And in 2026, when AI coding agents use static analysis output as context for decisions, a false positive is no longer just an annoyance. It is a bug waiting to be confidently introduced.

Why “find calls” is not enough

Tools like vulture and pyflakes are genuinely useful. They answer the direct-call question correctly and fast. The problem is that direct calls are not the whole story in modern Python.

Real applications are full of code that is never called by name:

  • FastAPI route handlers are registered through decorators and invoked by the framework routing layer
  • Starlette middleware methods are called by the runtime, not by application code
  • SQLAlchemy TypeDecorator hooks are triggered by the ORM during query binding and result hydration
  • Pydantic validators are discovered through model metadata at class construction time
  • Aiogram message handlers are registered through router observers
  • Dependency injection containers wire providers at composition time, not at call sites
  • Public package APIs may be exported through __all__ or lazy __getattr__ without a single explicit import in the same codebase

If your dead code tool only searches for explicit symbol references, every one of these patterns is a potential false positive.

And once a tool produces enough false positives, engineers stop trusting it entirely — which is the worst possible outcome, because the real dead code stays in the codebase forever.

Why this is a bigger problem if you use AI coding assistants

There is a second, newer dimension to this.

AI coding agents increasingly use static analysis output as context for automated decisions. When an agent sees a function flagged as dead code, it may suggest deleting it. If that function is actually a FastAPI route handler, an Aiogram message handler, or an ORM lifecycle hook, the agent will remove something real — confidently, because the tool said so.

A noisy report annoys a human developer. A human developer reads the code and figures it out.

A noisy report misleads an agent. An agent acts.

The more AI-assisted coding workflows rely on static signals, the more those signals need to be careful, explainable, and conservative. A dead code report that feeds into an agent’s context is closer to executable code than to a suggestion.

The tempting trap: name-based heuristics

When false positives pile up, the easy fix is to add suppressions.

If a function name starts with get_, maybe a framework uses it. Skip it.

If the file is named routes.py, do not report anything in it.

If a method is called dispatch, it is probably fine.

This makes reports quieter. It does not make them more honest. You are trading false positives for hidden real dead code, and the tool becomes progressively less useful the more you suppress.

The hard part is not making fewer reports. The hard part is making fewer wrong reports without burying genuine findings behind broad exemptions.

Reachability, not vibes

The direction we took in CodeClone is symbol reachability.

Instead of asking “does any code call this function by name?”, we ask a different question:

Is there deterministic evidence that this symbol is reachable at runtime?

That evidence can come from many places: a direct function call, a framework registration decorator, a base class contract, a runtime hook signature, a dependency injection provider binding, a __all__ export, or a carefully bounded getattr dynamic dispatch. The analyzer builds a reachability model over these edges and only reports a symbol as dead when it finds no evidence of runtime participation.

This is still static analysis. It does not execute the program. It cannot perfectly model every Python runtime pattern in existence.

But it is substantially better than pretending that only direct calls exist.

What real false positives look like in practice

Consider a FastAPI route handler:

router = APIRouter()

@router.get("/metrics")
async def get_metrics() -> dict[str, object]:
    return {"ok": True}

Nobody calls get_metrics() by name. The framework matches HTTP requests to it through the registered route. A naive analyzer reports an unused function.

The same pattern with Aiogram:

router = Router()

@router.message(Command("start"))
async def cmd_start(message: Message) -> None:
    await message.answer("Started")

And with SQLAlchemy runtime hooks:

class OrjsonJSON(TypeDecorator[object]):
    impl = JSON

    def process_bind_param(self, value: object, dialect: object) -> object:
        return encode(value)

    def process_result_value(self, value: object, dialect: object) -> object:
        return decode(value)

process_bind_param and process_result_value are never called directly. SQLAlchemy calls them during query execution. A reachability-aware analyzer recognizes the TypeDecorator base class contract and treats these methods as live. A call-site analyzer reports them as unused.

An important distinction worth making explicit

We do not want CodeClone to say:

This symbol is definitely alive.

That claim is too strong for static analysis. Python is too dynamic.

We want it to say:

There is concrete evidence that this symbol participates in runtime reachability, so reporting it as dead code would be misleading.

That distinction matters for trust. Static analysis tools earn credibility by being careful about what they assert, not by being comprehensive. A conservative, evidence-based report that engineers trust completely is worth more than a comprehensive report that engineers learn to ignore.

What changed in CodeClone 2.0.2

In 2.0.2 we extended the reachability model to cover the patterns that produce the most false positives in real Python codebases.

The main additions: FastAPI route decorators and typed route wrapper patterns, Aiogram router and dispatcher observer decorators, Starlette BaseHTTPMiddleware.dispatch, Flask and aiohttp route registration, SQLAlchemy TypeDecorator runtime hooks, Pydantic model validators and runtime hooks, __all__ public re-exports, PEP 562 lazy module exports through __getattr__, and guarded dynamic dispatch patterns where getattr is followed by a callable check.

The result is intentionally boring: fewer false positives, without converting dead code detection into a list of broad suppressions.

For users upgrading from 2.0.1: if your dead code count goes down, that is expected and correct. It means the reachability model learned more about how your codebase connects to runtime behavior, not that detection became weaker.

Dead code gates in CI only work if engineers trust them

This is worth saying directly.

If a CI pipeline fails because process_bind_param looks unused to the linter, engineers learn to ignore the gate. Or they add a # noqa comment. Or they disable the check altogether.

All three outcomes are worse than not having the check.

A dead code gate in CI needs a false positive rate low enough that every finding is treated as credible. When that bar is met, dead code checks become genuinely useful in automated pipelines — including pipelines where an AI agent is part of the review process.

When it is not met, the check becomes noise, and noise gets turned off.

The principle

A symbol should be reported as dead only when the analyzer cannot find deterministic reachability evidence for it.

Not every Python runtime pattern can be statically modeled. But every supported pattern should be based on concrete, traceable evidence — a decorator we recognize, a base class contract we understand, a dependency graph edge we can follow.

That is the line between static analysis you can act on and a collection of heuristics that produces noise at scale.

Closing

Dead code detection in Python is not a grep problem.

It is closer to building a small reachability model of how real applications connect symbols to runtime behavior: direct calls, framework registration, dependency injection, runtime hooks, public exports, and bounded dynamic dispatch.

Tools like vulture get you a long way there, and fast. The remaining false positives come from the parts of the runtime that reveal themselves only through structural analysis — framework contracts, base class hierarchies, decorator semantics.

That is the gap CodeClone is trying to close.

Not by claiming to solve the unsolvable. By being honest about what evidence it has found, and conservative about what it reports without it.

CodeClone is a structural review layer for Python. The 2.0.2 release notes and full reachability documentation are in the repository.