<?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: Kynth</title>
    <description>The latest articles on DEV Community by Kynth (@kynth).</description>
    <link>https://dev.to/kynth</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%2F4016803%2Fff796af4-7aae-4c10-8b00-f856260e4667.png</url>
      <title>DEV Community: Kynth</title>
      <link>https://dev.to/kynth</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kynth"/>
    <language>en</language>
    <item>
      <title>Compiling one tokens.json into an app, its landing page, its PDFs, and its OG card</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Mon, 27 Jul 2026 11:12:13 +0000</pubDate>
      <link>https://dev.to/kynth/compiling-one-tokensjson-into-an-app-its-landing-page-its-pdfs-and-its-og-card-193j</link>
      <guid>https://dev.to/kynth/compiling-one-tokensjson-into-an-app-its-landing-page-its-pdfs-and-its-og-card-193j</guid>
      <description>&lt;p&gt;TAGS: designsystems, webdev, css, architecture&lt;/p&gt;

&lt;p&gt;&lt;code&gt;draw-your-font&lt;/code&gt; did the rounds this week — photograph your handwriting, get a real TTF/WOFF back. Nice hack, and a good reminder that generating a brand asset is the easy half. Keeping that one asset identical across a React app, a marketing site, a generated PDF, and a 1200×630 share image is the half that quietly rots.&lt;/p&gt;

&lt;p&gt;That rot has a specific cause: those four surfaces usually get built by different people at different times. The app gets built first, the landing page gets bolted on before launch, the PDF export is a ticket nobody wanted, and the OG card is made in Figma the morning of. Each one re-declares the brand from memory. Six weeks later the accent color exists in four slightly different hexes.&lt;/p&gt;

&lt;p&gt;Here's the pipeline we use to stop that, and the three places it broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: tokens are primitives, never references
&lt;/h2&gt;

&lt;p&gt;The temptation is to write tokens as CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1f5f4b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--accent-weak&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;color-mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;srgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;12%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;white&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;This works right up until a consumer that isn't a browser needs the value. Chrome's &lt;code&gt;Page.printToPDF&lt;/code&gt; header template can't resolve your &lt;code&gt;var()&lt;/code&gt;. Neither can a canvas-based OG generator, nor an email, nor a &lt;code&gt;&amp;lt;meta name="theme-color"&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the source of truth is a flat JSON of resolved primitives, and everything else is generated:&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="c1"&gt;// tokens.json  →  four artifacts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&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="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tokens.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app/tokens.css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s2"&gt;`:root{&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;}`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// web app&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;site/tokens.css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s2"&gt;`:root{&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;}`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// landing page&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf/theme.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="s2"&gt;`export default &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// print + header/footer&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;og/theme.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="s2"&gt;`export default &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// share card renderer&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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;k&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;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`--&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any derived value — a 12% tint, a hover state — gets computed in the build step and written out as a literal. If a consumer has to do color math at runtime, it will do it differently than the others eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Chrome's PDF header/footer is a separate document.&lt;/strong&gt; &lt;code&gt;headerTemplate&lt;/code&gt; renders in its own tiny page with no access to your stylesheet, no external fonts, and it silently ignores most of what you send it. Everything has to be inlined, sized in &lt;code&gt;pt&lt;/code&gt;, and the font has to be a data URI of the same file the site loads:&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="nx"&gt;headerTemplate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;style&amp;gt;@font-face{font-family:B;src:url(data:font/woff2;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)}
  div{font:8pt B;color:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;padding:0 14pt}&amp;lt;/style&amp;gt;
  &amp;lt;div&amp;gt;&amp;lt;span class="title"&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Backgrounds vanish in print.&lt;/strong&gt; Any token used as a fill needs &lt;code&gt;print-color-adjust: exact&lt;/code&gt; on the element, not just the page. We set it once on a &lt;code&gt;.binder *&lt;/code&gt; selector after losing an afternoon to a header block that rendered correctly in the browser preview and white in the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page breaks are a token too.&lt;/strong&gt; Section spacing that looks right on a scrolling page produces orphaned headings on paper. We added &lt;code&gt;break-inside: avoid&lt;/code&gt; and a &lt;code&gt;--section-gap-print&lt;/code&gt; value to the same file, so the print rhythm is versioned alongside the screen rhythm instead of living in one person's head.&lt;/p&gt;

&lt;h2&gt;
  
  
  Versioning across repos
&lt;/h2&gt;

&lt;p&gt;The app and the landing page deploy separately, so &lt;code&gt;tokens.json&lt;/code&gt; is published as a tiny internal package and pinned by version in both. Not because the semver matters, but because it makes drift a diff instead of a discovery. When the accent shifts, you get one PR touching one file, and the next deploy of each surface picks it up.&lt;/p&gt;

&lt;p&gt;The payoff is that launch assets stop being a separate project. The OG card is a headless render of a component that imports the same theme; the ad thumbnails are crops of it. Change the accent, run the build, and the share preview, the report cover, and the landing hero all move together.&lt;/p&gt;

&lt;p&gt;That's the whole argument for shipping the product and its surrounding surfaces as one job rather than two: not that it's faster, but that consistency is a build artifact when the same team owns the pipeline, and a manual chore when it isn't.&lt;/p&gt;

&lt;p&gt;We built this for &lt;a href="https://civicbinder.org" rel="noopener noreferrer"&gt;CivicBinder&lt;/a&gt; — a service that runs web-accessibility audits for public agencies and generates the evidence binder as a PDF. Same pipeline: one tokens file, four renderers.&lt;/p&gt;

</description>
      <category>designsystems</category>
      <category>webdev</category>
      <category>css</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Put an LLM QA gate in front of your paid deliverable: fail-closed review, one self-heal, no retry loops</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Fri, 24 Jul 2026 22:57:22 +0000</pubDate>
      <link>https://dev.to/kynth/put-an-llm-qa-gate-in-front-of-your-paid-deliverable-fail-closed-review-one-self-heal-no-retry-daf</link>
      <guid>https://dev.to/kynth/put-an-llm-qa-gate-in-front-of-your-paid-deliverable-fail-closed-review-one-self-heal-no-retry-daf</guid>
      <description>&lt;p&gt;TAGS: ai, node, automation, playwright&lt;/p&gt;

&lt;p&gt;I built and shipped a compliance-report service — domain registered, Stripe live, fulfillment automated — in under a week of calendar time. Not a quarter. Not a team. One person with an AI-native toolchain. The point of this post isn't the speed; it's the specific piece of engineering that made the speed possible, because it's the piece most solo builds skip and then get burned by.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline is the easy part
&lt;/h2&gt;

&lt;p&gt;The product audits public-sector websites for ADA Title II accessibility and produces an evidence binder. The pipeline is boring on purpose — a chain of CLI stages, each writing JSON to disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;crawl-scan.mjs   → Playwright + axe-core over N pages → findings.json
pdf_census.py    → every linked PDF, tagged/untagged   → pdf-census.json
summarize.mjs    → severity-weighted score             → summary.json
evidence.mjs     → annotated screenshots of failures   → evidence/*.png
binder.mjs       → 13-page PDF                         → readiness-binder.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files on disk, not in-memory state. Every stage is resumable, inspectable, and re-runnable against a single town's directory. That's not architectural purity — it's what lets one person debug page 9 of a PDF at 11pm without re-crawling a municipal website.&lt;/p&gt;

&lt;p&gt;None of that is the hard part. AI-assisted, that chain came together fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part you can't hire
&lt;/h2&gt;

&lt;p&gt;Here's what actually blocks a solo operator from charging money: &lt;strong&gt;nobody checks the work before it goes out.&lt;/strong&gt; In a company that's a role. Someone reads the deliverable, notices the screenshot is missing its markers, and stops the send. Alone, you're the generator &lt;em&gt;and&lt;/em&gt; the reviewer, and you cannot review every artifact of an automated pipeline forever.&lt;/p&gt;

&lt;p&gt;So the reviewer became a subprocess. Before any binder is emailed, a headless model reads the actual PDF, the scan data, and — with vision — the evidence screenshots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`You are the pre-delivery QA reviewer, reviewing a PAID deliverable.
Read: readiness-binder.pdf (every page), summary.json, evidence/*.png (LOOK at them).
Check strictly:
 1. Entity name + domain correct on cover/headers — no other town's name anywhere.
 2. Counts in Section 1 match summary.json.
 3. Every screenshot shows numbered flag markers; captions match what's visible.
    A screenshot with no visible markers is a FAIL.
 4. No overflow into footers, blank pages, unreplaced {{PLACEHOLDER}} tokens.
Output ONLY: {"pass":bool,"issues":[...],"notes":"..."}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;qaReview&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;                 &lt;span class="c1"&gt;// reviewer errored? → pass:false&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;selfHeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;            &lt;span class="c1"&gt;// fixes src/, commits, ONE attempt&lt;/span&gt;
    &lt;span class="nf"&gt;regenerateArtifacts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;qaReview&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;   &lt;span class="c1"&gt;// send stays blocked, owner notified&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendBinder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in there are load-bearing, and I got each of them wrong first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed, including on the reviewer itself.&lt;/strong&gt; If the model times out, crashes, or returns garbage, the verdict is &lt;code&gt;pass: false&lt;/code&gt;. A broken referee must never become a green light. Every failure path routes to a human inbox with both verdicts attached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parse tolerantly or you'll false-fail good work.&lt;/strong&gt; "Output only JSON" is a suggestion. Code fences, a stray sentence, a lone &lt;code&gt;{&lt;/code&gt; in prose — early on, perfectly good binders got blocked by formatting quirks. The fix is a scanner that walks the string for the first &lt;em&gt;balanced&lt;/em&gt; &lt;code&gt;{...}&lt;/code&gt; that parses and contains a &lt;code&gt;pass&lt;/code&gt; key, ignoring fences and preamble. Strict on the deliverable, forgiving on the envelope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-heal fixes the generator, not the artifact — exactly once.&lt;/strong&gt; When the gate rejects, a headless coding session gets the findings plus repo access, with hard rules: only edit &lt;code&gt;src/&lt;/code&gt;, never hand-patch the generated PDF, never touch the email or payment logic, commit the fix. Then artifacts regenerate and the gate re-reviews. One attempt. On the money path, a retry loop is just a slower way to ship something broken.&lt;/p&gt;

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

&lt;p&gt;The gate earned itself in its first days. &lt;code&gt;evidence.mjs&lt;/code&gt; was placing its numbered flags &lt;em&gt;before&lt;/em&gt; the final scroll, so badges landed outside the captured frame and visible numbering started at #2 — a defect I'd have shipped, because the JSON manifest said four annotations and four annotations existed. Only something &lt;em&gt;looking at the PNG&lt;/em&gt; catches that. The fix went into the generator with a comment naming the date it was caught.&lt;/p&gt;

&lt;p&gt;That's the real speed argument. It isn't that AI writes code fast. It's that the review loop that normally costs a headcount and a week of calendar time is now a subprocess you can call from a fulfillment script — so an idea gets to &lt;em&gt;actually deployed and taking payments&lt;/em&gt; in days, and stays trustworthy after you stop watching it.&lt;/p&gt;

&lt;p&gt;That's how we built &lt;a href="https://civicbinder.org" rel="noopener noreferrer"&gt;CivicBinder&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>node</category>
      <category>automation</category>
      <category>playwright</category>
    </item>
    <item>
      <title>Auditing a 40,000-page government site for WCAG without auditing 40,000 pages</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Fri, 24 Jul 2026 20:57:53 +0000</pubDate>
      <link>https://dev.to/kynth/auditing-a-40000-page-government-site-for-wcag-without-auditing-40000-pages-439g</link>
      <guid>https://dev.to/kynth/auditing-a-40000-page-government-site-for-wcag-without-auditing-40000-pages-439g</guid>
      <description>&lt;p&gt;A city of 30,000 people has more web pages than it has employees. Agendas back to 2009, a permit portal, three departments that each bought their own CMS, and 12,000 PDFs. The DOJ's ADA Title II rule gives that city a hard date to be at WCAG 2.1 AA, and the person responsible is usually one IT generalist who also fixes the printers.&lt;/p&gt;

&lt;p&gt;You cannot run a human audit on 40,000 pages. You also cannot just run axe-core over a crawl and call the output an audit — that produces a 900,000-row CSV that proves nothing and fixes nothing. Here's the approach that actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pages don't vary. Templates do.
&lt;/h2&gt;

&lt;p&gt;The insight that makes this tractable: a 40,000-page municipal site is usually 40–80 templates. Every agenda page fails the same way. If the events template has a table without a &lt;code&gt;&amp;lt;caption&amp;gt;&lt;/code&gt;, you have one bug, not 4,000.&lt;/p&gt;

&lt;p&gt;So the first pass is clustering by DOM skeleton, not by URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dom&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;skeleton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&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;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isContentLeaf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;// drop &amp;lt;p&amp;gt;, text-only &amp;lt;span&amp;gt;, &amp;lt;li&amp;gt; bodies&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;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;stableClasses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&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;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;skeleton&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// stableClasses drops anything that looks generated:&lt;/span&gt;
&lt;span class="c1"&gt;//   /\d/ -&amp;gt; hashed utility classes, CSS-modules suffixes, per-post ids&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clusters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crawledPages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two knobs matter more than the hashing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;isContentLeaf&lt;/code&gt; is the whole ballgame.&lt;/strong&gt; Include too much and every page is its own cluster. Include too little and the permit form collapses into the same bucket as a press release. Tune it until cluster count stabilizes across two crawls a week apart — that's your signal the fingerprint is keyed on structure and not on content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One sample per cluster is not enough.&lt;/strong&gt; Take three: shortest, median, and longest page by content length. The long tail is where the embedded YouTube video, the nested data table, and the 2011 Flash-era markup live. Same template, wildly different violations. This one change roughly doubled real findings for us.&lt;/p&gt;

&lt;p&gt;40,000 pages → ~60 clusters → ~180 audited pages. That's a week of human review, not a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automated scans prove about a third of it
&lt;/h2&gt;

&lt;p&gt;This is the part most "compliance scanners" quietly skip. axe-core reliably detects roughly 30–40% of WCAG failures. It cannot tell you whether alt text is &lt;em&gt;accurate&lt;/em&gt;, whether focus order is &lt;em&gt;logical&lt;/em&gt;, or whether an error message is &lt;em&gt;understandable&lt;/em&gt;. A tool that reports "98% compliant" from a scan is reporting a number it did not measure.&lt;/p&gt;

&lt;p&gt;So every success criterion gets a three-state ledger entry, per template:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;machine_verified&lt;/code&gt; — a rule mapped to that criterion ran and passed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;human_verified&lt;/code&gt; — a reviewer performed the documented procedure (keyboard-only pass, screen-reader pass, contrast spot-check)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;not_applicable&lt;/code&gt; — with a written reason, because "N/A" without a reason is the single most common thing that gets a conformance claim thrown out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A criterion is only conformant if it's covered by one of the first two. The ledger makes the gap visible instead of hiding it behind a percentage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence has to be frozen
&lt;/h2&gt;

&lt;p&gt;An audit finding is worthless six months later if the page has changed. Every finding stores a content-addressed evidence bundle: raw HTML, a full-page screenshot, the axe JSON, the response headers, an RFC 3161-style timestamp, and a SHA-256 over all of it. Findings reference the hash, never the live URL.&lt;/p&gt;

&lt;p&gt;That also gives you cheap remediation tracking — re-crawl, re-fingerprint, diff the hashes. Templates whose fingerprint changed get re-audited. Everything else carries forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFs are where the real liability is
&lt;/h2&gt;

&lt;p&gt;Municipal sites are PDF delivery systems wearing a website. Check for a &lt;code&gt;/StructTreeRoot&lt;/code&gt; (untagged = fail), a document title, a language tag, and whether the text layer is real text or a scan. Cluster those too — the "agenda" generator produces one failure mode across 4,000 documents, and fixing the generator fixes all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes out
&lt;/h2&gt;

&lt;p&gt;Not a dashboard. A binder: scope and methodology, the sampling justification, the criterion ledger, per-finding evidence with hashes, a remediation plan with owners and dates, and the conformance statement. That's the artifact that answers a complaint.&lt;/p&gt;

&lt;p&gt;That's how we built CivicBinder.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;MVP in a Month — $12,000, ~4 weeks.&lt;/strong&gt; Empty repo to a shipped product doing real work, like the one above. &lt;a href="https://kynth.studio" rel="noopener noreferrer"&gt;See the work and what it costs.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>testing</category>
    </item>
    <item>
      <title>Turning an axe-core scan into a defensible ADA Title II evidence binder</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Wed, 22 Jul 2026 21:27:07 +0000</pubDate>
      <link>https://dev.to/kynth/turning-an-axe-core-scan-into-a-defensible-ada-title-ii-evidence-binder-i57</link>
      <guid>https://dev.to/kynth/turning-an-axe-core-scan-into-a-defensible-ada-title-ii-evidence-binder-i57</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/sample-binder.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/sample-binder.png" alt="Sample CivicBinder readiness report: a cover page reading " title="" width="800" height="400"&gt;&lt;/a&gt; &lt;em&gt;This is a real deliverable CivicBinder produces — a filed ADA Title II readiness binder for a small municipality. It's live: point it at a city's web presence, and it audits, gathers evidence, and assembles the whole thing. Everything below is how it actually works.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The April 2024 DOJ rule gave state and local governments a hard deadline to conform their web content to &lt;strong&gt;WCAG 2.1 AA&lt;/strong&gt;. A small city clerk cannot read a WCAG spec. So the interesting engineering problem isn't "run a scanner" — it's turning scanner output into something that survives a records request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://xowekqdsttxwbhfxvusa.supabase.co/storage/v1/object/public/crosspost/proof-civicbinder/proof-civicbinder-short.mp4" rel="noopener noreferrer"&gt;Watch the CivicBinder — ADA Title II compliance, done for you demo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Automated scans cover maybe a third of WCAG
&lt;/h2&gt;

&lt;p&gt;The first thing you learn: &lt;code&gt;axe-core&lt;/code&gt; (and every tool built on it) is honest about only catching ~30–40% of WCAG success criteria. Color contrast, missing alt text, form labels, ARIA misuse — machine-verifiable. But "is this alt text &lt;em&gt;meaningful&lt;/em&gt;?" or "does the reading order make sense?" — not.&lt;/p&gt;

&lt;p&gt;If you dump raw axe violations into a PDF and call it a binder, you've produced a document that quietly claims full coverage while silently omitting the half a scanner can't see. That's the opposite of defensible. So the core design decision is a three-way split on every success criterion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;SC&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;WCAG_2_1_AA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;checks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rules_mapping&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SC&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# axe rule ids → SC
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;checks&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;all&lt;/span&gt; &lt;span class="n"&gt;machine&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;verifiable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_axe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# PASS / FAIL, with evidence
&lt;/span&gt;    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;checks&lt;/span&gt; &lt;span class="n"&gt;partially&lt;/span&gt; &lt;span class="n"&gt;cover&lt;/span&gt; &lt;span class="n"&gt;SC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PARTIAL — machine-checked + needs review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MANUAL — human attestation required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every SC lands in exactly one bucket, and the binder shows all three. The clerk sees precisely what the tool verified and what a human still has to sign off on. Honesty &lt;em&gt;is&lt;/em&gt; the feature.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhbxzaliw9x2zjuwv6xzg.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhbxzaliw9x2zjuwv6xzg.png" alt="CivicBinder — ADA Title II compliance site" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The mapping table is the actual product
&lt;/h2&gt;

&lt;p&gt;axe emits rule ids like &lt;code&gt;color-contrast&lt;/code&gt; and &lt;code&gt;image-alt&lt;/code&gt;. WCAG is organized by success criterion — 1.4.3, 1.1.1. The glue is a hand-curated map, because it's many-to-many: one SC can need several axe rules, and one rule can touch several SCs.&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="nl"&gt;"1.1.1"&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="s2"&gt;"image-alt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input-image-alt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"area-alt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object-alt"&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="nl"&gt;"1.4.3"&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="s2"&gt;"color-contrast"&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="nl"&gt;"4.1.2"&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="s2"&gt;"aria-required-attr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"button-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;"link-name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get this table wrong and the whole binder is wrong. It's the part I'd never let a model hallucinate — it's reviewed against the official &lt;a href="https://www.w3.org/WAI/standards-guidelines/act/rules/" rel="noopener noreferrer"&gt;ACT rules&lt;/a&gt; and pinned to an axe version, because rule ids drift between releases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence needs provenance, not just a boolean
&lt;/h2&gt;

&lt;p&gt;A "PASS" with no proof is worthless in a filing. So for every finding the crawler captures, at scan time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;URL&lt;/strong&gt; and a content hash of the page,&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;DOM node&lt;/strong&gt; and selector that triggered (or cleared) the rule,&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;screenshot&lt;/strong&gt; with the element highlighted,&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;ISO-8601 timestamp&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That bundle is what makes a line in the table a piece of evidence instead of an assertion. When someone asks "prove the city was compliant on this date," the binder answers with a timestamped artifact, not a claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crawl is messier than a SaaS site
&lt;/h2&gt;

&lt;p&gt;Municipal web presence is not one Next.js app. It's a main &lt;code&gt;.gov&lt;/code&gt; site, a third-party permit portal, a legacy PDF library, an agenda-management subdomain, and an embedded payment iframe — different owners, different auth. The crawler treats each surface as its own scope with its own coverage report, and PDFs get routed to a separate document-accessibility check entirely (tagged structure, reading order), because axe only speaks HTML.&lt;/p&gt;

&lt;p&gt;The lesson that generalizes past ADA: &lt;strong&gt;compliance tooling lives or dies on the boring mapping layer and the evidence provenance&lt;/strong&gt; — not the scanner. The scanner is a commodity; the defensible trail around it is the work.&lt;/p&gt;

&lt;p&gt;I built this into a done-for-you product — &lt;a href="https://calendly.com/kyisaiah47" rel="noopener noreferrer"&gt;book a slot and I'll run your city's binder&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>webdev</category>
      <category>automation</category>
      <category>compliance</category>
    </item>
    <item>
      <title>Building a Shared Capability Layer: Why Your Fifth App Shouldn't Reimplement Email</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Mon, 20 Jul 2026 10:07:19 +0000</pubDate>
      <link>https://dev.to/kynth/building-a-shared-capability-layer-why-your-fifth-app-shouldnt-reimplement-email-35ie</link>
      <guid>https://dev.to/kynth/building-a-shared-capability-layer-why-your-fifth-app-shouldnt-reimplement-email-35ie</guid>
      <description>&lt;p&gt;You know the moment. You're three weeks into a new app, and it needs to send a password reset email.&lt;/p&gt;

&lt;p&gt;You already solved this. Twice. Once in the app you shipped last year, once in the side project before that. Both times you learned the same things: that SendGrid's sandbox mode silently swallows messages, that you need a suppression list before your first bounce, that the retry logic has to be idempotent because your queue will deliver twice eventually. Both times you wrote it down in a comment nobody read.&lt;/p&gt;

&lt;p&gt;And now you're opening the SendGrid docs again, because copying the old file means copying its bugs, its 2024 API version, and a &lt;code&gt;TODO: handle 429&lt;/code&gt; you never got to.&lt;/p&gt;

&lt;p&gt;That's the tax. Not the hard problems — the &lt;em&gt;solved&lt;/em&gt; problems you keep re-solving at 70% quality because the last solution was welded to the app it was born in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy-paste is a fork, and forks rot
&lt;/h2&gt;

&lt;p&gt;The usual instinct is a shared utils package. That works until it doesn't, and it stops working for a specific reason: shared code that only &lt;em&gt;shares interfaces&lt;/em&gt; still leaves every app owning its own state.&lt;/p&gt;

&lt;p&gt;An email helper that wraps the SDK is nice. But bounce handling isn't a function — it's a table. Suppression lists, delivery status, retry counters, per-recipient backoff. If each app owns its own copy of that state, you don't have one email capability. You have five, and only the one you're currently looking at is correct.&lt;/p&gt;

&lt;p&gt;The line I've landed on: &lt;strong&gt;a capability is a function plus the state it needs to be correct.&lt;/strong&gt; If you extract the function and leave the state behind, you extracted the easy half.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "built once" actually requires
&lt;/h2&gt;

&lt;p&gt;Three properties, and skipping any one of them collapses it back into a utils package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One deployment, not one library.&lt;/strong&gt; The capability runs as a service the apps call. When you fix the 429 handling, every app has the fix — not "every app that bumps the version."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tenancy at the boundary, not in the caller.&lt;/strong&gt; Every call carries which app it's for. The capability owns partitioning. Apps can't get isolation wrong because they never touch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A narrow surface that hides the vendor.&lt;/strong&gt; &lt;code&gt;send(to, template, data)&lt;/code&gt; — not the SendGrid shape. Because you &lt;em&gt;will&lt;/em&gt; migrate providers, and the migration should be one deploy, not five refactors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# The seam that matters:
capability.send(tenant, to, template, data)
  → check suppression (shared table, all tenants)
  → render template (tenant-scoped assets)
  → dispatch via provider adapter
  → record delivery state, emit event

# Apps never see: the provider, the retry policy,
# the suppression table, the backoff curve.
# They see: send(). That's the whole API.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The suppression check being &lt;em&gt;shared&lt;/em&gt; is the part people push back on. If a recipient hard-bounces in App A, should App B still email them? Usually yes — different products, different consent. So the table is shared but tenant-partitioned, and the &lt;em&gt;reputation&lt;/em&gt; signal is global. That distinction has to be a deliberate design decision, not an accident of where you put the WHERE clause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tricky part: versioning without freezing
&lt;/h2&gt;

&lt;p&gt;Here's what bit us. One deployment means one behavior — great, until App A needs the behavior to change and App B depends on the old one.&lt;/p&gt;

&lt;p&gt;The fix that held: &lt;strong&gt;capabilities may add, never change.&lt;/strong&gt; New optional parameters, new fields on responses, new event types. Never a changed default, never a removed field. When a change is genuinely breaking, it's a new capability with a new name, and the old one keeps running until the last caller is off it.&lt;/p&gt;

&lt;p&gt;That sounds expensive. It's cheaper than the alternative, which is every app pinning a version and quietly reconstituting the five-forks problem in a worse form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the boring ones
&lt;/h2&gt;

&lt;p&gt;The instinct is to extract the interesting stuff first. Wrong order. Extract the capabilities where correctness is subtle and the surface is stable: sending, payments, auth, storage. Nobody argues about what &lt;code&gt;send()&lt;/code&gt; should do. Everybody argues about what your domain layer should do — leave that in the apps.&lt;/p&gt;

&lt;p&gt;The test: if you can't name the capability in one verb, it's not a capability yet.&lt;/p&gt;




&lt;p&gt;We built this as &lt;a href="https://apollo.kynth.studio" rel="noopener noreferrer"&gt;Kynth Apollo&lt;/a&gt; — one capability layer, and every app we ship inherits it.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>webdev</category>
      <category>softwareengineering</category>
      <category>backend</category>
    </item>
    <item>
      <title>Booking a slot over the phone without double-booking the truck: the race condition nobody warns you about</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:23:16 +0000</pubDate>
      <link>https://dev.to/kynth/booking-a-slot-over-the-phone-without-double-booking-the-truck-the-race-condition-nobody-warns-you-5423</link>
      <guid>https://dev.to/kynth/booking-a-slot-over-the-phone-without-double-booking-the-truck-the-race-condition-nobody-warns-you-5423</guid>
      <description>&lt;p&gt;A plumber I know keeps his phone face-down on the passenger seat. Not because he doesn't want the work — he &lt;em&gt;is&lt;/em&gt; the work — but because when it rings, he's usually under a sink with both hands wet, or up a ladder, or elbow-deep in a job that pays exactly nothing extra if he stops to answer. So it rings out. And the person on the other end doesn't leave a voicemail. They just call the next name on the list.&lt;/p&gt;

&lt;p&gt;That missed ring is a booked job walking to a competitor. He knows it. He can't do anything about it. That's the dread.&lt;/p&gt;

&lt;p&gt;So we built a thing that answers every call, hears the job, quotes it, and books a crew. Sounds like a chatbot with a phone number. The hard part turned out to be one sentence: &lt;strong&gt;book a crew into the slot without ever double-booking a truck.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a real problem, not a CRUD write
&lt;/h2&gt;

&lt;p&gt;Voice calls are slow and concurrent. A call lasts 90 seconds. During those 90 seconds the caller is deciding, the AI is talking, and — critically — &lt;em&gt;other calls are happening on other lines.&lt;/em&gt; Two callers can both be told "Thursday 2pm is open" because at the moment each was quoted, it &lt;em&gt;was&lt;/em&gt; open. Neither has confirmed yet. Both say yes. Now one truck, two jobs, and a customer who took the afternoon off for nobody.&lt;/p&gt;

&lt;p&gt;The naive version looks fine in every demo and breaks the first busy Monday:&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="c1"&gt;// DON'T: read-then-write with a human-length gap in between&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOpenSlot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crew&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// t=0s, both calls see it open&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;speak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`I've got you Thursday at 2. Sound good?`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...15 seconds of the caller saying "uh, let me check with my wife"...&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                          &lt;span class="c1"&gt;// t=18s, both calls write. collision.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gap between &lt;em&gt;quote&lt;/em&gt; and &lt;em&gt;confirm&lt;/em&gt; is filled with human speech. You cannot hold a transaction open for 18 seconds of "let me check with my wife."&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: hold, don't book
&lt;/h2&gt;

&lt;p&gt;We split the single write into two phases with a short-lived reservation between them. Quoting a slot places a &lt;strong&gt;hold&lt;/strong&gt; with a TTL. Confirming &lt;em&gt;promotes&lt;/em&gt; the hold to a booking, but only if the hold is still yours.&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="c1"&gt;// Phase 1 — at quote time: claim a hold, atomically&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
  INSERT INTO holds (slot_id, call_id, expires_at)
  SELECT $1, $2, now() + interval '90 seconds'
  WHERE NOT EXISTS (
    SELECT 1 FROM holds    WHERE slot_id = $1 AND expires_at &amp;gt; now()
    UNION
    SELECT 1 FROM bookings WHERE slot_id = $1
  )
  RETURNING id;
`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slotId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;offerNextSlot&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// someone holds it — quote a different time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;WHERE NOT EXISTS&lt;/code&gt; runs inside the insert, so the check and the claim are one atomic step. Two concurrent calls race for the same row; the database picks a winner. The loser never even quotes 2pm — it's already moving on to 2:30 before the caller notices a pause.&lt;/p&gt;

&lt;p&gt;Phase 2, at "yes," is a conditional promote: &lt;code&gt;INSERT INTO bookings ... WHERE hold still valid AND owned by this call&lt;/code&gt;. If the caller rambled past the TTL, the hold's gone, the promote fails, and the agent gracefully re-quotes instead of silently overwriting someone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that bit us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTL tuning is a UX knob, not a config value.&lt;/strong&gt; Too short and slow talkers lose their slot mid-sentence; too long and you artificially block a slot while someone hangs up on you. We settled on releasing the hold the instant a call &lt;em&gt;ends&lt;/em&gt;, not just on timeout — a dropped call shouldn't freeze a truck's calendar for 90 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"One truck" isn't one row.&lt;/strong&gt; A crew has drive time. Booking 2pm across town when the 1pm job is 40 minutes away is a double-book in disguise. The hold has to reserve the &lt;em&gt;travel envelope&lt;/em&gt;, not just the appointment box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency on retries.&lt;/strong&gt; Phone networks retry webhooks. The promote had to be safe to run twice — same call_id, same slot, second attempt is a no-op, not a second booking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. It's the boring, correct version of a reservation system — the same trick airlines and ticket sites use — applied to a domain where the "user" is a synthesized voice negotiating in real time with someone who won't stop talking about their water heater.&lt;/p&gt;

&lt;p&gt;That reservation engine is the thing that lets &lt;strong&gt;Kynth Receptionist&lt;/strong&gt; answer the plumber's phone so he doesn't have to leave it face-down anymore.&lt;/p&gt;

&lt;p&gt;Hear it take a call → &lt;a href="https://kynth.studio/l/receptionist" rel="noopener noreferrer"&gt;https://kynth.studio/l/receptionist&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>node</category>
      <category>distributedsystems</category>
      <category>voice</category>
    </item>
    <item>
      <title>Stop sending net-30 reminders: predicting when a client *actually* pays</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Sun, 12 Jul 2026 15:20:56 +0000</pubDate>
      <link>https://dev.to/kynth/stop-sending-net-30-reminders-predicting-when-a-client-actually-pays-5318</link>
      <guid>https://dev.to/kynth/stop-sending-net-30-reminders-predicting-when-a-client-actually-pays-5318</guid>
      <description>&lt;p&gt;Every freelancer knows the invoice that goes into the void.&lt;/p&gt;

&lt;p&gt;You did the work. You sent the PDF. "Net 30" in the footer like it means something. Day 31 comes and goes. Now there's $4,250 sitting in someone else's account, and the only way to get it is to write the email you least want to write — the one where you chase your own money and try not to sound desperate. So you don't. You tell yourself you'll do it Monday. Monday you find something else to do.&lt;/p&gt;

&lt;p&gt;The tedious part isn't writing one reminder. It's that the &lt;em&gt;right&lt;/em&gt; moment to send it is different for every client, and you have no idea what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Net-30 is a lie your accounting software tells you
&lt;/h2&gt;

&lt;p&gt;Here's the thing I learned staring at a spreadsheet of paid invoices: the due date has almost nothing to do with when people pay.&lt;/p&gt;

&lt;p&gt;One client pays like clockwork — 3 days before due, every time. Another pays 12 days late but &lt;em&gt;always&lt;/em&gt; pays, and a reminder before day 12 just annoys them. A third only pays when you nudge, and the nudge has a 4-day lag. If you send everyone the same "friendly reminder on day 31," you're early for some, late for others, and wrong for most.&lt;/p&gt;

&lt;p&gt;The interesting problem isn't &lt;em&gt;writing&lt;/em&gt; reminders. It's &lt;strong&gt;estimating each client's personal payment distribution&lt;/strong&gt; and firing at the point where a nudge actually moves the date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the client, not the invoice
&lt;/h2&gt;

&lt;p&gt;Every client is a tiny time-series. For each one, collect the gap between &lt;em&gt;issue date&lt;/em&gt; and &lt;em&gt;paid date&lt;/em&gt; across their history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# days-to-pay samples for one client
&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# 45 was the holiday one
&lt;/span&gt;
&lt;span class="c1"&gt;# robust center + spread (median beats mean — one late pay skews it)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;
&lt;span class="n"&gt;median&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# 30.5
&lt;/span&gt;&lt;span class="n"&gt;mad&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;median&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# 1.5
&lt;/span&gt;
&lt;span class="c1"&gt;# the window where a nudge is worth sending
&lt;/span&gt;&lt;span class="n"&gt;nudge_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;median&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mf"&gt;1.4826&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mad&lt;/span&gt;   &lt;span class="c1"&gt;# ~26 days
&lt;/span&gt;&lt;span class="n"&gt;nudge_end&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;median&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mf"&gt;1.4826&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mad&lt;/span&gt;   &lt;span class="c1"&gt;# ~35 days
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MAD (median absolute deviation) instead of standard deviation matters more than it looks: one holiday-delayed payment shouldn't widen the whole window. You want the &lt;em&gt;typical&lt;/em&gt; rhythm, not the worst case.&lt;/p&gt;

&lt;p&gt;The signal you're really after is: &lt;strong&gt;is this invoice tracking behind this client's own baseline?&lt;/strong&gt; Day 30 means nothing in the abstract. Day 30 for someone who always pays by day 27 is a real anomaly worth a nudge. Day 30 for the reliable-day-40 client is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cold start: you have no history
&lt;/h2&gt;

&lt;p&gt;New client, zero samples. You can't model a distribution from nothing, so you fall back to a prior — a payment-terms segment (industry, invoice size, agency vs. direct) — and update toward the client's real behavior as payments land. Two invoices in, the prior barely matters. It's just enough to not send a tone-deaf reminder on day one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;estimate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;prior&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;client_median&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# k = prior strength (~2), n = number of real samples
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part everyone gets wrong: the words
&lt;/h2&gt;

&lt;p&gt;A perfectly-timed reminder that reads like a debt collector still costs you the relationship. The timing model tells you &lt;em&gt;when&lt;/em&gt;; the harder half is &lt;em&gt;what&lt;/em&gt;, and it has to sound like you — same greeting, same sign-off, same amount of "hey, no rush but."&lt;/p&gt;

&lt;p&gt;The trick we landed on: don't generate from scratch. Extract a voice profile from the user's &lt;em&gt;already-sent&lt;/em&gt; emails (greeting style, formality, whether they use the client's first name, how they hedge), then condition the draft on both the profile and the situation — 4 days behind baseline reads different from 20 days behind. Early: light touch. Overdue past the whole distribution: warmer, firmer, and it offers a path (payment link, "want me to split this?").&lt;/p&gt;

&lt;p&gt;And critically — it &lt;em&gt;drafts&lt;/em&gt;. It never sends on its own. You still hit approve. The AI removes the dread of the blank compose window and the "is now the right time" second-guessing; you keep the final say on your own client relationship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;Once the moment is computed per client and the words show up pre-written in my voice, the invoice stops being a thing I avoid. There's no Monday-that-never-comes. The reminder is just &lt;em&gt;there&lt;/em&gt;, correct, waiting for one click.&lt;/p&gt;

&lt;p&gt;That's what we built into &lt;strong&gt;Kynth Invoices&lt;/strong&gt; — it watches every invoice, learns each client's rhythm, and drafts the nudge at the moment that client actually pays.&lt;/p&gt;

&lt;p&gt;Try it → &lt;a href="https://kynth.studio/l/invoices" rel="noopener noreferrer"&gt;https://kynth.studio/l/invoices&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>freelance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dashboards Don't Get Opened: Building a Weekly Lapse-Risk Pipeline That Pushes Work Instead of Waiting for Logins</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Thu, 09 Jul 2026 13:43:21 +0000</pubDate>
      <link>https://dev.to/kynth/dashboards-dont-get-opened-building-a-weekly-lapse-risk-pipeline-that-pushes-work-instead-of-12g7</link>
      <guid>https://dev.to/kynth/dashboards-dont-get-opened-building-a-weekly-lapse-risk-pipeline-that-pushes-work-instead-of-12g7</guid>
      <description>&lt;p&gt;TAGS: ai, automation, llm, python&lt;/p&gt;

&lt;p&gt;There's a specific kind of dread in every independent insurance agency, and if you've ever built software for one, you've seen it: the renewal list. Somebody — usually the owner, at 9pm, after actual work — is supposed to pull every policy renewing in the next 60 days, cross-reference which clients haven't been contacted, and start making calls. It's tedious, it's never urgent &lt;em&gt;today&lt;/em&gt;, and so it slides. Then a slow month happens, nobody works the book for three weeks, and four policies quietly lapse. No alarm goes off. The revenue just isn't there anymore, and nobody can point to the day it left.&lt;/p&gt;

&lt;p&gt;We built a system to end that, and the interesting engineering lesson wasn't the AI part. It was this: &lt;strong&gt;the failure mode was attention, not information.&lt;/strong&gt; The data was always sitting in the AMS. A dashboard showing it would have changed nothing, because dashboards require someone to log in, and "nobody logged in for three weeks" was the whole problem.&lt;/p&gt;

&lt;p&gt;So we inverted the model: instead of a dashboard that waits, a pipeline that runs weekly and pushes a finished work product — a ranked risk list plus drafted outreach — into the agent's inbox.&lt;/p&gt;

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

&lt;p&gt;Three stages, run on a cron:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingest &amp;amp; normalize&lt;/strong&gt; renewal data (this is 70% of the work, see below)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score&lt;/strong&gt; each policy's lapse risk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draft&lt;/strong&gt; outreach for the top of the list, human-reviewed before send
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;weekly_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;policies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetch_renewals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;horizon_days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;scored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;score_lapse_risk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;actionable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scored&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;THRESHOLD&lt;/span&gt;
                  &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;recently_contacted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;drafts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;draft_outreach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;actionable&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;MAX_WEEKLY&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="nf"&gt;deliver_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;drafts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# push, don't wait for a login
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The tricky parts nobody warns you about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Renewal dates are lies.&lt;/strong&gt; Effective dates, expiration dates, and actual renewal-processing dates disagree constantly across carriers. Some carriers auto-renew unless cancelled; others lapse silently at expiration. Your scoring function has to model &lt;em&gt;what happens if nobody acts&lt;/em&gt; per carrier, not just "days until date X." We ended up with a per-carrier default-behavior table before any ML mattered at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risk scoring can be embarrassingly simple.&lt;/strong&gt; We started sketching a model and then realized a weighted heuristic — days to renewal, premium change at renewal (rate increases drive shopping), contact recency, payment-history flags — caught nearly everything an experienced agent would flag. Ship the heuristic first. The fancy model is a v3 problem; the cron job is the product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LLM's job is drafting, not deciding.&lt;/strong&gt; We use the model to write the outreach email/text: it gets the client's first name, policy type, renewal date, and the premium delta, and produces a short, specific message ("your auto policy renews March 12 and the premium is going up $31/mo — want 10 minutes to review options?"). Two hard rules we learned to enforce in the harness, not the prompt: the model &lt;strong&gt;never sees or invents numbers&lt;/strong&gt; — premium figures are injected into the template post-generation from source data — and every draft lands in a review queue. In insurance, a hallucinated dollar amount isn't a quality bug, it's an E&amp;amp;O claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency beats cleverness.&lt;/strong&gt; A weekly job that occasionally double-runs will nag the same client twice and torch trust instantly. Every (policy, renewal_cycle) pair gets a deterministic key; outreach is recorded against it; the pipeline checks before drafting. Boring, essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The agent's Monday inbox now contains the ten policies most likely to walk, each with a ready-to-edit message. The work that used to require someone remembering to do it now requires someone to &lt;em&gt;stop&lt;/em&gt; it — and defaults are destiny.&lt;/p&gt;

&lt;p&gt;If you're building for any business with a renewal-shaped revenue stream (SaaS, memberships, contracts), the pattern transfers whole: normalize the dates, score with a heuristic, let the LLM draft but never decide, and push the output instead of hosting it.&lt;/p&gt;

&lt;p&gt;This is how we built &lt;a href="https://kynth.studio/l/policy-renewals" rel="noopener noreferrer"&gt;Kynth Policy Renewals&lt;/a&gt; — it runs an agency's renewal pipeline every week so the book stops lapsing quietly.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>Any document in. Clean JSON out. — how I turned my apps' AI layer into a 39-endpoint API</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Wed, 08 Jul 2026 13:26:48 +0000</pubDate>
      <link>https://dev.to/kynth/any-document-in-clean-json-out-how-i-turned-my-apps-ai-layer-into-a-39-endpoint-api-40pc</link>
      <guid>https://dev.to/kynth/any-document-in-clean-json-out-how-i-turned-my-apps-ai-layer-into-a-39-endpoint-api-40pc</guid>
      <description>&lt;p&gt;I run a suite of AI products, and every one of them kept re-implementing the same jobs: parse an invoice, normalize a bank statement, redact PII before it hits a log, fight a chargeback. Prompt-level extraction fails silently — the JSON comes back malformed or subtly wrong just often enough that every app grew its own retry/validation/normalization layer.&lt;/p&gt;

&lt;p&gt;So I pulled that whole layer out into one API. It's called &lt;strong&gt;Kynth Core&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spearhead: documents
&lt;/h2&gt;

&lt;p&gt;Nine document engines, each committed to &lt;strong&gt;one document kind&lt;/strong&gt; — that commitment is what makes the output schema-valid and field-accurate instead of "usually fine":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invoice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Invoice → vendor, dates, PO refs, tax, line items&lt;/td&gt;
&lt;td&gt;$0.08/invoice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;receipt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Receipt → merchant, items, totals, expense category&lt;/td&gt;
&lt;td&gt;$0.06/receipt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;statement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bank/card statement → every transaction, normalized&lt;/td&gt;
&lt;td&gt;$0.12/statement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resume&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resume → structured candidate profile&lt;/td&gt;
&lt;td&gt;$0.08/resume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tables&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any document → every table as clean headers + rows&lt;/td&gt;
&lt;td&gt;$0.08/document&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;split&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Multi-doc scan bundle → classified, split documents&lt;/td&gt;
&lt;td&gt;$0.10/bundle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compare&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Two contract versions → material changes + risk&lt;/td&gt;
&lt;td&gt;$0.15/comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contract&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Contract → parties, term, renewal, obligations, risks&lt;/td&gt;
&lt;td&gt;$0.12/contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;parse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anything else → structured, validated JSON&lt;/td&gt;
&lt;td&gt;$0.10/document&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Flat price per document — a 9-page statement costs the same as a 1-pager. And the accuracy isn't a vibe: &lt;strong&gt;every endpoint's field-level score is public at &lt;a href="https://api.kynth.studio/benchmarks" rel="noopener noreferrer"&gt;api.kynth.studio/benchmarks&lt;/a&gt;&lt;/strong&gt;, run against the live production API and re-run on every prompt or model-routing change.&lt;/p&gt;

&lt;h2&gt;
  
  
  …and 30 more jobs on the same wallet
&lt;/h2&gt;

&lt;p&gt;The catalog behind the spearhead, browsed at &lt;a href="https://api.kynth.studio/endpoints" rel="noopener noreferrer"&gt;/endpoints&lt;/a&gt;: guaranteed-schema extraction (&lt;code&gt;structure&lt;/code&gt; — your JSON Schema in, conforming output out, retries handled), entity matching, batch categorization, ticket triage, meeting minutes, reply drafting, collections sequences, 3-way PO matching, fraud triage, moderation &lt;strong&gt;against your own policy&lt;/strong&gt;, brand-voice rewriting, a cited web-research brief, lead screening, agent memory (semantic store/search, no vector DB to run), transcription, image generation, and TTS.&lt;/p&gt;

&lt;p&gt;Every endpoint is a finished job with its own page and its own per-task price — never a prompt you have to engineer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design decisions I actually care about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Priced per task, not per token.&lt;/strong&gt; You know what a document costs before you send it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're only charged on success.&lt;/strong&gt; The charge happens &lt;em&gt;after&lt;/em&gt; a valid result, in the same row-locked transaction that writes the usage ledger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model routing is internal.&lt;/strong&gt; Each task runs on the cheapest model that's good enough (Gemini / Claude / GPT, chosen per job, re-tuned as models improve). You never configure it; you just get the price on the tin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;500 free credits every month, no card.&lt;/strong&gt; Enough to ship a real integration before you pay anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to call it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.kynth.studio/v1/invoice &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer ksk_live_…"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ "fileUrl": "https://…/invoice.pdf" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TypeScript:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;KynthCore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@kynth/api&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;kynth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;KynthCore&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KYNTH_API_KEY&lt;/span&gt;&lt;span class="o"&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;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;kynth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;fileUrl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;balanceRemaining&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Python:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;kynth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Kynth&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Kynth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ksk_live_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://.../invoice.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;And an MCP server&lt;/strong&gt;, so Claude or Cursor call all 39 endpoints as native tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add kynth-core &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;KYNTH_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ksk_live_… &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @kynth/api-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDKs and MCP server are generated from the same catalog the OpenAPI spec is built from, so they can't drift from the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Home:&lt;/strong&gt; &lt;a href="https://api.kynth.studio" rel="noopener noreferrer"&gt;https://api.kynth.studio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All 39 endpoints:&lt;/strong&gt; &lt;a href="https://api.kynth.studio/endpoints" rel="noopener noreferrer"&gt;https://api.kynth.studio/endpoints&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmarks:&lt;/strong&gt; &lt;a href="https://api.kynth.studio/benchmarks" rel="noopener noreferrer"&gt;https://api.kynth.studio/benchmarks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interactive reference:&lt;/strong&gt; &lt;a href="https://api.kynth.studio/reference" rel="noopener noreferrer"&gt;https://api.kynth.studio/reference&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAPI spec:&lt;/strong&gt; &lt;a href="https://api.kynth.studio/openapi.json" rel="noopener noreferrer"&gt;https://api.kynth.studio/openapi.json&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd genuinely like feedback on the endpoint set — which of these you'd actually reach for over rolling your own against a raw model, and what's missing. Reply here or poke at the API.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Modeling Prior Authorization as a Payer-Policy Matching Problem</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Mon, 06 Jul 2026 16:03:02 +0000</pubDate>
      <link>https://dev.to/kynth/modeling-prior-authorization-as-a-payer-policy-matching-problem-14o3</link>
      <guid>https://dev.to/kynth/modeling-prior-authorization-as-a-payer-policy-matching-problem-14o3</guid>
      <description>&lt;p&gt;It's 4:47 PM. There are nine prior authorizations in the queue and the person working them has been at it since lunch. Each one is the same ritual: open the chart, hunt for the diagnosis codes, cross-reference the payer's coverage policy PDF (the one that changed last quarter and nobody told you), copy the ICD-10 that justifies the CPT, paste it into a portal that logs you out every twenty minutes, and hit submit knowing there's maybe a 70% chance it comes back denied for a reason you could have caught if you'd had time to read all eleven pages of the policy.&lt;/p&gt;

&lt;p&gt;Then it comes back denied. And now it's an appeal, which is the whole thing again but angrier.&lt;/p&gt;

&lt;p&gt;If you've built anything near a medical practice, you know this queue never empties. It just ages. And every aged auth is revenue sitting in limbo. I spent the last year building software to work this pile, and the interesting part wasn't the AI — it was realizing prior auth isn't a &lt;em&gt;writing&lt;/em&gt; problem. It's a &lt;strong&gt;matching&lt;/strong&gt; problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pile is a matching problem, not a writing problem
&lt;/h2&gt;

&lt;p&gt;The naive build is: dump the chart into an LLM, say "write a medical necessity letter," ship it. It produces confident, fluent prose. It also gets denied, because a payer doesn't grade prose. A payer grades whether specific, enumerated criteria in &lt;em&gt;their&lt;/em&gt; coverage policy are each backed by &lt;em&gt;something in the chart&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So the real unit of work isn't a letter. It's a set of (criterion → evidence) pairs. Aetna's policy for a given procedure might require: failed conservative therapy for ≥6 weeks, documented functional impairment, imaging confirming the indication. UnitedHealthcare wants a different three things. The job is to bind each criterion to a concrete chart citation — and to notice, &lt;em&gt;before submitting&lt;/em&gt;, when a criterion has no evidence behind it. That empty binding is your denial risk, and you can see it coming.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assemble_justification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payer_policy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;bindings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;criterion&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payer_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;required_criteria&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;retrieve_evidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;criterion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# dated chart citations
&lt;/span&gt;        &lt;span class="n"&gt;bindings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;criterion&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;criterion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;satisfied&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;unmet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bindings&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;satisfied&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="n"&gt;denial_risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unmet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bindings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the number you want BEFORE submit
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Justification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bindings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bindings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;risk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;denial_risk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gaps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;unmet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;gaps&lt;/code&gt; list is the whole product, honestly. It turns a blind submission into "these two criteria are unmet — go get the PT notes before you send this."&lt;/p&gt;

&lt;h2&gt;
  
  
  Grounding, because hallucinated evidence is malpractice
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;retrieve_evidence&lt;/code&gt; is where every shortcut bites you. You cannot let the model &lt;em&gt;say&lt;/em&gt; the conservative therapy happened. It has to &lt;em&gt;cite&lt;/em&gt; the note that says it, with a date. We treat any binding whose evidence isn't traceable to a real chart span as unsatisfied — a false "satisfied" is far more dangerous than a false gap. A gap gets flagged for a human; a fabricated citation gets you denied, or worse.&lt;/p&gt;

&lt;p&gt;The other subtle bit: coverage policies drift. The criteria set has to be re-parsed from the live policy, not memorized, or you'll assemble a beautiful justification against last year's rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The denial is structured too
&lt;/h2&gt;

&lt;p&gt;When a denial comes back, resist the urge to "write an appeal." A denial cites specific reasons — usually a criterion the payer claims wasn't met. So the appeal is just the inverse operation: take each denial reason, find the criterion it maps to, and rebut it with the evidence binding you already built. Point by point, in the payer's own vocabulary. Half the time the evidence &lt;em&gt;was&lt;/em&gt; there and the reviewer missed it, and a targeted rebuttal is far stronger than a fresh persuasive essay.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;The lesson that generalizes beyond healthcare: when an LLM task feels like "generate a document," check whether it's secretly a &lt;em&gt;matching&lt;/em&gt; task with an audit trail. Model the criteria, bind them to evidence, expose the gaps, and let the fluent-prose part be the last, cheapest step. The pile still exists — but now something works it down instead of just aging it.&lt;/p&gt;

&lt;p&gt;We packaged this as &lt;strong&gt;Authorize&lt;/strong&gt; — &lt;a href="https://kynth.studio/l/authorize" rel="noopener noreferrer"&gt;try it free&lt;/a&gt;. It's how we built the thing that finally empties the queue.&lt;/p&gt;

</description>
      <category>healthcare</category>
      <category>ai</category>
      <category>automation</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Predicting When a Client Will Actually Pay: Modeling Invoice Timing With an AI Agent</title>
      <dc:creator>Kynth</dc:creator>
      <pubDate>Mon, 06 Jul 2026 00:38:49 +0000</pubDate>
      <link>https://dev.to/kynth/predicting-when-a-client-will-actually-pay-modeling-invoice-timing-with-an-ai-agent-2cjo</link>
      <guid>https://dev.to/kynth/predicting-when-a-client-will-actually-pay-modeling-invoice-timing-with-an-ai-agent-2cjo</guid>
      <description>&lt;p&gt;The single hardest thing about getting paid isn't writing the invoice. It's the follow-up — knowing &lt;em&gt;when&lt;/em&gt; to nudge a quiet client, and doing it in a tone that doesn't torch the relationship. Most tools solve this with a dumb cron job: "send a reminder 7 days after the due date." That's wrong for almost everyone, and here's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with fixed reminder schedules
&lt;/h2&gt;

&lt;p&gt;Payment behavior isn't uniform. One client pays like clockwork on day 32 of a "net 30" invoice — not late, just &lt;em&gt;their&lt;/em&gt; rhythm. Another pays on day 5 but only if you remind them on day 3. A blanket "day 7 past due" reminder annoys the first client (who was always going to pay) and misses the second (who needed the poke earlier).&lt;/p&gt;

&lt;p&gt;So the real problem is &lt;strong&gt;per-client timing prediction&lt;/strong&gt;, not scheduling. You want to model each client's payment distribution and act at the point where a reminder has the highest marginal effect — the moment they're most likely to convert intent into a transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modeling payment rhythm as a per-client distribution
&lt;/h2&gt;

&lt;p&gt;Every invoice gives you a labeled data point: &lt;code&gt;(sent_date, due_date, paid_date, amount, was_reminded)&lt;/code&gt;. Over time, per client, that's a distribution of "days from send to pay." The naive move is to average it. Don't — averages hide the shape, and the shape is the whole signal.&lt;/p&gt;

&lt;p&gt;We model each client's pay-day as a distribution and track two things that matter more than the mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dispersion&lt;/strong&gt; — a tight distribution (always day 30–32) means a reminder before day 30 is noise. A wide one means the client is reminder-sensitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reminder lift&lt;/strong&gt; — comparing paid-day distributions with and without a nudge tells you whether reminders actually move this client, and by how much.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;hist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;paid_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# list of days-to-pay
&lt;/span&gt;    &lt;span class="n"&gt;p50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p90&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quantiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;lift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days_without_reminder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days_with_reminder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# act just before the client's own habitual pay point,
&lt;/span&gt;    &lt;span class="c1"&gt;# but only if a nudge historically helps them
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lift&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MIN_LIFT_DAYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;send_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;due_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p50&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;REMINDER_LEAD&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;send_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;due_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p90&lt;/span&gt;     &lt;span class="c1"&gt;# let reliable-but-slow payers be
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;else&lt;/code&gt; branch is the one people skip, and it's the most important. A client who reliably pays on day 45 doesn't need three reminders — they need zero until day 44. Suppressing unnecessary nudges is as valuable as sending well-timed ones, because every needless reminder trains the client to ignore you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cold start: the first invoice has no history
&lt;/h2&gt;

&lt;p&gt;With no per-client data, you fall back to a &lt;strong&gt;hierarchical prior&lt;/strong&gt;: start from the population distribution (or a segment — agencies pay differently than startups), then let each new payment pull that client's estimate toward their own behavior. Bayesian updating does this cleanly. After two or three invoices, the client-specific signal dominates the prior. Before that, you're at least not guessing blind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drafting in the user's voice — the calibration problem
&lt;/h2&gt;

&lt;p&gt;Timing gets the money moving; tone keeps the client. The reminder has to sound like &lt;em&gt;you&lt;/em&gt;, not like a collections agency. We feed an LLM a few of the user's real past messages as style exemplars, plus structured context (days overdue, prior reminders sent, relationship length) so the model can calibrate escalation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First nudge on a reliable client → light, assume-good-faith.&lt;/li&gt;
&lt;li&gt;Third nudge, 30 days over → firmer, still professional.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trick is that escalation is a &lt;em&gt;function of the client's own history&lt;/em&gt;, not a global template. Same overdue count, different tone, depending on whether this client has ghosted before. Pass that history into the prompt and the model calibrates it far better than any if/else ladder you'd hand-write.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes it an agent, not a script
&lt;/h2&gt;

&lt;p&gt;It closes the loop. Every paid invoice updates the distribution, re-estimates lift, and reschedules the &lt;em&gt;next&lt;/em&gt; client's reminders. It decides not just what to send but whether to send at all — and the "send nothing" decision is a first-class action. That feedback loop is the difference between automation and an agent that gets better at your specific book of clients over time.&lt;/p&gt;




&lt;p&gt;We built this as &lt;strong&gt;Tally&lt;/strong&gt; — it watches every invoice, learns each client's rhythm, and drafts reminders in your voice at the moment they actually pay. Try it free → &lt;a href="https://kynth.studio/l/tally" rel="noopener noreferrer"&gt;https://kynth.studio/l/tally&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>saas</category>
      <category>freelancing</category>
    </item>
  </channel>
</rss>
