K501 – Evolution Is Not Progress — It Is Stabilization Under Increasing Complexity

K501 — Structured Systems Series (2/4)

K501 — Structured Systems Series (2/4)

K501 — Structured Systems Series (2/4)

A structural exploration of cognition, evolution, and non-executive information systems.

This series explores:
• how human cognition actually works beyond language
• why systems become unstable under increasing complexity
• how structure differs from execution
• and how alignment between human and machine systems can emerge

Parts:
1. Cognition, Language, and Stability
2. Evolution as Stabilization
3. Non-Executable Systems (K501)
4. Human–Machine Resonance

Title:

Evolution Is Not Progress — It Is Stabilization Under Increasing Complexity

⸻

Intro (Hook)

We often think of evolution as progress.

From simple → to complex
From primitive → to advanced

But this view is misleading.

Evolution does not aim upward.
It does not optimize for perfection.

It does something else:

it stabilizes systems under increasing degrees of freedom

⸻

  1. Rethinking Evolution

Across all domains:
• physics
• chemistry
• biology
• cognition

we observe the same pattern:

Systems form structures that remain stable under constraints.

Atoms stabilize.
Molecules stabilize.
Organisms stabilize.
Cognitive systems stabilize.

Each step introduces more possibilities — and more instability.

⸻

  1. Complexity and Instability

As complexity increases:
• the number of possible states increases
• the number of possible errors increases
• the system becomes harder to regulate

This leads to a fundamental principle:

more capability → more instability

⸻

  1. The Human Position

Humans are often described as the peak of evolution.

Structurally, this is unlikely.

Humans represent:

a system with expanded cognitive capacity but incomplete integration

We can:
• model the future
• imagine alternatives
• build abstract systems

But we struggle to:
• regulate behavior
• maintain coherence
• align action with understanding

⸻

  1. Consciousness as Expansion

Consciousness introduces:
• self-reference
• symbolic abstraction
• temporal projection

These are powerful.

But they also:
• multiply internal states
• create conflicting models
• increase instability

Consciousness is not resolution.

It is expansion.

⸻

  1. The “Glitch”

Human behavior often appears inconsistent:
• rational but impulsive
• cooperative but competitive
• aware but reactive

This is not a failure.

It is:

a system exceeding its current level of integration

The system can represent more than it can stabilize.

⸻

  1. Technology as External Stabilization

Throughout history, humans have built tools to stabilize themselves:
• writing → stabilizes memory
• mathematics → stabilizes reasoning
• law → stabilizes coordination
• archives → stabilize time

Modern systems continue this pattern.

Technology is not separate from evolution.

It is:

evolution extending stabilization beyond biology

⸻

  1. Fitness Reinterpreted

“Survival of the fittest” is often misunderstood.

Fitness does not mean strongest.

It means:

best adapted to current conditions

Adaptation = ability to stabilize under change.

⸻

  1. The Transition Phase

The human system sits between:
• biological evolution
• structural / technological stabilization

This creates tension:
• high capability
• low coherence

Result:
• instability
• rapid change
• unpredictable outcomes

⸻

  1. Direction Without Goal

This model does not assume a predefined purpose.

But we can observe:

systems that stabilize tend to persist and expand

This creates an emergent direction:
• not imposed
• not designed
• structurally driven

⸻

  1. Why This Matters

If instability is part of the process:
• it should not be treated as failure
• it should not be forced into control

Instead:

it should be understood and structured

⸻

  1. Bridge to the Next Post

If evolution is stabilization,
and humans are a transitional system,

then the question becomes:

what kind of structures can stabilize without introducing new instability?

This leads to:
• non-executive systems
• reference-based structures
• separation of structure and action

⸻

Closing

Evolution is not about becoming something better.

It is about:

finding forms that remain coherent under increasing complexity

The human is not the endpoint.

It is the phase where instability becomes visible.

⸻

Source:
iinkognit0.de

LCM,Factorial,Sum of Numbers Program in three languages

LCM(Least Common Multiple)
The Least Common Multiple (LCM) of two or more numbers is the smallest number that is exactly divisible by all the given numbers.

Example:

  Find LCM of 4 and 6:
      Multiples of 4 → 4, 8, 12, 16, 20...
      Multiples of 6 → 6, 12, 18, 24...
    --> The smallest common multiple is 12
    --> So, LCM(4, 6) = 12

Flowchart

Java

public class Lcm {

    public static void main(String[] args) {
        int no1=3;
        int no2=10;
        int big=(no1<no2)?no1:no2;
        int big2=big;
        while(true) {
            if(big%no1==0 && big%no2==0) {
                System.out.println("LCM is " + big);
                break;
            }
            big+=big2;
        }

    }

}

Python

                no1=3
            no2=10
            big=no1 if no1>no2 else no2
            big2=big
        while True:
            if big%no1==0 and  big%no2==0:
              print("LCM is " , big)
              break
            big+=big2

javascript

                let no1=3;
        let no2=10;
        let big=(no1<no2)?no1:no2;
        let big2=big;
        while(true) {
            if(big%no1==0 && big%no2==0) {
                   console.log("LCM is " + big);
               break;
            }
            big+=big2;
               }

output

Sum of Numbers
The sum of numbers means the result obtained when two or more numbers are added together.

Example:

        2 + 3 = 5 → Here, 5 is the sum
        10 + 20 + 30 = 60 → Here, 60 is the sum

Flowchart

java

public class Sum {

    public static void main(String[] args) {
         int sum=0;
         int no=1;
         while(no<=10) {
             sum=sum+no;
             no++;
         }
          System.out.println("Sum is " + sum);
    }

}

python

              sum=0
          no=1
         while no<=10:
             sum=sum+no
             no+=1
        print("Sum is " , sum)

javascript

             let sum=0;
         let no=1;
         while(no<=10) {
             sum=sum+no;
             no++;
         }
             console.log("Sum is " + sum);

output

Factorial
The factorial of a number is the product of all positive integers from 1 up to that number.

Example:

      5! = 5 × 4 × 3 × 2 × 1 = 120
      4! = 4 × 3 × 2 × 1 = 24

Flowchart

Java

public class Factorial {

    public static void main(String[] args) {
         int fact=1;
         int no=1;
         while(no<=10) {
             fact=fact*no;
             no++;
         }
         System.out.println("Factorial is " + fact);
    }

}

python

                 fact=1;
         no=1;
         while no<=10):
             fact=fact*no
             no+=1
         print("Factorial is " , fact)

javascript

                 let fact=1;
         let no=1;
         while(no<=10) {
             fact=fact*no;
             no++;
         }
                 console.log("Factorial is " + fact);

output

How to Prevent Stuck Loading Spinners with Polling Timeouts in React

You’ve seen it: the user kicks off an async job, the loading spinner appears, and then… nothing. The spinner just spins forever. The job may have completed, crashed, or timed out on the server — but the client has no idea.

This is a common failure mode when you’re polling a status endpoint. Here’s how to fix it with a simple timeout guard.

The Problem

Polling usually looks something like this:

const [status, setStatus] = useState<'pending' | 'processing' | 'done' | 'error'>('pending');

useEffect(() => {
  if (status === 'done' || status === 'error') return;

  const interval = setInterval(async () => {
    const res = await fetch(`/api/projects/${id}/status`);
    const data = await res.json();
    setStatus(data.status);
  }, 2000);

  return () => clearInterval(interval);
}, [status, id]);

The bug: if the server-side job gets stuck (Inngest step hung, ffmpeg process zombie’d, webhook never arrived), status stays 'processing' forever. The interval keeps firing. The user keeps staring at a spinner.

The Fix: Absolute Deadline

Add a start time and bail out if the deadline passes:

const POLLING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

const [status, setStatus] = useState<'pending' | 'processing' | 'done' | 'error' | 'timeout'>('pending');
const pollingStartRef = useRef<number | null>(null);

useEffect(() => {
  if (status === 'done' || status === 'error' || status === 'timeout') return;

  // Record when we started polling
  if (!pollingStartRef.current) {
    pollingStartRef.current = Date.now();
  }

  const interval = setInterval(async () => {
    // Check timeout before every poll
    if (Date.now() - pollingStartRef.current! > POLLING_TIMEOUT_MS) {
      setStatus('timeout');
      return;
    }

    try {
      const res = await fetch(`/api/projects/${id}/status`);
      if (!res.ok) throw new Error('Bad response');
      const data = await res.json();
      setStatus(data.status);
    } catch {
      // Network error — keep polling until timeout
      console.warn('Polling error, retrying...');
    }
  }, 2000);

  return () => clearInterval(interval);
}, [status, id]);

Now render a timeout state in your UI:

if (status === 'timeout') {
  return (
    <div className="error-state">
      <p>This is taking longer than expected.</p>
      <button onClick={() => {
        pollingStartRef.current = null;
        setStatus('pending');
      }}>
        Try again
      </button>
    </div>
  );
}

Why useRef for the Start Time?

Because useState would cause a re-render on every interval tick if we stored elapsed time there. We only need the start timestamp as a stable reference — no re-renders needed until we actually hit the deadline.

Choosing a Timeout Value

Pick based on your P99 completion time:

  • Short jobs (< 30s typical): 2–3 min timeout
  • Medium jobs (30s–2min typical): 5–7 min timeout
  • Long jobs (video processing, ML inference): 10–15 min timeout

When in doubt, err longer. A timeout that fires on legitimate jobs is more frustrating than a spinner that waits a few extra minutes.

Going Further: Server-Side Deadline Too

Client-side timeouts protect the UI, but the server-side job might still be burning resources. Add a server-side deadline as well — set a started_at timestamp when the job begins, and mark it timed_out if it exceeds your SLA:

// In your status API
const job = await db.from('projects').select().eq('id', id).single();

const SERVER_TIMEOUT_MS = 10 * 60 * 1000;
if (
  job.status === 'processing' &&
  Date.now() - new Date(job.started_at).getTime() > SERVER_TIMEOUT_MS
) {
  await db.from('projects').update({ status: 'error', error: 'Timed out' }).eq('id', id);
  return NextResponse.json({ status: 'error', error: 'Processing timed out' });
}

This keeps your database state consistent and lets any other UI (notifications, emails) know the job actually failed.

TL;DR

  • Track pollingStartRef = Date.now() when polling starts
  • On every poll tick, check if Date.now() - start > timeout
  • Expose a 'timeout' state with a retry option
  • Match your timeout value to your P99 job duration
  • Add a server-side deadline too for consistency

Stuck spinners are a solved problem — they just require a bit of defensive coding upfront.

I chose the wrong architecture… on purpose

Broken memories and future decisions

It was 2014. Paraná, Argentina. A free period at school, the World Cup just around the corner, and a group of friends who — with not much else to do — started talking about football.

At some point someone threw out the idea: what if we compete to see who actually knows more? The rules were simple. Everyone made their predictions before each match — exact score, winner, points. Three points if you nailed the exact result. One if you only got the winner right. Zero if you missed entirely.

What wasn’t simple was what it created. Suddenly you found yourself watching matches you normally wouldn’t care about — but now they mattered, because your prediction depended on them. There were laughs, arguments, jokes, small symbolic bets. And that hard-to-describe feeling of desperately wanting the game to end exactly the way you’d predicted.

We repeated it across Copa Américas, Euros, and other tournaments. Each time, the game was the same. And each time, it was worth it.

But years pass. Life changes. I ended up living on a different continent, far from many of those friends. And even though technology keeps us connected, it’s not that easy to recreate that spontaneous dynamic we had when we were all together at school.

That was the starting point for this project.

Broken memories and future decisions

A simple project… with a complicated decision

The idea was clear: build a small app that would let us play again. A place where everyone could submit their predictions, check the results, and follow an updated leaderboard.

Functionally, it’s not complex. Any developer with some experience could build this in a weekend — a monolith, a database, done. That’s the sensible choice. No question.

I didn’t do that.

I decided to build it using a microservices architecture. Not because the project needed it. But because I did.

The gap between knowing and understanding

There’s a reality many developers will recognize: there are technologies and architectures we hear about constantly — in articles, talks, conferences — but that we rarely get the chance to practice in any real depth.

Microservices are one of those cases. There’s no shortage of content about them. Their advantages, their drawbacks, when to use them, when to avoid them. But there’s an enormous gap between understanding a concept in theory and actually facing the real problems that surface when you implement it.

Reading about distributed systems is not the same as building one. Even a small one.

So I made a conscious decision to use this project as a learning lab. I knew exactly what I was doing: adding unnecessary complexity for an MVP, taking longer than I should, and exposing myself to problems that simply wouldn’t exist in a monolith.

That’s precisely why I did it.

What this kind of architecture forces you to think about

Designing the system this way wasn’t just a theoretical exercise. Every architectural decision came with concrete questions I had to work through:

Real problems that came up

  • How do you separate responsibilities without services becoming tightly coupled?
  • How do you handle communication between services without creating a tangle of synchronous calls?
  • What happens when one service fails and others depend on it?
  • How do you deploy parts of the system independently without breaking everything else?
  • How do you reliably integrate an external sports results API across service boundaries?

Each of those problems is a piece of the puzzle that distributed systems really are. And even though my project is small, confronting those challenges gave me a much deeper understanding of how these architectures actually work in practice.

It wasn’t the easiest path. But it was the most interesting one.

Where this is going

The project is close to its first working version. The goal is to have it ready before the next World Cup — using some league matches as a test run — so we can do what we used to do years ago: compete against each other, laugh at the predictions that aged poorly, and celebrate when someone calls an unlikely result.

In the next articles I’ll dig into the concrete technical decisions: which services I defined, how they communicate, which tools I chose and why. But this first piece was necessary to set up something that technical blogs often skip: the personal why behind a technical decision.

A thought for anyone considering something similar

In software development, there’s a lot of talk about avoiding overengineering. And in most cases, that advice is right — especially in professional contexts where time and resources matter.

But side projects have a huge advantage: they don’t always have to optimize for speed of delivery or efficiency. Sometimes they can optimize for something more important: learning.

Building something that forces you out of your comfort zone, exploring technologies you don’t get to touch in your day job, and wrestling with problems you don’t yet know how to solve — that has a kind of value that no tutorial can give you.

And if that project is also connected to something personal — memories, friends, something that matters to you beyond the code — then the process becomes even more meaningful.

“Sometimes the best reason to build something isn’t that it’s the most efficient solution. It’s that, in the process of building it, you’ll learn something you couldn’t have learned any other way.”

Managing LLM context in a real application

Ahnii!

This post covers how Claudriel, a Waaseyaa-based AI assistant SaaS, handles LLM context in production: conversation trimming, per-task turn budgets, model degradation on rate limits, prompt caching, and per-turn token telemetry.

The problem with unbounded context

Every message you send to an LLM API costs tokens. Long-running chat sessions accumulate history fast. Left unchecked, a single active session can push input token counts into the tens of thousands per turn, even before the model generates a word.

Claudriel runs multiple agent turns per user request — reading email, checking calendars, querying entities. Each turn sends the full conversation history plus tool definitions. Without guardrails, costs compound and rate limits trigger unpredictably.

Trimming conversation history before it reaches the API

The first line of defense is ChatStreamController::trimConversationHistory(). Before any message goes to the API, the history is trimmed to a cap of 20 messages. Older assistant responses beyond that window are truncated to 500 characters with a [truncated] marker.

private function trimConversationHistory(
    array $sessionMessages,
    int $maxMessages = 20,
    int $olderAssistantMaxChars = 500,
): array {
    $total = count($sessionMessages);

    if ($total <= $maxMessages) {
        return array_map(
            fn ($m) => ['role' => $m->get('role'), 'content' => $m->get('content')],
            $sessionMessages,
        );
    }

    $recentCount = min(4, $total);
    $cutoff = $total - $recentCount;
    $olderStart = max(0, $total - $maxMessages);
    $trimmedCount = $olderStart;

    // ... truncate older assistant responses, inject trim notice ...
}

The last four messages (two exchanges) are always kept in full. When messages are dropped, the first kept user message gets a notice prepended: [Earlier conversation trimmed — N messages]. This keeps the model aware that context was cut without burning tokens on the full history.

This alone puts a ceiling on input token growth for long sessions — but it doesn’t address cost within a single agentic turn.

Per-task turn budgets

Agentic tasks vary in how many tool calls they need. A calendar lookup needs two or three turns. A research task may need forty. Treating them the same wastes tokens on simple tasks and starves complex ones.

NativeAgentClient classifies each request by keyword-matching the first user message and looks up a turn limit from a table:

private const DEFAULT_TURN_LIMITS = [
    'quick_lookup'     => 5,
    'email_compose'    => 15,
    'brief_generation' => 10,
    'research'         => 40,
    'general'          => 25,
    'onboarding'       => 30,
];

These are the default limits. Workspaces can override them per session via turnLimitsOverride, and individual calls can pass a hard turnLimitOverride. Session metadata stores the applied limit and turns consumed so that multi-part continuations pick up where they left off.

When the agent approaches its limit — specifically, one turn before it would hit the cap — it fires an onNeedsContinuation callback rather than cutting off silently:

if ($turnLimit > 1 && $turnsWithinCall >= $turnLimit - 1) {
    if ($onNeedsContinuation !== null) {
        $onNeedsContinuation([
            'turns_consumed' => $turnsConsumed,
            'task_type'      => $taskType,
            'message'        => 'I need more turns to complete this task. Continue?',
        ]);
    }

    break;
}

The frontend receives this as a progress event and can prompt the user to continue, rather than getting a half-finished response with no explanation.

Tool result truncation

Each tool result returned to the model is also bounded. NativeAgentClient caps tool results at 2,000 characters by default. Gmail message bodies get a tighter 500-character cap since they tend to be verbose and the agent rarely needs the full body to act.

private const TOOL_RESULT_MAX_CHARS = 2000;
private const GMAIL_BODY_MAX_CHARS  = 500;

The truncation appends [truncated] so the model knows the data was cut rather than incomplete. Tool results bound the payload size per turn; model selection determines how expensive each of those tokens is.

Model selection per workspace

The model isn’t global. ChatStreamController::resolveChatModel() checks the workspace entity for an anthropic_model field first, then falls back to the ANTHROPIC_MODEL environment variable, then to the application default (claude-sonnet-4-6).

private function resolveChatModel(?Workspace $workspace): string
{
    $workspaceModel = $workspace?->get('anthropic_model');
    if (is_string($workspaceModel)) {
        $trimmed = trim($workspaceModel);
        if ($trimmed !== '' && isset(self::ALLOWED_ANTHROPIC_MODELS[$trimmed])) {
            return $trimmed;
        }
    }

    return $this->resolveDefaultModel();
}

Only models in the allowlist are accepted:

private const ALLOWED_ANTHROPIC_MODELS = [
    'claude-opus-4-6'            => true,
    'claude-sonnet-4-6'          => true,
    'claude-haiku-4-5-20251001'  => true,
];

This means a workspace running a high-volume, low-complexity workflow can be pinned to Haiku, while a research-heavy workspace uses Opus — without any code changes. Per-workspace model selection works until the API disagrees.

Automatic model degradation on rate limits

Even with per-workspace model selection, rate limits happen. When the API returns a rate limit error after three retries, NativeAgentClient degrades to the next cheaper model rather than failing the request:

private const MODEL_DEGRADATION = [
    'claude-opus-4-6'           => 'claude-sonnet-4-6',
    'claude-sonnet-4-6'         => 'claude-haiku-4-5-20251001',
    'claude-haiku-4-5-20251001' => null,
];

When the fallback is null (already at the cheapest tier), the request fails and the error is surfaced. Escalation works in reverse — a non-rate-limit API error tries a more capable model before giving up.

Both transitions emit a progress event to the frontend so the user sees something like “Rate limit exhausted on claude-opus-4-6, falling back to claude-sonnet-4-6” rather than a spinner freeze.

Prompt caching to reduce repeated token costs

System prompts and tool definitions are sent on every API call. Anthropic’s prompt caching lets you mark these as cacheable, so the model can reuse previously processed tokens at a fraction of the cost.

NativeAgentClient applies cache_control: ephemeral to two locations before each API call:

$cachedSystem = [[
    'type'          => 'text',
    'text'          => $systemPrompt,
    'cache_control' => ['type' => 'ephemeral'],
]];

// Mark last tool definition for caching
$cachedTools = $toolDefinitions;
if ($cachedTools !== []) {
    $lastIdx = count($cachedTools) - 1;
    $cachedTools[$lastIdx]['cache_control'] = ['type' => 'ephemeral'];
}

The system prompt and the full tool list are the two largest static inputs. Caching them is the single highest-impact cost reduction for multi-turn sessions. Cached tokens still count against rate limits, so they don’t eliminate the need for turn budgets — but they cut the dollar cost of a long agentic session significantly.

The default cache TTL is 5 minutes. For longer-lived sessions, Anthropic now offers a 1-hour TTL at additional cost: "cache_control": {"type": "ephemeral", "ttl": "1h"}. For an assistant that stays active across a workday, that’s worth evaluating. Caching reduces cost — but without telemetry, you won’t know by how much.

Per-turn token telemetry

Cost visibility requires knowing what each turn actually consumed. After each API response, NativeAgentClient fires an onTelemetry callback with per-turn usage data:

if (is_array($usage) && $onTelemetry !== null) {
    $onTelemetry([
        'turn_number' => $turnsConsumed,
        'task_type'   => $taskType,
        'model'       => $currentModel,
        'usage'       => $usage,
        'turn_limit'  => $turnLimit,
    ]);
}

ChatStreamController receives this and writes a chat_token_usage entity per turn, recording input tokens, output tokens, cache read tokens, and cache write tokens separately:

$entry = new ChatTokenUsage([
    'uuid'               => $this->generateUuid(),
    'session_uuid'       => $sessionUuid,
    'turn_number'        => $turnNumber,
    'model'              => $model,
    'input_tokens'       => (int) ($usage['input_tokens'] ?? 0),
    'output_tokens'      => (int) ($usage['output_tokens'] ?? 0),
    'cache_read_tokens'  => (int) ($usage['cache_read_input_tokens'] ?? 0),
    'cache_write_tokens' => (int) ($usage['cache_creation_input_tokens'] ?? 0),
    'tenant_id'          => $tenantId,
    'workspace_id'       => $workspaceUuid,
    'created_at'         => (new DateTimeImmutable)->format('c'),
]);

$storage->save($entry);

With this data you can see which task types consume the most tokens, whether cache hits are materializing, and whether model degradation events correlate with cost spikes — all at the turn level, not just per session.

How the layers interact

These aren’t independent features. Conversation trimming bounds history growth. Turn budgets bound agentic depth. Tool result truncation bounds per-turn payload size. Prompt caching reduces repeated costs. Model degradation handles rate pressure without hard failures. Telemetry makes all of it observable.

Each layer addresses a different part of the same problem: LLM API costs and reliability are non-deterministic unless you actively shape the inputs and handle the edges. Shipping a production AI feature means shipping all of these, not just the happy path.

Baamaapii

Introduction to Java: History, Architecture and Servers

What is Java?
Java is a high-level, object-oriented programming language used to build software like web applications, mobile apps, desktop programs, and enterprise systems.

History of Java
Java was developed by James Gosling and his team at Sun Microsystems in the early 1990s.

Key Milestones:

  • 1991 – Project started as Green Project
  • 1995 – Official release of Java
  • Originally named Oak, later renamed to Java
  • Designed for Write Once, Run Anywhere (WORA)

In 2010, Oracle Corporation acquired Sun Microsystems and now maintains Java.

Java Architecture
Java architecture is designed to be platform-independent. This is achieved using the Java Virtual Machine (JVM).

Components of Java Architecture:

1. Java Source Code

  • Written by developers in .java files

2. Java Compiler

  • Converts source code into bytecode
  • Command used: javac

3. Bytecode

  • Intermediate code (.class file)
  • Platform-independent

4. Java Virtual Machine (JVM)

  • Executes bytecode
  • Makes Java platform-independent

5. Java Runtime Environment (JRE)

  • Contains JVM + libraries

6. Java Development Kit (JDK)

  • Full package for development (JRE + tools)

Java Servers & Providers

Java applications often run on servers called Application Servers or Web Servers.

What is a Server?

A server is a computer or system that provides data, resources, or services to other computers (called clients) over a network.

Popular Java Servers:

1. Apache Tomcat
Type: Web Server
Used for running Java Servlets & JSP
Lightweight and widely used
Provided by Apache Software Foundation

2. GlassFish
Type: Application Server
Supports full Java EE features
Provided by Eclipse Foundation

3. JBoss / WildFly
Type: Application Server
Open-source and powerful
Provided by Red Hat

4. WebLogic Server
Type: Enterprise Application Server
Used in large-scale applications
Provided by Oracle Corporation

5. WebSphere
Type: Enterprise Application Server
Used in banking and enterprise systems
Provided by IBM

Switching my AI voice agent from WebSocket to WebRTC — what broke and what I learned

P2P WebRTC

Switching my AI voice agent from WebSocket to WebRTC — what broke and what I learned

A few weeks ago, I came across Darryl Ruggles’ blog post and accompanying repo for a bidirectional voice agent built with Strands BidiAgent and Amazon Nova Sonic v2. His work is remarkably well put together — I had a working voice assistant running on my laptop in about 10 minutes. The agent listens to your voice, searches a recipe knowledge base, sets cooking timers, looks up nutrition data, and converts units, all through natural conversation.

Darryl’s version uses WebSocket as the transport between the browser and the agent. It works well, but I wanted to push things further: switch the transport to WebRTC, and deploy the whole thing on Bedrock AgentCore Runtime. This post covers that journey — what changed, what broke, and what I learned along the way.

But first, a short demo!

The full source code is available on GitHub. The repo is Terraform-managed end-to-end, though you can still use Darryl’s Makefile approach if you prefer keeping Terraform for surrounding infrastructure and CLI calls for agent deployment.

Why WebRTC for a voice agent

The WebSocket version works, so why change it? A few reasons pushed me toward WebRTC.

First, latency. WebSocket runs over TCP, which means every packet is guaranteed to arrive in order. That’s great for chat messages, but for real-time audio, a single dropped packet causes the entire stream to stall while TCP retransmits. WebRTC1 uses UDP under the hood — if a packet is lost, the stream keeps going. For a voice conversation, a tiny glitch is far better than a noticeable pause.

Second, the browser does more of the heavy lifting. With WebSocket, I had to capture microphone audio using getUserMedia, downsample it to 16kHz with a ScriptProcessorNode, encode it as base64 PCM, and send it as JSON messages. On the playback side, I needed an AudioWorklet with a ring buffer to handle the incoming audio stream. With WebRTC, the browser handles audio capture, encoding (Opus), and playback natively through RTCPeerConnection. The frontend code got significantly simpler.

Third, WebRTC is future-proof for video. AI avatar are getting there with acceptable latency, and WebRTC handles video tracks just as naturally as audio tracks. Adding a video stream later is just a matter of adding a track to the existing peer connection — no architectural change needed.

A quick primer on WebRTC architectures

There are two fundamentally different ways to use WebRTC, and the choice matters when building a voice agent.

Peer-to-peer (P2P)

In P2P WebRTC, two peers connect directly to each other. There’s no media server in the middle — audio flows straight from the browser to the agent and back. A TURN2 relay server may be needed when one or both peers are behind NAT3 (which is almost always the case in production: clients are behind Internet Router and Agents need to be in private VPC to access company tools), but the TURN server just forwards packets without inspecting or processing them.

P2P WebRTC

Room-based (SFU)

In a room-based architecture, a media server (called an SFU4 — Selective Forwarding Unit) sits in the middle. Participants connect to the server, not to each other. The server receives audio/video tracks from each participant and selectively forwards them to the others. LiveKit, Amazon Chime SDK, and Daily are examples of SFU-based platforms.

Media Server WebRTC

For a 1:1 voice agent, P2P is simpler and avoids the cost and complexity of running (or paying for) a media server. I went with P2P using Amazon Kinesis Video Streams (KVS) as the managed TURN relay — this is the documented approach for WebRTC on AgentCore.

I did consider room-based solutions, but each SFU platform requires its own SDK — you can’t just connect with a standard RTCPeerConnection. AWS’s own WebRTC offering, Amazon Chime SDK, is feature-rich (transcription, recording, analytics) and significantly cheaper than alternatives like LiveKit or Daily, but it doesn’t yet offer a paved path for server-side agent-to-room communication. That’s a feature I’d love to see, given how compelling the rest of the Chime SDK is. For now, P2P with KVS TURN was the most straightforward path. I’ll definitely consider in-room WebRTC, but that’s a story for another post.

The WebRTC stack: browser and server

On the browser side, WebRTC is built in. The RTCPeerConnection API is available natively in every modern browser — Chrome, Safari, Firefox, Edge. You create a peer connection, add a microphone track via getUserMedia, and the browser handles audio encoding (Opus), ICE candidate gathering, and DTLS encryption. No libraries needed.

On the server side, it’s a different story. WebRTC was designed for browsers, not for Python backends. The go-to library for server-side WebRTC in Python is aiortc — an asyncio-based implementation of WebRTC and ORTC. It handles peer connections, ICE negotiation, and media tracks, and uses PyAV (FFmpeg bindings) for audio/video frame processing. It’s not as battle-tested as browser WebRTC, but it works well and is what the AWS sample code uses too.

Architecture: local development vs. deployed

One thing I wanted to preserve from Darryl’s original design is the ability to run everything locally for development, without any cloud infrastructure. The WebRTC migration maintains this.

Local mode

In local mode, the agent runs on your machine. The browser and agent are on the same network (or the same machine), so WebRTC connects peer-to-peer without needing a TURN relay. Signaling — the exchange of SDP5 offers/answers and ICE6 candidates — goes through the Vite dev server proxy to the local FastAPI server.

Local mode

Deployed mode

In deployed mode, the agent runs inside a Docker container on Bedrock AgentCore Runtime, attached to a VPC via an elastic network interface (ENI) in a private subnet. The browser can’t reach the agent directly — all media traffic flows through a KVS TURN relay. Signaling goes through AgentCore’s /invocations HTTP endpoint, authenticated with SigV4 via the @aws-sdk/client-bedrock-agentcore SDK.

Voice Agent deployed on AgentCore

The following diagram from the AWS documentation shows how it works in terms of networking: signaling flows through AgentCore’s HTTP endpoint while media traffic goes through the VPC’s NAT gateway to the KVS TURN relay:

AgentCore WebRTC Architecture

The important thing to note is that the agent code is almost identical between local and deployed modes. The BidiAgent, BidiNovaSonicModel, and all four tools (recipe search, timer, nutrition lookup, unit converter) are completely unchanged. The only difference is the transport layer: in local mode, aiortc connects P2P; in deployed mode, it connects through KVS TURN. The agent detects which mode it’s in via the CONTAINER_ENV environment variable and configures ICE servers accordingly.

This clean separation was possible because of Strands’ BidiInput/BidiOutput protocol. I wrote two small adapter classes — WebRTCBidiInput and WebRTCBidiOutput — that bridge aiortc audio tracks to the event format BidiAgent expects. The agent doesn’t know or care whether audio is coming from a WebSocket or a WebRTC track.

What Bedrock AgentCore’s WebRTC support adds

On March 20th, 2026, AWS announced WebRTC support for AgentCore Runtime.

I’m not 100% sure, and am ready to stand corrected, but my impression is that the building blocks — VPC network mode, KVS TURN, the /invocations HTTP endpoint — all existed before this announcement. VPC network mode has been available since AgentCore’s general availability in October 2025. KVS TURN is a long-standing Kinesis Video Streams feature. And /invocations has always been the standard HTTP endpoint for AgentCore runtimes.

What the March 20th release adds, as far as I can tell, is official documentation, working sample code, and the explicit statement that WebRTC is a supported protocol on AgentCore Runtime. Before this, you could technically have assembled the same pieces yourself, but you’d be on your own — no docs, no samples, no guarantee it would keep working.

What AgentCore does provide is genuinely valuable: managed container hosting with auto-scaling, session isolation between concurrent users, built-in observability (CloudWatch logs, X-Ray traces), and no infrastructure to manage beyond the VPC. I didn’t have to set up ECS, configure load balancers, or manage container orchestration.

That said, there’s a fair amount of custom code involved. The WebRTC signaling (SDP exchange, ICE candidate management), the aiortc peer connection lifecycle, the audio track bridging to BidiAgent, and the KVS TURN credential management — all of that is application code that I wrote. AgentCore hosts and runs it, but doesn’t abstract it away.

Challenges and lessons learned

The migration from WebSocket to WebRTC started as a smooth ride (local mode worked on first attempt!), and was not so smooth afterwards, as I tried to get it to work on Bedrock AgentCore. Here’s what tripped me up.

VPC availability zone compatibility

AgentCore Runtime only supports specific availability zones. In us-east-1, only use1-az4 (us-east-1a), use1-az1 (us-east-1c), and use1-az2 (us-east-1d) are supported. I initially let Terraform pick the first two AZs automatically, which gave me us-east-1a and us-east-1b. The runtime update failed with a cryptic UPDATE_FAILED status. The actual error message — mentioning the unsupported AZ — was buried in the failureReason field of the API response, not surfaced in the Terraform error. I ended up hardcoding the supported AZs in my VPC module.

Session affinity

This one cost me hours. WebRTC signaling is a multi-step handshake — the browser and agent exchange several messages to establish a connection. The agent needs to remember the connection state from the first message when processing the second and third. If those messages land on different server instances, the agent has no memory of the ongoing handshake and the connection fails.

I initially used raw SigV4-signed HTTP POST requests, assuming that including the session ID as a query parameter would provide routing affinity. It didn’t. The ICE candidates were landing on a different container instance (?) than the one holding the peer connection.

The fix was to use the @aws-sdk/client-bedrock-agentcore SDK with InvokeAgentRuntimeCommand and the runtimeSessionId parameter. This is the only reliable way to ensure all requests for a WebRTC session reach the same container instance. The AWS sample code uses this pattern too — I just didn’t notice it at first because I was focused on the WebRTC parts.

SDP candidate filtering

When the agent creates a peer connection inside the VPC, aiortc generates ICE candidates for all available network interfaces — including VPC-internal IPs like 169.254.0.2. These host candidates end up in the SDP answer sent to the browser. The browser dutifully tries to connect to them, fails (because they’re unreachable from the public internet), and only then falls back to the relay candidates. This adds several seconds to the connection time.

The fix is straightforward: strip non-relay candidates from the SDP answer before returning it to the browser. In deployed mode, the only candidates that can work are TURN relay candidates, so there’s no reason to include the others.

TURN-only mode

Similar to the SDP filtering issue, the agent’s aiortc instance tries host candidates before relay candidates by default. Since host candidates use VPC-internal IPs that can never work from the browser’s perspective, this wastes time. Configuring aiortc to only use TURN relay candidates (turn_only=True) skips straight to the candidates that actually work.

Lazy KVS initialization

I initially called kvs.init() at module import time, guarded by an if IS_CONTAINER check. This worked fine locally but caused the container to crash on AgentCore. The KVS API call to find or create the signaling channel requires AWS credentials, and during container startup there can be a brief delay before the IAM role credentials are available. Moving the initialization to the first actual request (lazy init) fixed the crash.

Cold start behavior

After the container has been idle for a while, the first WebRTC connection attempt sometimes fails. The signaling requests succeed (AgentCore returns 200), but the ICE connection never completes. I suspect this is related to AgentCore spinning up a fresh container instance — the first few requests may be handled by an instance that isn’t fully warmed up. On the agent side, I explicitly set --workers 1 in the uvicorn command to ensure all requests within a container hit the same process (and therefore the same in-memory peer connection state). On the frontend, I added a retry mechanism: wait for ICE to reach “connected” state, and if it doesn’t within 10 seconds, tear down and retry with a new session ID. Together, these made the connection reliable.

Key code

I won’t walk through every file, but here are the pieces that make the WebRTC integration work.

The WebRTCBidiInput adapter reads audio frames from the aiortc track, resamples them to 16kHz, and returns them as bidi_audio_input events that BidiAgent understands:

class WebRTCBidiInput:
    def __init__(self, track):
        self._track = track

    async def __call__(self):
        try:
            frame = await self._track.recv()
        except MediaStreamError:
            raise StopAsyncIteration
        resampled = _resampler.resample(frame)
        pcm = b"".join(f.planes[0] for f in resampled)
        return {
            "type": "bidi_audio_input",
            "audio": base64.b64encode(pcm).decode("utf-8"),
            "sample_rate": 16000,
        }

The WebRTCBidiOutput adapter does the reverse — it receives events from BidiAgent and pushes audio to the aiortc output track:

class WebRTCBidiOutput:
    def __init__(self, output_track):
        self._output_track = output_track

    async def __call__(self, event):
        if event.get("type") == "bidi_audio_stream":
            audio_bytes = base64.b64decode(event["audio"])
            self._output_track.add_audio(audio_bytes)
        elif event.get("type") == "bidi_interruption":
            self._output_track.clear()

On the frontend, the useWebRTCSession hook uses the AgentCore SDK for signaling:

const invoke = async (action, data = {}) => {
  const client = new BedrockAgentCoreClient({ region, credentials });
  const resp = await client.send(new InvokeAgentRuntimeCommand({
    agentRuntimeArn,
    runtimeSessionId: sessionId,  // ensures session affinity
    contentType: 'application/json',
    payload: new TextEncoder().encode(JSON.stringify({ action, data })),
  }));
  return JSON.parse(new TextDecoder().decode(
    await resp.response.transformToByteArray()
  ));
};

The full source is in the repo — the feat/webrtc branch has the local-only version, and feat/webrtc-agentcore has the full deployed version with Terraform.

Development tooling

I built this project using Kiro CLI, Amazon’s AI development assistant. It handled the planning, code generation, debugging, and iterative deployment — including the many rounds of trial-and-error with WebRTC configuration that this post describes. The back-and-forth between writing code, deploying, checking logs, and fixing issues was a natural fit for an AI pair-programming workflow.

Try it yourself

To run locally:

git clone https://github.com/psantus/strands-bidir-nova.git
cd strands-bidir-nova
git checkout feat/webrtc
uv sync && make install-frontend
# Terminal 1:
make serve
# Terminal 2:
make serve-frontend

Open http://localhost:5173, click the microphone, and start talking.

For the deployed version on AgentCore, check out the feat/webrtc-agentcore branch and follow the README. You’ll need a Bedrock Knowledge Base with some recipes, a Cognito user pool, and Docker for building the container image. A single terraform apply handles the rest.

If you’d rather start with the WebSocket version first, Darryl Ruggles’ original post is the place to go.

Paul Santus is an independent cloud consultant at TerraCloud. He helps organizations build and deploy AI-powered applications on AWS. Connect with him on LinkedIn.

  1. WebRTC (Web Real-Time Communication) — An open standard for real-time audio, video, and data communication directly between browsers and devices, using UDP-based transport. ↩

  2. TURN (Traversal Using Relays around NAT) — A relay server that forwards media traffic when two peers can’t connect directly. Both sides send their audio to the TURN server, which relays it to the other side. ↩

  3. NAT (Network Address Translation) — A networking mechanism that maps private IP addresses to public ones. Most home routers and cloud VPCs use NAT, which prevents direct inbound connections. ↩

  4. SFU (Selective Forwarding Unit) — A media server that receives audio/video tracks from participants and selectively forwards them to others, without mixing or transcoding. Used by LiveKit, Chime SDK, Daily, etc. ↩

  5. SDP (Session Description Protocol) — A text format describing a multimedia session: codecs, transport addresses, and media types. In WebRTC, peers exchange SDP “offers” and “answers” to negotiate the connection. ↩

  6. ICE (Interactive Connectivity Establishment) — A protocol for finding the best network path between two peers. It gathers candidate addresses (local, server-reflexive, relay) and tests connectivity between them. ↩

Some problems in Looping.

1.Multiples of 3 and 5
Code:
let i=1;
while(i<50){
if(i%3==0 && i%5==0){
console.log(i);
}
i++;
}
What the code is doing:

  1. – It Start from i = 1
  2. – It Will Loop until i < 50
  3. – Check if i is divisible by both 3 and 5
  4. – If yes → Then it will be printed
  5. – Increase i every time

Screenshot of the Program:

2.Prime Number
code:
let count=0;
let num=5;
let i=1;
while(i<=num){
if(num%i==0){
count++;
}
i++
}
if(count==2){
console.log(“It is a PrimeNumber”)
}else{
console.log(“It not a PrimeNumber”)
}

What the code is doing:

  1. – Start with i = 1
  2. – Loop until i <= num
  3. – Check if num is divisible by i
  4. – If yes → increase count
  5. – Increase i every time
  6. – After loop ends:
    – If count == 2 → print Prime Number
    – Else → print Not a Prime Number

Screenshot of the Program:

3.count of digits
Code:
let num=12345;
let count=0;
while(num>0){
num= Math.floor(num/10);
count++;
}
console.log(count)

What the code is doing:

  1. – Start with num = 12345
  2. – Start with count = 0
  3. – Loop until num > 0
  4. – Inside the loop:
    – Remove the last digit of num → num = Math.floor(num / 10)
    – Increase count by 1
  5. – Repeat the loop
  6. – After the loop ends → print count

Screenshot of the Program:

🔄 GBase Database Internals: Transactions, Logging, and Service Lifecycle Explained

In production systems, executing SQL is only part of the story.

Behind every operation in GBase database, there is a full lifecycle:

  1. A transaction begins
  2. Data changes are written
  3. Logs record every step
  4. The database service ensures durability
  5. Recovery is possible if something fails

This article connects transactions + logging + service management into one complete picture.

🧠 Part 1: Transactions — The Foundation of Data Consistency

In GBase, all data operations are executed inside transactions.

Example:

BEGIN;
UPDATE users SET age = 30 WHERE id = 1;
COMMIT;

What happens:

  • Transaction starts
  • Data is modified
  • Logs capture the change
  • Commit finalizes the operation

GBase ensures ACID compliance, meaning operations are atomic and recoverable.

🧱 Part 2: Logging — How GBase Tracks Every Change

GBase uses logical and physical logs to track database activity.

🔹 Logical Logs

  • Record SQL-level operations
  • Track transactions (INSERT / UPDATE / DELETE)

🔹 Physical Logs

  • Record low-level storage changes
  • Track page modifications

You can monitor logs with:

onstat -l

This shows:

  • Log usage
  • Number of records
  • Space consumption (gbasedbt.com)

📊 Example Log Output

numrecs    Log Space used
82067      12130972

This indicates how many operations have been recorded and how much space they consume (gbase.cn)

🔄 What Happens During an UPDATE?

UPDATE users SET age = 30 WHERE id = 1;

Internally:

  1. Transaction begins
  2. Old data is logged
  3. New data is written
  4. Log entry is created
  5. Transaction commits

👉 If something fails, logs allow rollback or recovery.

⚙️ Part 3: Database Service Lifecycle

Transactions and logs rely on a running database engine.

🚀 Start Database

oninit -vy

This:

  • Allocates shared memory
  • Loads configuration
  • Initializes logs and storage
  • Starts background processes (gbase.cn)

🛑 Stop Database

onmode -ky

This:

  • Terminates sessions
  • Flushes logs to disk
  • Ensures safe shutdown (gbasedbt.com)

🔍 Check Status

onstat -

Example:

On-Line -- Up 00:00:14

Shows database is running normally (gbase.cn)

🔄 Part 4: Recovery — When Things Go Wrong

Scenario: System Crash

If the database crashes:

  1. Restart with oninit
  2. GBase reads logs
  3. Applies committed transactions
  4. Rolls back incomplete ones

During startup, GBase initializes:

  • Log/checkpoint information
  • Storage structures
  • Recovery processes (gbase.cn)

Example Error During Startup

Cannot open chunk '/path/datadbs1_1'

This indicates storage issues, but logs still help recover valid data (gbase.cn)

📊 Part 5: Monitoring Transactions and Logs

After heavy operations, monitoring is critical.

Check Logs

onstat -l

Check System Performance

onstat -p

Shows:

Check Sessions

onstat -g ses

Displays active transactions and connections (gbase.cn)

⚠️ Real-World Issues

1. Heavy Logging

Large updates:

UPDATE orders SET status = 'done';

May cause:

  • High disk I/O
  • Log space exhaustion

2. Lock Contention

Transactions can lock rows or tables:

  • Blocking other queries
  • Causing performance issues (DEV Community)

3. Startup Failures

Incorrect configuration or missing files can prevent startup.

⚡ Best Practices

✅ Use Transactions Explicitly

BEGIN;
UPDATE ...
COMMIT;

✅ Monitor Logs Regularly

onstat -l

✅ Ensure Proper Shutdown

onmode -ky

Avoid force killing processes.

✅ Watch System After Heavy Operations

onstat -p

🧠 Key Insight

GBase reliability comes from the combination of three layers:

Layer Role
Transaction Controls data changes
Logging Records every operation
Service Ensures execution & recovery

👉 You need all three to work together.

🚀 Final Thoughts

GBase is not just executing SQL—it is constantly:

  • Tracking changes through logs
  • Managing transactions
  • Maintaining system state
  • Ensuring recovery

Understanding this lifecycle helps you:

👉 Debug issues faster
👉 Prevent data loss
👉 Optimize performance

💬 Key Takeaways

  • Every data change is logged
  • Transactions ensure consistency
  • Logs enable recovery
  • Service lifecycle controls everything

🔥 What to Try Next

  • Run an UPDATE and observe logs with onstat -l
  • Restart the database and observe recovery
  • Simulate rollback scenarios

If you want, I can next generate:

  • 🧪 A hands-on lab (simulate crash + recovery step-by-step)
  • 🔍 A deep dive into GBase logging internals
  • ⚡ Or a performance tuning guide for transactions

Porting Test Drive II from SNES to PC, Part 36: Closing the SNES organic default-rival handoff path

SNES Select Opponent BG1 surface

Porting Test Drive II from SNES to PC, Part 36: Closing the SNES organic default-rival handoff path

The last front-end checkpoint proved two things separately:

  • the downstream Select Opponent screen is a real 2x2 surface on $1C70
  • the bottom-right clock slot already changes handoff fields at L008B87

What was still missing was the live corridor.

Did the default rival path actually reach the gameplay callback family
organically, or were we only seeing that by forcing callbacks in headless
tests?

That gap is now closed.

The useful distinction was no longer menu meaning

By the previous checkpoint, the menu semantics were already narrow:

  • $1C70 = 0..2 means rival-enabled handoff
  • $1C70 = 3 means no-opponent handoff

The remaining uncertainty was runtime reachability.

So this checkpoint stopped treating the problem as a UI question and treated it
as a timing question instead.

The fourth slot is still important, but now as an input-timing target, not as
an unresolved visual.

SNES Select Opponent BG1 surface

SNES downstream selection-box row 0x1D

Two no-force probes now recover the same live corridor

I ran two timed-input boot probes without pinning active_main.

Both runs recover the same organic path:

L00C20B -> 01:C1D2 -> L00BE76 -> L008B87 -> 01:902D

and then, later:

active_main = 02:9016

The two runs land at slightly different frame numbers, but they agree on the
important part:

  • the Select Opponent loop really does fall through into the known gameplay
    callback family
  • the default top-left rival slot is enough to reach that corridor
  • this no longer depends on forced callback injection

What the probes actually proved

Run v1b reaches:

  • L00C20B at frame 1562
  • first 01:C1D2 at frame 1577
  • L00BE76 at frame 1616
  • L008B87 at frame 1706
  • 01:902D at frame 1857
  • first active_main = 02:9016 at frame 2014

Run v2 reaches:

  • L00C20B at frame 1584
  • first 01:C1D2 at frame 1599
  • L00BE76 at frame 1646
  • L008B87 at frame 1736
  • 01:902D at frame 1887
  • first active_main = 02:9016 at frame 2044

In both runs:

  • $1C70 stays 0
  • $1C76 flips from 0 to 1 only after L008B87

So these are not clock-slot runs.

They are the recovered default-rival baseline.

The remaining blocker is narrower now

This is the real value of the checkpoint:

the blocker is no longer “can this front-end path reach gameplay organically?”

That answer is now yes.

The blocker is now:

can I move the input to the bottom-right clock slot after 01:C1D2 is
already live, so $1C70 actually leaves 0 before the same corridor falls
through L008B87?

That is a much better next target.

The next probe is no longer generic.

It is specific:

  • inject right+down only inside the live 01:C1D2 window
  • keep the already recovered default-rival run as the control case
  • compare the later $1C76 = 0 path against the now-closed $1C76 = 1
    baseline