<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Gonzalo Ayuso &#8211; Web Architect</title>
	<atom:link href="https://gonzalo123.com/feed/" rel="self" type="application/rss+xml" />
	<link>https://gonzalo123.com</link>
	<description>gonzalo123.com</description>
	<lastBuildDate>Mon, 20 Jul 2026 12:09:00 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://s0.wp.com/i/webclip.png</url>
	<title>Gonzalo Ayuso &#8211; Web Architect</title>
	<link>https://gonzalo123.com</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">6282145</site>	<item>
		<title>Building a virtual museum with AI, Python, Blender and Three.js</title>
		<link>https://gonzalo123.com/2026/07/20/building-a-virtual-museum-with-ai-python-blender-and-three-js/</link>
					<comments>https://gonzalo123.com/2026/07/20/building-a-virtual-museum-with-ai-python-blender-and-three-js/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 20 Jul 2026 12:09:00 +0000</pubDate>
				<category><![CDATA[Technology]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Bedrock]]></category>
		<category><![CDATA[Blender]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[Three.js]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=88707</guid>

					<description><![CDATA[]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>I had never used Blender before starting this project. I knew what it was, of course, but if you had asked me to create a room, place paintings on the walls, configure materials and lights, add cameras and export everything as a GLB file, I would not have known where to start.</p>
<p>I have been exploring AI agents for a while, especially the idea of using an LLM not only to generate text, but to operate existing tools and create real artifacts. What if I could describe an exhibition in natural language and use AI to help me turn it into a museum that people can actually visit?</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img data-recalc-dims="1" decoding="async" src="https://i0.wp.com/raw.githubusercontent.com/gonzalo123/museum/refs/heads/main/exhibitions/ninfas/cover.png?w=656&#038;ssl=1" alt=""/></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>The project starts with a local export of my Notion database about nineteenth-century mythological painting (I like this kind of things). It contains artwork metadata, images and my own notes. Claude on AWS Bedrock researches that collection and proposes an exhibition. Python validates the proposal and translates it into a deterministic build specification. Blender creates the rooms, paintings, lights and cameras, and exports the result. Finally, a Three.js application turns the GLB into an interactive museum in the browser.</p>
<p>The interesting part for me is not the final 3D room. The interesting part is the path from an idea expressed in natural language to a real <code>.blend</code> file created by a tool I had never used.</p>
<h2>Two different AI layers</h2>
<p>There are actually two AI stories in this project.</p>
<p>The first one happened while building it. I used an AI coding agent as a collaborator to explore Blender’s Python API, create geometry, work with materials, configure lighting, inspect GLB metadata and iterate over the result. I was still defining the architecture and deciding what the system should do, but I did not need to become a Blender expert before producing something with Blender.</p>
<p>The second AI layer lives inside the application. Claude acts as a curator. It researches a bounded collection of paintings, chooses a narrative, decides which works belong together, organizes one or two rooms and defines the visitor route.</p>
<p>These two layers are related, but they are not the same thing. One helped me build the tool. The other is part of the tool’s runtime.</p>
<p>In both cases the useful pattern is the same: AI handles intent and exploration; deterministic software owns the dangerous and boring details.</p>
<h2>The architecture</h2>
<p>The complete flow looks like this:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-full coblocks-animate" data-coblocks-animation="fadeIn"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-14-57-45.png?ssl=1"><img data-recalc-dims="1" fetchpriority="high" decoding="async" width="523" height="688" data-attachment-id="88712" data-permalink="https://gonzalo123.com/2026/07/20/building-a-virtual-museum-with-ai-python-blender-and-three-js/screenshot-2026-07-19-at-14-57-45/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-14-57-45.png?fit=523%2C688&amp;ssl=1" data-orig-size="523,688" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="Screenshot 2026-07-19 at 14-57-45" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-14-57-45.png?fit=523%2C688&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-14-57-45.png?resize=523%2C688&#038;ssl=1" alt="" class="wp-image-88712" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-14-57-45.png?w=523&amp;ssl=1 523w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-14-57-45.png?resize=228%2C300&amp;ssl=1 228w" sizes="(max-width: 523px) 100vw, 523px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>I deliberately split the workflow into stages. Asking an LLM to create a museum in one giant step would be easy to demo and difficult to trust. Here every stage leaves an inspectable artifact behind: the imported catalog, the proposal, the accepted plan, the Blender build specification, the GLB, the content bundle and the final manifest.</p>
<p>If something is strange in the museum, I can see where it became strange.</p>
<h2>AI decides what. Code decides how.</h2>
<p>The most important decision in the project was not to ask the LLM to generate arbitrary Blender Python code.</p>
<p>That approach would look impressive in a short demo, but it would make validation almost impossible. A model could invent an API, place a painting outside the room, create overlapping frames, forget a camera or return code that only works with a particular Blender version.</p>
<p>Instead, the model returns a <code>MuseumPlan</code>. It is a declarative contract describing curatorial intent:</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
class MuseumPlan(BaseModel):
    schema_version: Literal&#x5B;&quot;1.0&quot;] = &quot;1.0&quot;
    plan_version: int
    title: str
    thesis: str
    request: ExhibitionRequest
    entrance_room_id: str
    rooms: list&#x5B;RoomPlan]
    connections: list&#x5B;RoomConnection]
    route: list&#x5B;RouteStop]
</pre></div>


<div class="wp-block-jetpack-markdown"><p>A room contains dimensions, atmosphere and artwork placements. A placement contains a wall, a normalized position, a display width, a center height and a lighting intention. The plan says that a painting belongs on the east wall at a certain position. It does not say which Blender mesh operations are required to put it there.</p>
<p><img data-recalc-dims="1" decoding="async" src="https://i0.wp.com/raw.githubusercontent.com/gonzalo123/museum/refs/heads/main/img/demo1.png?w=656&#038;ssl=1" alt="AI-curated virtual museum"></p>
<p>That distinction is the boundary:</p>
<blockquote>
<p>The AI decides what exhibition to create. Deterministic code decides how to create valid Blender geometry.</p>
</blockquote>
<p>Pydantic checks the shape of the response. Additional validators check identities, room topology, route continuity, image availability, painting dimensions, corner clearances, door clearances and frame overlaps. The LLM response is treated as untrusted JSON, even when it comes from a model that has just been given the schema.</p>
<p>If the response is invalid, the application can send the validation issues back to the model for a bounded repair attempt. If it remains invalid, nothing is published.</p>
<h2>Using Blender as a build engine</h2>
<p>Blender runs as an external application in background mode. Python prepares a non-executable JSON build specification and starts Blender with a small entrypoint:</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
return &#x5B;
    executable,
    &quot;--factory-startup&quot;,
    &quot;--background&quot;,
    &quot;--python&quot;,
    str(entrypoint),
    &quot;--&quot;,
    &quot;--build-spec&quot;,
    str(build_spec),
    &quot;--output&quot;,
    str(output),
]
</pre></div>


<div class="wp-block-jetpack-markdown"><p>Inside Blender, the <code>bpy</code> runtime resets the default scene, creates the architecture, builds the frames, loads the artwork textures, configures room and focal lights, adds the visitor camera and attaches semantic metadata to the objects.</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
def build_complete_scene(spec, build_spec_path):
    reset_complete_scene()
    themes = create_exhibition_themes(spec)
    materials = create_museum_palette(spec, themes)
    collections, connection = create_complete_architecture(spec, materials)

    create_complete_scenography(spec, collections, connection, materials, themes)

    for room in spec&#x5B;&quot;rooms&quot;]:
        add_room_fill(room, collections&#x5B;room&#x5B;&quot;id&quot;]])

    for painting in spec&#x5B;&quot;paintings&quot;]:
        add_complete_painting(...)
        add_complete_spot(...)

    add_visitor_spawn(...)

</pre></div>


<div class="wp-block-jetpack-markdown"><p>The scene is validated from inside Blender before it is exported. Expected object names, counts, artwork identities, route markers, colliders and geometry constraints are checked against the build specification. Only then does the pipeline save the <code>.blend</code> source and export the browser-ready <code>.glb</code>.</p>
<p>The build also happens in a temporary staging directory. The final output is replaced only after every artifact and hash has been verified. A failed Blender process cannot leave half a museum in the publication directory.</p>
<p>This is more machinery than a weekend 3D room normally needs. It is also the part that turns the experiment from “the model produced something once” into a repeatable software pipeline.</p>
<h2>The curator does not see everything</h2>
<p>The local catalog contains 270 records, with 190 eligible nineteenth-century works. Sending the complete database and every image to an LLM would be expensive and unnecessary.</p>
<p>Discovery is divided into two steps. First, Claude receives a compact projection of the eligible catalog and selects a bounded candidate set. Then it receives detailed metadata and reviews only for those candidates and creates the exhibition proposal.</p>
<p>The model never receives image bytes, local paths, import diagnostics or the complete Notion export. It must select existing stable artwork IDs, and the resulting plan is checked against the real local catalog.</p>
<p>The proposal is not accepted automatically either. Discovery creates an immutable review artifact. I can inspect it, ask for a refined alternative and explicitly accept only the version I want. Acceptance creates a draft; building and publishing are separate operations.</p>
<p>This separation matters because generation, acceptance and execution are different decisions. An LLM response should not become published state just because it parsed correctly.</p>
<h2>From Blender to the browser</h2>
<p>I did not want to bypass Blender by recreating the museum directly with Three.js. Blender is the authoring and build tool; Three.js is the visitor runtime.</p>
<p>The GLB contains more than visible meshes. Paintings carry stable artwork IDs, room IDs and route order. Walls and floors carry collision metadata. The scene contains route stops, label anchors, lights and a visitor spawn camera. The web application reads those semantics instead of guessing them from object positions or filenames.</p>
<p>Before enabling the <strong>Enter museum</strong> button, the browser verifies the exhibition manifest, content, build report and GLB identities. Once loaded, the visitor can walk through the rooms, follow the curatorial route, inspect each artwork, read the complete review and switch between Spanish and English content.</p>
<p>The application is static. There is no backend, database, AWS credential, Blender process or LLM call in the browser. Each published exhibition is a self-contained bundle that can be hosted with ordinary static files.</p>
<p>This is a PoC, not a general 3D authoring platform.</p>
<p>Rooms are rectangular. Exhibitions contain one or two rooms and up to 24 artworks. The scenography is generated from a small set of themes. Blender operations are intentionally limited, and the visual result is obviously not the work of an experienced 3D artist.</p>
<p>The source catalog is also very specific: nineteenth-century mythological painting, exported from my own Notion database. A different collection would probably expose different assumptions in the curator and the spatial rules.</p>
<p>But the experiment is not really about museums. A museum is only a good excuse to test a broader pattern:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?ssl=1"><img data-recalc-dims="1" decoding="async" width="656" height="72" data-attachment-id="88718" data-permalink="https://gonzalo123.com/2026/07/20/building-a-virtual-museum-with-ai-python-blender-and-three-js/screenshot-2026-07-19-at-15-02-42/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?fit=1358%2C150&amp;ssl=1" data-orig-size="1358,150" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="Screenshot 2026-07-19 at 15-02-42" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?fit=656%2C72&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?resize=656%2C72&#038;ssl=1" alt="" class="wp-image-88718" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?resize=1024%2C113&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?resize=300%2C33&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?resize=768%2C85&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?resize=1200%2C133&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-19-at-15-02-42.png?w=1358&amp;ssl=1 1358w" sizes="(max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>The professional tool could be Blender, a DAW, a CAD application, a video editor or a spreadsheet engine. The model does not need to replace the tool. It can provide a new interface to it.</p>
<h2>What I learned without learning Blender</h2>
<p>I still would not describe myself as a Blender user. If you open Blender and ask me to model this museum manually, I will probably get lost in the interface.</p>
<p>But I now understand enough of its concepts to work with it as an automation platform: scenes, collections, meshes, materials, lights, cameras, custom properties, headless rendering and GLB export. More importantly, I have a repeatable system that creates something I could not have created before.</p>
<p>AI did not make the engineering disappear. In fact, most of the project is about contracts, validation, boundaries, deterministic builds and failure handling. What AI changed was the entry cost. I could start with the result I wanted and explore an unfamiliar tool from there, instead of spending weeks learning the interface before knowing whether the idea was worth building.</p>
<p>That is the part I find interesting. AI is not only a faster way to write the software we already know how to write. It can be a bridge into tools and domains that were previously outside our reach.</p>
<p>Demo: <a href="https://gonzalo123.github.io/museum" rel="nofollow">https://gonzalo123.github.io/museum</a></p>
<p>Full source code is available in my <a href="https://github.com/gonzalo123/museum">GitHub</a>.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/07/20/building-a-virtual-museum-with-ai-python-blender-and-three-js/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">88707</post-id>	</item>
		<item>
		<title>Turning a code repository into a video podcast with Python, Remotion, AWS Bedrock and Polly</title>
		<link>https://gonzalo123.com/2026/07/13/turning-a-code-repository-into-a-video-podcast-with-python-remotion-aws-bedrock-and-polly/</link>
					<comments>https://gonzalo123.com/2026/07/13/turning-a-code-repository-into-a-video-podcast-with-python-remotion-aws-bedrock-and-polly/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 13 Jul 2026 12:12:10 +0000</pubDate>
				<category><![CDATA[Technology]]></category>
		<category><![CDATA[ai]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Bedrock]]></category>
		<category><![CDATA[Polly]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[Remotion]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=88662</guid>

					<description><![CDATA[]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>I like building small proof-of-concept projects. Writing the code is usually the fun part; explaining the project afterwards is another story. Normally, I write a post like this one, but this time I wanted to try something different: a narrated video explaining the code and the project’s architecture. Something like a video podcast with two synthetic voices, accompanied by a presentation of the project.</p>
<p>That’s the idea.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?ssl=1"><img data-recalc-dims="1" decoding="async" width="656" height="369" data-attachment-id="88666" data-permalink="https://gonzalo123.com/2026/07/13/turning-a-code-repository-into-a-video-podcast-with-python-remotion-aws-bedrock-and-polly/logo-11/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?fit=1672%2C941&amp;ssl=1" data-orig-size="1672,941" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="logo" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?fit=656%2C369&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?resize=656%2C369&#038;ssl=1" alt="" class="wp-image-88666" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?resize=1024%2C576&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?resize=300%2C169&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?resize=768%2C432&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?resize=1536%2C864&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?resize=1200%2C675&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?w=1672&amp;ssl=1 1672w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/logo.png?w=1312&amp;ssl=1 1312w" sizes="(max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>Repodcast is a command-line application that receives a local folder or a public GitHub repository and creates a narrated technical video about it. It scans the source, asks Claude on AWS Bedrock to write the episode, generates two voices with Amazon Polly, creates subtitles and renders the final MP4 with Remotion.</p>
<p>This is the kind of command I wanted to run:</p>
<pre><code class="language-bash">repodcast build gonzalo123/plainnews
</code></pre>
<p>And this is one of the generated frames:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="369" data-attachment-id="88668" data-permalink="https://gonzalo123.com/2026/07/13/turning-a-code-repository-into-a-video-podcast-with-python-remotion-aws-bedrock-and-polly/cover/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?fit=1920%2C1080&amp;ssl=1" data-orig-size="1920,1080" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="cover" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?fit=656%2C369&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?resize=656%2C369&#038;ssl=1" alt="" class="wp-image-88668" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?resize=1024%2C576&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?resize=300%2C169&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?resize=768%2C432&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?resize=1536%2C864&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?resize=1200%2C675&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?w=1920&amp;ssl=1 1920w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/cover.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>Of course, giving a repository to an LLM and asking for a video sounds easy. The interesting part starts when we want the video to describe the real code instead of producing a generic summary with a nice gradient.</p>
<h2>The pipeline</h2>
<p>The complete flow looks like this:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large coblocks-animate" data-coblocks-animation="fadeIn"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="92" data-attachment-id="88672" data-permalink="https://gonzalo123.com/2026/07/13/turning-a-code-repository-into-a-video-podcast-with-python-remotion-aws-bedrock-and-polly/screenshot-2026-07-12-at-12-14-31/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?fit=2650%2C371&amp;ssl=1" data-orig-size="2650,371" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="Screenshot 2026-07-12 at 12-14-31" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?fit=656%2C92&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?resize=656%2C92&#038;ssl=1" alt="" class="wp-image-88672" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?resize=1024%2C143&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?resize=300%2C42&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?resize=768%2C108&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?resize=1536%2C215&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?resize=2048%2C287&amp;ssl=1 2048w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?resize=1200%2C168&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?w=1312&amp;ssl=1 1312w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/Screenshot-2026-07-12-at-12-14-31.png?w=1968&amp;ssl=1 1968w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>I deliberately split the process into stages. A single giant prompt that reads a GitHub URL and somehow returns a video would be difficult to inspect and even more difficult to test. Here each stage leaves something useful behind: <code>repository.json</code>, <code>episode.json</code>, <code>script.md</code>, one audio file per scene, SRT, WebVTT and finally the video.</p>
<p>If something is strange in the result, I can see where it became strange.</p>
<h2>First, freeze the repository</h2>
<p>Repodcast accepts local directories, GitHub URLs and the short <code>owner/repository</code> form. Remote repositories are shallow-cloned into a local cache. I do not need the complete Git history to explain the current code, and I definitely do not want to download Git LFS objects or recurse into every submodule for a two-minute video.</p>
<p>The resolver keeps the exact commit SHA together with the generated episode:</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
return ResolvedRepository(
    path=checkout.resolve(),
    display_name=repository,
    source_url=url,
    requested_ref=ref,
    commit_sha=repo.head.commit.hexsha,
)
</pre></div>


<div class="wp-block-jetpack-markdown"><p>That SHA is more important than it looks. Repositories change. Without it, a video can show a diagram and a code fragment that no longer exist by the time somebody watches it. Repodcast renders the analyzed commit in the architecture scene, so the explanation has a concrete source of truth.</p>
</div>



<figure class="wp-block-image size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="369" data-attachment-id="88676" data-permalink="https://gonzalo123.com/2026/07/13/turning-a-code-repository-into-a-video-podcast-with-python-remotion-aws-bedrock-and-polly/flow-diagram/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?fit=1920%2C1080&amp;ssl=1" data-orig-size="1920,1080" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="flow-diagram" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?fit=656%2C369&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?resize=656%2C369&#038;ssl=1" alt="" class="wp-image-88676" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?resize=1024%2C576&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?resize=300%2C169&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?resize=768%2C432&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?resize=1536%2C864&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?resize=1200%2C675&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?w=1920&amp;ssl=1 1920w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/flow-diagram.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>



<div class="wp-block-jetpack-markdown"><h2>Less context, but better context</h2>
<p>Sending the whole repository to Claude is not a serious strategy. Apart from cost and context limits, a repository contains a lot of material that is useless for this task: virtual environments, compiled assets, generated files, lock internals and binaries.</p>
<p>The scanner is deterministic Python. It applies <code>.gitignore</code>, excludes well-known generated directories, rejects large or binary files and detects the stack from manifests and extensions. Then it selects a small group of interesting files: package manifests, entry points, source files and a couple of tests.</p>
<p>The result is a typed <code>RepositorySummary</code>, not an unstructured dump. The model receives the tree, detected technologies, entry points and bounded excerpts. Claude does the part where an LLM is useful —finding the story in the code— while Python owns file selection and limits.</p>
<p>This separation also makes the scanner testable without AWS credentials. An LLM should not decide whether <code>node_modules</code> belongs in the prompt.</p>
<h2>Asking for an episode, not a summary</h2>
<p>My first version of the prompt produced something technically correct and completely boring. It listed the technologies, repeated the README and finished with the usual optimistic conclusion. That is not how I explain a weekend experiment.</p>
<p>The current prompt asks for a small narrative arc: a concrete irritation, the idea, the execution path, one revealing implementation detail, an honest limitation and a final observation. It also asks for a conversation between a host and a guest. The guest is useful because it can ask the obvious question just before the answer needs to appear.</p>
<p>The response is strict JSON validated with Pydantic. A slide can contain bullets, a real code excerpt or a small architecture graph, but it always needs spoken content. Flow edges must reference existing nodes and slide indexes must be sequential.</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
class Episode(BaseModel):
    model_config = ConfigDict(frozen=True)

    title: str = Field(min_length=1)
    target_minutes: int = Field(gt=0)
    slides: list&#x5B;Slide] = Field(min_length=1)
    source_commit: str | None = None
</pre></div>


<div class="wp-block-jetpack-markdown"><p>LLMs do not always respect schemas, even when we ask politely. If Bedrock returns invalid JSON, Repodcast sends the validation error back once and asks Claude to repair the complete object. The second response goes through exactly the same validation. There is no <code>try: whatever except: continue</code> hidden in the pipeline.</p>
<p>The requested duration is also normalized in Python. The model proposes the relative duration of each scene, but code makes sure the total matches the number of minutes requested.</p>
<h2>Two voices and the problem with time</h2>
<p>Each dialogue turn has a speaker identifier, <code>host</code> or <code>guest</code>. Amazon Polly maps them to different neural voices and generates one small MP3 per turn. FFmpeg concatenates the fragments with a short silence between speakers and a small tail at the end of the scene.</p>
<p>Originally I trusted the duration written in <code>episode.json</code>. That worked until a voice needed longer than expected and the next scene started while the last word was still playing. Now <code>ffprobe</code> measures the real audio and those durations become the timing source for Remotion and the subtitle files.</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
slides = &#x5B;
    slide.model_copy(
        update={&quot;duration_seconds&quot;: max(1, math.ceil(_audio_duration(audio)))}
    )
    for slide, audio in zip(episode.slides, audio_files, strict=True)
]
</pre></div>


<div class="wp-block-jetpack-markdown"><p>It is a small detail, but it changes the pipeline from “I hope these numbers align” to “the rendered timeline follows the generated media”.</p>
<h2>Rendering code as code</h2>
<p>The visual part is a React application rendered with Remotion. There are three main scene types: covers, architecture flows and code scenes. Code is highlighted with Prism and revealed line by line. Architecture nodes arrive as structured data, not as Mermaid text invented at render time, and each node can point to a real path in the repository.</p>
</div>



<figure class="wp-block-image size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="369" data-attachment-id="88680" data-permalink="https://gonzalo123.com/2026/07/13/turning-a-code-repository-into-a-video-podcast-with-python-remotion-aws-bedrock-and-polly/code-scene/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?fit=1920%2C1080&amp;ssl=1" data-orig-size="1920,1080" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="code-scene" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?fit=656%2C369&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?resize=656%2C369&#038;ssl=1" alt="" class="wp-image-88680" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?resize=1024%2C576&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?resize=300%2C169&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?resize=768%2C432&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?resize=1536%2C864&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?resize=1200%2C675&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?w=1920&amp;ssl=1 1920w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/07/code-scene.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>



<div class="wp-block-jetpack-markdown"><p>The Python side copies the audio into Remotion’s public directory, writes the episode props and launches the composition. React owns layout and animation; Python owns the workflow and the data contract between stages.</p>
<p>This boundary is useful. I can change the complete visual identity without touching repository analysis, Bedrock or Polly. I can also replace Polly with another text-to-speech provider without changing a single React component.</p>
<h2>Building the pipeline without spending money every time</h2>
<p>Testing an application like this against real AI and text-to-speech services would be slow, expensive and unpredictable. Repodcast has deterministic fake adapters for both.</p>
<p>The fake AI produces a seven-scene episode with a flow diagram and a code example. The fake Polly adapter creates silent audio with FFmpeg. That means the same orchestration used by the real command can generate every intermediate artifact locally, including a real MP4, without calling AWS.</p>
<p>The tests exercise repository filtering, GitHub source resolution, prompt rules, JSON repair, SSML escaping, audio concatenation, duration measurement, subtitles and the Remotion contract. At the moment there are 19 tests. Ruff and mypy in strict mode are also part of the checks.</p>
<p>This does not prove that Claude will always write a good story. It proves that a strange story cannot silently break the media pipeline.</p>
<h2>What is still deliberately simple</h2>
<p>Repodcast is a PoC. The scanner does not build an AST or a call graph. It chooses representative files using small heuristics, which works surprisingly well for compact repositories but will miss important relationships in a large monorepo.</p>
<p>Subtitles are synchronized per scene, not per word. The architecture graph is intentionally limited to six nodes because more nodes might be more accurate, but they produce a worse video. The final quality still depends on the source repository and on the model choosing the right thread to follow.</p>
<p>There is also an uncomfortable recursive detail: the repository may already contain a README written to explain the project. Repodcast reads that README to create another explanation of the same project, now with two synthetic voices and animated code. This is clearly over-engineered.</p>
<p>But it is also the kind of over-engineering I like. The result is not a presentation about a possible system. It is a working pipeline that takes a real commit, generates inspectable artifacts and ends with a video that can be watched.</p>
<p>The LLM writes the story. Python keeps it attached to the repository. Polly gives it voices. Remotion makes it visible.</p>
<p>Example video of the generated podcast for this repository:</p>
<p><a href="https://www.youtube.com/watch?v=fc8IVhr6bT0"><img data-recalc-dims="1" decoding="async" src="https://i0.wp.com/img.youtube.com/vi/fc8IVhr6bT0/maxresdefault.jpg?w=656&#038;ssl=1" alt="Watch the generated podcast video"></a></p>
<p>And that’s all. Full source code is available in my <a href="https://github.com/gonzalo123/repodcast">GitHub</a>.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/07/13/turning-a-code-repository-into-a-video-podcast-with-python-remotion-aws-bedrock-and-polly/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">88662</post-id>	</item>
		<item>
		<title>A Pokédex in the terminal, but agentic, with LangChain</title>
		<link>https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/</link>
					<comments>https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 29 Jun 2026 12:13:18 +0000</pubDate>
				<category><![CDATA[Technology]]></category>
		<category><![CDATA[agentic-ai]]></category>
		<category><![CDATA[ai]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Bedrock]]></category>
		<category><![CDATA[LangChain]]></category>
		<category><![CDATA[Pokemon]]></category>
		<category><![CDATA[Python]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=88579</guid>

					<description><![CDATA[]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>This project is a spin-off of a small that we started during
the Katayuno on June 20, 2024. Katayuno is a Saturday morning programming kata where the
conversation, debate and retrospective normally matter more than finishing the
exercise. That time, helped by AI, almost every team actually finished.</p>
<p>My version used React, TypeScript and Vite, with PokeAPI directly from the
browser <a href="https://github.com/gonzalo123/pokemon">React Pokédex</a>. It had the usual things: list, details, comparison, shiny sprites and
even a small battle mode.</p>
<p>But that application was deterministic. Click here, fetch this, render that.</p>
<p>This time I wanted something slightly different. I wanted a Pokémon professor
in my terminal. Not a chatbot that hallucinates Pokémon facts. A small agent
that uses <a href="https://pokeapi.co/">PokeAPI</a> as the source of truth and an LLM only
as the reasoning layer.</p>
<blockquote>
<p>The LLM is not the database. The LLM is the reasoning layer.</p>
</blockquote>
<p>I don’t want the model to remember Pikachu’s Speed. I want the model to ask the
tool.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="369" data-attachment-id="88581" data-permalink="https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/logo-10/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?fit=1672%2C941&amp;ssl=1" data-orig-size="1672,941" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="logo" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?fit=656%2C369&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?resize=656%2C369&#038;ssl=1" alt="" class="wp-image-88581" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?resize=1024%2C576&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?resize=300%2C169&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?resize=768%2C432&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?resize=1536%2C864&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?resize=1200%2C675&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?w=1672&amp;ssl=1 1672w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/logo.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><h2>The idea</h2>
<p>The project is a Python 3.13 CLI built with Click, Rich, httpx, Pydantic and
LangChain. AWS Bedrock is the default model provider, but it lives behind one
small factory, so changing the provider does not require changing the PokeAPI
code.</p>
<p>The flow is deliberately boring:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-full"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/image.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="465" height="440" data-attachment-id="88584" data-permalink="https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/image-3/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/image.png?fit=465%2C440&amp;ssl=1" data-orig-size="465,440" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="image" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/image.png?fit=465%2C440&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/image.png?resize=465%2C440&#038;ssl=1" alt="" class="wp-image-88584" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/image.png?w=465&amp;ssl=1 465w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/image.png?resize=300%2C284&amp;ssl=1 300w" sizes="auto, (max-width: 465px) 100vw, 465px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>The agent cannot fetch arbitrary URLs. It only receives five tools:</p>
<ul>
<li><code>get_pokemon</code></li>
<li><code>get_type</code></li>
<li><code>compare_pokemon</code></li>
<li><code>get_evolution_chain</code></li>
<li><code>get_type_matchup</code></li>
</ul>
<p>This is not magic. The tools are normal Python functions returning small,
normalized dictionaries. LangChain decides when to call them and the model
reasons with their output.</p>
<h2>The CLI</h2>
<p>The deterministic commands work without AWS credentials:</p>
<pre><code class="language-bash">uv run python -m cli pokemon pikachu
uv run python -m cli search charizrad
uv run python -m cli compare charizard blastoise
</code></pre>
<p>The commands involving reasoning can use Bedrock:</p>
<pre><code class="language-bash">uv run python -m cli compare charizard blastoise --explain
uv run python -m cli battle charizard venusaur
uv run python -m cli ask &quot;Which Pokémon is faster, Gengar or Alakazam?&quot;
</code></pre>
<h2>The boring deterministic part</h2>
<p>Getting a Pokémon by name does not need an LLM. This is one of those examples
where using an LLM for everything would be a mistake.</p>
<p>The PokeAPI adapter turns the large API response into a small Pydantic model:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">class</span> <span class="tok-className">PokemonSummary</span><span class="tok-punctuation">(</span><span class="tok-variableName">BaseModel</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-variableName">id</span>: <span class="tok-variableName">int</span></div><div class="cm-line">    <span class="tok-variableName">name</span>: <span class="tok-variableName">str</span></div><div class="cm-line">    <span class="tok-variableName">types</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">[</span><span class="tok-variableName">str</span><span class="tok-punctuation">]</span></div><div class="cm-line">    <span class="tok-variableName">height</span>: <span class="tok-variableName">int</span> <span class="tok-operator">=</span> <span class="tok-variableName">Field</span><span class="tok-punctuation">(</span><span class="tok-variableName">description</span><span class="tok-operator">=</span><span class="tok-string">&quot;Height in decimetres, as returned by PokeAPI&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-variableName">weight</span>: <span class="tok-variableName">int</span> <span class="tok-operator">=</span> <span class="tok-variableName">Field</span><span class="tok-punctuation">(</span><span class="tok-variableName">description</span><span class="tok-operator">=</span><span class="tok-string">&quot;Weight in hectograms, as returned by PokeAPI&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-variableName">base_experience</span>: <span class="tok-variableName">int</span> <span class="tok-operator">|</span> <span class="tok-keyword">None</span></div><div class="cm-line">    <span class="tok-variableName">stats</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">[</span><span class="tok-variableName">PokemonStat</span><span class="tok-punctuation">]</span></div><div class="cm-line">    <span class="tok-variableName">abilities</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">[</span><span class="tok-variableName">str</span><span class="tok-punctuation">]</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">stat</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">,</span> <span class="tok-variableName">name</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">int</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">next</span><span class="tok-punctuation">(</span><span class="tok-punctuation">(</span><span class="tok-variableName">stat</span><span class="tok-operator">.</span><span class="tok-propertyName">value</span> <span class="tok-keyword">for</span> <span class="tok-variableName">stat</span> <span class="tok-keyword">in</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">stats</span> <span class="tok-keyword">if</span> <span class="tok-variableName">stat</span><span class="tok-operator">.</span><span class="tok-propertyName">name</span> <span class="tok-operator">==</span> <span class="tok-variableName">name</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span> <span class="tok-number">0</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-meta">@</span><span class="tok-variableName">property</span></div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">total_stats</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">int</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">sum</span><span class="tok-punctuation">(</span><span class="tok-variableName">stat</span><span class="tok-operator">.</span><span class="tok-propertyName">value</span> <span class="tok-keyword">for</span> <span class="tok-variableName">stat</span> <span class="tok-keyword">in</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">stats</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>The <code>pokemon</code> command fetches the data and Rich renders it. The <code>compare</code>
command fetches two Pokémon and compares their base stats with plain Python.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large coblocks-animate" data-coblocks-animation="fadeIn"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="416" data-attachment-id="88587" data-permalink="https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/pokemon/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?fit=2400%2C1520&amp;ssl=1" data-orig-size="2400,1520" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="pokemon" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?fit=656%2C416&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?resize=656%2C416&#038;ssl=1" alt="" class="wp-image-88587" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?resize=1024%2C649&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?resize=300%2C190&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?resize=768%2C486&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?resize=1536%2C973&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?resize=2048%2C1297&amp;ssl=1 2048w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?resize=1200%2C760&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?w=1312&amp;ssl=1 1312w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/pokemon.png?w=1968&amp;ssl=1 1968w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>

<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="303" data-attachment-id="88588" data-permalink="https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/compare/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?fit=2400%2C1108&amp;ssl=1" data-orig-size="2400,1108" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="compare" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?fit=656%2C303&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?resize=656%2C303&#038;ssl=1" alt="" class="wp-image-88588" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?resize=1024%2C473&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?resize=300%2C139&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?resize=768%2C355&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?resize=1536%2C709&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?resize=2048%2C945&amp;ssl=1 2048w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?resize=1200%2C554&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?w=1312&amp;ssl=1 1312w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/compare.png?w=1968&amp;ssl=1 1968w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>The deterministic part is boring. And that is good.</p>
<p>It is easy to test and easy to understand when something goes wrong.</p>
<p>Typos are deterministic too. A failed lookup downloads the compact species-name
index from PokeAPI and keeps it in memory for the current process. Python’s
<code>SequenceMatcher</code> then ranks names locally:</p>
<pre><code class="language-bash">uv run python -m cli search charizrad
</code></pre>
<pre><code class="language-text">charizard 89% similarity
</code></pre>
<p>Normal commands use the same matcher when PokeAPI returns a 404:</p>
<pre><code class="language-text">╭──────────────────────────── Pokémon not found ─────────────────────────────╮
│ I couldn't find 'charizrad'.                                               │
│                                                                            │
│ Best match: Charizard  (89% similarity)                                    │
╰────────────────────────────────────────────────────────────────────────────╯
Press Enter to use Charizard, type another name, or q to cancel:
</code></pre>
<p>Pressing Enter accepts the suggestion. Typing another name retries the lookup,
and <code>q</code> cancels. I deliberately do not ask the LLM and I do not silently replace
the name. In non-interactive scripts the command keeps returning a normal error
instead of waiting forever for input.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="545" data-attachment-id="88590" data-permalink="https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/typo-correction/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?fit=2400%2C1992&amp;ssl=1" data-orig-size="2400,1992" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="typo-correction" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?fit=656%2C545&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?resize=656%2C545&#038;ssl=1" alt="" class="wp-image-88590" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?resize=1024%2C850&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?resize=300%2C249&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?resize=768%2C637&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?resize=1536%2C1275&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?resize=2048%2C1700&amp;ssl=1 2048w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?resize=1200%2C996&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?w=1312&amp;ssl=1 1312w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/typo-correction.png?w=1968&amp;ssl=1 1968w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><h2>The agentic part</h2>
<p>The agentic part starts when the question is no longer a direct API call.</p>
<p>For example:</p>
<pre><code class="language-text">Which Pokémon is faster, Gengar or Alakazam?
Can Pikachu beat Squirtle?
What are Dragonite weaknesses?
Tell me the evolution chain of Eevee
</code></pre>
<p>Here LangChain’s <code>create_agent</code> receives the Bedrock chat model, the controlled
tools and a system prompt:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-variableName">agent</span> <span class="tok-operator">=</span> <span class="tok-variableName">create_agent</span><span class="tok-punctuation">(</span></div><div class="cm-line">    <span class="tok-variableName">model</span><span class="tok-operator">=</span><span class="tok-variableName">create_chat_model</span><span class="tok-punctuation">(</span><span class="tok-variableName">settings</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">tools</span><span class="tok-operator">=</span><span class="tok-variableName">build_tools</span><span class="tok-punctuation">(</span><span class="tok-variableName">client</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">system_prompt</span><span class="tok-operator">=</span><span class="tok-variableName">SYSTEM_PROMPT</span><span class="tok-punctuation">,</span></div><div class="cm-line"><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-variableName">result</span> <span class="tok-operator">=</span> <span class="tok-variableName">agent</span><span class="tok-operator">.</span><span class="tok-propertyName">invoke</span><span class="tok-punctuation">(</span></div><div class="cm-line">    <span class="tok-punctuation">{</span><span class="tok-string">&quot;messages&quot;</span>: <span class="tok-punctuation">[</span><span class="tok-punctuation">{</span><span class="tok-string">&quot;role&quot;</span>: <span class="tok-string">&quot;user&quot;</span><span class="tok-punctuation">,</span> <span class="tok-string">&quot;content&quot;</span>: <span class="tok-variableName">question</span><span class="tok-punctuation">}</span><span class="tok-punctuation">]</span><span class="tok-punctuation">}</span></div><div class="cm-line"><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>The important part is not <code>create_agent</code>. The important part is the boundary.
Facts come from tools. The model decides which facts it needs and explains the
result.</p>
<p>The prompt says it explicitly:</p>
<pre><code class="language-text">You must never invent Pokémon data. Use the available tools to retrieve facts
from PokeAPI before answering factual questions.

The LLM is not the database. The LLM is the reasoning layer.
</code></pre>
<p>A prompt is not a security boundary, of course. That is why the agent only gets
small, explicit tools and never gets a generic HTTP client.</p>
<h2>Tools</h2>
<p>The tools are created around the PokeAPI client. This makes them small and also
makes tests simple because I can inject an <code>httpx.MockTransport</code>.</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">tool</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">get_type_matchup</span><span class="tok-punctuation">(</span></div><div class="cm-line">    <span class="tok-variableName">attacker_type</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">defender_types</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">[</span><span class="tok-variableName">str</span><span class="tok-punctuation">]</span><span class="tok-punctuation">,</span></div><div class="cm-line"><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">dict</span><span class="tok-punctuation">[</span><span class="tok-variableName">str</span><span class="tok-punctuation">,</span> <span class="tok-variableName">Any</span><span class="tok-punctuation">]</span>:</div><div class="cm-line">    <span class="tok-string">&quot;&quot;&quot;Calculate the damage multiplier for one attacking type against defender types.&quot;&quot;&quot;</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">client</span><span class="tok-operator">.</span><span class="tok-propertyName">get_type_matchup</span><span class="tok-punctuation">(</span></div><div class="cm-line">        <span class="tok-variableName">attacker_type</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">defender_types</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-punctuation">)</span><span class="tok-operator">.</span><span class="tok-propertyName">model_dump</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>I don’t return the complete PokeAPI JSON. Agents work better when tools return
the information needed for the task instead of a small novel containing every
field an API has accumulated over the years.</p>
<h2>Structured output</h2>
<p>The battle command is intentionally limited. It is not a competitive Pokémon
simulator. It considers the Pokémon types, type multipliers, base Speed,
offensive stats and defensive stats. It does not consider moves, levels,
abilities, held items, natures, weather or battle format.</p>
<p>The local heuristic first creates a valid prediction:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">class</span> <span class="tok-className">BattlePrediction</span><span class="tok-punctuation">(</span><span class="tok-variableName">BaseModel</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-variableName">winner</span>: <span class="tok-variableName">str</span></div><div class="cm-line">    <span class="tok-variableName">confidence</span>: <span class="tok-variableName">float</span> <span class="tok-operator">=</span> <span class="tok-variableName">Field</span><span class="tok-punctuation">(</span><span class="tok-variableName">ge</span><span class="tok-operator">=</span><span class="tok-number">0</span><span class="tok-punctuation">,</span> <span class="tok-variableName">le</span><span class="tok-operator">=</span><span class="tok-number">1</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-variableName">reasons</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">[</span><span class="tok-variableName">str</span><span class="tok-punctuation">]</span></div><div class="cm-line">    <span class="tok-variableName">caveats</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">[</span><span class="tok-variableName">str</span><span class="tok-punctuation">]</span></div><div class="cm-line">    <span class="tok-variableName">recommended_attack_types</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">[</span><span class="tok-variableName">str</span><span class="tok-punctuation">]</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>In Bedrock mode I pass that prediction and the normalized PokeAPI facts to a
second LangChain agent using <code>response_format=BattlePrediction</code>. The result is
validated by Pydantic instead of parsing an optimistic blob of JSON from a
string.</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-variableName">agent</span> <span class="tok-operator">=</span> <span class="tok-variableName">create_agent</span><span class="tok-punctuation">(</span></div><div class="cm-line">    <span class="tok-variableName">model</span><span class="tok-operator">=</span><span class="tok-variableName">create_chat_model</span><span class="tok-punctuation">(</span><span class="tok-variableName">settings</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">tools</span><span class="tok-operator">=</span><span class="tok-punctuation">[</span><span class="tok-punctuation">]</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">system_prompt</span><span class="tok-operator">=</span><span class="tok-variableName">BATTLE_PROMPT</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">response_format</span><span class="tok-operator">=</span><span class="tok-variableName">ToolStrategy</span><span class="tok-punctuation">(</span><span class="tok-variableName">BattlePrediction</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line"><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>The model can improve the explanation, but it does not get permission to
invent a Flamethrower, an item or a hidden ability.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large coblocks-animate" data-coblocks-animation="fadeIn"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="431" data-attachment-id="88596" data-permalink="https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/battle/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?fit=2400%2C1578&amp;ssl=1" data-orig-size="2400,1578" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;,&quot;alt&quot;:&quot;&quot;}" data-image-title="battle" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?fit=656%2C431&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?resize=656%2C431&#038;ssl=1" alt="" class="wp-image-88596" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?resize=1024%2C673&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?resize=300%2C197&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?resize=768%2C505&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?resize=1536%2C1010&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?resize=2048%2C1347&amp;ssl=1 2048w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?resize=1200%2C789&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?w=1312&amp;ssl=1 1312w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/06/battle.png?w=1968&amp;ssl=1 1968w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>I use LangChain’s <code>ToolStrategy</code> explicitly here. Bedrock’s native structured
output currently rejects some numeric JSON Schema constraints generated by
Pydantic, such as the minimum and maximum for <code>confidence</code>. Tool calling still
returns a validated <code>BattlePrediction</code> without depending on that provider
limitation.</p>
<h2>Rich output</h2>
<p>Click handles the command-line interface and Rich handles tables, panels,
colours and stat bars.</p>
<p>Rich is not needed, but terminals should still look decent.</p>
<p>The visual layer is also separate from the data layer. <code>render.py</code> receives
Pydantic models. It does not know how PokeAPI works and it does not call the
LLM.</p>
<h2>When not to use the LLM</h2>
<p>I think this is the useful part of the experiment.</p>
<p>There is no model call in:</p>
<ul>
<li><code>pokemon</code></li>
<li><code>compare</code> without <code>--explain</code></li>
<li>typo suggestions and Pokémon name search</li>
<li>type multiplier calculation</li>
<li>the first battle prediction</li>
<li>tests</li>
</ul>
<p>An LLM is useful when the user asks an open question and the application needs
to choose tools, combine facts and explain a conclusion. It is not useful for
adding six integers or reading Pikachu’s height from JSON.</p>
<p>Using less AI here makes the agentic part easier to see.</p>
<h2>Running the project</h2>
<p>I normally use Poetry. For this small project I wanted to try <code>uv</code>, so it owns
Python installation, dependency resolution, command execution and the lock
file. I am not starting a package-manager religion here. It’s just a test.</p>
<pre><code class="language-bash">git clone https://github.com/gonzalo123/pokemon_cli.git
cd pokemon_cli

uv python install 3.13
uv sync --extra dev
</code></pre>
<p>That is enough for the deterministic commands.</p>
<p>For AWS Bedrock:</p>
<pre><code class="language-bash">uv sync --extra dev --extra bedrock
cp .env.example .env
</code></pre>
<p>Then configure the environment:</p>
<pre><code class="language-dotenv">AWS_PROFILE=sandbox
AWS_REGION=eu-west-1
BEDROCK_MODEL_ID=global.anthropic.claude-sonnet-4-6
</code></pre>
<p>No AWS key is stored in the repository. The AWS SDK uses the selected profile
or its normal credential chain.</p>
<p>Now the examples:</p>
<pre><code class="language-bash">uv run python -m cli pokemon pikachu
uv run python -m cli compare charizard blastoise
uv run python -m cli battle charizard venusaur
uv run python -m cli ask \
  &quot;Which Pokémon is faster, Gengar or Alakazam?&quot;
</code></pre>
<p>There is also an installed command:</p>
<pre><code class="language-bash">uv run pokemon-professor pokemon pikachu
</code></pre>
<h2>Things I liked</h2>
<p>The separation is small but useful. PokeAPI owns the facts, Pydantic owns the
shape, Python owns deterministic calculations, Rich owns presentation and the
LLM owns a narrow reasoning task.</p>
<h2>Things that still feel awkward</h2>
<p>The battle result is only a heuristic. A real battle model needs moves, abilities, levels, items, natures, generation rules, and probably much more. Adding all that while pretending the result is still simple would be dishonest.</p>
<p>I’m not a Pokémon expert. I have only been playing with the Pokémon API because of the Katayuno. This is just an excuse to use AI everywhere, just like we developers seem to be doing these days. Please don’t judge me too harshly.</p>
<h2>Final thoughts</h2>
<p>Agentic does not mean replacing every function with an LLM call. For me it
means giving the model a small set of reliable capabilities and letting it use
them when a deterministic route is no longer enough.</p>
<p>The Pokémon facts do not belong in the prompt and they do not belong in the
model’s memory. They belong in PokeAPI.</p>
<p>The LLM is not the database. The LLM is the reasoning layer.</p>
<p>And that’s all. Full source code is available in my GitHub <a href="https://github.com/gonzalo123/pokemon_cli">account</a>.</p>
</div>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/06/29/a-pokedex-in-the-terminal-but-agentic-with-langchain/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">88579</post-id>	</item>
		<item>
		<title>What Homer Would Reply to Your Email: A Classical Quote Recommender with Bedrock, RAG, and Strands Agents</title>
		<link>https://gonzalo123.com/2026/06/08/what-homer-would-reply-to-your-email-a-classical-quote-recommender-with-bedrock-rag-and-strands-agents/</link>
					<comments>https://gonzalo123.com/2026/06/08/what-homer-would-reply-to-your-email-a-classical-quote-recommender-with-bedrock-rag-and-strands-agents/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 08 Jun 2026 12:18:45 +0000</pubDate>
				<category><![CDATA[Technology]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Bedrock]]></category>
		<category><![CDATA[llm]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[RAG]]></category>
		<category><![CDATA[StrandsAgents]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=88361</guid>

					<description><![CDATA[Say the input is &#8220;Thanks for your feedback. I think we can find a middle ground&#8221;. Instead of just searching for those words, the system searches for negotiation, conciliatory, guarded optimism, diplomatic and bridge-building, all at once. The search doesn&#8217;t just look for passages about feedback. It looks for passages that feel the same way &#8230; <a href="https://gonzalo123.com/2026/06/08/what-homer-would-reply-to-your-email-a-classical-quote-recommender-with-bedrock-rag-and-strands-agents/" class="more-link">Continue reading <span class="screen-reader-text">What Homer Would Reply to Your Email: A Classical Quote Recommender with Bedrock, RAG, and Strands Agents</span></a>]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>What if every email you write could carry the rhetorical weight of Homer? Not as a gimmick, as a real tool that understands the tone, intent, and emotion of your message and finds the classical passage that fits.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large coblocks-animate" data-coblocks-animation="slideInLeft"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="438" data-attachment-id="88363" data-permalink="https://gonzalo123.com/2026/06/08/what-homer-would-reply-to-your-email-a-classical-quote-recommender-with-bedrock-rag-and-strands-agents/image-2/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?fit=1536%2C1024&amp;ssl=1" data-orig-size="1536,1024" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="image" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?fit=656%2C438&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?resize=656%2C438&#038;ssl=1" alt="" class="wp-image-88363" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?resize=1024%2C683&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?resize=300%2C200&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?resize=768%2C512&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?resize=1200%2C800&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?w=1536&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/image.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>That’s the idea behind this PoC: a system that takes a short text (an email, a Slack message, a reply to a tricky thread) and recommends the most fitting quote from classical literature. Right now the corpus is the Iliad and the Odyssey, nearly 5,000 passages of Homer, indexed and searchable by meaning, not just by keywords.</p>
<p>The interesting part isn’t the concept. It’s how the pieces fit together.</p>
<h2>The Architecture</h2>
<p>The system runs as a FastAPI service with a five-stage pipeline:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-full coblocks-animate" data-coblocks-animation="slideInLeft"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_classical-quote-rec%E2%80%A6for_short_emails_and_messages_.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="538" data-attachment-id="88366" data-permalink="https://gonzalo123.com/2026/06/08/what-homer-would-reply-to-your-email-a-classical-quote-recommender-with-bedrock-rag-and-strands-agents/gonzalo123_classical-quote-recfor_short_emails_and_messages_/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_classical-quote-rec%E2%80%A6for_short_emails_and_messages_.png?fit=868%2C712&amp;ssl=1" data-orig-size="868,712" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="gonzalo123_classical-quote-rec…for_short_emails_and_messages_" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_classical-quote-rec%E2%80%A6for_short_emails_and_messages_.png?fit=656%2C538&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_classical-quote-rec%E2%80%A6for_short_emails_and_messages_.png?resize=656%2C538&#038;ssl=1" alt="" class="wp-image-88366" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_classical-quote-rec%E2%80%A6for_short_emails_and_messages_.png?w=868&amp;ssl=1 868w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_classical-quote-rec%E2%80%A6for_short_emails_and_messages_.png?resize=300%2C246&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_classical-quote-rec%E2%80%A6for_short_emails_and_messages_.png?resize=768%2C630&amp;ssl=1 768w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>The key idea: the two ends of the pipeline (understanding your message and choosing the final quote) use an LLM, but the middle part, finding and ranking candidates, is pure code, no AI involved. That means the core search is predictable and inspectable. Bedrock handles the parts that need language understanding; everything else stays local.</p>
<h2>Query Enrichment: Not Just What You Said, But What You Meant</h2>
<p>When you search a typical RAG system, you take the user’s text and look for similar documents. Here I do something different: before searching, the system enriches the query with everything it learned during the rhetorical analysis.</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">class</span> <span class="tok-className">HybridRetriever</span>:</div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">_build_query</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">,</span> <span class="tok-variableName">text</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">,</span> <span class="tok-variableName">analysis</span>: <span class="tok-variableName">InputAnalysis</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">        <span class="tok-variableName">parts</span> <span class="tok-operator">=</span> <span class="tok-punctuation">[</span></div><div class="cm-line">            <span class="tok-variableName">text</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-variableName">analysis</span><span class="tok-operator">.</span><span class="tok-propertyName">summary</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-variableName">analysis</span><span class="tok-operator">.</span><span class="tok-propertyName">main_theme</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-string">&quot; &quot;</span><span class="tok-operator">.</span><span class="tok-propertyName">join</span><span class="tok-punctuation">(</span><span class="tok-variableName">analysis</span><span class="tok-operator">.</span><span class="tok-propertyName">secondary_themes</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-variableName">analysis</span><span class="tok-operator">.</span><span class="tok-propertyName">tone</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-variableName">analysis</span><span class="tok-operator">.</span><span class="tok-propertyName">intent</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-variableName">analysis</span><span class="tok-operator">.</span><span class="tok-propertyName">dominant_emotion</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-variableName">analysis</span><span class="tok-operator">.</span><span class="tok-propertyName">recommended_quote_type</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-punctuation">]</span></div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-string">&quot; &quot;</span><span class="tok-operator">.</span><span class="tok-propertyName">join</span><span class="tok-punctuation">(</span><span class="tok-variableName">part</span> <span class="tok-keyword">for</span> <span class="tok-variableName">part</span> <span class="tok-keyword">in</span> <span class="tok-variableName">parts</span> <span class="tok-keyword">if</span> <span class="tok-variableName">part</span><span class="tok-punctuation">)</span><span class="tok-operator">.</span><span class="tok-propertyName">strip</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<p class="wp-block-paragraph">Say the input is <em>&#8220;Thanks for your feedback. I think we can find a middle ground&#8221;</em>. Instead of just searching for those words, the system searches for <code>negotiation</code>, <code>conciliatory</code>, <code>guarded optimism</code>, <code>diplomatic and bridge-building</code>, all at once. The search doesn&#8217;t just look for passages about feedback. It looks for passages that feel the same way as the message.</p>



<h2 class="wp-block-heading">Zero-Dependency Vector Store</h2>



<p class="wp-block-paragraph">I deliberately avoided FAISS, Chroma, Pinecone, or any external vector database. The entire search index is a single NumPy matrix:</p>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">class</span> <span class="tok-className">NumpyVectorStore</span>:</div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">search</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">,</span> <span class="tok-variableName">query_vector</span>: <span class="tok-variableName">np</span><span class="tok-operator">.</span><span class="tok-propertyName">ndarray</span><span class="tok-punctuation">,</span> <span class="tok-variableName">top_k</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">list</span><span class="tok-punctuation">[</span><span class="tok-variableName">tuple</span><span class="tok-punctuation">[</span><span class="tok-variableName">str</span><span class="tok-punctuation">,</span> <span class="tok-variableName">float</span><span class="tok-punctuation">]</span><span class="tok-punctuation">]</span>:</div><div class="cm-line">        <span class="tok-variableName">query</span> <span class="tok-operator">=</span> <span class="tok-variableName">np</span><span class="tok-operator">.</span><span class="tok-propertyName">asarray</span><span class="tok-punctuation">(</span><span class="tok-variableName">query_vector</span><span class="tok-punctuation">,</span> <span class="tok-variableName">dtype</span><span class="tok-operator">=</span><span class="tok-variableName">np</span><span class="tok-operator">.</span><span class="tok-propertyName">float32</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">scores</span> <span class="tok-operator">=</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">vectors</span> <span class="tok-operator">@</span> <span class="tok-variableName">query</span></div><div class="cm-line">        <span class="tok-variableName">indices</span> <span class="tok-operator">=</span> <span class="tok-variableName">np</span><span class="tok-operator">.</span><span class="tok-propertyName">argsort</span><span class="tok-punctuation">(</span><span class="tok-variableName">scores</span><span class="tok-punctuation">)</span><span class="tok-punctuation">[</span>::<span class="tok-operator">-</span><span class="tok-number">1</span><span class="tok-punctuation">]</span><span class="tok-punctuation">[</span>:<span class="tok-variableName">top_k</span><span class="tok-punctuation">]</span></div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-punctuation">[</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">ids</span><span class="tok-punctuation">[</span><span class="tok-variableName">index</span><span class="tok-punctuation">]</span><span class="tok-punctuation">,</span> <span class="tok-variableName">float</span><span class="tok-punctuation">(</span><span class="tok-variableName">scores</span><span class="tok-punctuation">[</span><span class="tok-variableName">index</span><span class="tok-punctuation">]</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span> <span class="tok-keyword">for</span> <span class="tok-variableName">index</span> <span class="tok-keyword">in</span> <span class="tok-variableName">indices</span><span class="tok-punctuation">]</span></div></code></pre>
		</div>
	</div>
</div>


<p class="wp-block-paragraph">One line does the work: <code>scores = self.vectors @ query</code>. This is a matrix multiplication that compares the query against every passage in the corpus at once. Because the embedding model produces normalized vectors, this simple operation gives you cosine similarity, a standard way to measure how close two texts are in meaning.</p>



<p class="wp-block-paragraph">The full index is a matrix of 4,841 rows (one per passage) and 384 columns (one per dimension of the embedding). It loads in milliseconds and fits in memory easily. For a corpus of this size, a full-blown vector database would be overkill.</p>



<h2 class="wp-block-heading">Why all-MiniLM-L6-v2</h2>



<p class="wp-block-paragraph">The model that turns text into numbers (embeddings) is <code>all-MiniLM-L6-v2</code> from Sentence Transformers. It takes any piece of text and produces a list of 384 numbers that represent its meaning. Texts that say similar things end up with similar numbers, even if they use completely different words.</p>



<p class="wp-block-paragraph">It&#8217;s a small model, only 22 million parameters and 6 layers, but it was trained on over a billion pairs of sentences, so it&#8217;s surprisingly good at capturing semantic similarity. It loads in under a second on a regular CPU and processes the entire Homer corpus in a few seconds.</p>



<p class="wp-block-paragraph">There are bigger models (like <code>all-mpnet-base-v2</code> with 110M parameters) that would be slightly more precise, but for this project the bottleneck isn&#8217;t the embedding quality, it&#8217;s how well the query enrichment captures the intent of the message. The small model is more than enough.</p>



<h2 class="wp-block-heading">The Reranker: Five Signals, Calibrated Weights</h2>



<p class="wp-block-paragraph">After the search phase finds candidate passages using both meaning and keywords, a rule-based reranker scores each one across five signals:</p>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-variableName">candidate</span><span class="tok-operator">.</span><span class="tok-propertyName">rerank_score</span> <span class="tok-operator">=</span> <span class="tok-punctuation">(</span></div><div class="cm-line">    <span class="tok-number">0.45</span> <span class="tok-operator">*</span> <span class="tok-variableName">candidate</span><span class="tok-operator">.</span><span class="tok-propertyName">hybrid_score</span></div><div class="cm-line">    <span class="tok-operator">+</span> <span class="tok-number">0.20</span> <span class="tok-operator">*</span> <span class="tok-variableName">candidate</span><span class="tok-operator">.</span><span class="tok-propertyName">thematic_fit</span></div><div class="cm-line">    <span class="tok-operator">+</span> <span class="tok-number">0.15</span> <span class="tok-operator">*</span> <span class="tok-variableName">candidate</span><span class="tok-operator">.</span><span class="tok-propertyName">tonal_fit</span></div><div class="cm-line">    <span class="tok-operator">+</span> <span class="tok-number">0.10</span> <span class="tok-operator">*</span> <span class="tok-variableName">candidate</span><span class="tok-operator">.</span><span class="tok-propertyName">clarity_fit</span></div><div class="cm-line">    <span class="tok-operator">+</span> <span class="tok-number">0.10</span> <span class="tok-operator">*</span> <span class="tok-variableName">candidate</span><span class="tok-operator">.</span><span class="tok-propertyName">rhetorical_fit</span></div><div class="cm-line"><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<p class="wp-block-paragraph">Each signal measures something different: how well the topic matches, whether the tone is right, whether the passage would work rhetorically. The most product-shaped one is <code>clarity_fit</code>. It now favors excerpts that are short, sentence-bounded, and easy to paste into an email without further editing.</p>



<p class="wp-block-paragraph">That matters because pure semantic similarity misses a very obvious real-world constraint: a 200-word passage from Homer might be relevant, but it&#8217;s still useless if what you need is a quotable closing line.</p>



<h2 class="wp-block-heading">Strands Agents with Structured Output</h2>



<p class="wp-block-paragraph">The analyzer and selector use <a href="https://github.com/strands-agents/sdk-python">Strands Agents</a>, an open-source SDK for building AI agents. The key feature I rely on is structured output: instead of asking the LLM to return free text and then parsing it, the SDK forces the model to fill in a typed Python object directly.</p>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-variableName">agent</span> <span class="tok-operator">=</span> <span class="tok-variableName">Agent</span><span class="tok-punctuation">(</span></div><div class="cm-line">    <span class="tok-variableName">model</span><span class="tok-operator">=</span><span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">model</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">system_prompt</span><span class="tok-operator">=</span><span class="tok-punctuation">(</span></div><div class="cm-line">        <span class="tok-string">&quot;You analyze short emails or messages for a rhetorical quote recommender. &quot;</span></div><div class="cm-line">        <span class="tok-string">&quot;First infer the input language from the text itself. &quot;</span></div><div class="cm-line">        <span class="tok-string">&quot;Return the detected language plus concise, grounded rhetorical analysis.&quot;</span></div><div class="cm-line">    <span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line"><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-variableName">result</span> <span class="tok-operator">=</span> <span class="tok-variableName">agent</span><span class="tok-punctuation">(</span><span class="tok-variableName">prompt</span><span class="tok-punctuation">,</span> <span class="tok-variableName">structured_output_model</span><span class="tok-operator">=</span><span class="tok-variableName">AnalyzerResult</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<p class="wp-block-paragraph">The <code>AnalyzerResult</code> is a Pydantic model with fields like <code>main_theme</code>, <code>tone</code>, <code>intent</code>, <code>dominant_emotion</code>, and <code>recommended_quote_type</code>. The model doesn&#8217;t write prose, it fills in a form. This eliminates a whole category of bugs related to parsing LLM output.</p>



<p class="wp-block-paragraph">The selector agent works the same way: it receives the ranked candidates as JSON and returns exactly three choices, each with a <code>why_it_fits</code> explanation written in the language of the original message.</p>



<h2 class="wp-block-heading">Compact Quotes, Not Paragraphs</h2>



<p class="wp-block-paragraph">One thing became obvious very quickly: finding a semantically relevant passage is not the same as finding a quotable one. For emails and short messages, a 70-word block of Homer is basically unusable.</p>



<p class="wp-block-paragraph">So before the selector sees the candidates, the reranker extracts a compact quote window from each passage, usually one or two sentences, under 32 words. The quote still comes verbatim from the indexed corpus, but the system stops treating the full chunk as the thing you&#8217;d actually paste into an email.</p>



<p class="wp-block-paragraph">That small layer makes a big difference. It biases the output toward something you can actually use without turning your message into a wall of text.</p>



<h2 class="wp-block-heading">Representative Examples</h2>



<p class="wp-block-paragraph">Here are two representative examples of the kind of output the system is designed to produce.</p>



<h3 class="wp-block-heading">Example 1</h3>



<p class="wp-block-paragraph"><strong>Input</strong></p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Thanks for your feedback. I do not fully agree with the proposal, but I think we can still find a middle ground and move forward.</p>
</blockquote>



<p class="wp-block-paragraph"><strong>Analysis</strong></p>



<ul class="wp-block-list">
<li>Summary: Polite disagreement looking for a workable compromise</li>



<li>Main theme: Negotiation</li>



<li>Tone: Conciliatory</li>



<li>Intent: Negotiate</li>



<li>Dominant emotion: Controlled tension</li>
</ul>



<p class="wp-block-paragraph"><strong>Recommended quote</strong></p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">But now let each becalm his troubled breast,<br>Wash, and partake serene the friendly feast.</p>
</blockquote>



<p class="wp-block-paragraph">Homer, <em>The Odyssey</em></p>



<p class="wp-block-paragraph"><strong>Why it fits</strong></p>



<p class="wp-block-paragraph">It cools the temperature without sounding weak. The quote shifts the message away from friction and toward calm, shared ground, and continued conversation.</p>



<h3 class="wp-block-heading">Example 2</h3>



<p class="wp-block-paragraph"><strong>Input</strong></p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">We need to stay focused, make a decision, and keep moving even if the road is rough.</p>
</blockquote>



<p class="wp-block-paragraph"><strong>Analysis</strong></p>



<ul class="wp-block-list">
<li>Summary: Call for disciplined action under pressure</li>



<li>Main theme: Leadership</li>



<li>Tone: Resolute</li>



<li>Intent: Persuade</li>



<li>Dominant emotion: Focus</li>
</ul>



<p class="wp-block-paragraph"><strong>Recommended quote</strong></p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">In battle calm he guides the rapid storm,<br>Wise to resolve, and patient to perform.</p>
</blockquote>



<p class="wp-block-paragraph">Homer, <em>The Odyssey</em></p>



<p class="wp-block-paragraph"><strong>Why it fits</strong></p>



<p class="wp-block-paragraph">It is short, memorable, and action-oriented. The line matches a message that asks for composure, judgment, and forward motion at the same time.</p>



<h2 class="wp-block-heading">Bedrock as the Language Brain</h2>



<p class="wp-block-paragraph">Language detection, rhetorical analysis, quote selection, and translation all go through AWS Bedrock (running Claude Sonnet 4). I tried a local language detector first, but short real-world messages are messy: mixed languages, ticket IDs, URLs, corporate jargon. Bedrock handles that ambiguity much better and keeps the pipeline simpler.</p>



<p class="wp-block-paragraph">The deterministic part of the system is still retrieval and reranking. But for anything that requires actually understanding language, Bedrock does the heavy lifting.</p>



<h2 class="wp-block-heading">Reading EPUBs With Zero Dependencies</h2>



<p class="wp-block-paragraph">The corpus loader handles EPUB files using only Python&#8217;s standard library, <code>zipfile</code>, <code>xml.etree.ElementTree</code>, and <code>html.parser</code>:</p>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">_load_epub</span><span class="tok-punctuation">(</span><span class="tok-variableName">path</span>: <span class="tok-variableName">Path</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">LoadedDocument</span>:</div><div class="cm-line">    <span class="tok-keyword">with</span> <span class="tok-variableName">zipfile</span><span class="tok-operator">.</span><span class="tok-propertyName">ZipFile</span><span class="tok-punctuation">(</span><span class="tok-variableName">path</span><span class="tok-punctuation">,</span> <span class="tok-string">&quot;r&quot;</span><span class="tok-punctuation">)</span> <span class="tok-keyword">as</span> <span class="tok-variableName">archive</span>:</div><div class="cm-line">        <span class="tok-variableName">container_xml</span> <span class="tok-operator">=</span> <span class="tok-variableName">archive</span><span class="tok-operator">.</span><span class="tok-propertyName">read</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;META-INF/container.xml&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">container_root</span> <span class="tok-operator">=</span> <span class="tok-variableName">ET</span><span class="tok-operator">.</span><span class="tok-propertyName">fromstring</span><span class="tok-punctuation">(</span><span class="tok-variableName">container_xml</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">rootfile</span> <span class="tok-operator">=</span> <span class="tok-variableName">container_root</span><span class="tok-operator">.</span><span class="tok-propertyName">find</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;.//c:rootfile&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">CONTAINER_NS</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">        <span class="tok-variableName">opf_root</span> <span class="tok-operator">=</span> <span class="tok-variableName">ET</span><span class="tok-operator">.</span><span class="tok-propertyName">fromstring</span><span class="tok-punctuation">(</span><span class="tok-variableName">archive</span><span class="tok-operator">.</span><span class="tok-propertyName">read</span><span class="tok-punctuation">(</span><span class="tok-variableName">opf_path</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-comment"># Parse manifest, follow the spine, extract XHTML chapters</span></div><div class="cm-line">        <span class="tok-keyword">for</span> <span class="tok-variableName">chapter_path</span> <span class="tok-keyword">in</span> <span class="tok-variableName">spine_paths</span>:</div><div class="cm-line">            <span class="tok-variableName">chapter_html</span> <span class="tok-operator">=</span> <span class="tok-variableName">archive</span><span class="tok-operator">.</span><span class="tok-propertyName">read</span><span class="tok-punctuation">(</span><span class="tok-variableName">chapter_path</span><span class="tok-punctuation">)</span><span class="tok-operator">.</span><span class="tok-propertyName">decode</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;utf-8&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">errors</span><span class="tok-operator">=</span><span class="tok-string">&quot;ignore&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line">            <span class="tok-variableName">text</span> <span class="tok-operator">=</span> <span class="tok-variableName">_extract_epub_text</span><span class="tok-punctuation">(</span><span class="tok-variableName">chapter_html</span><span class="tok-punctuation">)</span></div><div class="cm-line">            <span class="tok-variableName">chapters</span><span class="tok-operator">.</span><span class="tok-propertyName">append</span><span class="tok-punctuation">(</span><span class="tok-variableName">text</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<p class="wp-block-paragraph">An EPUB is really just a ZIP file with XML and HTML inside. The loader opens the ZIP, reads the table of contents (the OPF file), follows the chapter order (the spine), and extracts clean text from each HTML chapter. No external libraries needed, just Python&#8217;s built-in tools.</p>



<h2 class="wp-block-heading">The HashingEmbedder: Deterministic Tests Without Models</h2>



<p class="wp-block-paragraph">Running the full pipeline in tests means loading Sentence Transformers, which means downloading a 90MB model. Instead, there&#8217;s a <code>HashingEmbedder</code> that produces fake-but-consistent embeddings using SHA1:</p>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">class</span> <span class="tok-className">HashingEmbedder</span>:</div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">_embed</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">,</span> <span class="tok-variableName">text</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">np</span><span class="tok-operator">.</span><span class="tok-propertyName">ndarray</span>:</div><div class="cm-line">        <span class="tok-variableName">vector</span> <span class="tok-operator">=</span> <span class="tok-variableName">np</span><span class="tok-operator">.</span><span class="tok-propertyName">zeros</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">dimension</span><span class="tok-punctuation">,</span> <span class="tok-variableName">dtype</span><span class="tok-operator">=</span><span class="tok-variableName">np</span><span class="tok-operator">.</span><span class="tok-propertyName">float32</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-keyword">for</span> <span class="tok-variableName">token</span> <span class="tok-keyword">in</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_tokenize</span><span class="tok-punctuation">(</span><span class="tok-variableName">text</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">            <span class="tok-variableName">digest</span> <span class="tok-operator">=</span> <span class="tok-variableName">hashlib</span><span class="tok-operator">.</span><span class="tok-propertyName">sha1</span><span class="tok-punctuation">(</span><span class="tok-variableName">token</span><span class="tok-operator">.</span><span class="tok-propertyName">encode</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;utf-8&quot;</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span><span class="tok-operator">.</span><span class="tok-propertyName">hexdigest</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line">            <span class="tok-variableName">bucket</span> <span class="tok-operator">=</span> <span class="tok-variableName">int</span><span class="tok-punctuation">(</span><span class="tok-variableName">digest</span><span class="tok-punctuation">[</span>:<span class="tok-number">8</span><span class="tok-punctuation">]</span><span class="tok-punctuation">,</span> <span class="tok-number">16</span><span class="tok-punctuation">)</span> <span class="tok-operator">%</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">dimension</span></div><div class="cm-line">            <span class="tok-variableName">sign</span> <span class="tok-operator">=</span> <span class="tok-number">1.0</span> <span class="tok-keyword">if</span> <span class="tok-variableName">int</span><span class="tok-punctuation">(</span><span class="tok-variableName">digest</span><span class="tok-punctuation">[</span><span class="tok-number">8</span>:<span class="tok-number">10</span><span class="tok-punctuation">]</span><span class="tok-punctuation">,</span> <span class="tok-number">16</span><span class="tok-punctuation">)</span> <span class="tok-operator">%</span> <span class="tok-number">2</span> <span class="tok-operator">==</span> <span class="tok-number">0</span> <span class="tok-keyword">else</span> <span class="tok-operator">-</span><span class="tok-number">1.0</span></div><div class="cm-line">            <span class="tok-variableName">vector</span><span class="tok-punctuation">[</span><span class="tok-variableName">bucket</span><span class="tok-punctuation">]</span> <span class="tok-operator">+=</span> <span class="tok-variableName">sign</span></div><div class="cm-line">        <span class="tok-variableName">norm</span> <span class="tok-operator">=</span> <span class="tok-variableName">np</span><span class="tok-operator">.</span><span class="tok-propertyName">linalg</span><span class="tok-operator">.</span><span class="tok-propertyName">norm</span><span class="tok-punctuation">(</span><span class="tok-variableName">vector</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-keyword">if</span> <span class="tok-variableName">norm</span> <span class="tok-operator">&gt;</span> <span class="tok-number">0</span>:</div><div class="cm-line">            <span class="tok-variableName">vector</span> <span class="tok-operator">/=</span> <span class="tok-variableName">norm</span></div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">vector</span></div></code></pre>
		</div>
	</div>
</div>


<p class="wp-block-paragraph">The idea: each word gets hashed to a position and a direction in the vector. The same word always produces the same result, so the tests are reproducible. These embeddings don&#8217;t understand meaning, &#8220;king&#8221; and &#8220;queen&#8221; won&#8217;t be close, but the whole pipeline runs exactly the same way, with real numbers flowing through every step. Tests stay fast, offline, and predictable.</p>



<h2 class="wp-block-heading">Source code</h2>



<p class="wp-block-paragraph">Full source code available in my <a href="https://github.com/gonzalo123/classical-quote-recommender">GitHub repository</a>.</p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/06/08/what-homer-would-reply-to-your-email-a-classical-quote-recommender-with-bedrock-rag-and-strands-agents/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">88361</post-id>	</item>
		<item>
		<title>AI-Powered CloudWatch Logs Analysis with Python and Strands Agents</title>
		<link>https://gonzalo123.com/2026/05/11/ai-powered-cloudwatch-logs-analysis-with-python-and-strands-agents/</link>
					<comments>https://gonzalo123.com/2026/05/11/ai-powered-cloudwatch-logs-analysis-with-python-and-strands-agents/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 11 May 2026 12:24:03 +0000</pubDate>
				<category><![CDATA[Technology]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[cloudwatch]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[StrandsAgents]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=87732</guid>

					<description><![CDATA[Configure known log groups in settings.py: Now you can ask specific questions about your logs: With custom CloudWatch Insights query: Using natural language time ranges: The project also allows us to launch an interactive session for exploratory analysis: This ensures you can analyze any time range without manual intervention, regardless of log volume. For large &#8230; <a href="https://gonzalo123.com/2026/05/11/ai-powered-cloudwatch-logs-analysis-with-python-and-strands-agents/" class="more-link">Continue reading <span class="screen-reader-text">AI-Powered CloudWatch Logs Analysis with Python and Strands Agents</span></a>]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>When you’re debugging production issues at 3 AM, the last thing you want is to scroll through thousands of CloudWatch log entries trying to find that one error. I’ve built a CLI tool that uses AWS Bedrock (Claude Sonnet 4.5) to analyze CloudWatch logs intelligently. You ask questions in natural language, and it gives you insights instead of raw log dumps.</p>
<p>This project is an exploration of combining AWS services with AI agents. Yes, it’s probably over-engineered for simple log queries, but it demonstrates interesting patterns for handling large datasets with parallel AI processing.</p>
<h2>The Problem</h2>
<p>CloudWatch Logs Insights is powerful, but it has limitations:</p>
<ul>
<li>You need to know the query syntax</li>
<li>Results are raw data, not insights</li>
<li>Large result sets are overwhelming</li>
<li>Pattern recognition requires manual analysis</li>
</ul>
<p>What if you could ask: “What errors occurred in the last 2 hours?” and get an intelligent summary instead of 10,000 raw log entries?</p>
<h2>Architecture</h2>
<p>The tool implements two interaction modes: direct CLI queries and an interactive agent.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2025/12/online_flowchart___diagrams_editor_-_mermaid_live_editor.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="448" height="732" data-attachment-id="87753" data-permalink="https://gonzalo123.com/2026/05/11/ai-powered-cloudwatch-logs-analysis-with-python-and-strands-agents/online_flowchart___diagrams_editor_-_mermaid_live_editor/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2025/12/online_flowchart___diagrams_editor_-_mermaid_live_editor.png?fit=448%2C732&amp;ssl=1" data-orig-size="448,732" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="Online_FlowChart___Diagrams_Editor_-_Mermaid_Live_Editor" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2025/12/online_flowchart___diagrams_editor_-_mermaid_live_editor.png?fit=448%2C732&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2025/12/online_flowchart___diagrams_editor_-_mermaid_live_editor.png?resize=448%2C732&#038;ssl=1" alt="" class="wp-image-87753" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><h3>Key Components</h3>
<p><strong>CloudWatch Insights Query Layer</strong> (<code>modules/logs/main.py</code>)</p>
<ul>
<li>Recursively subdivides time ranges when hitting AWS’s 10,000 result limit</li>
<li>Parses natural language time ranges (“last 2 hours”, “since yesterday”)</li>
<li>Supports custom CloudWatch Insights query syntax</li>
</ul>
<p><strong>Smart Dataset Routing</strong></p>
<ul>
<li>Small datasets (2,000 logs): Parallel worker-coordinator pattern</li>
<li>Configurable chunk size and max workers</li>
</ul>
<p><strong>Worker-Coordinator Pattern</strong>
Each worker agent analyzes a chunk of logs (2,000 records), then a coordinator agent synthesizes all analyses into a coherent answer. This architecture allows processing 10,000+ log records efficiently while staying within Claude’s context limits.</p>
<p><strong>Interactive Agent</strong> (<code>agents/log_agent.py</code>)
A specialized agent with access to the <code>analyze_cloudwatch_logs</code> tool, configured with known log groups and time parsing capabilities.</p>
<h2>Technology Stack</h2>
<ul>
<li><strong>Python 3.13</strong> with type hints and Pydantic models</li>
<li><strong>boto3</strong> for AWS CloudWatch Logs API</li>
<li><strong>AWS Bedrock</strong> (Claude Sonnet 4.5) for AI analysis</li>
<li><strong>Strands Agents</strong> for agent orchestration and tool integration</li>
<li><strong>Click</strong> for CLI interface</li>
</ul>
<p>Create your AWS credentials and configure the tool:</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
# Environment variables
AWS_REGION=eu-central-1
AWS_PROFILE_NAME=your-profile
MAX_CHUNKS_TO_PROCESS=5  # Safety limit for cost control

# Optional: Define known log groups
KNOWN_LOG_GROUPS=&quot;/aws/lambda/api,/aws/ecs/backend&quot;
</pre></div>


<p class="wp-block-paragraph">Configure known log groups in <code>settings.py</code>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
class LogGroups(StrEnum):
    &quot;&quot;&quot;Known CloudWatch log groups for type-safe references.&quot;&quot;&quot;
    API_LAMBDA = &quot;/aws/lambda/api&quot;
    BACKEND_ECS = &quot;/aws/ecs/backend-service&quot;
    DATABASE_RDS = &quot;/aws/rds/instance/prod/postgresql&quot;
</pre></div>


<p class="wp-block-paragraph">Now you can ask specific questions about your logs:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
poetry run python src/cli.py log \
  --group &quot;/aws/lambda/api-handler&quot; \
  --question &quot;What errors occurred?&quot; \
  --start &quot;2025-12-20T10:00:00&quot; \
  --end &quot;2025-12-20T12:00:00&quot;
</pre></div>


<p class="wp-block-paragraph">With custom CloudWatch Insights query:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
poetry run python src/cli.py log \
  --group &quot;/aws/lambda/payment&quot; \
  --question &quot;Analyze payment failures&quot; \
  --start &quot;2025-12-20&quot; \
  --query &quot;fields @timestamp, @message, userId | filter @message like /ERROR/&quot;
</pre></div>


<p class="wp-block-paragraph">Using natural language time ranges:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
poetry run python src/cli.py log \
  --group &quot;/aws/ecs/backend&quot; \
  --question &quot;What performance issues occurred?&quot; \
  --start &quot;last 2 hours&quot;
</pre></div>


<p class="wp-block-paragraph">The project also allows us to launch an interactive session for exploratory analysis:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
poetry run python src/cli.py agent
</pre></div>


<div class="wp-block-jetpack-markdown"><p>Example interaction:</p>
<pre><code>============================================================
CloudWatch Logs Analysis Agent
============================================================
Ask me about your CloudWatch logs!

Examples:
  - What errors occurred in /aws/lambda/api in the last hour?
  - Analyze /aws/ecs/backend-service from last 2 hours for memory issues
  - Show me exceptions in /aws/lambda/payment-api since yesterday

Type 'exit', 'quit', or 'q' to quit.
============================================================

&amp;gt; What errors happened in /aws/lambda/api in the last hour?

[Agent analyzes logs and provides intelligent summary]

&amp;gt; Were there any timeouts?

[Agent refines analysis based on context]
</code></pre>
<p>The agent mode maintains conversation context and can refine analyses based on follow-up questions.</p>
<p>CloudWatch Insights limits results to 10,000 records per query. The tool automatically subdivides time ranges when hitting this limit:</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
def query_chunk_recursively(log_group: str, start: datetime, end: datetime,
                           query: str, depth: int = 0) -&gt; list&#x5B;list&#x5B;dict]]:
    &quot;&quot;&quot;
    Queries a time chunk and subdivides it recursively if it hits the result limit.
    Returns all log entries for the given time range.
    &quot;&quot;&quot;
    status, rows = insights_query(log_group, start=start, end=end,
                                 query=query, limit=MAX_RESULTS_PER_QUERY)

    if len(rows) &gt;= MAX_RESULTS_PER_QUERY:
        # Subdivide in half
        midpoint = start + (end - start) / 2
        first_half = query_chunk_recursively(log_group, start, midpoint, query, depth + 1)
        second_half = query_chunk_recursively(log_group, midpoint, end, query, depth + 1)
        return first_half + second_half

    return rows
</pre></div>


<p class="wp-block-paragraph">This ensures you can analyze any time range without manual intervention, regardless of log volume.</p>



<p class="wp-block-paragraph">For large datasets, logs are split into chunks and processed in parallel:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
def analyze_chunk_with_worker(
    chunk: LogChunk, question: str, log_group: str, global_metadata: dict
) -&gt; ChunkAnalysisResult:
    &quot;&quot;&quot;
    Analyze a single chunk of logs using a worker agent.
    Each worker gets chunk-specific context and the user&#039;s question.
    &quot;&quot;&quot;
    worker_prompt = WORKER_AGENT_PROMPT.format(
        chunk_index=chunk.chunk_index + 1,
        total_chunks=chunk.total_chunks,
        chunk_size=chunk.chunk_size,
        time_range=chunk.get_time_range_description(),
        question=question,
    )

    worker_agent = create_agent(
        system_prompt=worker_prompt,
        model=Models.CLAUDE_45,
        temperature=0.3,
        read_timeout=WORKER_TIMEOUT_SECONDS,
    )

    chunk_context = {
        &quot;metadata&quot;: {...},
        &quot;logs&quot;: chunk.logs,
    }

    result = worker_agent(prompt=&#x5B;
        {&quot;text&quot;: f&quot;Question: {question}&quot;},
        {&quot;text&quot;: f&quot;Log context: {json.dumps(chunk_context)}&quot;},
        {&quot;text&quot;: &quot;Analyze this chunk of logs according to the guidelines in your system prompt.&quot;},
    ])

    return ChunkAnalysisResult(...)
</pre></div>


<p class="wp-block-paragraph">Workers run concurrently using ThreadPoolExecutor:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
with ThreadPoolExecutor(max_workers=MAX_PARALLEL_WORKERS) as executor:
    future_to_chunk = {
        executor.submit(analyze_chunk_with_worker, chunk, question, log_group, global_metadata): chunk
        for chunk in chunks
    }

    for future in as_completed(future_to_chunk):
        result = future.result()
        chunk_results.append(result)
</pre></div>


<div class="wp-block-jetpack-markdown"><p>After workers complete, a coordinator agent synthesizes their analyses:</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
def consolidate_with_coordinator(
    chunk_results: list&#x5B;ChunkAnalysisResult],
    question: str,
    log_group: str,
    start: datetime,
    end: datetime,
    total_records: int,
) -&gt; str:
    &quot;&quot;&quot;
    Use coordinator agent to synthesize chunk analyses into final answer.
    &quot;&quot;&quot;
    coordinator_context = {
        &quot;metadata&quot;: {
            &quot;log_group&quot;: log_group,
            &quot;time_range&quot;: f&quot;{start.isoformat()} to {end.isoformat()}&quot;,
            &quot;total_records&quot;: total_records,
            &quot;total_chunks&quot;: len(chunk_results),
        },
        &quot;chunk_analyses&quot;: &#x5B;
            {
                &quot;chunk_index&quot;: r.chunk_index + 1,
                &quot;time_range&quot;: r.chunk_time_range,
                &quot;analysis&quot;: r.analysis,
            }
            for r in successful_results
        ],
    }

    result = coordinator(prompt=&#x5B;
        {&quot;text&quot;: f&quot;Original Question: {question}&quot;},
        {&quot;text&quot;: f&quot;Chunk Analyses: {json.dumps(coordinator_context)}&quot;},
        {&quot;text&quot;: &quot;Synthesize these chunk analyses to answer the user&#039;s question.&quot;},
    ])

    return str(result)
</pre></div>


<p class="wp-block-paragraph">This pattern allows analyzing datasets far exceeding Claude&#8217;s context window while maintaining coherent insights.</p>



<p class="wp-block-paragraph">The tool supports flexible time specifications:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
# modules/logs/time_parser.py
def parse_time_range(time_range: str) -&gt; tuple&#x5B;datetime, datetime]:
    &quot;&quot;&quot;
    Parse natural language time ranges:
    - &quot;last 2 hours&quot;
    - &quot;since yesterday&quot;
    - &quot;2025-12-10 to 2025-12-12&quot;
    - &quot;last 7 days&quot;
    &quot;&quot;&quot;
    # Implementation handles various patterns
    pass
</pre></div>


<p class="wp-block-paragraph">The <code>analyze_cloudwatch_logs</code> tool integrates with Strands agents:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
@tool
def analyze_cloudwatch_logs(
    log_group: Annotated&#x5B;Union&#x5B;LogGroups, str], &quot;CloudWatch log group name&quot;],
    question: Annotated&#x5B;str, &quot;Question to answer about the logs&quot;],
    time_range: Annotated&#x5B;Optional&#x5B;str], &quot;Time range examples: &#039;last 2 hours&#039;, &#039;since yesterday&#039;&quot;] = None,
    cloudwatch_sql: Annotated&#x5B;Optional&#x5B;str], &quot;CloudWatch Insights query string&quot;] = None,
) -&gt; dict:
    &quot;&quot;&quot;
    Analyze AWS CloudWatch Logs to answer questions about application behavior.
    Automatically handles large datasets through parallel chunking.
    &quot;&quot;&quot;
    # Parse time range
    start_dt, end_dt = parse_time_range(time_range or &quot;last 24 hours&quot;)

    # Call the existing analysis function
    analysis, metadata = ask_to_log(log_group, question, start_dt, end_dt,
                                   cloudwatch_sql=cloudwatch_sql or DEFAULT_CW_SQL)

    return {
        &quot;status&quot;: &quot;success&quot;,
        &quot;content&quot;: &#x5B;{&quot;text&quot;: f&quot;Analysis for log group &#039;{log_group}&#039;:\n\n{analysis}&quot;}],
        &quot;metadata&quot;: metadata,
    }
</pre></div>


<p class="wp-block-paragraph">This tool can be composed with other agent tools for more sophisticated workflows.</p>



<p class="wp-block-paragraph">Each worker agent receives context about its role in the larger analysis:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
WORKER_AGENT_PROMPT = &quot;&quot;&quot;You are a CloudWatch Logs Analysis Worker Agent.

Role: Analyze a specific chunk of logs (part {chunk_index} of {total_chunks})
Time range: {time_range}
Chunk size: {chunk_size} log records

Your task:
1. Analyze this chunk for patterns, errors, anomalies related to: {question}
2. Provide factual observations, not speculation
3. Note timestamps for important events
4. Be concise - a coordinator will synthesize all chunks

Focus on:
- Error messages and stack traces
- Unusual patterns or spikes
- Performance indicators
- User-impacting events

Output format: Concise bullet points with timestamps.
&quot;&quot;&quot;
</pre></div>


<p class="wp-block-paragraph">The coordinator synthesizes worker outputs into coherent insights:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
COORDINATOR_AGENT_PROMPT = &quot;&quot;&quot;You are a CloudWatch Logs Coordinator Agent.

Role: Synthesize analyses from {chunks_processed} worker agents
Dataset: {total_records} total log records
Time range: {time_range}

You&#039;ve received chunk-level analyses. Your task:
1. Identify patterns across all chunks
2. Synthesize a coherent narrative answering the user&#039;s question
3. Highlight critical findings
4. Provide actionable insights

Output format:
- Executive summary
- Key findings (chronological if relevant)
- Patterns or trends observed
- Recommendations (if applicable)

Be direct and actionable. Focus on what matters.
&quot;&quot;&quot;
</pre></div>


<p class="wp-block-paragraph">Processing large log volumes with AI can get expensive. The tool includes configurable safety limits:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">

</pre></div>

<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
# settings.py
MAX_CHUNKS_TO_PROCESS = int(os.getenv(&quot;MAX_CHUNKS_TO_PROCESS&quot;, &quot;5&quot;))

# In main.py
if len(chunks) &gt; MAX_CHUNKS_TO_PROCESS:
    error_msg = (
        f&quot;Dataset would generate {len(chunks)} chunks, which exceeds the maximum limit &quot;
        f&quot;of {MAX_CHUNKS_TO_PROCESS} chunks.\n\n&quot;
        f&quot;Options:\n&quot;
        f&quot;  1. Reduce time range to analyze fewer logs\n&quot;
        f&quot;  2. Increase MAX_CHUNKS_TO_PROCESS in settings\n&quot;
        f&quot;  3. Use more specific CloudWatch Insights filters&quot;
    )
    return f&quot;ERROR: {error_msg}&quot;, {...}
</pre></div>


<p class="wp-block-paragraph">With default settings (chunk size = 2,000, max chunks = 5), you can analyze up to 10,000 log records per query. Adjust these values based on your budget and requirements.</p>



<p class="wp-block-paragraph">The project uses Pydantic for all data structures:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
# modules/logs/models.py
class LogChunk(BaseModel):
    &quot;&quot;&quot;Represents a chunk of logs for parallel processing.&quot;&quot;&quot;
    chunk_index: int
    total_chunks: int
    chunk_size: int
    start_timestamp: str | None
    end_timestamp: str | None
    logs: list&#x5B;dict&#x5B;str, str]]

    def get_time_range_description(self) -&gt; str:
        if self.start_timestamp and self.end_timestamp:
            return f&quot;{self.start_timestamp} to {self.end_timestamp}&quot;
        return &quot;Unknown time range&quot;


class ChunkAnalysisResult(BaseModel):
    &quot;&quot;&quot;Result from a worker agent analyzing a chunk.&quot;&quot;&quot;
    chunk_index: int
    chunk_time_range: str
    chunk_size: int
    analysis: str
    success: bool = True
    error_message: str | None = None
    processing_time_seconds: float = 0.0
</pre></div>


<p class="wp-block-paragraph">Let&#8217;s say you&#8217;re investigating a production incident:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
# Step 1: Check what happened in the last hour
poetry run python src/cli.py log \
  --group &quot;/aws/lambda/payment-api&quot; \
  --question &quot;What errors occurred?&quot; \
  --start &quot;last 1 hour&quot;

# Output:
# Analysis for log group &#039;/aws/lambda/payment-api&#039; from 2025-12-20T14:00:00 to 2025-12-20T15:00:00:
#
# Key Findings:
# - 47 payment timeout errors between 14:23 and 14:45
# - Errors clustered around Stripe API calls
# - No database connection issues observed
# - Timeout duration: consistently 30 seconds
#
# Pattern: All failures occurred during userId sessions starting with &#039;eu-&#039;
# suggesting regional routing issue.
#
# &#x5B;Metadata: 8,432 records, 5 chunks, 23.4s]
</pre></div>


<p class="wp-block-paragraph">The agent identified the pattern (regional issue) and specific time window without you writing complex queries or manually reviewing logs.</p>



<p class="wp-block-paragraph">This project is deliberately over-engineered. For simple log queries, CloudWatch Insights is sufficient. But building this taught me about:</p>



<ul class="wp-block-list">
<li>Managing AI context window limits at scale</li>



<li>Worker-coordinator patterns for parallel processing</li>



<li>Designing tools for agent consumption</li>



<li>Balancing cost vs. capability in AI systems</li>
</ul>



<p class="wp-block-paragraph">We can use this tool effectively in scenarios like:</p>



<ul class="wp-block-list">
<li>Debugging complex incidents requiring pattern recognition</li>



<li>Onboarding new team members who don&#8217;t know your query syntax</li>



<li>Exploratory analysis where you don&#8217;t know what you&#8217;re looking for</li>



<li>Generating incident reports from raw logs</li>
</ul>



<p class="wp-block-paragraph">When NOT to Use This:</p>



<ul class="wp-block-list">
<li>Real-time monitoring (use CloudWatch alarms)</li>



<li>Known queries you run repeatedly (use saved Insights queries)</li>



<li>Cost-sensitive environments (AI analysis adds expense)</li>
</ul>



<p class="wp-block-paragraph">AI agents transform log analysis from query construction to question asking. Instead of learning CloudWatch Insights syntax, you describe what you want to know. The worker-coordinator pattern demonstrates how to scale AI analysis beyond single-agent context limits.</p>



<p class="wp-block-paragraph">Is it practical for every use case? No. Is it interesting to build and explore? Absolutely.</p>



<p class="wp-block-paragraph">The complete implementation is available in my <a href="https://github.com/gonzalo123/cw.logs">GitHub</a> account.</p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/05/11/ai-powered-cloudwatch-logs-analysis-with-python-and-strands-agents/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">87732</post-id>	</item>
		<item>
		<title>Removing Clickbait from News Articles with an AI Agent, Python, Strands Agents, and AWS Bedrock</title>
		<link>https://gonzalo123.com/2026/05/04/removing-clickbait-from-news-articles-with-an-ai-agent-python-strands-agents-and-aws-bedrock/</link>
					<comments>https://gonzalo123.com/2026/05/04/removing-clickbait-from-news-articles-with-an-ai-agent-python-strands-agents-and-aws-bedrock/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 04 May 2026 12:15:05 +0000</pubDate>
				<category><![CDATA[Technology]]></category>
		<category><![CDATA[agentic-ai]]></category>
		<category><![CDATA[ai]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Bedrock]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[StrandsAgents]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=88343</guid>

					<description><![CDATA[]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>The web is full of articles that do not want to tell you what happened too soon. The headline hints at something. The first paragraphs add suspense. The useful information is somewhere below the fold, after the cookie banner, the newsletter box, a couple of related links, and enough scrolling to make the advertising model happy.</p>
<p>That is annoying when all we want is the news.</p>
<p>That’s my PoC. A small command-line application that receives the URL of a news article, converts the page into clean Markdown, and asks an AI agent to rewrite it as clear journalism: direct headline, concise lead, short paragraphs, no clickbait.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="369" data-attachment-id="88346" data-permalink="https://gonzalo123.com/2026/05/04/removing-clickbait-from-news-articles-with-an-ai-agent-python-strands-agents-and-aws-bedrock/logo-9/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?fit=1672%2C941&amp;ssl=1" data-orig-size="1672,941" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="logo" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?fit=656%2C369&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?resize=656%2C369&#038;ssl=1" alt="" class="wp-image-88346" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?resize=1024%2C576&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?resize=300%2C169&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?resize=768%2C432&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?resize=1536%2C864&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?resize=1200%2C675&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?w=1672&amp;ssl=1 1672w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/logo-1.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>The idea is simple:</p>
<pre><code class="language-bash">plainnews rewrite &quot;https://example.com/news/article&quot;
</code></pre>
<p>The CLI does not scrape the page directly. It gives the URL to a Strands Agent. The agent has one tool, <code>fetch_url_as_markdown</code>, and the model decides when to use it. Once the article is available as Markdown, the agent rewrites it following a focused system prompt.</p>
<h2>The architecture</h2>
<p>The flow is straightforward:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large coblocks-animate" data-coblocks-animation="slideInLeft"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="161" data-attachment-id="88348" data-permalink="https://gonzalo123.com/2026/05/04/removing-clickbait-from-news-articles-with-an-ai-agent-python-strands-agents-and-aws-bedrock/gonzalo123_plainnews__rewritetrands_agents_and_aws_bedrock/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?fit=1374%2C338&amp;ssl=1" data-orig-size="1374,338" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="gonzalo123_plainnews__Rewrite…trands_Agents_and_AWS_Bedrock" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?fit=656%2C161&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?resize=656%2C161&#038;ssl=1" alt="" class="wp-image-88348" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?resize=1024%2C252&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?resize=300%2C74&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?resize=768%2C189&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?resize=1200%2C295&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?w=1374&amp;ssl=1 1374w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/gonzalo123_plainnews__Rewrite%E2%80%A6trands_Agents_and_AWS_Bedrock.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>The important part is the boundary between the agent and the tool. Fetching a web page, removing navigation, and converting HTML into Markdown is deterministic Python code. Deciding how to rewrite the story is the LLM’s job.</p>
<p>This keeps the PoC small and easy to reason about.</p>
<h2>Project structure</h2>
<p>I like to keep configuration in <code>settings.py</code>. It is a pattern I borrowed years ago from Django and I still use it in small prototypes because it keeps things simple:</p>
<pre><code class="language-text">src/
  cli.py
  settings.py
  commands/
    rewrite.py
  lib/
    agent.py
    prompts.py
    tools.py
    ui.py
  env/
    local/
      .env.example
tests/
</code></pre>
<p>The responsibilities are intentionally small:</p>
<ul>
<li><code>src/commands/rewrite.py</code> contains the Click command.</li>
<li><code>src/lib/tools.py</code> contains the Strands tool and the HTML-to-Markdown pipeline.</li>
<li><code>src/lib/agent.py</code> wires Strands Agents with AWS Bedrock.</li>
<li><code>src/lib/prompts.py</code> keeps the editor prompt and the user task prompt.</li>
<li><code>src/lib/ui.py</code> renders Markdown in the terminal with Rich.</li>
</ul>
<h2>Fetching a URL as Markdown</h2>
<p>The agent only gets one tool. It fetches the URL, removes noisy page elements, selects the main content, converts it to Markdown, and truncates the result to 100K characters:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">tool</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">fetch_url_as_markdown</span><span class="tok-punctuation">(</span><span class="tok-variableName">url</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">    <span class="tok-string">&quot;&quot;&quot;</span></div><div class="cm-line"><span class="tok-string">    Fetch an HTTP or HTTPS URL, remove navigation, ads, scripts and layout noise,</span></div><div class="cm-line"><span class="tok-string">    extract the main article content, convert it to Markdown, and return up to</span></div><div class="cm-line"><span class="tok-string">    100K characters of clean text.</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Use this tool when the user pastes a URL or asks you to analyze a web page.</span></div><div class="cm-line"><span class="tok-string">    &quot;&quot;&quot;</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">fetch_url_as_markdown_impl</span><span class="tok-punctuation">(</span><span class="tok-variableName">url</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">clean_html_to_markdown</span><span class="tok-punctuation">(</span><span class="tok-variableName">html</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">,</span> <span class="tok-keyword">*</span><span class="tok-punctuation">,</span> <span class="tok-variableName">max_chars</span>: <span class="tok-variableName">int</span> <span class="tok-operator">=</span> <span class="tok-number">100_000</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">    <span class="tok-variableName">soup</span> <span class="tok-operator">=</span> <span class="tok-variableName">BeautifulSoup</span><span class="tok-punctuation">(</span><span class="tok-variableName">html</span><span class="tok-punctuation">,</span> <span class="tok-string">&quot;html.parser&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">for</span> <span class="tok-variableName">selector</span> <span class="tok-keyword">in</span> <span class="tok-variableName">NOISY_SELECTORS</span>:</div><div class="cm-line">        <span class="tok-keyword">for</span> <span class="tok-variableName">tag</span> <span class="tok-keyword">in</span> <span class="tok-variableName">soup</span><span class="tok-operator">.</span><span class="tok-propertyName">select</span><span class="tok-punctuation">(</span><span class="tok-variableName">selector</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">            <span class="tok-variableName">tag</span><span class="tok-operator">.</span><span class="tok-propertyName">decompose</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-variableName">content</span> <span class="tok-operator">=</span> <span class="tok-variableName">soup</span><span class="tok-operator">.</span><span class="tok-propertyName">find</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;main&quot;</span><span class="tok-punctuation">)</span> <span class="tok-keyword">or</span> <span class="tok-variableName">soup</span><span class="tok-operator">.</span><span class="tok-propertyName">find</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;article&quot;</span><span class="tok-punctuation">)</span> <span class="tok-keyword">or</span> <span class="tok-variableName">soup</span><span class="tok-operator">.</span><span class="tok-propertyName">body</span></div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-variableName">content</span> <span class="tok-keyword">is</span> <span class="tok-keyword">None</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-string">&quot;&quot;</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-variableName">markdown</span> <span class="tok-operator">=</span> <span class="tok-variableName">md</span><span class="tok-punctuation">(</span><span class="tok-variableName">str</span><span class="tok-punctuation">(</span><span class="tok-variableName">content</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span> <span class="tok-variableName">heading_style</span><span class="tok-operator">=</span><span class="tok-string">&quot;ATX&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">bullets</span><span class="tok-operator">=</span><span class="tok-string">&quot;-&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">strip</span><span class="tok-operator">=</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;a&quot;</span><span class="tok-punctuation">]</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-variableName">markdown</span> <span class="tok-operator">=</span> <span class="tok-variableName">normalize_markdown</span><span class="tok-punctuation">(</span><span class="tok-variableName">markdown</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-variableName">len</span><span class="tok-punctuation">(</span><span class="tok-variableName">markdown</span><span class="tok-punctuation">)</span> <span class="tok-operator">&gt;</span> <span class="tok-variableName">max_chars</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">markdown</span><span class="tok-punctuation">[</span>:<span class="tok-variableName">max_chars</span><span class="tok-punctuation">]</span><span class="tok-operator">.</span><span class="tok-propertyName">rstrip</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span> <span class="tok-operator">+</span> <span class="tok-string">&quot;</span><span class="tok-string2">\n</span><span class="tok-string2">\n</span><span class="tok-string">[Content truncated]&quot;</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">markdown</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>I am not trying to build a perfect browser engine here. This is a PoC. The goal is to get enough readable article content for the agent to work with. For many news pages, removing scripts, navigation, cookie boxes, newsletter blocks, related links and advertising containers is enough.</p>
<h2>The agent</h2>
<p>The agent uses Claude on AWS Bedrock through Strands Agents:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">create_agent</span><span class="tok-punctuation">(</span><span class="tok-keyword">*</span><span class="tok-punctuation">,</span> <span class="tok-variableName">settings</span>: <span class="tok-variableName">Settings</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">Agent</span>:</div><div class="cm-line">    <span class="tok-variableName">boto_session</span> <span class="tok-operator">=</span> <span class="tok-variableName">create_boto_session</span><span class="tok-punctuation">(</span><span class="tok-variableName">settings</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">Agent</span><span class="tok-punctuation">(</span></div><div class="cm-line">        <span class="tok-variableName">model</span><span class="tok-operator">=</span><span class="tok-variableName">BedrockModel</span><span class="tok-punctuation">(</span></div><div class="cm-line">            <span class="tok-variableName">boto_session</span><span class="tok-operator">=</span><span class="tok-variableName">boto_session</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-variableName">model_id</span><span class="tok-operator">=</span><span class="tok-variableName">settings</span><span class="tok-operator">.</span><span class="tok-propertyName">resolved_bedrock_model_id</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">tools</span><span class="tok-operator">=</span><span class="tok-punctuation">[</span><span class="tok-variableName">fetch_url_as_markdown</span><span class="tok-punctuation">]</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">system_prompt</span><span class="tok-operator">=</span><span class="tok-variableName">SYSTEM_PROMPT</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>The system prompt is the editorial policy. It tells the model to preserve only facts supported by the fetched article, answer in the requested output language, put the most important information first, remove suspense and filler, and write in a neutral tone.</p>
<p>The output format is Markdown:</p>
<ul>
<li>a direct H1 headline</li>
<li>a concise lead paragraph</li>
<li>short factual paragraphs</li>
<li>a final <code>What changed</code> section, translated to the requested output language, explaining
what noise was removed</li>
</ul>
<p>That last section is useful during development. It gives us a quick sanity check: did the model actually remove clickbait, or did it just paraphrase the article?</p>
<h2>The CLI</h2>
<p>The command is intentionally small:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">click</span><span class="tok-operator">.</span><span class="tok-variableName">command</span><span class="tok-punctuation">(</span><span class="tok-variableName">name</span><span class="tok-operator">=</span><span class="tok-string">&quot;rewrite&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">click</span><span class="tok-operator">.</span><span class="tok-variableName">argument</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;url&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">runtime_options</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">rewrite_command</span><span class="tok-punctuation">(</span></div><div class="cm-line">    <span class="tok-variableName">url</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">aws_profile</span>: <span class="tok-variableName">str</span> <span class="tok-operator">|</span> <span class="tok-keyword">None</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">region</span>: <span class="tok-variableName">str</span> <span class="tok-operator">|</span> <span class="tok-keyword">None</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">model</span>: <span class="tok-variableName">str</span> <span class="tok-operator">|</span> <span class="tok-keyword">None</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-variableName">language</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">,</span></div><div class="cm-line"><span class="tok-punctuation">)</span> -&gt; <span class="tok-keyword">None</span>:</div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-keyword">not</span> <span class="tok-variableName">is_supported_url</span><span class="tok-punctuation">(</span><span class="tok-variableName">url</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">        <span class="tok-keyword">raise</span> <span class="tok-variableName">click</span><span class="tok-operator">.</span><span class="tok-propertyName">ClickException</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;URL must start with http:// or https://&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-variableName">settings</span> <span class="tok-operator">=</span> <span class="tok-variableName">resolve_settings</span><span class="tok-punctuation">(</span></div><div class="cm-line">        <span class="tok-variableName">aws_profile</span><span class="tok-operator">=</span><span class="tok-variableName">aws_profile</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">aws_region</span><span class="tok-operator">=</span><span class="tok-variableName">region</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">bedrock_model_id</span><span class="tok-operator">=</span><span class="tok-variableName">model</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-variableName">agent</span> <span class="tok-operator">=</span> <span class="tok-variableName">create_agent</span><span class="tok-punctuation">(</span><span class="tok-variableName">settings</span><span class="tok-operator">=</span><span class="tok-variableName">settings</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-variableName">result</span> <span class="tok-operator">=</span> <span class="tok-variableName">agent</span><span class="tok-punctuation">(</span><span class="tok-variableName">build_rewrite_prompt</span><span class="tok-punctuation">(</span><span class="tok-variableName">url</span><span class="tok-punctuation">,</span> <span class="tok-variableName">language</span><span class="tok-operator">=</span><span class="tok-variableName">language</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-variableName">print_result</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;PlainNews&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">str</span><span class="tok-punctuation">(</span><span class="tok-variableName">result</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>The CLI validates the URL, creates the agent, sends the URL in the prompt, and renders the final Markdown with Rich.</p>
<p>The tool is not called manually from the command. That is the point of this PoC: the URL is part of the task, and the agent decides to call <code>fetch_url_as_markdown</code> because the tool description says it should be used when the user pastes a URL or asks to analyze a web page.</p>
<h2>Usage</h2>
<p>Run the command:</p>
<pre><code class="language-bash">poetry run plainnews rewrite &quot;https://example.com/news/article&quot;
</code></pre>
<p>By default, PlainNews writes the rewritten article in English. You can choose a
different output language with <code>--language</code>:</p>
<pre><code class="language-bash">poetry run plainnews rewrite &quot;https://example.com/news/article&quot; --language Spanish
</code></pre>
<p>The output is rendered as Markdown in the terminal.</p>
<p>Example terminal output:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large coblocks-animate" data-coblocks-animation="slideInLeft"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="299" data-attachment-id="88352" data-permalink="https://gonzalo123.com/2026/05/04/removing-clickbait-from-news-articles-with-an-ai-agent-python-strands-agents-and-aws-bedrock/demo/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?fit=1524%2C695&amp;ssl=1" data-orig-size="1524,695" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="demo" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?fit=656%2C299&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?resize=656%2C299&#038;ssl=1" alt="" class="wp-image-88352" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?resize=1024%2C467&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?resize=300%2C137&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?resize=768%2C350&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?resize=1200%2C547&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?resize=656%2C300&amp;ssl=1 656w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?w=1524&amp;ssl=1 1524w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/05/demo.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><h2>Tech stack</h2>
<ul>
<li><strong>Python</strong> with Poetry</li>
<li><strong>Strands Agents</strong> for tool-based agent orchestration</li>
<li><strong>AWS Bedrock</strong> for the LLM runtime</li>
<li><strong>BeautifulSoup</strong> for HTML cleanup</li>
<li><strong>markdownify</strong> for HTML-to-Markdown conversion</li>
<li><strong>Click</strong> for the command-line interface</li>
<li><strong>Rich</strong> for Markdown terminal rendering</li>
<li><strong>pytest</strong> for tests</li>
</ul>
<h2>A couple of notes</h2>
<p>This is not a product and it is not a universal paywall remover. It is a small agentic workflow for a very specific frustration: articles that make readers work too hard to understand the basic facts.</p>
<p>Even in this small version, the pattern is useful: deterministic Python code prepares clean context, and the AI agent performs the editorial rewrite with a tight prompt.</p>
<p>And that’s all. Full source code available on <a href="https://github.com/gonzalo123/plainnews">GitHub</a>.</p>
</div>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/05/04/removing-clickbait-from-news-articles-with-an-ai-agent-python-strands-agents-and-aws-bedrock/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">88343</post-id>	</item>
		<item>
		<title>Production-Ready Logging with AWS CloudWatch for Python applications</title>
		<link>https://gonzalo123.com/2026/04/27/production-ready-logging-with-aws-cloudwatch-for-python-applications/</link>
					<comments>https://gonzalo123.com/2026/04/27/production-ready-logging-with-aws-cloudwatch-for-python-applications/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 27 Apr 2026 12:13:22 +0000</pubDate>
				<category><![CDATA[Uncategorized]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=87746</guid>

					<description><![CDATA[from typing import Literal, Unionfrom pathlib import Path def setup_logging(env: Literal[&#8216;local&#8217;, &#8216;production&#8217;],app: str,log_path: Union[Path, str],process: str = &#8216;main&#8217;,log_level: str = &#8216;INFO&#8217;) -&#62; None:&#8220;&#8221;&#8221;Configure logging with environment-specific settings.&#8221;&#8221;&#8221;if env == &#8216;local&#8217;:logging.basicConfig(format=&#8217;%(asctime)s [%(levelname)s] %(message)s&#8217;,level=log_level,datefmt=&#8217;%d/%m/%Y %X&#8217;)else:# Console handler with human-readable formatconsole_handler = logging.StreamHandler()console_formatter = ConsoleFormatter(fmt=&#8217;%(asctime)s [%(levelname)s] %(name)s &#8211; %(message)s&#8217;,datefmt=&#8217;%Y-%m-%d %H:%M:%S&#8217;)console_handler.setFormatter(console_formatter) Console output: JSON output (CloudWatch): The CloudWatch Agent &#8230; <a href="https://gonzalo123.com/2026/04/27/production-ready-logging-with-aws-cloudwatch-for-python-applications/" class="more-link">Continue reading <span class="screen-reader-text">Production-Ready Logging with AWS CloudWatch for Python applications</span></a>]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>Today we’re going to build a production-grade logging system for Python applications using. We’re going to use CloudWatch Agent with it’s <code>auto_removal</code> feature to automatically delete log files after they’ve been uploaded to CloudWatch Logs.</p>
<p>This architectural constraint requires careful design of your logging pipeline.</p>
<p>The solution uses a dual-formatter approach:</p>
<ul>
<li><strong>Console output</strong>: Human-readable format for <code>docker compose logs</code></li>
<li><strong>File output</strong>: Structured JSON for CloudWatch with hourly rotation</li>
<li><strong>CloudWatch Agent</strong>: Reads rotated files and automatically deletes them after upload</li>
</ul>
<pre><code>Flask App
    ↓
Logging System (dual formatters)
    ├─→ Console Handler → Human-readable + extras
    └─→ File Handler → JSON (hourly rotation)
            ↓
        Rotated files (app.log.YYYY-MM-DD_HH)
            ↓
        CloudWatch Agent (auto_removal: true)
            ↓
        AWS CloudWatch Logs
</code></pre>
<p>The logging system uses two custom formatters to serve different purposes:</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
from datetime import datetime
from logging.handlers import TimedRotatingFileHandler
from pythonjsonlogger.json import JsonFormatter
import logging

class CloudWatchJsonFormatter(JsonFormatter):
    &quot;&quot;&quot;JSON formatter for CloudWatch logs with custom metadata fields.&quot;&quot;&quot;

    def __init__(self, app: str, process: str, *args, **kwargs):
        self.app = app
        self.process = process
        super().__init__(*args, **kwargs)

    def add_fields(self, log_record, record, message_dict):
        &quot;&quot;&quot;Add CloudWatch-specific fields to log record.&quot;&quot;&quot;
        log_record&#x5B;&#039;@timestamp&#039;] = datetime.fromtimestamp(record.created).isoformat()
        log_record&#x5B;&#039;level&#039;] = record.levelname
        log_record&#x5B;&#039;app&#039;] = self.app
        log_record&#x5B;&#039;logger&#039;] = record.name
        log_record&#x5B;&#039;process&#039;] = self.process
        super(CloudWatchJsonFormatter, self).add_fields(log_record, record, message_dict)


class ConsoleFormatter(logging.Formatter):
    &quot;&quot;&quot;Console formatter that includes extra fields.&quot;&quot;&quot;

    RESERVED_ATTRS = {
        &#039;name&#039;, &#039;msg&#039;, &#039;args&#039;, &#039;created&#039;, &#039;filename&#039;, &#039;funcName&#039;, &#039;levelname&#039;,
        &#039;levelno&#039;, &#039;lineno&#039;, &#039;module&#039;, &#039;msecs&#039;, &#039;message&#039;, &#039;pathname&#039;, &#039;process&#039;,
        &#039;processName&#039;, &#039;relativeCreated&#039;, &#039;thread&#039;, &#039;threadName&#039;, &#039;exc_info&#039;,
        &#039;exc_text&#039;, &#039;stack_info&#039;, &#039;asctime&#039;
    }

    def format(self, record):
        base_message = super().format(record)

        # Extract extra fields
        extras = {
            key: value
            for key, value in record.__dict__.items()
            if key not in self.RESERVED_ATTRS
        }

        if extras:
            extras_str = &#039; &#039;.join(f&#039;{k}={v}&#039; for k, v in extras.items())
            return f&#039;{base_message} | {extras_str}&#039;

        return base_message
</pre></div>


<div class="wp-block-jetpack-markdown"><p>The <code>ConsoleFormatter</code> automatically appends extra fields to the log message, making debugging easier. The <code>CloudWatchJsonFormatter</code> creates structured JSON logs with CloudWatch-specific metadata.</p>
<p>The key to making <code>auto_removal</code> work is using <code>TimedRotatingFileHandler</code> with hourly rotation:</p>
</div>



<p class="wp-block-paragraph">from typing import Literal, Union<br>from pathlib import Path</p>



<p class="wp-block-paragraph">def setup_logging(<br>env: Literal[&#8216;local&#8217;, &#8216;production&#8217;],<br>app: str,<br>log_path: Union[Path, str],<br>process: str = &#8216;main&#8217;,<br>log_level: str = &#8216;INFO&#8217;<br>) -&gt; None:<br>&#8220;&#8221;&#8221;Configure logging with environment-specific settings.&#8221;&#8221;&#8221;<br>if env == &#8216;local&#8217;:<br>logging.basicConfig(<br>format=&#8217;%(asctime)s [%(levelname)s] %(message)s&#8217;,<br>level=log_level,<br>datefmt=&#8217;%d/%m/%Y %X&#8217;<br>)<br>else:<br># Console handler with human-readable format<br>console_handler = logging.StreamHandler()<br>console_formatter = ConsoleFormatter(<br>fmt=&#8217;%(asctime)s [%(levelname)s] %(name)s &#8211; %(message)s&#8217;,<br>datefmt=&#8217;%Y-%m-%d %H:%M:%S&#8217;<br>)<br>console_handler.setFormatter(console_formatter)</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
    # JSON formatter for file (CloudWatch)
    json_formatter = CloudWatchJsonFormatter(
        app=app,
        process=process,
        fmt=&#039;%(levelname)s %(name)s %(message)s&#039;
    )

    # File handler with hourly rotation
    file_handler = TimedRotatingFileHandler(
        log_path,
        when=&#039;H&#039;,        # Hourly rotation
        interval=1,      # Every 1 hour
        backupCount=2,   # Keep 2 backups
        encoding=&#039;utf-8&#039;
    )
    file_handler.setLevel(logging.INFO)
    file_handler.setFormatter(json_formatter)

    logging.basicConfig(
        level=log_level,
        handlers=&#x5B;console_handler, file_handler]
    )
</pre></div>


<div class="wp-block-jetpack-markdown"><p>Why hourly rotation? CloudWatch Agent’s <code>auto_removal</code> only deletes complete files. With daily rotation, you’d have up to 24 hours of logs accumulating. Hourly rotation minimizes disk usage to just 1-2 hours of logs at any time.</p>
<p><strong>Important</strong>: Do not use minute-level rotation (<code>when='M'</code>). Fast rotation intervals cause timing issues where CloudWatch Agent cannot properly track file inodes during rotation, leading to log loss or incorrect file deletion. AWS documentation recommends hourly or longer rotation intervals for reliable <code>auto_removal</code> behavior.</p>
<p>Using the logger is straightforward. Extra fields are automatically handled:</p>
</div>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: python; title: ; notranslate">
import logging
from flask import Flask
from lib.logger import setup_logging
from settings import APP, PROCESS, LOG_PATH, ENVIRONMENT

app = Flask(__name__)
logger = logging.getLogger(__name__)

setup_logging(
    env=ENVIRONMENT,
    app=APP,
    process=PROCESS,
    log_path=LOG_PATH
)

@app.get(&quot;/&quot;)
def health():
    logger.info(&quot;GET /&quot;, extra=dict(
        user_id=123,
        response_time_ms=45
    ))
    return {&#039;status&#039;: &#039;ok&#039;}
</pre></div>


<p class="wp-block-paragraph"><strong>Console output:</strong></p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
2025-12-13 12:30:45 &#x5B;INFO] app - GET / | user_id=123 response_time_ms=45
</pre></div>


<p class="wp-block-paragraph"><strong>JSON output (CloudWatch):</strong></p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: jscript; title: ; notranslate">
{
  &quot;@timestamp&quot;: &quot;2025-12-13T12:30:45.123456&quot;,
  &quot;level&quot;: &quot;INFO&quot;,
  &quot;logger&quot;: &quot;app&quot;,
  &quot;app&quot;: &quot;cw_demo&quot;,
  &quot;process&quot;: &quot;cw_demo&quot;,
  &quot;message&quot;: &quot;GET /&quot;,
  &quot;user_id&quot;: 123,
  &quot;response_time_ms&quot;: 45
}
</pre></div>


<p class="wp-block-paragraph">The CloudWatch Agent uses <code>auto_removal</code> to delete rotated files automatically:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: jscript; title: ; notranslate">
{
  &quot;agent&quot;: {
    &quot;debug&quot;: false
  },
  &quot;logs&quot;: {
    &quot;logs_collected&quot;: {
      &quot;files&quot;: {
        &quot;collect_list&quot;: &#x5B;
          {
            &quot;file_path&quot;: &quot;/logs/*.log*&quot;,
            &quot;log_group_name&quot;: &quot;${LOG_GROUP_NAME}&quot;,
            &quot;log_stream_name&quot;: &quot;{hostname}&quot;,
            &quot;auto_removal&quot;: true
          }
        ]
      }
    }
  }
}
</pre></div>


<p class="wp-block-paragraph">The wildcard pattern <code>/logs/*.log*</code> matches any log file and its rotations (e.g., <code>app.log</code>, <code>cw.log</code>, <code>worker.log</code> and their rotated versions like <code>app.log.2025-12-13_12</code>). This allows different applications to use different log file names based on their <code>app_id</code>.</p>



<p class="wp-block-paragraph">The <code>LOG_GROUP_NAME</code> environment variable is injected at runtime by the entrypoint script. If not provided, it defaults to <code>/app/logs</code>.</p>



<p class="wp-block-paragraph">The application runs alongside CloudWatch Agent with a shared volume:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: yaml; title: ; notranslate">
services:
  api:
    build:
      context: .
    volumes:
      - logs_volume:/src/logs
    environment:
      - ENVIRONMENT=production
      - PROCESS_ID=api
    ports:
      - 5000:5000
    command: gunicorn -w 1 app:app -b 0.0.0.0:5000 --timeout 180

  cloudwatch-agent:
    build:
      context: .docker/cw
    volumes:
      - logs_volume:/logs
    environment:
      - LOG_GROUP_NAME=/mi-proyecto/app  # Optional: defaults to /app/logs if not set

volumes:
  logs_volume:
</pre></div>


<p class="wp-block-paragraph">The CloudWatch Agent container&#8217;s entrypoint script handles the <code>LOG_GROUP_NAME</code> variable with a default value:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
#!/bin/sh
set -e

# Set default value for LOG_GROUP_NAME if not provided
LOG_GROUP_NAME=${LOG_GROUP_NAME:-/app/logs}

# Replace LOG_GROUP_NAME environment variable in the config file
sed &quot;s|\${LOG_GROUP_NAME}|${LOG_GROUP_NAME}|g&quot; \
    /opt/aws/amazon-cloudwatch-agent/bin/config.template.json &gt; /opt/aws/amazon-cloudwatch-agent/bin/default_linux_config.json

echo &quot;CloudWatch Agent starting with LOG_GROUP_NAME=${LOG_GROUP_NAME}&quot;

# Start the CloudWatch Agent
exec /opt/aws/amazon-cloudwatch-agent/bin/start-amazon-cloudwatch-agent
</pre></div>


<p class="wp-block-paragraph">This ensures the agent always has a valid log group name, even if the environment variable is not explicitly set.</p>



<p class="wp-block-paragraph">When using CloudWatch Insights, remember to use the actual field names from your JSON structure:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: bash; title: ; notranslate">
fields @timestamp, level, logger, message, user_id, response_time_ms
| filter level = &quot;ERROR&quot;
| sort @timestamp desc
| limit 100
</pre></div>


<p class="wp-block-paragraph">This logging architecture uses sidecar patterns to decouple application logic from logging concerns, ensuring robust, production-ready logging with minimal disk usage and automatic log management via AWS CloudWatch.</p>



<p class="wp-block-paragraph">full code in my GitHub <a href="https://github.com/gonzalo123/py2cw">account</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/04/27/production-ready-logging-with-aws-cloudwatch-for-python-applications/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">87746</post-id>	</item>
		<item>
		<title>What if you could ask questions to any GitHub repository? Building a repository-aware AI agent with Python, Strands Agents, and Bedrock</title>
		<link>https://gonzalo123.com/2026/04/06/what-if-you-could-ask-questions-to-any-github-repository-building-a-repository-aware-ai-agent-with-python-strands-agents-and-bedrock/</link>
					<comments>https://gonzalo123.com/2026/04/06/what-if-you-could-ask-questions-to-any-github-repository-building-a-repository-aware-ai-agent-with-python-strands-agents-and-bedrock/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 06 Apr 2026 14:19:41 +0000</pubDate>
				<category><![CDATA[Uncategorized]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=88206</guid>

					<description><![CDATA[Full code in my github]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>Sometimes we land on an unfamiliar GitHub repository and the first problem is not writing code. The real problem is understanding the project fast enough. Is this a REST API? Where are the entrypoints? How is the application wired? Are there obvious risks in the codebase? If the repository is big enough, answering those questions manually is slow and boring.</p>
<p>That’s just my PoC. An interactive command-line application that can inspect any public GitHub repository and answer questions about it.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter"><img data-recalc-dims="1" decoding="async" src="https://i0.wp.com/github.com/gonzalo123/github.kb/raw/main/assets/logo.png?w=656&#038;ssl=1" alt="logo"/></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>I have the feeling this workflow should exist natively on GitHub. Once repositories become large enough, being able to ask architecture, audit, or API questions feels like a natural evolution of code search and Copilot. Maybe the reason it does not exist yet is cost, scope, or product complexity. In the meantime, a CLI-first open source approach feels like a good place to start: simple, scriptable, hackable, and based on bring-your-own-model credentials so each user keeps control of their own usage and billing.</p>
<p>The idea is simple. We give a GitHub repository to a CLI application. The CLI creates a local checkout, exposes a small set of repository-aware tools to a Strands Agent, and lets the agent inspect the project with AWS Bedrock. Because the agent can list directories, search code and read files, we can ask practical questions such as:</p>
<ul>
<li>Explain how the project works</li>
<li>Audit the codebase looking for risks</li>
<li>List the API endpoints</li>
<li>Describe the execution flow of a specific module</li>
</ul>
<p>This is not a vector database project and it is not a RAG pipeline. It is a much simpler approach. We let the agent explore the repository directly, file by file, using tools.</p>
<h2>The architecture</h2>
<p>The flow is straightforward:</p>
<ol>
<li>The user calls the CLI with a GitHub repository.</li>
<li>The repository is cloned into a local cache.</li>
<li>A Strands Agent is created with a Bedrock model.</li>
<li>The agent receives a system prompt plus four tools:
<code>get_directory_tree</code>, <code>list_directory</code>, <code>search_code</code> and <code>read_file</code>.</li>
<li>The agent inspects the repository and returns the final answer in Markdown.</li>
</ol>
<p>This is enough for a surprising number of use cases. If the system prompt is focused on architecture, the answer becomes an explanation. If the prompt is focused on risk, the answer becomes a code audit. If the prompt is focused on HTTP routes, the answer becomes an API inventory.</p>
<h2>Project structure</h2>
<p>I like to keep configuration in <code>settings.py</code>. It is a pattern I borrowed years ago from Django and I still use it in small prototypes because it keeps things simple:</p>
<pre><code class="language-text">src/
└── github_kb/
    ├── cli.py
    ├── settings.py
    ├── commands/
    │   ├── ask.py
    │   ├── audit.py
    │   ├── chat.py
    │   ├── endpoints.py
    │   └── explain.py
    ├── lib/
    │   ├── agent.py
    │   ├── github.py
    │   ├── models.py
    │   ├── prompts.py
    │   ├── repository.py
    │   └── ui.py
    └── env/
        └── local/
            └── .env.example
</code></pre>
<p>The responsibilities are small and explicit:</p>
<ul>
<li><code>github_kb/commands/</code> contains the Click commands.</li>
<li><code>github_kb/lib/github.py</code> resolves the GitHub repository and manages the local checkout.</li>
<li><code>github_kb/lib/repository.py</code> contains the repository exploration logic used by the agent tools.</li>
<li><code>github_kb/lib/agent.py</code> wires Strands Agents with AWS Bedrock.</li>
<li><code>github_kb/lib/prompts.py</code> keeps the system prompt and the task-specific prompts in one place.</li>
</ul>
<h2>Why this works</h2>
<p>Large repositories are difficult because we rarely need the whole repository at once. We normally need a guided exploration strategy. A tree view helps us identify the shape of the project. Search helps us jump to the interesting files. Reading files gives us the final confirmation.</p>
<p>That sequence maps very well to tool-based agents.</p>
<p>Instead of trying to send the whole repository in one prompt, the model can progressively inspect only the relevant parts. It is cheaper, easier to reason about, and much closer to how we inspect an unknown codebase ourselves.</p>
<h2>Install</h2>
<p>The intended installation flow is:</p>
<pre><code class="language-bash">pipx install github-kb
</code></pre>
<h2>Quick start</h2>
<p>The happy path should look like this:</p>
<pre><code class="language-bash">aws sso login --profile sandbox
AWS_PROFILE=sandbox AWS_REGION=us-west-2 github-kb doctor
AWS_PROFILE=sandbox AWS_REGION=us-west-2 github-kb chat gonzalo123/autofix
</code></pre>
<p>The CLI is designed to work out of the box with the standard AWS credential chain. That means it can use:</p>
<ul>
<li><code>AWS_PROFILE</code></li>
<li><code>AWS_REGION</code></li>
<li><code>aws sso login</code></li>
<li>regular access keys if they are already configured in the environment</li>
</ul>
<p>By default, <code>github-kb</code> uses <code>global.anthropic.claude-sonnet-4-6</code> unless <code>BEDROCK_MODEL_ID</code> or <code>--model</code> says otherwise.</p>
<p>You can also override the runtime explicitly with CLI flags such as <code>--aws-profile</code>, <code>--region</code>, and <code>--model</code>.</p>
<h2>Usage</h2>
<p>Now we can ask questions:</p>
<pre><code class="language-bash">github-kb ask gonzalo123/autofix &quot;How does the automated fix flow work?&quot;
github-kb chat gonzalo123/autofix
github-kb explain gonzalo123/autofix --topic architecture
github-kb audit gonzalo123/autofix --focus github
github-kb endpoints gonzalo123/autofix
github-kb doctor
</code></pre>
<p>If we want to keep the same conversation alive across multiple questions in one terminal session:</p>
<pre><code class="language-bash">github-kb chat gonzalo123/autofix
</code></pre>
<p>It also accepts full GitHub URLs:</p>
<pre><code class="language-bash">github-kb ask https://github.com/gonzalo123/autofix &quot;Where is the application bootstrapped?&quot;
</code></pre>
<p>If we want to refresh the local cache:</p>
<pre><code class="language-bash">github-kb audit gonzalo123/autofix --refresh
</code></pre>
<p>We can also pass the AWS runtime explicitly:</p>
<pre><code class="language-bash">github-kb chat gonzalo123/autofix --aws-profile sandbox --region eu-central-1
github-kb ask gonzalo123/autofix &quot;Explain the architecture&quot; --model global.anthropic.claude-sonnet-4-6
</code></pre>
<h2>Demo screenshots</h2>
<p>Here are a few real screenshots generated against one of my own repositories, <a href="https://github.com/gonzalo123/autofix"><code>gonzalo123/autofix</code></a>.</p>
<p>The screenshots below are embedded as PNG files:</p>
<h3><code>explain</code></h3>
</div>



<figure class="wp-block-image"><img data-recalc-dims="1" decoding="async" src="https://i0.wp.com/github.com/gonzalo123/github.kb/raw/main/assets/demo-explain.png?w=656&#038;ssl=1" alt="Explain demo"/></figure>



<div class="wp-block-jetpack-markdown"><h3><code>endpoints</code></h3>
</div>



<figure class="wp-block-image"><img data-recalc-dims="1" decoding="async" src="https://i0.wp.com/github.com/gonzalo123/github.kb/raw/main/assets/demo-endpoints.png?w=656&#038;ssl=1" alt="Endpoints demo"/></figure>



<div class="wp-block-jetpack-markdown"><h3><code>audit</code></h3>
</div>



<figure class="wp-block-image"><img data-recalc-dims="1" decoding="async" src="https://i0.wp.com/github.com/gonzalo123/github.kb/raw/main/assets/demo-audit.png?w=656&#038;ssl=1" alt="Audit demo"/></figure>



<div class="wp-block-jetpack-markdown"><h2>A couple of notes</h2>
<p>This is still a PoC. The goal is not to build a perfect repository analysis platform. The goal is to validate a simple idea: an agent with a tiny set of well-chosen tools can already be useful for code understanding.</p>
<p>There are several obvious next steps:</p>
<ul>
<li>add more repository-aware tools</li>
<li>persist analysis sessions</li>
<li>summarize previous findings before starting a new question</li>
<li>support GitHub authentication for private repositories</li>
<li>add specialized prompts for security reviews or framework-specific inspections</li>
</ul>
<p>Even in its current state, it is already a nice example of how tool-based agents can help with a very real developer problem.</p>
</div>



<p class="wp-block-paragraph">Full code in my <a href="https://github.com/gonzalo123/github.kb">github</a></p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/04/06/what-if-you-could-ask-questions-to-any-github-repository-building-a-repository-aware-ai-agent-with-python-strands-agents-and-bedrock/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">88206</post-id>	</item>
		<item>
		<title>Exposing a REST API Through MCP: Turning Any API into an AI Tool</title>
		<link>https://gonzalo123.com/2026/03/23/exposing-a-rest-api-through-mcp-turning-any-api-into-an-ai-tool/</link>
					<comments>https://gonzalo123.com/2026/03/23/exposing-a-rest-api-through-mcp-turning-any-api-into-an-ai-tool/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 23 Mar 2026 13:34:54 +0000</pubDate>
				<category><![CDATA[Technology]]></category>
		<category><![CDATA[mcp]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[rest]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=88035</guid>

					<description><![CDATA[That&#8217;s exactly what this project does. We take a standard Flask REST API and wrap it with an MCP server using FastMCP. Any MCP-compatible client, Claude Code, Cursor, Windsurf, can discover and call the API endpoints as tools, without knowing there&#8217;s a REST layer underneath.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>Most organizations already have REST APIs. They power internal dashboards, connect microservices, expose data to mobile apps. They work. But now you want an AI agent to use those same services, and agents don’t speak REST. They speak MCP. Do you rewrite everything? No. You build a thin adapter layer that translates between the two protocols, and your existing API stays untouched.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="438" data-attachment-id="88043" data-permalink="https://gonzalo123.com/2026/03/23/exposing-a-rest-api-through-mcp-turning-any-api-into-an-ai-tool/logo-5/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?fit=1536%2C1024&amp;ssl=1" data-orig-size="1536,1024" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="logo" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?fit=656%2C438&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?resize=656%2C438&#038;ssl=1" alt="" class="wp-image-88043" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?resize=1024%2C683&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?resize=300%2C200&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?resize=768%2C512&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?resize=1200%2C800&amp;ssl=1 1200w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?w=1536&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<p class="wp-block-paragraph">That&#8217;s exactly what this project does. We take a standard Flask REST API and wrap it with an MCP server using FastMCP. Any MCP-compatible client, Claude Code, Cursor, Windsurf, can discover and call the API endpoints as tools, without knowing there&#8217;s a REST layer underneath.</p>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="120" data-attachment-id="88041" data-permalink="https://gonzalo123.com/2026/03/23/exposing-a-rest-api-through-mcp-turning-any-api-into-an-ai-tool/gonzalo123_rest2mcp__exposing_a_rest_api_through_mcp__turning_any_api_into_an_ai_tool/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool.png?fit=1035%2C190&amp;ssl=1" data-orig-size="1035,190" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool.png?fit=656%2C120&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool.png?resize=656%2C120&#038;ssl=1" alt="" class="wp-image-88041" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool.png?resize=1024%2C188&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool.png?resize=300%2C55&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool.png?resize=768%2C141&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool.png?w=1035&amp;ssl=1 1035w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><h2>The REST API</h2>
<p>The API is a standard Flask application with CRUD endpoints for managing notes. Nothing special here, just the kind of REST service you’d find in any organization:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-variableName">notes_bp</span> <span class="tok-operator">=</span> <span class="tok-variableName">Blueprint</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;notes&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">__name__</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">notes_bp</span><span class="tok-operator">.</span><span class="tok-variableName">route</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;/api/notes&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">methods</span><span class="tok-operator">=</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;GET&quot;</span><span class="tok-punctuation">]</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">list_notes</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-variableName">get_all_notes</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">notes_bp</span><span class="tok-operator">.</span><span class="tok-variableName">route</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;/api/notes/&lt;int:note_id&gt;&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">methods</span><span class="tok-operator">=</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;GET&quot;</span><span class="tok-punctuation">]</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">read_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-variableName">note</span> <span class="tok-operator">=</span> <span class="tok-variableName">get_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-variableName">note</span> <span class="tok-keyword">is</span> <span class="tok-keyword">None</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-punctuation">{</span><span class="tok-string">&quot;error&quot;</span>: <span class="tok-string">&quot;Note not found&quot;</span><span class="tok-punctuation">}</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span> <span class="tok-number">404</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-variableName">note</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">notes_bp</span><span class="tok-operator">.</span><span class="tok-variableName">route</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;/api/notes&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">methods</span><span class="tok-operator">=</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;POST&quot;</span><span class="tok-punctuation">]</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">add_note</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-variableName">data</span> <span class="tok-operator">=</span> <span class="tok-variableName">request</span><span class="tok-operator">.</span><span class="tok-propertyName">get_json</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-keyword">not</span> <span class="tok-variableName">data</span> <span class="tok-keyword">or</span> <span class="tok-string">&quot;title&quot;</span> <span class="tok-keyword">not</span> <span class="tok-keyword">in</span> <span class="tok-variableName">data</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-punctuation">{</span><span class="tok-string">&quot;error&quot;</span>: <span class="tok-string">&quot;title is required&quot;</span><span class="tok-punctuation">}</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span> <span class="tok-number">400</span></div><div class="cm-line">    <span class="tok-variableName">note</span> <span class="tok-operator">=</span> <span class="tok-variableName">create_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">title</span><span class="tok-operator">=</span><span class="tok-variableName">data</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;title&quot;</span><span class="tok-punctuation">]</span><span class="tok-punctuation">,</span> <span class="tok-variableName">body</span><span class="tok-operator">=</span><span class="tok-variableName">data</span><span class="tok-operator">.</span><span class="tok-propertyName">get</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;body&quot;</span><span class="tok-punctuation">,</span> <span class="tok-string">&quot;&quot;</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-variableName">note</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span> <span class="tok-number">201</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">notes_bp</span><span class="tok-operator">.</span><span class="tok-variableName">route</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;/api/notes/&lt;int:note_id&gt;&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">methods</span><span class="tok-operator">=</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;PUT&quot;</span><span class="tok-punctuation">]</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">edit_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-variableName">data</span> <span class="tok-operator">=</span> <span class="tok-variableName">request</span><span class="tok-operator">.</span><span class="tok-propertyName">get_json</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-variableName">note</span> <span class="tok-operator">=</span> <span class="tok-variableName">update_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span><span class="tok-punctuation">,</span> <span class="tok-variableName">title</span><span class="tok-operator">=</span><span class="tok-variableName">data</span><span class="tok-operator">.</span><span class="tok-propertyName">get</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;title&quot;</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span> <span class="tok-variableName">body</span><span class="tok-operator">=</span><span class="tok-variableName">data</span><span class="tok-operator">.</span><span class="tok-propertyName">get</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;body&quot;</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-variableName">note</span> <span class="tok-keyword">is</span> <span class="tok-keyword">None</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-punctuation">{</span><span class="tok-string">&quot;error&quot;</span>: <span class="tok-string">&quot;Note not found&quot;</span><span class="tok-punctuation">}</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span> <span class="tok-number">404</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-variableName">note</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">notes_bp</span><span class="tok-operator">.</span><span class="tok-variableName">route</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;/api/notes/&lt;int:note_id&gt;&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">methods</span><span class="tok-operator">=</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;DELETE&quot;</span><span class="tok-punctuation">]</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">remove_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-variableName">delete_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-punctuation">{</span><span class="tok-string">&quot;status&quot;</span>: <span class="tok-string">&quot;deleted&quot;</span><span class="tok-punctuation">}</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">jsonify</span><span class="tok-punctuation">(</span><span class="tok-punctuation">{</span><span class="tok-string">&quot;error&quot;</span>: <span class="tok-string">&quot;Note not found&quot;</span><span class="tok-punctuation">}</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span> <span class="tok-number">404</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>Five endpoints: list, get, create, update, delete. The data store is an in-memory dictionary for simplicity, but in a real scenario this would be your existing database, your internal service, your legacy system. The point is that the REST API already exists and works. We don’t want to change it.</p>
<h2>The MCP server</h2>
<p>This is the core of the project. The MCP server uses <a href="https://gofastmcp.com/">FastMCP</a> to expose each REST endpoint as an MCP tool. It uses <code>requests</code> to call the Flask API over HTTP:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">import</span> <span class="tok-variableName">requests</span></div><div class="cm-line"><span class="tok-keyword">from</span> <span class="tok-variableName">mcp</span><span class="tok-operator">.</span><span class="tok-variableName">server</span><span class="tok-operator">.</span><span class="tok-variableName">fastmcp</span> <span class="tok-keyword">import</span> <span class="tok-variableName">FastMCP</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-keyword">from</span> <span class="tok-variableName">settings</span> <span class="tok-keyword">import</span> <span class="tok-variableName">API_BASE_URL</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-variableName">mcp</span> <span class="tok-operator">=</span> <span class="tok-variableName">FastMCP</span><span class="tok-punctuation">(</span><span class="tok-variableName">name</span><span class="tok-operator">=</span><span class="tok-string">&quot;notes-api&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-variableName">BASE</span> <span class="tok-operator">=</span> <span class="tok-variableName">API_BASE_URL</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">mcp</span><span class="tok-operator">.</span><span class="tok-variableName">tool</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">list_notes</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">    <span class="tok-string">&quot;&quot;&quot;List all notes stored in the system.</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Returns a JSON array of note objects, each containing:</span></div><div class="cm-line"><span class="tok-string">    id, title, body, and created_at fields.</span></div><div class="cm-line"><span class="tok-string">    &quot;&quot;&quot;</span></div><div class="cm-line">    <span class="tok-variableName">response</span> <span class="tok-operator">=</span> <span class="tok-variableName">requests</span><span class="tok-operator">.</span><span class="tok-propertyName">get</span><span class="tok-punctuation">(</span><span class="tok-string2">f&quot;</span><span class="tok-punctuation">{</span><span class="tok-variableName">BASE</span><span class="tok-punctuation">}</span><span class="tok-string2">/api/notes&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">response</span><span class="tok-operator">.</span><span class="tok-propertyName">text</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">mcp</span><span class="tok-operator">.</span><span class="tok-variableName">tool</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">get_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">    <span class="tok-string">&quot;&quot;&quot;Get a single note by its ID.</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Returns the note object with id, title, body, and created_at fields.</span></div><div class="cm-line"><span class="tok-string">    Returns an error if the note is not found.</span></div><div class="cm-line"><span class="tok-string">    &quot;&quot;&quot;</span></div><div class="cm-line">    <span class="tok-variableName">response</span> <span class="tok-operator">=</span> <span class="tok-variableName">requests</span><span class="tok-operator">.</span><span class="tok-propertyName">get</span><span class="tok-punctuation">(</span><span class="tok-string2">f&quot;</span><span class="tok-punctuation">{</span><span class="tok-variableName">BASE</span><span class="tok-punctuation">}</span><span class="tok-string2">/api/notes/</span><span class="tok-punctuation">{</span><span class="tok-variableName">note_id</span><span class="tok-punctuation">}</span><span class="tok-string2">&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">response</span><span class="tok-operator">.</span><span class="tok-propertyName">text</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">mcp</span><span class="tok-operator">.</span><span class="tok-variableName">tool</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">create_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">title</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">,</span> <span class="tok-variableName">body</span>: <span class="tok-variableName">str</span> <span class="tok-operator">=</span> <span class="tok-string">&quot;&quot;</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">    <span class="tok-string">&quot;&quot;&quot;Create a new note.</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Args:</span></div><div class="cm-line"><span class="tok-string">        title: The title of the note (required).</span></div><div class="cm-line"><span class="tok-string">        body: The body content of the note (optional, defaults to empty string).</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Returns the created note object with its assigned id.</span></div><div class="cm-line"><span class="tok-string">    &quot;&quot;&quot;</span></div><div class="cm-line">    <span class="tok-variableName">response</span> <span class="tok-operator">=</span> <span class="tok-variableName">requests</span><span class="tok-operator">.</span><span class="tok-propertyName">post</span><span class="tok-punctuation">(</span></div><div class="cm-line">        <span class="tok-string2">f&quot;</span><span class="tok-punctuation">{</span><span class="tok-variableName">BASE</span><span class="tok-punctuation">}</span><span class="tok-string2">/api/notes&quot;</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">json</span><span class="tok-operator">=</span><span class="tok-punctuation">{</span><span class="tok-string">&quot;title&quot;</span>: <span class="tok-variableName">title</span><span class="tok-punctuation">,</span> <span class="tok-string">&quot;body&quot;</span>: <span class="tok-variableName">body</span><span class="tok-punctuation">}</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">response</span><span class="tok-operator">.</span><span class="tok-propertyName">text</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">mcp</span><span class="tok-operator">.</span><span class="tok-variableName">tool</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">update_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">,</span> <span class="tok-variableName">title</span>: <span class="tok-variableName">str</span> <span class="tok-operator">=</span> <span class="tok-string">&quot;&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">body</span>: <span class="tok-variableName">str</span> <span class="tok-operator">=</span> <span class="tok-string">&quot;&quot;</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">    <span class="tok-string">&quot;&quot;&quot;Update an existing note by its ID.</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Args:</span></div><div class="cm-line"><span class="tok-string">        note_id: The ID of the note to update.</span></div><div class="cm-line"><span class="tok-string">        title: New title for the note (optional, send empty string to keep current).</span></div><div class="cm-line"><span class="tok-string">        body: New body for the note (optional, send empty string to keep current).</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Returns the updated note object, or an error if not found.</span></div><div class="cm-line"><span class="tok-string">    &quot;&quot;&quot;</span></div><div class="cm-line">    <span class="tok-variableName">payload</span> <span class="tok-operator">=</span> <span class="tok-punctuation">{</span><span class="tok-punctuation">}</span></div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-variableName">title</span>:</div><div class="cm-line">        <span class="tok-variableName">payload</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;title&quot;</span><span class="tok-punctuation">]</span> <span class="tok-operator">=</span> <span class="tok-variableName">title</span></div><div class="cm-line">    <span class="tok-keyword">if</span> <span class="tok-variableName">body</span>:</div><div class="cm-line">        <span class="tok-variableName">payload</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;body&quot;</span><span class="tok-punctuation">]</span> <span class="tok-operator">=</span> <span class="tok-variableName">body</span></div><div class="cm-line">    <span class="tok-variableName">response</span> <span class="tok-operator">=</span> <span class="tok-variableName">requests</span><span class="tok-operator">.</span><span class="tok-propertyName">put</span><span class="tok-punctuation">(</span></div><div class="cm-line">        <span class="tok-string2">f&quot;</span><span class="tok-punctuation">{</span><span class="tok-variableName">BASE</span><span class="tok-punctuation">}</span><span class="tok-string2">/api/notes/</span><span class="tok-punctuation">{</span><span class="tok-variableName">note_id</span><span class="tok-punctuation">}</span><span class="tok-string2">&quot;</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">json</span><span class="tok-operator">=</span><span class="tok-variableName">payload</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">response</span><span class="tok-operator">.</span><span class="tok-propertyName">text</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-meta">@</span><span class="tok-variableName">mcp</span><span class="tok-operator">.</span><span class="tok-variableName">tool</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">delete_note</span><span class="tok-punctuation">(</span><span class="tok-variableName">note_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">    <span class="tok-string">&quot;&quot;&quot;Delete a note by its ID.</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Args:</span></div><div class="cm-line"><span class="tok-string">        note_id: The ID of the note to delete.</span></div><div class="cm-line"></div><div class="cm-line"><span class="tok-string">    Returns a status confirmation or an error if the note is not found.</span></div><div class="cm-line"><span class="tok-string">    &quot;&quot;&quot;</span></div><div class="cm-line">    <span class="tok-variableName">response</span> <span class="tok-operator">=</span> <span class="tok-variableName">requests</span><span class="tok-operator">.</span><span class="tok-propertyName">delete</span><span class="tok-punctuation">(</span><span class="tok-string2">f&quot;</span><span class="tok-punctuation">{</span><span class="tok-variableName">BASE</span><span class="tok-punctuation">}</span><span class="tok-string2">/api/notes/</span><span class="tok-punctuation">{</span><span class="tok-variableName">note_id</span><span class="tok-punctuation">}</span><span class="tok-string2">&quot;</span><span class="tok-punctuation">)</span></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">response</span><span class="tok-operator">.</span><span class="tok-propertyName">text</span></div><div class="cm-line"></div><div class="cm-line"></div><div class="cm-line"><span class="tok-keyword">if</span> <span class="tok-variableName">__name__</span> <span class="tok-operator">==</span> <span class="tok-string">&quot;__main__&quot;</span>:</div><div class="cm-line">    <span class="tok-variableName">mcp</span><span class="tok-operator">.</span><span class="tok-propertyName">run</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>Each <code>@mcp.tool()</code> maps to one REST endpoint. The decorator extracts parameter types from the function signature to build the JSON schema that MCP clients use to understand what parameters to send. The docstring becomes the tool description that the AI agent reads to decide when and how to call each tool. When you run <code>python src/server/main.py</code>, it starts listening on stdio for MCP requests.</p>
<p>The pattern is straightforward: receive the MCP call, translate it into an HTTP request, forward it to the REST API, and return the response. The MCP server knows nothing about the business logic. The REST API knows nothing about MCP. Each side does its job.</p>
<h2>The adapter pattern</h2>
<p>This is the <strong>Adapter Pattern</strong> applied at the protocol level. The MCP server adapts the REST interface into the MCP protocol. The REST API doesn’t need to change. The MCP client doesn’t need to know it’s talking to a REST service. The adapter handles the translation:</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-full"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool-1.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="314" data-attachment-id="88046" data-permalink="https://gonzalo123.com/2026/03/23/exposing-a-rest-api-through-mcp-turning-any-api-into-an-ai-tool/gonzalo123_rest2mcp__exposing_a_rest_api_through_mcp__turning_any_api_into_an_ai_tool-2/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool-1.png?fit=760%2C364&amp;ssl=1" data-orig-size="760,364" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool-1.png?fit=656%2C314&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool-1.png?resize=656%2C314&#038;ssl=1" alt="" class="wp-image-88046" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool-1.png?w=760&amp;ssl=1 760w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/gonzalo123_rest2mcp__Exposing_a_REST_API_Through_MCP__Turning_Any_API_into_an_AI_Tool-1.png?resize=300%2C144&amp;ssl=1 300w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>The MCP client calls <code>create_note(&quot;Meeting notes&quot;, &quot;Discussed Q3 roadmap&quot;)</code>. The MCP server translates this into a <code>POST /api/notes</code> with a JSON body. The Flask API processes it, creates the note, and returns the result. The MCP server passes the response back to the client. The agent sees a tool that creates notes. It doesn’t know or care that there’s an HTTP call in between.</p>
<h2>Configuration</h2>
<p>To use the MCP server from Claude Code, create a <code>.mcp.json</code> file in your project root:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-json"><div class="cm-line"><span class="tok-punctuation">{</span></div><div class="cm-line">  <span class="tok-propertyName">&quot;mcpServers&quot;</span><span class="tok-punctuation">:</span> <span class="tok-punctuation">{</span></div><div class="cm-line">    <span class="tok-propertyName">&quot;notes-api&quot;</span><span class="tok-punctuation">:</span> <span class="tok-punctuation">{</span></div><div class="cm-line">      <span class="tok-propertyName">&quot;command&quot;</span><span class="tok-punctuation">:</span> <span class="tok-string">&quot;/path/to/venv/bin/python&quot;</span><span class="tok-punctuation">,</span></div><div class="cm-line">      <span class="tok-propertyName">&quot;args&quot;</span><span class="tok-punctuation">:</span> <span class="tok-punctuation">[</span><span class="tok-string">&quot;/path/to/src/server/main.py&quot;</span><span class="tok-punctuation">]</span></div><div class="cm-line">    <span class="tok-punctuation">}</span></div><div class="cm-line">  <span class="tok-punctuation">}</span></div><div class="cm-line"><span class="tok-punctuation">}</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>Claude Code reads this file, launches the MCP server as a subprocess, performs the MCP handshake, and discovers the five tools automatically. The same server works with Cursor, Windsurf, VS Code with Copilot, or any other MCP-compatible client, just point it to the same Python script.</p>
<h2>Running it</h2>
<p>First, install dependencies:</p>
<pre><code class="language-bash">poetry install
</code></pre>
<p>Start the Flask API in one terminal:</p>
<pre><code class="language-bash">make api
</code></pre>
<p>You can verify it works with curl:</p>
<pre><code class="language-bash">curl -X POST http://127.0.0.1:5000/api/notes \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;title&quot;: &quot;First note&quot;, &quot;body&quot;: &quot;Hello from REST&quot;}'

curl http://127.0.0.1:5000/api/notes
</code></pre>
<p>With the API running and <code>.mcp.json</code> in place, open Claude Code in the project directory. It discovers the <code>notes-api</code> MCP server and makes all five tools available. You can ask things like “Create a note about the deployment we did today” or “List all my notes” and the agent calls the MCP tools, which call your REST API, automatically.</p>
<h2>Taking it further</h2>
<p>This POC uses a simple notes API, but the same pattern works with any existing REST service. Your internal APIs, third-party integrations, legacy systems, anything with HTTP endpoints can be wrapped with a thin MCP layer. The REST API stays unchanged, the MCP server handles the translation, and suddenly your existing services become tools that any AI agent can use.</p>
<p>You could also add authentication headers in the MCP server (forwarding API keys or tokens to the REST API), error handling with retry logic, or caching for read-heavy endpoints. The adapter layer is the right place for these cross-cutting concerns.</p>
<p>And that’s all. With a thin MCP adapter on top of any REST API, your existing services become tools that any AI agent can discover and use. The REST API stays unchanged, the MCP server handles the protocol translation, and the standard connects them. Build the adapter once, use it from any MCP client.</p>
<p>Full code in my <a href="https://github.com/gonzalo123/rest2mcp">github</a> account.</p>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/03/23/exposing-a-rest-api-through-mcp-turning-any-api-into-an-ai-tool/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">88035</post-id>	</item>
		<item>
		<title>AI Eurobeat Producer: Generating Music in Real-Time with AI Agents, Python, and MIDI</title>
		<link>https://gonzalo123.com/2026/03/09/ai-eurobeat-producer-generating-music-in-real-time-with-ai-agents-python-and-midi/</link>
					<comments>https://gonzalo123.com/2026/03/09/ai-eurobeat-producer-generating-music-in-real-time-with-ai-agents-python-and-midi/#respond</comments>
		
		<dc:creator><![CDATA[Gonzalo Ayuso]]></dc:creator>
		<pubDate>Mon, 09 Mar 2026 13:13:39 +0000</pubDate>
				<category><![CDATA[Technology]]></category>
		<category><![CDATA[agentic-ai]]></category>
		<category><![CDATA[ai]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Bedrock]]></category>
		<category><![CDATA[MIDI]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[StrandsAgents]]></category>
		<guid isPermaLink="false">https://gonzalo123.com/?p=88078</guid>

					<description><![CDATA[]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-jetpack-markdown"><p>What if you could describe the music you want to hear and have an AI produce it in real-time, sending MIDI notes directly to your DAW? That’s exactly what I built: a Python application that uses AI agents to generate Eurobeat and 90s techno patterns, outputting them as live MIDI to Akai’s MPC Beats.</p>
<p>I’m not a musician. I enjoy playing guitar from time to time, but I have zero experience with music production software. However, I’m gifted myself a Akai MPK mini Plus MIDI controller, which has 8 knobs and 8 pads, and I experimented with using it to control a music generation agent. No idea what I’m doing, but it’s fun.</p>
<p>As Akai MIDI controller can be connected to a laptop, and there I’ve got Python, this saturday morning I decided to build a simple prototype that connects an AI agent to MIDI output. The idea is simple. You write a prompt like “Energetic eurobeat in Am, Daft Punk style”, and an AI agent powered by Claude on AWS Bedrock generates patterns for 8 tracks: two drum kits, bass, rhodes, pluck, pad, and a lead melody. The patterns are sent as MIDI messages to MPC Beats, where each track is routed to a different virtual instrument. You can then modify the music live by writing new instructions, and use the physical knobs and pads on an Akai MPK Mini Plus to mute/unmute tracks, regenerate patterns, or reset the session.</p>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="438" data-attachment-id="88080" data-permalink="https://gonzalo123.com/2026/03/09/ai-eurobeat-producer-generating-music-in-real-time-with-ai-agents-python-and-midi/logo-6/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?fit=1536%2C1024&amp;ssl=1" data-orig-size="1536,1024" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="logo" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?fit=656%2C438&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?resize=656%2C438&#038;ssl=1" alt="" class="wp-image-88080" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?resize=1024%2C683&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?resize=300%2C200&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?resize=768%2C512&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?w=1536&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/logo-1.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>I’m using the MPC Beats because it’s free and has a simple MIDI setup, but in theory this could work with any DAW that accepts MIDI input. The whole system is built in Python using Strands Agents for the AI orchestration, mido + python-rtmidi for MIDI I/O, and Rich for the terminal UI.</p>
<h2>The Architecture</h2>
<p>The flow is straightforward:</p>
</div>



<figure class="wp-block-image size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="147" data-attachment-id="88082" data-permalink="https://gonzalo123.com/2026/03/09/ai-eurobeat-producer-generating-music-in-real-time-with-ai-agents-python-and-midi/eurobeat_readme_md_at_main_%c2%b7_gonzalo123_eurobeat/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?fit=1377%2C309&amp;ssl=1" data-orig-size="1377,309" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="eurobeat_README_md_at_main_·_gonzalo123_eurobeat" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?fit=656%2C147&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?resize=656%2C147&#038;ssl=1" alt="" class="wp-image-88082" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?resize=1024%2C230&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?resize=300%2C67&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?resize=768%2C172&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?w=1377&amp;ssl=1 1377w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/eurobeat_README_md_at_main_%C2%B7_gonzalo123_eurobeat.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>



<div class="wp-block-jetpack-markdown"><h2>Project Structure</h2>
<pre><code>src/
  settings.py           # Configuration: BPM, tracks, MIDI devices
  cli.py                # Click CLI entry point
  commands/play.py      # Main play command
  agent/
    prompts.py          # System prompts for the AI producer
    tools.py            # PatternStore + @tool functions
    factory.py          # Agent creation
  midi/
    device.py           # MIDI device detection
    melody_player.py    # Threaded melody loop player
    drum_player.py      # Threaded drum loop player
  session/
    state.py            # State machine (IDLE/GENERATING/PLAYING)
    session.py          # Session orchestrator
  ui/
    menu.py             # Interactive terminal menu
</code></pre>
<h2>Configuration</h2>
<p>Everything starts with <code>settings.py</code>. The MIDI devices and AWS region are loaded from environment variables, while the musical parameters are defined as constants:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-cython"><div class="cm-line"><span class="tok-variableName">BPM</span> <span class="tok-operator">=</span> <span class="tok-number">122</span></div><div class="cm-line"><span class="tok-variableName">BAR_DURATION</span> <span class="tok-operator">=</span> <span class="tok-variableName">round</span>((<span class="tok-number">60</span> <span class="tok-operator">/</span> <span class="tok-variableName">BPM</span>) <span class="tok-operator">*</span> <span class="tok-number">4</span>, <span class="tok-number">3</span>)</div><div class="cm-line"><span class="tok-variableName">LOOP_BARS</span> <span class="tok-operator">=</span> <span class="tok-number">4</span></div><div class="cm-line"><span class="tok-variableName">LOOP_DURATION</span> <span class="tok-operator">=</span> <span class="tok-variableName">round</span>(<span class="tok-variableName">BAR_DURATION</span> <span class="tok-operator">*</span> <span class="tok-variableName">LOOP_BARS</span>, <span class="tok-number">3</span>)</div><div class="cm-line"></div><div class="cm-line"><span class="tok-variableName">TRACKS</span> <span class="tok-operator">=</span> {</div><div class="cm-line">    <span class="tok-number">1</span>: {<span class="tok-string">&quot;name&quot;</span>: <span class="tok-string">&quot;Drums&quot;</span>,         <span class="tok-string">&quot;channel&quot;</span>: <span class="tok-number">0</span>, <span class="tok-string">&quot;type&quot;</span>: <span class="tok-string">&quot;drums&quot;</span>},</div><div class="cm-line">    <span class="tok-number">2</span>: {<span class="tok-string">&quot;name&quot;</span>: <span class="tok-string">&quot;Drums Detroit&quot;</span>, <span class="tok-string">&quot;channel&quot;</span>: <span class="tok-number">1</span>, <span class="tok-string">&quot;type&quot;</span>: <span class="tok-string">&quot;drums&quot;</span>},</div><div class="cm-line">    <span class="tok-number">3</span>: {<span class="tok-string">&quot;name&quot;</span>: <span class="tok-string">&quot;Rhodes&quot;</span>,        <span class="tok-string">&quot;channel&quot;</span>: <span class="tok-number">2</span>, <span class="tok-string">&quot;type&quot;</span>: <span class="tok-string">&quot;melody&quot;</span>},</div><div class="cm-line">    <span class="tok-number">4</span>: {<span class="tok-string">&quot;name&quot;</span>: <span class="tok-string">&quot;Pluck&quot;</span>,         <span class="tok-string">&quot;channel&quot;</span>: <span class="tok-number">3</span>, <span class="tok-string">&quot;type&quot;</span>: <span class="tok-string">&quot;melody&quot;</span>},</div><div class="cm-line">    <span class="tok-number">5</span>: {<span class="tok-string">&quot;name&quot;</span>: <span class="tok-string">&quot;Bass&quot;</span>,          <span class="tok-string">&quot;channel&quot;</span>: <span class="tok-number">4</span>, <span class="tok-string">&quot;type&quot;</span>: <span class="tok-string">&quot;melody&quot;</span>},</div><div class="cm-line">    <span class="tok-number">6</span>: {<span class="tok-string">&quot;name&quot;</span>: <span class="tok-string">&quot;Org Bass&quot;</span>,      <span class="tok-string">&quot;channel&quot;</span>: <span class="tok-number">5</span>, <span class="tok-string">&quot;type&quot;</span>: <span class="tok-string">&quot;melody&quot;</span>},</div><div class="cm-line">    <span class="tok-number">7</span>: {<span class="tok-string">&quot;name&quot;</span>: <span class="tok-string">&quot;Pad&quot;</span>,           <span class="tok-string">&quot;channel&quot;</span>: <span class="tok-number">6</span>, <span class="tok-string">&quot;type&quot;</span>: <span class="tok-string">&quot;melody&quot;</span>},</div><div class="cm-line">    <span class="tok-number">8</span>: {<span class="tok-string">&quot;name&quot;</span>: <span class="tok-string">&quot;Lead&quot;</span>,          <span class="tok-string">&quot;channel&quot;</span>: <span class="tok-number">7</span>, <span class="tok-string">&quot;type&quot;</span>: <span class="tok-string">&quot;melody&quot;</span>},</div><div class="cm-line">}</div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>Each track maps to a MIDI channel. Tracks 1-2 are drum kits (offset-based timing), tracks 3-8 are melodic instruments (duration-based timing). The MPC Beats “House Template” provides the virtual instruments: a Classic drum kit, a Detroit percussion kit, Electric Rhodes, Tube Pluck, Bassline, Organ Bass, Tube Pad, and an Instant Go lead synth.</p>
<h2>The Bridge Between AI and MIDI: PatternStore and Tools</h2>
<p>The core of the system is the <code>PatternStore</code>, a simple shared store where the AI writes patterns and the MIDI players read them:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">class</span> <span class="tok-className">PatternStore</span>:</div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">__init__</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_patterns</span>: <span class="tok-variableName">dict</span><span class="tok-punctuation">[</span><span class="tok-variableName">int</span><span class="tok-punctuation">,</span> <span class="tok-variableName">list</span><span class="tok-punctuation">]</span> <span class="tok-operator">=</span> <span class="tok-punctuation">{</span><span class="tok-punctuation">}</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">set</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">,</span> <span class="tok-variableName">track_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">,</span> <span class="tok-variableName">pattern</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-keyword">None</span>:</div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_patterns</span><span class="tok-punctuation">[</span><span class="tok-variableName">track_id</span><span class="tok-punctuation">]</span> <span class="tok-operator">=</span> <span class="tok-variableName">pattern</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">get</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">,</span> <span class="tok-variableName">track_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">list</span> <span class="tok-operator">|</span> <span class="tok-keyword">None</span>:</div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_patterns</span><span class="tok-operator">.</span><span class="tok-propertyName">get</span><span class="tok-punctuation">(</span><span class="tok-variableName">track_id</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">clear</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-keyword">None</span>:</div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_patterns</span><span class="tok-operator">.</span><span class="tok-propertyName">clear</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>The Strands <code>@tool</code> functions are created via a factory that closes over the store:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">create_tools</span><span class="tok-punctuation">(</span><span class="tok-variableName">store</span>: <span class="tok-variableName">PatternStore</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">list</span>:</div><div class="cm-line">    <span class="tok-meta">@</span><span class="tok-variableName">tool</span></div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">set_melody_pattern</span><span class="tok-punctuation">(</span><span class="tok-variableName">track_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">,</span> <span class="tok-variableName">pattern</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">        <span class="tok-string">&quot;&quot;&quot;Define a melodic line for a specific track.&quot;&quot;&quot;</span></div><div class="cm-line">        <span class="tok-variableName">data</span> <span class="tok-operator">=</span> <span class="tok-variableName">json</span><span class="tok-operator">.</span><span class="tok-propertyName">loads</span><span class="tok-punctuation">(</span><span class="tok-variableName">pattern</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">store</span><span class="tok-operator">.</span><span class="tok-propertyName">set</span><span class="tok-punctuation">(</span><span class="tok-variableName">track_id</span><span class="tok-punctuation">,</span> <span class="tok-variableName">data</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">total</span> <span class="tok-operator">=</span> <span class="tok-variableName">sum</span><span class="tok-punctuation">(</span><span class="tok-variableName">n</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;duration&quot;</span><span class="tok-punctuation">]</span> <span class="tok-keyword">for</span> <span class="tok-variableName">n</span> <span class="tok-keyword">in</span> <span class="tok-variableName">data</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">name</span> <span class="tok-operator">=</span> <span class="tok-variableName">TRACKS</span><span class="tok-punctuation">[</span><span class="tok-variableName">track_id</span><span class="tok-punctuation">]</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;name&quot;</span><span class="tok-punctuation">]</span></div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-string2">f&quot;OK - </span><span class="tok-punctuation">{</span><span class="tok-variableName">name</span><span class="tok-punctuation">}</span><span class="tok-string2">: </span><span class="tok-punctuation">{</span><span class="tok-variableName">len</span><span class="tok-punctuation">(</span><span class="tok-variableName">data</span><span class="tok-punctuation">)</span><span class="tok-punctuation">}</span><span class="tok-string2"> notes, total duration </span><span class="tok-punctuation">{</span><span class="tok-variableName">total</span>:<span class="tok-keyword">.3f</span><span class="tok-punctuation">}</span><span class="tok-string2">s&quot;</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-meta">@</span><span class="tok-variableName">tool</span></div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">set_drum_pattern</span><span class="tok-punctuation">(</span><span class="tok-variableName">track_id</span>: <span class="tok-variableName">int</span><span class="tok-punctuation">,</span> <span class="tok-variableName">pattern</span>: <span class="tok-variableName">str</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">str</span>:</div><div class="cm-line">        <span class="tok-string">&quot;&quot;&quot;Define a drum pattern for a specific drum track.&quot;&quot;&quot;</span></div><div class="cm-line">        <span class="tok-variableName">data</span> <span class="tok-operator">=</span> <span class="tok-variableName">json</span><span class="tok-operator">.</span><span class="tok-propertyName">loads</span><span class="tok-punctuation">(</span><span class="tok-variableName">pattern</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">store</span><span class="tok-operator">.</span><span class="tok-propertyName">set</span><span class="tok-punctuation">(</span><span class="tok-variableName">track_id</span><span class="tok-punctuation">,</span> <span class="tok-variableName">data</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">name</span> <span class="tok-operator">=</span> <span class="tok-variableName">TRACKS</span><span class="tok-punctuation">[</span><span class="tok-variableName">track_id</span><span class="tok-punctuation">]</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;name&quot;</span><span class="tok-punctuation">]</span></div><div class="cm-line">        <span class="tok-keyword">return</span> <span class="tok-string2">f&quot;OK - </span><span class="tok-punctuation">{</span><span class="tok-variableName">name</span><span class="tok-punctuation">}</span><span class="tok-string2">: </span><span class="tok-punctuation">{</span><span class="tok-variableName">len</span><span class="tok-punctuation">(</span><span class="tok-variableName">data</span><span class="tok-punctuation">)</span><span class="tok-punctuation">}</span><span class="tok-string2"> hits&quot;</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-punctuation">[</span><span class="tok-variableName">set_drum_pattern</span><span class="tok-punctuation">,</span> <span class="tok-variableName">set_melody_pattern</span><span class="tok-punctuation">]</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>A melody pattern is a JSON array of <code>{note, duration, velocity}</code> objects where the sum of durations must equal <code>LOOP_DURATION</code> (4 bars). A drum pattern uses <code>{note, velocity, offset}</code> where offset is the time in seconds from the loop start. The note value <code>-1</code> represents silence, which is crucial for creating space in the arrangement.</p>
<h2>The Agent</h2>
<p>The agent is a Strands Agent using Claude Sonnet on AWS Bedrock. The system prompt is heavily detailed with music production instructions: frequency ranges for each track, velocity guidelines, and structural rules. The key instruction is “less is more” – not all tracks should play notes all the time:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">create_agent</span><span class="tok-punctuation">(</span><span class="tok-variableName">store</span>: <span class="tok-variableName">PatternStore</span><span class="tok-punctuation">)</span> -&gt; <span class="tok-variableName">Agent</span>:</div><div class="cm-line">    <span class="tok-keyword">return</span> <span class="tok-variableName">Agent</span><span class="tok-punctuation">(</span></div><div class="cm-line">        <span class="tok-variableName">model</span><span class="tok-operator">=</span><span class="tok-variableName">BedrockModel</span><span class="tok-punctuation">(</span></div><div class="cm-line">            <span class="tok-variableName">model_id</span><span class="tok-operator">=</span><span class="tok-variableName">Models</span><span class="tok-operator">.</span><span class="tok-propertyName">CLAUDE_SONNET</span><span class="tok-punctuation">,</span></div><div class="cm-line">            <span class="tok-variableName">region_name</span><span class="tok-operator">=</span><span class="tok-variableName">AWS_REGION</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">tools</span><span class="tok-operator">=</span><span class="tok-variableName">create_tools</span><span class="tok-punctuation">(</span><span class="tok-variableName">store</span><span class="tok-punctuation">)</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">system_prompt</span><span class="tok-operator">=</span><span class="tok-variableName">SYSTEM_PROMPT</span><span class="tok-punctuation">,</span></div><div class="cm-line">        <span class="tok-variableName">callback_handler</span><span class="tok-operator">=</span><span class="tok-keyword">None</span><span class="tok-punctuation">,</span></div><div class="cm-line">    <span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>There are two agents: one for initial generation (calls all 8 tools) and one for live modifications (only modifies the tracks that need to change). A third, lighter agent using Haiku generates the menu suggestions to keep latency and cost low.</p>
<h2>MIDI Players</h2>
<p>Two player classes handle the actual MIDI output. The <code>MelodyLoopPlayer</code> iterates through note events with durations:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">_loop</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">,</span> <span class="tok-variableName">melody</span>: <span class="tok-variableName">list</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-keyword">while</span> <span class="tok-keyword">not</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">stop_event</span><span class="tok-operator">.</span><span class="tok-propertyName">is_set</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">        <span class="tok-variableName">current</span> <span class="tok-operator">=</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">store</span><span class="tok-operator">.</span><span class="tok-propertyName">get</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">track_id</span><span class="tok-punctuation">)</span> <span class="tok-keyword">or</span> <span class="tok-variableName">melody</span></div><div class="cm-line">        <span class="tok-keyword">for</span> <span class="tok-variableName">ev</span> <span class="tok-keyword">in</span> <span class="tok-variableName">current</span>:</div><div class="cm-line">            <span class="tok-keyword">if</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">stop_event</span><span class="tok-operator">.</span><span class="tok-propertyName">is_set</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">                <span class="tok-keyword">break</span></div><div class="cm-line">            <span class="tok-variableName">note</span> <span class="tok-operator">=</span> <span class="tok-variableName">ev</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;note&quot;</span><span class="tok-punctuation">]</span></div><div class="cm-line">            <span class="tok-variableName">vel</span> <span class="tok-operator">=</span> <span class="tok-variableName">ev</span><span class="tok-operator">.</span><span class="tok-propertyName">get</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;velocity&quot;</span><span class="tok-punctuation">,</span> <span class="tok-number">80</span><span class="tok-punctuation">)</span></div><div class="cm-line">            <span class="tok-keyword">if</span> <span class="tok-variableName">note</span> <span class="tok-operator">&gt;=</span> <span class="tok-number">0</span>:</div><div class="cm-line">                <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_send</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;note_on&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">note</span><span class="tok-operator">=</span><span class="tok-variableName">note</span><span class="tok-punctuation">,</span> <span class="tok-variableName">velocity</span><span class="tok-operator">=</span><span class="tok-variableName">vel</span><span class="tok-punctuation">,</span> <span class="tok-variableName">channel</span><span class="tok-operator">=</span><span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">channel</span><span class="tok-punctuation">)</span></div><div class="cm-line">            <span class="tok-variableName">deadline</span> <span class="tok-operator">=</span> <span class="tok-variableName">time</span><span class="tok-operator">.</span><span class="tok-propertyName">time</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span> <span class="tok-operator">+</span> <span class="tok-variableName">ev</span><span class="tok-punctuation">[</span><span class="tok-string">&quot;duration&quot;</span><span class="tok-punctuation">]</span></div><div class="cm-line">            <span class="tok-keyword">while</span> <span class="tok-keyword">not</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">stop_event</span><span class="tok-operator">.</span><span class="tok-propertyName">is_set</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span> <span class="tok-keyword">and</span> <span class="tok-variableName">time</span><span class="tok-operator">.</span><span class="tok-propertyName">time</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span> <span class="tok-operator">&lt;</span> <span class="tok-variableName">deadline</span>:</div><div class="cm-line">                <span class="tok-variableName">time</span><span class="tok-operator">.</span><span class="tok-propertyName">sleep</span><span class="tok-punctuation">(</span><span class="tok-number">0.02</span><span class="tok-punctuation">)</span></div><div class="cm-line">            <span class="tok-keyword">if</span> <span class="tok-variableName">note</span> <span class="tok-operator">&gt;=</span> <span class="tok-number">0</span>:</div><div class="cm-line">                <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_send</span><span class="tok-punctuation">(</span><span class="tok-string">&quot;note_off&quot;</span><span class="tok-punctuation">,</span> <span class="tok-variableName">note</span><span class="tok-operator">=</span><span class="tok-variableName">note</span><span class="tok-punctuation">,</span> <span class="tok-variableName">velocity</span><span class="tok-operator">=</span><span class="tok-number">0</span><span class="tok-punctuation">,</span> <span class="tok-variableName">channel</span><span class="tok-operator">=</span><span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">channel</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>The <code>DrumLoopPlayer</code> uses offset-based timing instead, scheduling hits at specific points within the loop. Both players read from the <code>PatternStore</code> on each loop iteration, which enables hot-swapping patterns during live modifications.</p>
<h2>The Session</h2>
<p>The <code>Session</code> class orchestrates everything. It manages the state machine (IDLE -&gt; GENERATING -&gt; PLAYING), owns the <code>PatternStore</code>, creates the agents, and handles MIDI input from the controller:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">class</span> <span class="tok-className">Session</span>:</div><div class="cm-line">    <span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">__init__</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">state</span> <span class="tok-operator">=</span> <span class="tok-variableName">State</span><span class="tok-operator">.</span><span class="tok-propertyName">IDLE</span></div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">store</span> <span class="tok-operator">=</span> <span class="tok-variableName">PatternStore</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">agent</span> <span class="tok-operator">=</span> <span class="tok-variableName">create_agent</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">store</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">live_agent</span> <span class="tok-operator">=</span> <span class="tok-variableName">create_live_agent</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">store</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_agent_busy</span> <span class="tok-operator">=</span> <span class="tok-variableName">threading</span><span class="tok-operator">.</span><span class="tok-propertyName">Lock</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>


<div class="wp-block-jetpack-markdown"><p>When generation completes, playback starts with a progressive intro – tracks are unmuted one by one with a 2-bar delay between each, creating a build-up effect:</p>
</div>


<div class="wp-block-code">
	<div class="cm-editor">
		<div class="cm-scroller">
			
<pre>
<code class="language-python"><div class="cm-line"><span class="tok-keyword">def</span> <span class="tok-variableName tok-definition">_start_playback</span><span class="tok-punctuation">(</span><span class="tok-variableName">self</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">    <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">state</span> <span class="tok-operator">=</span> <span class="tok-variableName">State</span><span class="tok-operator">.</span><span class="tok-propertyName">PLAYING</span></div><div class="cm-line">    <span class="tok-keyword">for</span> <span class="tok-variableName">tid</span> <span class="tok-keyword">in</span> <span class="tok-variableName">TRACKS</span>:</div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">players</span><span class="tok-punctuation">[</span><span class="tok-variableName">tid</span><span class="tok-punctuation">]</span><span class="tok-operator">.</span><span class="tok-propertyName">muted</span> <span class="tok-operator">=</span> <span class="tok-bool">True</span></div><div class="cm-line">        <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">players</span><span class="tok-punctuation">[</span><span class="tok-variableName">tid</span><span class="tok-punctuation">]</span><span class="tok-operator">.</span><span class="tok-propertyName">start</span><span class="tok-punctuation">(</span><span class="tok-variableName">patterns</span><span class="tok-punctuation">[</span><span class="tok-variableName">tid</span><span class="tok-punctuation">]</span><span class="tok-punctuation">)</span></div><div class="cm-line"></div><div class="cm-line">    <span class="tok-variableName">intro_delay</span> <span class="tok-operator">=</span> <span class="tok-variableName">BAR_DURATION</span> <span class="tok-operator">*</span> <span class="tok-number">2</span></div><div class="cm-line">    <span class="tok-keyword">for</span> <span class="tok-variableName">i</span><span class="tok-punctuation">,</span> <span class="tok-variableName">tid</span> <span class="tok-keyword">in</span> <span class="tok-variableName">enumerate</span><span class="tok-punctuation">(</span><span class="tok-variableName">INTRO_ORDER</span><span class="tok-punctuation">)</span>:</div><div class="cm-line">        <span class="tok-variableName">timer</span> <span class="tok-operator">=</span> <span class="tok-variableName">threading</span><span class="tok-operator">.</span><span class="tok-propertyName">Timer</span><span class="tok-punctuation">(</span><span class="tok-variableName">intro_delay</span> <span class="tok-operator">*</span> <span class="tok-variableName">i</span><span class="tok-punctuation">,</span> <span class="tok-variableName">self</span><span class="tok-operator">.</span><span class="tok-propertyName">_unmute_track</span><span class="tok-punctuation">,</span> <span class="tok-variableName">args</span><span class="tok-operator">=</span><span class="tok-punctuation">(</span><span class="tok-variableName">tid</span><span class="tok-punctuation">,</span><span class="tok-punctuation">)</span><span class="tok-punctuation">)</span></div><div class="cm-line">        <span class="tok-variableName">timer</span><span class="tok-operator">.</span><span class="tok-propertyName">start</span><span class="tok-punctuation">(</span><span class="tok-punctuation">)</span></div></code></pre>
		</div>
	</div>
</div>

<div class="wp-block-image">
<figure class="aligncenter size-large coblocks-animate" data-coblocks-animation="fadeIn"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="281" data-attachment-id="88089" data-permalink="https://gonzalo123.com/2026/03/09/ai-eurobeat-producer-generating-music-in-real-time-with-ai-agents-python-and-midi/setup/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?fit=1806%2C773&amp;ssl=1" data-orig-size="1806,773" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="setup" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?fit=656%2C281&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?resize=656%2C281&#038;ssl=1" alt="" class="wp-image-88089" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?resize=1024%2C438&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?resize=300%2C128&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?resize=768%2C329&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?resize=1536%2C657&amp;ssl=1 1536w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?w=1806&amp;ssl=1 1806w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/setup.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><h2>How It Works</h2>
<ol>
<li>Run <code>python cli.py play</code></li>
<li>The app detects your MPK Mini Plus and shows a menu with AI-generated suggestions</li>
<li>Select a suggestion or write your own prompt</li>
<li>The AI generates 8 track patterns (takes a few seconds)</li>
<li>Playback begins with a progressive build-up</li>
<li>Write new instructions to modify the music live</li>
<li>Use knobs K1-K8 to mute/unmute individual tracks</li>
<li>PAD 1 regenerates with the same prompt, PAD 2 resets everything</li>
</ol>
<h2>Tech Stack</h2>
<ul>
<li><strong>Python 3.13</strong> with Poetry</li>
<li><strong>Strands Agents</strong> for AI agent orchestration</li>
<li><strong>AWS Bedrock</strong> (Claude Sonnet + Haiku) for pattern generation</li>
<li><strong>mido</strong> + <strong>python-rtmidi</strong> for MIDI I/O</li>
<li><strong>Akai MPK Mini Plus</strong> as MIDI controller</li>
<li><strong>MPC Beats</strong> as the DAW/sound engine</li>
<li><strong>Rich</strong> for terminal UI</li>
<li><strong>Click</strong> for CLI</li>
</ul>
</div>


<div class="wp-block-image">
<figure class="aligncenter size-large"><a href="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="656" height="361" data-attachment-id="88091" data-permalink="https://gonzalo123.com/2026/03/09/ai-eurobeat-producer-generating-music-in-real-time-with-ai-agents-python-and-midi/mpc_beats/" data-orig-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?fit=1507%2C830&amp;ssl=1" data-orig-size="1507,830" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="MPC_Beats" data-image-description="" data-image-caption="" data-large-file="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?fit=656%2C361&amp;ssl=1" src="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?resize=656%2C361&#038;ssl=1" alt="" class="wp-image-88091" srcset="https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?resize=1024%2C564&amp;ssl=1 1024w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?resize=300%2C165&amp;ssl=1 300w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?resize=768%2C423&amp;ssl=1 768w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?w=1507&amp;ssl=1 1507w, https://i0.wp.com/gonzalo123.com/wp-content/uploads/2026/03/MPC_Beats.png?w=1312&amp;ssl=1 1312w" sizes="auto, (max-width: 656px) 100vw, 656px" /></a></figure>
</div>


<div class="wp-block-jetpack-markdown"><p>And that’s all. Full source code available on <a href="https://github.com/gonzalo123/eurobeat">GitHub</a>.</p>
</div>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://gonzalo123.com/2026/03/09/ai-eurobeat-producer-generating-music-in-real-time-with-ai-agents-python-and-midi/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">88078</post-id>	</item>
	</channel>
</rss>