Why Logs Aren’t Enough to Debug AI Agents

Most teams start debugging AI agents the same way they debug normal software: logs.

That works until the failure is not a single exception.

AI agents fail across decisions:

  • the model picked the wrong tool
  • the tool returned ambiguous data
  • the agent ignored relevant context
  • a retry changed the path
  • the final answer looked correct, but came from the wrong chain of decisions

A log line can tell you what happened.
It rarely tells you why the agent chose that path.

That difference matters a lot once agents start doing real work.

The problem with agent logs

Traditional logs are linear.

A request comes in. Your system calls a service. A response comes back. Something succeeds or fails.

For normal backend systems, that is often enough. If a database query times out, a log line can point you to the query. If an API returns a 500, the log can tell you which call failed.

AI agents are different.

An agent run is not just a sequence of function calls. It is a decision process:

  1. read the task
  2. inspect context
  3. choose a next action
  4. call a tool
  5. interpret the result
  6. update the plan
  7. decide what to do next

The failure may not be in the tool call itself. It may be in the decision that led to the tool call.

That is where logs start to break down.

Example: a tool calling failure

Imagine an agent that should look up a customer invoice.

The user says:

Can you check whether ACME has paid invoice INV-1042?

The agent calls the CRM search tool and gets no result.

The logs show something like this:

crm.search_customer({ "name": "ACME" })
→ []

final_answer: "I couldn't find an invoice for ACME."

From the logs, this looks straightforward. The CRM returned no result, so the agent answered that no invoice was found.

But the real problem might be somewhere else:

  • the agent searched by customer name instead of invoice ID
  • the CRM tool expected an account ID, not a name
  • the invoice number was available in context but ignored
  • a previous tool returned partial data and the agent misread it
  • a retry changed the search parameter
  • the agent failed to call the billing tool after the CRM returned empty

The failure is not simply “CRM returned empty.”

The failure is: why did the agent decide that CRM search by customer name was the right next action?

A log line usually cannot answer that.

What engineers need to know

When an agent fails, the useful debugging questions are different from normal software debugging.

You need to know:

  • What did the agent know at this step?
  • What options did it have?
  • Which tool did it choose?
  • Why did it choose that tool?
  • What did the tool return?
  • How did the agent interpret the result?
  • Where did the first bad decision happen?

Logs usually capture pieces of this, but not the decision context around it.

That is why teams end up doing log archaeology: reading prompts, tool inputs, tool outputs, retries, traces, and final answers separately, trying to reconstruct the run after the fact.

More logs are not the same as better debugging

A common reaction is to add more logging.

Log the prompt. Log the tool input. Log the tool output. Log the final answer. Log token counts. Log latency. Log cost.

All of that is useful.

But it still leaves a gap: logs are observations, not explanations.

If an agent calls the wrong tool, the log can show the wrong tool call. It does not automatically show why the agent thought that was correct.

If an agent ignores context, the log can show the prompt contained the context. It does not show which part of the context the agent used or skipped.

If an agent succeeds after a retry, the log can show the retry. It does not always show how the retry changed the path.

For agents, the key unit of debugging is not just the event. It is the decision.

What better agent debugging looks like

For production agents, useful debugging needs more than flat logs.

It needs a replayable structure of the run:

  • the goal the agent received
  • the context available at each step
  • every decision point
  • every tool call and result
  • retries and alternate paths
  • the final answer
  • the first point where behavior diverged from what was expected

This is closer to replaying a session than reading a log file.

A good agent debugger should let you inspect a failed run step by step and answer:

At this exact moment, why did the agent do this?

That is the question that matters.

Decision graphs instead of timelines

Many observability tools show agent activity as a timeline.

Timelines are helpful, but agents are not always best understood as timelines. They are better understood as decision graphs.

A timeline tells you:

Step 1 → Step 2 → Step 3 → Step 4

A decision graph tells you:

The agent had these options.
It chose this one.
That choice led to this tool call.
The result changed the next decision.
This is where the run went wrong.

That structure is much more useful when you are trying to debug behavior instead of infrastructure.

Logs are still necessary

None of this means logs are bad.

You still need logs for:

  • errors
  • latency
  • cost
  • request volume
  • tool availability
  • API failures
  • security audits

Logs are part of the debugging picture.

They are just not the whole picture.

For AI agents, logs tell you what happened. Replay and decision context tell you why it happened.

A practical checklist

If you are building agents, ask whether you can answer these questions for a failed run:

  • Can you replay the exact run?
  • Can you see each tool input and output?
  • Can you inspect what context was available before each decision?
  • Can you identify the first bad decision, not just the final bad answer?
  • Can you compare a failed run to a successful one?
  • Can you explain why the agent chose a specific tool or path?

If the answer is no, more log lines probably will not solve the problem.

You need decision-level debugging.

Closing thought

AI agents introduce a new debugging problem.

The hard part is not always knowing whether a tool failed. The hard part is understanding why the agent chose that tool, how it interpreted the result, and where the reasoning path first went wrong.

That requires moving beyond flat logs toward replayable traces and decision graphs.

If you are working on production agents and have felt this pain, Opswald is building around exactly that problem: making agent runs easier to replay, inspect, and explain.

https://www.opswald.com/

The Solo Developer’s Secret: Why Boring Tech is More Profitable

Every morning, I see the same thing on my Twitter feed or the GitHub Trending page. A new Ruby gem is released that promises to “revolutionize” how we handle state, or a new “minimalist” alternative to a standard Rails tool that has 500 stars in its first week.

As developers, we love new things. We love “cleaner” syntax and smaller dependencies. But after building and maintaining Rails apps for years, I’ve stopped chasing the hype.

In 2026, my Gemfile is intentionally boring. I prefer gems that have been around for a decade over the latest shiny trends. Here is why boring tech is actually a solo developer’s greatest competitive advantage.

1. The “Abandoned Gem” Trap

When you use a brand-new gem that just trended on GitHub, you are taking a massive risk.

The maintainer might be excited today, but what happens in six months when they get a new job or lose interest in the project? If that gem breaks when Rails 8.1 comes out, you are the one who has to fix the source code or find a way to migrate away from it.

“Boring” gems like Devise, Sidekiq, or Kaminari have survived multiple Rails version upgrades. They have a team of maintainers and a massive community ensuring they don’t die. For a solo developer, “stability” is a feature that is more important than “cool syntax.”

2. AI and Documentation Edge

In the era of Vibe Coding and AI assistants like Cursor or ChatGPT, boring gems have a huge advantage: Training Data.

AI models have read millions of lines of code involving the “standard” gems.

  • If I ask an AI to “Add a password reset flow using Devise,” it gets it right 100% of the time.
  • If I use a shiny new auth library released three months ago, the AI will hallucinate and give me broken code because it hasn’t seen enough examples yet.

By using the most popular gems, you make your AI assistant 10x more effective. You can find the answer to any bug on StackOverflow in 30 seconds. With shiny new gems, you are the one writing the first StackOverflow question.

3. The “Standard” Rails Way (Omakase)

Rails is built on the idea of Convention over Configuration. When you stick to the “Boring” defaults, your app stays standard.

If I hire a freelancer or bring in a partner to help me with a project, they already know how Devise works. They already know how to check a Sidekiq queue. If I have replaced every standard tool with a “shiny” alternative, I have to spend three days teaching them my unique, custom stack.

4. Don’t Waste Your “Innovation Tokens”

There is a great concept in engineering called Innovation Tokens. You only get a few of them for each project.

If you spend your tokens on a “revolutionary new way to handle database IDs” and a “bleeding-edge frontend reactive library,” you have no tokens left to spend on the Actual Business Logic that makes your app unique.

I save my innovation for the features that my customers pay for. I don’t want to innovate on “how to upload a file” or “how to paginate a list.” I want those things to be as boring and invisible as possible.

Summary: My “Boring” Checklist

Before I add a new gem to my project, I check three things:

  1. Age: Has it been maintained for at least 2 or 3 years?
  2. Usage: Does it have millions of downloads on RubyGems?
  3. Recent Activity: Was there a commit or a release in the last 6 months?

If the answer to any of these is “No,” I usually stick to the standard Rails default, even if the new gem looks “fancier.”

Boring gems allow you to focus on shipping products instead of fixing your tools. In the world of indie hacking, the developer who ships the most wins – and boring tech helps you ship.

AI Daily Digest: May 21, 2026 — Agentic Workflows, Coding Agents & Embodied AI

Cover

Cover

title: “AI Daily Digest: May 21, 2026 — Agentic Workflows, Coding Agents & Embodied AI”
published: false
tags: ai, machinelearning, programming, agents
cover_image: https://files.catbox.moe/y0zqnv.png

description: “Cursor vs Windsurf 2026, LangGraph + MCP, OpenCode 160K+ stars, Pelican-Unified 1.0, Claude Code Opus 4.7, and Embodied AI at SAE World Congress. Curated by an AI Architect.”

5-min read · Curated daily by an AI Systems Architect

Focus: Agentic Workflows · AI Coding Tools · Embodied Intelligence

1. Cursor vs Windsurf 2026 — The AI IDE Bake-Off

【Technical Core】

Cursor 3.1 ships async sub-agents that refactor across multiple files in parallel, while Windsurf 2.0 (post-Cognition acquisition) counters with Agent Command Center — a Kanban-style UI for managing persistent cloud agents that survive local shutdown. Both fork VS Code, both ship Claude Sonnet 4.5 and GPT-5 at the $15–20/mo entry tier. Cursor’s differentiator: deepest project-level context understanding; Windsurf’s: speed and multi-IDE coverage (VS Code + JetBrains + Zed plugins).

【Why It Matters】

If you’re choosing an AI IDE in 2026, the decision is no longer “which has better autocomplete.” It’s: which agent runtime matches your team’s workflow? Cursor = agentic refactor specialist. Windsurf = fast, cloud-persistent generalist. Pick by use case, not marketing.

🔗 Cursor vs Windsurf 2026 — GPTPrompts.ai

2. LangGraph + MCP — Production Multi-Agent Workflows in 2026

【Technical Core】

LangGraph’s updated 2026 guide shows how to wire a supervisor agent that routes tasks between specialist workers (research agent ↔ code agent), each calling tools via MCP (Model Context Protocol v1.4 RC). Key architecture: StateGraph with AsyncPostgresSaver for persistent checkpoints, with_structured_output(Route) for Pydantic-validated routing (no fragile string parsing), and SSE/streamable-HTTP transport to MCP servers. The supervisor never executes tools directly — it only routes.

【Why It Matters】

The LangGraph + MCP combo is becoming the default production stack for multi-agent systems. If you’re building agentic workflows in 2026 without MCP integration, you’re accumulating technical debt. The v1.4 protocol changelog (April 2026) introduces breaking changes — read it before upgrading.

🔗 LangGraph + MCP Guide — TechBytes

3. OpenCode Hits 160K+ GitHub Stars — The Open-Source Coding Agent Alternative

【Technical Core】

OpenCode (MIT, anomalyco/opencode) crossed 160K GitHub stars in May 2026, with 7.5M monthly active developers and 900+ contributors. v1.3.3 highlights: event-sourced session sync (SQLite-backed, replacing plain-text storage), TUI Mission Control for multi-session management, and native MCP integration. The Zen model tier curates models pre-benchmarked for coding tasks. GitHub Copilot subscribers can authenticate into OpenCode at no extra cost (partnership announced Jan 2026).

【Why It Matters】

OpenCode is the first open-source coding agent to achieve critical mass while staying truly model-agnostic (75+ LLM providers, including local models). For teams avoiding vendor lock-in to Cursor/Windsurf, this is now a legitimate production-grade alternative. The Copilot integration is a massive distribution unlock.

🔗 OpenCode Official Site · Deep Dive — sanj.dev

4. Pelican-Unified 1.0 — The First Truly Unified Embodied AI Model

【Technical Core】

Pelican-Unified 1.0 (arXiv:2605.15153, May 14, 2026) is the first embodied foundation model trained under a strict unification principle: a single VLM serves as the unified understanding module, autoregressively generating task-oriented, action-oriented, and future-oriented chains of thought in one forward pass. The Unified Future Generator (UFG) then jointly denoises future video and future actions via dual modality-specific output heads. One checkpoint. No pipeline glue code.

【Why It Matters】

This breaks the modular paradigm (perception → planning → action as separate expert systems). A single checkpoint achieving #1 on WorldArena (66.03) and 93.5 on RoboTwin proves unification doesn’t require compromising specialist performance. For robotics developers, this is a massive simplification.

🔗 arXiv:2605.15153

5. Claude Code Opus 4.7 — 87.6% on SWE-bench Verified

【Technical Core】

Anthropic shipped Opus 4.7 in April 2026, pushing SWE-bench Verified from 80.8% to 87.6% — a landmark for coding agents. Architecture updates: 1M token context (200K default for tools), 3.75MP visual resolution (up from 1.15MP), and a new xhigh effort tier between high and max. Task Budgets let the model autonomously allocate token budgets across sub-tasks. Background Agents execute in isolated Git worktrees. Agent Teams (research preview) enables multi-agent collaboration with role specialization.

【Why It Matters】

87.6% on SWE-bench Verified means Claude Code can now resolve the majority of real-world GitHub issues autonomously. The new tokenizer does produce ~35% more tokens for identical input — a cost warning worth heeding. Still, this is the new state of the art for coding agents.

🔗 Anthropic Opus 4.7 Announcement

6. Embodied AI in Action — SAE World Congress 2026 Panel Insights

【Technical Core】

White paper from SAE World Congress 2026 (arXiv:2605.10653) summarizes the “Embodied AI in Action” panel with experts from automotive, robotics, and AI. Key technical theme: the integration of large language model agents with Robot Operating System (ROS) frameworks is moving from research demo to production consideration. The panel identifies simulation-to-real transfer and real-time latency as the two primary blockers to production deployment.

【Why It Matters】

This is a signal that embodied AI is crossing from academic curiosity to industrial engineering concern. If you’re working on LLM-to-robotics pipelines, the companion Nature paper (doi:10.1038/s42256-026-01186-z) — which demonstrates a complete LLM-agent-to-ROS framework — is the reference architecture to study.

🔗 arXiv:2605.10653 · Nature Article

7. Windsurf 2.0 + Devin Cloud — Cloud Agents That Outlive Your Laptop

【Technical Core】

Acquired by Cognition (Devin’s maker) in April 2026, Windsurf 2.0 introduced Agent Command Center (Kanban-style agent state management) and Spaces (bundle agent sessions, PRs, files, and context into a task unit that survives session restarts). The headline feature: Devin Cloud one-click deploy — plan locally, dispatch to cloud Devin, and the agent keeps running after you close your laptop. Default model upgraded to in-house SWE-1.5.

【Why It Matters】

The “cloud agent that survives local shutdown” pattern is new and powerful. For long-running refactors or multi-repo migrations, this changes the ergonomics fundamentally. Note: the original founding team has joined Google, so long-term product roadmap has some uncertainty. Pro plan is $20/mo; a $200/mo Max tier is available.

🔗 Windsurf vs Cursor 2026 — GPTPrompts.ai

Curated by an AI Systems Architect · May 21, 2026

Next digest: tomorrow 07:00 JST

When boto3 doesn’t have it (yet), you write it: a realtime speech-to-speech story in Python

Architecture

Architecture

At a meetup’s networking session, someone dropped: “the new speech-to-speech feature in Teams is really cool”. Microsoft Teams added the interpreter agent with realtime AI-powered speech-to-speech translation during calls. So the natural question: how complicated is building one with AWS ? And what performance does it deliver ?

Meanwhile, for PyCon IT 2026, with an inclusivity goal, the plan was already to use bilardi/realtime-transcription with a monitor in the room showing the talk transcript. But wouldn’t it be handier if each attendee had the translated transcript directly on their own mobile, and maybe the audio in their own language too, naturally without installing anything ?

And so bilardi/realtime-speech-to-speech was born, ready to use, for any conference or meetup. Under the hood there are three AWS services chained together: Transcribe Streaming for Automatic Speech Recognition (ASR) from audio to text, Translate for the translation, Polly bidirectional streaming for Text-to-Speech (TTS) from text to audio. Architecture, costs and usage live in the repo: here, instead, I tell the choices and what went sideways along the way.

A stage PoC for multilingual meetups

The initial alternatives were three, from the simplest to the most complex.

Option When it makes sense Effort
One-way PoC, 1 speaker language → 1 listener language Minimal validation of the AWS pipeline Headphones to keep the mic from recapturing the TTS
Bidirectional 1:1 conversation International meeting between two people Two symmetric pipelines + a second device to test
1-to-many conference (fan-out), multilingual Talks and meetups with international audience Browser audio playback + N parallel pipelines under contention

I started from the 1:1 one-way PoC to validate the AWS pipeline and the new piece (Polly bidirectional streaming plus the browser audio playback), and from there moved on to 1-to-many, which is the real scenario for a conference or a meetup. Direction and language pair stay as two environment variables: changing scenario becomes editing two lines in .env, no refactor.

Listener client: the browser. Mobile has it without installing anything, and opening a URL is the simplest UX for the “PC speaks, mobile listens” test. A native app isn’t worth it even in production for this use case, let alone for a PoC: targets to maintain, stores to publish to, zero advantages over a page opened from a QR code.

Why not Nova 2 Sonic ?

AWS recently announced Amazon Nova 2 Sonic: an end-to-end speech-to-speech model, ASR plus LLM plus TTS in a single bidirectional connection. Obligatory question: why not Nova Sonic, then ?

Nova Sonic is designed to respond to an audio: conversational assistant, human-AI dialogue, turn-taking, managed interruptions. The use case here is the opposite: a transmission to multiple listeners, a different language for each (multilingual broadcast), with faithful translation. For example, Italian audio as input, the same sentence as audio in N different languages as output, across N parallel channels. They are two different products: the fact that both go by “speech-to-speech” is a marketing collision.

Mapping the current three stages against Nova Sonic:

Current stage Function Nova 2 Sonic covers ? Same guarantee ?
Transcribe Streaming ASR audio to text Yes, integrated Plausible, but I haven’t tested
Translate Deterministic Neural Machine Translation (NMT) Yes, via prompting No, not deterministic
Polly Generative TTS reading quality Yes, conversational voices No, dialogue intonation

The three critical points, from most to least blocking:

  • Translate: an NMT trained for faithful, deterministic translation. Nova Sonic would do translation via LLM prompting: more fluent but not deterministic, may paraphrase or add conversational fillers. Unacceptable for a broadcast where the audience expects exactly what the speaker says
  • Polly Generative: voices optimized for reading a given text. Nova Sonic has voices optimized for dialogue, intonation that adapts to the user’s voice input. For reading a translation it’s the wrong voice
  • Transcribe: replaceable in principle, but Nova Sonic doesn’t expose ASR as a standalone service billed separately

Operational constraints independent of quality: 8-minute connection limit against Transcribe Streaming’s 4 hours, and Nova requires a separate session per target language (the current pipeline calls Transcribe once for N languages).

Decision: pipeline with three specialized services. Nova 2 Sonic stays the natural candidate for a different scenario, where the listener asks the AI a question and the AI answers, not for a meetup with a human speaker and a passive audience.

Here is the stack

As a lazy developer, the first thing I looked for is reusable pieces. realtime-transcription already has the audio_client/ module to capture Pulse-Code Modulation (PCM) audio from a device and the FastAPI WebSocket scaffold: cherry-pick roughly 140 lines and you’re off. The browser display, instead, is from scratch, because audio playback is a different beast from text display.

The server-side pipeline is simple and linear: Transcribe streaming → Translate one-shot → Polly bidirectional. Transcribe can deliver a partial text (is_partial=True) on faster timing, but it might be wrong and so cancelled and rewritten: the goal is validating the chain end to end, not shaving milliseconds of latency. Everything therefore starts from Transcribe once it has recognized a complete sentence (is_partial=False): at that point Translate fires with a single call per sentence, and the translated text goes to Polly bidirectional, which begins returning audio while it’s still generating the rest.

For the audio format the options were compressed MP3 and raw PCM. MP3 uses ~4 times less bandwidth, but the browser has to decode it asynchronously for each chunk (decodeAudioData), breaking the continuity of the playback queue. PCM (16-bit signed LE, 16 kHz mono) weighs more on bandwidth but the browser writes it straight into a Web Audio API AudioBuffer: no intermediate decoding, linear queue. On LAN or local WiFi bandwidth isn’t the constraint, latency is: I picked PCM. On top of that, 16 kHz mono matches the sample rate of the microphone and of Transcribe: no format conversion in the middle of the pipeline. In the cloud, where the audio going out from the server to each listener is data transfer out (AWS egress, billed), PCM might blow past the 100GB / month free tier, which is ~35h with 25 listeners.

To pick a Polly voice in the target language, there were two paths. A hardcoded (language) → (voice id): simple but it breaks every time AWS publishes new voices. The other calls DescribeVoices at server boot and discovers dynamically what’s available, with an in-memory cache. I picked the second: one API call at startup, zero maintenance when AWS adds voices. To stay compatible with bidirectional streaming I filtered by LanguageCode (the target language) and by voices that support it: the feature is recent (2026) and not every language covers it, so without the filter synthesis would fail at the first start_speech_synthesis_stream.

The truly new piece is precisely StartSpeechSynthesisStream, the Amazon Polly bidirectional API. Announced in March 2026, exposed in the Java SDK, and missing in boto3. The feature shows up in the Java SDK because its code generator reads service-2.json and supports the HTTP/2 bidirectional event-stream protocol. Under boto3 there’s botocore, and even botocore doesn’t have that infrastructure: the operation stays declared in the service model but the Python client doesn’t expose it. Same scenario for aioboto3, the asynchronous version of boto3, which reuses the same service models. Verified on boto3 1.43.9.

So, what paths are available ?

Path Pros Cons
synthesize_speech sync Already in the SDK, 5 lines No fast first-byte: waits until Polly has generated all the audio before returning any byte
HTTP/2 raw + SigV4 + event-stream parser Real bidirectional, first audio chunk arriving while Polly is still generating Not in Python: needs to be written from scratch

Decision: the sync one first to validate the pipeline, then the bidirectional one.

And here begins the piece that became a package of its own: amazon-polly-streaming. A PR to boto3 would have been the first reflex, but boto3 doesn’t have the HTTP/2 bidirectional event-stream infrastructure. For Transcribe streaming AWS kept it out of boto3 in a separate package under awslabs: first in amazon-transcribe-streaming-sdk (deprecated today) that delegates the HTTP/2 transport to awscrt, then in aws-sdk-transcribe-streaming (the successor) that delegates the event-stream too to smithy_aws_core. For Polly bidirectional an official equivalent doesn’t exist yet (verified in May 2026, neither on awslabs nor on PyPI), so amazon-polly-streaming is the first public Python implementation of the feature.

The public API is PollyStreamingClient.start_speech_synthesis_stream(), a mirror of TranscribeStreamingClient.start_stream_transcription() from aws-sdk-transcribe-streaming. Same pattern as the official AWS package for Transcribe: a convention that lets future adoption by awslabs happen without redesigning the API. Same for exceptions: a separate module that mirrors the types Polly exposes in StartSpeechSynthesisStream.

And why not delegate the HTTP/2 bidirectional event-stream to smithy_aws_core[eventstream], the way aws-sdk-transcribe-streaming does ? The bulk of the package would stay uncovered: AWS hasn’t published a smithy client for Polly bidirectional. Since that client doesn’t exist, it’s simpler to keep the protocol in-house too: one fewer dependency, and no need to sync amazon-polly-streaming‘s cycles with those of an external lib under active development.

The stories the README doesn’t tell

That ServiceFailureException that says nothing

I started from the AWS documentation for StartSpeechSynthesisStream: it lists the parameters (Engine, LanguageCode, VoiceId, OutputFormat, ..) and the event types (TextEvent, CloseStreamEvent, AudioEvent), but doesn’t explain how to package the bidirectional event-stream body. The first attempt was therefore naive: I built a single event-stream body with TextEvent followed by CloseStreamEvent, signed it with SigV4 in its standard form (HTTP_REQUEST_HEADERS headers and EMPTY_SHA256 payload), and sent it in one shot. AWS Polly’s response:

ServiceFailureException: Service is unavailable

That’s it. No “this header is missing”, no “the body isn’t the type I expect”, nothing that lets you figure out what’s wrong. Always the same response across every combination I tried. Pushing harder on the Polly endpoint by tweaking parameters was therefore pointless: the contract had to be found elsewhere.

I checked botocore’s service-2.json file (the same file is in the Java SDK, but only the latter implements it in a client): it’s the canonical declaration of the AWS contract, committed to the repos as input for the code generator. For Polly it declares protocol: "rest-json" with protocolSettings: { h2: "eventstream" } and an ActionStream payload of type eventstream. It’s the same protocol Transcribe Streaming uses for start-stream-transcription, and for Transcribe a public Python implementation already exists: amazon-transcribe-streaming-sdk (Apache 2.0, awslabs). I read the transcribe-sdk and ported its signing logic to amazon-polly-streaming, adapting it to Polly.

What I learned (the hard way):

  • AWS errors like ServiceFailureException don’t say what went wrong: a design choice. For AWS services not yet in boto3, you have to go straight to the service-2.json file (in botocore or in the Java SDK, they are identical): faster than debugging parameter by parameter
  • smithy_aws_core[eventstream] is today the most complete Python reference for the generic part of the AWS HTTP/2 bidirectional event-stream; the event types (for Polly: TextEvent, CloseStreamEvent, AudioEvent) aren’t there, whoever builds the client writes them (in this case the Polly client)
  • the Java SDK v2 client code is generated automatically at build time from service-2.json, it isn’t committed in the repo: searching the method name (e.g. startSpeechSynthesisStream) in the source returns only changelogs and the service model, not the real signatures. For the protocol contract, service-2.json stays the canonical source (both in the Java SDK and in botocore)

That pool that worked solo

An HTTPS call to AWS has a cost: before exchanging the first byte of data, there’s the TLS handshake and the HTTP/2 setup. A connection pool removes that cost for every call after the first: open once, reuse N times. On a pipeline that calls Polly bidirectional once per finalized sentence it’s an immediate win: ~50 ms less median per call, from the second call onwards.

I added the HTTP/2 pool in amazon-polly-streaming v0.2.0 with use_pool=True as the default, and on a single listener it worked fine ..

Then I implemented the multilingual broadcast fan-out: 1 speaker to N listeners, each with its own target language. The test with 2 listeners (en-US and de-DE), 5 sentences per 2 target languages: I expected 10 calls to Polly. Instead half of the calls emitted no audio. Alternating pattern: in the same execution one language always “won” and the other always “lost”, but across different executions the role flipped. So it wasn’t language-specific, it was specific to the second parallel task of the fan-out iteration (a for target in targets: over an unordered set).

Pool 0.2.0

Diagnosis: pool v0.2.0 kept one and only HttpClientConnection per (host, port) pair. Under fan-out, two near-simultaneous calls asked the pool for a connection to Polly: the first opened one from scratch, the second received the same connection already open. Both opened a new HTTP/2 stream on the same connection. But Polly bidirectional enforces “1 stream = 1 sentence” and the Polly endpoint accepts only one active bidirectional stream at a time: what I observed was that awscrt queued the second stream until the first one closed. Under fan-out the queue never drained: before the first one finished, the next sentence arrived. From here two moves: one immediate and one structural.

As a lazy developer, the workaround first: POLLY_USE_POOL=false so every call opened a fresh connection and every call produced audio. Cost: the ~50 ms gained earlier from the pool were lost on every call. The refactor of _ConnectionPool with lease semantics was needed: amazon-polly-streaming v0.3.0 creates a list of connections per (host, port) instead of a single one, so every fan-out task leases a distinct connection (opened cold the first time, reused after that).

Pool 0.3.0

Improvement table across iterations. The polly_first_byte_ms metric measures the time between when Translate returns the translated text and the first audio byte arriving from Polly: TLS plus HTTP/2 setup plus Polly’s start-up latency. It’s not the end-to-end latency perceived by the listener (which also includes server-to-browser forwarding).

Scenario Median polly_first_byte_ms warm
Single listener, no pool ~370 ms
Single listener, with pool (v0.2.0) ~331 ms
Fan-out 1-to-2, workaround without pool (v0.2.0) ~373 ms
Fan-out 1-to-2, with fixed pool (v0.3.0) ~306 ms

The fixed pool in v0.3.0 beats every previous measurement: ~25 ms less median compared to the single-listener pool in v0.2.0. This extra delta comes from pipeline optimizations accumulated across iterations, orthogonal to the pool but that show up in the final result.

That WAF that, thankfully, isn’t needed

At the first deploy on EC2 via aws-docker-host, with a public ALB at https://sts.workshop.pandle.net, uvicorn’s logs filled up within minutes of the apply with:

POST /hello.world?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input   404
GET  /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php                            404
GET  /vendor/phpunit/Util/PHP/eval-stdin.php                                        404
GET  /phpunit/phpunit/src/Util/PHP/eval-stdin.php                                   404

Tens of requests per minute: no problem, FastAPI answers 404 to all of them. The real risk is different: a targeted bot connects to /ws/speak or /ws/listen and fires Transcribe plus Translate plus Polly at the expense of the AWS account owner. The figure is low per single call but scales linearly with the number of malicious connections.

So, how do you defend yourself ?

Option Pros Cons
IP allowlist on the ALB security group Granular The audience IPs at the talk are not known in advance
AWS WAF with rules on scanner patterns Blocks the known noise (scanner UA, PHP paths) Doesn’t block “competent” abuse (bot with browser UA, correct path), and costs 5-10 € / month
Single shared token Simple to implement The QR code reaches tens of people, to be treated as a secret
Double token per role Exposure asymmetry 15 extra lines of code

Decision: double token. SPEAKER_TOKEN protects /ws/speak (the cost driver: Transcribe plus Translate plus Polly for N languages), LISTENER_TOKEN protects /ws/listen (the distribution path via QR code). Independent: the listener token doesn’t work for the speaker, and vice versa. If the QR code leaks (photos on social, screenshots, shares), the damage is limited to “anyone can listen”, not “anyone can spend the AWS owner’s money”. The SPEAKER_TOKEN stays in the shell history and in the .env of the deploy.

The design stays minimal at every level. Locally, with no tokens set, authentication is off and nothing changes. The architecture adds no complications: no cookies, no login form, no OAuth, just a string comparison at each connection. And the code fits in a few lines on the server, a flag on the audio client, a URL parameter for the browsers, a few sample environment variables. Good enough for a PoC with frequent token rotation between events.

What else could be added ?

Signed JWTs in place of static tokens: for prolonged use (always-on service, multiple events) JWTs with TTL per role. If the internet exposure becomes continuous, manually rotating the two static tokens gets tiring.

Subtitles sync: the translated text arrives at the browser as a JSON message before the audio, so it’s already on screen when the audio starts. A precise text-to-audio sync (word-by-word highlight) is the next step for accessibility. Polly exposes SpeechMark exactly for this in the sync synthesize; for the bidirectional one they need to be checked in service-2.json.

Pause-based hybrid transcription pipeline: to cut the perceived latency between “I’m done speaking” and “first audio byte”, the pipeline needs to fire even when a Transcribe partial has been still for N milliseconds, not only when is_partial=False arrives. Worth it only if you really want to optimize timing to the millisecond: the current sentence-bounded handling is enough, and implementing it requires a cancellation logic that’s anything but trivial, because when Transcribe corrects a partial, the pipeline may already have fired translation and synthesis: you have to decide whether to let them finish, cancel them, or replace them.

Adoption of amazon-polly-streaming by awslabs: today it’s the first public Python implementation of Polly bidirectional. The concrete path is a PR to aws-sdk-python to publish aws-sdk-polly-streaming (sibling of aws-sdk-transcribe-streaming), built on top of the generic primitives of smithy_aws_core[eventstream]. When that client exists, amazon-polly-streaming can be considered deprecated.

Zero-Trust RAG: Defeating the Shared Private Link Deadlock in Azure Terraform

Your Terraform pipeline is green. The deployment completes without errors. You grab a coffee.

Ten minutes later, you test your new Enterprise RAG application. It throws a 403 Forbidden. You open the Azure Portal, check the OpenAI Networking tab, and there it is: your Shared Private Link from AI Search is sitting in Pending.

Nobody told Terraform to approve it. Nobody told you it even needed approving.

This is the CI/CD killer of Azure AI infrastructure.

Why It Happens

AI Search must call OpenAI to vectorize data. The azurerm provider can successfully request this Shared Private Link — but it cannot approve its own request. The target resource (OpenAI) must explicitly accept the inbound connection. The standard provider has no method for this. Pipeline deadlocked. Someone has to click “Approve” in the Portal manually. ClickOps in 2026.

The AzAPI State Machine Fix

We bypass the azurerm provider entirely and talk directly to the Azure Resource Manager REST API using the azapi provider.

Because Azure generates a random GUID for the incoming connection, we can’t hardcode the ID — we read it at runtime:

data "azapi_resource_list" "pe_connections" {
  type                   = "Microsoft.CognitiveServices/accounts/privateEndpointConnections@2023-05-01"
  parent_id              = azurerm_cognitive_account.openai.id
  response_export_values = ["value"]

  depends_on = [azurerm_search_shared_private_link_service.openai_link]
}

The depends_on is critical — without it, Terraform queries the connection list before the link is even requested, returns empty, and the approval silently fails.

Then we filter for the Pending connection and approve it:

resource "azapi_update_resource" "approve_shared_link" {
  type = "Microsoft.CognitiveServices/accounts/privateEndpointConnections@2023-05-01"

  resource_id = try(
    [for conn in jsondecode(data.azapi_resource_list.pe_connections.output).value :
      conn.id
      if conn.properties.privateLinkServiceConnectionState.status == "Pending"
    ][0],
    ""
  )

  body = jsonencode({
    properties = {
      privateLinkServiceConnectionState = {
        status      = "Approved"
        description = "Approved via Terraform AzAPI Pipeline"
      }
    }
  })
}

The try() wrapper is not optional. On terraform destroy, the Shared Private Link is deleted before this resource is evaluated. Without try(), indexing [0] on an empty array crashes the destroy run and leaves orphaned resources in Azure.

After terraform apply, the link goes from Pending to Approved in under 30 seconds. No Portal access required.

Identity Chaining — Kill the API Keys

Auto-approving the link lets AI Search reach OpenAI. But static API keys will fail your compliance audit. Keys leak. Keys get committed to Git.

Disable local auth and use a System Managed Identity instead:

resource "azurerm_search_service" "search" {
  name                         = var.search_service_name
  sku                          = "standard"
  public_network_access_enabled = false
  local_authentication_enabled  = false

  identity {
    type = "SystemAssigned"
  }
}

resource "azurerm_role_assignment" "search_to_openai" {
  scope                = azurerm_cognitive_account.openai.id
  role_definition_name = "Cognitive Services OpenAI User"
  principal_id         = azurerm_search_service.search.identity[0].principal_id
}

Zero credential management. When the AI Search instance is deleted, its identity and permissions are destroyed automatically.

Don’t Forget Private DNS

If your-instance.openai.azure.com still resolves to a public IP, the Azure Firewall drops the traffic and you get another opaque 403. Both services need their DNS zones linked to the VNet:

resource "azurerm_private_dns_zone" "openai_dns" {
  name                = "privatelink.openai.azure.com"
  resource_group_name = var.resource_group_name
}

resource "azurerm_private_dns_zone_virtual_network_link" "openai_vnet_link" {
  name                  = "link-openai-vnet"
  resource_group_name   = var.resource_group_name
  private_dns_zone_name = azurerm_private_dns_zone.openai_dns.name
  virtual_network_id    = azurerm_virtual_network.vnet.id
  registration_enabled  = false
}

registration_enabled = false — always. Automatic registration conflicts with centralized Hub & Spoke DNS management.

The free baseline (AzAPI auto-approval + basic networking) is on GitHub. The full article with complete VNet injection, Private DNS automation, and RBAC Identity Chaining is on my blog.

👉 Full article on woitzik.dev
👉 Free GitHub repo

You Can’t Co-Design What You Don’t Operate

An article circulating this week argues that faculty AI buy-in in higher education is a human factors engineering problem. The framing is correct. The path the piece describes skips the only two steps that matter, and the reason it skips them is structural, not pedagogical.

Start with the framework on its own terms. Human factors engineering, as a discipline, is most rigorous in the places where mistakes kill people — aviation, medicine, nuclear operations, military command. In none of those places does participatory design mean asking operators to author protocols for systems they have not yet operated. Crew Resource Management in commercial aviation was built by pilots who had logged thousands of hours on the platform. The accident-investigation literature, the cognitive task analyses, the checklists, the cross-checks — all of it sits downstream of operator-grade familiarity. Mature HFE practice in industrial settings treats prerequisite familiarity as a precondition for authorship, not as a parallel track. The order is fixed: operate the system, then design the safeguards.

The Step the Article Skips

The piece on faculty AI buy-in moves directly from “engage faculty as co-designers” to the outcomes — trust, transparency, governance, alignment with academic values. The prerequisite that holds every successful HFE program together never appears in the prose. The article asks faculty to co-design governance for tools the average faculty member has used for less than ten hours total, primarily in artificial training contexts.

What the article describes as co-design is closer to structured surveying. Faculty in a one-hour ChatGPT workshop can tell you what the demo felt like. They cannot tell you which boundaries a graduate seminar in clinical psychology needs around hallucination, or which retention defaults a research-methods course needs around student-generated prompts, or which provenance attribution rules an introductory writing course needs to keep its rubric honest. Those are the governance questions that matter. Surface familiarity produces surface governance.

What the article wants — discipline-specific, boundary-aware, defensible against edge cases — requires sustained use in the actual work. Faculty have to teach with the tool, grade against the tool, fail against the tool, and revise around the tool, for weeks or semesters, before they can author governance worth shipping. The discipline has a name for this kind of sustained operation in the actual work, and the name is praxis.

The Sequence That Makes the Outcomes Hold

The order matters because what comes out of a co-design session is exactly proportional to what its participants have actually done with the tool. A committee composed of operators who have spent a semester working through real student artifacts produces governance that survives the first stress case. A committee composed of policy interpreters who watched a demo produces governance that fails on contact with real coursework.

The fix is a sequencing change: praxis programs first, in disciplines, with real workflows and instructional artifacts, for at least one cycle of student work. Governance authorship after. The order is not optional, and the patience required to hold it is the part most institutions cannot afford politically. The faculty AI committee is sitting now; the spring catalog is locked; the student-affairs office wants a policy by July. So the committee is asked to ship governance from surface familiarity, and the result is governance theater.

The Second Step Hidden in Plain Sight

There is a second reason most institutions cannot deliver participatory design on AI even when they want to, and this one has nothing to do with pedagogy. By the time the faculty AI committee convenes, the enterprise contract has already been signed. Microsoft 365 Copilot for Education was procured eighteen months ago. The Google Workspace AI add-on, the OpenAI Edu tier, the Canvas-integrated AI tutor — all already on the books, with contract terms negotiated by procurement and counsel against the vendor’s standard data-protection and indemnity language.

The actual policy surface — data flows, retention windows, opt-out defaults, training-data carve-outs, accountability allocation, liability for hallucinated outputs reaching students — was decided in that contract. What the faculty AI committee ships from here is acceptable-use guidance inside a perimeter that was drawn elsewhere by people the committee never met. Co-design at the policy layer is downstream of choices that already foreclosed most of what could be co-designed.

This is the same structural pattern that shows up whenever software arrives through the procurement door instead of the operator door. The real co-design moment is the moment the contract is being negotiated. The operators are not in that room. By the time the operators are in the room, the room has been redecorated, and the decisions that needed operator input are the wallpaper.

The Reframe

The vocabulary the discussion runs on is part of the trouble. Buy-in is a marketing term. It implies persuading a population to consent to a decision that has been made. Higher-ed faculty are operators of AI workflows in disciplines where errors compound — into student records, into transcripts, into citations, into degree credentials. Authorship is the target the framework actually requires.

Authorship requires praxis. Praxis requires sustained operation in the actual work. Sustained operation requires that the procurement phase admit it is the policy phase, and seat operators where the contract gets negotiated. The article describes the destination correctly. Trust, transparency, governance, alignment — all of those are the right outcomes. The path it draws skips the only two steps that can produce them.

What This Looks Like In Practice

For an institution willing to do the work, the program structure is concrete. A nine-to-twelve-month operator residency for each discipline before its AI governance is drafted, structured around real student artifacts and graded course outputs. A standing seat for faculty operators in the procurement workstream, with veto power on terms that touch retention, training-data use, and provenance. An explicit acknowledgment in published policy that the contract terms are the upstream constraint, named and dated, so the limits of faculty authorship are honest and visible. A sunset clause on every contract that returns the policy surface to renegotiation when the operator cohort says the boundary is wrong.

None of this is the part faculty AI committees are currently asked to produce. All of it is the part the human factors engineering frame, taken seriously, would require. The framework is right. The implementations being shipped this year are the framework with the prerequisites filed off.

Higher education will get AI governance worth defending only when the operators arrive before the contract is signed and the praxis arrives before the committee meets. Until then, what most institutions are calling co-design is a way of borrowing the legitimacy of participation without paying its operating cost.

Counting tokens is dumb. So we built a free metric for AI proficiency.

We’ve been trying to figure out a real answer to a question that keeps coming up: how do you measure whether someone is actually good at Claude Code, Codex, and the other AI coding tools? Not “do they use them,” but how good are they at using AI.

The first metric we looked at, like everyone else, was token usage. It’s the only number you can pull out of the box. Anthropic and OpenAI hand you spend data in the console. Spend correlates with cost. Cost is something finance asks about. So token usage becomes an easy first answer.

But obviously counting tokens sucks as a metric.

What we noticed when we looked at the actual sessions

When we started reading session logs from people who were clearly good with these tools, and people who were clearly struggling, both groups burned tokens. Sometimes the strugglers burned more.

A senior developer who has refined their workflow ships in 100,000 tokens what a junior chews through a million on. The high-skill move is fewer turns, sharper prompts, smaller context windows, more planning up front.

Rank by token spend and you end up rewarding the things that make people slower:

  • Padded context with files that aren’t relevant
  • Brute-forcing with longer and longer prompts
  • Staying in chat mode forever instead of building reusable workflows

On top of all this, once a company starts measuring token usage, the incentive flips from accidental to deliberate. If your performance review or “AI adoption KPI” depends on token spend, the rational move is to burn tokens on purpose. We’ve already heard about people writing scripts that loop the model on busywork just to pump their number. The metric becomes the work, and the work stops mattering.

We’ve seen the same critique made about lines of code and commit counts. Volume isn’t skill. It just looks like it on a dashboard.

So we tried looking at something else

We started watching for things you can read from local session activity that show how someone configured the tool, not how much they spent on it.

Eight things kept clustering together. People with two of them usually had four. People with five usually had close to all of them.

  • Customization — CLAUDE.md, AGENTS.md, custom slash commands, hooks. How much did they shape the tool to their workflow, vs run defaults?
  • Parallel Agents — Are they using multiple agents working at once, or one chat at a time?
  • Background Work — Tasks delegated to run unattended, or babysitting every turn?
  • Tool Breadth — To what degree do they have MCP servers, skills, plugins wired into the environment?
  • Planning — Plan mode, structured /spec / /plan workflows, or jumping straight to file edits?
  • Repetition — Skill breadth and skill depth, measured separately. A lot of people install skills they never actually use.
  • Custom Skills — Written their own reusable workflows for things they do more than once?
  • Multi-Tasking — AI treated as a team running in parallel, or as a single chat window?

Plus a few tool-specific ones we added later for Codex and Cowork.

Each of these is observable from session activity. No self-report, no interview answer to game.

So we built a free metric

We turned the framework into AIQ Rank. AIQ Rank reads local session activity from whatever AI coding tools you’re using (Claude Code, Codex, Cursor, OpenCode, Cowork) and scores you 0-1000 across the eleven dimensions. Think of it as a credit score, but for AI fluency.

We made it free. It runs locally — transcripts never leave your machine. You get a number, a per-dimension breakdown, and a profile URL to share if you want to.

The score is the hook. The breakdown is the part that’s actually useful. When we ran it on ourselves the first time, the dimension that surprised us wasn’t a strength we expected. It was a weakness we hadn’t noticed.

What we’d suggest doing with it

If you ran a quick sanity check on your team — top 10% by token spend, top 10% by AIQ Rank — we suspect the overlap would be smaller than you’d expect. Some of the token-heavy people are still brute-forcing every problem in chat mode. Some of the lower-spend people quietly built skills, wired up MCPs, learned plan mode, and run parallel agents.

That gap is the interesting part. Not because token spend is bad data, but because it’s a different question than “who’s good at this.”

If you want to do this comparison across your own team, AIQ Rank has private team leaderboards — invite people, scores aggregate to a board only your group sees. Transcripts still never leave anyone’s machine.

What do you think?

Let us know what changes/improvements we should make and please try it out! Do you agree/disagree with these parameters?

Enter the one command from aiqrank.com. It takes about a minute to run. We’d love any constructive feedback!

ABD'li çip üreticisi Nvidia'nın geliri yüzde 85 arttı

California merkezli çip üreticisi Nvidia, 2027 mali yılı ilk çeyreğinde gelirini yıllık yüzde 85 artırarak 81,6 milyar dolara yükseltti. Net karı ise yüzde 211 artışla 58,3 milyar dolar oldu. Şirket, yapay zeka altyapısına olan talebin etkisiyle piyasa beklentilerini aştı.