<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yuuki Yamashita</title>
    <description>The latest articles on DEV Community by Yuuki Yamashita (@_76130e67067eab4c8510).</description>
    <link>https://dev.to/_76130e67067eab4c8510</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3963934%2F42eb54d8-268d-480f-a502-3e39f8a58186.png</url>
      <title>DEV Community: Yuuki Yamashita</title>
      <link>https://dev.to/_76130e67067eab4c8510</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_76130e67067eab4c8510"/>
    <language>en</language>
    <item>
      <title>Generating animated LINE-style chat slides with python-pptx + raw XML (and shipping it on Vercel)</title>
      <dc:creator>Yuuki Yamashita</dc:creator>
      <pubDate>Tue, 02 Jun 2026 15:20:32 +0000</pubDate>
      <link>https://dev.to/_76130e67067eab4c8510/generating-animated-line-style-chat-slides-with-python-pptx-raw-xml-and-shipping-it-on-vercel-3g32</link>
      <guid>https://dev.to/_76130e67067eab4c8510/generating-animated-line-style-chat-slides-with-python-pptx-raw-xml-and-shipping-it-on-vercel-3g32</guid>
      <description>&lt;p&gt;I was putting together a talk and had one of those half-baked ideas that you can't shake off: what if I showed an iPhone with a LINE chat screen, and the messages popped in one by one, like a real conversation happening live? The problem is, building that by hand in PowerPoint sounds miserable — laying out every bubble, then setting up an entrance animation for each one, one at a time. So instead I built a little tool that takes a chunk of conversation text and spits out a &lt;code&gt;.pptx&lt;/code&gt;, and then I turned it into a web app and put it on Vercel.&lt;/p&gt;

&lt;p&gt;This post focuses on the three things that actually gave me trouble:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building an iPhone / LINE-style UI out of nothing but shapes in python-pptx&lt;/li&gt;
&lt;li&gt;Working around the fact that python-pptx has no animation support — by hand-writing the &lt;code&gt;timing&lt;/code&gt; XML and injecting it into the slide&lt;/li&gt;
&lt;li&gt;Wiring up "conversation in, pptx out" with a static HTML front end and a Vercel serverless function (including the deploy that bit me)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what came out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App: &lt;a href="https://line-chat-app-nine.vercel.app" rel="noopener noreferrer"&gt;https://line-chat-app-nine.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/yama3133/line-chat-app" rel="noopener noreferrer"&gt;https://github.com/yama3133/line-chat-app&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the slides it generates look like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7zpkv5o94yrgewow7jel.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7zpkv5o94yrgewow7jel.jpg" alt="Example slides" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The overall shape of it
&lt;/h2&gt;

&lt;p&gt;It's nothing fancy. This is the whole file list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
├─ index.html        front end (input form + live preview)
├─ api/generate.py   conversation JSON → pptx (build_pptx) + handler
├─ requirements.txt  python-pptx
├─ vercel.json       builds (static + @vercel/python)
└─ dev_server.py     for local testing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All the real generation logic lives in Python (python-pptx), and the front end is plain HTML/CSS/JS. On Vercel, &lt;code&gt;api/generate.py&lt;/code&gt; runs as a serverless function and &lt;code&gt;index.html&lt;/code&gt; is served as a static file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the iPhone / LINE UI out of shapes
&lt;/h2&gt;

&lt;p&gt;Set the slide to a tall 9:16 aspect ratio, and from there it's just a matter of stacking rounded rectangles, ellipses, and text boxes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SLIDE_W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Inches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;7.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SLIDE_H&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Inches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;13.333&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 7.5 : 13.333 = 9 : 16
&lt;/span&gt;&lt;span class="n"&gt;prs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slide_width&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SLIDE_W&lt;/span&gt;
&lt;span class="n"&gt;prs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slide_height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SLIDE_H&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stacking order goes: phone frame (black rounded rect) → screen (chat background) → green header → Dynamic Island → status bar (clock, signal, battery) → input bar. For something like the Dynamic Island, a black rectangle with its corner roundness (adjustment) cranked up to 0.5 already reads as the real thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;isl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MSO_SHAPE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ROUNDED_RECTANGLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="n"&gt;isl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adjustments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;   &lt;span class="c1"&gt;# fully rounded into a "pill" shape
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the header and the input bar, I used &lt;code&gt;ROUND_2_SAME_RECTANGLE&lt;/code&gt; ("rounded on the top two corners only" / "bottom two only") so they don't fight with the rounded corners of the screen. The input bar is just rotated 180° so its rounded side faces down.&lt;/p&gt;

&lt;p&gt;One thing that quietly mattered: Japanese fonts. If you only set &lt;code&gt;a:latin&lt;/code&gt;, Japanese text can fall back to some other font, so I push the same typeface into &lt;code&gt;a:ea&lt;/code&gt; (East Asian) as well. That kept things consistent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rPr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_or_add_rPr&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a:latin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a:ea&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a:cs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rPr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;qn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rPr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeelement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;qn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;typeface&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hiragino Sans&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Sizing the bubbles by guesswork
&lt;/h3&gt;

&lt;p&gt;Each bubble gets a width and height estimated from its text, then placed. python-pptx can't measure rendered text, so I just approximate the display width as full-width characters = 1.0 and half-width = 0.5: short lines get a one-line width, longer ones wrap at a maximum width. Pretty crude, but it works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;measure_bubble&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;char_w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BUBBLE_FONT_PT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;72.0&lt;/span&gt;           &lt;span class="c1"&gt;# one full-width char ≈ 0.208in
&lt;/span&gt;    &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;MAX_BUBBLE_W&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;PAD&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;char_w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.06&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# full-width chars per line
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;char_w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PAD&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.18&lt;/span&gt;   &lt;span class="c1"&gt;# slack so it doesn't wrap
&lt;/span&gt;        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_BUBBLE_W&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;LINE_H&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PAD_Y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first I didn't add the slack (that trailing &lt;code&gt;+ 0.18&lt;/code&gt;) and used the exact width. The result: a short phrase like &lt;code&gt;おつかれさま！&lt;/code&gt; ("hey, nice work today!") would wrap onto two lines and look weirdly stretched. The actual rendered character width comes out a bit wider than my estimate, so giving it a little breathing room settled things down.&lt;/p&gt;

&lt;p&gt;For the arrangement I copied real LINE and made "bottom-aligned" the default (newest message sits right above the input bar). I just sum up the height of the whole conversation first and set the start position to &lt;code&gt;CHAT_BOTTOM - block_h&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The main event: no animation API, so write the XML yourself
&lt;/h2&gt;

&lt;p&gt;This is where I got stuck the longest. python-pptx can create shapes and text just fine, but it gives you no way to touch animations (entrance effects and the like) through its API. So what do you do? Animations are stored as an OOXML element called &lt;code&gt;&amp;lt;p:timing&amp;gt;&lt;/code&gt;, so I build that element myself and append it to the end of an already-generated slide.&lt;/p&gt;

&lt;p&gt;PowerPoint animations are a tree of time nodes. Under &lt;code&gt;mainSeq&lt;/code&gt; (the sequence that advances on click), you hang a &lt;code&gt;&amp;lt;p:par&amp;gt;&lt;/code&gt; for each effect. What I wanted was "on click, bubbles appear one after another at 0.8s intervals," so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first effect is &lt;code&gt;nodeType="clickEffect"&lt;/code&gt; (fires on click)&lt;/li&gt;
&lt;li&gt;Every effect after that is &lt;code&gt;nodeType="withEffect"&lt;/code&gt; with a &lt;code&gt;delay&lt;/code&gt;, staggered in time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also an "After Previous" option, but it works by waiting for the previous effect to finish, and the behavior felt flaky. So I leaned on absolute offsets of &lt;code&gt;i × 800ms&lt;/code&gt; instead. That turned out to be both more predictable and, honestly, less work.&lt;/p&gt;

&lt;p&gt;The effect on each bubble is "Float In" — it fades in while drifting up slightly from below. I use &lt;code&gt;&amp;lt;p:set&amp;gt;&lt;/code&gt; to make it visible, &lt;code&gt;&amp;lt;p:anim&amp;gt;&lt;/code&gt; to move &lt;code&gt;ppt_y&lt;/code&gt; (vertical position) from a touch below up to its real spot, and &lt;code&gt;&amp;lt;p:animEffect filter="fade"&amp;gt;&lt;/code&gt; to fade it in, all running together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_effect_par&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grp&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;p:par ...&amp;gt;
  &amp;lt;p:cTn id=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;eid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; presetID=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;42&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; presetClass=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; presetSubtype=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
         fill=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; grpId=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;grp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; nodeType=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;node_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;
    &amp;lt;p:stCondLst&amp;gt;&amp;lt;p:cond delay=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&amp;gt;&amp;lt;/p:stCondLst&amp;gt;
    &amp;lt;p:childTnLst&amp;gt;
      &amp;lt;p:set&amp;gt; ... style.visibility=visible ... &amp;lt;/p:set&amp;gt;
      &amp;lt;p:anim calcmode=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; valueType=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;
        &amp;lt;p:cBhvr additive=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;
          &amp;lt;p:cTn id=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;eid&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; dur=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;EFFECT_MS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; fill=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&amp;gt;
          &amp;lt;p:tgtEl&amp;gt;&amp;lt;p:spTgt spid=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;spid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&amp;gt;&amp;lt;/p:tgtEl&amp;gt;
          &amp;lt;p:attrNameLst&amp;gt;&amp;lt;p:attrName&amp;gt;ppt_y&amp;lt;/p:attrName&amp;gt;&amp;lt;/p:attrNameLst&amp;gt;
        &amp;lt;/p:cBhvr&amp;gt;
        &amp;lt;p:tavLst&amp;gt;
          &amp;lt;p:tav tm=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&amp;lt;p:val&amp;gt;&amp;lt;p:strVal val=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ppt_y+0.04&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&amp;gt;&amp;lt;/p:val&amp;gt;&amp;lt;/p:tav&amp;gt;
          &amp;lt;p:tav tm=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&amp;lt;p:val&amp;gt;&amp;lt;p:strVal val=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ppt_y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&amp;gt;&amp;lt;/p:val&amp;gt;&amp;lt;/p:tav&amp;gt;
        &amp;lt;/p:tavLst&amp;gt;
      &amp;lt;/p:anim&amp;gt;
      &amp;lt;p:animEffect transition=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; filter=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt; ... &amp;lt;/p:animEffect&amp;gt;
    &amp;lt;/p:childTnLst&amp;gt;
  &amp;lt;/p:cTn&amp;gt;
&amp;lt;/p:par&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;spid&lt;/code&gt; is &lt;code&gt;shape.shape_id&lt;/code&gt;. For an incoming message I wanted the bubble and the avatar to appear together, so I give them the same &lt;code&gt;grpId&lt;/code&gt; and the same &lt;code&gt;delay&lt;/code&gt;. Once the &lt;code&gt;&amp;lt;p:timing&amp;gt;&lt;/code&gt; string is assembled, I parse it with lxml and just append it to the slide.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;timing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;etree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromstring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timing_xml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nice bonus: once a shape has an entrance effect, PowerPoint handles "keep it hidden until it fires" on its own. So I didn't have to write any code to hide the initial state myself.&lt;/p&gt;

&lt;p&gt;One more note on checking my work. I was eyeballing layout by exporting to PDF with LibreOffice — but LibreOffice's PDF export only renders the &lt;em&gt;final&lt;/em&gt; state of an animation. It's perfectly fine for catching layout problems, but to verify the motion itself I had to fall back to running an actual slideshow in PowerPoint/Keynote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it into a web app
&lt;/h2&gt;

&lt;p&gt;Because I'd already factored the generation into a single &lt;code&gt;build_pptx(data) -&amp;gt; bytes&lt;/code&gt; function, the web side only needed to POST the conversation as JSON. I wrote the serverless function with the standard-library &lt;code&gt;BaseHTTPRequestHandler&lt;/code&gt; and return the bytes from &lt;code&gt;build_pptx&lt;/code&gt; directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;pptx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_pptx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/vnd.openxmlformats-officedocument.presentationml.presentation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Disposition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;attachment; filename=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line_chat.pptx&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pptx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Input is just one text area
&lt;/h3&gt;

&lt;p&gt;I didn't get clever with the input UI — it's a single text area. The only rules are "&lt;code&gt;L:&lt;/code&gt; is the other person, &lt;code&gt;R:&lt;/code&gt; is you, a blank line starts a new slide." Keeping it this loose is actually what makes it pleasant: you just dump in whatever conversation pops into your head.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L: Hey, nice work today!
L: There's something I want to run by you
R: What's up?

L: This thing's been eating up my time lately
R: Oh, I totally get that
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parsing is a regex applied line by line. Any line without an &lt;code&gt;L:&lt;/code&gt;/&lt;code&gt;R:&lt;/code&gt; prefix gets joined onto the previous bubble with a newline (i.e. multi-line bubbles).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;LRlr&lt;/span&gt;&lt;span class="se"&gt;])[&lt;/span&gt;&lt;span class="sr"&gt;:：&lt;/span&gt;&lt;span class="se"&gt;]\s?(&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;side&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]};&lt;/span&gt; &lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// multi-line bubble&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A live preview in the browser
&lt;/h3&gt;

&lt;p&gt;Having to download the file just to see the result is a miserable loop, so I rebuilt the same look in HTML/CSS and added a preview-and-play feature right in the browser. The colors and the bottom-alignment match the generated pptx, and for playback I just add CSS transitions (opacity and translateY) one after another with &lt;code&gt;setTimeout&lt;/code&gt;. It ends up feeling about the same as the animation inside the pptx.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that bit me on deploy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A requirements.txt makes Vercel think it's a "Python app"
&lt;/h3&gt;

&lt;p&gt;My very first &lt;code&gt;vercel --prod&lt;/code&gt; died immediately with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The pattern "api/generate.py" defined in `functions`
doesn't match any Serverless Functions inside the `api` directory.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause was the &lt;code&gt;requirements.txt&lt;/code&gt; sitting at the project root. With that present, Vercel decides the whole project is a "Python app" and stops picking up &lt;code&gt;api/generate.py&lt;/code&gt; as an individual function. I fixed it by spelling out &lt;code&gt;builds&lt;/code&gt; in &lt;code&gt;vercel.json&lt;/code&gt;, telling Vercel to build the static HTML and the Python function separately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"builds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"index.html"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"use"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@vercel/static"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"api/generate.py"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"use"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@vercel/python"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"routes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/generate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/generate.py"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="nl"&gt;"dest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/index.html"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, python-pptx installed itself from &lt;code&gt;requirements.txt&lt;/code&gt;, and both the function and the static serving worked exactly as I'd hoped.&lt;/p&gt;

&lt;h3&gt;
  
  
  You can't protect production on the free plan
&lt;/h3&gt;

&lt;p&gt;Once it was live, I wanted to put it behind some access control, so I tried enabling &lt;code&gt;ssoProtection&lt;/code&gt; (Vercel Authentication) via Vercel's REST API. This came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid_sso_protection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vercel Authentication is not available on your plan for production deployments"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turns out the Hobby (free) plan doesn't let you put Vercel Authentication / Password Protection on production deployments (you need Pro). Preview deployments can be protected on the free plan. If you want to keep production hidden while staying free, you're looking at rolling your own gate — Edge Middleware, or Basic auth inside the Python function. For now I left it open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;After doing all this, the part I'm most glad I pushed through was writing the animation XML by hand. I'd nearly written off animations as impossible with python-pptx, but it turns out that hand-assembling a &lt;code&gt;&amp;lt;p:timing&amp;gt;&lt;/code&gt; element and appending it is all it takes to get bubbles floating in, one by one, on a real PowerPoint. And the dead-simple &lt;code&gt;withEffect + absolute delay&lt;/code&gt; approach was more than enough.&lt;/p&gt;

&lt;p&gt;Factoring the generation into a single function also paid off more than I expected — moving from a CLI to a web app was basically copy-paste.&lt;/p&gt;

&lt;p&gt;Being able to type out a conversation and get an animated chat slide back made prepping for talks noticeably less of a chore. The code is up on &lt;a href="https://github.com/yama3133/line-chat-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; if you want to poke around — happy to hear what you'd build with it.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>productivity</category>
      <category>python</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building Diagram AI: From Natural Language (and Screenshots) to AWS Architecture Diagrams</title>
      <dc:creator>Yuuki Yamashita</dc:creator>
      <pubDate>Tue, 02 Jun 2026 13:59:18 +0000</pubDate>
      <link>https://dev.to/_76130e67067eab4c8510/building-diagram-ai-from-natural-language-and-screenshots-to-aws-architecture-diagrams-31jj</link>
      <guid>https://dev.to/_76130e67067eab4c8510/building-diagram-ai-from-natural-language-and-screenshots-to-aws-architecture-diagrams-31jj</guid>
      <description>&lt;p&gt;Drawing architecture diagrams by hand is tedious. You drag boxes around, align icons, fix arrows, and then your design changes and you do it all again. I wanted something where I could just &lt;em&gt;describe&lt;/em&gt; an architecture in plain English — or paste an existing diagram — and get a clean, AWS-style diagram back.&lt;/p&gt;

&lt;p&gt;This post is the story of building &lt;strong&gt;Diagram AI&lt;/strong&gt;: a browser app that turns natural language and images into architecture diagrams. It runs on Vercel, uses Amazon Bedrock (Claude Haiku 4.5) for the language understanding, and renders the actual diagrams with AWS's open-source &lt;a href="https://github.com/awslabs/diagram-as-code" rel="noopener noreferrer"&gt;&lt;code&gt;diagram-as-code&lt;/code&gt;&lt;/a&gt; tool. I'll be honest about the parts that didn't go smoothly, because that's where the real lessons were.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F38wqgd8t72lvx70gayih.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F38wqgd8t72lvx70gayih.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;The pipeline is simple to describe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[User input: text or a diagram screenshot]
        │
        ▼
[Vercel · Next.js]
   ├─ /api/generate-yaml  → Bedrock Claude Haiku 4.5 → diagram-as-code YAML
   └─ /api/render         → AWS Lambda (Function URL) → awsdac → PNG
        │
        ▼
[Browser: live preview + PNG download]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clever part is the middle layer. Instead of asking an LLM to "draw a diagram" (which it can't do reliably), I ask it to write &lt;strong&gt;YAML in the &lt;code&gt;diagram-as-code&lt;/code&gt; schema&lt;/strong&gt;. A deterministic CLI then renders that YAML into a real, standards-compliant AWS diagram. The LLM does the &lt;em&gt;understanding&lt;/em&gt;; a proven tool does the &lt;em&gt;drawing&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why diagram-as-code?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;diagram-as-code&lt;/code&gt; (the &lt;code&gt;awsdac&lt;/code&gt; CLI) is an AWS Labs project that generates architecture diagrams from human-readable YAML. It ships official AWS service icons and follows AWS diagram conventions. Crucially, it's &lt;strong&gt;extensible&lt;/strong&gt;: you can add your own definition files to draw non-AWS services too. That extensibility became important later.&lt;/p&gt;

&lt;h2&gt;
  
  
  First wall: you can't just &lt;code&gt;import&lt;/code&gt; it
&lt;/h2&gt;

&lt;p&gt;My initial plan was elegant on paper: &lt;code&gt;diagram-as-code&lt;/code&gt; is written in Go, so I'd compile it into a Vercel Go Serverless Function and call it as a library. Clean, all-in-one, no extra infrastructure.&lt;/p&gt;

&lt;p&gt;Then I read the source. Almost all of the core logic lives under Go's &lt;code&gt;internal/&lt;/code&gt; directory — &lt;code&gt;internal/ctl&lt;/code&gt;, &lt;code&gt;internal/types&lt;/code&gt;, &lt;code&gt;internal/definition&lt;/code&gt;, and so on. Go's language rules forbid importing &lt;code&gt;internal/&lt;/code&gt; packages from outside the module. &lt;strong&gt;The library-import approach was simply impossible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I pivoted: run &lt;code&gt;awsdac&lt;/code&gt; as a CLI inside &lt;strong&gt;AWS Lambda&lt;/strong&gt;, exposed via a Function URL, and have the Vercel app call it over HTTPS. Vercel handles the frontend and the LLM; Lambda handles the rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second wall: no Docker, no Go on the machine
&lt;/h2&gt;

&lt;p&gt;The plan was to package the Lambda as a container image. But the build machine had neither Docker nor Go installed. Rather than fight that, I switched to a &lt;strong&gt;zip-based Lambda on the Python 3.12 runtime&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Download the official prebuilt &lt;code&gt;awsdac&lt;/code&gt; Linux binary from the GitHub releases.&lt;/li&gt;
&lt;li&gt;Write a tiny Python handler that shells out to the binary.&lt;/li&gt;
&lt;li&gt;Zip the two together and upload. No Docker, no Go build step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The handler is small — receive YAML, write it to &lt;code&gt;/tmp&lt;/code&gt;, run &lt;code&gt;awsdac&lt;/code&gt;, return the PNG as base64:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AWSDAC_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--allow-untrusted-definitions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_path&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HOME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Third wall: a 403 that wasn't in any policy
&lt;/h2&gt;

&lt;p&gt;With the function deployed, direct &lt;code&gt;aws lambda invoke&lt;/code&gt; worked perfectly — a clean PNG came back. But calling the &lt;strong&gt;Function URL&lt;/strong&gt; over HTTPS returned &lt;code&gt;403 Forbidden / AccessDeniedException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The auth type was &lt;code&gt;NONE&lt;/code&gt;. The resource policy granted &lt;code&gt;lambda:InvokeFunctionUrl&lt;/code&gt; to &lt;code&gt;*&lt;/code&gt;. Everything looked right. I checked for org-level SCPs and RCPs — none.&lt;/p&gt;

&lt;p&gt;The answer was in a documentation note: &lt;strong&gt;since October 2025, public function URLs require &lt;em&gt;two&lt;/em&gt; permissions — both &lt;code&gt;lambda:InvokeFunctionUrl&lt;/code&gt; and &lt;code&gt;lambda:InvokeFunction&lt;/code&gt;.&lt;/strong&gt; I only had the first. Adding the second statement fixed it instantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws lambda add-permission &lt;span class="nt"&gt;--function-name&lt;/span&gt; diagram-ai-render &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--statement-id&lt;/span&gt; FunctionURLAllowInvoke &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; lambda:InvokeFunction &lt;span class="nt"&gt;--principal&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--invoked-via-function-url&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: when a 403 makes no sense, check whether the &lt;em&gt;platform's rules changed&lt;/em&gt; recently, not just your own config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the LLM output good diagrams
&lt;/h2&gt;

&lt;p&gt;Getting valid YAML was the easy half. Getting &lt;em&gt;good-looking&lt;/em&gt; diagrams took several rounds of prompt engineering, each driven by an ugly output:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Arrows piercing containers.&lt;/strong&gt; The LLM would draw an arrow from &lt;code&gt;User&lt;/code&gt; straight to an &lt;code&gt;ALB&lt;/code&gt; deep inside a VPC, slicing through the "AWS Cloud" and "VPC" boxes. Fix: a hard rule that any external→internal arrow must route through an Internet Gateway placed on the VPC's border (&lt;code&gt;BorderChildren&lt;/code&gt;), exactly like AWS's own reference diagrams.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Arrows overlapping label text.&lt;/strong&gt; &lt;code&gt;diagram-as-code&lt;/code&gt; pins each label directly under its icon and routes arrows through the icon's center, so vertical flows always crossed the text. After testing four layout strategies, the winner was &lt;strong&gt;horizontal flow&lt;/strong&gt; (&lt;code&gt;Direction: horizontal&lt;/code&gt;): horizontal arrows pass through the icon's mid-height and clear the labels below.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Invented resource types.&lt;/strong&gt; Asked for a "box," the model confidently produced &lt;code&gt;AWS::Diagram::Container&lt;/code&gt; — a type that doesn't exist. I added an explicit list of safe AWS types plus a rule: never invent types; fall back to a generic resource if unsure.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each fix went straight into the system prompt with a concrete good/bad example. The prompt grew long, but the outputs got dramatically cleaner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going beyond AWS
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;diagram-as-code&lt;/code&gt; is AWS-first, but its definition-file mechanism lets you add arbitrary icons. I wanted hybrid diagrams — "GitHub → Vercel → AWS Lambda" — to look right.&lt;/p&gt;

&lt;p&gt;I built a small pipeline: pull SVGs from &lt;a href="https://simple-icons.org/" rel="noopener noreferrer"&gt;simple-icons&lt;/a&gt;, tint them with each brand's color, and convert to 128×128 PNGs with &lt;code&gt;sharp&lt;/code&gt;. That produced 19 external service icons (Vercel, Netlify, Cloudflare, GitHub, Supabase, Auth0, OpenAI, Anthropic, Stripe, and more), bundled into an &lt;code&gt;icons.zip&lt;/code&gt; plus an &lt;code&gt;external-icons.yaml&lt;/code&gt; definition file that maps types like &lt;code&gt;External::Vercel&lt;/code&gt; to the icons.&lt;/p&gt;

&lt;p&gt;One subtlety: &lt;code&gt;awsdac&lt;/code&gt; refuses to load definition files from URLs outside the official repo unless you pass &lt;code&gt;--allow-untrusted-definitions&lt;/code&gt;. And I originally hosted the icons on GitHub Releases, but a private-repo mix-up left GitHub's CDN serving cached 404s. The robust fix was to &lt;strong&gt;serve the definition file and icon zip from Vercel's own &lt;code&gt;/public&lt;/code&gt; folder&lt;/strong&gt; — same origin as the app, instantly updatable on each deploy.&lt;/p&gt;

&lt;p&gt;I also added &lt;code&gt;Type: Group&lt;/code&gt; definitions so you can draw a branded &lt;em&gt;container&lt;/em&gt; — e.g. a "Vercel Platform" box with the Vercel logo in the corner, holding child resources inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading existing diagrams
&lt;/h2&gt;

&lt;p&gt;The feature I'm happiest with: &lt;strong&gt;upload a screenshot of an existing diagram and regenerate it.&lt;/strong&gt; Claude Haiku 4.5 on Bedrock is multimodal, so I extended the API to accept an image (base64) alongside or instead of text.&lt;/p&gt;

&lt;p&gt;Three modes fall out of this naturally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image only&lt;/strong&gt; → read the diagram and reproduce an equivalent YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image + text&lt;/strong&gt; → transform it: "take this diagram but swap Cloudflare for Vercel and EC2 for ECS."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text only&lt;/strong&gt; → the original behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One build-time gotcha worth noting: the Bedrock client was being constructed at module load. During &lt;code&gt;next build&lt;/code&gt;'s page-data collection (which runs without AWS env vars) that threw &lt;code&gt;Region is missing&lt;/code&gt;. Moving the client into a lazy, request-time factory fixed it — and it's a good pattern regardless.&lt;/p&gt;

&lt;p&gt;The frontend supports paste (⌘V), drag-and-drop, and file selection, with a 5 MB cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it: CI/CD
&lt;/h2&gt;

&lt;p&gt;Finally, I wired up automatic deploys. Connecting Vercel's GitHub App via CLI didn't work (it needs a browser step for repo access), so I used &lt;strong&gt;GitHub Actions + the Vercel CLI&lt;/strong&gt; instead: on every push to &lt;code&gt;main&lt;/code&gt;, the workflow runs &lt;code&gt;vercel pull&lt;/code&gt; / &lt;code&gt;build&lt;/code&gt; / &lt;code&gt;deploy --prebuilt --prod&lt;/code&gt; with a token stored in GitHub Secrets.&lt;/p&gt;

&lt;p&gt;The one hiccup: the Vercel token expired mid-way, producing &lt;code&gt;The token provided via --token argument is not valid&lt;/code&gt;. Regenerating it (with no expiry) and re-registering the secret got the pipeline green. Now &lt;code&gt;git push&lt;/code&gt; is all it takes to ship.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpf517anotl5az963f5d5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpf517anotl5az963f5d5.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the source before committing to an integration strategy.&lt;/strong&gt; The &lt;code&gt;internal/&lt;/code&gt; package issue would have saved a day if I'd caught it on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When auth fails inexplicably, suspect recent platform changes.&lt;/strong&gt; The dual-permission Lambda URL requirement was invisible in my own config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For LLM-generated structured output, encode taste as rules with examples.&lt;/strong&gt; "Don't pierce labels" only worked once it became a concrete layout rule with a good/bad sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same-origin hosting beats clever CDN tricks&lt;/strong&gt; when you control the deploy anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack, in one line
&lt;/h2&gt;

&lt;p&gt;Next.js on Vercel · Amazon Bedrock (Claude Haiku 4.5, text + vision) · AWS Lambda (zip, Python 3.12) running the &lt;code&gt;awsdac&lt;/code&gt; CLI · simple-icons + sharp for non-AWS icons · GitHub Actions for CI/CD.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>aws</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
