<?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.us-east-2.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>The 3-line discipline</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 29 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-3-line-discipline-3lla</link>
      <guid>https://dev.to/hidekimori/the-3-line-discipline-3lla</guid>
      <description>&lt;p&gt;When I write code in unfamiliar territory, I write three lines, then I run it.&lt;/p&gt;

&lt;p&gt;Then I write three more lines, and I run it again.&lt;/p&gt;

&lt;p&gt;I've been doing this for twenty-four years. It's the most specific habit I have. I almost didn't write this article, because the habit feels too small to be worth describing — but then I noticed that it's the part of my way of working that I can never seem to explain to someone in real time. It needs writing down.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three principles
&lt;/h2&gt;

&lt;p&gt;The discipline rests on three things I believe about writing code. They're not deep. They've just stayed with me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Trust nothing but your own code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you can't trust the code you wrote yourself, what can you trust? Not a library, not a vendor's documentation, not your own assumption from yesterday. The only thing in the system whose behavior you can fully verify is the code you just typed, by running it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Write in code, not in language.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're describing what the code should do in Japanese or English, you're spending the same time you could have spent writing the code itself. By the time the code runs, the description is already done — by the code, in a more precise form than any language could give it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make three lines complete.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The three lines you just wrote should be complete. Error handling included. Validation included. Logging included. Not "I'll add validation later." Not "I'll wrap it in a try-catch later." Three lines, complete, then run.&lt;/p&gt;

&lt;p&gt;(There's a small exception to this. Sometimes you do want to ignore every error and move on — for instance, when you're trying to understand whether the happy path works at all before you care about anything else. That's a different mode, used deliberately. It's not the same as "I'll handle errors later.")&lt;/p&gt;




&lt;h2&gt;
  
  
  Why three lines
&lt;/h2&gt;

&lt;p&gt;Three lines is roughly the unit of thought I can hold completely. Five lines, and I start guessing what the third line did. Ten lines, and I'm reading the code as if it were someone else's. Three lines is the size that stays mine.&lt;/p&gt;

&lt;p&gt;When three lines run and produce what I expected, I keep them. When they don't, I either fix them or delete them. The cost of deleting three lines is small enough that I have no attachment to keeping them.&lt;/p&gt;

&lt;p&gt;What I'm protecting, by writing this small, is the alignment between the code in my head and the code that's actually running. When that alignment is intact, debugging is fast: I know which three lines just changed. When that alignment slips — because I wrote thirty lines without running them — debugging becomes archaeology. I'd rather spend the time in three-line increments and avoid the archaeology.&lt;/p&gt;




&lt;h2&gt;
  
  
  Components: write the caller, run, write again, run
&lt;/h2&gt;

&lt;p&gt;When I introduce a new component into a system — a new library, a new vendor's API, a new framework — I don't start by writing the code that needs the component. I start by writing the code that calls the component.&lt;/p&gt;

&lt;p&gt;The caller is small at first. Three lines. I call the component, I print what comes back, I run it. Then three more lines. I call it with different arguments. I print what comes back. I run it. Three more lines. I call it in a way that should fail. I see what failure looks like.&lt;/p&gt;

&lt;p&gt;By the time I've spent an hour doing this, I know how the component behaves on the inputs I care about, how it behaves on edge cases, how it fails, what it returns when it fails. I have a small body of code that has tested the component from the outside, written in my own hand.&lt;/p&gt;

&lt;p&gt;After that, the component almost never surprises me when I integrate it into the real work. It surprised me already, during the hour I was poking at it, and I noted what I learned.&lt;/p&gt;

&lt;p&gt;This part of the discipline is what twenty-four years has changed about me the most. When I started, I would try to use a new library inside the real code right away, and then I'd be debugging both the library's behavior and my own use of it at the same time. I don't do that anymore. The library has to pass a small private interview first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Even then, live data still surprises you
&lt;/h2&gt;

&lt;p&gt;Here's the part that keeps the discipline honest: even when you've done all of the above, live data will still surprise you. The vendor's API will return something the documentation never mentioned. The status code will say success while the payload says something else. The same call you've made a thousand times will, on the thousand-and-first try, return data that belongs to a different question entirely.&lt;/p&gt;

&lt;p&gt;I worked with a fairly mainstream translation API for many years. It's the kind of API most people in the industry have heard of. In day-to-day operation, the integration was stable. But in the operational record of how my code calls it, there are several places where I had to write defenses that aren't suggested by the documentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The languages endpoint, when asked for &lt;em&gt;target&lt;/em&gt; languages, sometimes returned the response shaped like a &lt;em&gt;source&lt;/em&gt; language listing. The HTTP status was 200. The JSON parsed. But a field that should be present on target languages was missing. The fix wasn't to escalate or to file a bug — it was to detect the mismatch in my code, log it as a retry-worthy condition, and call the endpoint again. The next call usually returned correctly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Certain error messages from the API turned out to be retry-worthy, even though the HTTP status code didn't say so. A &lt;code&gt;"Temporary Error"&lt;/code&gt; in the response body, or a &lt;code&gt;"Tag handling parsing failed"&lt;/code&gt;, both warranted retrying. I learned this not from the documentation but from watching the production logs over many months.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a complaint about the vendor. It's a mainstream API. The point isn't that it's flawed; the point is that any API run at scale, against real data, will produce these moments. The documentation is a description of intended behavior, not observed behavior. Observed behavior, in production, is always wider.&lt;/p&gt;

&lt;p&gt;So even after the three-line discipline, even after the private interview with the component, the system goes into production and surprises me. Not catastrophically. Quietly. A condition I hadn't tested, behaving in a way I hadn't predicted.&lt;/p&gt;

&lt;p&gt;I don't experience this as a failure of the discipline. I experience it as the part of the work that the discipline doesn't cover — and was never going to cover. The discipline brings me to the doorstep with my code in good shape. Live data is what's on the other side of the door, and it's not something I get to fully prepare for. It's something I respond to.&lt;/p&gt;

&lt;p&gt;This is, I think, the part of the work I find most enjoyable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twenty-four years of this
&lt;/h2&gt;

&lt;p&gt;I didn't set out to develop a discipline. I started writing software in 2002, at a company that was almost out of money, on a product that needed to ship in thirty days. There was no time to write thirty lines and then debug them. I wrote three lines, I ran them, I wrote three more. The shape of how I work formed itself in that situation, and it never went away.&lt;/p&gt;

&lt;p&gt;What's changed over twenty-four years is what I do with the result. The three-line increments are the same. The "write the caller first, run, run again" is the same. The willingness to be surprised by live data is the same. What's deeper now is just the cumulative trust in my own code, and the cumulative humility about everything outside it.&lt;/p&gt;

&lt;p&gt;If you write the code you can trust, you can carry the weight of everything you can't.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&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;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>productivity</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Billing asynchronous work exactly once</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 24 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/billing-asynchronous-work-exactly-once-fnl</link>
      <guid>https://dev.to/hidekimori/billing-asynchronous-work-exactly-once-fnl</guid>
      <description>&lt;p&gt;Synchronous billing is easy, and that's the problem — it makes you think all billing is easy.&lt;/p&gt;

&lt;p&gt;When a request does its work inline, the billable number is in the response by the time you send it. The gateway meters from there — the meter write, retries and all, is its problem, not yours. From your side, synchronous billing is one number in the response.&lt;/p&gt;

&lt;p&gt;Asynchronous work breaks that. The request submits a job; the work happens later, in a worker; the result comes back through a poll or a callback. And the thing you bill for — characters processed, pages converted — isn't known when the request arrives. It's known when the job &lt;em&gt;finishes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So you can't meter at the edge. The meter has to fire from the completion path. And the real difficulty is firing it &lt;em&gt;exactly once&lt;/em&gt; per unit of completed work — because requests, polls, and retries all conspire to make that zero times or many times.&lt;/p&gt;

&lt;p&gt;This is platform-agnostic. Every submit-process-poll API has it. I'll use the system I run as the example, but the shape is the same anywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three ways metering goes wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On arrival.&lt;/strong&gt; Carry the synchronous habit over and you meter when the job is submitted. But you don't know the size yet, so you're forced into a crude flat fee — or you bill for work that hasn't happened and might fail. Wrong unit, wrong time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On retrieval.&lt;/strong&gt; The subtle one. You wire the meter to fire when the client fetches the result. Now a client who submits a job, lets it run — costing you real money downstream — and never bothers to poll is never billed. You did the work for free. "Completion" is not "the client picked up the result." It's the worker finishing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without a fixed quantity.&lt;/strong&gt; Input characters or output characters? Pages before OCR or after? If you haven't decided exactly what you measure and where, invoices drift and customers argue. Decide once; measure there.&lt;/p&gt;

&lt;p&gt;All three point the same way: meter on measured work-completion, with a fixed definition of the unit. Not on arrival. Not on retrieval.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mechanism: a durable outbox
&lt;/h2&gt;

&lt;p&gt;In synchronous billing the gateway took the numbers off the response and metered them for you. Async takes that away: the numbers exist only in the worker, after the request has returned. So completion itself has to become a durable event.&lt;/p&gt;

&lt;p&gt;The completion path writes a metering task — the job's measured quantities — into a durable outbox: a table that is the source of truth for what still needs sending. Something drains it, sends each task to the meter, records the outcome; a failed send stays in the table and is retried until it lands. (In my system a once-a-minute batch does the draining. The interval doesn't matter; the durability does.)&lt;/p&gt;

&lt;p&gt;It has a name — the transactional outbox pattern — though it's the sort of thing you'd build without the name. And it is, exactly, the one rule the rest of the system already runs on: when the job finishes, report it reliably — retry as much as possible, return the result. Metering is just one more result that has to be reported reliably. I didn't build a billing system. I pointed the engine's own discipline at billing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Exactly once = at-least-once × at-most-once
&lt;/h2&gt;

&lt;p&gt;The outbox gives me &lt;em&gt;at-least-once&lt;/em&gt;. A meter event is never silently dropped, because a failed send leaves the task in place to retry.&lt;/p&gt;

&lt;p&gt;But at-least-once, on its own, double-charges. The classic failure: the send succeeds, the acknowledgement is lost on the way back, the task looks failed, the next run resends — and now it is counted twice.&lt;/p&gt;

&lt;p&gt;So at-least-once needs a partner: an &lt;em&gt;idempotent sink&lt;/em&gt;. Send the same meter ID twice, it counts once. That is &lt;em&gt;at-most-once&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;exactly-once = outbox (at-least-once) × idempotent sink (at-most-once)&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Neither half is enough alone. I learned the second one the hard way — the same outbox-and-retry code, pointed at two different metering backends. One deduplicated on the ID and the numbers stayed clean. The other didn't, and the retries double-charged. Same mechanism, different sink, different bill.&lt;/p&gt;

&lt;p&gt;So the thing worth writing down isn't "this platform guarantees idempotency." Platforms change. The durable statement is: &lt;em&gt;this pattern requires an idempotent sink.&lt;/em&gt; If yours doesn't deduplicate, your retries are a liability, not a safety net.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bill on success, and survive retries
&lt;/h2&gt;

&lt;p&gt;Two more places it bites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Success, not completion.&lt;/strong&gt; Fire the meter on &lt;em&gt;successful&lt;/em&gt; completion — not on "the job reached a terminal state." A failed job must not emit a billable event. Wire it to the wrong terminal state and you charge people for errors, then spend your week on refunds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial failure.&lt;/strong&gt; What you bill on a half-finished job depends on whether half a result is worth anything. A text extraction fans out into many independent calls; if nine of ten succeed and one fails for good, you bill the nine — the successful work has standalone value. Document conversion is the opposite: a file that converts eight of ten pages and then dies isn't eighty percent of a document, it's a corrupted one. No charge, nothing returned. Bill at the granularity where partial output has value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries.&lt;/strong&gt; The engine retries aggressively — that is the point of it. Meter per &lt;em&gt;attempt&lt;/em&gt; and every retry inflates the bill. So the meter is per &lt;em&gt;successful job&lt;/em&gt;, fired once — which is exactly what the outbox and the idempotent sink already guarantee. It is not extra work; it falls out of the same design.&lt;/p&gt;

&lt;p&gt;It all reduces to one sentence: the billable event is one successfully-completed unit, counted once.&lt;/p&gt;




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

&lt;p&gt;In synchronous billing the meter is a property of a request arriving. In asynchronous billing it is a property of work &lt;em&gt;finishing&lt;/em&gt; — and the discipline is firing it exactly once per successful unit.&lt;/p&gt;

&lt;p&gt;It is worth separating what is hard from what is free. The completion wiring — the outbox, the retries — is yours to build. The at-most-once half is the sink's job, if you chose a sink that does it. Get both, and a client polling ten times, a worker retrying five, and a job that half-failed all resolve to the right number of credits.&lt;/p&gt;

&lt;p&gt;That is the whole thing. It isn't much once it's drawn — but every line of it is a place I have watched a bill come out wrong.&lt;/p&gt;




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

</description>
      <category>api</category>
      <category>architecture</category>
      <category>billing</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Write the code well once, the spec stops bothering you</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 22 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3</link>
      <guid>https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3</guid>
      <description>&lt;p&gt;I wrote a Java class twenty years ago that assembled tar archives on the fly. It ran for fifteen years. In that fifteen years, nobody touched it. Not me, not anyone else.&lt;/p&gt;

&lt;p&gt;This is a story about why.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hundred storefronts and the one carrier spec
&lt;/h2&gt;

&lt;p&gt;In the mid-2000s, I was running a content distribution platform. Over a hundred storefronts plugged into it. Bookstores, label-branded stores, carrier-branded stores. Each one resold the same underlying content — books, comics, music, video, and more — but with their own branding wrapped around it.&lt;/p&gt;

&lt;p&gt;Among those hundred-plus storefronts, a few dozen of them shared a particular delivery spec — one of the major Japanese carriers had pinned down a specific shape for downloadable content on their old mobile phones. The content had to arrive as a tar archive. The phone would fetch it using HTTP Range Requests, byte ranges at a time, often resuming after a dropped connection.&lt;/p&gt;

&lt;p&gt;The format was the same for all storefronts on that spec: a tar archive containing a known set of files, in a known structure. The files inside were not the same. Each storefront wanted its own branding image, its own store name in the metadata, its own thumbnail. The wrapper format was fixed. The contents were per-storefront, per-product.&lt;/p&gt;

&lt;p&gt;A few dozen storefronts asking for the same shape with different contents, multiplied by the catalog. It was a combinatorial problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I didn't do: pre-generate
&lt;/h2&gt;

&lt;p&gt;The obvious approach was to pre-generate the tar archives. For each storefront, for each product, produce the archive once, write it to disk, serve it from there.&lt;/p&gt;

&lt;p&gt;I rejected this almost immediately.&lt;/p&gt;

&lt;p&gt;Storage isn't free. Number of storefronts times number of products times the size of each archive. The math wasn't terrible at the time, but the moment any storefront changed its branding — a new logo, a new store name, a new image — every archive associated with that storefront would be invalidated. Every product, every catalog entry. I'd be re-running the bake of tens of thousands of archives because someone tweaked a string.&lt;/p&gt;

&lt;p&gt;I thought about this for a while and then stopped thinking about it. The shape that came out the other side was different.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I did: deterministic generation at request time
&lt;/h2&gt;

&lt;p&gt;I generated the archive at the moment of the request.&lt;/p&gt;

&lt;p&gt;For each incoming download:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Look up the product. Find the raw content.&lt;/li&gt;
&lt;li&gt;Look up the storefront. Find the branding config: the store name, the image to embed, the thumbnail.&lt;/li&gt;
&lt;li&gt;Assemble a tar archive in memory, with that store's content wrapped around that product's data.&lt;/li&gt;
&lt;li&gt;Stream the result to the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The archive didn't exist before the request arrived. It didn't exist after.&lt;/p&gt;

&lt;p&gt;The CPU cost was real but small. Application servers were cheap and easy to scale horizontally. Storage was scarce and combinatorially expensive. The trade was obvious to me.&lt;/p&gt;

&lt;p&gt;But there was one detail that made the whole shape work — and without it, the rest would have fallen apart.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mtime had to be fixed
&lt;/h2&gt;

&lt;p&gt;HTTP Range Requests assume the resource is stable. The client says &lt;em&gt;give me bytes 0 through 8191&lt;/em&gt;, you give them those bytes. Later the client says &lt;em&gt;give me bytes 8192 through 16383&lt;/em&gt;, and the bytes you give now have to be the second half of the same file the client started downloading. If they're not — if the file changed between the two range requests — the client ends up with a corrupted archive.&lt;/p&gt;

&lt;p&gt;A tar header has a field for modification time. Every file inside the archive has an mtime — twelve bytes of octal-encoded Unix timestamp.&lt;/p&gt;

&lt;p&gt;If I generated those mtimes from the current clock at the moment of header creation, every regeneration would produce a different archive. Even though the content was identical. Even though the structure was identical. Just the timestamps would shift, and Range Requests would break.&lt;/p&gt;

&lt;p&gt;So the mtime had to be deterministic. The same archive had to come out every time, regardless of when the request arrived.&lt;/p&gt;

&lt;p&gt;I fixed it to the product's update timestamp:&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="n"&gt;mtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updateDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTime&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000L&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That timestamp changed only when the underlying product was updated by an operations colleague. Between updates, every tar archive for that product was byte-identical, regardless of how many times it was assembled.&lt;/p&gt;

&lt;p&gt;The natural follow-up question is: what about an update that lands mid-stream, while an archive is being assembled? The source files for each product were versioned. A product update produced a new version of the source set, not an in-place rewrite. An archive being assembled never saw a half-updated set of source files; it saw a consistent version, from start to finish.&lt;/p&gt;

&lt;p&gt;Range Requests worked. Resumes worked. The phone could disconnect at byte 5000 and reconnect for bytes 5001 onwards from a different application instance entirely. The bytes would line up.&lt;/p&gt;

&lt;p&gt;The archive existed only at the moment of the request, but it was deterministic to the last source update.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fifteen years of nobody touching it
&lt;/h2&gt;

&lt;p&gt;I wrote that class in 2006. The platform ran on it for the next fifteen years.&lt;/p&gt;

&lt;p&gt;In those fifteen years:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New storefronts were added. Config rows in a database. No code change.&lt;/li&gt;
&lt;li&gt;New products were added. New files on disk in a known structure. No code change.&lt;/li&gt;
&lt;li&gt;New storefronts wanted different branding images. They uploaded different images. No code change.&lt;/li&gt;
&lt;li&gt;The product update flow was tweaked many times. The mtime convention held. No code change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nobody re-wrote it. Nobody refactored it. Nobody patched it. It just kept assembling tar archives.&lt;/p&gt;

&lt;p&gt;The on-call queue never had an alert about it. The maintenance docs never had a runbook section for it. New engineers never asked about it, because there was nothing to ask.&lt;/p&gt;

&lt;p&gt;The spec stopped bothering anybody.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I was actually choosing
&lt;/h2&gt;

&lt;p&gt;When I picked dynamic generation over pre-generation, I wasn't really picking CPU over storage. I was picking &lt;em&gt;where the complexity would live&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you pre-generate, the complexity lives in the data. Every change in input — branding, content, metadata — has to propagate into a regeneration of the materialized output. The complexity is distributed across the storage layer, and someone has to maintain the regeneration pipeline.&lt;/p&gt;

&lt;p&gt;If you generate at request time, the complexity lives in one piece of code. That code is hard to write well the first time. There's a tar format to understand. There's a deterministic mtime requirement that's easy to miss. There's the Range Request semantics. But once that code is written well, the system has no other place where the complexity is stored.&lt;/p&gt;

&lt;p&gt;And then nobody has to touch it.&lt;/p&gt;

&lt;p&gt;This is the trade I keep coming back to, twenty-four years into writing software alone. If you write the code well once, the spec stops bothering you. The work you did at the start absorbs all the work you didn't have to do later — by you, or by anyone else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twenty-four years of the same choice
&lt;/h2&gt;

&lt;p&gt;I'm still doing it. The platform I run now is built on the same pattern at a different scale: a hundred-plus vendors of OCR, translation, and other document services, all behind a small set of public APIs that compose them dynamically per request. There's no pre-generated catalog of vendor combinations. There's one piece of code that knows how to wrap one vendor in one shape, and a configuration system that lets new vendors plug in.&lt;/p&gt;

&lt;p&gt;I expect the same fifteen-year quiet that the tar archive code got. It's not that I'm confident. It's that I've made the choice often enough to know what it usually leads to.&lt;/p&gt;

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

&lt;p&gt;If you write the code well once, the spec stops bothering you. Most of what I do, I do for that.&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;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>design</category>
      <category>backend</category>
    </item>
    <item>
      <title>Two patterns, five services, one n8n workflow</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 17 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/two-patterns-five-services-one-n8n-workflow-a4</link>
      <guid>https://dev.to/hidekimori/two-patterns-five-services-one-n8n-workflow-a4</guid>
      <description>&lt;p&gt;The first two articles in this series each showed one technique. &lt;a href="https://dev.to/hidekimori/build-a-multi-step-n8n-form-with-dynamic-dropdowns-no-plugin-needed-1pm4"&gt;Implementation notes #001&lt;/a&gt; was a dynamic dropdown — a form field that fills itself from an API. &lt;a href="https://dev.to/hidekimori/let-your-n8n-template-ask-for-the-users-api-key-54n9"&gt;Implementation notes #002&lt;/a&gt; was a dynamic credential — an API key that arrives from the form and threads through to the HTTP nodes.&lt;/p&gt;

&lt;p&gt;This article is the capstone. It walks through &lt;code&gt;all-services-demo&lt;/code&gt;, the example workflow that ships with &lt;code&gt;n8n-nodes-ldxhub&lt;/code&gt;, where those two techniques combine with a Switch node to host five different AI document-processing services inside one workflow — structured extraction, translation refinement, OCR, PDF conversion, and text extraction.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The screenshots and the workflow JSON below come from the &lt;code&gt;n8n-nodes-ldxhub&lt;/code&gt; package. The patterns themselves are generic — they work for any set of services you want to consolidate into a single template.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not a "follow these steps" article. It's a parts catalog. No two readers are solving the same problem, and templates rarely fit anyone's situation as-is. Take what fits. Drop the rest. You don't need to understand all 46 nodes to reuse the patterns.&lt;/p&gt;




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

&lt;p&gt;The workflow has 46 nodes — large enough to look intimidating in the editor, but structurally it's just five repeated paths plus a small routing section.&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%2Fwnstxhtpe34wdci1igqg.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%2Fwnstxhtpe34wdci1igqg.png" alt="all-services-demo workflow overview" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The entry section is two nodes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On form submission&lt;/strong&gt; — the trigger. Asks the user which service they want and collects an API key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route by Service&lt;/strong&gt; — a Switch node with five outputs, one per service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything to the right of the Switch is service-specific. Five paths fan out: StructFlow, RefineLoop, RenderOCR, CastDoc, ExtractDoc. Each path ends in two Form Ending nodes — one for success (auto-downloads the result), one for error.&lt;/p&gt;

&lt;p&gt;That's the spine: form → switch → service path → ending. The complexity is pushed into the service paths.&lt;/p&gt;




&lt;h2&gt;
  
  
  The spine: routing by static comparison
&lt;/h2&gt;

&lt;p&gt;The Switch node ("Route by Service") uses Rules mode. Each rule reads the same expression from the form — &lt;code&gt;{{ $json.service }}&lt;/code&gt; — and compares it to a static service name.&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%2Fox7mxo0tnzu2e8td8ofv.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%2Fox7mxo0tnzu2e8td8ofv.png" alt="Switch node Route by Service" width="800" height="986"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rule 1:  {{ $json.service }}  is equal to  structflow   → output: structflow
Rule 2:  {{ $json.service }}  is equal to  refineloop   → output: refineloop
Rule 3:  {{ $json.service }}  is equal to  renderocr    → output: renderocr
Rule 4:  {{ $json.service }}  is equal to  castdoc      → output: castdoc
Rule 5:  {{ $json.service }}  is equal to  extractdoc   → output: extractdoc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The read side is dynamic (the expression resolves to whatever the user picked). The match side is static (fixed strings). That asymmetry is intentional. Static rules mean adding a new service is a manual edit — open the Switch node, add a row, save. No regeneration, no template hooks, no auto-discovery. Boring and unsurprising.&lt;/p&gt;

&lt;p&gt;This is the part you can lift cleanly: a Switch with N static rules driven by one expression from upstream. It works for service routing, document type routing, user tier routing, anything that fans into discrete branches.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two patterns inside
&lt;/h2&gt;

&lt;p&gt;Once you start reading the service paths, you notice something: they are not all the same shape. There are two distinct patterns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern A: single-step form (StructFlow, RefineLoop — 7 nodes)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Get Models (HTTP)
  → Derive Options (Set)
    → Run Form (Form, next page)
      → Inject Binary (Code)
        → Run (LDX hub)
          → Download / Error (Form Endings)
&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%2Fiq6bur5akbspcqpvhq27.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%2Fiq6bur5akbspcqpvhq27.png" alt="StructFlow path" width="800" height="214"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One form page collects everything the user needs to choose. The model selection, the input, the parameters — all in one screen. There's only one form page after the Switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern B: cascading multi-step form (RenderOCR, CastDoc, ExtractDoc — 10 nodes)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Get Engines (HTTP)
  → Select Engine (Form, next page)
    → Derive Options (Set)
      → Upload File (Form, next page)
        → Filter by File (Set)
          → Select Output (Form, next page)
            → Inject Binary (Code)
              → Run (LDX hub)
                → Download / Error (Form Endings)
&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%2Fluikg8p3s4drhia7ospf.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%2Fluikg8p3s4drhia7ospf.png" alt="RenderOCR path" width="800" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three form pages, each gated on the previous one. First the engine is chosen. Then the file is uploaded. Then the output format is selected — and the available outputs are filtered based on what the chosen engine supports for the uploaded file type. The &lt;code&gt;Filter by File&lt;/code&gt; Set node sits in the middle of that dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why two patterns, not one
&lt;/h3&gt;

&lt;p&gt;The shape of the path follows the shape of the user's decisions. When the choices are independent — pick a model, pass some data — one form page is enough. When the choices cascade — engine restricts file types, file restricts output formats — the form has to be split, and intermediate Set nodes have to filter the options between pages.&lt;/p&gt;

&lt;p&gt;I tried to force a single pattern across all five services. It made the simpler services more complicated than they needed to be. The honest design was to let the cascading services be longer, accept the asymmetry, and document it.&lt;/p&gt;

&lt;p&gt;Two patterns isn't a sign of incompleteness. It's the consolidation accepting that two shapes were genuinely warranted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anatomy of one path
&lt;/h2&gt;

&lt;p&gt;Walking through StructFlow gives you the vocabulary for all five paths. The other four are variations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get Models&lt;/strong&gt; — an HTTP node that hits &lt;code&gt;/structflow/models&lt;/code&gt; and returns the available LLMs (gpt-5.5, claude-sonnet-4-6, gemini-3-flash, etc.). This is the data source for the dropdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Derive Options&lt;/strong&gt; — a Set node that reshapes the model list into the format n8n's Form trigger wants for a dropdown: &lt;code&gt;[{name, value}, ...]&lt;/code&gt;. Same trick as in &lt;a href="https://dev.to/hidekimori/build-a-multi-step-n8n-form-with-dynamic-dropdowns-no-plugin-needed-1pm4"&gt;#001&lt;/a&gt; — derive the dropdown from data, not from a hardcoded list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run Form&lt;/strong&gt; — a single form page that asks for everything: which model to use, the input data, any parameters. The "model" dropdown reads its options from the upstream &lt;code&gt;Derive Options&lt;/code&gt; node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inject Binary&lt;/strong&gt; — a Code node that does one thing. In n8n, uploaded files travel through the workflow as binary data, separate from the JSON fields, and some intermediate nodes (like Set) only preserve the JSON side. By the time data reaches the LDX hub node, the binary part has been dropped.&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;StructFlow: Run Form&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;The Code node reaches back to the form node and re-attaches the binary. One line of glue, but without it the file disappears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run&lt;/strong&gt; — the LDX hub custom node, with &lt;code&gt;runJob: structFlow&lt;/code&gt;. This is where the API call actually happens. The credential is set in expression mode to read from the form input — the dynamic credential pattern from &lt;a href="https://dev.to/hidekimori/let-your-n8n-template-ask-for-the-users-api-key-54n9"&gt;#002&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download / Error&lt;/strong&gt; — two Form Ending nodes. The Run node has two output ports: Success goes to Download (which serves the result file), Error goes to Error (which shows the error message).&lt;/p&gt;

&lt;p&gt;Five other paths follow the same idea. The names change, the number of form pages changes, but the role of each node is the same.&lt;/p&gt;




&lt;h2&gt;
  
  
  The convergence: five paths, one node
&lt;/h2&gt;

&lt;p&gt;All five service paths end at the same LDX hub custom node. Same node type, same credential, same shape — only the &lt;code&gt;runJob&lt;/code&gt; parameter differs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;StructFlow: Run    →  runJob: structFlow
RefineLoop: Run    →  runJob: refineLoop
RenderOCR: Run     →  runJob: renderOcr
CastDoc: Run       →  runJob: castDoc
ExtractDoc: Run    →  runJob: extractDoc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the abstraction the custom node provides. From the workflow's perspective, "running a service" looks identical across the five paths. The differences are buried inside the node's implementation, where they belong.&lt;/p&gt;

&lt;p&gt;This generalizes the idea from &lt;a href="https://dev.to/hidekimori/let-your-n8n-template-ask-for-the-users-api-key-54n9"&gt;#002's sidebar&lt;/a&gt;: a custom node that hides its variations behind a uniform interface lets you compose it freely. Five services. One credential type. One node. Five jobs. The workflow author doesn't have to know how StructFlow differs from RenderOCR — the node knows.&lt;/p&gt;

&lt;p&gt;If you're building your own custom node, this is the shape worth aiming for. One node, parameterized by job kind. The workflow stays clean. The variations stay encapsulated.&lt;/p&gt;




&lt;h2&gt;
  
  
  The if/else pair: error endings
&lt;/h2&gt;

&lt;p&gt;Every service path has two endpoints: Download (success) and Error. Both are Form Ending nodes. Both are visible to the user. This isn't decorative.&lt;/p&gt;

&lt;p&gt;A distributable template has one minimum obligation: once the user clicks Submit, they need to see &lt;em&gt;what happened&lt;/em&gt;. If the call succeeded, they get the result. If it failed, they get the error. There's no third state where the form just ends silently.&lt;/p&gt;

&lt;p&gt;Earlier failures — the Get Models call returning empty, the Inject Binary node crashing — are not handled. Those are skipped here because the template is meant to be minimal, and because adding error endings everywhere makes the canvas unreadable. But the final Run node's error branch is mandatory. That's the one place where the user's expectation ("I started a job, what happened?") has to be answered.&lt;/p&gt;

&lt;p&gt;Where there's an &lt;code&gt;if&lt;/code&gt;, you need an &lt;code&gt;else&lt;/code&gt;. The else doesn't have to be elegant. It just has to exist.&lt;/p&gt;

&lt;p&gt;This is the part you can lift on its own: any time a workflow has a user-visible "run" step, pair its success with an error ending. Other branches can be skipped or logged, but the user-facing one is non-negotiable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What consolidation looks like
&lt;/h2&gt;

&lt;p&gt;The technique parts above — Switch routing, two form patterns, Binary injection, Run convergence, if/else error pairing — are each portable. You can lift them one at a time. But the article would be missing something if it stopped there.&lt;/p&gt;

&lt;p&gt;Bringing five services into one workflow is itself the work. Not a tutorial-friendly kind of work, because nothing in this section is a discrete technique. It's a series of small decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choosing which five services to include (not all of them — five is enough to demonstrate, more would clutter).&lt;/li&gt;
&lt;li&gt;Standardizing the node naming across paths (&lt;code&gt;&amp;lt;Service&amp;gt;: &amp;lt;Role&amp;gt;&lt;/code&gt; everywhere — every reader knows where they are).&lt;/li&gt;
&lt;li&gt;Accepting that the two patterns are real, and not forcing every path into the same shape.&lt;/li&gt;
&lt;li&gt;Putting the differences inside the form pages and inside the Run node's &lt;code&gt;runJob&lt;/code&gt; parameter — the spine stays uniform.&lt;/li&gt;
&lt;li&gt;Designing the convergence: every path ends at the same node type, so adding a sixth service later is a copy-paste-edit, not a redesign.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are clever. Each is the obvious decision once you've seen the alternatives. But the obvious decisions are what hold the template together.&lt;/p&gt;

&lt;p&gt;There's a particular kind of reader this article is also for: the one who doesn't need the technique, who just needs a working template they can drop into n8n and run. The consolidation work is the deliverable for them. The fact that the article also explains how it works is a side benefit.&lt;/p&gt;

&lt;p&gt;This contrasts with a different design philosophy — the one that spreads concerns across many separate sub-workflows, each handling a narrow responsibility. In larger n8n installations with several maintainers, you might split these responsibilities into reusable sub-workflows, with each service called via the Execute Workflow node. For a solo-maintained distributable template intended to be lifted and adapted, the consolidated shape was easier to understand and ship. Fewer moving parts, fewer integration points, one place to read.&lt;/p&gt;




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

&lt;p&gt;This is parts.&lt;/p&gt;

&lt;p&gt;If you read this article and take the whole workflow, run it as-is, that's fine. If you take only the Switch routing and rebuild every service path from scratch, that's better in many cases. If you take only the Inject Binary trick because that's what bit you yesterday, that's the best use of this article.&lt;/p&gt;

&lt;p&gt;No two requirements are identical. Every reader is solving a different problem. A template that pretends to be a one-size answer would be lying. A template that is honest about being a parts catalog — here are the pieces, here is how they fit together, take what fits — is a different kind of useful thing.&lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;all-services-demo&lt;/code&gt; is meant to be. That's what this article is meant to be.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.npmjs.com/package/n8n-nodes-ldxhub" rel="noopener noreferrer"&gt;n8n-nodes-ldxhub package&lt;/a&gt; ships &lt;code&gt;examples/all-services-demo.json&lt;/code&gt; alongside the node code. Import it into your n8n instance, add an &lt;a href="https://gw.portal.ldxhub.io" rel="noopener noreferrer"&gt;LDX hub API key&lt;/a&gt; (free tier: 25,000 credits/month, no card), and the workflow runs. Open the JSON and lift parts into your own templates.&lt;/p&gt;

&lt;p&gt;This closes Phase 1 of Implementation notes — three articles, three angles on the same theme: how an n8n workflow becomes a small, distributable thing. The next phase will pick up other corners worth writing down.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Twenty four years, ten DB migrations, zero downtime</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 15 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633</link>
      <guid>https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633</guid>
      <description>&lt;p&gt;Twenty four years. Ten DB migrations. Zero downtime.&lt;/p&gt;

&lt;p&gt;Except the first one, where I lost seven minutes I couldn't accept.&lt;/p&gt;

&lt;p&gt;That seven minutes is why this article exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The seven minutes
&lt;/h2&gt;

&lt;p&gt;It was in the mid-2000s. I was running a content distribution system on my own, with a small open-source database underneath. The platform was growing, and at some point we decided to move to a much larger commercial database — an appliance-grade one, the kind you specify by line of business and not by hostname.&lt;/p&gt;

&lt;p&gt;I planned the switchover for a thirty-minute maintenance window. I did the work. End-to-end, it took seven minutes.&lt;/p&gt;

&lt;p&gt;Seven minutes during which the service was down. End users couldn't reach the catalog. Bookstores couldn't sync. Publishers couldn't see their numbers.&lt;/p&gt;

&lt;p&gt;It bothered me more than it should have. The migration was a success. The system came back up. Nobody complained.&lt;/p&gt;

&lt;p&gt;But it had been down. Seven minutes that, on paper, the agreement said was acceptable. Seven minutes that, in my head, I never wanted to repeat.&lt;/p&gt;

&lt;p&gt;That feeling didn't go away. I started designing every database operation from that day as if seven minutes was the wrong answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The contract no one made me write
&lt;/h2&gt;

&lt;p&gt;That platform delivered books, comics, and music — content from publishers and labels through the bookstores that resold it. End users, bookstores, publishers, label owners: all of them sat on top of a single piece of plumbing I was responsible for.&lt;/p&gt;

&lt;p&gt;There was no formal SLA written anywhere that said "this never goes down." But there was something stronger than an SLA: a sales-side expectation. Inside the company, it was assumed the service was always reachable. Customers were sold on the assumption that downloads worked at any time of day. Bookstores integrated against that assumption. Publishers settled royalties on top of it.&lt;/p&gt;

&lt;p&gt;If I took the service down for an hour, none of those agreements would have technically been broken. But the silent contract — &lt;em&gt;you never notice me running maintenance&lt;/em&gt; — would have been.&lt;/p&gt;

&lt;p&gt;Once, before a particularly large migration, I had to brief one of the major carriers (they were on the bookstore side, technically a B2B customer). We met around a whiteboard. I drew the sequence. After about five minutes, the lead engineer on their side just nodded and said something like, "okay, if you're doing it, we're fine." We didn't need a recovery plan from them. We didn't need a coordinated test window. They didn't even update their monitoring.&lt;/p&gt;

&lt;p&gt;That trust didn't come from documentation. It came from the fact that none of the previous migrations had touched their integration.&lt;/p&gt;

&lt;p&gt;So the shape of how I do these migrations started with seven minutes I couldn't accept, and was kept alive by twenty-three years of not breaking that quiet contract again.&lt;/p&gt;




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

&lt;p&gt;Here is the shape, stripped of vendor names. It is not new. It is not clever. It has just survived.&lt;/p&gt;

&lt;p&gt;Step zero: separate the data into two kinds.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data A&lt;/strong&gt;: anything the end user reads. This is what the service actually serves. If this is wrong or unavailable, the service is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data B&lt;/strong&gt;: aggregates, summaries, derived tables, everything else. Batches write to it. End users never read from it directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule for Data A is: real-time synchronization, both old and new databases, no exceptions. The rule for Data B is: batches can stop for a while, you'll catch up later.&lt;/p&gt;

&lt;p&gt;Step one: well before the migration window, set up two batches that incrementally copy Data A and Data B from the old database to the new one. Use the last-modified timestamp on each row. Take physical deletes into account by occasionally diffing the row sets and removing what's no longer there. Run these for days or weeks until the new database is essentially a copy of the old, plus or minus the most recent few minutes.&lt;/p&gt;

&lt;p&gt;Step two: at migration time, stop the batches that write Data B. Run the Data B copy one last time. The aggregate tables on the new database are now identical to the old.&lt;/p&gt;

&lt;p&gt;Step three: switch the application logic to a maintenance mode where Data A is written to both databases, but read only from the old one. Every user-facing update now produces two writes: one to the old database (the authoritative one), one to the new database (best effort, the eventually-authoritative one). If the write to the new database fails, it's swallowed — step four catches the drift.&lt;/p&gt;

&lt;p&gt;Step four: once all instances of the application are in this dual-write mode, run the Data A copy one final time. This catches anything that was written to the old database between the last sync and the dual-write switchover. After this, both databases agree.&lt;/p&gt;

&lt;p&gt;Step five: switch the application logic to read from the new database, while still writing to both. This is the moment of truth. If anything is wrong with the new database, this is when the user feels it.&lt;/p&gt;

&lt;p&gt;Step six: switch the application logic to read and write to the new database only. The old database is detached.&lt;/p&gt;

&lt;p&gt;For a migration (the new database stays), the batches that write Data B can now resume against the new database, and you're done. For a maintenance bypass (the old database is coming back), you do the same sequence in reverse, and the old database returns to service.&lt;/p&gt;

&lt;p&gt;For reference, here's how the application behaves at each step:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;App writes&lt;/th&gt;
&lt;th&gt;App reads&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Classify rows into Data A and Data B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;Incremental copy batches running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;Stop Data B batches, final Data B copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;old + new&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;Dual-write switchover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;old + new&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;Final Data A sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;old + new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;Read switchover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;Old database detached&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The thing nobody talks about: mixed states
&lt;/h2&gt;

&lt;p&gt;This shape works because each step is &lt;em&gt;resilient to mixed states&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I deploy applications manually. I have for twenty-four years. There is no coordinated rolling restart, no atomic feature flag flip across all instances. When I switch the application logic to dual-write mode in step three, some instances are still in single-write mode (against the old database only) while others are already in dual-write mode. That mixed state can last as long as it takes me to walk through each server.&lt;/p&gt;

&lt;p&gt;The shape is designed so that mixed states are correct.&lt;/p&gt;

&lt;p&gt;When step three is rolling out: some instances write to old only, some write to both. All instances read from the old. The old database stays authoritative. Reads are consistent.&lt;/p&gt;

&lt;p&gt;When step five is rolling out: some instances read from old, some read from new. By this point both databases agree (step four just synced them). Either read is correct.&lt;/p&gt;

&lt;p&gt;When step six is rolling out: some instances still dual-write, some write to new only. All instances read from new. The new database is authoritative. The few writes that still hit the old database are harmless — it'll be detached momentarily.&lt;/p&gt;

&lt;p&gt;I don't have to wait for a deployment to finish. I don't need a feature flag system to coordinate it. I don't need a service mesh to make it safe. I need the property that the shape stays correct while it's transitioning.&lt;/p&gt;

&lt;p&gt;This is the part of the design that is older than every operational tool I see today. And it's the part I haven't found a reason to replace.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rollback was never a special case
&lt;/h2&gt;

&lt;p&gt;If you'd asked me five years ago whether this design has rollback, I would have said yes, of course. The reverse sequence &lt;em&gt;is&lt;/em&gt; the rollback.&lt;/p&gt;

&lt;p&gt;A maintenance bypass already runs forward, then backward. That backward leg is rollback. It's executed every single time. Rollback isn't an emergency path. It's a normal part of the workflow.&lt;/p&gt;

&lt;p&gt;I've done this kind of migration over ten times in twenty-four years. I have never had to use the reverse sequence as an emergency. Not because nothing ever went wrong. But because the preparation phase — the days and weeks of incremental copying, of double-checking the deletes, of staring at row counts — catches the things that would have gone wrong, before they can.&lt;/p&gt;

&lt;p&gt;The boring part of the work is what makes the dramatic part of the work disappear.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twenty four years later
&lt;/h2&gt;

&lt;p&gt;I'm going to write something now that should probably embarrass me but doesn't: I enjoy this work.&lt;/p&gt;

&lt;p&gt;A new database to move into — especially a serious appliance-grade one — is one of the most enjoyable things I get to do. The preparation is meditative. The cutover itself is short and quiet. The week after, when the system is running on the new hardware and the end users have noticed nothing, is satisfying in a way I have not gotten from any other kind of engineering.&lt;/p&gt;

&lt;p&gt;Twenty four years. Ten migrations. Zero downtime, after the seven minutes I couldn't accept.&lt;/p&gt;

&lt;p&gt;That's the entire story. There are other databases out there now, other appliances, other ways of doing this. There are managed services that do most of the dance automatically. There are tools that take a lot of the carefulness off your hands.&lt;/p&gt;

&lt;p&gt;I don't have an argument against any of those. I just know what survives in my hands: a separation of data into two kinds, a dual-write window in the middle, a resilience to mixed states, and a reverse sequence I always treat as ordinary.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&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;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>devops</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Let your n8n template ask for the user's API key</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 10 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/let-your-n8n-template-ask-for-the-users-api-key-54n9</link>
      <guid>https://dev.to/hidekimori/let-your-n8n-template-ask-for-the-users-api-key-54n9</guid>
      <description>&lt;p&gt;You built a workflow worth sharing — and it works perfectly. Until someone else imports it.&lt;/p&gt;

&lt;p&gt;The bottleneck is the API key. Use yours, and every user is billed against your account. Use theirs, and they each have to find the credential UI, paste their key, and reconnect every time. Both are friction. The cleaner option is to let the workflow ask for the key on the form, then thread it through to the HTTP nodes that need it. It's simpler than it sounds.&lt;/p&gt;

&lt;p&gt;This post walks through the pattern with a working credential setup, an alternative for single-node simple cases, the gotchas, and a note on what this enables for custom node authors.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The screenshots below come from n8n's built-in Bearer Auth credential and from the &lt;code&gt;n8n-nodes-ldxhub&lt;/code&gt; package's own credential schema. The technique itself is generic — what's shown here works for any HTTP-node workflow and any custom node that supports expression-mode credentials.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The form asks, the credential listens
&lt;/h2&gt;

&lt;p&gt;The simplest case: a Form Trigger collects an API key, then an HTTP node hits an authenticated endpoint with that key. Two nodes, one bridge between them — but the bridge isn't a direct expression. It runs through a credential.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Form Trigger collects &lt;code&gt;api_key&lt;/code&gt; (use the &lt;code&gt;Password&lt;/code&gt; element type for masking)&lt;/li&gt;
&lt;li&gt;A Bearer Auth credential references that form input via expression&lt;/li&gt;
&lt;li&gt;HTTP node picks the credential&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Form Trigger is straightforward. Add one field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;Form Trigger&lt;/span&gt;
  &lt;span class="s"&gt;Form Fields&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;API Key&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Element Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Password&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Custom Field Name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api_key&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Required Field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Element type matters. Use &lt;code&gt;Password&lt;/code&gt; instead of &lt;code&gt;Text&lt;/code&gt; and the input gets masked on screen — the key isn't readable to someone glancing at the browser.&lt;/p&gt;

&lt;p&gt;Here's the rendered form a user sees when they open the workflow URL:&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%2Fgjb63jl1qddhkstcmbzm.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%2Fgjb63jl1qddhkstcmbzm.png" alt="Dynamic credentials demo form" width="800" height="666"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring the credential to expression mode
&lt;/h2&gt;

&lt;p&gt;For a Bearer token (which is what most modern APIs use), create a new credential of type &lt;strong&gt;Bearer Auth&lt;/strong&gt; — a generic credential built into n8n that's purpose-built for &lt;code&gt;Authorization: Bearer ...&lt;/code&gt; headers. It has a single field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bearer Token: YOUR_API_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Click the small &lt;code&gt;fx&lt;/code&gt; (or &lt;code&gt;=&lt;/code&gt;) toggle next to the Bearer Token field to switch it into expression mode. Then replace the value with a reference to the form's input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;Bearer Token: =&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'On form submission'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;item.json.api_key&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;=&lt;/code&gt; prefix tells n8n this is an expression, not a literal string. Everything inside &lt;code&gt;{{ }}&lt;/code&gt; is JavaScript, and &lt;code&gt;$('Node Name').item.json.field&lt;/code&gt; reaches across the workflow to grab a value from another node — the same long-form reference pattern from &lt;a href="https://dev.to/hidekimori/build-a-multi-step-n8n-form-with-dynamic-dropdowns-no-plugin-needed-1pm4"&gt;Implementation notes #001&lt;/a&gt;. The Bearer Auth credential automatically prepends &lt;code&gt;Bearer&lt;/code&gt; to the token at send time, so you don't write the prefix yourself.&lt;/p&gt;

&lt;p&gt;Save the credential. You'll see this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ERROR: No path back to node]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's display-only. The credential edit UI has no workflow execution context, so it can't resolve the expression at that moment. When an HTTP node later invokes this credential from inside a running workflow, the expression resolves correctly.&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%2Fb247kaaqe84fnyhef4p0.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%2Fb247kaaqe84fnyhef4p0.png" alt="Bearer Auth credential with expression mode" width="800" height="618"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is worth pausing on, because many people stop here. They assume the credential is broken and abandon the technique. It isn't broken. Save it anyway. Run the workflow. The key flows through.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on the error text:&lt;/strong&gt; Different credential types show slightly different display-only messages for the same situation. The Bearer Auth credential shows &lt;code&gt;[ERROR: No path back to node]&lt;/code&gt;; some custom-node credentials show &lt;code&gt;[ERROR: Referenced node doesn't exist]&lt;/code&gt;. Same root cause (no workflow context at edit time), same harmlessness — just different wording depending on the credential implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on credential type choice:&lt;/strong&gt; Bearer Auth uses the standard &lt;code&gt;Authorization&lt;/code&gt; header. If your target API expects a different header name — &lt;code&gt;X-API-Key&lt;/code&gt;, &lt;code&gt;X-Custom-Auth&lt;/code&gt;, etc. — use &lt;strong&gt;Header Auth&lt;/strong&gt; (custom Name/Value) or &lt;strong&gt;Custom Auth&lt;/strong&gt; (full JSON-shaped headers) instead. The expression-mode technique works identically for all of them.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Using the credential in an HTTP node
&lt;/h2&gt;

&lt;p&gt;Configure the HTTP Request node to use the credential:&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
  Authentication:    Generic Credential Type
  Generic Auth Type: Bearer Auth
  Credential:        &amp;lt;your credential name&amp;gt;
  Method:            (whatever the API needs)
  URL:               (the endpoint)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP node now sends &lt;code&gt;Authorization: Bearer ...&lt;/code&gt; with the value filled in from the form. No hardcoded keys. No per-user credential setup. The workflow asks, the user types, the request goes out.&lt;/p&gt;

&lt;p&gt;You can drop in as many HTTP nodes as the workflow needs — every one of them references the same credential, so the key only has to be entered once on the form, regardless of how many endpoints get called downstream.&lt;/p&gt;




&lt;h2&gt;
  
  
  Alternative: skip the credential entirely
&lt;/h2&gt;

&lt;p&gt;For a workflow with one HTTP node, you can short-circuit this. Set &lt;code&gt;Authentication: None&lt;/code&gt; on the HTTP node and write the header directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;HTTP Request
  Authentication: None
  Headers:
    - Name:  Authorization
    - Value: =Bearer &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'On form submission'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;item.json.api_key&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works. It's faster to set up. It's worth knowing.&lt;/p&gt;

&lt;p&gt;What it doesn't give you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reuse across multiple HTTP nodes in the same workflow&lt;/li&gt;
&lt;li&gt;A path to custom nodes that expect credentials (more on that below)&lt;/li&gt;
&lt;li&gt;The mental separation between "this is auth material" and "this is request data"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use it for one-off prototypes. For published templates with more than one authenticated call, the credential approach scales better.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  1. The display-only error has different wording per credential type
&lt;/h3&gt;

&lt;p&gt;Already covered above — the credential edit UI has no execution context, so it can't resolve the expression at design time. Save it, run the workflow, the expression resolves. What's worth noting is that the &lt;strong&gt;message itself differs by credential type&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bearer Auth, Header Auth, and most generic credentials show: &lt;code&gt;[ERROR: No path back to node]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Some custom-node credentials (the LDX hub Dynamic credential below, for instance) show: &lt;code&gt;[ERROR: Referenced node doesn't exist]&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are display-only. Both go away at runtime. Don't let the wording trick you into thinking one credential type is more broken than the other.&lt;/p&gt;

&lt;p&gt;If you actually have a typo (wrong node name in the expression), you'll see the same error message at design time — same display, different cause. The only way to distinguish a display-only error from a real one is to run the workflow and look at the outgoing request.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The &lt;code&gt;Password&lt;/code&gt; element type controls masking, not security
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Password&lt;/code&gt; element type on the Form Trigger masks input on screen. It doesn't encrypt anything, doesn't store the key separately, and doesn't hide the key from n8n's execution logs. Anyone with access to the workflow's execution history can see what was sent.&lt;/p&gt;

&lt;p&gt;Treat masking as a courtesy to the user (so the key isn't readable over their shoulder), not as a security boundary. If you need stronger guarantees, the key needs to live in a properly stored credential — and at that point you're back to per-user credential setup, which defeats the form-based approach.&lt;/p&gt;

&lt;p&gt;This pattern is convenience-oriented, not secret-management-oriented. For genuine secret handling — vault integration, audit trails, key rotation — n8n offers external vault support on its Enterprise plan. For everything else, the form-based pattern is a UX optimization, not a security upgrade.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Credentials are global, expressions are runtime-scoped
&lt;/h3&gt;

&lt;p&gt;There's a slightly odd structural fact behind this pattern: the credential record itself isn't tied to any specific workflow — n8n stores credentials globally, and any workflow can reference one. But the expression inside the credential references a specific node by name (&lt;code&gt;$('On form submission')&lt;/code&gt;), which can only resolve inside a workflow execution context.&lt;/p&gt;

&lt;p&gt;The credential is global. The expression inside it is workflow-scoped. At edit time, those two worlds are disconnected. At runtime, n8n binds them together — but only if both halves match.&lt;/p&gt;

&lt;p&gt;This is also why a credential built around one template's node names can fail in a different workflow that doesn't have those nodes. The mismatch is silent until the workflow runs.&lt;/p&gt;

&lt;p&gt;Either standardize your trigger node names across templates (always &lt;code&gt;On form submission&lt;/code&gt;), or create a separate credential per template. If you're publishing several templates that each ask for an API key, having one credential per template is the cleaner long-term shape — credentials are cheap, mismatches are expensive.&lt;/p&gt;




&lt;p&gt;Everything above applies to standard HTTP-node workflows. The next section is about something broader: how custom nodes can participate in the same pattern, and what that means for n8n's community ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a custom node also supports expression credentials
&lt;/h2&gt;

&lt;p&gt;Most discussions of dynamic credentials stop at the HTTP node pattern above. There's a second class worth knowing about: custom nodes (community-built, like &lt;code&gt;n8n-nodes-asana&lt;/code&gt; or &lt;code&gt;n8n-nodes-ldxhub&lt;/code&gt;) that define their own credential types. They don't use HTTP node headers — they read from the credential directly. Which means the form-to-credential pattern needs to be supported by the node itself.&lt;/p&gt;

&lt;p&gt;A custom node that anticipates varied use cases lets the user choose between two modes for the same credential:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static mode&lt;/strong&gt; — traditional credential setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Key stored directly in the credential&lt;/li&gt;
&lt;li&gt;One-time setup per user&lt;/li&gt;
&lt;li&gt;Best for personal/internal workflows&lt;/li&gt;
&lt;/ul&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%2Fmi3txxxi2r020xhk0akn.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%2Fmi3txxxi2r020xhk0akn.png" alt="LDX hub Static credential" width="800" height="617"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic mode&lt;/strong&gt; — same credential, key field in expression mode (&lt;code&gt;={{ $('On form submission').item.json.api_key }}&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Key resolved from form input at runtime&lt;/li&gt;
&lt;li&gt;No per-user setup&lt;/li&gt;
&lt;li&gt;Best for distributable templates&lt;/li&gt;
&lt;/ul&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%2Fjdfl5kjbty5eta4ksxku.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%2Fjdfl5kjbty5eta4ksxku.png" alt="LDX hub Dynamic credential" width="800" height="616"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same credential type. Two usage patterns. The user picks based on what they're doing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note that only the API Key field is in expression mode here — the Base URL is left as a static &lt;code&gt;https://gw.ldxhub.io&lt;/code&gt;. You could put the Base URL in expression mode too (and the &lt;code&gt;all-services-demo&lt;/code&gt; template in the LDX hub package does exactly that, asking the user for a host on the form). For this article's example we keep it simple: one moving part, one expression.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For node authors, the takeaway is: design your credential schema to support expression-mode values from the start. Don't hardcode a regex validation that rejects &lt;code&gt;{{ }}&lt;/code&gt; syntax. Don't fail on empty initial values (the expression resolves at runtime, not at credential save time). Don't insist on a particular key format if the value will be computed from somewhere else.&lt;/p&gt;

&lt;p&gt;A node that works in both modes is useful in twice as many contexts — personal automation and template publishing — for the cost of letting the credential field be an expression. That's a low investment with broad payoff.&lt;/p&gt;




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

&lt;p&gt;The default n8n workflow is a one-account thing. You build it to call APIs against your own credentials, save it, and it runs for you. Anyone who imports it has to either match your account exactly or rewrite the auth.&lt;/p&gt;

&lt;p&gt;Dynamic credentials change this. The same workflow now adapts to whoever runs it — their API key, their account, their bill. The template becomes a small application that anyone can pick up.&lt;/p&gt;

&lt;p&gt;Together with dynamic dropdowns (&lt;a href="https://dev.to/hidekimori/build-a-multi-step-n8n-form-with-dynamic-dropdowns-no-plugin-needed-1pm4"&gt;Implementation notes #001&lt;/a&gt;), this is the second piece you need to ship a workflow that's actually distributable. The form asks for a key. The credential threads it through. The HTTP nodes — or compatible custom nodes — make calls. No setup ceremony, no account juggling, no copy-paste of credentials from configuration page to configuration page.&lt;/p&gt;




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

&lt;p&gt;The &lt;code&gt;all-services-demo&lt;/code&gt; example in the &lt;a href="https://www.npmjs.com/package/n8n-nodes-ldxhub" rel="noopener noreferrer"&gt;n8n-nodes-ldxhub&lt;/a&gt; package ships with this pattern. The Form Trigger asks for an API key and a host. Dynamic credentials thread both into five different service paths, each using the LDX hub custom node with expression-mode credentials. Inspect &lt;code&gt;examples/all-services-demo.json&lt;/code&gt; to see the credential setup and the node calls that consume it.&lt;/p&gt;

&lt;p&gt;For a free LDX hub key to run the demo, head to &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. Or take the credential pattern and apply it to any API you want.&lt;/p&gt;




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

&lt;p&gt;The credential-with-expression pattern is one of those n8n techniques that's both completely sanctioned by the design and never quite documented as "this is how you ship templates." It just sits there, in the small &lt;code&gt;fx&lt;/code&gt; toggle next to every credential field, waiting for someone to flip it.&lt;/p&gt;

&lt;p&gt;For node authors, designing for expression-mode support from day one costs almost nothing. For workflow authors, it's the difference between "here's my template, please configure four things to use it" and "here's the form, paste your key."&lt;/p&gt;

&lt;p&gt;The boring part is that there's almost nothing exotic happening. The mechanism was already there. The missing piece was realizing that credentials didn't have to belong to the workflow author.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <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;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>
  </channel>
</rss>
