<?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: Tessa Kriesel</title>
    <description>The latest articles on DEV Community by Tessa Kriesel (@tessak22).</description>
    <link>https://dev.to/tessak22</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%2F142543%2F391aa73e-bb8f-4e21-b9bf-b69fccad57f1.jpg</url>
      <title>DEV Community: Tessa Kriesel</title>
      <link>https://dev.to/tessak22</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tessak22"/>
    <language>en</language>
    <item>
      <title>How to Set Up Signup Attribution in PostHog Behind a Cookie Banner</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Mon, 12 Oct 2026 18:39:17 +0000</pubDate>
      <link>https://dev.to/tessak22/how-to-set-up-signup-attribution-in-posthog-behind-a-cookie-banner-14f5</link>
      <guid>https://dev.to/tessak22/how-to-set-up-signup-attribution-in-posthog-behind-a-cookie-banner-14f5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1604962052822-eb6259a4c10a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDMxfHxoZWRnZWhvZ3xlbnwwfHx8fDE3OTE4MjY3MzZ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1604962052822-eb6259a4c10a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDMxfHxoZWRnZWhvZ3xlbnwwfHx8fDE3OTE4MjY3MzZ8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="How to Set Up Signup Attribution in PostHog Behind a Cookie Banner"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When someone signs up for your product, you want to know what brought them in. PostHog can tell you. It takes some extra setup when a cookie banner sits in front of your website and signup happens in a separate app. Without it, nearly every signup shows up as direct.&lt;/p&gt;

&lt;p&gt;This tutorial follows one visitor from their first click to their account and fixes each place where their source gets lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path one visitor takes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Someone clicks a link to your website in a Reddit post.&lt;/li&gt;
&lt;li&gt;They land on your site. A cookie banner asks if analytics is okay.&lt;/li&gt;
&lt;li&gt;They read a couple of pages, then accept.&lt;/li&gt;
&lt;li&gt;They click Sign up. That takes them off your website and into your app, which often lives at a different address.&lt;/li&gt;
&lt;li&gt;They create an account.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You want the account at the end of that path to say "Reddit." The source drops off at two points. Both happen because analytics waits for the visitor's consent.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;At the banner.&lt;/strong&gt; On a site with a cookie banner, PostHog is usually set to stay off until the visitor accepts. The landing from Reddit happens before that, so it isn't recorded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When they move to the app.&lt;/strong&gt; The app is often a separate site, and the visitor hasn't accepted cookies there yet. So it starts them as a new visitor with no history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Steps 1 and 2 fix those two points. Step 3 gets the source into your CRM. Step 4 checks that it all works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PostHog's JavaScript library on the website and the app, using the same project&lt;/li&gt;
&lt;li&gt;A cookie banner that controls whether analytics runs&lt;/li&gt;
&lt;li&gt;The website and the app on the same parent domain, with the app on a subdomain of it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Remember how the visitor arrived
&lt;/h2&gt;

&lt;p&gt;The visitor lands from Reddit before they've accepted cookies, so PostHog isn't running yet. By the time they accept, the landing is over and there's no record of where it came from.&lt;/p&gt;

&lt;p&gt;Start PostHog on the website so it captures and stores nothing until the visitor accepts.&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;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;visitorHasAccepted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;POSTHOG_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;POSTHOG_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;opt_out_capturing_by_default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// capture nothing until the visitor accepts&lt;/span&gt;
  &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accepted&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localStorage+cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;memory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// store nothing until the visitor accepts&lt;/span&gt;
  &lt;span class="na"&gt;person_profiles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;always&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// keep a profile for visitors before they sign up&lt;/span&gt;
  &lt;span class="na"&gt;capture_pageview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// you send the pageview yourself, below&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;visitorHasAccepted()&lt;/code&gt; stands for however your cookie banner remembers the visitor's choice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;person_profiles: 'always'&lt;/code&gt; matters here. The first source is stored on the visitor's profile, so the visitor needs a profile before they have an account.&lt;/p&gt;

&lt;p&gt;Then save the landing yourself. When the first page loads, put the page's address and the referrer into the browser tab's session storage. The address carries any campaign tags (the &lt;code&gt;utm_&lt;/code&gt; parameters) and ad click IDs. Nothing leaves the browser.&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;ENTRY_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;entry_context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stashEntryContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ENTRY_KEY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;// only the first page of the visit&lt;/span&gt;
    &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ENTRY_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;stashEntryContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// run this on every page load&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the visitor accepts cookies, turn PostHog on and send one pageview that carries what you saved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The campaign tags and ad click IDs PostHog records as first touch, as of posthog-js 1.434.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CAMPAIGN_PARAMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_campaign&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_term&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gad_source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mc_cid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gclid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gclsrc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dclid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gbraid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wbraid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fbclid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;msclkid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;twclid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;li_fat_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;igshid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ttclid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rdt_cid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;epik&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qclid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sccid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;irclid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_kx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;// Returns the saved landing once per visit. After that it returns null.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;takeEntryContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ENTRY_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ENTRY_KEY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_sent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ENTRY_KEY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_sent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hostOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$direct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendPageview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;takeEntryContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$pageview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// The landing page, in the fields PostHog uses.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;landing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;$current_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;$pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;$referrer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$direct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;$referring_domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;hostOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$direct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;for &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;param&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;CAMPAIGN_PARAMS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;landing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;param&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;param&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// PostHog stores first touch as the same fields with an $initial_ prefix.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;firstTouch&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="nx"&gt;landing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;landing&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;firstTouch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$initial_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// The pageview itself reports the landing's referrer and campaign tags.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$referrer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;landing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$referrer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;$referring_domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;landing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$referring_domain&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;for &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;param&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;CAMPAIGN_PARAMS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;landing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;param&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;param&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;landing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;param&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$pageview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$set_once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;firstTouch&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;Then connect it to your cookie banner. Turn PostHog on and send the pageview when the visitor accepts, and turn it back off if they withdraw.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// When the visitor clicks Accept on your banner:&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onAccept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localStorage+cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opt_in_capturing&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;captureEventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nf"&gt;sendPageview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// When the visitor rejects or withdraws consent:&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onReject&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opt_out_capturing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;memory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// On every page load, for visitors who accepted earlier:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;sendPageview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The saved landing goes in &lt;code&gt;$set_once&lt;/code&gt;, as the third argument to &lt;code&gt;capture&lt;/code&gt;. &lt;code&gt;$set_once&lt;/code&gt; is PostHog's way of saying "set this only if it isn't set already." It's what keeps Reddit as the visitor's first source, whatever they click later. PostHog fills in first-touch values from the current page on its own, and values passed this way take their place.&lt;/p&gt;

&lt;p&gt;Every campaign field gets a value, including &lt;code&gt;null&lt;/code&gt; for tags the landing didn't have. That way a later page can't fill them in.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;captureEventName: false&lt;/code&gt; skips the separate opt-in event, so this pageview is the first event PostHog receives for the visitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Make the website and the app agree on who the visitor is
&lt;/h2&gt;

&lt;p&gt;PostHog gives every visitor an anonymous ID. Because the app starts fresh, the visitor gets one ID on the website and a different one in the app. That looks like two people, and the one who signs up has no Reddit visit behind them.&lt;/p&gt;

&lt;p&gt;The fix is to make the app use the ID the website already handed out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the website.&lt;/strong&gt; There's nothing to add. Once the visitor accepts cookies, PostHog stores their ID in a cookie on your parent domain, where the app can read it. That setting is &lt;code&gt;cross_subdomain_cookie&lt;/code&gt;, and it's on by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the app.&lt;/strong&gt; Start PostHog in memory only, so it doesn't read or write cookies until the visitor accepts. That means it won't pick up the website's ID on its own.&lt;/p&gt;

&lt;p&gt;So read the cookie yourself and pass the ID in when PostHog starts, using the &lt;code&gt;bootstrap&lt;/code&gt; option.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Find the ID the website stored in the shared cookie.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sharedDistinctId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posthogKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`ph_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;posthogKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_posthog=`&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;decodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookie&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="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nx"&gt;distinct_id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;visitorHasAccepted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Once the visitor has accepted, PostHog reads the cookie itself.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sharedId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;accepted&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sharedDistinctId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;POSTHOG_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Start PostHog with that ID, if there is one.&lt;/span&gt;
&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;POSTHOG_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;POSTHOG_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;opt_out_capturing_by_default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accepted&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localStorage+cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;memory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;sharedId&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;distinctID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sharedId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isIdentifiedID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app and the website now use the same ID for the visitor.&lt;/p&gt;

&lt;p&gt;The last part happens in the app. Opt the visitor in when they accept cookies there, and tell PostHog who they are once they're signed in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// When the visitor accepts cookies in the app. Run it then, not on every page:&lt;/span&gt;
&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localStorage+cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opt_in_capturing&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;captureEventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// When the visitor rejects or withdraws consent:&lt;/span&gt;
&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opt_out_capturing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;memory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Once they're signed in:&lt;/span&gt;
&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostHog attaches what the anonymous visitor did, including the landing from Reddit, to that user. &lt;code&gt;isIdentifiedID: false&lt;/code&gt; in the code above is what allows that. It tells PostHog the shared ID doesn't belong to a known user yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Put the source on the account
&lt;/h2&gt;

&lt;p&gt;At this point PostHog knows the user came from Reddit. I wanted that on the account in my CRM, next to everything else I know about them.&lt;/p&gt;

&lt;p&gt;A script asks PostHog for the first source of whoever created each account. This is my query. My accounts are organizations, so the first part finds the person who created each one. If your accounts are single users, you can skip that part and read the same properties off the person who signed up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;creators&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;group_0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;org_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;argMax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;person_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;creator&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'organization_created'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;org_key&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;org_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;initial_utm_source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;initial_utm_medium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;initial_utm_campaign&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;initial_referring_domain&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;creators&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;persons&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creator&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the &lt;code&gt;LIMIT&lt;/code&gt;. Without one, the query API returns the first 100 rows.&lt;/p&gt;

&lt;p&gt;The script then writes four fields to the account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Channel:&lt;/strong&gt; where they came from, like Reddit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type:&lt;/strong&gt; paid ad, organic, referral, or direct, worked out from the &lt;code&gt;utm_medium&lt;/code&gt; tag and the referrer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidence:&lt;/strong&gt; high when a campaign tag or referrer backs it up, lower when it's a guess&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Basis:&lt;/strong&gt; the raw values from PostHog, so I can check any account by hand&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Check that it works
&lt;/h2&gt;

&lt;p&gt;Events showing up in PostHog doesn't mean the pieces are connected. I use three checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Walk the path yourself.&lt;/strong&gt; Open a link to your site with campaign tags on it. Click to a second page and accept cookies there. Then click Sign up and create an account. Your new account should show that campaign.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look at pageviews per person.&lt;/strong&gt; If the number sits near 1, that often means every page load is being counted as a new person and the ID isn't being kept.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count how many signups have a source.&lt;/strong&gt; A signup has a source when the person has a campaign tag, or a referrer from somewhere other than your own site. Swap in your signup event and your domain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;uniqExact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;person_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;uniqExactIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;person_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;initial_utm_source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;initial_referring_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$direct'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'$direct'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;initial_referring_domain&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%yoursite.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;signups_with_a_source&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'user_signed_up'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostHog also gives each person a channel type on its own, like Direct, Referral, or Organic Search. If you count signups by that and get a much higher number than this query, check how many are labeled Unknown. PostHog uses Unknown for people it never saw on your website. They have no source, so this query leaves them out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;For every visitor who accepts cookies, the account they create shows the page they landed on and what sent them there. Visitors who never accept stay without a source. That's by design.&lt;/p&gt;

&lt;p&gt;Before you trust the numbers, run the first check in step 4 once on your live site.&lt;/p&gt;

</description>
      <category>analytics</category>
    </item>
    <item>
      <title>Redact PII before your model searches the web</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/tabstack/redact-pii-before-your-model-searches-the-web-167e</link>
      <guid>https://dev.to/tabstack/redact-pii-before-your-model-searches-the-web-167e</guid>
      <description>&lt;p&gt;Support chats, intake forms, benefits questions, patient messages. The messages people send your app are full of personal details: names, addresses, account numbers, IDs. And many of those questions need a current answer from the web, like a program’s income limits, a bank’s wire rules, or a state’s renewal process.&lt;/p&gt;

&lt;p&gt;The answer depends on what they’re asking, not on who’s asking. This tutorial builds a loop that takes personal details out before the question leaves your machine: Rampart’s open model redacts personal details on your machine, Tabstack runs the search-to-answer loop on the redacted question, your own model in Ollama writes the reply, and the details go back in on your side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rampart, from National Design Studio
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/nationaldesignstudio/rampart" rel="noopener noreferrer"&gt;Rampart&lt;/a&gt; is an open PII redaction library from &lt;a href="https://ndstudio.gov/posts/say-hello-to-rampart" rel="noopener noreferrer"&gt;National Design Studio&lt;/a&gt;. It runs in the browser, in the moment between typing a message and sending it, and replaces personal details with placeholders like &lt;code&gt;[GIVEN_NAME_1]&lt;/code&gt; before anything reaches a server. The mapping back to real values never leaves the device.&lt;/p&gt;

&lt;p&gt;At its core is a 14.7 MB MiniLM model that recognizes names, phone numbers, bank account and routing numbers, tax and government IDs, passports, driver’s licenses, and every part of a street address. Rules cover card numbers, SSNs, emails, URLs, and IP addresses. Together they work across seven Latin-script languages and report 98.42% private-term recall on Rampart’s held-out test set. National Design Studio is clear about what it is for: harm reduction for people entering their own information.&lt;/p&gt;

&lt;p&gt;Two choices make it a natural fit for web research. It keeps city, state, and ZIP by default, because location is what most “what applies to me” questions turn on. And the model is published openly on &lt;a href="https://huggingface.co/nationaldesignstudio/rampart" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt;, so it runs from Python on your server as well as in the browser. That is how we use it here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Rampart’s model catches
&lt;/h2&gt;

&lt;p&gt;Here is what the code in this tutorial sends onward for a few kinds of questions. Everything in brackets stays on the machine.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Intake&lt;/th&gt;
&lt;th&gt;What leaves your machine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Benefits&lt;/td&gt;
&lt;td&gt;My name is [GIVEN_NAME_1] [SURNAME_1], I live at [BUILDING_NUMBER_1] [STREET_NAME_1], Brookvale, CT 06482, and I make $1,950 a month. What housing assistance in Connecticut am I eligible for?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Banking&lt;/td&gt;
&lt;td&gt;Hi, this is [GIVEN_NAME_1] [SURNAME_1] from Acme Corp, account [BANK_ACCOUNT_1], routing [ROUTING_NUMBER_1]. Why was my wire held?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Travel documents&lt;/td&gt;
&lt;td&gt;Passport [PASSPORT_1], driver’s license [DRIVERS_LICENSE_1] issued in California. Can I renew my passport by mail from Fresno?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health&lt;/td&gt;
&lt;td&gt;I’m [GIVEN_NAME_1] [SURNAME_1], DOB 03/14/1988, member ID [GOVERNMENT_ID_1], diagnosed with type 1 diabetes. Does Anthem cover insulin pumps in Ohio?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spanish&lt;/td&gt;
&lt;td&gt;Mi nombre es [GIVEN_NAME_1] [SURNAME_1], vivo en [STREET_NAME_1] [BUILDING_NUMBER_1], Madrid. ¿Qué ayudas hay para alquiler?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The names and IDs are gone, and the state, program, amount, and topic are still there, so each question can still be answered. The health row also shows what the model leaves alone, like a date of birth, which we cover in tuning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you’ll build
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tabstack.ai/blog/redact-pii-before-web-research" rel="noopener noreferrer"&gt;View the diagram in the original post.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four short files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;redact.py&lt;/code&gt; finds PII with Rampart’s model and swaps it for placeholders.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;research.py&lt;/code&gt; sends the redacted question to Tabstack and gets back a cited report.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reply.py&lt;/code&gt; has your model write the answer from that report.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ask.py&lt;/code&gt; runs the loop and restores the details.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Set up
&lt;/h2&gt;

&lt;p&gt;You need Python 3.9 or later, &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; with a model pulled, and a Tabstack API key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;onnxruntime tokenizers huggingface_hub numpy tabstack ollama
ollama pull llama3.1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TABSTACK_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run downloads Rampart’s model files from Hugging Face and caches them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Redact PII with Rampart’s model
&lt;/h2&gt;

&lt;p&gt;The model labels each token: &lt;code&gt;B-GIVEN_NAME&lt;/code&gt; starts a name, &lt;code&gt;I-STREET_NAME&lt;/code&gt; continues a street, &lt;code&gt;O&lt;/code&gt; is ordinary text. We join those labels into spans, skip the ones in &lt;code&gt;KEEP&lt;/code&gt;, and run a short list of rules for the classes Rampart handles with rules instead of the model: card numbers, SSNs, IP addresses, emails, and URLs. Long messages run in overlapping 512-token windows, the model’s limit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# redact.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;onnxruntime&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ort&lt;/span&gt; &lt;span class="c1"&gt;# type: ignore[import-untyped]
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;huggingface_hub&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hf_hub_download&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tokenizers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Tokenizer&lt;/span&gt; &lt;span class="c1"&gt;# type: ignore[import-untyped]
&lt;/span&gt;
&lt;span class="n"&gt;REPO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nationaldesignstudio/rampart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;KEEP&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;CITY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ZIP_CODE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# the answer needs these; Rampart keeps them too
&lt;/span&gt;&lt;span class="n"&gt;MIN_SCORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="c1"&gt;# start a span
&lt;/span&gt;&lt;span class="n"&gt;EXTEND_SCORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="c1"&gt;# continue a span across punctuation, as Rampart does
&lt;/span&gt;
&lt;span class="n"&gt;tokenizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Tokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hf_hub_download&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tokenizer.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enable_truncation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stride&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# long messages run in overlapping windows
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hf_hub_download&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;id2label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id2label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;label2id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;id2label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InferenceSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hf_hub_download&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onnx/model_q4.onnx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;GAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[\s\-.()/]*&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;luhn_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;number&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\D&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;number&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="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;d&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="k"&gt;for&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;d&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;digits&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="c1"&gt;# Rampart catches these with rules rather than the model, so we do too.
# Each rule is (pattern, label, optional check). Append your own for IDs the model doesn't know.
&lt;/span&gt;&lt;span class="n"&gt;Rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pattern&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Callable&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="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]]]&lt;/span&gt;
&lt;span class="n"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Rule&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="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b\d{3}-\d{2}-\d{4}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SSN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(?:\d{4}[-]?){3}\d{4}\b|\b\d{4}[-]?\d{6}[-]?\d{5}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CARD_NUMBER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;luhn_ok&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(?:\d{1,3}\.){3}\d{1,3}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IP_ADDRESS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EMAIL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\bhttps?://[^\s&amp;lt;&amp;gt;\"&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)\]]*[^\s&amp;lt;&amp;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&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;find_pii&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="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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&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;spans&lt;/span&gt;&lt;span class="p"&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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&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;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;enc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overflowing&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;feeds&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;input_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attention_mask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attention_mask&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_type_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_ids&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feeds&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&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;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="nf"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scores&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;start&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id2label&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmax&lt;/span&gt;&lt;span class="p"&gt;()))]&lt;/span&gt;
            &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
            &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window&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="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;GAP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fullmatch&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;window&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="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
            &lt;span class="c1"&gt;# Join word pieces and multi-part values ("Cedar Lane", "203-555-0142") into one span.
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;O&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;label2id&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]]]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;EXTEND_SCORE&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
                &lt;span class="n"&gt;window&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="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&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;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;O&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MIN_SCORE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;spans&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;

    &lt;span class="n"&gt;rules&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="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;label&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;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RULES&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;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;check&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="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# Rule matches win over the model, longer spans win over shorter ones, and none overlap.
&lt;/span&gt;    &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&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;=&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;span&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;KEEP&lt;/span&gt; &lt;span class="ow"&gt;and&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;span&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;c&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="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;span&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;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&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;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Redactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Swaps PII for numbered placeholders and keeps the mapping in memory.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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="nb"&gt;str&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="c1"&gt;# placeholder -&amp;gt; original value
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;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="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;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;find_pii&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;value&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="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;placeholder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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;v&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&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;placeholder&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&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="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&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="n"&gt;placeholder&lt;/span&gt; &lt;span class="o"&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;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kind&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;count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&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;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;
        &lt;span class="k"&gt;return&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;out&lt;/span&gt; &lt;span class="o"&gt;+&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;last&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;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;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="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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The placeholder table lives in memory in your process. The same value always gets the same placeholder, so a name that comes up again in a follow-up message keeps its placeholder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Research the redacted question with Tabstack
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/research&lt;/code&gt; plans searches, reads sources, and returns a Markdown report with the pages it cited, all inside one call. Your model never fetches a page or reads raw HTML, and Tabstack only receives the redacted question.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# research.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tabstack&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Tabstack&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tabstack&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# reads TABSTACK_API_KEY
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;research&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;tuple&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="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]]:&lt;/span&gt;
    &lt;span class="n"&gt;stream&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="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;research&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&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;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fast&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;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cited_pages&lt;/span&gt; &lt;span class="ow"&gt;or&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;title&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(untitled)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;url&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;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&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="s"&gt;Research failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Research stream ended without a result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the redacted benefits question, a &lt;code&gt;fast&lt;/code&gt; call came back in about 17 seconds with a report on Connecticut income limits, the Section 8 Housing Choice Voucher Program, and the state Rental Assistance Program, citing five sources including the Connecticut Department of Housing. Use &lt;code&gt;mode="balanced"&lt;/code&gt; when a question needs more sources.&lt;/p&gt;

&lt;p&gt;Tabstack is built by Mozilla. Every request is covered by two commitments on our &lt;a href="https://docs.tabstack.ai/trust/data-handling/" rel="noopener noreferrer"&gt;Data Handling page&lt;/a&gt;: never trained on, and private by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Write the reply with your model
&lt;/h2&gt;

&lt;p&gt;Your model gets the redacted question and the finished report. Tell it to keep placeholders exactly as written, so the details can go back in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# reply.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;

&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llama3.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;SYSTEM_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 user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s question using only the research report. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Keep every placeholder, such as [GIVEN_NAME_1], exactly as written.&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;write_reply&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="n"&gt;report&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&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;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&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="s"&gt;Question:&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;question&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;Research report:&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;report&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="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="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any model you run in Ollama works here. Swap &lt;code&gt;MODEL&lt;/code&gt; for Qwen, Mistral, or whatever you already use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Run the loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ask.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;redact&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Redactor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;write_reply&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;research&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;research&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;redactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Redactor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# one per conversation; the mapping never leaves this process
&lt;/span&gt;    &lt;span class="n"&gt;safe_question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redact&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="nf"&gt;print&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="s"&gt;Sent: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;safe_question&lt;/span&gt;&lt;span class="si"&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="n"&gt;report&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="nf"&gt;research&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe_question&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;write_reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe_question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Sources:&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;source&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&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="s"&gt;- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;'&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;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; __main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Usage: python ask.py &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your question&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;ask&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="s"&gt;Could not answer: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;err&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with any question from the table above, or one of your own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python ask.py "Hi, this is Tom Becker from Acme Corp, account 0042981173, routing 021000021. Why was my wire held?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line shows exactly what left your machine. The answer that follows has the real details back in it, with the sources it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tune it for your data
&lt;/h2&gt;

&lt;p&gt;Three adjustments cover most intakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide what stays.&lt;/strong&gt; &lt;code&gt;KEEP&lt;/code&gt; is the list of labels that pass through untouched. City, state, and ZIP are the default because local rules depend on them. A question about a federal program may not need the ZIP; a question about a city service needs the city. Set &lt;code&gt;KEEP&lt;/code&gt; per use case, not once for the whole app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send structured fields by choice.&lt;/strong&gt; When the intake is a form, you already know which field holds the name. There is no need to guess with a model: send the fields the answer needs, run the model only over the free text, and never send the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# intake.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;redact&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Redactor&lt;/span&gt;

&lt;span class="n"&gt;SEND&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;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zip_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;household_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly_income&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# fields the answer needs
&lt;/span&gt;&lt;span class="n"&gt;FREE_TEXT&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;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# fields people type into
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;redactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Redactor&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;lines&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;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&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="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="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;value&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;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SEND&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;redactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;field&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;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FREE_TEXT&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;form&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;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Research a form the same way as a question:&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;intake&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;build_question&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;redact&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Redactor&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;write_reply&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;research&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;research&lt;/span&gt;

&lt;span class="n"&gt;form&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;full_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Maria Garcia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maria@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zip_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;06482&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;household_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly_income&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$1,950&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My landlord Dave Kim raised the rent 30%. What help is there?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;redactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Redactor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redactor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;report&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="nf"&gt;research&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;write_reply&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;report&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The question that goes to Tabstack is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;state: CT
zip code: 06482
household size: 2
monthly income: $1,950
My landlord [GIVEN_NAME_1] [SURNAME_1] raised the rent 30%. What help is there?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name and email fields are never sent at all, and the one name inside the free text is redacted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add rules for your own identifiers.&lt;/strong&gt; Case numbers, patient IDs, policy numbers, and dates of birth follow formats you know. Append them to &lt;code&gt;RULES&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;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;redact&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RULES&lt;/span&gt;

&lt;span class="n"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?i)(?&amp;lt;=dob )\d{1,2}/\d{1,2}/\d{4}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATE_OF_BIRTH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\bCASE-\d{6}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CASE_NUMBER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With those added, “I’m Priya Nair, DOB 03/14/1988, case CASE-204871. Does Ohio Medicaid cover insulin pumps?” goes out as “I’m [GIVEN_NAME_1] [SURNAME_1], DOB [DATE_OF_BIRTH_1], case [CASE_NUMBER_1]. Does Ohio Medicaid cover insulin pumps?”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send only the question.&lt;/strong&gt; Redaction removes identifiers, but the rest of the message still goes to Tabstack: greetings, background, details the answer doesn’t need. Your model is already running locally, so have it rewrite the redacted message as one standalone research question first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# focus.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MODEL&lt;/span&gt;

&lt;span class="n"&gt;FOCUS_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;Rewrite the user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s message as one standalone research question. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Keep only the facts needed to answer it, such as location, program, amounts, dates, or conditions. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Drop greetings, names, placeholders like [GIVEN_NAME_1], and any detail the answer does not depend on. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reply with the question only.&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;focus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FOCUS_PROMPT&lt;/span&gt;&lt;span class="p"&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;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&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="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;ask.py&lt;/code&gt;, import &lt;code&gt;focus&lt;/code&gt;, research and print the focused question, and keep the full redacted message for the reply, so your model can still answer the person directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;focus&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;focus&lt;/span&gt; &lt;span class="c1"&gt;# add to the imports at the top of ask.py
&lt;/span&gt;
&lt;span class="n"&gt;safe_question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redact&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;research_question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe_question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&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="s"&gt;Sent: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;research_question&lt;/span&gt;&lt;span class="si"&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="n"&gt;report&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="nf"&gt;research&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;research_question&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;write_reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;safe_question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the date-of-birth and case rules from above, one run sent Priya’s message to Tabstack as just “Does Ohio Medicaid cover insulin pumps?” and turned the redacted housing question into “What housing assistance programs in Connecticut are available to individuals with a monthly income of $1,950?” The wording changes from run to run.&lt;/p&gt;

&lt;p&gt;Redact first and focus second. The rewrite is a model following instructions, so it can miss. In testing it often kept placeholders like &lt;code&gt;[BANK_ACCOUNT_1]&lt;/code&gt;, which is harmless because a placeholder contains no personal data. It also sometimes added details the person didn’t give, like “for individuals with diabetes”, so check the focused question in the &lt;code&gt;Sent:&lt;/code&gt; line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know what Rampart leaves alone
&lt;/h2&gt;

&lt;p&gt;Rampart redacts identifiers and leaves the rest of the message alone, which is usually what the answer needs: a diagnosis for a coverage question, an income for an eligibility question, an employer for a benefits question. Dates of birth, ages, usernames, and your own internal IDs also pass through until you add a rule.&lt;/p&gt;

&lt;p&gt;Names in non-Latin scripts are out of scope for this release of Rampart, and so are indirect identifiers: details that only point to someone in combination. National Design Studio describes Rampart as harm reduction for people entering their own information, and that is the right way to use this loop too. This tutorial is a minimal Python port of Rampart’s approach, so Rampart’s published evaluation numbers apply to the library itself, not to this code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to take it next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redact in the browser.&lt;/strong&gt; For a web app, use Rampart’s own JavaScript library so questions are redacted before they reach your server at all: &lt;code&gt;npm install @nationaldesignstudio/rampart @huggingface/transformers&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redact replies before you log them.&lt;/strong&gt; Run the model’s output through &lt;code&gt;Redactor().redact()&lt;/code&gt; before it goes into logs or analytics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep one redactor per conversation.&lt;/strong&gt; Placeholders stay consistent across turns, so follow-up questions about the same person keep working.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with your own question: &lt;a href="https://console.tabstack.ai" rel="noopener noreferrer"&gt;get a Tabstack API key&lt;/a&gt; and make a research call.&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>Dev Tool GTM, Done Agentically</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:57:13 +0000</pubDate>
      <link>https://dev.to/tessak22/dev-tool-gtm-done-agentically-gjn</link>
      <guid>https://dev.to/tessak22/dev-tool-gtm-done-agentically-gjn</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1639431682289-72b9ebb75895%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDM5fHxjb2RlfGVufDB8fHx8MTc5MTQ3MjQ1NXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1639431682289-72b9ebb75895%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDM5fHxjb2RlfGVufDB8fHx8MTc5MTQ3MjQ1NXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="Dev Tool GTM, Done Agentically" width="760" height="950"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'm Founding Technical GTM at &lt;a href="https://tabstack.ai/?ref=tessakriesel.com" rel="noopener noreferrer"&gt;Tabstack&lt;/a&gt;. I run GTM with a stack of agents and tools, and I stay in the loop for the calls that need a human.&lt;/p&gt;

&lt;p&gt;This post is the whole system: the parts of dev tool GTM I run, how I do each one today, and what comes out of it.&lt;/p&gt;

&lt;p&gt;One rule holds it together. Positioning, ICP, messaging, voice, and vocabulary are never hard-coded anywhere in the stack. Every system reads them from &lt;strong&gt;Quiver context&lt;/strong&gt; at the moment it works. Change the context in Quiver once, and Tabstack OS, Hermes, and every skill follow on their next run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The systems
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.quivergtm.dev/?ref=tessakriesel.com" rel="noopener noreferrer"&gt;&lt;strong&gt;Quiver&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;:&lt;/strong&gt; the system of record. It holds the product marketing context (positioning, ICP, messaging, competitive landscape, customer language, brand voice, words to use and avoid), plus research, campaigns, the content workflow and calendar, and every report the agents produce. It's versioned. Agents propose changes and I approve them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tabstack OS:&lt;/strong&gt; where I work. A Claude Code workspace that loads Quiver context every session, drafts and reviews content and docs, queries analytics and the CRM, and runs the other systems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hermes:&lt;/strong&gt; my always-on agent. It runs scheduled jobs for competitive intel, community listening, prospecting, sponsorships, social drafts, and event briefs. It reads Quiver context before every job and saves every report to Quiver. It drafts and never posts, publishes, emails, or contacts anyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rival:&lt;/strong&gt; my open-source competitive intel app. I built it on Tabstack to dogfood and learn the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Magister:&lt;/strong&gt; weekly AI visibility tracking and AEO audits.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.mentiondrop.com/?ref=tessakriesel.com" rel="noopener noreferrer"&gt;&lt;strong&gt;MentionDrop&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;:&lt;/strong&gt; social listening on the forums and social sites our own tools can't reach reliably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostHog and GA4 via Supermetrics:&lt;/strong&gt; product and web analytics: signups, API usage, activation, and retention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Airtable CRM:&lt;/strong&gt; data-collection-approved signups have an account, with lifecycle stage, usage, acquisition source, and a PostHog event timeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granola and Linear:&lt;/strong&gt; call transcripts, and where product feedback lands for engineering.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Marketing effort&lt;/th&gt;
&lt;th&gt;How we do it today&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Customer and market research&lt;/td&gt;
&lt;td&gt;Every signal, call, and thread lands in Quiver research, which extracts themes, sentiment, and quotes, scores it against our hypotheses, and proposes context updates.&lt;/td&gt;
&lt;td&gt;Customer language and hypothesis evidence in one place. Approved proposals update what every agent reads.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Positioning&lt;/td&gt;
&lt;td&gt;Human-led. I make the call from Quiver research, product data, and customer calls.&lt;/td&gt;
&lt;td&gt;One positioning that Tabstack OS, Hermes, and every skill read from Quiver at runtime. Change it once, everything follows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messaging&lt;/td&gt;
&lt;td&gt;Agents draft from Quiver context. I polish.&lt;/td&gt;
&lt;td&gt;One vocabulary across site, docs, and posts, with the banned-word list enforced on every draft.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Competitive intel&lt;/td&gt;
&lt;td&gt;Rival, my open-source app built on Tabstack, scans 15 competitors across 11 page types (homepage, pricing, changelog, docs, careers and hiring tech stack, blog, G2 and other reviews, social, GitHub, company profile, tech stack), keeps every diff, and writes intelligence briefs with threat tiers and 7-axis scores. Hermes reads it with Quiver's watchlist every morning, drops the noise, and scores each change against the positioning in Quiver context.&lt;/td&gt;
&lt;td&gt;A daily brief of what actually changed and what to do about it. This week: check Parallel's new Task API tiers before writing Research comparison copy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Community listening&lt;/td&gt;
&lt;td&gt;Find the conversations where the ICP in Quiver context hits the problems we solve, so we show up where the pain is and learn their language. MentionDrop covers the sites our own tools can't reach reliably. Hermes turns the daily haul into exact threads with draft replies in my voice.&lt;/td&gt;
&lt;td&gt;2,680 mentions a month filtered to a short daily list. I reply the same day; the best threads become research.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content production workflow&lt;/td&gt;
&lt;td&gt;Tabstack OS drafts, runs the editorial principles review, fact-checks against the live API, and renders covers. Quiver runs draft, review, approve, publish, plus the calendar.&lt;/td&gt;
&lt;td&gt;13 posts in the 24 days after the pivot, every one technically verified.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs and API reference&lt;/td&gt;
&lt;td&gt;Tabstack OS tech-writing, api-reviewer and docs-reviewer agents, sdk-sync to track SDK changes.&lt;/td&gt;
&lt;td&gt;Docs keep pace with every SDK release.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social&lt;/td&gt;
&lt;td&gt;Hermes drafts LinkedIn and X posts every Thursday from the Quiver calendar.&lt;/td&gt;
&lt;td&gt;Native drafts waiting for every published post. I edit and post.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prospecting and CRM&lt;/td&gt;
&lt;td&gt;Hermes prospects every Tuesday into Airtable. crm-import and trace enrich signups. My network: 10k people I've connected with over nearly 20 years in tech in one deduplicated database, which supplies prospects, warm intros, and contact emails.&lt;/td&gt;
&lt;td&gt;5 to 10 qualified teams a week, each with a cited signal, deduped. 491 signups enriched. Contacts for outreach in minutes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sponsorships and creators&lt;/td&gt;
&lt;td&gt;Hermes finds creators and newsletters whose audience matches the ICP in Quiver context.&lt;/td&gt;
&lt;td&gt;A ranked sponsorship shortlist every week.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Events and CFPs&lt;/td&gt;
&lt;td&gt;Hermes writes a Monday brief, scored attend, speak, or sponsor.&lt;/td&gt;
&lt;td&gt;Events 30 days out and CFPs 90 days out, already scored.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website&lt;/td&gt;
&lt;td&gt;Copy and site architecture from Tabstack OS, Astro build, launch audit.&lt;/td&gt;
&lt;td&gt;Rebrand live 21 days after the positioning decision was. made.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics and attribution&lt;/td&gt;
&lt;td&gt;PostHog and GA4 (via Supermetrics), queried in plain language from Tabstack OS.&lt;/td&gt;
&lt;td&gt;Answers in minutes, no dashboard requests.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activation and retention&lt;/td&gt;
&lt;td&gt;PostHog tracks every account from signup to API key to API usage, plus retention at organization level. The Airtable CRM carries each account's milestone timeline, acquisition source, and use case.&lt;/td&gt;
&lt;td&gt;This data, with Quiver research, is why we pivoted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI visibility (LLM citations)&lt;/td&gt;
&lt;td&gt;Magister tracks 15 prompts weekly across Claude, ChatGPT, Gemini, and Google AI Overviews, plus AEO audits.&lt;/td&gt;
&lt;td&gt;Visibility score 22.3 to 42.7 in two weeks. Homepage AEO 80 to 95.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product feedback loop&lt;/td&gt;
&lt;td&gt;Fully automated. When any of my agents hits a Tabstack error or DX problem, in any repo, it logs it, and a hook files it as a Linear ticket to engineering. No human in the loop.&lt;/td&gt;
&lt;td&gt;Every bit of friction my agents hit becomes an engineering ticket, without anyone stopping to write it up.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&amp;lt;br&amp;gt;
.gtm-table-wrap { width: 100%; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 1.5em 0; }&amp;lt;br&amp;gt;
.gtm-table-wrap table.gtm-table { display: table !important; width: 100% !important; min-width: 640px; max-width: none; table-layout: fixed; border-collapse: collapse; border-spacing: 0; margin: 0; font-size: 0.85em; line-height: 1.45; white-space: normal !important; background: none !important; }&amp;lt;br&amp;gt;
.gtm-table-wrap .gtm-table th, .gtm-table-wrap .gtm-table td { padding: 10px 12px !important; text-align: left; vertical-align: top; border: 0; border-bottom: 1px solid rgba(128, 128, 128, 0.35); white-space: normal !important; overflow-wrap: break-word; background: none !important; }&amp;lt;br&amp;gt;
.gtm-table-wrap .gtm-table th { border-bottom-width: 2px; font-weight: 700; }&amp;lt;br&amp;gt;
.gtm-table-wrap .gtm-table th:nth-child(1) { width: 20%; }&amp;lt;br&amp;gt;
.gtm-table-wrap .gtm-table th:nth-child(2) { width: 46%; }&amp;lt;br&amp;gt;
.gtm-table-wrap .gtm-table th:nth-child(3) { width: 34%; }&amp;lt;br&amp;gt;
.gtm-table-wrap .gtm-table td:nth-child(1) { font-weight: 600; }&amp;lt;br&amp;gt;
&amp;lt;!--kg-card-end: html--&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The eight cores of dev tool GTM, and how the stack covers them
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Know the developer
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ICP and jobs to be done from real interviews:&lt;/strong&gt; Granola captures calls. Each call becomes a Quiver research entry, which is processed automatically for themes, sentiment, quotes, and whether it validates or challenges an active hypothesis, then turned into proposed context updates. I approve them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events and in-person community to validate the market:&lt;/strong&gt; conversations at events and meetups go into Quiver research from my own notes, then get the same automatic processing: themes, quotes, hypothesis signals, and proposed context updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch developers try the product and log the friction:&lt;/strong&gt; my agents use Tabstack every day. When one hits an error or DX problem, it logs it and a hook files it as a Linear ticket to engineering. Fully automated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track competitors and real alternatives, including build-it-yourself:&lt;/strong&gt; Rival scans 15 competitors across 11 page types and writes threat-scored intelligence briefs. Hermes reads it with Quiver's watchlist every morning and scores each change against the positioning in Quiver context. Community listening surfaces the DIY builds people post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Positioning and messaging
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Category, alternatives, differentiated value:&lt;/strong&gt; human-led. I make the call from Quiver research, product data, and customer calls. It lives in Quiver context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messaging hierarchy:&lt;/strong&gt; messaging pillars live in Quiver context. Agents draft from them. I polish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homepage, docs landing, and pricing copy:&lt;/strong&gt; Tabstack OS drafts from Quiver context and writes the site architecture. The rebrand went live 21 days after the positioning call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everyone saying the same thing:&lt;/strong&gt; every agent and skill reads Quiver context at runtime and enforces its words-to-avoid list on every draft. Context changes are proposals I approve, so nothing drifts silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Developer experience
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Time to first value:&lt;/strong&gt; PostHog tracks every account from signup to API key to API usage. The Airtable CRM carries that milestone timeline per account, with usage days and span, so I can see how far each signup got and how long it took.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs, examples, SDKs, error messages:&lt;/strong&gt; Tabstack OS writes docs, READMEs, changelogs, and API reference. Review agents check every claim against SDK source and the live API. sdk-sync tracks upstream SDK, API, and docs repos and updates what changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent-readiness:&lt;/strong&gt; llms.txt and llms-full.txt on the site, plus AEO audits in Magister.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route field feedback into the roadmap:&lt;/strong&gt; the automated DX-notes-to-Linear loop, plus product signals flagged on Quiver research entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing and packaging:&lt;/strong&gt; competitor pricing changes arrive daily from Hermes. Pricing decisions are fed from research, made by a human.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Awareness and demand
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Technical content:&lt;/strong&gt; Tabstack OS drafts, reviews against the 10 editorial principles, fact-checks against the live API, and renders covers. Quiver runs draft, review, approve, publish, and the calendar. 13 posts shipped in the 24 days after the pivot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search visibility, including AI answer engines:&lt;/strong&gt; Magister tracks AI visibility weekly across Claude, ChatGPT, Gemini, and Google AI Overviews, plus AEO audits. Search Console through Supermetrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Community presence:&lt;/strong&gt; MentionDrop listens on the forums and social sites our own tools can't reach reliably, using keywords tuned to the ICP in Quiver context. Hermes turns the daily haul into exact threads with draft replies in my voice. I reply. The best threads become research.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events, talks, CFPs:&lt;/strong&gt; Hermes writes a Monday brief of events 30 days out and CFPs 90 days out, scored attend, speak, or sponsor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sponsorships:&lt;/strong&gt; Hermes finds creators and newsletters whose audience matches the ICP in Quiver context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrations, partnerships, listings:&lt;/strong&gt; tracked as Quiver campaigns. Directory submissions and co-marketing skills in Tabstack OS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Social:&lt;/strong&gt; Hermes drafts LinkedIn and X posts every Thursday from the Quiver calendar. I edit and post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Activation and conversion
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instrumentation:&lt;/strong&gt; PostHog and GA4, queried in plain language from Tabstack OS, which also builds the dashboards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finding and fixing the biggest drop-off:&lt;/strong&gt; the funnel from visit to signup to API key to usage is read in PostHog. Every account carries its acquisition source and use case, so drop-off splits by channel and by audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention:&lt;/strong&gt; PostHog tracks retention at organization level. The CRM shows usage days and span per account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product-qualified accounts:&lt;/strong&gt; the Airtable CRM holds every signup with lifecycle stage, usage, acquisition source, newsletter status, and a PostHog event timeline, kept current by crm-import. Accounts that created an API key are enriched through trace and classified by use case. Hermes adds qualified prospects weekly, each with a cited signal, deduplicated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network intelligence:&lt;/strong&gt; my network: 10k people I've connected with over nearly 20 years in tech into one deduplicated database with append-only enrichment (Hunter, GitHub, Gravatar, Tabstack). Searchable in a local UI. It's where prospects, warm intros, and contact emails come from. Hermes keeps it up-to-date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle email:&lt;/strong&gt; drafted in Tabstack OS and tracked as a Quiver campaign so performance can be logged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Revenue
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sales enablement:&lt;/strong&gt; one-pagers, objection handling, and demo material drafted in Tabstack OS from Quiver context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Founder-led sales&lt;/strong&gt; : Quiver holds the ICP shape. Hermes finds teams that fit it in my network and beyond, and adds them to the CRM with the signal. I reach out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer stories (in progress):&lt;/strong&gt; drawn from Quiver research, the same calls, quotes, and notes that feed everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. Measurement and ops
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decisions from the data:&lt;/strong&gt;  &lt;strong&gt;&lt;em&gt;our most recent pivot came out of this system&lt;/em&gt;&lt;/strong&gt;. PostHog activation and retention data, plus Quiver research from calls, interviews, and forum threads scored against active hypotheses, is why we repositioned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data that keeps getting better:&lt;/strong&gt; I've improved the data the whole time. Client-side web analytics added in June. Accounts tagged by job (extract, research, automate) from endpoint usage, first-touch source backfilled, and PostHog events synced into the CRM. Analytics identity and signup-source tracking shipped for the pivot. Complete attribution tracking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly review:&lt;/strong&gt; every Tabstack OS session opens with a brief pulled from Quiver: what's due, Hermes reports and research waiting on review, what's publishing, what shipped, and pending context proposals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Experiments with stated hypotheses:&lt;/strong&gt; active hypotheses live in Quiver context. Every research entry is scored against them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics:&lt;/strong&gt; a north-star metric and the funnel behind it in PostHog, plus GA4, Search Console, and Magister AI visibility, all queried from Tabstack OS and synthesized into Quiver as performance logs and the weekly marketing report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tooling, budget, hiring:&lt;/strong&gt; drafted in Tabstack OS. Decisions are human.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  8. Internal alignment
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One agreed ICP and priority set:&lt;/strong&gt; Quiver context is the single source, versioned, with every change approved by me. Everyone works together in Quiver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report in business terms:&lt;/strong&gt; exec updates, one-pagers, and decks drafted in Tabstack OS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly marketing updates&lt;/strong&gt; : built and sent on Mondays by Hermes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What stays human
&lt;/h2&gt;

&lt;p&gt;Agents draft. People decide. Positioning calls, design, privacy, publishing, posting, and outreach all stay with a human.&lt;/p&gt;

&lt;p&gt;If this stack is speaking your love language, check out &lt;a href="https://www.quivergtm.dev/?ref=tessakriesel.com" rel="noopener noreferrer"&gt;Quiver&lt;/a&gt;—#3 on &lt;a href="https://www.producthunt.com/products/quiver-gtm?ref=tessakriesel.com" rel="noopener noreferrer"&gt;Product Hunt&lt;/a&gt; on Vercel Day. 🔥&lt;/p&gt;

&lt;p&gt;&lt;em&gt;You might also like&lt;/em&gt; &lt;a href="https://tessakriesel.com/how-i-grew-signups-71-in-90-days-by-running-gtm-like-an-engineer/" rel="noopener noreferrer"&gt;&lt;em&gt;How I Grew Signups 71% in 90 Days by Running GTM like an Engineer&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>When should you use search, agentic search, a Research API, or build the loop yourself?</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Mon, 05 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/tabstack/when-should-you-use-search-agentic-search-a-research-api-or-build-the-loop-yourself-1aaj</link>
      <guid>https://dev.to/tabstack/when-should-you-use-search-agentic-search-a-research-api-or-build-the-loop-yourself-1aaj</guid>
      <description>&lt;p&gt;Use a search API when your application needs sources. Use agentic search when your model needs to choose what to investigate next. Use a Research API when your application needs a finished answer with citations. Build the loop yourself when control over source selection, evidence handling, or synthesis is part of your product.&lt;/p&gt;

&lt;p&gt;The decision starts with the job. If a coding assistant needs a parameter definition from a known documentation page, fetch that page. If it needs to compare current capabilities across several projects, someone must find the sources, read them, check coverage, and reconcile the evidence before writing the answer.&lt;/p&gt;

&lt;p&gt;Choose where that work belongs and which parts your team needs to control.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, define what your application must receive
&lt;/h2&gt;

&lt;p&gt;“Add web access” leaves the output undefined. Write the result your application actually needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Required output&lt;/th&gt;
&lt;th&gt;Starting point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Relevant URLs and snippets&lt;/td&gt;
&lt;td&gt;Search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean content from a known URL&lt;/td&gt;
&lt;td&gt;Fetch or Markdown extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Specific fields from a known page&lt;/td&gt;
&lt;td&gt;Structured extraction, followed by value checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An answer supported by current sources&lt;/td&gt;
&lt;td&gt;A research loop, operated by your application, agent, or managed service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A completed website task&lt;/td&gt;
&lt;td&gt;Browser automation with explicit action boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These jobs can exist inside the same application. A pasted link does not need source discovery. A request to find current deployment guidance might. Reading a pricing page and comparing several vendors’ pricing are different workflows even when both begin with a URL.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://tabstack.ai/blog/search-vs-research" rel="noopener noreferrer"&gt;search-versus-research guide&lt;/a&gt; explains the work between finding sources and producing a cited answer. This guide helps you decide who should perform it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each approach gives you
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A search API returns candidates for your application to use
&lt;/h3&gt;

&lt;p&gt;A search API fits a source picker, a documentation finder, or a system that already knows how to build an answer from retrieved material.&lt;/p&gt;

&lt;p&gt;For example, &lt;a href="https://docs.ollama.com/capabilities/web-search" rel="noopener noreferrer"&gt;Ollama’s Web Search API&lt;/a&gt; returns results with &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, and a relevant &lt;code&gt;content&lt;/code&gt; snippet. Its Web Fetch API returns a page’s main content and links. The two responses support different next steps: a snippet helps identify a source; fetched content gives your application material to inspect.&lt;/p&gt;

&lt;p&gt;If your feature displays links, search can finish the job. If it displays a cited explanation, your application still owns source selection, reading, answer construction, and the connection between claims and sources. That can be a small, bounded implementation when one authoritative page contains the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose search when finding sources is the required output, or your existing downstream system already meets the answer requirements.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Agentic search lets the model decide what to do next
&lt;/h3&gt;

&lt;p&gt;In model-operated agentic search, the application gives the model search and fetch tools. The model chooses a query, reads the results, and decides whether to open a page, search again, or answer. The application executes those tool calls and enforces their limits.&lt;/p&gt;

&lt;p&gt;Ollama’s &lt;a href="https://docs.ollama.com/capabilities/web-search#building-a-search-agent" rel="noopener noreferrer"&gt;search-agent example&lt;/a&gt; demonstrates this pattern. It adds tool results to the conversation and continues until the model stops requesting tools.&lt;/p&gt;

&lt;p&gt;This approach fits a task whose next step depends on what the agent learns. A coding assistant can inspect a local dependency version, retrieve the corresponding documentation, and use it while proposing a change. The application decides which local context the model can use and which information it can send in a public-web query.&lt;/p&gt;

&lt;p&gt;You still maintain tool definitions, context handling, loop limits, source mappings, and failure behavior. Treat web content as untrusted input; it must not grant the agent new permissions or override the application’s instructions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose agentic search when the model’s adaptive investigation belongs inside the larger task.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A Research API returns the answer before your model continues
&lt;/h3&gt;

&lt;p&gt;A managed Research API takes a question and performs the answer-building work inside the service. Your application receives the researched output instead of executing each search and fetch decision itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.tabstack.ai/guides/research" rel="noopener noreferrer"&gt;Tabstack Research&lt;/a&gt; plans queries, discovers and reads sources, checks gaps, follows up when needed, and synthesizes a Markdown report with citation metadata. It returns the report on the stream’s terminal &lt;code&gt;complete&lt;/code&gt; event. Your application can give that result to your own model as an input to its next step.&lt;/p&gt;

&lt;p&gt;That boundary suits a briefing feature or assistant that needs a current public-web answer but does not need to control every intermediate retrieval decision.&lt;/p&gt;

&lt;p&gt;Your application still owns acceptance. Check whether the answer covers the question and whether its decision-driving claims have support. Handle task-level &lt;code&gt;error&lt;/code&gt; events, and treat a stream that ends without a terminal event as incomplete. Research always streams; receiving progress is not the same as receiving the finished report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose a Research API when you need the answer as an input and the service’s exposed controls satisfy the job.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A DIY loop gives your team control over the implementation
&lt;/h3&gt;

&lt;p&gt;A custom loop lets you choose the retrieval components, source-selection rules, context limits, reconciliation method, synthesis prompts, and citation construction. You can combine deterministic steps with model decisions.&lt;/p&gt;

&lt;p&gt;Build when those choices create product value or satisfy a requirement a managed service cannot meet. Examples include a prescribed source hierarchy, retrieval across private and public systems, or a requirement to preserve selected evidence passages in your own format.&lt;/p&gt;

&lt;p&gt;Ownership includes maintenance. Your team handles changed pages, failed fetches, provider limits, model changes, incomplete evidence, and malformed output. It also maintains the evaluation that determines whether the loop still works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose DIY when you can name the intermediate decisions you must control and assign the engineering capacity to operate them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Agentic search and DIY overlap. “Agentic” describes how the system chooses its next action; “DIY” describes what your team builds and owns. A managed Research service can also run an agentic loop internally. Inspect the response contract and control boundary rather than choosing from the label alone.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What your application receives&lt;/th&gt;
&lt;th&gt;Who chooses follow-up work?&lt;/th&gt;
&lt;th&gt;What your team maintains&lt;/th&gt;
&lt;th&gt;Prefer another path when…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Search API&lt;/td&gt;
&lt;td&gt;Candidate sources, commonly URLs and snippets&lt;/td&gt;
&lt;td&gt;Your application or model&lt;/td&gt;
&lt;td&gt;Retrieval integration and any downstream answer-building&lt;/td&gt;
&lt;td&gt;You need a finished answer but have no dependable downstream loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model-operated agentic search&lt;/td&gt;
&lt;td&gt;The configured agent’s answer or task result&lt;/td&gt;
&lt;td&gt;Your model, within application limits&lt;/td&gt;
&lt;td&gt;Tools, prompts, context, orchestration, and acceptance checks&lt;/td&gt;
&lt;td&gt;The web question is a separable subtask and the model does not need intermediate control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed Research API&lt;/td&gt;
&lt;td&gt;The service’s documented report and citation format&lt;/td&gt;
&lt;td&gt;The service, within its exposed controls&lt;/td&gt;
&lt;td&gt;Integration, output acceptance, and provider fit&lt;/td&gt;
&lt;td&gt;Required sources, controls, or processing boundaries fall outside the service’s contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DIY research loop&lt;/td&gt;
&lt;td&gt;The output contract you implement&lt;/td&gt;
&lt;td&gt;Your code, models, or both&lt;/td&gt;
&lt;td&gt;Components, orchestration, evidence handling, evaluation, and operations&lt;/td&gt;
&lt;td&gt;The custom loop adds no necessary control and your team cannot justify maintaining it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The architecture does not establish which implementation answers better, runs faster, or costs less. Compare the complete configurations on your questions. Include failed attempts and incomplete outputs rather than pricing only the successful demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the work run?
&lt;/h2&gt;

&lt;p&gt;Use this process model before selecting a provider:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tabstack.ai/blog/search-agentic-search-research-api-or-diy" rel="noopener noreferrer"&gt;View the diagram in the original post.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If a required processing boundary rules out a provider, eliminate it before testing answer quality. A good report cannot make an incompatible data path acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply the guide to a concrete job
&lt;/h2&gt;

&lt;p&gt;Suppose a developer assistant receives this question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What does Ollama’s Web Search API return, how does that differ from Web Fetch, and can I display the search response as a cited answer?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before running anything, define the required answer elements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Name the documented fields in each response.&lt;/li&gt;
&lt;li&gt;Distinguish a relevant snippet from fetched page content.&lt;/li&gt;
&lt;li&gt;Explain the application work required to turn those responses into an answer.&lt;/li&gt;
&lt;li&gt;Cite the documentation for the API facts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is a complete, documentation-derived answer example, not output from a new API run:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Ollama’s Web Search API returns a &lt;code&gt;results&lt;/code&gt; array whose entries contain &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, and a relevant &lt;code&gt;content&lt;/code&gt; snippet. Web Fetch returns &lt;code&gt;title&lt;/code&gt;, the page’s main &lt;code&gt;content&lt;/code&gt;, and &lt;code&gt;links&lt;/code&gt;. Neither response defines a finished cited-answer format. Your application can display search results directly, but it must construct the explanation and attach supporting sources if the feature promises a cited answer. See the &lt;a href="https://docs.ollama.com/capabilities/web-search" rel="noopener noreferrer"&gt;Web Search and Web Fetch documentation&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;For this job, start by reading the known documentation page.&lt;/strong&gt; It contains the response definitions. If your application can write the explanation and preserve its source, it does not need an open-ended research loop.&lt;/p&gt;

&lt;p&gt;Change the job and the choice can change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find official web-search documentation.&lt;/strong&gt; Use search when you do not already know the destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspect this repository’s dependency version and work out the relevant integration steps.&lt;/strong&gt; Use an agentic path when the model needs to move between local inspection and public documentation. Keep private context out of public queries unless your policy explicitly permits it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare current retrieval approaches for a developer assistant using evidence across several projects.&lt;/strong&gt; Evaluate a Research API if your application needs the comparison report before its model continues. Freeze the comparison criteria first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use only approved sources and preserve the exact passages selected at every stage.&lt;/strong&gt; Build or select a loop that exposes those controls. Do not infer that a report and citation list satisfy an evidence-archive requirement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question determines the workflow. A product does not need one architecture for every web request.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Tabstack is not the answer
&lt;/h2&gt;

&lt;p&gt;We built Tabstack to return finished web output to applications. These requirements point elsewhere:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your requirement&lt;/th&gt;
&lt;th&gt;Better starting point&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;You only need ranked links and snippets&lt;/td&gt;
&lt;td&gt;A search API&lt;/td&gt;
&lt;td&gt;Research performs answer-building work the feature does not need&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your existing assistant or search loop already meets the job&lt;/td&gt;
&lt;td&gt;Keep the existing path&lt;/td&gt;
&lt;td&gt;A new provider needs a concrete reason to enter the architecture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your product must control intermediate source selection or synthesis beyond the API’s controls&lt;/td&gt;
&lt;td&gt;A custom loop or service exposing those controls&lt;/td&gt;
&lt;td&gt;The required behavior belongs in the implementation contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your workload must stay inside an air-gapped or exclusively controlled environment&lt;/td&gt;
&lt;td&gt;An architecture compatible with that boundary&lt;/td&gt;
&lt;td&gt;Hosted Tabstack Research processes requests outside your environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your workflow needs private sources the public-web service cannot access&lt;/td&gt;
&lt;td&gt;An authorized private-data retrieval path, optionally combined with public research&lt;/td&gt;
&lt;td&gt;Public-web research cannot supply inaccessible evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need a hosted browser task behind a login&lt;/td&gt;
&lt;td&gt;An authenticated automation system you control&lt;/td&gt;
&lt;td&gt;Hosted Tabstack Automate handles public websites and has no credential store or session management&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A known URL is a separate case: it can fit Tabstack, but usually through &lt;a href="https://tabstack.ai/structured-extraction" rel="noopener noreferrer"&gt;Extract&lt;/a&gt;, not Research. Ask for Markdown when the next step needs page content, or matching JSON when it needs fields. Validate the values as well as the shape.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mozilla/pilo" rel="noopener noreferrer"&gt;Pilo&lt;/a&gt; provides a self-hosted automation path. It is not a self-hosted deployment of Tabstack Research.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the processing boundary
&lt;/h2&gt;

&lt;p&gt;Running your own model does not make external web services local. Search queries and research requests follow the data paths of the providers your application calls.&lt;/p&gt;

&lt;p&gt;Tabstack Research runs on hosted infrastructure. Contracted model providers process the query and web content to produce the answer. Our &lt;a href="https://docs.tabstack.ai/trust/data-handling/" rel="noopener noreferrer"&gt;Data Handling documentation&lt;/a&gt; describes that path, and the &lt;a href="https://tabstack.ai/legal/privacy" rel="noopener noreferrer"&gt;Privacy Notice&lt;/a&gt; is the canonical policy. Confirm workload-specific retention and provider requirements before sending sensitive inputs. Do not interpret “never trained on” as a promise that no hosted processing or storage occurs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose a path for one real job
&lt;/h2&gt;

&lt;p&gt;Write down the output your feature promises, the intermediate decisions you must control, and the data it can send outside your environment. Then test the smallest approach that satisfies those requirements.&lt;/p&gt;

&lt;p&gt;Use the &lt;a href="https://tabstack.ai/blog/useful-citations-ai-answers" rel="noopener noreferrer"&gt;citation review method&lt;/a&gt; to check claim support and answer coverage. The &lt;a href="https://tabstack.ai/blog/trace-question-to-source-backed-answer" rel="noopener noreferrer"&gt;traced Python example&lt;/a&gt; shows how to preserve the report, cited pages, and request timeline. For a comparison between complete systems, use the &lt;a href="https://tabstack.ai/blog/search-fetch-model-vs-managed-research-evaluation-plan" rel="noopener noreferrer"&gt;search + fetch + model evaluation plan&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your job needs a finished answer from public sources, bring that question to the &lt;a href="https://console.tabstack.ai/playground/research" rel="noopener noreferrer"&gt;Research playground&lt;/a&gt;, which requires sign-in. Write its required answer elements first, then check the returned report against them. If search or a known-page fetch already meets the job, use it.&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>How to Make Claude Code Stop Writing Output Essays</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Fri, 31 Jul 2026 18:18:00 +0000</pubDate>
      <link>https://dev.to/tessak22/how-to-make-claude-code-stop-writing-output-essays-1p2m</link>
      <guid>https://dev.to/tessak22/how-to-make-claude-code-stop-writing-output-essays-1p2m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1455390582262-044cdead277a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDJ8fGVzc2F5fGVufDB8fHx8MTc4NTUyMTU5OXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1455390582262-044cdead277a%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDJ8fGVzc2F5fGVufDB8fHx8MTc4NTUyMTU5OXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="How to Make Claude Code Stop Writing Output Essays"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I can't stand reading long output.&lt;/p&gt;

&lt;p&gt;Not "I mildly prefer brevity." I mean I hit a wall of text in my terminal and my brain refuses. I asked Claude to fix a nav dropdown, it fixed it, and then it wrote me eleven lines summarizing the fix I had just watched it make. Tool calls are visible. I saw the diff. I don't need the recap.&lt;/p&gt;

&lt;p&gt;Sound familiar?&lt;/p&gt;

&lt;p&gt;Telling it to be concise doesn't work. Not reliably. I had a memory saved that said "keep replies SHORT, she reacts strongly to long output," and it still handed me a status inventory with four headers.&lt;/p&gt;

&lt;p&gt;So I went looking for what actually holds. Four options, ranked from weakest to strongest.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLAUDE.md
&lt;/h2&gt;

&lt;p&gt;The obvious one. Put your rule in &lt;code&gt;./CLAUDE.md&lt;/code&gt; for a project, or &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; for every project on the machine.&lt;/p&gt;

&lt;p&gt;This is where most people stop, and it's the weakest option. Not because it doesn't load, it does, every session. It's that your rule sits in a file competing with project context, tool definitions, and a conversation that grows all afternoon. By hour two it's noise.&lt;/p&gt;

&lt;p&gt;One thing that genuinely helps: put the rule at the very top of the file, not buried at the bottom. Position matters more than I expected.&lt;/p&gt;

&lt;p&gt;Use this if you want something scoped, visible, and easy to change. Don't expect it to hold under a long session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills
&lt;/h2&gt;

&lt;p&gt;Skip this one. I went down this path first and it's a dead end for this problem.&lt;/p&gt;

&lt;p&gt;Skills load when they're invoked. That's the whole design. A skill that says "write concisely" only helps if the model remembers to invoke it, which is the exact failure you're trying to fix. You'd be relying on the thing that's already unreliable.&lt;/p&gt;

&lt;p&gt;Skills are great. They're just the wrong tool for always-on behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Append a system prompt
&lt;/h2&gt;

&lt;p&gt;This is what I landed on.&lt;/p&gt;

&lt;p&gt;Claude Code takes a &lt;code&gt;--append-system-prompt-file&lt;/code&gt; flag. Whatever is in that file gets appended to the system prompt every single turn. Not memory, not context, the actual system prompt.&lt;/p&gt;

&lt;p&gt;Write your rules to a file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude/concise.md

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then alias the command so you don't have to remember the flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;claude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'claude --append-system-prompt-file ~/.claude/concise.md'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open a new terminal and you're done. Every session, every project, no flag to remember.&lt;/p&gt;

&lt;p&gt;Two tradeoffs worth knowing. It applies everywhere, which I wanted but you might not. And it's invisible, so six months from now you may forget why your output looks different. Leave yourself a comment in the alias.&lt;/p&gt;

&lt;p&gt;What goes in the file matters more than the mechanism. "Be concise" is useless. Ban specific behaviors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No recapping work the user just watched&lt;/li&gt;
&lt;li&gt;No status inventories unless asked&lt;/li&gt;
&lt;li&gt;No preamble ("Great", "You're right", "Let me")&lt;/li&gt;
&lt;li&gt;No closing summary paragraph&lt;/li&gt;
&lt;li&gt;No unsolicited next-step menus&lt;/li&gt;
&lt;li&gt;Answer first, context second&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Specific prohibitions beat vague vibes. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Stop hook
&lt;/h2&gt;

&lt;p&gt;This is the only option that actually forces anything.&lt;/p&gt;

&lt;p&gt;Hooks are different from everything above. The harness runs them, not the model. A &lt;code&gt;Stop&lt;/code&gt; hook fires when Claude finishes a response, and it can block that response and send feedback, forcing a retry. You configure it in &lt;code&gt;settings.json&lt;/code&gt; alongside whatever else you're running.&lt;/p&gt;

&lt;p&gt;That's real enforcement. Nothing else on this list is.&lt;/p&gt;

&lt;p&gt;I passed on it for two reasons. A blocked response means a retry, and a retry means more tokens, which is a strange way to solve "you're wasting my tokens yapping." And any length check crude enough to write is crude enough to block an answer that genuinely needed the room.&lt;/p&gt;

&lt;p&gt;If you want determinism and you accept the cost, this is your option. I wanted most of the benefit without the retry loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to do
&lt;/h2&gt;

&lt;p&gt;Start with CLAUDE.md and append a system prompt. The alias is one line, it works everywhere, and you can delete it in five seconds.&lt;/p&gt;

&lt;p&gt;Spend your effort on the rules file, not the mechanism. Ban behaviors by name. Vague instructions produce vague compliance.&lt;/p&gt;

&lt;p&gt;And be honest with yourself about why you want this. Walls of text aren't a preference problem, they're a wall. Tuning your tools around how your brain actually works isn't precious. It's the whole point of having tools you can configure.&lt;/p&gt;

&lt;p&gt;The model won't hold a rule you only mention once. Put it somewhere structural.&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>How I Grew Signups 71% in 90 Days by Running GTM Like an Engineer</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Wed, 08 Jul 2026 02:19:55 +0000</pubDate>
      <link>https://dev.to/tessak22/how-i-grew-signups-71-in-90-days-by-running-gtm-like-an-engineer-cpk</link>
      <guid>https://dev.to/tessak22/how-i-grew-signups-71-in-90-days-by-running-gtm-like-an-engineer-cpk</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1551822554-eaef9bf157f3%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDE4fHxzeXN0ZW1zfGVufDB8fHx8MTc4MzQ2OTAzNnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1551822554-eaef9bf157f3%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDE4fHxzeXN0ZW1zfGVufDB8fHx8MTc4MzQ2OTAzNnww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="How I Grew Signups 71% in 90 Days by Running GTM Like an Engineer" width="799" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In my first 90 days at &lt;a href="https://tabstack.ai/?utm_source=tessakriesel.com&amp;amp;utm_medium=blog&amp;amp;utm_campaign=90-days-gtm-like-an-engineer" rel="noopener noreferrer"&gt;Tabstack&lt;/a&gt;, signups went up 71%. I didn’t get there by running a pile of campaigns. I got there by building software.&lt;/p&gt;

&lt;p&gt;I market developer tools, and I’ve been writing code since 2006. That combination changes how I work. Where a lot of marketers open a campaign calendar, I open my terminal. I treat go-to-market like an engineering problem: understand the system, build it, ship it, measure it, then make it better.&lt;/p&gt;

&lt;p&gt;Here’s exactly what I did, week by week, and the play you can take from each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before Day One: I Built on the Product First
&lt;/h2&gt;

&lt;p&gt;The best decision I made happened before my first day.&lt;/p&gt;

&lt;p&gt;Once I accepted the offer, I built a real product on Tabstack’s API. It’s called Rival, an open-source competitive-intelligence app that tracks everything a competitor does in public: pricing, hiring, product changes, positioning, reviews, GitHub activity, and it runs full cited research on demand, then tells you what actually changed and uses all five of Tabstack’s endpoints.&lt;/p&gt;

&lt;p&gt;I do this kind of thing with every developer product I take on. I want to understand the product I'm marketing. So I built, and ran a full developer-experience audit while I built Rival, logging every rough edge I hit. Developers are the audience. I wanted the developer experience improved before I sent more developers there.&lt;/p&gt;

&lt;p&gt;Rival actually serves multiple purposes. It’s a test surface for the product. It’s a competitive-intelligence tool I use every day. And it’s a distribution play: it’s open source, self-hosting it requires a Tabstack API key, and I had the campaign to get it in front of developers planned before I ever started.&lt;/p&gt;

&lt;p&gt;The play: use the product yourself, at real depth, before you market it. Build something real with it, not a click-through demo, so you understand what you're actually marketing and you catch the gaps early, before your users hit them. If you're not a coder, try Claude code or other coding agents, they're quite good. Your developers will thank you.&lt;/p&gt;

&lt;p&gt;Rival is open source. You can &lt;a href="https://rivaldemo.com/?ref=tessakriesel.com" rel="noopener noreferrer"&gt;try the live demo&lt;/a&gt; or &lt;a href="https://github.com/tessak22/rival?ref=tessakriesel.com" rel="noopener noreferrer"&gt;dig into the code&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week One: I Stood Up My Systems Before I Ran Any Marketing
&lt;/h2&gt;

&lt;p&gt;I came in already knowing the product and the direction I wanted to take, so I started with systems, not deliverables. I built our GTM Command Center, the hub the whole operation would run on, so a single change to our positioning reaches everything downstream. And I stood up our CRM under a real constraint: at Mozilla, consent is off by default and we collect almost nothing unless the user says yes, so I can’t lean on behavioral tracking to understand who signs up. My CRM works entirely from public data instead, respectful by design. The Mozilla way.&lt;/p&gt;

&lt;p&gt;I'd done this before. I ran a developer GTM agency before Tabstack, so I came in with a playbook, not a blank page. These systems are that playbook, built for Tabstack.&lt;/p&gt;

&lt;p&gt;The play: don’t start with campaigns. Start with the infrastructure that makes every campaign easier to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Two: I Turned the Docs Gap Into a System
&lt;/h2&gt;

&lt;p&gt;When you actually build on a developer product, its problems announce themselves. A setup step fails silently. The docs skip the exact case you just hit. The messaging never quite says what the thing does. We had all of it, and I refuse to send a developer to a bad developer experience, so I fixed ours before I drove anyone to it. Since I’m a team of one, I didn’t rewrite the docs myself. I built a system, trained deeply on Tabstack: a skill for each kind of doc, a knowledge base of the product, review steps that check every claim against the live API, and a pipeline that drafts and opens a pull request for me to approve.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It wrote 45 docs in a single day, across half a dozen pull requests, that needed only light polish.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is how I work: I build a system to solve the problem, then keep improving it as I use it. For a team of one, that's the best way to scale. The one I wrote for docs writes my marketing content now, too.&lt;/p&gt;

&lt;p&gt;The play: never send a developer to a bad developer experience. Marketing only gets a developer to the door. The developer experience decides whether they stay, and developers judge it fast and rarely give it a second look. That makes DX a prerequisite for marketing, not a parallel track. So fix the docs, the messaging, and the setup that fails silently before you drive anyone to them. And fix them with a system, not a one-off, so they hold as the product keeps changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Three: I Did the Strategic Work, Then Placed the Bets
&lt;/h2&gt;

&lt;p&gt;Leadership wanted to put money into marketing earlier than I’m normally ready to make bets on. Early or not, I wasn't going to spend it on a guess. So I started with the questions that actually matter.&lt;/p&gt;

&lt;p&gt;What does this product need to succeed? How do we stand apart from everyone else fetching web data? What pays off this month, what compounds over multiple quarters, and how do I fund both at once?&lt;/p&gt;

&lt;p&gt;The answer was five bets, each one tied to how developers actually find and adopt tools: showing up in the tools they already use, a public schema library where every use case becomes its own discoverable entry point, content built to be found by the AI models developers now ask, brand and marketing execution, and field and community distribution.&lt;/p&gt;

&lt;p&gt;The play: do the strategic work before you spend a dollar. Understand the market, define what you are and how you differ, then make a short list of bets that balance quick wins against longer plays that compound. The best opportunities will make themselves known this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Four: I Launched by Day 24, With the Data Dashboard First
&lt;/h2&gt;

&lt;p&gt;By day 24, we had our first Product Hunt launch live.&lt;/p&gt;

&lt;p&gt;Product Hunt was deliberate, and not for the obvious reason that developers are there. It’s a source the AI models trust, so a launch doubles as training signal for the assistants developers now ask. I also have a track record there, which buys speed. I planned and launched this one in a few days. Early, trusted, and fast to run was exactly the profile I wanted for a first move.&lt;/p&gt;

&lt;p&gt;Two days before it went live, I stood up our first metrics dashboard across the whole developer journey, API usage, activation funnel, time to value, playground to production, so the moment traffic hit I could see what it did, not just that it showed up. Instrument before you ship, or the launch is noise you can’t learn from.&lt;/p&gt;

&lt;p&gt;The play: early on, ship one real growth effort, and choose it by impact. You’ll have a hundred things you could do. The discipline is picking the one that drives the most growth right now and getting it out, while the longer-term work keeps moving in parallel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weeks Five and Six: I Built an Agent to Scale Myself, Not to Replace Myself
&lt;/h2&gt;

&lt;p&gt;There’s too much for one person to track. Competitors, community, prospects, sponsorships, content, events. So I built a go-to-market agent, on the open-source Hermes framework, to take the repetitive gathering off my plate.&lt;/p&gt;

&lt;p&gt;The agent runs the recurring jobs on a schedule. Daily competitive-intelligence digests. Community listening across Hacker News, Reddit, GitHub, and more. Prospect signals from our CRM. Prospecting batches. Sponsorship research. A social pipeline with &lt;a href="https://mentiondrop.com/?utm_source=tessakriesel.com&amp;amp;utm_medium=blog&amp;amp;utm_campaign=90-days-gtm-like-an-engineer" rel="noopener noreferrer"&gt;MentionDrop&lt;/a&gt;. Event-prep briefs. It commits its reports and pings me.&lt;/p&gt;

&lt;p&gt;Here’s a concrete one. Rival already runs a cron every night, pulling competitor updates and pushing them into the GTM Command Center. The agent picks that up and synthesizes it, so instead of reading raw diffs every morning, I get the read that actually matters.&lt;/p&gt;

&lt;p&gt;The agent gathers and drafts. I read every report, make the call, and do the work. The system makes me faster. It does not replace me. That distinction is the whole reason it works: I get leverage without giving up a single decision.&lt;/p&gt;

&lt;p&gt;The play: use AI to scale yourself. There is always more GTM work than one person can do by hand, so hand the repetitive gathering to an agent and keep your hours for judgment and action. One person can run a full operation this way. The leverage is real, and every decision stays yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Seven: The Machine Started Pumping
&lt;/h2&gt;

&lt;p&gt;This was the inflection point. The daily reports turned on, and the systems started feeding me fresh intelligence every morning instead of me hunting for it.&lt;/p&gt;

&lt;p&gt;Two things made it click. First, I wired the systems together: the agent could now read and write the CRM directly, and the GTM Command Center held the single source of positioning that fed everything else. Second, I built the operating layer that gives all that daily output somewhere to go, a content calendar and backlog, a distribution system, and outreach lists pulled from my own network of thousands of contacts.&lt;/p&gt;

&lt;p&gt;The play: this is where the work starts compounding. Once your systems connect and feed you output, the job flips from setting the machine up to running on what it produces, and every new problem is easier to solve because the system to handle it is already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Eight: I Turned Product Hunt Into an Engine
&lt;/h2&gt;

&lt;p&gt;Our second launch, Web Research, went out about a month after the first. From there they came almost weekly, on purpose. A launch every week is a forcing function, and the amount of work behind a single one is the part people underestimate: the positioning and messaging, the website work itself (product pages designed and built, homepage and navigation updates, the SEO and discoverability layer), a working demo, images and launch cards, a video, the Product Hunt listing and maker comment, forum posts, social, a launch email, and answering every question in the thread on the day. The fixed deadline is what gets all of it out the door, week after week.&lt;/p&gt;

&lt;p&gt;I already knew the whole site needed a rebuild. I didn’t wait for it. I designed and built a focused product page for this launch myself, so we had something strong to land on now, and left the full rebuild for later.&lt;/p&gt;

&lt;p&gt;The play: don’t wait for perfect resourcing to move. Ship a focused page instead of blocking on a full redo, an agency, or a designer, and own your launch surface end to end. Then put yourself on a cadence and let it do the forcing. Nothing gets work out the door like a deadline you committed to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Nine: I Rebuilt the Whole Website Presence in a Weekend
&lt;/h2&gt;

&lt;p&gt;I knew from early on the site needed a full facelift. The old homepage was a single page, and it was weak. Developers couldn’t tell what Tabstack did or didn’t do.&lt;/p&gt;

&lt;p&gt;But I didn’t rebuild the whole site first. I did higher-leverage work and waited. When our research page shipped as a one-off landing page with a new design, it set the standard the rest of the site had to meet. So the facelift happened between launches: the product pages, the homepage relaunch, and the search-visibility layer underneath it, llms.txt, per-page structured data, canonicals, a branded 404, a clean sitemap, so the models developers ask can actually find us. Each launch got a stronger surface to land on, and the presence kept improving without ever blocking a launch.&lt;/p&gt;

&lt;p&gt;The play: improve your GTM presence, but sequence it. Recognize the big investment early, then hold off until it’s the highest-leverage move instead of the urgent one. Let one strong page set the design and messaging standard, then roll the rest out between launches, so your presence keeps getting better without ever blocking one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Ten: I Reinvested in the Systems &amp;amp; Data
&lt;/h2&gt;

&lt;p&gt;I pulled the whole operation into one terminal workspace with memory, so I start every session already in context instead of re-explaining myself. And I improved the data. With consent off by default and nothing collected unless you allow it, reliable attribution is genuinely hard, so I made it work within that limit, keeping referrer and UTM data through the consent step instead of losing it. A better workspace makes me faster. Fuller data makes every decision better. I do everything in here from ad copy, to email outreach drafts, to building agent skills. It empowers me to do everything within the defined system.&lt;/p&gt;

&lt;p&gt;This is also the week I turned on our first paid spend, a TLDR newsletter placement, and not a day before. I waited until the site and the developer experience were good enough that the traffic would land on something ready.&lt;/p&gt;

&lt;p&gt;The play: reinvest in your own systems and your data as you go. Efficiency and better information both compound. And don’t buy traffic until you’ve built something worth sending it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Eleven: The Systems Started Paying Me Back
&lt;/h2&gt;

&lt;p&gt;By now, running go-to-market like an engineer had a feel to it. Every time I hit a wall, I solved it for good.&lt;/p&gt;

&lt;p&gt;I’d made launch images by hand for three launches, hours each in Claude Design, so I built a helper that renders them on brand in minutes. My next launch’s imagery took me minutes. I planned a ten-category schema library and had the agent build it in a single overnight batch while I reviewed and shipped it in the morning, weeks of work done in hours. And I measured it hard: a four-launch traffic comparison, a growth report going back to April, attribution diagnostics, a case-study shortlist of our most-engaged orgs, and an exec narrative packaged into a deck.&lt;/p&gt;

&lt;p&gt;I’m not doing less. The more my systems handle, the more I get to take on, well beyond what one person could carry alone.&lt;/p&gt;

&lt;p&gt;The play: solve every hurdle once. Feel the pain a couple of times, then build the thing that removes it for good, a tool that turns hours of design work into minutes, an agent that runs an overnight build while you sleep. Each fix makes the next thing easier, and easier is what lets one person do the work of a team. That is what running go-to-market like an engineer actually buys you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Twelve: Not Every Launch Is a Tentpole
&lt;/h2&gt;

&lt;p&gt;Our fifth launch, Schema Source, was a quick one. It put eyes on the schema source tool our team built and the schema library of pre-defined schemas my agent built and kept the cadence alive without a heavy lift.&lt;/p&gt;

&lt;p&gt;The rest of the week went to the work that doesn’t announce itself. I integrated a fresh positioning pass so our message stayed sharp as we grew, and I got the Browser Automation launch copy ready for the next week, keeping it strictly to what was actually shipping, because overselling a developer tool costs you the moment someone tries it.&lt;/p&gt;

&lt;p&gt;The play: not every launch has to be a tentpole. A quick, light launch off something you already shipped keeps the cadence alive and puts eyes on an asset, without the heavy lift of a marquee release. The cadence is the engine. Sometimes you just feed it with what you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week Thirteen: Final Push before Day 90
&lt;/h2&gt;

&lt;p&gt;Week thirteen ran hard. The sixth launch, Browser Automation, went out with its announcement email. I shipped a competitor-brief tool, and finished the CRM enrichment layer that turns an email into a cited public profile on Tabstack’s own research endpoint. Even then I kept tuning the systems, because at day 90 I’m still sharpening them.&lt;/p&gt;

&lt;p&gt;Then I measured my own 90 days the same way I measure a launch. Here’s what the systems produced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Signups up 71%&lt;/li&gt;
&lt;li&gt;Activation up 29%&lt;/li&gt;
&lt;li&gt;API requests up almost 6x&lt;/li&gt;
&lt;li&gt;Website traffic up 129%&lt;/li&gt;
&lt;li&gt;Around 1,096 commits&lt;/li&gt;
&lt;li&gt;29,938 Tabstack API calls of my own, the heaviest usage on the platform&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tabstack is one tool in my stack, but it earns its spot. Rival is built entirely on it. Here’s a small example of why. I wanted to pull a recipe off a page and get clean, structured JSON back for a blog. I asked Claude to just do it, and it failed. Then I had it call Tabstack, and the data came back in seconds, structured exactly the way I needed and exactly what the model couldn’t produce on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;I grew signups 71% in 90 days by building the systems that made me faster. Work smarter, not harder.&lt;/p&gt;

&lt;p&gt;If you market a developer tool, or you’re a developer thinking about marketing your own, here’s the whole thing in one breath. Learn the product by building on it. Fix the developer experience, and build the system that keeps it fixed. Turn launches into a cadence, and never launch without your data ready. Use AI to scale yourself without handing over the judgment. And solve every hurdle once.&lt;/p&gt;

&lt;p&gt;You don’t have to be an engineer to run go-to-market this way. It just helps to think like one. I’m a marketer for developers because I’m a developer, and after 90 days I’m more sure than ever that’s the advantage.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you’re building agents that need to read the live web, Tabstack is worth a look.&lt;/em&gt; &lt;a href="https://tabstack.ai/?ref=tessakriesel.com" rel="noopener noreferrer"&gt;&lt;em&gt;Grab an API key&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything I Built and Shipped
&lt;/h2&gt;

&lt;p&gt;If you want the action behind the playbook, here’s the full body of work from the quarter.&lt;/p&gt;

&lt;h3&gt;
  
  
  The systems (the machine everything runs on)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rival&lt;/strong&gt; : an open-source competitive-intelligence app on all five Tabstack endpoints: multi-surface scanning (homepage, pricing, blog, changelog, reviews, profile), AI-generated diffs, on-demand cited research, a positioning matrix, threat scoring, a public streaming demo, nightly pushes into the GTM Command Center, and its own MCP server. Open-sourced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GTM Command Center&lt;/strong&gt; : the hub the whole operation runs on: versioned positioning and product context, AI work sessions (strategy, creation, feedback, analysis, optimization), a campaigns module with an AI brief generator, a versioned content and artifact library with SEO/OG and distribution, customer-research ingestion (themes, sentiment, quotes), performance logging that proposes updates back to the context, live metric connectors (PostHog, GA4, EthicalAds), a campaign-performance dashboard, team management, notifications, magic-link auth with domain gating, and an MCP server every other system reads and writes to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tabstack CRM&lt;/strong&gt; : public-data-only enrichment and ICP scoring, an identity pass (Gravatar, GitHub, npm, PyPI, Slack), a source-linked signal feed, a prospect and account pipeline, funnel-stage imports, and a hosted MCP server. Later moved to Airtable with a trace-based enrichment pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GTM agent&lt;/strong&gt; : built on the open-source Hermes framework: recurring competitive-intel, community-listening, prospecting, sponsorship, and social jobs, network intelligence, publishing integrations, and a Telegram interface. It gathers and drafts; I decide and act.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tabstack OS&lt;/strong&gt; : one terminal workspace with memory: a state-of-work brain, session briefings, reference-repo sync, and a portable knowledge mirror.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tech-writing and blog-authoring system&lt;/strong&gt; : a skill per doc type, a product knowledge base, accuracy-checking review agents, and a draft-to-pull-request pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Skills, subagents, and com &lt;strong&gt;mands I built&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Custom skills: api-docs, tech-writing, changelog, readme-writer, tabstack-sdk, blog-author, external-publication-editor, sdk-sync, quiver-publish, product-marketing, vbf-messaging, competitor-profiling, state-of-work, crm-import, writing-clearly-and-concisely, plus a mirrored marketing-skills pack&lt;/li&gt;
&lt;li&gt;Review subagents: api-reviewer, docs-reviewer, readme-writer&lt;/li&gt;
&lt;li&gt;Slash commands for the doc and publishing workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Marketing website (built and rebuilt)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Four product pages: structured extraction, browser automation, web research, and competitive intelligence, with hero demos and real token-cost visuals&lt;/li&gt;
&lt;li&gt;A full homepage relaunch (dark nav and footer, Stack mega-menu) and a blog redesign&lt;/li&gt;
&lt;li&gt;A Developer Hub / resources overhaul and a Use Cases section&lt;/li&gt;
&lt;li&gt;Attribution plumbing: referral/UTM passthrough into signup CTAs, entry referrer/UTMs retained across the cookie-consent delay, and internal links de-tagged&lt;/li&gt;
&lt;li&gt;Consent-gated web analytics&lt;/li&gt;
&lt;li&gt;AI-search visibility: llms.txt and llms-full.txt, per-page JSON-LD, canonicals, keyword-targeted meta titles, a branded 404, a clean sitemap, and legacy redirects&lt;/li&gt;
&lt;li&gt;A consistent UTM + GA taxonomy across every CTA, an accessibility pass, and a font migration to the Mozilla brand faces&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Docs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;45 docs in a single day&lt;/li&gt;
&lt;li&gt;The Interactive Mode set (quickstart, guides, launch post) and an accuracy pass&lt;/li&gt;
&lt;li&gt;Per-endpoint API references (extract/json, extract/markdown, generate/json, automate, research)&lt;/li&gt;
&lt;li&gt;Quickstarts (first extraction, interactive mode, research)&lt;/li&gt;
&lt;li&gt;Seven how-to guides (effort levels, geotargeting, schema design, streaming, production reliability, integrations, LangChain)&lt;/li&gt;
&lt;li&gt;A 14-page comparisons section and SDK-first rewrites&lt;/li&gt;
&lt;li&gt;A changelog page covering SDK 2.0.0–2.6.1&lt;/li&gt;
&lt;li&gt;Ongoing docs work from DevRel: error reference, research guide, a generate-vs-extract page, production rate limits, a full docs audit, and single-source changelog data&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Developer tooling
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The Tabstack CLI (built by DevRel, hardened to launch-ready, with a schema-pull command and install script)&lt;/li&gt;
&lt;li&gt;A public vertical schema library, ten categories built in a single overnight batch, plus a competitor-profile schema&lt;/li&gt;
&lt;li&gt;A competitor-brief demo app spanning all five endpoints (an 11-section streaming brief with a two-URL positioning comparison)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Product Hunt launches (six)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Six launches: the first Tabstack launch, then Web Research, Structured Extraction, Dev Tools, Schema Source, and Browser Automation&lt;/li&gt;
&lt;li&gt;For each one: positioning, messaging, taglines, the listing and maker comment, ten forum posts, a launch email, and answering questions in the thread on the day&lt;/li&gt;
&lt;li&gt;A reusable Product Hunt image helper (on-brand launch cards in minutes), launch-day sentiment analysis, and launch-history + launch-comparison research&lt;/li&gt;
&lt;li&gt;Launch videos by DevRel&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  GTM intelligence and reporting
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Daily competitive-intel, community-signal, and CRM-signal reports; weekly competitive summaries, account intelligence, prospecting, sponsorship, social, and events reports&lt;/li&gt;
&lt;li&gt;A four-launch traffic comparison, a growth report since April, attribution diagnostics, an engaged-orgs case-study shortlist with pre-filled briefs, and an exec growth narrative with a slide deck&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Content
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A dozen technical blog drafts, personal-voice and brand voice profiles, a monthly content calendar, a distribution system, and a 30-post backlog&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Positioning and messaging
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A five-bet growth investment plan, a VBF messaging framework, six audience micro-segments, a jobs-to-be-done framework, exec and company one-pagers, and a full positioning integration that leans on Mozilla’s trust and privacy stance (ephemeral processing, no training on your data, privacy by default)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Specs and plans
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Design specs and implementation plans for the blog-author system, the Tabstack OS workspace, the using-Tabstack skill, the competitive-intelligence sales page, and the competitor-brief tool&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Analytics and dashboards
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Three PostHog dashboards (GTM Metrics, live Launch Day, and Growth/Attribution/Signups) with 36 insights&lt;/li&gt;
&lt;li&gt;The GTM Command Center’s own campaign-performance dashboard, pulling spend and metrics from PostHog, GA4, and EthicalAds to show cost-per-outcome across every campaign&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Paid and community distribution
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A TLDR newsletter placement (first paid spend), a bytes.dev newsletter sponsorship, an EthicalAds campaign (per-segment copy and creative), Reddit Ads, and a paid-channel experiment plan&lt;/li&gt;
&lt;li&gt;Creator and sponsorship lists mined from a network of thousands&lt;/li&gt;
&lt;li&gt;An agent-ecosystem play: registered the product as an agent, plus an agent-injection campaign plan&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Team and ops
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Hired and directed our DevRel engineer, Steve McDougall, brought on Flo Merian as our Product Hunt hunter, and onboarded our brand-and-marketing agency, Conversion Factory&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Research and evaluation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A pricing evaluation, competitor ad-intelligence research, and a developer-marketing playbook synthesis&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  And the dogfooding receipt
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;27 product issues filed to engineering from real use, and the #1 power user of the Tabstack API at 29,938 calls&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devrel</category>
    </item>
    <item>
      <title>I Stopped Checking My Mentions Dashboard. Now an Agent Drafts the Replies.</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Tue, 16 Jun 2026 23:06:44 +0000</pubDate>
      <link>https://dev.to/tessak22/i-stopped-checking-my-mentions-dashboard-now-an-agent-drafts-the-replies-49ak</link>
      <guid>https://dev.to/tessak22/i-stopped-checking-my-mentions-dashboard-now-an-agent-drafts-the-replies-49ak</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxcohpdsxjcy30gucxoaa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxcohpdsxjcy30gucxoaa.png" alt="I Stopped Checking My Mentions Dashboard. Now an Agent Drafts the Replies." width="800" height="559"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every monitoring tool sells you the same thing. A dashboard. You open it, you scroll, you promise yourself you'll keep up. Two weeks later you stop opening it.&lt;/p&gt;

&lt;p&gt;Sound familiar? The dashboard was never the point. The point is the handful of conversations happening right now where you could be useful, and those rot in a browser tab you forgot to check.&lt;/p&gt;

&lt;p&gt;I run GTM for &lt;a href="https://tabstack.ai/?utm_source=tessak22.com&amp;amp;utm_medium=referral&amp;amp;utm_campaign=mentiondrop-mcp-post" rel="noopener noreferrer"&gt;Tabstack&lt;/a&gt;, so this as an actual problem I needed to solve. I need to know when someone mentions us, when someone's frustrated with a tool in our space, when there's a question I can help with. But those few conversations sit buried under hundreds that look relevant and aren't. That's the work: cutting the noise down to what matters, then replying before it goes cold. Neither half scales by adding another tab to your morning.&lt;/p&gt;

&lt;p&gt;So I'd already taken a swing at it. My Hermes GTM agent runs a community signals report every morning before I'm even at my desk. It works. But on its own, it leaned toward housekeeping tasks: which PR merged, which repo pushed overnight. The conversations I actually wanted stayed the thinnest slice of the report, because raw keywords drown them in noise.&lt;/p&gt;

&lt;p&gt;Then MentionDrop shipped an MCP. I wired it into Hermes this week, and the report I re-ran was completely different.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the MCP actually changes
&lt;/h2&gt;

&lt;p&gt;MentionDrop has always done the hard part, filtering social noise into real signal. The MCP changes how you use it. Instead of a dashboard I visit, it becomes a set of tools my agent calls directly: the same filtered data, exposed so Hermes can pull it without me clicking anything.&lt;/p&gt;

&lt;p&gt;That's the whole shift. A tool with an MCP doesn't have to be a place you go. It can be a capability your agent has.&lt;/p&gt;

&lt;p&gt;Connecting it took about two minutes. The tuning came after. A broad keyword pulls in everything at first; one of mine was matching a soccer player, an obituary, and a makeup app. What sold me is how much control MentionDrop hands you to fix that. Every keyword takes a role, a context sentence, a relevance floor, and exclusion terms. A few minutes turned a messy feed into a high-signal one, and because the filter learns from what I mark reviewed, tomorrow's run will be sharper than today's.&lt;/p&gt;

&lt;p&gt;Here's the division of labor I landed on: MentionDrop does the filtering, Hermes pulls what's left into one morning list and drafts a reply for each in my voice. But Hermes can't touch my account or post anything in public. That part is mine. I read the draft, make my edits, and hit post. Every draft also clears my editorial principles before it reaches me, so what I'm reviewing already sounds like me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually get now
&lt;/h2&gt;

&lt;p&gt;A short list each morning. Five or six conversations, not five hundred. Each one comes with a line on why it matters, a direct link, and a reply already drafted the way I'd say it.&lt;/p&gt;

&lt;p&gt;Here's one from today's run. Hermes flagged a developer on r/ClaudeAI stuck trying to install Apify, the kind of friction moment where an alternative is genuinely welcome. It pulled the thread, noted why it mattered, and wrote the draft:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Apify's MCP setup can be pretty finicky, the actor runtime adds a bunch of overhead that doesn't play nicely with Claude's tool calling in every setup.  &lt;/p&gt;

&lt;p&gt;If you're trying to pull structured data or do browser-based research, you might find Tabstack's API simpler to wire up. It's a plain REST API with no runtime to configure. Happy to share a quick example if you tell me what you're trying to extract.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I read it, removed spaces around the em dashes, and hit post. Done in 3o seconds.&lt;/p&gt;

&lt;p&gt;Compare that to what Hermes gave me on its own a few days ago: a thorough rundown of GitHub activity. Which issues got acknowledged, which PR was blocking a launch, which repo pushed overnight. All true, all useful for ops. But not one conversation I could go &lt;em&gt;be useful in&lt;/em&gt;. That report was a status update. This one's a revenue-driving to-do list.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"MentionDrop's MCP turns my Hermes agent's morning report from a status update into a revenue-driving to-do list."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;I only set this up this week, so I won't pretend it has remade everything. But the first real run handed me a list worth acting on, and that's the part that has me hooked. The tool didn't change. &lt;a href="https://www.mentiondrop.com/?utm_source=tessakrieselcom&amp;amp;utm_medium=referral&amp;amp;utm_campaign=mentiondrop-mcp-post" rel="noopener noreferrer"&gt;MentionDrop&lt;/a&gt; was already good at the filtering. What changed is that it shipped an MCP, and that turned it from something I check into something my agent runs for me.&lt;/p&gt;

&lt;p&gt;That's what I keep coming back to. My agent was already doing the work. It just needed better inputs. An MCP is how a good tool stops being a destination and becomes a capability.&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>How I Deployed Hermes Agent on Railway with Telegram (And Every Gotcha I Hit Along the Way)</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Tue, 12 May 2026 21:14:06 +0000</pubDate>
      <link>https://dev.to/tessak22/how-i-deployed-hermes-agent-on-railway-with-telegram-and-every-gotcha-i-hit-along-the-way-4hhk</link>
      <guid>https://dev.to/tessak22/how-i-deployed-hermes-agent-on-railway-with-telegram-and-every-gotcha-i-hit-along-the-way-4hhk</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR for the skimmers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hermes is NousResearch's self-improving AI agent framework—persistent memory, skills system, multi-platform messaging, multiple LLM providers&lt;/li&gt;
&lt;li&gt;You can run it as a persistent Docker service on Railway with Telegram as your interface&lt;/li&gt;
&lt;li&gt;The docs don't cover Railway/Docker deployment, so I had to figure most of this out through trial, error, and log-reading&lt;/li&gt;
&lt;li&gt;There are 10 specific gotchas that will wreck your deploy if you don't know about them&lt;/li&gt;
&lt;li&gt;Full working files at the end of this post&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;When I found Hermes—NousResearch's self-improving agent framework—I knew I wanted it in the cloud. I didn't want to think about whether my laptop was on. I wanted to message it from my phone at any hour and have it just work.&lt;/p&gt;

&lt;p&gt;The problem? There is essentially zero documentation for deploying Hermes to a persistent cloud service like Railway. The install docs assume you're running it locally. The Docker support exists but isn't documented in any deployment context. And when you're dealing with a gateway-based agent that uses volumes, environment variables, MCP subprocess servers, and Telegram webhooks, the failure surface is enormous.&lt;/p&gt;

&lt;p&gt;This post is the full deployment story—every gotcha, every fix, every file—so you don't have to spend three days in Railway logs the way I did.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Hermes actually is
&lt;/h2&gt;

&lt;p&gt;Before I get into the deployment mechanics, let me quickly explain what Hermes is and why it's worth the deployment friction.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/NousResearch/hermes-agent?ref=tessakriesel.com" rel="noopener noreferrer"&gt;Hermes&lt;/a&gt; is NousResearch's AI agent framework. What makes it different from a simple chatbot or wrapper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-improving skills system&lt;/strong&gt; : Hermes creates skills from experience and improves them over time. You can also load external skill directories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent memory&lt;/strong&gt; : &lt;code&gt;MEMORY.md&lt;/code&gt; and &lt;code&gt;USER.md&lt;/code&gt; build up across sessions. The agent actually remembers you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-platform messaging&lt;/strong&gt; : Telegram, Discord, Slack, WhatsApp, Signal, email—you pick your interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-LLM support&lt;/strong&gt; : Anthropic, OpenRouter, Nous Portal, and more. You can configure fallback providers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP support&lt;/strong&gt; : Connect external tools via Model Context Protocol servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron jobs, kanban, session search, hooks&lt;/strong&gt; : Full agentic infrastructure baked in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config lives in &lt;code&gt;~/.hermes/&lt;/code&gt;&lt;/strong&gt; : Main files are &lt;code&gt;config.yaml&lt;/code&gt;, &lt;code&gt;.env&lt;/code&gt;, and &lt;code&gt;SOUL.md&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Getting it running on Railway
&lt;/h2&gt;

&lt;p&gt;This section covers only what's Railway-specific. For general Hermes setup—skills, SOUL.md content, memory configuration—refer to the &lt;a href="https://github.com/NousResearch/hermes-agent?ref=tessakriesel.com" rel="noopener noreferrer"&gt;Hermes repo&lt;/a&gt;. What follows is the deployment layer on top of that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;p&gt;Before you start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://railway.com/?ref=tessakriesel.com" rel="noopener noreferrer"&gt;Railway account&lt;/a&gt; with the CLI installed (&lt;code&gt;npm install -g @railway/cli&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Railway CLI logged in: &lt;code&gt;railway login&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A Telegram bot token (create one via &lt;a href="https://t.me/BotFather?ref=tessakriesel.com" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Your Telegram user ID (message &lt;a href="https://t.me/userinfobot?ref=tessakriesel.com" rel="noopener noreferrer"&gt;@userinfobot&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;An Anthropic API key (or whichever LLM provider you're using)&lt;/li&gt;
&lt;li&gt;Docker installed locally if you want to test the build before deploying&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1: Create your repo structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your-agent/
├── Dockerfile
├── entrypoint.sh
├── railway.json
├── .env.example
├── .gitignore
├── AGENTS.md # Optional — auto-injected context every session
└── hermes/
    ├── cli-config.yaml
    └── SOUL.md

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;hermes/&lt;/code&gt; directory is your agent's config source. The entrypoint copies these files into the Railway volume on every startup, so changes you commit and deploy take effect immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the core files
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;Dockerfile&lt;/code&gt;, &lt;code&gt;entrypoint.sh&lt;/code&gt;, &lt;code&gt;railway.json&lt;/code&gt;, and &lt;code&gt;hermes/cli-config.yaml&lt;/code&gt; using the full working files later in this post. Come back to this step once you've read the gotchas—they'll explain some choices in those files that would otherwise seem arbitrary.&lt;/p&gt;

&lt;p&gt;The one file you'll want to create now is &lt;code&gt;hermes/SOUL.md&lt;/code&gt;. This is your agent's identity—who it is, what it knows, how it behaves. The &lt;a href="https://github.com/NousResearch/hermes-agent?ref=tessakriesel.com" rel="noopener noreferrer"&gt;Hermes repo&lt;/a&gt; covers what goes in SOUL.md. Create it before your first deploy so the agent starts with a defined persona rather than a blank slate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Set up the Telegram bot
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Message &lt;a href="https://t.me/BotFather?ref=tessakriesel.com" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt; on Telegram&lt;/li&gt;
&lt;li&gt;Send &lt;code&gt;/newbot&lt;/code&gt; and follow the prompts&lt;/li&gt;
&lt;li&gt;Copy the token it gives you—that's &lt;code&gt;TELEGRAM_BOT_TOKEN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Message &lt;a href="https://t.me/userinfobot?ref=tessakriesel.com" rel="noopener noreferrer"&gt;@userinfobot&lt;/a&gt; to get your numeric user ID—that's &lt;code&gt;TELEGRAM_ALLOWED_USERS&lt;/code&gt; and &lt;code&gt;TELEGRAM_HOME_CHANNEL&lt;/code&gt; (they're the same value for a personal DM setup)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 4: Initialize the Railway project
&lt;/h3&gt;

&lt;p&gt;From your repo root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Initialize and link to Railway&lt;/span&gt;
railway init

&lt;span class="c"&gt;# Create the service with an initial deploy&lt;/span&gt;
railway up &lt;span class="nt"&gt;--detach&lt;/span&gt;

&lt;span class="c"&gt;# Link to the service by name&lt;/span&gt;
railway service &lt;span class="s2"&gt;"your-service-name"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Set environment variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-ant-...
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_ALLOWED_USERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;123456789
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;123456789
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Your Name DM"&lt;/span&gt;
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;HERMES_HUMAN_DELAY_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;natural
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;HERMES_ACCEPT_HOOKS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add any additional secrets for MCP servers you're connecting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;NOTION_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;NOUS_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... &lt;span class="c"&gt;# if using Nous Portal fallback&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 6: Add the persistent volume
&lt;/h3&gt;

&lt;p&gt;This is the most important step and the order matters—add the volume before your first real deploy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway volume add &lt;span class="nt"&gt;--mount-path&lt;/span&gt; /root/.hermes

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The volume must be mounted at &lt;code&gt;/root/.hermes&lt;/code&gt;—the entire Hermes home directory, not a subdirectory. See Gotcha 2 for why this matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Redeploy and verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway up &lt;span class="nt"&gt;--detach&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the build logs in the Railway dashboard. A successful startup looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hermes config ready. Starting gateway...
⚕ Hermes Gateway Starting...
Messaging platforms + cron scheduler

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you see the gateway banner, send a message to your Telegram bot. It should respond.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Set up auto-deploy on push
&lt;/h3&gt;

&lt;p&gt;Railway doesn't automatically redeploy when you push to GitHub unless you connect the repo through their UI. The easiest workaround is a git post-push hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create the hook&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .git/hooks/post-push &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
echo "Deploying to Railway..."
railway up --detach
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x .git/hooks/post-push

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;git push&lt;/code&gt; triggers a Railway deploy automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 10 gotchas
&lt;/h2&gt;

&lt;p&gt;I want to share the gotchas because they are the entire reason this post exists. None of these are documented anywhere. The first six will affect everyone doing this setup. The rest depend on your configuration choices—but if you're adding MCP servers or fallback providers, you'll hit those too.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotcha 1: &lt;code&gt;gateway start&lt;/code&gt; vs &lt;code&gt;gateway run&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The Hermes docs (and some examples floating around) reference &lt;code&gt;hermes gateway start&lt;/code&gt;. Don't use that in Docker. It's for host machine services.&lt;/p&gt;

&lt;p&gt;Inside a Docker container, you will get this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Service start is not applicable inside a Docker container.
Or run the gateway directly: hermes gateway run

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; In your &lt;code&gt;entrypoint.sh&lt;/code&gt;, use &lt;code&gt;exec hermes gateway run&lt;/code&gt;—not &lt;code&gt;start&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;exec &lt;/span&gt;hermes gateway run

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;exec&lt;/code&gt; matters too—it replaces the shell process so tini can properly manage the process tree.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotcha 2: Volume mount path was wrong
&lt;/h3&gt;

&lt;p&gt;This one cost me the most time. I initially mounted the Railway Volume at &lt;code&gt;/root/.hermes/memories&lt;/code&gt;—just the memories subdirectory—thinking that was the only thing I needed to persist.&lt;/p&gt;

&lt;p&gt;Wrong. Hermes stores a lot more than memories outside that subdirectory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gateway state&lt;/li&gt;
&lt;li&gt;Session history (&lt;code&gt;state.db&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Cron jobs&lt;/li&gt;
&lt;li&gt;The home channel setup state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every deploy wiped all of that, so the bot would come back up and immediately ask me to "set your home channel" as if it had never been configured. Completely stateless on every restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Mount the volume at &lt;code&gt;/root/.hermes&lt;/code&gt;—the entire Hermes home directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway volume add &lt;span class="nt"&gt;--mount-path&lt;/span&gt; /root/.hermes

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Gotcha 3: Wrong MCP config key
&lt;/h3&gt;

&lt;p&gt;I burned an embarrassing amount of time on this one. My &lt;code&gt;cli-config.yaml&lt;/code&gt; had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;mcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;my-tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://..."&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hermes does not recognize this structure. The correct top-level key is &lt;code&gt;mcp_servers:&lt;/code&gt;—flat, not nested under &lt;code&gt;mcp:&lt;/code&gt;.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;mcp_servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;my-tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://..."&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error is thrown for the wrong key—Hermes just silently ignores the MCP servers entirely. You only notice because your tools don't show up.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotcha 4: &lt;code&gt;config.yaml&lt;/code&gt; vs &lt;code&gt;cli-config.yaml&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The sample file in the Hermes repo is named &lt;code&gt;cli-config.yaml.example&lt;/code&gt;. Naturally, I named my file &lt;code&gt;cli-config.yaml&lt;/code&gt;. That works for CLI usage.&lt;/p&gt;

&lt;p&gt;But the &lt;strong&gt;gateway&lt;/strong&gt; reads &lt;code&gt;config.yaml&lt;/code&gt; at runtime. The CLI and the gateway use different config loaders, and they look for different filenames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Write your config to both files in &lt;code&gt;entrypoint.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; /app/hermes/cli-config.yaml &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/cli-config.yaml"&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; /app/hermes/cli-config.yaml &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/config.yaml"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maintain one source file in your repo and copy it to both locations on startup. Config changes propagate correctly regardless of which loader is running.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotcha 5: MCP secrets not reaching Hermes
&lt;/h3&gt;

&lt;p&gt;I had all my MCP secrets set as Railway environment variables. They were there in the Railway dashboard, visibly set, verified. Hermes couldn't see them.&lt;/p&gt;

&lt;p&gt;The issue: Hermes reads secrets from &lt;code&gt;~/.hermes/.env&lt;/code&gt;, not directly from the system environment. My &lt;code&gt;entrypoint.sh&lt;/code&gt; was only writing a handful of variables to that file—not the MCP secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Write ALL secrets to &lt;code&gt;~/.hermes/.env&lt;/code&gt; in &lt;code&gt;entrypoint.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
ANTHROPIC_API_KEY=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
TELEGRAM_BOT_TOKEN=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
TELEGRAM_ALLOWED_USERS=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_ALLOWED_USERS&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
TELEGRAM_HOME_CHANNEL=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
TELEGRAM_HOME_CHANNEL_NAME=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL_NAME&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
MY_MCP_SECRET=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MY_MCP_SECRET&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
NOTION_TOKEN=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NOTION_TOKEN&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
HERMES_HUMAN_DELAY_MODE=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HERMES_HUMAN_DELAY_MODE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;natural&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
HERMES_ACCEPT_HOOKS=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HERMES_ACCEPT_HOOKS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
NOUS_API_KEY=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NOUS_API_KEY&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:-&lt;/code&gt; pattern uses the environment variable if set, empty string if not. Keeps the file clean even if some optional secrets aren't configured yet.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotcha 6: Agent is completely silent after deploy
&lt;/h3&gt;

&lt;p&gt;After fixing the gateway run command, the bot would start, logs looked clean, no errors—and then complete silence. Send a Telegram message, nothing comes back. No errors, no timeouts, no indication anything was wrong.&lt;/p&gt;

&lt;p&gt;The cause: &lt;code&gt;TELEGRAM_HOME_CHANNEL&lt;/code&gt; was not set. When Hermes doesn't know its home channel, it enters a waiting-for-setup state and ignores incoming messages entirely. It's not an error state—it's just waiting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Set &lt;code&gt;TELEGRAM_HOME_CHANNEL&lt;/code&gt; to your Telegram user ID as a Railway environment variable. For a personal DM bot, this is the same as your user ID. Find it by messaging &lt;a href="https://t.me/userinfobot?ref=tessakriesel.com" rel="noopener noreferrer"&gt;@userinfobot&lt;/a&gt; on Telegram.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;123456789

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Gotcha 7: No tini = zombie MCP processes
&lt;/h3&gt;

&lt;p&gt;Hermes runs MCP stdio servers as subprocesses—for example, the Notion MCP runs as &lt;code&gt;npx @notionhq/notion-mcp-server&lt;/code&gt;. Without a proper init system as PID 1, these subprocesses become zombies when they exit. They accumulate over time and eventually cause problems.&lt;/p&gt;

&lt;p&gt;The upstream Hermes Dockerfile uses &lt;code&gt;tini&lt;/code&gt; explicitly for exactly this reason. I missed that detail initially.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Install tini in your Dockerfile and use it as the entrypoint. Critically, do NOT set a &lt;code&gt;startCommand&lt;/code&gt; in &lt;code&gt;railway.json&lt;/code&gt;—Railway's startCommand overrides the Docker ENTRYPOINT, which means tini never runs. Leave startCommand out and let Railway use the ENTRYPOINT directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; tini
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/usr/bin/tini", "-g", "--", "/entrypoint.sh"]&lt;/span&gt;


{
  "build": { "builder": "DOCKERFILE" },
  "deploy": {
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-g&lt;/code&gt; flag means tini will send signals to the entire process group, which matters for subprocess cleanup.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotcha 8: stdio MCP servers fail to start in the container
&lt;/h3&gt;

&lt;p&gt;If you're using stdio-based MCP servers (like the Notion MCP via &lt;code&gt;npx&lt;/code&gt;), the package has to be downloaded on first invocation. In a Railway container, this download can fail entirely—leaving the MCP server in an infinite retry loop with no useful error message. You won't see a crash. You'll just see your MCP tools never appear and Notion never responds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Pre-install the package in the Dockerfile so it's already there when the container starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;RUN npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @notionhq/notion-mcp-server

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, &lt;code&gt;npx&lt;/code&gt; finds the package locally and starts immediately. Do this for every stdio MCP server you configure.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotcha 9: &lt;code&gt;api_max_retries&lt;/code&gt; too high with fallback providers
&lt;/h3&gt;

&lt;p&gt;I had &lt;code&gt;api_max_retries: 3&lt;/code&gt; in my config. What this means in practice: if Anthropic hits an error or rate limit, Hermes retries the same provider 3 times before trying the fallback. That's potentially 3 failed requests with exponential backoff before you failover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Set &lt;code&gt;api_max_retries: 1&lt;/code&gt; when using fallback providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api_max_retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One failure on the primary, immediately tries the fallback.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotcha 10: Will the volume hide the Hermes install?
&lt;/h3&gt;

&lt;p&gt;When running as root on Linux (Docker default), the Hermes install script puts code at &lt;code&gt;/usr/local/lib/hermes-agent&lt;/code&gt;—not at &lt;code&gt;~/.hermes&lt;/code&gt;. So mounting a volume at &lt;code&gt;/root/.hermes&lt;/code&gt; does NOT hide or overwrite the installed Hermes code. It only covers user config and data.&lt;/p&gt;

&lt;p&gt;I mention this because I spent time wondering whether my volume mount was somehow replacing the install. You don't need to reinstall Hermes on every container start. The install is baked into the image. The volume handles your data only.&lt;/p&gt;




&lt;h2&gt;
  
  
  The full working setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Dockerfile
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.13-slim&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    curl bash git nodejs npm build-essential tini &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# Pre-install any stdio MCP servers to avoid cold-start downloads&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @notionhq/notion-mcp-server

&lt;span class="c"&gt;# Install Hermes — pinned to a specific commit for reproducibility&lt;/span&gt;
&lt;span class="c"&gt;# Update this SHA when you want to upgrade Hermes&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; HERMES_COMMIT=c23a87bc163b188abc7e40fbdccf07a9739231c3&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="s2"&gt;"https://raw.githubusercontent.com/NousResearch/hermes-agent/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HERMES_COMMIT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/scripts/install.sh"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    | bash &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--skip-setup&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PATH="/root/.local/bin:${PATH}"&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# Sanity check — verify install succeeded&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;hermes &lt;span class="nt"&gt;--version&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; entrypoint.sh /entrypoint.sh&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /entrypoint.sh

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8443&lt;/span&gt;

&lt;span class="c"&gt;# tini as PID 1 to handle zombie subprocess cleanup&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/usr/bin/tini", "-g", "--", "/entrypoint.sh"]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  entrypoint.sh
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;HERMES_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HERMES_HOME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="p"&gt;/.hermes&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Create all directories Hermes expects (volume may be empty on first deploy)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/memories"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/skills"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/sessions"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/cron"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/cron/output"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/hooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
         &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/logs"&lt;/span&gt;

&lt;span class="c"&gt;# Write config to BOTH filenames — CLI uses cli-config.yaml, gateway uses config.yaml&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; /app/hermes/cli-config.yaml &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/cli-config.yaml"&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; /app/hermes/cli-config.yaml &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/config.yaml"&lt;/span&gt;

&lt;span class="c"&gt;# Write SOUL.md — agent identity/persona&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; /app/hermes/SOUL.md &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/SOUL.md"&lt;/span&gt;

&lt;span class="c"&gt;# Write ALL secrets to ~/.hermes/.env — Hermes reads from here, not system env&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
ANTHROPIC_API_KEY=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
TELEGRAM_BOT_TOKEN=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
TELEGRAM_ALLOWED_USERS=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_ALLOWED_USERS&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
TELEGRAM_HOME_CHANNEL=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
TELEGRAM_HOME_CHANNEL_NAME=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL_NAME&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
NOTION_TOKEN=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NOTION_TOKEN&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
HERMES_HUMAN_DELAY_MODE=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HERMES_HUMAN_DELAY_MODE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;natural&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
HERMES_ACCEPT_HOOKS=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HERMES_ACCEPT_HOOKS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
NOUS_API_KEY=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NOUS_API_KEY&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Hermes config ready. Starting gateway..."&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;hermes gateway run

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  hermes/cli-config.yaml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic/claude-sonnet-4-6"&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic"&lt;/span&gt;

&lt;span class="na"&gt;fallback_providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nous-api"&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hermes-4-405B"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nous-api"&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hermes-4-70B"&lt;/span&gt;

&lt;span class="c1"&gt;# Note: model names vary by Nous Portal plan. Verify yours at portal.nousresearch.com&lt;/span&gt;
&lt;span class="c1"&gt;# before deploying — a wrong model name causes silent fallback failures.&lt;/span&gt;

&lt;span class="na"&gt;terminal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local"&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;180&lt;/span&gt;

&lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;memory_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;user_profile_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;memory_char_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
  &lt;span class="na"&gt;user_char_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2000&lt;/span&gt;
  &lt;span class="na"&gt;nudge_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;flush_min_turns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;

&lt;span class="na"&gt;session_reset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;both&lt;/span&gt;
  &lt;span class="na"&gt;idle_minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1440&lt;/span&gt;
  &lt;span class="na"&gt;at_hour&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;

&lt;span class="na"&gt;group_sessions_per_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;max_turns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
  &lt;span class="na"&gt;reasoning_effort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium"&lt;/span&gt;
  &lt;span class="na"&gt;api_max_retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;tool_loop_guardrails&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;warnings_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;hard_stop_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;platform_toolsets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cli&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;hermes-cli&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;telegram&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;hermes-telegram&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;session_search&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# IMPORTANT: top-level key is mcp_servers, NOT mcp.servers&lt;/span&gt;
&lt;span class="na"&gt;mcp_servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;notion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npx"&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@notionhq/notion-mcp-server"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;NOTION_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${NOTION_TOKEN}"&lt;/span&gt;
  &lt;span class="c1"&gt;# Add HTTP MCP servers the same way — secrets go in Railway vars and .env&lt;/span&gt;
  &lt;span class="c1"&gt;# my-http-tool:&lt;/span&gt;
  &lt;span class="c1"&gt;# url: "https://your-mcp-endpoint.com/mcp"&lt;/span&gt;
  &lt;span class="c1"&gt;# headers:&lt;/span&gt;
  &lt;span class="c1"&gt;# Authorization: "Bearer ${MY_MCP_SECRET}"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  railway.json
&lt;/h3&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;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://railway.app/railway.schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&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;"builder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DOCKERFILE"&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;"deploy"&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;"restartPolicyType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ON_FAILURE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"restartPolicyMaxRetries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&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;No &lt;code&gt;startCommand&lt;/code&gt; here intentionally. Railway's startCommand overrides Docker's ENTRYPOINT—which means tini never runs if you add one. Leave it out and Railway uses the ENTRYPOINT from the Dockerfile.&lt;/p&gt;




&lt;h2&gt;
  
  
  Railway setup walkthrough
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Initialize Railway project&lt;/span&gt;
railway init

&lt;span class="c"&gt;# 2. Initial deploy to create the service&lt;/span&gt;
railway up &lt;span class="nt"&gt;--detach&lt;/span&gt;

&lt;span class="c"&gt;# 3. Link to the service&lt;/span&gt;
railway service &lt;span class="s2"&gt;"your-service-name"&lt;/span&gt;

&lt;span class="c"&gt;# 4. Set environment variables&lt;/span&gt;
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-ant-...
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_ALLOWED_USERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;123456789
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;123456789
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_HOME_CHANNEL_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Your Name DM"&lt;/span&gt;
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;HERMES_HUMAN_DELAY_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;natural
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;HERMES_ACCEPT_HOOKS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;NOUS_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
railway variables &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;NOTION_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...

&lt;span class="c"&gt;# Optional: webhook mode is more efficient than polling for hosted deployments&lt;/span&gt;
&lt;span class="c"&gt;# railway variables set TELEGRAM_WEBHOOK_URL=https://your-service.up.railway.app/telegram&lt;/span&gt;
&lt;span class="c"&gt;# railway variables set TELEGRAM_WEBHOOK_SECRET=$(openssl rand -hex 32)&lt;/span&gt;

&lt;span class="c"&gt;# 5. Add persistent volume — MUST be /root/.hermes, not a subdirectory&lt;/span&gt;
railway volume add &lt;span class="nt"&gt;--mount-path&lt;/span&gt; /root/.hermes

&lt;span class="c"&gt;# 6. Redeploy to pick up the volume&lt;/span&gt;
railway up &lt;span class="nt"&gt;--detach&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Getting your Telegram IDs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a bot via &lt;a href="https://t.me/BotFather?ref=tessakriesel.com" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt; → &lt;code&gt;TELEGRAM_BOT_TOKEN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Message &lt;a href="https://t.me/userinfobot?ref=tessakriesel.com" rel="noopener noreferrer"&gt;@userinfobot&lt;/a&gt; → your user ID for &lt;code&gt;TELEGRAM_ALLOWED_USERS&lt;/code&gt; and &lt;code&gt;TELEGRAM_HOME_CHANNEL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;For a personal DM setup, both values are the same&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Auto-deploy on push:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .git/hooks/post-push&lt;/span&gt;
&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
railway up &lt;span class="nt"&gt;--detach&lt;/span&gt;


&lt;span class="nb"&gt;chmod&lt;/span&gt; +x .git/hooks/post-push

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with the volume at the right path from day one.&lt;/strong&gt; The &lt;code&gt;/root/.hermes&lt;/code&gt; vs &lt;code&gt;/root/.hermes/memories&lt;/code&gt; mistake wasted the most time because the symptom—bot asking to set home channel—looked like a configuration problem, not a persistence problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify MCP tools are loading before assuming they work.&lt;/strong&gt; Ask Hermes directly: "What MCP tools do you have available?" If it lists nothing, you've got a config key problem or a secrets problem. Don't assume silence means they're loading quietly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;TELEGRAM_HOME_CHANNEL&lt;/code&gt; before your first deploy.&lt;/strong&gt; The silent-bot symptom with no logs is deeply confusing. Just have this set from the start.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference: the 10 gotchas
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gateway start&lt;/code&gt; fails in Docker&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;hermes gateway run&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Bot resets on every deploy&lt;/td&gt;
&lt;td&gt;Mount volume at &lt;code&gt;/root/.hermes&lt;/code&gt;, not a subdirectory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;MCP servers silently ignored&lt;/td&gt;
&lt;td&gt;Config key is &lt;code&gt;mcp_servers:&lt;/code&gt;, not &lt;code&gt;mcp.servers:&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Gateway ignores your config&lt;/td&gt;
&lt;td&gt;Copy config to both &lt;code&gt;cli-config.yaml&lt;/code&gt; AND &lt;code&gt;config.yaml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;MCP secrets not available&lt;/td&gt;
&lt;td&gt;Write all secrets to &lt;code&gt;~/.hermes/.env&lt;/code&gt; in entrypoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Bot starts but never responds&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;TELEGRAM_HOME_CHANNEL&lt;/code&gt; env var&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Zombie subprocess accumulation&lt;/td&gt;
&lt;td&gt;Install and use &lt;code&gt;tini&lt;/code&gt; as PID 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;MCP stdio server cold-start delay&lt;/td&gt;
&lt;td&gt;Pre-install packages in Dockerfile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Slow LLM failover&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;api_max_retries: 1&lt;/code&gt; when using fallback providers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Will volume hide the Hermes install?&lt;/td&gt;
&lt;td&gt;No—install is at system path, volume is for data only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




</description>
      <category>ai</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Measure Time-to-Value for Your API (Without a Data Team or Custom Analytics Build)</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Wed, 11 Mar 2026 19:37:03 +0000</pubDate>
      <link>https://dev.to/builtfordevs/how-to-measure-time-to-value-for-your-api-without-a-data-team-or-custom-analytics-build-5029</link>
      <guid>https://dev.to/builtfordevs/how-to-measure-time-to-value-for-your-api-without-a-data-team-or-custom-analytics-build-5029</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1501139083538-0139583c060f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDF8fHRpbWV8ZW58MHx8fHwxNzczMTg5MTIxfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1501139083538-0139583c060f%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDF8fHRpbWV8ZW58MHx8fHwxNzczMTg5MTIxfDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="How to Measure Time-to-Value for Your API (Without a Data Team or Custom Analytics Build)" width="2000" height="1333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You don't know your TTV. I'm willing to bet on it.&lt;/p&gt;

&lt;p&gt;Not because you're not tracking things. You probably have a dashboard full of signups, API calls, session lengths, and something labeled "activation rate" that nobody has ever cleanly defined. You're tracking plenty. You're just not tracking the right thing.&lt;/p&gt;

&lt;p&gt;Here's why that matters: developers who don't reach value fast leave fast. And you won't know they were ever close until they're already gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Activity Metrics
&lt;/h2&gt;

&lt;p&gt;Most API founders measure activity and call it progress. Sound familiar?&lt;/p&gt;

&lt;p&gt;You end up tracking things like time to first API call, number of calls in the first session, or completion of an onboarding checklist. These metrics tell you a developer showed up and touched your product. They don't tell you whether that developer got what they came for.&lt;/p&gt;

&lt;p&gt;Here's where it breaks down. The developer who made 3 API calls and churned looks identical in your data to the developer who made 3 API calls and became a power user. Until you look at whether they hit your Value Moment.&lt;/p&gt;

&lt;p&gt;You're measuring motion, not progress. That's the real problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a Value Moment?
&lt;/h2&gt;

&lt;p&gt;Your Value Moment is the specific interaction where a developer gets undeniable proof that your API does what they need it to do.&lt;/p&gt;

&lt;p&gt;Not "they called the endpoint." Not "they saw a response." The moment they got a result that made them think: &lt;em&gt;okay, this actually works for my use case.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It looks different for every product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For a payments API, it's the first successful test transaction in their dev environment&lt;/li&gt;
&lt;li&gt;For a speech-to-text API, it's the first transcript that returns with over 95% accuracy on their test audio&lt;/li&gt;
&lt;li&gt;For an AI completions API, it's the first response that matches their expected output format&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Value Moment is not generic. It's specific to what your product actually does. And it's almost always one concrete event, not a sequence.&lt;/p&gt;

&lt;p&gt;Your TTV is the gap between signup and that moment. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Value Moment Method: Define It, Track It, Fix It
&lt;/h2&gt;

&lt;p&gt;You can define your Value Moment and start measuring TTV in an afternoon. Here's exactly how.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Define Your Value Moment
&lt;/h3&gt;

&lt;p&gt;Pull up data on your best-retained developers, the ones who've been active 60+ days. What did they do in the first 24 hours that churned developers almost never did?&lt;/p&gt;

&lt;p&gt;You're looking for one identifiable event. If you can't access the data yet, interview three to five developers who successfully integrated your API. Ask them: "What was the moment you knew this was going to work?" Their answers will cluster around one or two specific interactions.&lt;/p&gt;

&lt;p&gt;That cluster is your Value Moment. Write it down in plain language: "A developer successfully [specific action] with our API." That sentence is the foundation of everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Identify the Signal
&lt;/h3&gt;

&lt;p&gt;Your Value Moment needs to map to something your system actually logs: an event, a response code, a specific API call.&lt;/p&gt;

&lt;p&gt;For most APIs this is straightforward. If your Value Moment is "first successful test transaction," your signal is probably &lt;code&gt;transaction.success&lt;/code&gt; with a test-mode flag. If it's "first accurate transcript returned," your signal might be a response with a confidence score above your threshold.&lt;/p&gt;

&lt;p&gt;If your system doesn't log the signal you need, that's a one-line tracking addition, not an analytics overhaul. Add the event, tag it as your Value Moment signal, move on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Measure the Gap
&lt;/h3&gt;

&lt;p&gt;TTV = time from signup to first Value Moment signal.&lt;/p&gt;

&lt;p&gt;That's the whole formula. You're not averaging session lengths or weighting engagement scores. You're measuring one gap: how long does it take a developer to get their first undeniable proof your API works?&lt;/p&gt;

&lt;p&gt;Track it for every new developer who signs up. Over time you'll have a distribution. What's the median? What's the 75th percentile? And the number that really matters: what percentage of developers hit the Value Moment at all within their first 7 days?&lt;/p&gt;

&lt;p&gt;That last number explains your retention curve better than anything else in your dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Do With Your TTV Number
&lt;/h2&gt;

&lt;p&gt;Your TTV number tells you two things immediately.&lt;/p&gt;

&lt;p&gt;First, whether your onboarding is actually working. If the median TTV for developers who stay long-term is 18 minutes and your current median is 4 hours, that gap is your onboarding problem. Not your product. Your onboarding.&lt;/p&gt;

&lt;p&gt;Second, where your intervention window is. Developers who don't hit the Value Moment in their first session almost never come back. That's not a judgment call, it's a pattern. Your job is to get them there before that window closes.&lt;/p&gt;

&lt;p&gt;From here you optimize by working backward from the Value Moment. What's the friction between signup and that first signal? Where do developers drop off before they get there? Those are the specific things to fix. Not "improve the docs" or "add more examples" in the abstract, but the precise steps between where developers start and where they need to get.&lt;/p&gt;

&lt;h2&gt;
  
  
  See Your TTV Before It Shows Up in Your Churn Report
&lt;/h2&gt;

&lt;p&gt;Here's the bottom line: defining your Value Moment takes an afternoon. Watching TTV data accumulate manually, correlating it against retention, turning it into something you can actually act on? That takes a lot longer.&lt;/p&gt;

&lt;p&gt;That's what Built for Devs handles for you. Sign up, define your Value Moment, and as new developers flow through your onboarding, you'll see your TTV in your dashboard automatically. No custom analytics pipeline required.&lt;/p&gt;

&lt;p&gt;You don't need a data team to start. You need to define the right moment, then let the tool do the tracking.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://builtfor.dev/score?ref=blog.builtfor.dev" rel="noopener noreferrer"&gt;Start measuring your developer TTV →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgbalq27aweul7l6ea071.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgbalq27aweul7l6ea071.png" alt="How to Measure Time-to-Value for Your API (Without a Data Team or Custom Analytics Build)" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;View of Built for Devs Dashboard&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>tools</category>
      <category>devrel</category>
    </item>
    <item>
      <title>Stop asking developers what they think. Watch what they do.</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Tue, 10 Mar 2026 17:55:12 +0000</pubDate>
      <link>https://dev.to/builtfordevs/stop-asking-developers-what-they-think-watch-what-they-do-4k9b</link>
      <guid>https://dev.to/builtfordevs/stop-asking-developers-what-they-think-watch-what-they-do-4k9b</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1530210124550-912dc1381cb8%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDI2fHx0aGlua2luZ3xlbnwwfHx8fDE3NzMxMTA0MzV8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1530210124550-912dc1381cb8%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDI2fHx0aGlua2luZ3xlbnwwfHx8fDE3NzMxMTA0MzV8MA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="Stop asking developers what they think. Watch what they do." width="2000" height="1334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ask a developer how your onboarding went and they'll probably say it was fine. Maybe a little long. Could use better docs. You take notes, you nod, you move on.&lt;/p&gt;

&lt;p&gt;What they won't tell you: they almost quit twice, they couldn't find the API reference without Googling, and the moment they hit your pricing page they had to go back and re-read the docs before they trusted you enough to continue.&lt;/p&gt;

&lt;p&gt;That version of events lives in their behavior. Not in their answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem isn't your feedback. It's what feedback can't capture.
&lt;/h2&gt;

&lt;p&gt;You're getting feedback. You're talking to users. You're doing the things the startup playbook says to do. But the signal you're collecting is optimized for your comfort, not your understanding.&lt;/p&gt;

&lt;p&gt;When a developer answers "was that confusing?", they don't replay the experience. They construct a memory of it—filtered through what they think you want to hear, how competent they want to appear, and what they've already forgotten. They won't tell you your onboarding took 47 minutes and they almost quit three times. They'll say it was "a little long, but fine." They self-censor because they've moved on and don't remember the exact sticking points.&lt;/p&gt;

&lt;p&gt;That's not dishonesty. That's just how memory works.&lt;/p&gt;

&lt;p&gt;The enemy isn't your users. It's the method you're using to understand them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Behavior tells you what answers can't
&lt;/h2&gt;

&lt;p&gt;Your existing users have context. They've learned your product, forgiven its rough edges, built workarounds. They're not starting from scratch—they're showing you what a loyalist does, not what a new developer does. Your network is worse. They want to be helpful. They'll push through friction that would make a stranger quit.&lt;/p&gt;

&lt;p&gt;What you need is a developer who actually matches the profile of someone you're trying to win—not a friend, not a power user, not someone already in your orbit. The friction that matters is what happens before they care about you. Before they've decided to give you the benefit of the doubt.&lt;/p&gt;

&lt;p&gt;What you see when that developer tries your product for the first time will not match what your feedback sessions told you.&lt;/p&gt;

&lt;p&gt;You'll see where they actually get stuck—not where they said they got stuck. You'll see the moment they almost quit. You'll see the exact point where your product either earns their trust or loses it. And you'll see things you never thought to ask about, because you didn't know they were problems.&lt;/p&gt;

&lt;p&gt;One founder we worked with discovered this the hard way. Their onboarding requested full Google Drive permissions when all they actually needed was access to a single spreadsheet. Feedback sessions didn't surface it. But when they started watching real developers go through the flow—and listening to what those developers said out loud—they found something no form had captured: developers were genuinely annoyed. Many said they would never grant those permissions. Nobody had written that in a feedback session because nobody asks "how do you feel about our OAuth scope?"&lt;/p&gt;

&lt;p&gt;Most founders who go from asking to watching describe the same experience: they thought they knew their onboarding flow. They'd walked through it dozens of times. Then they watched a real developer do it and saw three failure points they'd never once noticed—because they already knew where everything was.&lt;/p&gt;

&lt;p&gt;You know your product too well to use it naively. A developer who's never seen it before doesn't. That's exactly why their experience is the one worth watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of collecting the wrong signal
&lt;/h2&gt;

&lt;p&gt;With limited runway, every product bet has a price. The features you ship in the next two months either move retention or they don't. If those decisions are grounded in self-reported feedback, you're making expensive guesses dressed up as research.&lt;/p&gt;

&lt;p&gt;There's a specific kind of founder regret that comes from shipping a big feature, watching it go unused, and then finding out through a casual conversation that the feature developers actually needed was something much simpler. Something they never mentioned in a feedback session because they didn't think you'd want to hear it, or they'd already found a workaround and stopped thinking about it.&lt;/p&gt;

&lt;p&gt;That regret is avoidable. Not by doing more research. By doing a different kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signal you can't collect any other way
&lt;/h2&gt;

&lt;p&gt;The only way to get honest signal is to watch a developer who's never seen your product try to use it—without context, without guidance, without anyone from your team in the room. No prompts. No hand-holding. Just a real developer and your product.&lt;/p&gt;

&lt;p&gt;The problem is finding them. Not just any developer—one who actually looks like the developer you're trying to win. Your network is limited. Your existing users aren't starting from zero. There's no obvious place to find the right people with zero prior exposure—so most founders never get there at all.&lt;/p&gt;

&lt;p&gt;That's the problem &lt;a href="https://builtfor.dev/?ref=blog.builtfor.dev" rel="noopener noreferrer"&gt;Built for Devs&lt;/a&gt; solves. We match real developers who fit the profile of your target user, bring them to your product for the first time, and capture everything—every click, every hesitation, every moment they say "this makes no sense" out loud. That data powers a findings report that tells you precisely where the experience breaks and what to fix first. Not a list of opinions. A prioritized action plan grounded in real behavior.&lt;/p&gt;

&lt;p&gt;That discomfort of watching someone struggle through something you built—that's the data you've been missing.&lt;/p&gt;

</description>
      <category>startup</category>
      <category>tools</category>
      <category>devrel</category>
    </item>
    <item>
      <title>Why Developers Aren't Coming Back After Their First Session</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Mon, 09 Mar 2026 14:50:57 +0000</pubDate>
      <link>https://dev.to/builtfordevs/why-developers-arent-coming-back-after-their-first-session-1f3b</link>
      <guid>https://dev.to/builtfordevs/why-developers-arent-coming-back-after-their-first-session-1f3b</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1584105154398-99080f81b8ee%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDF8fGNsb3NlZCUyMGRvb3J8ZW58MHx8fHwxNzczMDY2ODk4fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1584105154398-99080f81b8ee%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDF8fGNsb3NlZCUyMGRvb3J8ZW58MHx8fHwxNzczMDY2ODk4fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="Why Developers Aren't Coming Back After Their First Session" width="2000" height="1333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You've fixed the tutorial. You've cleaned up the docs. You've killed the bugs early users reported. Developers still aren't coming back.&lt;/p&gt;

&lt;p&gt;If this is your situation, you're probably running the same troubleshooting loop most founders run: something about the product isn't clicking. So you improve the product. You add a better onboarding flow. You write a getting-started guide. You watch the metrics. Nothing changes.&lt;/p&gt;

&lt;p&gt;Here's what I keep seeing across dev tool companies stuck in this loop: the diagnosis is wrong. First-session churn usually isn't a product problem. It's a stage problem. And if you're measuring product metrics to fix a stage problem, you're going to keep shipping features that don't move retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Onboarding Loop That Goes Nowhere
&lt;/h2&gt;

&lt;p&gt;Most founders respond to first-session churn by improving onboarding. This makes sense on the surface—developers aren't coming back, so something about their first experience isn't working, so you fix the first experience.&lt;/p&gt;

&lt;p&gt;The problem is that "first experience" and "first session" aren't the same thing. The onboarding flow is one slice of the first session. But a developer's first session spans a journey that started before they ever opened your product—and continues through stages that have nothing to do with your tutorial.&lt;/p&gt;

&lt;p&gt;When you only measure what happens inside your product during session one, you're measuring a fraction of what actually determines whether someone comes back. You can optimize that fraction indefinitely and never touch the part that matters.&lt;/p&gt;

&lt;p&gt;I call this stage blindness: diagnosing developer churn with product metrics when the drop-off is happening at the journey level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Developers Actually Exit
&lt;/h2&gt;

&lt;p&gt;There's a framework I use to think about how developers move through any tool's ecosystem. Five stages: Discover, Evaluate, Learn, Build, Scale.&lt;/p&gt;

&lt;p&gt;Most first-session exits happen in Evaluate and Learn. Not Build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discover&lt;/strong&gt; is how they found you—search, a recommendation, a conference talk. If they're in your product, they've cleared Discover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evaluate&lt;/strong&gt; is the decision stage. Before a developer commits to learning anything, they're asking a question you probably can't see on your dashboard: &lt;em&gt;is this actually for me?&lt;/em&gt; They're scanning docs, reading changelog entries, checking the GitHub repo, looking at the community, reviewing pricing. They're trying to answer the question fast, before they invest real time. If your product doesn't give them a fast, clear answer, they're gone—and they look like a session-one exit, but the real exit happened during Evaluate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learn&lt;/strong&gt; is where developers start building skills, not building product. They're running the quickstart. They're working through examples. They're hitting friction and deciding whether the friction is temporary (learning curve) or permanent (bad product fit). If the gap between your quickstart and real-world usage is too big, or if the docs assume knowledge they don't have, they exit here. Again, looks like session-one churn. Actually a stage failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build&lt;/strong&gt; is where most onboarding optimization is aimed. More tutorials, better error messages, cleaner UI. The problem: if developers are exiting in Evaluate and Learn, they never make it to Build. Improving Build doesn't help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale&lt;/strong&gt; is retention at depth—developers who've integrated your tool and are now spreading it to their team or expanding usage. You don't have a Scale problem if you're seeing first-session exits. But you won't get there without clearing the earlier stages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stage Blindness Costs You
&lt;/h2&gt;

&lt;p&gt;When you can't see which stage developers are exiting, you default to shipping. More features, more onboarding improvements, more docs. I've watched companies spend a quarter iterating on their Build experience when their real problem was an Evaluate stage that left developers unsure whether the tool was even meant for them.&lt;/p&gt;

&lt;p&gt;The compounding cost: every iteration cycle you spend in the wrong stage is a cycle you're not spending on the right one. And with 4-6 months of runway, you don't have many cycles left.&lt;/p&gt;

&lt;p&gt;The other cost is signal distortion. When you're measuring product engagement during session one and attributing poor retention to product quality, you're training yourself to look in the wrong place. The feedback loop reinforces the wrong diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Diagnose Which Stage Is Failing
&lt;/h2&gt;

&lt;p&gt;You can't improve what you can't see. The starting point is mapping your developer journey with enough specificity to identify where the drop-off actually happens.&lt;/p&gt;

&lt;p&gt;Start with Evaluate. What does a developer experience between finding your product and deciding to invest time in it? What questions are they trying to answer? How fast can they answer them? If you don't know, your Evaluate stage might be invisible to you—which is exactly the problem.&lt;/p&gt;

&lt;p&gt;Then look at Learn. What's the gap between your quickstart and a real use case? Where does that first "wait, this doesn't work the way I expected" moment happen? That's usually where Learn exits occur.&lt;/p&gt;

&lt;p&gt;If you want a fast read on where your developer journey is failing, take the Developer Adoption Score at &lt;a href="https://builtfor.dev/score?ref=blog.builtfor.dev" rel="noopener noreferrer"&gt;builtfor.dev/score&lt;/a&gt;. Free, about 5 minutes, and it gives you an actual score on your journey—not a generic 'improve your onboarding' recommendation. Want to go deeper? Our product offering tracks real developer behavior across every stage so you can see exactly where the drop-off happens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl2tudh7jukqk8r05p8ms.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl2tudh7jukqk8r05p8ms.png" alt="Why Developers Aren't Coming Back After Their First Session" width="800" height="484"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Developer Journey Tracking in the Pro Plan&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Question to Ask
&lt;/h2&gt;

&lt;p&gt;The next time a developer doesn't come back after session one, the question isn't "what was wrong with their first experience?" The question is "which stage failed them?"&lt;/p&gt;

&lt;p&gt;Those are different questions with different answers. And until you can ask the right one, the onboarding loop keeps going.&lt;/p&gt;

&lt;p&gt;You've already tried fixing the product. That's not what's broken.&lt;/p&gt;

</description>
      <category>startup</category>
      <category>tooling</category>
      <category>devrel</category>
    </item>
    <item>
      <title>The 3 Signals That Tell You Whether to Rebuild or Improve Your Dev Tool Onboarding</title>
      <dc:creator>Tessa Kriesel</dc:creator>
      <pubDate>Mon, 09 Mar 2026 04:57:13 +0000</pubDate>
      <link>https://dev.to/builtfordevs/the-3-signals-that-tell-you-whether-to-rebuild-or-improve-your-dev-tool-onboarding-2iim</link>
      <guid>https://dev.to/builtfordevs/the-3-signals-that-tell-you-whether-to-rebuild-or-improve-your-dev-tool-onboarding-2iim</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1709593729967-f490f19a7a96%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDY1fHxzdG9wbGlnaHR8ZW58MHx8fHwxNzczMDMyMTA5fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1709593729967-f490f19a7a96%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDY1fHxzdG9wbGlnaHR8ZW58MHx8fHwxNzczMDMyMTA5fDA%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="The 3 Signals That Tell You Whether to Rebuild or Improve Your Dev Tool Onboarding" width="2000" height="1333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most teams are having the wrong argument. The debate isn't "should we rebuild"—it's whether you've looked at the right data first.&lt;/p&gt;

&lt;p&gt;I've pulled more than a few product teams out of this exact trap—one that burned a quarter rebuilding their onboarding to see activation barely move, another that iterated for six months on a flow with a structural problem from the start, adding tooltips and reorganizing steps while the actual issue sat untouched two layers deeper.&lt;/p&gt;

&lt;p&gt;Here's what both teams had in common: they made the rebuild vs. improve call based on instinct and internal pressure, not signal.&lt;/p&gt;

&lt;p&gt;That's fixable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem with this decision
&lt;/h2&gt;

&lt;p&gt;When activation flatlines, the conversation usually goes one of two ways.&lt;/p&gt;

&lt;p&gt;The first: "We've been incrementally improving this for months. It's time to rebuild." This feels decisive. But it's often a reaction to frustration, not evidence. Rebuilding satisfies the internal need to feel like you're taking bold action. It doesn't automatically address the root cause.&lt;/p&gt;

&lt;p&gt;The second: "A full rebuild is too expensive right now. Let's keep iterating." This feels prudent. But if the current flow has a structural problem—a wrong assumption about what developers need to experience first, or a sequence that doesn't match how they actually think—iterating around it will only produce marginal gains. You're polishing something that needs to be torn down.&lt;/p&gt;

&lt;p&gt;Neither instinct is wrong, exactly. They just aren't grounded in what the data can actually tell you.&lt;/p&gt;

&lt;p&gt;There are three signals that cut through the argument. I've seen them hold up across enough developer products to trust them. And the time it takes to check all three is probably less than your next sprint planning meeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 1: Where in the journey is the drop-off happening?
&lt;/h2&gt;

&lt;p&gt;This one requires you to be honest about what "activation" actually means for your product. Not sign-up. Not email confirmation. The moment a developer has experienced enough of your product to understand whether it solves their problem.&lt;/p&gt;

&lt;p&gt;Now look at your drop-off data. Where are developers leaving?&lt;/p&gt;

&lt;p&gt;If developers are dropping off before they've had the chance to reach that value moment—before the first API call, before the first integration runs, before they've gotten any output at all—that's usually a rebuild signal. The problem isn't that your steps are confusing or your copy is unclear. The problem is that the path to value is too long, or the framing at the front end is creating the wrong expectations, or developers aren't self-selecting correctly before they start.&lt;/p&gt;

&lt;p&gt;The instinct in this situation is to improve the steps right before the drop-off. But drop-off location is telling you something different: it's showing you where the gap between developer expectation and product reality became too wide to cross. If that gap opens before developers have experienced core value at all, improving the surrounding steps won't close it. The architecture is wrong—the flow is asking developers to commit before they understand what they're committing to.&lt;/p&gt;

&lt;p&gt;That's not an iteration problem. That's a structural one.&lt;/p&gt;

&lt;p&gt;If your drop-off is concentrated in the middle or later stages—after developers have had initial contact with the core value, but before they've completed the full flow—that's more often an improvement opportunity. The structure is right. There's friction you haven't removed yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The signal:&lt;/strong&gt; Drop-off concentrated before value exposure means rebuild. Drop-off concentrated after value exposure means improve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 2: Does completing onboarding predict activation?
&lt;/h2&gt;

&lt;p&gt;This one surprises teams every time they run it. Pull two cohorts: developers who completed your onboarding flow, and developers who skipped it and went straight to your docs. Compare activation rates.&lt;/p&gt;

&lt;p&gt;If developers who completed onboarding activate at significantly higher rates than those who skipped it, your flow is doing something useful. The signal-to-noise ratio is there. You have a structure worth improving.&lt;/p&gt;

&lt;p&gt;If the difference is small—or if developers who skipped onboarding activate at similar or better rates—that's a different story. It means your onboarding isn't providing something developers couldn't find themselves. They're working around it. That's a structural indictment, not an execution one.&lt;/p&gt;

&lt;p&gt;When developers route around your designed path and still succeed, it usually means the flow is optimized around internal assumptions about what developers need—not around what they actually do to reach value. The flow reflects how your team thinks about the product, not how developers experience it. You can improve that flow indefinitely. You're improving the wrong thing.&lt;/p&gt;

&lt;p&gt;The variation worth watching: if high-ICP developers skip onboarding and succeed, but lower-ICP developers complete it and still don't activate, your onboarding might be serving the wrong segment. That's a rebuild with a different diagnosis—you're building onboarding for developers who don't need it, instead of for the ones who do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The signal:&lt;/strong&gt; Completion predicts activation means improve. Completion and activation are uncorrelated means rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 3: Has friction migrated without completion rates improving?
&lt;/h2&gt;

&lt;p&gt;This is the one that tells you whether you've been iterating in circles.&lt;/p&gt;

&lt;p&gt;Pull the last three iterations of your onboarding. For each one: where was the primary drop-off point, and what was the overall completion rate?&lt;/p&gt;

&lt;p&gt;If the friction point has moved between iterations—step 4 became step 5 became step 7—but the completion rate hasn't meaningfully improved, you've been relocating friction, not removing it. Developers are hitting the same resistance; it's just showing up in a different place now.&lt;/p&gt;

&lt;p&gt;This is the clearest rebuild signal in the data. It tells you there's a load-bearing structural issue that iteration hasn't touched. The friction isn't in the steps. It's in the assumption the steps are built on.&lt;/p&gt;

&lt;p&gt;When the problem location moves but completion doesn't improve, you haven't solved anything—you've relocated the resistance. Eventually teams stop noticing that the friction point keeps shifting because each iteration feels like progress. It's not. You're rebuilding the same wall in a different spot.&lt;/p&gt;

&lt;p&gt;If friction is migrating but completion is improving—say, from 30% to 45% to 55%—that's a healthy iteration signal. Keep going. The structure is sound and the changes are working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The signal:&lt;/strong&gt; Friction migrates, completion stays flat means rebuild. Friction migrates, completion improves means improve.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the three signals combine
&lt;/h2&gt;

&lt;p&gt;You rarely need all three to point the same direction to get clarity. Here's how I use them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two or more signals point to rebuild:&lt;/strong&gt; Stop iterating. You're spending engineering time on the wrong problem. Rebuild means rethinking the journey architecture—what the flow is trying to accomplish, in what order, for which developer. Not just a new UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One signal points to rebuild, others are inconclusive:&lt;/strong&gt; Dig deeper before committing. One anomaly is worth investigating; it might be segment-specific or explained by another variable. But don't dismiss it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All signals point to improve:&lt;/strong&gt; Good news—you have a structural foundation worth building on. The work ahead is meaningful friction removal, clearer copy, better error handling, tighter time-to-value within the existing architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No signals point to rebuild and activation is still flat:&lt;/strong&gt; Look upstream. The problem might not be in your onboarding at all. It might be in how developers arrive—the expectations your marketing and content are creating before they sign up. Fixing the flow won't fix a positioning mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing before you start
&lt;/h2&gt;

&lt;p&gt;The instinct call usually costs you a quarter. Occasionally it costs two. The three signals cost an afternoon with your analytics.&lt;/p&gt;

&lt;p&gt;That's worth doing before you walk into the sprint planning meeting and announce a rebuild that might be the wrong call—or commit to another round of improvements on a flow that needs to be torn down.&lt;/p&gt;

&lt;p&gt;If you want to know where your developer journey is actually breaking, the &lt;a href="https://builtfor.dev/score?ref=blog.builtfor.dev" rel="noopener noreferrer"&gt;Developer Adoption Score&lt;/a&gt; is a free tool that evaluates your current experience across all five stages. It takes about ten minutes and gives you a starting point for this kind of diagnosis.&lt;/p&gt;

&lt;p&gt;The debate teams have about rebuild vs. improve is usually the right debate. They're just having it without the data that would make it productive.&lt;/p&gt;

</description>
      <category>startup</category>
      <category>tooling</category>
      <category>devrel</category>
    </item>
  </channel>
</rss>
