<?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: LLMGraph</title>
    <description>The latest articles on DEV Community by LLMGraph (@llmgraph).</description>
    <link>https://dev.to/llmgraph</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%2F4012779%2Faf0e8457-5711-42f0-9b92-7a5482a7e50c.png</url>
      <title>DEV Community: LLMGraph</title>
      <link>https://dev.to/llmgraph</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/llmgraph"/>
    <language>en</language>
    <item>
      <title>JSM Assets has no bulk export. Here is the AQL workaround and a one-click option</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Wed, 02 Sep 2026 22:26:04 +0000</pubDate>
      <link>https://dev.to/llmgraph/jsm-assets-has-no-bulk-export-here-is-the-aql-workaround-and-a-one-click-option-5h15</link>
      <guid>https://dev.to/llmgraph/jsm-assets-has-no-bulk-export-here-is-the-aql-workaround-and-a-one-click-option-5h15</guid>
      <description>&lt;p&gt;Sooner or later every Assets (formerly Insight) admin needs the whole CMDB out: an auditor wants the asset register, a migration needs a baseline, or someone sensible wants a backup before a big cleanup. Then you hit the gap: Jira Service Management Assets has no built-in bulk export of an object schema. The feature request has been open for years with hundreds of votes, and the accepted workaround is exporting object type by object type, by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the native path hurts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The UI exports &lt;strong&gt;one object type at a time&lt;/strong&gt;; a real schema has dozens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attributes vary per type&lt;/strong&gt;, so naive concatenation gives misaligned columns&lt;/li&gt;
&lt;li&gt;Assets reads are &lt;strong&gt;gated behind schema roles&lt;/strong&gt;, separate from Jira permissions, which surprises every script author on their first 403&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The DIY approach: page the Assets REST API with AQL
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;List schemas and object types via the Assets workspace REST API&lt;/li&gt;
&lt;li&gt;For each object type, page through objects with an AQL query (&lt;code&gt;objectType = "..."&lt;/code&gt;), requesting attributes with each page&lt;/li&gt;
&lt;li&gt;Normalize per-type attribute columns before writing CSV, or emit JSON and defer the flattening&lt;/li&gt;
&lt;li&gt;Handle pagination limits, rate limits, and the schema-role grant for the account the script runs as&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It works. It also becomes another script that has to live somewhere, keep an API token somewhere safer, and survive the next person asking for the same export six months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The packaged version
&lt;/h2&gt;

&lt;p&gt;We productized this as &lt;a href="https://marketplace.atlassian.com/apps/3829199441/assets-export-manager-for-jira-service-management" rel="noopener noreferrer"&gt;Assets Export Manager for Jira Service Management&lt;/a&gt; (disclosure: I am the author). Pick a schema, pick CSV or JSON, and it exports every object with all attributes in one run. It is a Forge app: the export is generated on Atlassian's platform inside your tenant and streamed to your browser, so nothing leaves your instance. Read-only, and honest about limits: very large schemas are truncated with a clear flag rather than silently cut.&lt;/p&gt;

&lt;p&gt;One setup note, stated upfront because it is the number one support question shape for any Assets integration: after install, grant the app read access on each schema's Roles tab. Assets gates reads there, not in Jira permissions. Then it just works.&lt;/p&gt;

&lt;p&gt;Free for up to 10 users. If your export workflow needs something this does not cover, tell us and we will build it.&lt;/p&gt;

</description>
      <category>jira</category>
      <category>atlassian</category>
      <category>itsm</category>
      <category>devops</category>
    </item>
    <item>
      <title>Atlassian gives you a Python script to find group usage in Jira. Here is what it misses</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Wed, 02 Sep 2026 22:25:33 +0000</pubDate>
      <link>https://dev.to/llmgraph/atlassian-gives-you-a-python-script-to-find-group-usage-in-jira-here-is-what-it-misses-b6n</link>
      <guid>https://dev.to/llmgraph/atlassian-gives-you-a-python-script-to-find-group-usage-in-jira-here-is-what-it-misses-b6n</guid>
      <description>&lt;p&gt;Ask any Jira admin who has tried to delete a group: before you remove it, you need to know every place it is used, or something breaks quietly three weeks later. The native feature request for a group-usage view has over a thousand votes and remains open. Atlassian's current answer is a support KB article with a Python script you run yourself, and the article itself tells you two important things: the script is beyond Atlassian Support's scope, and permission schemes are not the only place groups are used.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the KB script does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Calls &lt;code&gt;GET /rest/api/3/permissionscheme?expand=permissions&lt;/code&gt; for every scheme and its grants&lt;/li&gt;
&lt;li&gt;Loops the grants looking for your group name&lt;/li&gt;
&lt;li&gt;Prints the scheme names that reference it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Useful for a one-off check. But the output is scheme names, not answers: it does not tell you which projects use those schemes, which permissions the group holds there, or who inherits all of it through membership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where groups hide beyond permission schemes
&lt;/h2&gt;

&lt;p&gt;The KB's own caveat is the important part. A group can also sit in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project roles&lt;/strong&gt;, per project, plus the default role actors for new projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notification schemes&lt;/strong&gt; (delete the group, silently stop emails)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Issue security levels&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter sharing and edit grants&lt;/strong&gt;, &lt;code&gt;membersOf()&lt;/code&gt; in filter JQL, filter subscriptions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dashboard sharing&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product access&lt;/strong&gt; (application roles), which may be granting Jira seats&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group-picker custom fields&lt;/strong&gt;, workflow conditions and validators, automation rules, board permissions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global permissions&lt;/strong&gt;, where Jira Cloud has no public API that returns grantees at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One admin on the feature-request thread reported his home-grown crawler makes roughly two thousand REST requests per run. It works, until the author leaves or the security review asks where that script runs and what token it holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extending the DIY version honestly
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Resolve schemes to projects with &lt;code&gt;GET /rest/api/3/project/{id}/permissionscheme&lt;/code&gt; so grants map to where they apply&lt;/li&gt;
&lt;li&gt;Walk &lt;code&gt;GET /rest/api/3/project/{id}/role&lt;/code&gt; and each role's actors; the scheme scan misses role grants entirely&lt;/li&gt;
&lt;li&gt;Add per-surface passes (notification schemes, issue security, filters, dashboards), each with its own pagination&lt;/li&gt;
&lt;li&gt;Record a completeness note per surface, because some (global permissions above all) cannot be enumerated, and an audit that silently omits them is worse than one that says so&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The packaged version
&lt;/h2&gt;

&lt;p&gt;We productized this pattern as &lt;a href="https://marketplace.atlassian.com/apps/3162504883/access-governance-reporter" rel="noopener noreferrer"&gt;Access Governance Reporter&lt;/a&gt; (disclosure: I am the author). It crawls schemes with grants, resolves them to projects, and walks every role and its actors, then answers both directions: per group, every project and permission it grants and whether it comes from a scheme or a role; per user, the full effective-access view with the exact group or role behind each grant. Summary-first dashboard that loads instantly at any site size, server-side search, one-click audit-ready CSV. It is a Forge app: everything runs on Atlassian's platform inside your tenant, and nothing leaves your instance. Free for up to 10 users.&lt;/p&gt;

&lt;p&gt;If your access-review workflow needs a surface we do not cover yet, tell us and we will build it.&lt;/p&gt;

</description>
      <category>jira</category>
      <category>atlassian</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Azure will not warn you before App Gateway and App Service TLS certs expire. Here is how to fix that.</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Mon, 31 Aug 2026 22:09:42 +0000</pubDate>
      <link>https://dev.to/llmgraph/azure-will-not-warn-you-before-app-gateway-and-app-service-tls-certs-expire-here-is-how-to-fix-3k1g</link>
      <guid>https://dev.to/llmgraph/azure-will-not-warn-you-before-app-gateway-and-app-service-tls-certs-expire-here-is-how-to-fix-3k1g</guid>
      <description>&lt;p&gt;An expired TLS certificate is one of the dumbest ways to take production down, because the outage date was printed inside the certificate the whole time. And yet, if that certificate lives in an &lt;strong&gt;Azure Application Gateway listener&lt;/strong&gt;, an &lt;strong&gt;App Service&lt;/strong&gt;, or an &lt;strong&gt;API Management&lt;/strong&gt; instance, Azure will not say a word before it lapses.&lt;/p&gt;

&lt;p&gt;That surprises people, because Azure does alert on certificates in some places. The catch is &lt;em&gt;which&lt;/em&gt; places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key Vault&lt;/strong&gt; certificates get near-expiry Event Grid events. Covered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Front Door / CDN managed&lt;/strong&gt; certificates are auto-rotated by Azure. Covered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Gateway bring-your-own listener certs&lt;/strong&gt; (&lt;code&gt;sslCertificates&lt;/code&gt; on the gateway): nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Gateway backend trust roots&lt;/strong&gt; (&lt;code&gt;trustedRootCertificates&lt;/code&gt;, used for end-to-end TLS): nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Service uploaded certs&lt;/strong&gt; (&lt;code&gt;Microsoft.Web/certificates&lt;/code&gt;): the resource exposes &lt;code&gt;expirationDate&lt;/code&gt;, but nothing watches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Management certs&lt;/strong&gt;: same story, &lt;code&gt;expirationDate&lt;/code&gt; exists, no one is looking at it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncovered stores are exactly the ones where somebody uploaded a cert by hand two years ago and moved on. That is where the 2 a.m. outages come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DIY sweep
&lt;/h2&gt;

&lt;p&gt;The shape of the fix is simple: a scheduled Function with a subscription-scope &lt;strong&gt;Reader&lt;/strong&gt; role that enumerates every un-alerted certificate store, reads expiry dates, and posts a digest to Teams.&lt;/p&gt;

&lt;p&gt;App Service and APIM are the easy part, since they hand you &lt;code&gt;expirationDate&lt;/code&gt; as a plain date. App Gateway is where it gets fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: &lt;code&gt;publicCertData&lt;/code&gt; is not what the docs say
&lt;/h2&gt;

&lt;p&gt;For each App Gateway, the ARM GET returns the uploaded listener certs with a base64 &lt;code&gt;publicCertData&lt;/code&gt; field. ARM documents it as "Base-64 encoded Public cert data corresponding to pfx". It is not. It is a &lt;strong&gt;PKCS7 (P7B) bundle&lt;/strong&gt;, leaf plus chain, not a bare X.509 certificate. Feed it straight to an X.509 parser and it fails.&lt;/p&gt;

&lt;p&gt;So the decode has to be: try PKCS7 first, pick the &lt;strong&gt;leaf&lt;/strong&gt; (end-entity) cert out of the bundle, and read its &lt;code&gt;notAfter&lt;/code&gt;. Then fall back to parsing a single DER/PEM cert for blobs that are not bundles (the backend trust roots often are single certs). In Node, &lt;code&gt;node-forge&lt;/code&gt; handles the PKCS7 case, and the built-in &lt;code&gt;node:crypto&lt;/code&gt; &lt;code&gt;X509Certificate&lt;/code&gt; covers the single-cert fallback.&lt;/p&gt;

&lt;p&gt;Two more things worth building in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Recurring escalation.&lt;/strong&gt; A one-shot alert 30 days out gets lost. Classify against thresholds (30, 14, 7, 1 days works well) and re-report every expiring cert daily until it is actually renewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "could not read expiry" bucket.&lt;/strong&gt; A cert blob that fails to decode is itself a signal. Surface it instead of swallowing the parse error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And keep it least-privilege: everything above needs only certificate metadata and public cert data through the management plane. Private keys never enter the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not maintain it
&lt;/h2&gt;

&lt;p&gt;Disclosure: I work for Katabarwa Labs, and we ship one app that does exactly this, &lt;strong&gt;Cert Sentinel&lt;/strong&gt;. It is one option next to building the sweep yourself, not the only way.&lt;/p&gt;

&lt;p&gt;It deploys into your own subscription as a managed application (no vendor backend, nothing leaves your tenant), sweeps App Gateway listener and backend certs, App Service certs, and APIM certs every day at 08:00 UTC, and posts one Teams digest of everything expired, expiring, or unreadable, re-reported daily until renewed. It needs only the Reader role, and it deliberately skips Key Vault and Front Door / CDN managed certs because Azure already covers those.&lt;/p&gt;

&lt;p&gt;Listing: &lt;a href="https://marketplace.microsoft.com/en-us/product/azure-application/katabarwalabs.cert-sentinel" rel="noopener noreferrer"&gt;https://marketplace.microsoft.com/en-us/product/azure-application/katabarwalabs.cert-sentinel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wrote up the longer version, including the full DIY pattern, here: &lt;a href="https://katabarwalabs.dev/blog/azure-tls-certificate-expiry-alerts" rel="noopener noreferrer"&gt;https://katabarwalabs.dev/blog/azure-tls-certificate-expiry-alerts&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Do not assume Azure is watching your TLS expiry dates. It watches Key Vault and its own managed certs, and that is it. The certs that actually terminate your production traffic on App Gateway and App Service are on you. Whether you build the sweep or buy one, make sure something reads those &lt;code&gt;notAfter&lt;/code&gt; dates before your customers do.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>tls</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Your Jira audit log is quietly deleting your compliance evidence</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Sat, 29 Aug 2026 19:00:16 +0000</pubDate>
      <link>https://dev.to/llmgraph/your-jira-audit-log-is-quietly-deleting-your-compliance-evidence-1mpi</link>
      <guid>https://dev.to/llmgraph/your-jira-audit-log-is-quietly-deleting-your-compliance-evidence-1mpi</guid>
      <description>&lt;p&gt;Here is a fun conversation to have mid-audit: the auditor asks for twelve months of Jira audit history, and you discover the platform only kept about six.&lt;/p&gt;

&lt;p&gt;Jira Cloud's audit log is capped. Older records age out after roughly 180 days, and once they are gone, they are gone. No export, no admin setting, no support ticket brings them back. SOC 2 and ISO auditors routinely ask for a year or more of history, and incident reviews have a habit of starting well after the records that matter have already rolled off. This is not an obscure complaint either: longer audit-log retention is a recurring, long-open request on the Jira issue tracker, with roughly 350 combined votes across related asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem in one sentence
&lt;/h2&gt;

&lt;p&gt;The audit log is a rolling window, and your compliance obligations are longer than the window.&lt;/p&gt;

&lt;p&gt;Day to day you never notice, because the recent records are all there. The gap only shows up at the worst possible moment: an access review, a security incident, or an auditor with a checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DIY fix: get the records out before they age out
&lt;/h2&gt;

&lt;p&gt;The durable pattern is to copy audit records into storage you control while they still exist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run a scheduled job that reads the Jira auditing REST API (&lt;code&gt;GET /rest/api/3/auditing/record&lt;/code&gt;, paginated).&lt;/li&gt;
&lt;li&gt;Keep a cursor (timestamp or id of the last stored record) so each run only pulls what is new.&lt;/li&gt;
&lt;li&gt;Dedupe by record id when appending, so overlapping runs never double-store an event.&lt;/li&gt;
&lt;li&gt;When compliance asks, flatten everything to CSV: one row per record with the id, timestamp, author, summary, category, object item, and changed values.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works. The catch is that it has to run reliably every single day, forever. Miss a stretch longer than the retention window and that slice of your evidence trail is unrecoverable. You also have to host the store and the scheduler somewhere, which means new infrastructure outside Jira, and now that infrastructure has to pass your own security review too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The packaged version
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://marketplace.atlassian.com/apps/4152437534/compliance-log-vault" rel="noopener noreferrer"&gt;Compliance Log Vault&lt;/a&gt; to be exactly that pattern, minus the infrastructure. It is an Atlassian Forge app, so everything (the daily scheduled sync, the storage, the export) runs on Atlassian's own serverless platform inside your Jira Cloud tenant. No external servers, no data egress, eligible for the Runs on Atlassian trust badge.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Daily incremental sync of new audit records into an in-tenant vault, deduped by id so nothing is ever stored twice.&lt;/li&gt;
&lt;li&gt;Long-term retention past the ~180-day cap; the vault only grows.&lt;/li&gt;
&lt;li&gt;An admin page to browse retained records newest-first, plus retention stats: counts by day, counts by category, and the full retained span.&lt;/li&gt;
&lt;li&gt;One-click, audit-ready CSV export of the whole vault.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is read-only and least-privilege by design: just audit-log read and user read scopes. It appends records into its own Forge storage and changes nothing in Jira.&lt;/p&gt;

&lt;p&gt;If you want the longer written-up version of the DIY approach, I go into more detail in &lt;a href="https://katabarwalabs.dev/blog/keep-atlassian-audit-logs-beyond-retention" rel="noopener noreferrer"&gt;the full article on our site&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Either way, the takeaway is the same: if your compliance horizon is longer than 180 days, start syncing your Jira audit log out of the rolling window now, because you cannot backfill later.&lt;/p&gt;

</description>
      <category>atlassian</category>
      <category>jira</category>
      <category>security</category>
      <category>compliance</category>
    </item>
    <item>
      <title>Entra ID will not warn you before an app registration secret expires. Build the alert yourself.</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Sat, 29 Aug 2026 18:59:29 +0000</pubDate>
      <link>https://dev.to/llmgraph/entra-id-will-not-warn-you-before-an-app-registration-secret-expires-build-the-alert-yourself-201m</link>
      <guid>https://dev.to/llmgraph/entra-id-will-not-warn-you-before-an-app-registration-secret-expires-build-the-alert-yourself-201m</guid>
      <description>&lt;p&gt;You have probably lived this one, or you will. An integration that has worked for two years starts throwing &lt;code&gt;AADSTS7000222&lt;/code&gt;, "the provided client secret keys are expired," at 3am. Nothing was deployed. Nothing changed. A client secret on an app registration simply reached its end date, and Entra ID, which knew that date all along, told absolutely no one.&lt;/p&gt;

&lt;p&gt;Here is the part that still surprises people: &lt;strong&gt;Microsoft Entra ID has no native expiry alerting for app registration credentials.&lt;/strong&gt; The portal shows each secret's expiry date on the registration blade, but nothing watches those dates. No email to the owner, no alert rule, no Action Group hook. The feedback idea asking for owner alerts has been open for over six years with 223+ votes, and it is still unaddressed.&lt;/p&gt;

&lt;p&gt;So the first signal most teams get is the outage itself, followed by the fun part: figuring out under incident pressure which of your dozens of app registrations it was, which credential expired, and who even owns the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is annoying to solve by hand
&lt;/h2&gt;

&lt;p&gt;The data is all there, just scattered. Every application object in Microsoft Graph carries its own &lt;code&gt;passwordCredentials&lt;/code&gt; (client secrets) and &lt;code&gt;keyCredentials&lt;/code&gt; (certificates), each with an &lt;code&gt;endDateTime&lt;/code&gt;. Answering "what expires in the next 30 days, tenant-wide?" means walking every registration, not checking one dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build: a daily Graph scan with escalating thresholds
&lt;/h2&gt;

&lt;p&gt;The fix is small and pleasant to run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A daily timer job&lt;/strong&gt;, an Azure Function on a consumption plan works nicely, running under a &lt;strong&gt;system-assigned managed identity&lt;/strong&gt;. That last part matters: the watcher itself has no credential that can expire. No irony-based outages.&lt;/li&gt;
&lt;li&gt;Grant the identity the &lt;strong&gt;&lt;code&gt;Application.Read.All&lt;/code&gt;&lt;/strong&gt; Graph application role. That is enough to list registrations and read credential metadata. It cannot read secret values, which is exactly what you want.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;GET /v1.0/applications&lt;/code&gt;, and for every app read &lt;code&gt;endDateTime&lt;/code&gt; on each entry in both &lt;code&gt;passwordCredentials&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;keyCredentials&lt;/code&gt;. Two gotchas here: follow the paging or a large tenant silently truncates, and do not skip certificates. They expire too, and cert-based auth failures are even more confusing to debug at 3am.&lt;/li&gt;
&lt;li&gt;Compute days to expiry and compare against a &lt;strong&gt;ladder&lt;/strong&gt; of thresholds, for example 30, 14, 7, and 1 days. A single warning that fires once and goes quiet is easy to lose in a busy channel. The escalation is what actually gets a secret rotated.&lt;/li&gt;
&lt;li&gt;Post hits to a Teams webhook with the app name, credential type, and days remaining, so the alert lands where the owning team already works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is genuinely the whole thing: managed identity, one read-only Graph permission, one API call with paging, a threshold ladder, a webhook.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not maintain it
&lt;/h2&gt;

&lt;p&gt;Disclosure: I work for Katabarwa Labs, and we ship this exact pattern packaged as &lt;strong&gt;Secret Sentinel: App Registration Expiry Alerts&lt;/strong&gt;. It deploys from the Azure Marketplace as a managed application into your own tenant, scans every app registration daily, and alerts your Teams channel at 30/14/7/1 days (you pick the thresholds at deployment). It runs on a managed identity with only &lt;code&gt;Application.Read.All&lt;/code&gt;, reads expiry dates and never secret values, and there is no vendor backend, so credential metadata never leaves your tenant.&lt;/p&gt;

&lt;p&gt;Listing: &lt;a href="https://marketplace.microsoft.com/en-us/product/azure-application/katabarwalabs.secret-sentinel" rel="noopener noreferrer"&gt;https://marketplace.microsoft.com/en-us/product/azure-application/katabarwalabs.secret-sentinel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is a longer write-up of the DIY version on our blog: &lt;a href="https://katabarwalabs.dev/blog/entra-app-registration-secret-expiry-alerts" rel="noopener noreferrer"&gt;Get alerted before Entra app registration secrets and certificates expire&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Every client secret and certificate in your tenant has a known expiry date sitting in Graph right now, and by default nothing will warn you before one of them takes an integration down. Whether you write the twenty-line scan yourself or grab a packaged one, put something between those dates and your on-call rotation. Future 3am-you will be grateful.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>entra</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Azure Anomaly Detector retires Oct 1, 2026. Here is how to keep per-team cost-spike alerts.</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Fri, 28 Aug 2026 05:46:10 +0000</pubDate>
      <link>https://dev.to/llmgraph/azure-anomaly-detector-retires-oct-1-2026-here-is-how-to-keep-per-team-cost-spike-alerts-3d5g</link>
      <guid>https://dev.to/llmgraph/azure-anomaly-detector-retires-oct-1-2026-here-is-how-to-keep-per-team-cost-spike-alerts-3d5g</guid>
      <description>&lt;p&gt;If you built Azure cost-spike detection on &lt;strong&gt;Azure AI Anomaly Detector&lt;/strong&gt;, you have both a deadline and a gap to plan around. Microsoft's own &lt;a href="https://learn.microsoft.com/en-us/lifecycle/products/azure-anomaly-detector" rel="noopener noreferrer"&gt;Anomaly Detector lifecycle page&lt;/a&gt; sets the date: the service &lt;strong&gt;retires on October 1, 2026&lt;/strong&gt;, and creating new Anomaly Detector resources has been disabled since September 20, 2023. After the retirement date, the endpoints, models, and configurations are gone.&lt;/p&gt;

&lt;p&gt;Microsoft's migration guidance sends general time-series workloads to Microsoft Fabric. But a huge real-world use of Anomaly Detector was narrower: &lt;strong&gt;detecting Azure cost spikes per team or per resource group before month-end.&lt;/strong&gt; For that, Fabric is a lot of machinery for one question: &lt;em&gt;did any team's daily cost just jump?&lt;/em&gt; And the obvious native fallback has a hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hole: Azure cost anomaly detection is subscription-scope only
&lt;/h2&gt;

&lt;p&gt;Azure Cost Management does ship built-in anomaly detection, but read Microsoft's own documentation carefully. The page &lt;a href="https://learn.microsoft.com/en-us/azure/cost-management-billing/understand/analyze-unexpected-charges" rel="noopener noreferrer"&gt;Identify anomalies and unexpected changes in cost&lt;/a&gt; states plainly that &lt;strong&gt;"anomaly detection is only available for subscriptions."&lt;/strong&gt; That single line is the gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subscription scope only.&lt;/strong&gt; A spike inside one team's resource group is averaged into the subscription total. A team that quietly doubles its spend can be invisible in the subscription number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule cap.&lt;/strong&gt; Native anomaly &lt;em&gt;alert&lt;/em&gt; rules are limited per subscription, so you cannot give every team, environment, or cost center its own detector and its own recipient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong recipient.&lt;/strong&gt; A subscription-level alert does not reach the team that actually owns the runaway resource group and can fix it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So after October 2026 you lose Anomaly Detector, and the native cost feature that remains will not alert you per team or per resource group. Here is a small, native way to close that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build: per-slice daily cost-spike detection
&lt;/h2&gt;

&lt;p&gt;Pull daily actual cost per slice from the Cost Management query API, grouped by resource group or a tag key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://management.azure.com/subscriptions/{subId}/providers/Microsoft.CostManagement/query?api-version=2023-11-01
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ActualCost"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeframe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Custom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timePeriod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-27"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dataset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"granularity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Daily"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"aggregation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"totalCost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;"Cost"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sum"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"grouping"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TagKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;"Team"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Follow &lt;code&gt;nextLink&lt;/code&gt; paging or you will silently truncate large tenants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one gotcha that will ruin your week: cost-data latency.&lt;/strong&gt; Cost Management actuals for the most recent one to three days are incomplete and get restated as usage is ingested. If you judge yesterday against a full baseline, the newest day reads artificially low, and then every morning it "spikes" as it settles. Trim the most recent two days from both the judged day and the baseline:&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;LATENCY_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// newest days are still settling&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;judged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;LATENCY_DAYS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// today - 2&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;LATENCY_DAYS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// trailing window before it&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseline&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;sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stddev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseline&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;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;judged&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sd&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isAnomaly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;judged&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// z-score + a dollar floor to kill noise&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;judged&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;mean&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// short-history fallback: percent jump&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then rank anomalies by dollar delta and post to the owning team's Teams channel with slice name, latest cost, baseline, delta, and z-score. Do not alert brand-new slices with under three days of history.&lt;/p&gt;

&lt;p&gt;That is the whole idea: per-slice trailing baseline, latency trim, a z-score with a dollar floor, and a short-history fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not maintain it
&lt;/h2&gt;

&lt;p&gt;Disclosure: I work for Katabarwa Labs, and we ship one app that does exactly this, &lt;strong&gt;Cost Spike Sentinel&lt;/strong&gt;. It is one option next to the code above, not the only way.&lt;/p&gt;

&lt;p&gt;It deploys into your own subscription as a managed application (runs entirely in your tenant), checks daily cost per resource group or per tag (&lt;code&gt;Team&lt;/code&gt;, &lt;code&gt;CostCenter&lt;/code&gt;, &lt;code&gt;Environment&lt;/code&gt;) against its own trailing z-score baseline, handles the latency trim, falls back to percent-jump for short history, and alerts the owning team in Teams. Two honest caveats: it is &lt;strong&gt;alert-only&lt;/strong&gt; (no write permission at all, it never caps or stops anything), and it &lt;strong&gt;replaces the cost use case, not all of Anomaly Detector&lt;/strong&gt; (for general time-series, look at Fabric).&lt;/p&gt;

&lt;p&gt;Listing: &lt;a href="https://marketplace.microsoft.com/en-us/product/azure-application/katabarwalabs.cost-spike-sentinel" rel="noopener noreferrer"&gt;https://marketplace.microsoft.com/en-us/product/azure-application/katabarwalabs.cost-spike-sentinel&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Do not let the October 2026 Anomaly Detector retirement quietly remove your cost-spike safety net, and do not assume the native Cost Management feature covers the gap: by Microsoft's own docs it is subscription-scope only. Decide now between native subscription alerts, the per-slice detector above, or a packaged one. What matters is that a runaway deployment gets noticed in a day, by the team that owns it, instead of at month-end by finance.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>azure</category>
      <category>finops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Flowise is shutting down: a migration path for your visual LLM workflows</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Wed, 05 Aug 2026 00:03:44 +0000</pubDate>
      <link>https://dev.to/llmgraph/flowise-is-shutting-down-a-migration-path-for-your-visual-llm-workflows-1jce</link>
      <guid>https://dev.to/llmgraph/flowise-is-shutting-down-a-migration-path-for-your-visual-llm-workflows-1jce</guid>
      <description>&lt;p&gt;Flowise announced on July 29 that it's winding down. The GitHub repo gets archived on August 10, npm packages and Docker images are being deprecated, and August 31 is end of life. The code stays Apache 2.0, and the team's guidance is to fork and maintain it yourself.&lt;/p&gt;

&lt;p&gt;If you're running Flowise chatflows in production, that leaves you with two real options: become the maintainer of your own fork (security patches, dependency upgrades, model API churn, all of it) or move to a platform that's actively maintained.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stated reason, and why I read it differently
&lt;/h2&gt;

&lt;p&gt;The Flowise team's stated reason for shutting down is that "rigid workflow low code approach quickly hits the limit when it comes to complexity." I'd frame it differently: visual workflow builders aren't the problem. Abandoned ones are. Most teams using a tool like Flowise chose it precisely because they don't want to hand-maintain orchestration code. That need didn't go away last week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What migration actually looks like
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://llmgraph.ai" rel="noopener noreferrer"&gt;LLMGraph&lt;/a&gt;, a hosted visual LLM workflow builder, and the concepts map over directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chatflows map to Graphs.&lt;/strong&gt; Same node-and-edge mental model: models, prompts, logic, and data sources wired together on a canvas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment is built in.&lt;/strong&gt; A finished graph deploys as a public invoke API or an embeddable chat widget in one click. No Docker, no reverse proxy, no self-hosting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted and maintained.&lt;/strong&gt; Model providers, infra, and security patches are our problem, not yours.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before August 31
&lt;/h2&gt;

&lt;p&gt;If you want to move a Flowise setup over before end of life, every LLMGraph tier starts with a 14-day trial, and I'm happy to personally help port your chatflows. Email &lt;a href="mailto:founders@llmgraph.ai"&gt;founders@llmgraph.ai&lt;/a&gt; or ping me in the comments.&lt;/p&gt;

</description>
      <category>lowcode</category>
    </item>
    <item>
      <title>Hexagonal architecture in Rust, and why it makes your codebase legible to coding agents</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Tue, 28 Jul 2026 21:13:44 +0000</pubDate>
      <link>https://dev.to/llmgraph/hexagonal-architecture-in-rust-and-why-it-makes-your-codebase-legible-to-coding-agents-544a</link>
      <guid>https://dev.to/llmgraph/hexagonal-architecture-in-rust-and-why-it-makes-your-codebase-legible-to-coding-agents-544a</guid>
      <description>&lt;p&gt;Coding agents are good at local edits and bad at holding a whole system in their head. Ask one to fix a function and it does fine. Ask it to "add Postgres support" to a codebase where the database calls are smeared across HTTP handlers, and it starts editing in six places, guessing at each, and quietly breaking the seventh. The problem is not the model. The problem is that the codebase never told anyone, human or machine, where things are allowed to live.&lt;/p&gt;

&lt;p&gt;Hexagonal architecture is a way of telling them. It is an old idea (Alistair Cockburn named it "ports and adapters" in 2005) that turns out to be exactly the structure an agent needs: narrow contracts, bounded blast radius, and a spec it can read before it writes. This post walks through what it is, how it looks in Rust, and why the same boundaries that keep code readable for people make it tractable for agents.&lt;/p&gt;

&lt;p&gt;There is a companion repo that compiles, tests, and runs: &lt;a href="https://github.com/llmgraph-ai/hexagonal-rust-template" rel="noopener noreferrer"&gt;llmgraph-ai/hexagonal-rust-template&lt;/a&gt;. Everything below is in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule
&lt;/h2&gt;

&lt;p&gt;Hexagonal architecture has a lot of vocabulary (ports, adapters, driving side, driven side) but only one rule that matters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependencies point inward. The core depends on nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your business logic sits in the middle. It defines the interfaces it needs from the outside world (a place to store data, a way to send email) as traits. It does not know or care what implements them. Everything technological (the web framework, the database driver, the JSON) lives at the edge and depends on the core, never the other way around.&lt;/p&gt;

&lt;p&gt;Draw it as a picture and it is a set of rings with the arrows all pointing to the center:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server  -&amp;gt;  adapters  -&amp;gt;  application  -&amp;gt;  domain
                \______________________________^
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;domain&lt;/strong&gt; is the core: business types and the ports (traits).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;application&lt;/strong&gt; is the use cases, written against those ports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;adapters&lt;/strong&gt; are the edge: an HTTP handler, a Postgres client, an in-memory fake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;server&lt;/strong&gt; is the composition root: the one place that wires a specific adapter to the core.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  In Rust, the compiler enforces it
&lt;/h2&gt;

&lt;p&gt;Most languages document the dependency rule and hope. Rust lets you make it a compile error. Put each ring in its own crate, and give each crate a dependencies list that only points inward. Now the core cannot import an adapter, because the adapter's crate is not in its dependency graph. The rule stops being a convention that erodes over time and becomes a fact the compiler checks on every build.&lt;/p&gt;

&lt;p&gt;Here is the core. Note what is not imported: no &lt;code&gt;axum&lt;/code&gt;, no &lt;code&gt;sqlx&lt;/code&gt;, no &lt;code&gt;serde&lt;/code&gt;. Just the standard library and two small utilities.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// crates/domain/src/lib.rs&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;ShortCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;ShortCode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The inner String is private, so the only way to get a ShortCode is to&lt;/span&gt;
    &lt;span class="c1"&gt;// parse one. An invalid code cannot exist anywhere in the system.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DomainError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;..=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="nf"&gt;.chars&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.all&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="nf"&gt;.is_ascii_lowercase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="nf"&gt;.is_ascii_digit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;DomainError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;InvalidCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core also defines the ports: the interfaces it needs from the world, as traits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// crates/domain/src/ports.rs&lt;/span&gt;

&lt;span class="c1"&gt;// A driven (outbound) port: somewhere to store links. The core does not know&lt;/span&gt;
&lt;span class="c1"&gt;// if this is Postgres, Redis, or a HashMap.&lt;/span&gt;
&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;LinkRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ShortLink&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;DomainError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ShortCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ShortLink&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DomainError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// A driving (inbound) port: the use cases the application offers to the world.&lt;/span&gt;
&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;Shortener&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;shorten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ShortLink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DomainError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ShortLink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DomainError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application layer implements the driving port by orchestrating the driven one. This file is the behavior of the system with none of the plumbing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// crates/application/src/lib.rs&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ShortenerService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="n"&gt;LinkRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// depends on the port, not a concrete DB&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[async_trait]&lt;/span&gt;
&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;Shortener&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ShortenerService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;shorten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ShortLink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DomainError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;validate_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;ShortCode&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="n"&gt;raw&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="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;ShortCode&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nf"&gt;generate_code&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="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.repo&lt;/span&gt;&lt;span class="nf"&gt;.find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.is_some&lt;/span&gt;&lt;span class="p"&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;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;DomainError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CodeTaken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ShortLink&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.repo&lt;/span&gt;&lt;span class="nf"&gt;.save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP adapter depends on the &lt;code&gt;Shortener&lt;/code&gt; port, not the concrete service. Its only job is to translate HTTP into a call on the port and translate the result back. There is no business logic here, which is exactly why you can throw it away and replace it with a CLI without touching the core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// crates/adapters/src/http.rs&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;create_link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;State&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CreateLinkRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LinkResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ApiError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.shortener&lt;/span&gt;&lt;span class="nf"&gt;.shorten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="py"&gt;.target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="py"&gt;.code&lt;/span&gt;&lt;span class="nf"&gt;.as_deref&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CREATED&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="n"&gt;link&lt;/span&gt;&lt;span class="nf"&gt;.into&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;And the composition root, the single file that knows every concrete type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// crates/server/src/main.rs&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="n"&gt;LinkRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;InMemoryLinkRepository&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;shortener&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="n"&gt;Shortener&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ShortenerService&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shortener&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nn"&gt;axum&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&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;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want Postgres instead of memory? Write a &lt;code&gt;PostgresLinkRepository&lt;/code&gt; that implements &lt;code&gt;LinkRepository&lt;/code&gt;, and change line one. Want a CLI instead of HTTP? Change line three. Nothing upstream notices, because nothing upstream ever knew.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents thrive on this
&lt;/h2&gt;

&lt;p&gt;Now the payoff, and the reason this old pattern is worth revisiting in 2026. Every property that makes hexagonal architecture pleasant for a human maps directly onto something an agent needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tasks map to a single file with a precise contract.&lt;/strong&gt; "Add a Postgres adapter" is not a vague request that could touch anything. It is: implement this one trait in this one new file. The agent has the exact signature to satisfy and physically cannot reach into business logic while doing it, because the adapter crate does not depend on private application internals. The task is scoped by the architecture, not by your prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ports are the spec.&lt;/strong&gt; An agent can write a correct use-case test from the trait definition alone, before a single adapter exists. In the template, the use-case tests run against a five-line fake repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[tokio::test]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;shorten_then_resolve_roundtrips&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;ShortenerService&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;FakeRepo&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="nf"&gt;.shorten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"my-code"&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;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="nf"&gt;.resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"my-code"&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;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nd"&gt;assert_eq!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolved&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;No database, no HTTP server, no fixtures. An agent asked to raise test coverage on the core has everything it needs from the port and nothing it needs to mock away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blast radius is bounded by construction.&lt;/strong&gt; This is the big one. When an agent makes a wrong turn in a well-layered codebase, the mistake stays inside the layer it was working in. A bad decision in the HTTP adapter cannot corrupt the domain, because the compiler will not let the domain see the adapter. You get to be wrong locally, which is the only kind of wrong that is cheap to fix. In a codebase where a database call can appear inside a request handler, there is no such floor. A confused edit propagates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The structure survives context limits.&lt;/strong&gt; An agent does not need to load the whole repository to work on one adapter. The relevant surface is the port it implements plus the crate it lives in. The architecture pre-chunks the codebase into units small enough to reason about in isolation, which is the same reason it was pleasant for humans in the first place. Good structure is good structure; agents just raise the stakes on having it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;Do not start by rewriting everything into four crates. Start with the one rule and one seam. Pick the dependency that hurts most (usually the database) and define it as a trait in your core. Move the concrete client behind an adapter that implements the trait. Wire it up in one place. You will feel the core get quieter almost immediately, because it stops importing things that have nothing to do with your business.&lt;/p&gt;

&lt;p&gt;Then, if it earns its keep, split the rings into crates so the compiler enforces the rule you have been maintaining by hand. Rust makes that enforcement free, and free enforcement is the difference between an architecture you have and an architecture you had six months ago.&lt;/p&gt;

&lt;p&gt;The full template compiles, tests, and runs: &lt;a href="https://github.com/llmgraph-ai/hexagonal-rust-template" rel="noopener noreferrer"&gt;github.com/llmgraph-ai/hexagonal-rust-template&lt;/a&gt;. Clone it, run &lt;code&gt;cargo test&lt;/code&gt;, delete the URL shortener, and keep the shape.&lt;/p&gt;




&lt;p&gt;This is the kind of structured backend we care about at &lt;a href="https://llmgraph.ai" rel="noopener noreferrer"&gt;LLMGraph&lt;/a&gt;, where we build LLM and AI-agent workflows that people deploy as APIs. Legible structure is what lets both our team and our tools move fast without breaking the parts that matter.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>architecture</category>
      <category>ai</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>What it actually costs to run a RAG chatbot</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Tue, 28 Jul 2026 12:06:38 +0000</pubDate>
      <link>https://dev.to/llmgraph/what-it-actually-costs-to-run-a-rag-chatbot-2hoe</link>
      <guid>https://dev.to/llmgraph/what-it-actually-costs-to-run-a-rag-chatbot-2hoe</guid>
      <description>&lt;p&gt;"How much will this cost to run?" is the question that kills more AI side projects than any technical problem. People build a working RAG chatbot in a weekend, then freeze because they have no idea whether serving it costs 5 dollars a month or 5,000. This post breaks the bill into its real parts so you can estimate before you commit.&lt;/p&gt;

&lt;p&gt;I'll use round numbers and current-ish prices. Yours will differ. The point is the shape of the bill, not the exact digits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill has four line items
&lt;/h2&gt;

&lt;p&gt;A retrieval-augmented chatbot spends money in four places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Embedding your documents (one time, plus updates)&lt;/li&gt;
&lt;li&gt;Storing the vectors&lt;/li&gt;
&lt;li&gt;Embedding each incoming question&lt;/li&gt;
&lt;li&gt;Generating each answer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three of those four are close to free. One of them is the whole bill. Guess which before you read on.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Embedding your documents
&lt;/h2&gt;

&lt;p&gt;You embed each chunk of your documents once. Embedding is cheap. Most providers charge a few cents per million tokens.&lt;/p&gt;

&lt;p&gt;Say you have 200 documents averaging 3,000 words each. That's roughly 800,000 tokens. At current embedding prices that is a fraction of a cent to a few cents, total, one time. Even 10,000 documents lands in the low single-dollar range.&lt;/p&gt;

&lt;p&gt;If your documents never change, you pay this once and forget it. If they change daily, you re-embed the changed ones, which is still pennies.&lt;/p&gt;

&lt;p&gt;Line item: negligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Storing the vectors
&lt;/h2&gt;

&lt;p&gt;Each chunk becomes a vector, usually 768 or 1536 numbers. A few thousand chunks is a few megabytes. You can hold it in memory, in a local file, or in a hosted vector database.&lt;/p&gt;

&lt;p&gt;For a small project, a local store (Chroma, SQLite with a vector extension, FAISS on disk) costs nothing beyond the machine you already run. Hosted vector databases start free and stay cheap until you're storing millions of vectors.&lt;/p&gt;

&lt;p&gt;Line item: zero to a few dollars a month until you're at real scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Embedding each question
&lt;/h2&gt;

&lt;p&gt;Every question a user asks gets embedded so you can search. A question is short, maybe 20 tokens. At embedding prices that is far less than a hundredth of a cent per question.&lt;/p&gt;

&lt;p&gt;A thousand questions a day is still cents per month.&lt;/p&gt;

&lt;p&gt;Line item: negligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Generating the answer
&lt;/h2&gt;

&lt;p&gt;Here's the bill.&lt;/p&gt;

&lt;p&gt;Every answer is a call to a language model, and the input to that call is not just the question. It's the question plus the chunks you retrieved plus your instructions. That's the expensive part, because generation models charge per token and you're feeding them a few thousand tokens of context every single time.&lt;/p&gt;

&lt;p&gt;Rough math. Say each answer sends 2,000 tokens of context and gets back 400 tokens. On a mid-tier model at a few dollars per million input tokens and a bit more for output, one answer costs somewhere around half a cent to two cents depending on the model.&lt;/p&gt;

&lt;p&gt;Now multiply by traffic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;100 answers a day: roughly 1 to 6 dollars a month&lt;/li&gt;
&lt;li&gt;1,000 answers a day: roughly 15 to 60 dollars a month&lt;/li&gt;
&lt;li&gt;10,000 answers a day: roughly 150 to 600 dollars a month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That range is wide because model choice swings it more than anything else. A frontier model can cost 10 to 20 times what a smaller model costs for the same job. For a lot of support and lookup use cases, a smaller model with good retrieval beats a frontier model with sloppy retrieval, and costs a tenth as much.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moves the number
&lt;/h2&gt;

&lt;p&gt;Once you see that generation is the bill, the levers are obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model choice.&lt;/strong&gt; The single biggest lever. Try the cheapest model that passes your quality bar before reaching for the expensive one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context size.&lt;/strong&gt; Retrieving 15 chunks instead of 5 triples your input tokens for often worse answers. Fewer, better chunks cost less and read better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching.&lt;/strong&gt; If the same questions come up again and again, and in support they do, cache the answers. A cache hit costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trimming instructions.&lt;/strong&gt; A 600-token system prompt sent on every call adds up. Tighten it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A quick estimate you can do right now
&lt;/h2&gt;

&lt;p&gt;Take your expected answers per day, multiply by 30 for the month, multiply by roughly 1 cent. That's your rough monthly generation bill on a mid-tier model. Everything else on the bill is rounding error at small scale.&lt;/p&gt;

&lt;p&gt;So a chatbot answering 300 questions a day costs on the order of 10 dollars a month to run. That's the number that unfreezes the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;If you'd rather not stand up the embedding, storage, and generation plumbing yourself and meter each piece, that's the kind of thing &lt;a href="https://llmgraph.ai" rel="noopener noreferrer"&gt;LLMGraph&lt;/a&gt; handles. You build the workflow, it runs the retrieval and the model calls, and you get a deployed API and chat widget. The cost shape above is still what's happening underneath, so it's worth understanding either way.&lt;/p&gt;

&lt;p&gt;The takeaway: a RAG chatbot is cheap to run at small scale, generation is the only line item that matters, and model choice is the dial that turns a 10-dollar bill into a 200-dollar one. Estimate before you build, and the "what will this cost" freeze goes away.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turn a folder of PDFs into a question-answering API in an afternoon</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Tue, 28 Jul 2026 03:58:48 +0000</pubDate>
      <link>https://dev.to/llmgraph/turn-a-folder-of-pdfs-into-a-question-answering-api-in-an-afternoon-14kg</link>
      <guid>https://dev.to/llmgraph/turn-a-folder-of-pdfs-into-a-question-answering-api-in-an-afternoon-14kg</guid>
      <description>&lt;p&gt;Someone hands you 200 PDFs and asks for a "chatbot that knows this stuff." You could read all 200. Or you could build a small API that reads them for you and answers questions with citations. This post is the second option, start to finish, with the parts that usually break called out.&lt;/p&gt;

&lt;p&gt;I'll show the mechanics first so you understand what's happening, then a shorter path if you don't want to run the plumbing yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're actually building
&lt;/h2&gt;

&lt;p&gt;A question-answering API over your own documents is four moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingest.&lt;/strong&gt; Read each PDF, pull out the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunk.&lt;/strong&gt; Split that text into pieces small enough to search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed and store.&lt;/strong&gt; Turn each chunk into a vector, keep it in a store you can query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer.&lt;/strong&gt; At question time, find the closest chunks and hand them to a model with the question.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;People call the whole thing RAG (retrieval-augmented generation). The name makes it sound heavier than it is. It's search, then a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: get the text out
&lt;/h2&gt;

&lt;p&gt;PDFs are a mess. Some are clean text, some are scanned images, some have two columns that extract in the wrong order. Start simple with &lt;code&gt;pypdf&lt;/code&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;pypdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfReader&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="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="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where it breaks: scanned PDFs return empty strings because there's no text layer, only pixels. If &lt;code&gt;extract_text()&lt;/code&gt; gives you nothing, those files need OCR (Tesseract, or a hosted vision model). Check for empty output early so you don't silently index blank pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: chunk the text
&lt;/h2&gt;

&lt;p&gt;You can't embed a whole document as one vector and expect good answers. Retrieval works on pieces. The size of those pieces matters more than most tutorials admit.&lt;/p&gt;

&lt;p&gt;Too small (a sentence) and each chunk loses its context. Too big (a whole page) and the model gets a wall of mostly-irrelevant text and the answer drifts. A reasonable starting point is 500 to 800 tokens per chunk with a small overlap so a sentence split across a boundary isn't lost.&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;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;size&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This splits on whitespace, which is crude but fine to start. If your documents have clear structure (headings, sections), splitting on those boundaries beats a fixed window because a chunk that matches a section is a chunk that makes sense on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: embed and store
&lt;/h2&gt;

&lt;p&gt;An embedding turns a chunk into a list of numbers that captures its meaning. Similar text lands near similar text. You query by embedding the question the same way and finding the nearest chunks.&lt;/p&gt;

&lt;p&gt;For a folder of 200 PDFs you do not need a managed vector database. A local store is plenty:&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;import&lt;/span&gt; &lt;span class="n"&gt;chromadb&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chromadb.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;embedding_functions&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chromadb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PersistentClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./store&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DefaultEmbeddingFunction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_or_create_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding_function&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ef&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;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_texts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&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;chunks&lt;/span&gt;&lt;span class="p"&gt;))],&lt;/span&gt;
        &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;metadatas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc_id&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;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&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;Keep the source filename in the metadata. That's what lets you cite where an answer came from, which is the difference between a demo and something people trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: answer with citations
&lt;/h2&gt;

&lt;p&gt;At question time: embed the question, pull the top handful of chunks, put them in the prompt, ask the model to answer only from what it was given.&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;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;col&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="n"&gt;query_texts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;n_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&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="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&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;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadatas&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;

    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer the question using only the context below. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;If the context does not contain the answer, say so.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Context:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Question: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_your_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Claude, GPT, a local model, your choice
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Sources: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things that matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;n_results&lt;/code&gt;.&lt;/strong&gt; Five is a good default. More context is not better. Past a point you're paying for tokens and diluting the signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "say so" instruction.&lt;/strong&gt; Without it the model fills gaps with plausible fiction. With it you get "the documents don't cover this," which is the honest answer and the one that keeps people trusting the system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wrap &lt;code&gt;answer()&lt;/code&gt; in any web framework and you have an API:&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;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where this gets hard in production
&lt;/h2&gt;

&lt;p&gt;The afternoon version above works. The things that turn it into a real project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-indexing.&lt;/strong&gt; Documents change. You need a way to update or drop chunks without rebuilding everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bad retrieval.&lt;/strong&gt; When answers are wrong it's usually retrieval, not the model. The right chunk never made it into the context. Logging what got retrieved for each question is the first debugging tool you'll want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control.&lt;/strong&gt; If different people should see different documents, retrieval has to filter by permission before it ranks. Bolting this on later is painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; Embeddings are cheap. The generation call is not, and it scales with traffic. Caching common questions helps.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shorter path
&lt;/h2&gt;

&lt;p&gt;If you'd rather not run the ingest, chunking, vector store, and prompt loop yourself, this is the exact shape of thing &lt;a href="https://llmgraph.ai" rel="noopener noreferrer"&gt;LLMGraph&lt;/a&gt; builds. You point it at your documents, it handles the chunking and retrieval, and you get a REST API and an embeddable chat widget out the other side. The four steps above still happen. You just don't maintain them.&lt;/p&gt;

&lt;p&gt;Either way, the mental model is the same: read, chunk, embed, retrieve, answer. Once that clicks, "chatbot that knows our stuff" stops sounding like a research project and starts sounding like an afternoon.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Add an AI Customer Support Assistant — Without Writing Code</title>
      <dc:creator>LLMGraph</dc:creator>
      <pubDate>Mon, 13 Jul 2026 22:55:46 +0000</pubDate>
      <link>https://dev.to/llmgraph/how-to-add-an-ai-customer-support-assistant-without-writing-code-38fp</link>
      <guid>https://dev.to/llmgraph/how-to-add-an-ai-customer-support-assistant-without-writing-code-38fp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://llmgraph.ai/blog/no-code-customer-support-assistant" rel="noopener noreferrer"&gt;LLMGraph blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your customers expect answers fast — at 9am on a Tuesday and at 2am on a Saturday. Meanwhile your support team is finite, and a large share of the questions they field are the same ones, over and over: where's my order, how do I reset this, what does this plan include. Every leader has heard that an AI assistant can take that load off. Most assume it's a months-long engineering project. It isn't anymore.&lt;/p&gt;

&lt;p&gt;Here is the entire build, start to finish, in a short demo — a working customer support assistant, no code written:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/AVk7xhOiMCc"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AI customer support assistant actually does
&lt;/h2&gt;

&lt;p&gt;A good support assistant isn't a generic chatbot that guesses. It answers from &lt;strong&gt;your&lt;/strong&gt; knowledge and knows its limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Answers instantly, around the clock&lt;/strong&gt; — in your product's voice, without adding headcount for every timezone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grounded in your own content&lt;/strong&gt; — your docs, help center, and policies, via retrieval (RAG), so it gives the real answer instead of a plausible-sounding one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knows its limits&lt;/strong&gt; — recognizes the complex or sensitive cases that belong with your team and points customers there instead of bluffing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lives where customers already are&lt;/strong&gt; — an embeddable chat widget on your site or app, or wired into your stack through an API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this used to take an engineering team
&lt;/h2&gt;

&lt;p&gt;The reason so many companies shelved the idea is that the traditional build is genuinely involved. To do it yourself you'd choose and host a model, build a retrieval pipeline (chunk your documents, generate embeddings, stand up a vector database, wire up search), tune prompts and guardrails, build the chat widget, then deploy, scale, and monitor the whole thing. That's specialized hires and a quarter of work before a single customer is helped.&lt;/p&gt;

&lt;h2&gt;
  
  
  How LLMGraph turns it into a visual build
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://llmgraph.ai" rel="noopener noreferrer"&gt;LLMGraph&lt;/a&gt; collapses that stack into a visual workflow. You design the assistant as a graph on a canvas — or just describe what you want in chat and let LLMGraph assemble it — and the plumbing is handled for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Point it at your documents; LLMGraph does the embeddings and retrieval so answers stay grounded.&lt;/li&gt;
&lt;li&gt;Shape the assistant's role, tone, and boundaries without writing code.&lt;/li&gt;
&lt;li&gt;Ship it in one click to a live &lt;strong&gt;chat widget&lt;/strong&gt; you paste onto any page, plus a &lt;strong&gt;REST API&lt;/strong&gt; for deeper integration. No infrastructure to manage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That flow — knowledge in, behavior shaped, deployed — is the whole video above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can stand up this afternoon
&lt;/h2&gt;

&lt;p&gt;A first working assistant is four steps, not four sprints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add your knowledge.&lt;/strong&gt; Upload docs or connect the help content that already answers your common questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shape behavior.&lt;/strong&gt; Set the assistant's role, tone, and what it should leave to your team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on the canvas.&lt;/strong&gt; Ask it real customer questions and refine until the answers are right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy.&lt;/strong&gt; Drop the chat widget on your site, or call the API from your product.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The business case for leaders
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage&lt;/strong&gt; — 24/7 first-response without hiring for every shift and region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deflection&lt;/strong&gt; — repetitive questions get handled automatically, freeing your team for the complex, high-value cases only a person should touch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency&lt;/strong&gt; — the same accurate answer every time, grounded in your single source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control&lt;/strong&gt; — your data, your brand, and a human in the loop where it matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed to value&lt;/strong&gt; — build and iterate in an afternoon, then improve it as you learn, instead of committing to a quarter-long project up front.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start with one workflow
&lt;/h2&gt;

&lt;p&gt;You don't have to automate everything on day one. Pick the ten questions your team answers most, point an assistant at the docs that already answer them, and ship the widget on a single page. Watch what it handles, refine, and expand from there. The first version is the afternoon; the compounding value is everything you add after.&lt;/p&gt;

&lt;p&gt;If you want to see it against your own content, &lt;a href="https://llmgraph.ai/signin" rel="noopener noreferrer"&gt;start a free trial&lt;/a&gt; or &lt;a href="https://llmgraph.ai/pricing" rel="noopener noreferrer"&gt;look at the plans&lt;/a&gt; — every tier includes a 14-day free trial.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>nocode</category>
      <category>chatbot</category>
    </item>
  </channel>
</rss>
