<?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: Hideki Mori</title>
    <description>The latest articles on DEV Community by Hideki Mori (@hidekimori).</description>
    <link>https://dev.to/hidekimori</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%2F3903757%2F5d1a2986-7f25-4c35-b5e8-4d489fc18a94.png</url>
      <title>DEV Community: Hideki Mori</title>
      <link>https://dev.to/hidekimori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hidekimori"/>
    <language>en</language>
    <item>
      <title>Abstractions are fine. Starting on them isn't.</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 08 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff</link>
      <guid>https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff</guid>
      <description>&lt;p&gt;I've been writing software alone for 24 years. The shape of how I do that hasn't changed much — but I've watched the environment around me change a lot, and one shift has been quietly bothering me.&lt;/p&gt;

&lt;p&gt;It's the shift in what application engineers are given on day one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pain used to teach you when something was wrong
&lt;/h2&gt;

&lt;p&gt;In the old days, the lesson was simple. Your hardware stopped responding. That was the lesson.&lt;/p&gt;

&lt;p&gt;A box couldn't keep up — you saw it on the dashboard, you felt it in the user complaints. You learned, often the hard way, that you needed an LB and two machines instead of one. The pain told you the system had outgrown its current shape.&lt;/p&gt;

&lt;p&gt;EC2 didn't change this much. An instance could still get pinned at 100% CPU. The application could still hang. The pain was still there, just on rented hardware instead of bought hardware. The lesson — &lt;em&gt;squeeze whatever you can out of the application layer first&lt;/em&gt; — was still delivered in the same form.&lt;/p&gt;

&lt;p&gt;The pain was the cheapest monitoring tool any of us ever had.&lt;/p&gt;




&lt;h2&gt;
  
  
  Serverless took three pains away
&lt;/h2&gt;

&lt;p&gt;Then serverless arrived. Auto-scaling. Pay-per-use. Managed everything. And without anyone really announcing it, three things that used to teach engineers stopped teaching them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pain 1: cost spikes that show up too late
&lt;/h3&gt;

&lt;p&gt;In a per-use billing model, your application can be running badly for weeks before the financial signal arrives. You only notice when the invoice shows up at the end of the month — by which point, the failure is already paid for.&lt;/p&gt;

&lt;p&gt;The "fix" most people reach for is a budget alert. But a budget alert is a smoke detector after the fire is already in the wall. You needed to know in week one, not in week four.&lt;/p&gt;

&lt;p&gt;Modern anomaly-detection tools can shorten the gap from weeks to hours. They help. But notice the framing: each new tool exists because something underneath was hidden in the first place. The tool is a patch on a layer of invisibility, not a substitute for not having that layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pain 2: performance degradation that auto-scaling hides
&lt;/h3&gt;

&lt;p&gt;You're looking at a database load graph that's been climbing for three months. Why?&lt;/p&gt;

&lt;p&gt;Is it because users are growing? Healthy. Or is it because your aggregation table is missing partitions, so query cost grows superlinearly with data volume? Not healthy.&lt;/p&gt;

&lt;p&gt;When the database has fixed limits, this question gets forced on you. The database starts complaining. You have to look.&lt;/p&gt;

&lt;p&gt;When the database auto-scales, the second condition is much easier to overlook. The infrastructure absorbs the inefficiency, and nothing forces you to look. The graph keeps climbing. The bill keeps climbing. Nobody traces it back to the actual code that's doing the wrong thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pain 3: where the work actually happens
&lt;/h3&gt;

&lt;p&gt;This is the one I think about most.&lt;/p&gt;

&lt;p&gt;Someone I work with recently described a problem to me like this: &lt;em&gt;the managed GraphQL layer is firing way too many SQL queries, and I need to do something about it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I kept turning that statement over in my head. &lt;em&gt;The managed GraphQL layer is firing too many SQL queries.&lt;/em&gt; What does that actually mean?&lt;/p&gt;

&lt;p&gt;It means: queries are being issued, against a relational database, on behalf of an application this engineer is responsible for. And the engineer doesn't fully know how many, when, or with what transaction boundaries.&lt;/p&gt;

&lt;p&gt;That last part is the one that matters. I asked them: if you're aggregating into yearly, monthly, and daily summary tables, and then setting a "processed" flag on the source records — and the very last UPDATE fails — can you roll all of that back? Is the whole sequence under your control?&lt;/p&gt;

&lt;p&gt;I'm not sure they fully understood the question. To be fair, in the world they were handed on day one, the question doesn't naturally come up. The way the queries were resolved broke the unit of work into independent pieces, and each piece looked atomic on its own.&lt;/p&gt;

&lt;p&gt;That isn't an SQL problem. It's a &lt;em&gt;control&lt;/em&gt; problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  A web design analogy
&lt;/h2&gt;

&lt;p&gt;If I had to put this another way:&lt;/p&gt;

&lt;p&gt;You can build a beautiful website using a no-code tool today. The output looks great. It deploys fine. It probably even works in production for a while.&lt;/p&gt;

&lt;p&gt;But when something needs adjusting at the level of the generated code, you're stuck. The tool has handed you a polished surface, and not the means to repair it. For prototyping, this is excellent. For long-term operation, it's a problem you'll only discover the day you need to fix something the tool didn't anticipate.&lt;/p&gt;

&lt;p&gt;Serverless plus heavy abstraction layers, given to an engineer on day one, is a similar situation — except the things they can't repair include cost, performance, and transactional integrity, all at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  The application engineer is the right judge — they just need to be able to see
&lt;/h2&gt;

&lt;p&gt;Here's the part that often gets reversed in these discussions.&lt;/p&gt;

&lt;p&gt;The right person to judge whether an application is well-optimized is the application engineer who built it. They know the business requirements. They know which endpoint is hit by 1M requests a day, and which one is hit by 10. They know which tables grow linearly with users and which grow with the square of users. Infrastructure people, by their position, often don't have this context — not because they lack ability, but because the role doesn't naturally include the business reasoning behind every endpoint. They can't make this judgment from the outside.&lt;/p&gt;

&lt;p&gt;The application engineer is the right judge.&lt;/p&gt;

&lt;p&gt;But to judge, they need to be able to see. They need to know how many SQL queries their endpoint issues. They need to know what transactions they own. They need to know what happens when the third write succeeds and the fourth fails. None of this is obscure or fancy — it's the basic literacy of running a relational database under load.&lt;/p&gt;

&lt;p&gt;The problem isn't that abstractions exist. Abstractions are fine. The problem is that abstractions are now the &lt;em&gt;first thing&lt;/em&gt; an engineer encounters, and the things underneath them are made deliberately invisible.&lt;/p&gt;




&lt;h2&gt;
  
  
  My setup, for what it's worth
&lt;/h2&gt;

&lt;p&gt;I run something called LDX hub. It's a public API platform on top of an internal hub I've been growing for years. Five services, document processing, the usual.&lt;/p&gt;

&lt;p&gt;For the public-facing edge, I use a managed gateway on a flat-rate monthly subscription. It's "serverless" in shape, but the billing is fixed. There is no cost-spike risk to monitor for. If the bill could ever scale with traffic — even gracefully — I would seriously reconsider. The flat rate is the entire reason this part of the stack is comfortable to me.&lt;/p&gt;

&lt;p&gt;For the compute layer, I run Java on EC2. Java's cold-start cost makes a long-running process the natural fit, but that's only half the reason. The other half is that EC2 has fixed limits. When something is wrong with the application, the fixed ceiling forces me to see it. I keep some old-fashioned things — server-side metrics, per-segment timing logs that record how long each part of a request took, alerts when something runs slower than it should — and these tell me, fairly directly, whether the application is doing its job efficiently or not.&lt;/p&gt;

&lt;p&gt;For the database, I run my own RDB on EC2 too. Managed databases would handle backups and patches for me, but they would also schedule downtime I can't always control. I prefer the option of doing maintenance myself, on my schedule, with the techniques I've used for two decades — including running two databases in parallel through an application-layer two-phase commit, then cutting over, all without taking the system down. None of that works if the database is something I can't touch.&lt;/p&gt;

&lt;p&gt;The whole stack is in AWS. None of it is "old school" in the sense of being on physical hardware. But each layer was chosen so that the things I should be able to see remain visible, and the things I should be able to control remain in my control.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what 24 years has taught one specific person to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;I'm not against abstractions. I use them. The managed gateway in front of LDX hub is one. Cloud is one. Even my IDE is one.&lt;/p&gt;

&lt;p&gt;What I'm against is &lt;em&gt;starting&lt;/em&gt; there.&lt;/p&gt;

&lt;p&gt;If you learn to write applications in an environment where you can't see what you're consuming, you don't learn the relationship between what you wrote and what your system did. You also don't learn how to cover the gap when something goes wrong. Twenty-four years from now, you'll still be guessing at it.&lt;/p&gt;

&lt;p&gt;The engineers I see escape this trap usually do it because someone — often by accident — exposed them to a system where the lights were on.&lt;/p&gt;

&lt;p&gt;I don't know if that's a fixable situation at the level of an industry. I work around it in my own setup by keeping the lights on — not because visibility is a virtue, but because the business logic is the one thing in my stack that no one else will get right for me. Everything else exists to give me time to get that part right.&lt;/p&gt;

&lt;p&gt;Experience widens the set of choices that work for that. I'm not arguing against any of those choices. I'm arguing against the day one where there isn't a choice yet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>serverless</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Build a multi-step n8n Form with dynamic dropdowns (no plugin needed)</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 03 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/build-a-multi-step-n8n-form-with-dynamic-dropdowns-no-plugin-needed-1pm4</link>
      <guid>https://dev.to/hidekimori/build-a-multi-step-n8n-form-with-dynamic-dropdowns-no-plugin-needed-1pm4</guid>
      <description>&lt;p&gt;You want a multi-step form in n8n. Each step's dropdown depends on what the user picked or uploaded in the previous step. You search for "n8n dynamic dropdown" and find static &lt;code&gt;fieldOptions&lt;/code&gt; examples and the occasional "use a Code node and hope."&lt;/p&gt;

&lt;p&gt;I had the same problem building the distribution workflow for &lt;a href="https://gw.portal.ldxhub.io" rel="noopener noreferrer"&gt;LDX hub&lt;/a&gt;, an AI document processing API. I wanted one workflow where a user picks one of five services, then walks through engine selection, file upload, and output format — with every dropdown computed from the previous step.&lt;/p&gt;

&lt;p&gt;It turns out n8n already supports this. The feature is just hidden behind one toggle on the Form node.&lt;/p&gt;

&lt;p&gt;This post walks through the pattern, with working code, three things to watch out for, and a link to a complete reference workflow you can import.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one toggle that changes everything
&lt;/h2&gt;

&lt;p&gt;The n8n Form node has a setting called &lt;strong&gt;Define Form&lt;/strong&gt;. The default is &lt;strong&gt;Using Fields Below&lt;/strong&gt; — you add fields in the UI like a typical form builder. There's a second option: &lt;strong&gt;Using JSON&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In JSON mode, the entire field array becomes an n8n expression. You return a JavaScript array where each element is a field definition, and you can reference any previous step's data inside that expression.&lt;/p&gt;

&lt;p&gt;That's the whole technique. Everything below is just showing what you can do once you flip that switch.&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%2F50ei3ayafi1vaaea0g10.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%2F50ei3ayafi1vaaea0g10.png" alt="The Define Form parameter switched to Using JSON, with the dropdown options visible" width="800" height="743"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the chain we're going to build, for one branch of the workflow:&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%2Fwa865j0q5hmtkq74xzi6.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%2Fwa865j0q5hmtkq74xzi6.png" alt="ExtractDoc path: Form Trigger → HTTP Request → Form (Engine) → Set → Form (File) → Set → Form (Output) → Code → LDX hub Run → Form Ending" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Form Trigger collects the API key and the service the user wants to use. The HTTP Request fetches the available engines from LDX hub. Then a sequence of Form / Set / Form / Set / Form steps progressively narrows the user's choices based on what came before. The LDX hub node runs the job. A final Form node displays the result.&lt;/p&gt;




&lt;h2&gt;
  
  
  The minimal example: dropdown from an API call
&lt;/h2&gt;

&lt;p&gt;The simplest dynamic dropdown is: call an endpoint that returns a list, render that list as options.&lt;/p&gt;

&lt;p&gt;LDX hub's &lt;code&gt;/extractdoc/engines&lt;/code&gt; endpoint returns the available extraction engines without authentication. Calling it from n8n:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Request node
  URL: https://gw.ldxhub.io/extractdoc/engines
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual response right now:&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;"data"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ki/extract"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"display_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"KI Extract"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ki"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Extracts plain text from documents in reading order..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"supported_conversions"&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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jsonl"&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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jsonl"&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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"xlsx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"xlsx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jsonl"&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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pptx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pptx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jsonl"&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;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;One engine today. If the provider adds more tomorrow, they show up in the response automatically — and, as you're about to see, in the dropdown.&lt;/p&gt;

&lt;p&gt;The Form node that follows the HTTP Request:&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="err"&gt;Form&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;node&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;Define&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Form:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Using&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Output:&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;span class="err"&gt;fieldLabel:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;fieldName:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"engine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;fieldType:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dropdown"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;fieldOptions:&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="err"&gt;values:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;$json.data.map(e&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;option:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;e.id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&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="err"&gt;requiredField:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;The expression returns an array with one field. The field is a dropdown. Its options are computed by mapping the HTTP response's &lt;code&gt;data&lt;/code&gt; array into the shape n8n expects (&lt;code&gt;{ option: "ki/extract" }&lt;/code&gt;).&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%2Fwxvbld1xs7zxa60djchx.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%2Fwxvbld1xs7zxa60djchx.png" alt="The JSON Output expression editor: the engine dropdown definition in the middle pane, the previous node's data tree on the left, and the result preview on the right" width="800" height="601"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When the form renders, the user sees &lt;code&gt;ki/extract&lt;/code&gt; as the only choice. The list reflects whatever the API returned just now, not what you hardcoded last week. That's the whole future-proofing argument for doing it this way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Going one level deeper: filter by user's choice
&lt;/h2&gt;

&lt;p&gt;Static dropdowns are easy. The interesting case is when one dropdown's options depend on what the user chose in a previous dropdown.&lt;/p&gt;

&lt;p&gt;After the user picks an engine, I want to compute which input file formats it supports. That information is in the engine's &lt;code&gt;supported_conversions&lt;/code&gt; array. A Set node does the derivation:&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="nb"&gt;Set&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;
  &lt;span class="nx"&gt;Assignments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;from_options&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;array&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ExtractDoc: Get Engines&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;supported_conversions&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;i&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;p&gt;For &lt;code&gt;ki/extract&lt;/code&gt;, this produces &lt;code&gt;["pdf", "docx", "xlsx", "pptx"]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three things to notice in that expression:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;$('ExtractDoc: Get Engines').item.json&lt;/code&gt;&lt;/strong&gt; — the long form for cross-step reference. The short form &lt;code&gt;$json&lt;/code&gt; only sees the immediately previous step's data. To reach back past the Form node that ran in between, you need the long form. This is the single biggest gotcha. More on this below.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;$json.engine&lt;/code&gt;&lt;/strong&gt; — the immediately previous step's data, which is the engine the user just selected.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;.filter((v, i, a) =&amp;gt; a.indexOf(v) === i)&lt;/code&gt;&lt;/strong&gt; — uniquify. Engines advertise the same input format across multiple conversion pairs (&lt;code&gt;pdf → text&lt;/code&gt;, &lt;code&gt;pdf → jsonl&lt;/code&gt;), and you don't want the dropdown to repeat "pdf" four times.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The next Form node renders the file upload field, restricting accepted file types to what the chosen engine actually supports:&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="err"&gt;Form&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;node&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;Define&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Form:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Using&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Output:&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;span class="err"&gt;fieldLabel:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Source File"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;fieldName:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;fieldType:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;acceptFileTypes:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;$json.from_options.join(&lt;/span&gt;&lt;span class="s2"&gt;",."&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;requiredField:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;&lt;code&gt;acceptFileTypes&lt;/code&gt; becomes the browser's &lt;code&gt;&amp;lt;input accept="..."&amp;gt;&lt;/code&gt; attribute. With &lt;code&gt;from_options = ["pdf", "docx", "xlsx", "pptx"]&lt;/code&gt;, the resulting value is &lt;code&gt;.pdf,.docx,.xlsx,.pptx&lt;/code&gt; — the user's file picker shows only those four types.&lt;/p&gt;




&lt;h2&gt;
  
  
  Even one more level: filter output by the uploaded file
&lt;/h2&gt;

&lt;p&gt;The output format dropdown depends on what file the user actually uploaded — not what the engine generally supports, but what it can convert &lt;em&gt;this specific input&lt;/em&gt; into.&lt;/p&gt;

&lt;p&gt;This is where you reach into the uploaded file's metadata via &lt;code&gt;$binary&lt;/code&gt;:&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="nb"&gt;Set&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;
  &lt;span class="nx"&gt;Assignments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;to_options&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;array&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;jpg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tif&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tiff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileExtension&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&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="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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ExtractDoc: Get Engines&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ExtractDoc: Select Engine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;supported_conversions&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&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;p&gt;A few notes on this expression:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;IIFE wrapper&lt;/strong&gt; &lt;code&gt;(() =&amp;gt; { ... })()&lt;/code&gt; — to use local variables (&lt;code&gt;map&lt;/code&gt;, &lt;code&gt;raw&lt;/code&gt;, &lt;code&gt;from&lt;/code&gt;) and an explicit return. n8n expressions are JavaScript; any valid JS works inside &lt;code&gt;{{ }}&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Extension mapping&lt;/strong&gt; — &lt;code&gt;$binary.file.fileExtension&lt;/code&gt; returns whatever the user's file is named with. APIs sometimes use a canonical form (e.g., &lt;code&gt;jpeg&lt;/code&gt; rather than &lt;code&gt;jpg&lt;/code&gt;); a small map normalizes the boundary so the filter doesn't miss matches.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Two long-form references&lt;/strong&gt; — &lt;code&gt;$('ExtractDoc: Get Engines')&lt;/code&gt; for the engine list, &lt;code&gt;$('ExtractDoc: Select Engine')&lt;/code&gt; for the user's selection. Long form everywhere.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a user uploading a PDF against &lt;code&gt;ki/extract&lt;/code&gt;, this resolves to &lt;code&gt;["text", "jsonl"]&lt;/code&gt;. The output dropdown is then trivial:&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="err"&gt;Form&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;node&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;Define&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Form:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Using&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Output:&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;span class="err"&gt;fieldLabel:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Output Format"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;fieldName:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"output_format"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;fieldType:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dropdown"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;fieldOptions:&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="err"&gt;values:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;$json.to_options.map(t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;option:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&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="err"&gt;requiredField:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;Here's what the user sees on the entry page of the rendered form, before any of the dynamic dropdowns appear:&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%2Fwfx7c3mnwzs1o5jxc9u5.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%2Fwfx7c3mnwzs1o5jxc9u5.png" alt="The first page of the rendered form: API Key, API Host, and Service dropdown. The Engine, File, and Output Format dropdowns we just built appear on subsequent pages" width="800" height="1131"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Three things to watch out for
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The short form &lt;code&gt;$json&lt;/code&gt; does not reach across forms
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;$json&lt;/code&gt; refers to the immediately previous node's output. The moment a Form node sits between two nodes, &lt;code&gt;$json&lt;/code&gt; can't see the data from before that Form.&lt;/p&gt;

&lt;p&gt;Fix: the long form, &lt;code&gt;$('Node Name').item.json.field&lt;/code&gt;. Works from anywhere in the workflow, regardless of how many nodes intervene.&lt;/p&gt;

&lt;p&gt;Make it your default. Use the long form even when the short form would work — it'll save you when you later insert a Form node and don't want to rewrite three expressions.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Binary data needs an explicit pass-through
&lt;/h3&gt;

&lt;p&gt;Form nodes return data on the &lt;code&gt;json&lt;/code&gt; channel. The file the user uploaded sits on a separate &lt;code&gt;binary&lt;/code&gt; channel that doesn't automatically propagate through Set nodes, Switch nodes, or subsequent Form nodes. To carry the binary forward to the node that actually consumes it, insert a Code node:&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ExtractDoc: Upload File&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the smallest Code node that does anything useful. Steal it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. When exporting as a template, blank out the &lt;code&gt;webhookId&lt;/code&gt; fields
&lt;/h3&gt;

&lt;p&gt;The Form Trigger and Form nodes each carry a &lt;code&gt;webhookId&lt;/code&gt; UUID in the exported JSON. n8n preserves these on import — it doesn't regenerate them — so your IDs travel with the workflow into the importer's instance.&lt;/p&gt;

&lt;p&gt;This is a known sharp edge in n8n's import path (&lt;a href="https://github.com/n8n-io/n8n/issues/18683" rel="noopener noreferrer"&gt;issue #18683&lt;/a&gt;). The convention for shareable templates is to strip the IDs before publishing: set every &lt;code&gt;webhookId&lt;/code&gt; value to &lt;code&gt;""&lt;/code&gt;. The importing instance allocates fresh ones on first save.&lt;/p&gt;

&lt;p&gt;If you forget, the most common failure mode is silent webhook hijacking — calls intended for your original workflow get routed to the imported one, or vice versa.&lt;/p&gt;

&lt;p&gt;The reference workflow below ships with every &lt;code&gt;webhookId&lt;/code&gt; blanked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;The default n8n template is a one-configuration workflow. You build it for your exact use case, save it, and it works for you. Anyone else who imports it either matches your configuration exactly or edits the JSON by hand.&lt;/p&gt;

&lt;p&gt;Dynamic dropdowns change that. The same workflow now adapts to the user — their file, their choice of engine, their available output formats. The template becomes a small application instead of a personal artifact.&lt;/p&gt;

&lt;p&gt;For LDX hub, this meant one workflow could expose five different AI services with all their valid configurations, without me hardcoding any of them. When the provider adds a sixth engine tomorrow, the workflow doesn't change. The Get Engines call returns one more entry, the dropdown shows one more option, the rest of the flow handles it.&lt;/p&gt;

&lt;p&gt;That's the reason this technique is worth knowing. Not because dynamic dropdowns are interesting in themselves, but because they're the part of the n8n stack that lets you ship a configurable workflow.&lt;/p&gt;




&lt;h2&gt;
  
  
  The complete reference workflow
&lt;/h2&gt;

&lt;p&gt;The full demo — five services, every dropdown driven by this pattern, plus dynamic credentials (a separate post) — ships with the &lt;a href="https://www.npmjs.com/package/n8n-nodes-ldxhub" rel="noopener noreferrer"&gt;n8n-nodes-ldxhub&lt;/a&gt; npm package as &lt;code&gt;examples/all-services-demo.json&lt;/code&gt;. Import it into your n8n instance and inspect any of the five service paths to see the pattern in production form.&lt;/p&gt;

&lt;p&gt;If you want to run it against LDX hub, get a free API key at &lt;a href="https://gw.portal.ldxhub.io" rel="noopener noreferrer"&gt;gw.portal.ldxhub.io&lt;/a&gt; — 25,000 credits per month, no card. If you just want the pattern, the JSON is permissively licensed and the dropdown logic is portable to any API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The dropdown pattern collapses what looks like a hard problem ("how do I make my n8n template work for arbitrary users") into something almost boring ("call an endpoint, render its response as options"). Most of the configurability complexity I've fought over the years dissolves the same way, once I find the right boundary to draw.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't writing the expressions. It was discovering that the toggle had been sitting there in the Form node settings the whole time. The technique looks novel only because it's underdocumented.&lt;/p&gt;

&lt;p&gt;If you've been building n8n templates that only work for your exact setup, try moving one configuration knob to a dropdown driven by an API call. That's where this starts paying off.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The loop I didn't notice closing</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 01 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8</link>
      <guid>https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8</guid>
      <description>&lt;h1&gt;
  
  
  The loop I didn't notice closing
&lt;/h1&gt;

&lt;p&gt;Seven weeks ago I started using AI for work. Two weeks after that, I published an article. Seven weeks after that — today — the article is one of sixteen, and they are all in a memory file that the AI reads at the start of every new conversation.&lt;/p&gt;

&lt;p&gt;I didn't notice the loop until I named it.&lt;/p&gt;

&lt;p&gt;This is a note about that loop, what it is, what it isn't, and why I keep publishing even though the loop doesn't strictly need me to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;It runs like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I decide what to do.&lt;/li&gt;
&lt;li&gt;I work it out with the AI — usually in dialogue, sometimes by pasting raw code or data.&lt;/li&gt;
&lt;li&gt;The dialogue becomes a record. Sometimes a memory entry. Sometimes a published article.&lt;/li&gt;
&lt;li&gt;The record becomes context for the next conversation, which informs the next decision.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It didn't look this clean while it was happening. The numbering is hindsight. From inside, the steps overlap.&lt;/p&gt;

&lt;p&gt;The first step is the one I keep. Direction is mine: what to build, what to write, what to negotiate. The history that shapes those decisions — twenty-four years of solo work, my company, my family, my health — is also mine. The AI is not setting direction.&lt;/p&gt;

&lt;p&gt;The second step is where most of the leverage is. I describe what I want to do as completely as I can, sometimes by handing over source code. Then I ask: does this look right? Is there a path I'm missing? Where would this break? I'm opening drawers — possibilities I half-saw in my own head — and checking which ones open cleanly. When one opens cleanly, that is the GO signal. Not "will this succeed" but "this is doable, so do it."&lt;/p&gt;

&lt;p&gt;The third step happens almost without effort. The conversation already exists as text. Some of it becomes a memory entry I add deliberately. Some of it becomes raw material for an article. The article writes itself partly because I have already explained the thing to the AI.&lt;/p&gt;

&lt;p&gt;The fourth step is the one that took longest to arrive — and the one I want to be most careful about describing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three phases, not one
&lt;/h2&gt;

&lt;p&gt;The loop didn't close in a moment. It accumulated.&lt;/p&gt;

&lt;p&gt;In April, when I started using AI, it was just an easier way to write about things I already knew. My first published article was about a technique I had been using for years — a way of decomposing one long LLM prompt into two stages. I had named the technique privately. Explaining it to the AI turned the explanation into a draft. The article went out two weeks after I started. The AI didn't discover the technique; I had it already. The AI made it writable.&lt;/p&gt;

&lt;p&gt;That was phase one. AI as a transcriber for things I already had.&lt;/p&gt;

&lt;p&gt;A few weeks later, something shifted. The dialogue started shaping execution. When I redesigned the pricing model, the rate formula went through several iterations in conversation. When I built the chunked upload API, the chunk size and the format were chosen in the back-and-forth, not before. The decisions were still mine — I confirmed each one — but the candidate options came from the dialogue. I would not have arrived at exactly the same designs alone. Probably similar. Not the same.&lt;/p&gt;

&lt;p&gt;That was phase two. AI as a tactical partner inside the execution.&lt;/p&gt;

&lt;p&gt;The third phase took longer. It needed a critical mass of accumulated record. By the time I had a dozen published articles, a dense memory file, and weeks of transcripts, something else became possible: the AI could read across all of that in a single conversation. Now when I plan the next thing — which article to publish next, how to structure the next negotiation, whether to take a particular technical bet — I am not feeding context from scratch. The context is loaded. The conversation starts from where the last conversation left off, three weeks ago, with full continuity.&lt;/p&gt;

&lt;p&gt;That was phase three. The accumulated record informing the next execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it feels like
&lt;/h2&gt;

&lt;p&gt;Two things stand out.&lt;/p&gt;

&lt;p&gt;First, there is no shame and no time pressure. Explaining your full background to a human advisor is sometimes painful. You skip parts because they are embarrassing, or because you don't want to bore them, or because the context would take an hour to set up. The failures, the half-finished detours, the impulses you talked yourself out of — those usually get edited out. With an AI you don't edit them out. You hand over everything you have, including raw code, and ask what it sees. The friction of being honest is much lower.&lt;/p&gt;

&lt;p&gt;Second, the bottleneck is mine. The AI doesn't tire. I do. When the loop slows down, it slows down because I am tired, distracted, or running into something I haven't thought through yet. The tool itself is patient in a way humans cannot be. This shifts the constraint structure of the work. The limit is my own attention, not someone else's calendar.&lt;/p&gt;

&lt;p&gt;I open every drawer I might be able to pull out. Some open cleanly. Some don't. The cleanly-opening ones are the GO signal. Success or failure is not the test — the test is whether the thing looks doable from where I am standing. If it does, I go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tactical, not strategic
&lt;/h2&gt;

&lt;p&gt;I want to be careful with the word "strategy."&lt;/p&gt;

&lt;p&gt;The high-level direction is mine. The decisions about what to build, what to refuse, what to invest in, where my career goes — those come from my own history, my values, the people in my life, my health. The AI is not setting that direction.&lt;/p&gt;

&lt;p&gt;The execution is collaborative. The drawer-opening is collaborative. The articulation is collaborative.&lt;/p&gt;

&lt;p&gt;So when I describe the loop, I would call it a tactical loop, not a strategic one. The intent stays mine. The execution finds its shape in the dialogue.&lt;/p&gt;

&lt;p&gt;This distinction matters because the situation is easy to misread. "Solo developer outsources thinking to AI" is wrong. "Solo developer uses AI as a tactical tool with persistent memory" is closer. The agency is intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recursive part
&lt;/h2&gt;

&lt;p&gt;This article is in the loop.&lt;/p&gt;

&lt;p&gt;Earlier today I asked the AI what I should write about next. It read the memory and the recent transcripts and proposed this — the loop itself. We discussed it. I gave my felt-sense answers about what it is like from the inside. The conversation by then already contained most of the structure. The AI wrote a draft from it. I refined. The article will be published, become part of the memory, and inform the next decision about what to write.&lt;/p&gt;

&lt;p&gt;The article describes the phenomenon it is also an instance of.&lt;/p&gt;

&lt;p&gt;This is not theater. It is just what the loop looks like when it is also reflective.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why publish, then
&lt;/h2&gt;

&lt;p&gt;If the loop runs on memory and transcripts, what is the public article for?&lt;/p&gt;

&lt;p&gt;If I am honest, the loop doesn't strictly need it. The closed loop runs fine on internal artifacts.&lt;/p&gt;

&lt;p&gt;But two things hold.&lt;/p&gt;

&lt;p&gt;First, writing for an audience tightens the thought differently than writing for an AI. The forcing function is different. The fear of being misread, of saying something stupid in public, of being quoted out of context — these tighten the prose in ways talking to an AI does not. I get a cleaner record by publishing than by just transcribing.&lt;/p&gt;

&lt;p&gt;Second, and this is the one that matters more: not everyone can run this loop. I see colleagues with the same tools, the same access, the same time, and the gap is large. Some people have the sense for it, and others don't. The tool does not close that gap. If it could, the tool would already be replacing judgment — and it isn't.&lt;/p&gt;

&lt;p&gt;This is the structural point. AI cannot teach humans to use AI well. If it could, AI would not need humans at all. The persistence of the skill gap is the same fact as the persistence of human judgment. The two cannot be separated.&lt;/p&gt;

&lt;p&gt;So I publish. Not because the loop needs it. Because the sense — the thing that lets the loop run at all — isn't transferable through the tool. It transfers, if at all, by watching someone else use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;I started seven weeks ago without intending to start a loop. The first article was a side effect of being asked to explain something. Then the dialogue started shaping the execution. Then the record started shaping what came next. The loop closed without my noticing.&lt;/p&gt;

&lt;p&gt;When I named it, I could see it.&lt;/p&gt;

&lt;p&gt;The next conversation has already started.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>discuss</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>72% -&gt; 75% -&gt; 92%: a reproducible RAG validation</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Fri, 29 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/72-75-92-a-reproducible-rag-validation-1ngc</link>
      <guid>https://dev.to/hidekimori/72-75-92-a-reproducible-rag-validation-1ngc</guid>
      <description>&lt;p&gt;I expected converting docs into Q&amp;amp;A pairs to improve retrieval. It mostly didn't.&lt;/p&gt;

&lt;p&gt;I built three knowledge bases from the same source document and ran the same twelve questions against each, three times.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Raw markdown chunks: &lt;strong&gt;72%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Q&amp;amp;A facts from a generic prompt: &lt;strong&gt;75%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Q&amp;amp;A facts from a retrieval-aware prompt: &lt;strong&gt;92%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 75% caught me. Naive Q&amp;amp;A conversion lifts retrieval by three points — a wash, not a gain. The +20 over raw markdown comes from something else entirely: five prompt design rules applied at fact-generation time. The rules cluster around two ideas — each fact should answer fully on its own, and the tokens retrieval needs (service names, parameter names, identifiers) should survive verbatim from source to indexed fact.&lt;/p&gt;

&lt;p&gt;This is a reproducible record of where the gains actually came from.&lt;/p&gt;

&lt;p&gt;The full repo, including every prompt, every fact, every chatbot response: &lt;a href="https://github.com/HidekiMori/rag-accordion-demo" rel="noopener noreferrer"&gt;github.com/HidekiMori/rag-accordion-demo&lt;/a&gt; (MIT).&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The setup
&lt;/h2&gt;

&lt;p&gt;The source document was the &lt;a href="https://gw.portal.ldxhub.io" rel="noopener noreferrer"&gt;LDX hub developer portal&lt;/a&gt; — an 821-line Markdown file describing five API services (StructFlow, RefineLoop, RenderOCR, CastDoc, and ExtractDoc). It mixes prose, parameter tables, JSON examples, and a cross-cutting "Errors" section. Long enough to test retrieval at scale, structured enough to test what each piece of the pipeline does.&lt;/p&gt;

&lt;p&gt;From this document, three knowledge bases were built:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;mdx_direct&lt;/code&gt; — the raw Markdown, chunked on &lt;code&gt;\n\n&lt;/code&gt;, 1024 tokens per chunk, 50 token overlap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;naive_facts&lt;/code&gt; — Q&amp;amp;A facts produced from the document by a generic "extract Q&amp;amp;A pairs from this" prompt. 78 facts, one per JSONL line.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;best_facts&lt;/code&gt; — Q&amp;amp;A facts produced by a Stage 2 prompt with five explicit rules. 82 facts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are loaded into Dify Cloud as Knowledge Bases. Same embedding model (OpenAI &lt;code&gt;text-embedding-3-large&lt;/code&gt;, default dimensions). Same retrieval (Hybrid Search, Weighted Score, 0.7 semantic / 0.3 keyword, Top K=3). Same chatbot LLM (OpenAI GPT-5.5). Same system prompt. The only variable across runs is which KB is attached.&lt;/p&gt;

&lt;p&gt;Dify Cloud's vector storage runs on TiDB Cloud Starter — see &lt;a href="https://www.pingcap.com/case-study/dify-consolidates-massive-database-containers-into-one-unified-system-with-tidb/" rel="noopener noreferrer"&gt;PingCAP's case study&lt;/a&gt; on Dify's consolidation. I didn't write SQL against TiDB directly; what matters here is that Hybrid Search on top of TiDB is what made the keyword side of Rule 5 (below) visible in the results.&lt;/p&gt;

&lt;p&gt;The Q&amp;amp;A facts were produced by a two-stage StructFlow pipeline. StructFlow is a generalized "input + instructions → structured output" function, not a structuring tool per se — the instructions decide what the output looks like, so the same function produces 53 self-contained sections in Stage 1 and Q&amp;amp;A facts in Stage 2. Both stages run on Google Gemini 3.5 Flash at temperature 0. Stage 1 outputs a &lt;code&gt;sections&lt;/code&gt; array; Stage 2 outputs a &lt;code&gt;facts&lt;/code&gt; array. Each array gets flattened into line-per-record JSONL between stages — the shape §5 below calls the "accordion." The Stage 1 prompt is identical for both &lt;code&gt;naive_facts&lt;/code&gt; and &lt;code&gt;best_facts&lt;/code&gt; — only the Stage 2 prompt differs.&lt;/p&gt;

&lt;p&gt;A note on naming. StructFlow appears in this article in two roles: as a service documented in the test corpus (the LDX hub developer portal), and as the engine that built the Q&amp;amp;A facts from that corpus. Same tool, two contexts. When "StructFlow" shows up in the example facts and test questions, it is the service-in-corpus role; when it shows up in pipeline descriptions and the accordion diagram, it is the engine role.&lt;/p&gt;

&lt;p&gt;Twelve test questions, each designed to probe a specific retrieval challenge (direct lookup, default value buried in a parameter list, cross-service comparison, hidden entity inside a code block, casual phrasing, cross-cutting concern scoped to a service, end-to-end workflow synthesis). Three independent runs per KB. Twelve questions × three KBs × three runs = 108 graded chatbot responses, scored as ✅ / ⚠️ / ❌.&lt;/p&gt;

&lt;p&gt;Grading was manual, against a per-question rubric prepared before runs: ✅ for a complete answer that included every expected piece of information, ⚠️ for a partial or incomplete answer (e.g., missing the partial-failure warning in Q12), and ❌ for a wrong answer or a "not in the documentation" refusal when the answer was actually present in the source. Aggregate percentages treat ✅ as correct and both ⚠️ and ❌ as not correct — no partial credit. Per-question patterns and verbatim chatbot responses for all 108 runs are committed under &lt;code&gt;results/&lt;/code&gt; in the repo, so anyone can re-grade if they disagree with the rubric.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The headline numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;KB&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;vs &lt;code&gt;mdx_direct&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mdx_direct&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;— (baseline)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;naive_facts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;+3 pt&lt;/strong&gt; (operationally negligible)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;best_facts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;+20 pt&lt;/strong&gt; (and +17 over naive)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The +3 pt for naive Q&amp;amp;A conversion is a wash, not a gain. Looking question by question reveals why:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;&lt;code&gt;mdx_direct&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;naive_facts&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Instance change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q2 (&lt;code&gt;max_revisions&lt;/code&gt; default)&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;+3 (genuine fix)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q6 (is LDX hub free?)&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;+3 (genuine fix)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q4 (RenderOCR vs CastDoc)&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;td&gt;−3 (genuine break)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q12 (&lt;code&gt;completed&lt;/code&gt; for StructFlow)&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;td&gt;−3 (genuine break)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q10 (scanned-contracts workflow)&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;+1 (mdx run variance)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The four genuine fix/break pairs cancel exactly at the instance level: +3 +3 −3 −3 = 0. The entire +3 pt difference (naive 27/36 vs mdx 26/36) comes from Q10 alone — where &lt;code&gt;mdx_direct&lt;/code&gt; dropped to ⚠️ on a single run (the ExtractDoc→StructFlow chain collapsed that run) while &lt;code&gt;naive_facts&lt;/code&gt; stayed ✅ across all three. That +1 instance is run variance on the raw-markdown side, not a retrieval gain from Q&amp;amp;A conversion. Naive Q&amp;amp;A shuffles where the failures land (Q2/Q6 fixed, Q4/Q12 broken) and nets out at zero; the headline +3 pt is noise on Q10.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;best_facts&lt;/code&gt; keeps all four ✅, and brings six more questions from partial to full credit. Same Stage 1 sections, same LLM, same retrieval — only the Stage 2 prompt differs between &lt;code&gt;naive_facts&lt;/code&gt; and &lt;code&gt;best_facts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The structural gap reproduced across all three runs of all three KBs without exception. It is not statistical noise.&lt;/p&gt;

&lt;p&gt;That said, this is not a benchmark paper. It is a controlled engineering validation on one realistic corpus, with a deliberately small question battery designed to probe specific retrieval failure modes. Whether the five rules below hold under different document structures, retrieval backends, or chunking strategies is something the repo is designed to let you check on your own corpus — not something this single validation establishes.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The five rules
&lt;/h2&gt;

&lt;p&gt;These are the rules encoded in the retrieval-aware Stage 2 prompt. Each one prevents a specific failure mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 1 — Self-contained answers
&lt;/h3&gt;

&lt;p&gt;Every fact must answer its question fully on its own, without needing to read other facts.&lt;/p&gt;

&lt;p&gt;Hybrid Search returns the top three chunks. If the answer is split across multiple facts — "this is the default" + "the parameter is &lt;code&gt;max_revisions&lt;/code&gt;" + "RefineLoop accepts these settings" — retrieval has to pull all three. That does not always happen. When each fact carries the full picture in one place, retrieval only needs to land on that one entry.&lt;/p&gt;

&lt;p&gt;In practice: the &lt;code&gt;best_facts&lt;/code&gt; entry for "what are the possible job statuses?" names every value (&lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;completed&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;) and what each means, in one fact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 2 — Developer-friendly question phrasing
&lt;/h3&gt;

&lt;p&gt;Use the way real users would actually type the query: "How do I...?", "What is the default value of...?", "Which formats does X support?"&lt;/p&gt;

&lt;p&gt;Embeddings reward semantic similarity. If the generated question is phrased like a real user query, the embedding lands close in vector space. Declarative summaries ("Describe the configuration of...") sit further away from how queries actually arrive at retrieval time.&lt;/p&gt;

&lt;p&gt;This rule is easy to underestimate. It costs nothing at generation time and pays back on every retrieval.&lt;/p&gt;

&lt;p&gt;A note on evidence: this validation does not isolate Rule 2's effect. Both &lt;code&gt;naive_facts&lt;/code&gt; and &lt;code&gt;best_facts&lt;/code&gt; already produce question-shaped questions, so the rule is not directly tested here. It is included because the failure mode (declarative summaries instead of questions) is common in prompts that ask for "summaries" or "key points," and once it occurs, retrieval degrades.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 3 — Exact preservation of technical identifiers
&lt;/h3&gt;

&lt;p&gt;API names, endpoint paths, parameter names, enum values, code snippets — keep them verbatim. Do not rephrase.&lt;/p&gt;

&lt;p&gt;Hybrid Search has two channels: vector and keyword. The keyword channel is a literal-token match. &lt;code&gt;max_revisions&lt;/code&gt;, &lt;code&gt;results[].status&lt;/code&gt;, &lt;code&gt;ki/ocr&lt;/code&gt; — these strings only contribute if they appear verbatim. Paraphrased ("the revision count parameter") loses the keyword channel entirely.&lt;/p&gt;

&lt;p&gt;The vector channel can sometimes carry it. Often it cannot. The combined score drops below the cutoff and the fact never enters the top three.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 4 — Service-specific facts for cross-cutting information
&lt;/h3&gt;

&lt;p&gt;When a section describes errors, statuses, or behaviors tied to a specific service, generate a fact with the service name in both the question and the answer.&lt;/p&gt;

&lt;p&gt;Source documents often place cross-cutting information in standalone sections away from the services they affect. Our source has a &lt;code&gt;## Errors&lt;/code&gt; section that includes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;StructFlow jobs may also have &lt;code&gt;status: completed&lt;/code&gt; with some individual records marked &lt;code&gt;failed&lt;/code&gt; — always check &lt;code&gt;summary.failed_count&lt;/code&gt; and each &lt;code&gt;results[].status&lt;/code&gt; to detect partial failures.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The section header is just "Errors", not "StructFlow errors". A generic Q&amp;amp;A extractor handles this content inconsistently. In our &lt;code&gt;naive_facts&lt;/code&gt; run, the partial-failure content was skipped entirely — &lt;code&gt;grep "partial failure" data/facts_naive.txt&lt;/code&gt; returns nothing.&lt;/p&gt;

&lt;p&gt;The retrieval pipeline then cannot answer &lt;em&gt;What does &lt;code&gt;completed&lt;/code&gt; mean for a StructFlow job?&lt;/em&gt; because no indexed fact connects &lt;code&gt;completed&lt;/code&gt;, &lt;code&gt;StructFlow&lt;/code&gt;, and partial failures in the same line. This is Q12, and naive achieves zero correct answers across three runs.&lt;/p&gt;

&lt;p&gt;The retrieval-aware version forces Stage 2 to generate a service-scoped variant: a fact whose question is &lt;em&gt;How should I check for partial failures in a completed StructFlow job?&lt;/em&gt;, with answer text that mentions StructFlow explicitly and keeps every identifier verbatim. Q12 then resolves cleanly on every run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 5 — Deliberate keyword design
&lt;/h3&gt;

&lt;p&gt;Each fact carries a &lt;code&gt;keywords&lt;/code&gt; field with three to seven short tokens — service names, parameters, concepts. These tokens are direct ammunition for the keyword channel of Hybrid Search.&lt;/p&gt;

&lt;p&gt;Without explicit guidance, the LLM tends toward loose, descriptive keywords. A naive fact about the &lt;code&gt;wait&lt;/code&gt; parameter ends up with &lt;code&gt;["wait", "connection", "timeout", "polling"]&lt;/code&gt; — only &lt;code&gt;wait&lt;/code&gt; is LDX hub-specific. The retrieval-aware version keeps &lt;code&gt;["StructFlow", "curl", "job_id", "wait"]&lt;/code&gt; — service name and resource type included.&lt;/p&gt;

&lt;p&gt;This is the rule that resolves Q4 in the validation. The naive prompt did generate a RenderOCR primary-role fact, but its keywords were &lt;code&gt;["OCR", "scanned PDF", "Office files", "layout preservation", "languages"]&lt;/code&gt; — no literal &lt;code&gt;RenderOCR&lt;/code&gt; token. When the user types "RenderOCR vs CastDoc?", the keyword channel contributes far less than it could, and the vector channel alone does not consistently lift this fact into the top three. The retrieval-aware version puts &lt;code&gt;RenderOCR&lt;/code&gt; in every RenderOCR-scoped fact's keywords. The fact gets retrieved.&lt;/p&gt;

&lt;p&gt;The full prompts, with examples and failure modes for each rule, are in &lt;a href="https://github.com/HidekiMori/rag-accordion-demo/blob/main/prompt_engineering.md" rel="noopener noreferrer"&gt;&lt;code&gt;prompt_engineering.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The one question that defeats every prompt
&lt;/h2&gt;

&lt;p&gt;Q8 ("what engines are available for OCR?") is the only question that fails in all three KBs. The source mentions "KI OCR" once in a prose bullet at line 157 — &lt;em&gt;Powered by KI OCR, a battle-tested enterprise OCR engine&lt;/em&gt; — and the engine ID &lt;code&gt;ki/ocr&lt;/code&gt; lives inside the JSON response example of &lt;code&gt;GET /renderocr/engines&lt;/code&gt; at line 644. Neither location surfaces consistently for the query. The prose bullet gets dominated by other RenderOCR feature points. The JSON code block is hard for both raw chunking and Q&amp;amp;A generation to digest.&lt;/p&gt;

&lt;p&gt;Neither prompt rescued this. Q&amp;amp;A conversion in this run did not promote either form into a retrievable "OCR engine name" fact. The Stage 2 best prompt knows about identifier preservation (Rule 3), but it was not given explicit guidance to lift entities out of code blocks into prose-style facts.&lt;/p&gt;

&lt;p&gt;This is the honest limit of what the five rules can do here. Missing data is missing data. Future work: explicit code-block entity extraction, or a follow-up Stage 2 pass that lifts JSON-embedded identifiers into their own facts.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The accordion pattern
&lt;/h2&gt;

&lt;p&gt;The mechanism that produces one JSONL line per Q&amp;amp;A fact, from any structured source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;document
  → Stage 1 (segmenter): { sections: [...] }
  → flatten: one section per JSONL line
  → Stage 2 (extractor): { facts: [...] } per section
  → flatten: one fact per JSONL line
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fiy03jkyszule60n36zaz.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%2Fiy03jkyszule60n36zaz.png" alt="Dify Workflow implementation of the accordion pipeline" width="800" height="104"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same shape implemented in Dify. Stage 1 (S1) and Stage 2 (S2) StructFlow nodes both run on Google Gemini 3.5 Flash. Flatten nodes turn each stage's array output into line-per-record JSONL. Save nodes write the intermediate files for inspection. Full YAML: &lt;code&gt;workflows/dify-accordion.yml&lt;/code&gt; in the repo.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The name "accordion" comes from the shape: 1 doc → N sections → M facts, with a flatten step after each stage to expand array outputs into line-per-record JSONL. Both stages are StructFlow jobs — the LDX hub structured-extraction service. The same mechanism works on HTML, PDF chapters, DOCX heading styles, or any text where a section boundary can be defined.&lt;/p&gt;

&lt;p&gt;What this demo shows is that the mechanism itself is neutral. Paired with a generic Stage 2 prompt, the accordion produces facts that perform at roughly raw-chunking parity. The +17 pt over naive Q&amp;amp;A is not in the mechanism. It is in the Stage 2 prompt.&lt;/p&gt;

&lt;p&gt;This is the part that surprised me most. I had expected the mechanism — the two-stage segment-then-extract design — to carry more of the weight. It does not. The Stage 2 prompt is the lever.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. What this means in practice
&lt;/h2&gt;

&lt;p&gt;A lot of RAG advice focuses on the retrieval side: which embedding model, which similarity function, which chunk size, how big the top-K window. Those decisions matter, but they all act on whatever facts have already been indexed. The shape of those facts — what each fact says, what tokens it carries, how it phrases its question — is decided at fact-generation time. And that decision is what this validation isolates.&lt;/p&gt;

&lt;p&gt;Three takeaways for anyone building Q&amp;amp;A-style RAG over their own documents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do not assume Q&amp;amp;A conversion is automatically better than raw chunks.&lt;/strong&gt; It can be, but only when the conversion preserves what retrieval actually needs. A generic "extract Q&amp;amp;A pairs" prompt is roughly neutral against well-chunked Markdown.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keyword design carries the keyword-channel lift.&lt;/strong&gt; Rule 5 above — putting service names in the keyword field is what fixed Q4. Identifier preservation (Rule 3) targets the same channel and becomes load-bearing when the model paraphrases, but at temperature 0 here the model preserved identifiers on its own, so it was insurance rather than the driver. Both cost nothing at generation time and are easy to encode in the Stage 2 prompt.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-cutting sections need to be re-anchored to their services.&lt;/strong&gt; Rule 4. A &lt;code&gt;## Errors&lt;/code&gt; section that mentions four products should produce four product-scoped facts, not one neutral fact. Otherwise no indexed fact connects the service name and the error in the same line, and retrieval cannot bridge the gap at query time.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These rules generalize to any domain with characteristic identifiers — drug names, statute codes, model numbers, CSS class libraries. The retrieval system already knows how to match strings. The prompt's job at generation time is to make sure the strings stay strings.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Open question — the prompt is the next bottleneck
&lt;/h2&gt;

&lt;p&gt;The five rules describe what works. They do not describe how to apply them.&lt;/p&gt;

&lt;p&gt;The Stage 2 best prompt used in this validation is heavily LDX hub-specific. It encodes that &lt;code&gt;max_revisions&lt;/code&gt; is a parameter, that RenderOCR and CastDoc are distinct services, that &lt;code&gt;## Errors&lt;/code&gt; is a cross-cutting section, that StructFlow jobs can have &lt;code&gt;completed&lt;/code&gt; status with &lt;code&gt;failed&lt;/code&gt; records inside. Every one of those decisions was hand-written into the prompt by someone who already understood the domain.&lt;/p&gt;

&lt;p&gt;This is fine for one domain. It does not generalize.&lt;/p&gt;

&lt;p&gt;A team adopting this pattern for, say, drug labeling, statute codes, or CSS class libraries would have to write their own version of the Stage 2 prompt — with their own service names, their own cross-cutting concerns, their own identifiers to preserve. The know-how is not in the LLM. It is in the prompt author's head.&lt;/p&gt;

&lt;p&gt;There are two paths out, and I have not yet validated which is correct.&lt;/p&gt;

&lt;p&gt;The first is to &lt;strong&gt;codify the methodology&lt;/strong&gt;. Build a checklist (the repo already has one in &lt;code&gt;prompt_engineering.md&lt;/code&gt;) and trust that engineers using the pattern will fill it in for their domain. This is the documentation-as-scaffolding approach. It works for teams with strong technical writers. It does less for teams without them.&lt;/p&gt;

&lt;p&gt;The second is to &lt;strong&gt;make the prompt itself a StructFlow output&lt;/strong&gt;. Add a Stage 0 that ingests the source document and produces the Stage 2 prompt for that domain. The accordion stretches one more time: 1 doc → 1 prompt (Stage 0) → N sections (Stage 1) → M facts (Stage 2). The Stage 0 prompt would be the only domain-agnostic prompt in the system, and it would be reusable across any source.&lt;/p&gt;

&lt;p&gt;I lean toward Stage 0 being a real possibility, but this part is still hypothetical — I have not validated it. The accordion mechanism already proves that one StructFlow step can turn one input into many structured outputs. There is no reason in principle why "the prompt for the next StructFlow step" cannot be one of those outputs. Whether that actually beats a hand-written domain prompt for retrieval quality is an open question. That is the next thing I want to test.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Closing
&lt;/h2&gt;

&lt;p&gt;The repo is MIT-licensed and the Dify Workflow YAML is included — import it into Dify, drop in your own document, and rerun. The shortest possible summary of this whole note is to diff the two Stage 2 prompts: &lt;a href="https://github.com/HidekiMori/rag-accordion-demo/blob/main/prompts/stage2_best.md" rel="noopener noreferrer"&gt;&lt;code&gt;stage2_best.md&lt;/code&gt;&lt;/a&gt; vs &lt;a href="https://github.com/HidekiMori/rag-accordion-demo/blob/main/prompts/stage2_naive.md" rel="noopener noreferrer"&gt;&lt;code&gt;stage2_naive.md&lt;/code&gt;&lt;/a&gt;. Everything else in this article unpacks what that diff is doing.&lt;/p&gt;

&lt;p&gt;TiDB Cloud, accessed through Dify's Knowledge feature, is the silent infrastructure that made this validation reproducible without standing up a vector database from scratch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>promptengineering</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Live report: at this speed, you don't theorize. You eliminate.</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 25 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h</link>
      <guid>https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h</guid>
      <description>&lt;p&gt;From April 15 to April 30. Two weeks. Solo. Plus an AI.&lt;/p&gt;

&lt;p&gt;This is a live report — not a retrospective.&lt;/p&gt;

&lt;p&gt;The earlier posts in this series have been about shape: the contract, the pattern, the twenty-four years of building alone. This one is about what happens when an AI is added to that shape and the speed changes.&lt;/p&gt;

&lt;p&gt;I'm writing this while still inside it. The shape held. The speed didn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Two weeks. One person. Here's the list, flat and unromantic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API gateway&lt;/strong&gt;: Zuplo, five services unified behind one public API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domains&lt;/strong&gt;: &lt;code&gt;gw.ldxhub.io&lt;/code&gt; for production, &lt;code&gt;gw.portal.ldxhub.io&lt;/code&gt; for the developer portal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DevPortal&lt;/strong&gt;: API reference auto-generated, plus introduction, per-service guides, pricing, MCP setup, "use anywhere"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt;: Auth0 with Google OAuth, GitHub OAuth, and email — sign-up to first call in roughly thirty seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free tier&lt;/strong&gt;: 25,000 credits per month, no credit card required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing&lt;/strong&gt;: a unified credit system, $0.0001 per credit, four plans (Free, Starter $15, Standard $50, Pro $150), overage billed at the same rate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe integration&lt;/strong&gt;: connected for paid plan subscriptions and overage settlement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async metering pipeline&lt;/strong&gt;: a separate path that fires credit consumption &lt;em&gt;after&lt;/em&gt; a job finishes (minutes or longer after &lt;code&gt;202 Accepted&lt;/code&gt;), batched to the gateway's billing API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Five services published&lt;/strong&gt;: StructFlow, RefineLoop, RenderOCR, CastDoc, ExtractDoc&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four channels integrated&lt;/strong&gt;: n8n nodes (verified, on the marketplace), Dify plugin (verified, multilingual), MCP for AI assistants like Claude Desktop, plus the direct API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing pages&lt;/strong&gt;: four product pages on the corporate LP (&lt;code&gt;ldxlab.io/ldxhub&lt;/code&gt;, plus per-service pages), each linking to the developer portal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eight scheduled articles&lt;/strong&gt; on dev.to and Zenn, covering the design philosophy. This one is article five.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the surface. None of it is glamorous. All of it had to be done.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape held
&lt;/h2&gt;

&lt;p&gt;The shape from the earlier posts didn't change.&lt;/p&gt;

&lt;p&gt;Jobs go in. The API holds the work. The API retries through whatever needs retrying. The API reports when there's a real result to report. The same shape that ran the music platform in 2003 and the e-book platform from 2006 to 2014, now wrapping a structured-extraction service and four others.&lt;/p&gt;

&lt;p&gt;What changed was the speed within it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What sped up, what didn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sped up:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API design iteration — every interface lived through three or four shapes before settling&lt;/li&gt;
&lt;li&gt;Documentation in four languages (English, Japanese, Chinese, Portuguese) — because the marketplace plugins required it and there was no time to do it sequentially&lt;/li&gt;
&lt;li&gt;Edge-case verbalization — "what happens if the input is missing both &lt;code&gt;inputs&lt;/code&gt; and &lt;code&gt;file_id&lt;/code&gt;?" — answered in writing the same hour the question came up&lt;/li&gt;
&lt;li&gt;Marketplace plugin onboarding (n8n, Dify) — submission, review, version bumps&lt;/li&gt;
&lt;li&gt;Migrations, fixtures, sanity checks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Didn't change:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decisions about the billing structure&lt;/li&gt;
&lt;li&gt;Where to draw the integration boundary (which engines, which surfaces)&lt;/li&gt;
&lt;li&gt;What to ship and what to skip&lt;/li&gt;
&lt;li&gt;What stays in the legacy internal API and what becomes a public service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI didn't replace judgment. It replaced most of the typing.&lt;/p&gt;

&lt;p&gt;(Most. The wrappers that bridge the public services to the legacy internal API — those I typed myself. They were small, they were boring, and they were exactly the shape I had in my head from twenty-four years of doing this. Faster to type than to explain.)&lt;/p&gt;




&lt;h2&gt;
  
  
  What was hardest
&lt;/h2&gt;

&lt;p&gt;The piece I almost gave up on was the credit metering for async jobs.&lt;/p&gt;

&lt;p&gt;Most API gateways meter on the request — easy. What I needed was: meter when the job &lt;em&gt;finishes&lt;/em&gt;, which is minutes (or much longer) after the request returns &lt;code&gt;202 Accepted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I evaluated two gateways. Both could do async reporting on paper. The first one I took all the way through — the API was there, the docs were there, but the UI and the operational behavior had a rough edge to it that I couldn't quite ignore. I wanted the second one. So I went back.&lt;/p&gt;

&lt;p&gt;The second gateway was where I wanted to land. But when I got there, I wasn't sure whether the async metering I needed was actually possible. The documentation was thin in exactly that spot. I tried their support bot. The bot didn't know either. Then a human came on, and the human walked me through how to fire metering events from the job-completion side, batched.&lt;/p&gt;

&lt;p&gt;It worked.&lt;/p&gt;

&lt;p&gt;That's the only piece of this build where I didn't know whether it was possible until someone on the other side confirmed it was. I want to remember that. Twenty-four years of building alone, and the fastest path through this particular unknown wasn't to figure it out alone — it was to ask, and to have the other side answer well.&lt;/p&gt;

&lt;p&gt;(There was also a smaller stumble with Stripe. I assumed I'd need to configure something on the Stripe side to make the gateway's billing flow work. I spent a while looking for that something. There wasn't one. The gateway handled it end-to-end and Stripe just received what it received. Sometimes the answer to "how do I integrate this?" is "you already did.")&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision volume
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me.&lt;/p&gt;

&lt;p&gt;The output increased dramatically. I expected that. What I didn't expect: the volume of decisions and branches multiplied with it. Each one had to be closed individually — there's no way around that. Closing each decision was also faster, so none of them became the bottleneck.&lt;/p&gt;

&lt;p&gt;But the fatigue is different.&lt;/p&gt;

&lt;p&gt;I've been building things alone for twenty-four years. I know what tired feels like. This is something else. The speed compresses everything — the questions, the answers, the small course corrections — into a window where nothing has time to settle. You're never in a phase. You're always at the seam.&lt;/p&gt;

&lt;p&gt;Here's something I didn't expect from twenty-four years of solo work: I never used to worry about missing a branch. The volume of decisions was always within what one head could hold. Now, with the output multiplied, there's a new fear — that something I should have considered slipped past while I was closing five other things. The path keeps moving and the adjacent branches keep widening, and I want to see them all before I commit. That fear didn't exist before. It's not crippling. It's just present.&lt;/p&gt;

&lt;p&gt;I don't have a clean answer for what to do about that. I'm reporting it because it seems important and it isn't in the AI-tooling articles I've read.&lt;/p&gt;




&lt;h2&gt;
  
  
  Elimination, not theory
&lt;/h2&gt;

&lt;p&gt;Here's the part I want to keep.&lt;/p&gt;

&lt;p&gt;At this speed, you don't theorize. You eliminate.&lt;/p&gt;

&lt;p&gt;There isn't time to model out every option, weigh tradeoffs in a meeting, or build a deck. You hold a thing in your hand for an hour, see whether it survives contact with the next thing, and either keep it or remove it. What survives is what you didn't have time to argue with.&lt;/p&gt;

&lt;p&gt;This isn't a productivity claim. It's a description.&lt;/p&gt;

&lt;p&gt;When the friction is low enough — when typing is no longer the rate-limiter — the way decisions get made shifts. They get made by elimination, by what a thing fails to support, rather than by what a thing might enable. The negative case becomes louder than the positive case. You stop asking "what if we add this?" and start asking "what does this make it harder to remove later?"&lt;/p&gt;

&lt;p&gt;I don't know if this generalizes. I know it's what happened in these two weeks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Speed isn't a virtue. It's just what happens when the friction is gone. After twenty-four years, the friction I had left was mostly typing.&lt;/p&gt;

&lt;p&gt;I removed the typing. The shape was already there.&lt;/p&gt;

&lt;p&gt;The fatigue is part of the report.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;LDX hub developer portal: &lt;a href="https://gw.portal.ldxhub.io/introduction" rel="noopener noreferrer"&gt;gw.portal.ldxhub.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;n8n integration: &lt;a href="https://www.npmjs.com/package/n8n-nodes-ldxhub" rel="noopener noreferrer"&gt;n8n-nodes-ldxhub&lt;/a&gt; on npm&lt;/li&gt;
&lt;li&gt;Dify plugin: &lt;a href="https://marketplace.dify.ai/plugin/ldxhub-io/ldxhub" rel="noopener noreferrer"&gt;LDX hub on Dify Marketplace&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MCP endpoint: &lt;code&gt;gw.ldxhub.io/mcp&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Dynamic isn't enough. Operations is the other half.</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 18 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f</link>
      <guid>https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f</guid>
      <description>&lt;p&gt;If you've been writing software for a few years, you eventually start designing for change.&lt;/p&gt;

&lt;p&gt;A content delivery system: a new format will appear, a new player will appear, so let's not hard-code formats. An e-commerce site: a new payment method will be requested, so let's not hard-code billing logic. A document pipeline: a new processing engine will appear, so let's not hard-code engines.&lt;/p&gt;

&lt;p&gt;This is good instinct. You read books like &lt;em&gt;Analysis Patterns&lt;/em&gt;, you internalize the idea that the model should be flexible, you push concrete decisions out of code and into data. New format? Insert a row. New payment? Insert a row. New engine? Insert a row.&lt;/p&gt;

&lt;p&gt;It's a beautiful pattern. It really does extend the lifespan of a system.&lt;/p&gt;

&lt;p&gt;But after enough years of running systems built this way, here's the part that doesn't show up in the books:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Dynamic isn't enough. Operations is the other half — and that other half is usually paid by someone else.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The "wait, maybe..." moment
&lt;/h2&gt;

&lt;p&gt;Reality always overshoots the model. Always.&lt;/p&gt;

&lt;p&gt;You build a content system that handles formats dynamically, and then someone asks for a format whose delivery rules don't fit the existing shape. You build a payment system that takes new methods through configuration, and then someone wants a billing model where the "amount" isn't even computed at the same time as the "charge." You build an engine registry, and then a new engine doesn't conform to the lifecycle every other engine has assumed.&lt;/p&gt;

&lt;p&gt;Each time, the moment looks the same. You stare at the request, and your first thought is: &lt;em&gt;that's outside the model&lt;/em&gt;. And your second thought, a few minutes later, is:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Wait, maybe... if I interpret this field a little differently, and if I add one column here, and if I let this branch handle a special case... it fits. Sort of. It doesn't break anything.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So you do that.&lt;/p&gt;

&lt;p&gt;The system keeps running. No downtime. No coordinated migration. No painful conversation with the integration partners. From your seat as the designer, the dynamic structure absorbed the change. The pattern worked.&lt;/p&gt;

&lt;p&gt;That's the part designers love. That's the part that gets written about.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who pays for the other half
&lt;/h2&gt;

&lt;p&gt;Here's what's harder to see from the designer's seat: every "wait, maybe..." moment leaves a small residue somewhere in operations.&lt;/p&gt;

&lt;p&gt;The operator now has to remember that for &lt;em&gt;this&lt;/em&gt; particular case, the workflow is slightly different. The reporting tool needs a footnote. The on-call playbook gets one more conditional. The next person to onboard needs another paragraph of context. The integrating system has to send a value in a slightly unexpected place.&lt;/p&gt;

&lt;p&gt;None of these are catastrophic. None of them break the system. Each one, on its own, is a small inconvenience.&lt;/p&gt;

&lt;p&gt;But the small inconveniences accumulate. And the people accumulating them are usually not the people who designed the dynamic structure in the first place.&lt;/p&gt;

&lt;p&gt;The designer experiences each "wait, maybe..." as a successful absorption. The operator experiences it as one more thing to remember. Same event, two ledgers. Only the designer's ledger gets reviewed.&lt;/p&gt;

&lt;p&gt;This is where engineering satisfaction can become quietly self-serving. The system didn't break. The pattern held. Therefore the design is good — except &lt;em&gt;good for whom&lt;/em&gt;, exactly?&lt;/p&gt;




&lt;h2&gt;
  
  
  The road I didn't take
&lt;/h2&gt;

&lt;p&gt;There is, of course, an alternative. You can stop the system, ship version 2, ask everyone to migrate, and start clean.&lt;/p&gt;

&lt;p&gt;A scheduled 24-hour outage. A v2 documentation packet sent to every integrating partner. A coordination meeting. A cutover.&lt;/p&gt;

&lt;p&gt;I have, for twenty-four years, mostly chosen &lt;em&gt;not&lt;/em&gt; to do this. I have preferred quiet absorption — interpretation, gentle reshaping, "wait, maybe..." — over coordinated rebuild.&lt;/p&gt;

&lt;p&gt;Whether that was right, I genuinely don't know. There were probably operators along the way who would have been happier with one painful migration than with years of small accommodations. There were probably integrating systems whose engineers would have preferred a clean v2 over a v1 that kept quietly bending.&lt;/p&gt;

&lt;p&gt;I chose to keep the shape. That choice has a cost. The cost is paid in operator-hours, in playbook footnotes, in the small extra cognitive load of working with a system that has absorbed more than its original model anticipated.&lt;/p&gt;

&lt;p&gt;I'm not saying I was wrong. I'm saying: I'm still not sure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Generic underneath, specific on top
&lt;/h2&gt;

&lt;p&gt;There's one thing I do believe, with more confidence than the rest:&lt;/p&gt;

&lt;p&gt;When the model gets very generic, the UI usually has to get very specific to compensate.&lt;/p&gt;

&lt;p&gt;A maximally flexible data model — the kind that absorbs new formats, new payment methods, new engines — is, almost by definition, awkward to interact with directly. Direct interaction with a generic model means the human has to supply all the missing context. That's exhausting.&lt;/p&gt;

&lt;p&gt;The good outcome is: the model stays generic underneath, and the UI is built specific to each use case on top. Each surface is custom. Each surface is rigid in exactly the way that makes it usable.&lt;/p&gt;

&lt;p&gt;This is a hard decision to make, because every specific UI you build feels temporary. You're going to throw it away when the use case shifts. You build it knowing it has a shorter half-life than the model below it. That asymmetry is uncomfortable.&lt;/p&gt;

&lt;p&gt;But I think the discomfort is the right discomfort. A long-lived generic core, with shorter-lived specific surfaces on top, is — based on what I've watched survive over twenty-four years — closer to how systems actually age well.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three observations
&lt;/h2&gt;

&lt;p&gt;None of these are conclusions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Dynamic structure extends a system's life. It does not, on its own, make operating that system pleasant.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Every "wait, maybe..." absorption is a real engineering achievement &lt;em&gt;and&lt;/em&gt; a small tax on someone downstream. Both of these are true.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you make the model very generic, give the operators a UI that isn't generic.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;Dynamic isn't enough. Operations is the other half — and that other half is usually paid by someone else.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After twenty-four years of this, that's the only thing I feel sure of.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;What survives when you build alone for 24 years.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>What survives when you build alone for 24 years</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 11 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d</link>
      <guid>https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d</guid>
      <description>&lt;p&gt;In December 2002 I joined a company that was nearly bankrupt.&lt;/p&gt;

&lt;p&gt;I was twenty-five. A software engineer with no title beyond that. The company was burning cash, and the next product launch had to happen in January — about a month after I started.&lt;/p&gt;

&lt;p&gt;By January 2003, the demo shipped.&lt;/p&gt;

&lt;p&gt;It was an HTML browser for mobile phones — with packet reduction built in. It rendered HTML, played back MIDI files linked from &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tags, ran simple background animations. I wrote it in Java.&lt;/p&gt;

&lt;p&gt;The server was PHP. There was no engineer for the server-side compression part, so I did that too — also in Java, called from PHP over localhost.&lt;/p&gt;

&lt;p&gt;A strange shape, but the deadline was real.&lt;/p&gt;

&lt;p&gt;I remember being a little angry. I also remember being much happier than angry. I had shipped something, in a month.&lt;/p&gt;

&lt;p&gt;That's how I started designing things alone. Not as a plan. Just: someone had to, and nobody else moved.&lt;/p&gt;




&lt;h2&gt;
  
  
  The first thing that wasn't a plan
&lt;/h2&gt;

&lt;p&gt;Looking back twenty-three years later, here's something I didn't notice at the time:&lt;/p&gt;

&lt;p&gt;I had no responsibility. I was a junior engineer with a deadline. If the demo had failed, the company would have collapsed faster — but the failure wouldn't have been mine to own.&lt;/p&gt;

&lt;p&gt;That made it easy to ship.&lt;/p&gt;

&lt;p&gt;After that first month, I never really had another hard deadline. Not on the projects I owned. I'd just answer "as soon as I can" and try to deliver on that.&lt;/p&gt;

&lt;p&gt;By 2005 I was a CTO. The work didn't get lighter; the responsibility arrived. And here's the thing I didn't expect: &lt;strong&gt;the shape of my code didn't change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I kept writing things by myself. I kept ending up alone on the deep parts. The org chart rearranged around me, but at the bottom — when something had to actually work — I was still just there, writing.&lt;/p&gt;

&lt;p&gt;Productivity and accountability don't mix easily. The shape I learned at twenty-five — &lt;em&gt;write it because nobody else will&lt;/em&gt; — was the shape that kept showing up after the title changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  A shape I didn't notice was a shape (2003–2005)
&lt;/h2&gt;

&lt;p&gt;After the packet-reduction tool, I built a music distribution platform for mobile phones. This was the carrier-billed era. Feature phones. Dropped connections in elevators and tunnels.&lt;/p&gt;

&lt;p&gt;The carrier had a billing rule that taught me something I wouldn't articulate for twenty years.&lt;/p&gt;

&lt;p&gt;The rule was: &lt;strong&gt;don't bill until the user's device confirms the download finished.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the flow looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User taps "buy"&lt;/li&gt;
&lt;li&gt;The phone starts downloading (with HTTP Range, because connections drop)&lt;/li&gt;
&lt;li&gt;The server delivers in chunks&lt;/li&gt;
&lt;li&gt;When the device finishes, it sends a hidden completion signal&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; the server bills&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the train went into a tunnel, the user wasn't charged. The server kept holding the work for as long as the device was alive. Eventually either the download completed and we billed, or it didn't and we ate the cost.&lt;/p&gt;

&lt;p&gt;Twenty years later I'd phrase the rule like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Nobody knows when a job will finish. But when it does, I want to report it accurately. So I make users wait — and in exchange, I retry as hard as I can until I have a result.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was already the rule for the music platform in 2003. I just didn't know it was a rule yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape kept showing up (2006–2018)
&lt;/h2&gt;

&lt;p&gt;In 2006 I started on an e-book distribution platform. Four months from blank page to production. I maintained it for eight years, alone.&lt;/p&gt;

&lt;p&gt;The shape stayed the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Direct delivery, no intermediary platforms in the way&lt;/li&gt;
&lt;li&gt;Real-time settlement back to publishers&lt;/li&gt;
&lt;li&gt;HTTP Range support, because phones still dropped connections&lt;/li&gt;
&lt;li&gt;Tar archives generated &lt;em&gt;per request&lt;/em&gt; — buttons, branding, rights metadata stitched in at the moment of delivery, not pre-built and stored&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I stepped off in 2014. The platform kept running.&lt;/p&gt;

&lt;p&gt;By 2018, with someone else maintaining it, it was distributing tens of billions of yen in transactions per year.&lt;/p&gt;

&lt;p&gt;It ran for seven years after I left, and then, in 2021, it was discontinued.&lt;/p&gt;

&lt;p&gt;Eight years on my watch. Seven more without me. Then it stopped.&lt;/p&gt;

&lt;p&gt;Not a tragedy. Not a triumph. Just what happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Same shape, bigger numbers (2021–)
&lt;/h2&gt;

&lt;p&gt;In 2021 I started on what's now called &lt;a href="https://gw.portal.ldxhub.io/introduction" rel="noopener noreferrer"&gt;LDX hub&lt;/a&gt; — a document processing platform for translation, OCR, structured extraction, and AI-assisted refinement.&lt;/p&gt;

&lt;p&gt;Different domain. Same shape.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Year   Documents processed   Characters processed
2021   2,200                 ~20M
2022   48,500                ~810M
2023   85,000                ~2.7B
2024   163,000               ~3.5B
2025   351,000               ~8.3B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers grew. I didn't change anything fundamental about how the system is shaped. Jobs are submitted, the API holds the work, the API retries through whatever needs retrying, the API reports when there's a real result to report.&lt;/p&gt;

&lt;p&gt;Same as the carrier-billed music platform. Same as the e-book distribution. Same as that strange first month with two languages talking through localhost.&lt;/p&gt;

&lt;p&gt;I didn't decide to keep this shape. I just never found a better one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Productivity and accountability, again
&lt;/h2&gt;

&lt;p&gt;Looking back at twenty-four years, here's the only honest thing I can say:&lt;/p&gt;

&lt;p&gt;The shape I learned with no responsibility — the shape that lets one person ship something in a month — turned out to be the shape that kept showing up after responsibility arrived.&lt;/p&gt;

&lt;p&gt;Not because I planned for that. Because it was the only shape I knew how to write, and I kept writing it, and nothing about the larger numbers ever required me to write differently.&lt;/p&gt;

&lt;p&gt;Whether that means anything for your system, I genuinely don't know.&lt;/p&gt;

&lt;p&gt;I haven't found a different shape worth writing.&lt;/p&gt;

&lt;p&gt;For twenty-four years, I keep ending up here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Nobody knows when a job will finish. I'd still like to report it accurately. (scheduled for May 4)&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>career</category>
      <category>solo</category>
    </item>
    <item>
      <title>Nobody knows when a job will finish. I'd still like to report it accurately.</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 04 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn</link>
      <guid>https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn</guid>
      <description>&lt;p&gt;Most async APIs commit to one thing: starting your job. They return &lt;code&gt;202 Accepted&lt;/code&gt;, hand you a job ID, and that's where the contract ends. The rest is your problem.&lt;/p&gt;

&lt;p&gt;I do something different. I make one promise:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When your job is done, I'll tell you accurately. Until then, I'll keep retrying.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the entire contract for everything I've ever shipped. It sounds small. In practice, it's the only thing I actually do.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape every job in my system shares
&lt;/h2&gt;

&lt;p&gt;You hand me work.&lt;/p&gt;

&lt;p&gt;You wait.&lt;/p&gt;

&lt;p&gt;I retry as hard as I can.&lt;/p&gt;

&lt;p&gt;I report when it's done.&lt;/p&gt;

&lt;p&gt;That's it. Whether the job is OCR on a scanned PDF, structured extraction from a long document, or refining the translation of an XLIFF file — the shape is identical. You give me an input. You don't watch the screen. I come back when I have something honest to report.&lt;/p&gt;

&lt;p&gt;This sounds obvious until you try to actually deliver it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "started" is easier than "finished"
&lt;/h2&gt;

&lt;p&gt;Returning &lt;code&gt;202 Accepted&lt;/code&gt; is easy. The hard part starts right after that.&lt;/p&gt;

&lt;p&gt;Real jobs hit things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vendor APIs that occasionally throw 503. No reason. Just sometimes.&lt;/li&gt;
&lt;li&gt;Native binaries that core dump. Twice in a row, then fine for a week.&lt;/li&gt;
&lt;li&gt;Subprocesses that go zombie. Not crashed. Not finished. Just defunct. The OS still holds them.&lt;/li&gt;
&lt;li&gt;Disks that fill up with stale debug files because something somewhere wrote them and forgot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you ship "started, here's a job ID, good luck" and call that an API, you're outsourcing all of the above to your user.&lt;/p&gt;

&lt;p&gt;I'm not willing to do that. So I take the work back inside.&lt;/p&gt;




&lt;h2&gt;
  
  
  What that looks like in code
&lt;/h2&gt;

&lt;p&gt;I'm not going to name any vendor. They don't matter. What matters is the shape. The code below is a simplified sketch — the production version handles a lot more (PDF library version quirks, fallback engines when the first one rejects the input, demo-mode page limits, and a long list of vendor-specific error codes that mean "retry," "skip," or "stop"). The shape is what survives.&lt;/p&gt;

&lt;p&gt;Here's a sketch of the inside of one of my conversion services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;JobResult&lt;/span&gt; &lt;span class="nf"&gt;runJob&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Input&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Process&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"java"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-cp"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;classpath&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EngineMain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;redirectErrorStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;passInputToStdin&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAlive&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;MAX_RUNTIME_MS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;destroyForcibly&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isDefunct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;reap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;sweepStaleCoreFiles&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workDir&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;MAX_CORE_AGE_MS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sleep&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;POLL_INTERVAL_MS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;ChildOutcome&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;readOutcome&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isTransientError&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// retry&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isIrrelevantError&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"irrelevant error, treating as success: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toSuccessResult&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasResult&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toResult&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JobResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;failedAfterRetries&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things in there are worth pointing at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;new ProcessBuilder("java", ..., EngineMain.class.getName())&lt;/code&gt;.&lt;/strong&gt; Not "call a library function." Not "use the SDK." I literally re-enter &lt;code&gt;main&lt;/code&gt; from another process. The reason is that the underlying engine, in its native form, is unreliable enough that I want process-level isolation. If it dies, only the child dies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;if (isDefunct(child)) { reap(child); break; }&lt;/code&gt;.&lt;/strong&gt; Native binaries don't always exit cleanly. Sometimes they're not crashed and not running — they're stuck. The parent has to notice, decide, and clean up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sweepStaleCoreFiles(workDir, MAX_CORE_AGE_MS)&lt;/code&gt;.&lt;/strong&gt; When a child crashes hard, the OS dumps a core file. That file is huge. If you don't sweep it, the disk fills up. There is no clever solution here. You sweep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;outcome.isTransientError()&lt;/code&gt; → &lt;code&gt;continue&lt;/code&gt;.&lt;/strong&gt; Some vendor errors come and go. The fix is to wait and try again. If you don't try again, your user sees &lt;code&gt;failed&lt;/code&gt;. If you do try again, your user sees "took a bit longer." I pick the second one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;outcome.isIrrelevantError()&lt;/code&gt; → log and return success.&lt;/strong&gt; This is the part that surprises people. Some errors aren't actually errors for the use case. They're noise the engine emits. Knowing which is which takes years, and is most of the actual product.&lt;/p&gt;

&lt;p&gt;None of this is elegant. None of it shows up in an architecture diagram. It all lives in the gap between "the job was submitted" and "the job is done, here's the result."&lt;/p&gt;

&lt;p&gt;That gap is what I do.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I gave up
&lt;/h2&gt;

&lt;p&gt;I don't promise low latency. I can't. The thing I'm waiting on isn't predictable.&lt;/p&gt;

&lt;p&gt;I don't promise the job will always succeed. Sometimes the input is genuinely broken. Then I report that, accurately, instead of pretending.&lt;/p&gt;

&lt;p&gt;I don't promise streaming partial results. I keep the user out of the loop until I have something stable to hand back. The cost is they wait. The benefit is they don't see noise.&lt;/p&gt;

&lt;p&gt;These trade-offs aren't sophisticated. They're just consistent.&lt;/p&gt;




&lt;h2&gt;
  
  
  I didn't design this. It survived.
&lt;/h2&gt;

&lt;p&gt;Looking back, this is how every job-shaped API I've ever built has worked. I didn't sit down one day and decide on a contract. I kept ending up here.&lt;/p&gt;

&lt;p&gt;Each time I tried to ship something where the API said &lt;code&gt;started&lt;/code&gt; and stopped caring, the user came back asking what happened. So I started caring. Each time I tried to surface every transient error to the user, the user got scared. So I started absorbing them. Each time I tried to make jobs faster by skipping the cleanup, the disks filled up. So I started sweeping.&lt;/p&gt;

&lt;p&gt;After enough years of this, what's left is a single rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When the job is done, I'll tell you accurately. Until then, I'll keep retrying.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Whether that's the right contract for your system, I genuinely don't know. It's just the only one I've found that survives.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series: &lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Accordion Pattern: Why I stopped writing one fat LLM prompt</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 29 Apr 2026 07:51:20 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb</link>
      <guid>https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb</guid>
      <description>&lt;p&gt;Most structured-extraction tutorials look the same. Take a document, write one big prompt that says "extract A, B, C, D, E, F", get JSON back. Done.&lt;/p&gt;

&lt;p&gt;This works on short inputs.&lt;/p&gt;

&lt;p&gt;It quietly breaks on long ones.&lt;/p&gt;

&lt;p&gt;After running this in production for a while, I stopped doing it. Here's what I switched to and why.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fat prompt problem
&lt;/h2&gt;

&lt;p&gt;Say you have a 50-page report and you want a structured summary out of it. The natural first move is something like:&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="err"&gt;Extract:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;title&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sections&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;headings)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;purpose&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;mentioned&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;services&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;acceptance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;criteria&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;shape:&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="err"&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;You hand the whole document to the model. It returns JSON. It looks fine on the first try.&lt;/p&gt;

&lt;p&gt;Then you scale it up and three things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Quality drifts.&lt;/strong&gt; The model "forgets" mid-document. Later sections are summarized worse than earlier ones, or fields go missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One bad field poisons the whole call.&lt;/strong&gt; If "acceptance criteria" hallucinates, you don't just lose that field — the whole record gets quarantined for review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency goes up, parallelism goes down.&lt;/strong&gt; A single 30k-token call takes what it takes. You can't shard it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can fight this with longer prompts, more examples, stricter formatting rules. I did. It buys you maybe 10% more reliability and costs you a lot of prompt-engineering time.&lt;/p&gt;

&lt;p&gt;The structural problem doesn't go away.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I do now: split it
&lt;/h2&gt;

&lt;p&gt;The pattern I use looks like an accordion that expands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ document ]
     │
     ▼
[ Stage 1: segment ]   ← one prompt, one job: produce a list
     │
     ▼
[ array of segments ]
     │
     ▼ (fan out)
[ Stage 2: extract ]   ← one prompt, runs per segment
     │
     ▼
[ structured records ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stage 1 reads the whole document and returns a clean array of segments — sections, paragraphs, line items, whatever the right unit is for the task.&lt;/p&gt;

&lt;p&gt;Stage 2 takes one segment at a time and extracts the structured fields you actually want.&lt;/p&gt;

&lt;p&gt;Two prompts, each doing one thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this works better
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Each prompt has a single job.&lt;/strong&gt;&lt;br&gt;
Stage 1 is "find the boundaries". Stage 2 is "extract the schema". Neither prompt has to hold both ideas at once. You can write each one tightly. Examples are shorter and more on-point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors localize.&lt;/strong&gt;&lt;br&gt;
If Stage 2 fails on segment 7, you re-run segment 7. You don't redo the whole document. Bad fields get isolated to one record instead of contaminating the whole batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 2 parallelizes naturally.&lt;/strong&gt;&lt;br&gt;
The output of Stage 1 is an array. Fan it out. Run 50 small extractions in parallel instead of one big one. Total wall-clock time drops, and so does the variance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache hits go up.&lt;/strong&gt;&lt;br&gt;
If the same segment shows up twice (templates, standard headers, repeated forms), Stage 2 sees the same input and you can cache. The fat-prompt version sees the entire document as one unique input every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long documents stop being scary.&lt;/strong&gt;&lt;br&gt;
The hard limit on a fat prompt is the model's context window. The accordion pattern doesn't have that ceiling. Stage 1 still has to read the whole document, but its output is small. Stage 2 only ever sees one segment.&lt;/p&gt;


&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;It's not free.&lt;/p&gt;

&lt;p&gt;You're making more LLM calls — one for Stage 1 plus N for Stage 2 instead of one. On short inputs that's wasteful. The accordion pattern is for documents long enough that fat prompts start failing, not for two-paragraph emails.&lt;/p&gt;

&lt;p&gt;You also need to think a little harder about what a "segment" is for your task. Sometimes it's a section heading. Sometimes it's a row in a table. Sometimes it's a logical unit that doesn't map to any visible boundary. That's a design decision and it matters.&lt;/p&gt;


&lt;h2&gt;
  
  
  When to use it
&lt;/h2&gt;

&lt;p&gt;Reach for the accordion when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The document is long enough that you've seen the model lose the thread mid-way.&lt;/li&gt;
&lt;li&gt;The output schema has more than ~5 fields and they don't all care about the same context.&lt;/li&gt;
&lt;li&gt;You need to retry failed records without redoing successful ones.&lt;/li&gt;
&lt;li&gt;You want parallelism.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stick with one fat prompt when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The input is short and the schema is small.&lt;/li&gt;
&lt;li&gt;The fields are tightly coupled (extracting one needs context from another).&lt;/li&gt;
&lt;li&gt;You're prototyping and don't care yet.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  A small concrete example
&lt;/h2&gt;

&lt;p&gt;I run this on a service called StructFlow. The shape of the calls is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 1: segment&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://gw.ldxhub.io/structflow/jobs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "google/gemini-3-flash-preview",
    "system_prompt": "Split this document into logical sections. Return one JSON record per section.",
    "example_output": { "section_title": "...", "section_text": "..." },
    "inputs": [{ "id": "doc1", "data": { "text": "..." } }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response gives you back an array. Then Stage 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 2: extract (one call per segment, run in parallel)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://gw.ldxhub.io/structflow/jobs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "google/gemini-3-flash-preview",
    "system_prompt": "From this section, extract: purpose, mentioned services, acceptance criteria.",
    "example_output": { "purpose": "...", "mentioned_services": [], "acceptance_criteria": [] },
    "inputs": [{ "id": "sec1", "data": { "section_text": "..." } }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two calls, each focused. One returns segments. The other turns each segment into structured fields.&lt;/p&gt;

&lt;p&gt;That's the whole pattern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I'm posting this
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://gw.portal.ldxhub.io" rel="noopener noreferrer"&gt;LDX hub&lt;/a&gt; partly to make this pattern easy to run — one API, async jobs, file-based input/output so Stage 1's output is directly usable as Stage 2's input. But the pattern itself doesn't depend on any specific tool. You can do it with raw OpenAI calls, Anthropic calls, anything that takes a prompt and returns text.&lt;/p&gt;

&lt;p&gt;The takeaway isn't "use my API". It's: &lt;strong&gt;if your structured extraction is getting flaky on long inputs, the answer probably isn't a longer prompt. It's two prompts.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've tried something similar — or if you've got a case where this falls apart — I'd be curious to hear it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>architecture</category>
      <category>api</category>
    </item>
  </channel>
</rss>
