<?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: Mozi Mohidien</title>
    <description>The latest articles on DEV Community by Mozi Mohidien (@mozimohidien).</description>
    <link>https://dev.to/mozimohidien</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%2F4041622%2F54e71cb9-4d55-46af-bfb5-9fd89378169f.png</url>
      <title>DEV Community: Mozi Mohidien</title>
      <link>https://dev.to/mozimohidien</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mozimohidien"/>
    <language>en</language>
    <item>
      <title>How to Bulk-Enrich E-commerce Product Listings with AI</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 29 Jul 2026 08:06:20 +0000</pubDate>
      <link>https://dev.to/mozimohidien/how-to-bulk-enrich-e-commerce-product-listings-with-ai-29gp</link>
      <guid>https://dev.to/mozimohidien/how-to-bulk-enrich-e-commerce-product-listings-with-ai-29gp</guid>
      <description>&lt;p&gt;Most e-commerce catalog work is brutal. You have hundreds or thousands of products with descriptions that are either copied from a supplier, machine-translated from Chinese, or just the product name repeated three times. Rewriting them manually doesn't scale. Hiring a copywriter for every SKU isn't viable.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apify.com/mozimohidien/ecommerce-product-ai-enricher" rel="noopener noreferrer"&gt;E-commerce Product AI Enricher&lt;/a&gt; solves this. You give it a list of product page URLs. It scrapes each page, extracts all available product data, and runs it through Claude AI to generate: an SEO-optimized title, a 150-word description with natural keyword integration, a structured spec table, competitive positioning, cross-sell angles, and a target customer persona.&lt;/p&gt;

&lt;p&gt;This tutorial walks through exactly how to use it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;A pipeline that takes product page URLs and returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SEO title optimized for search intent (not just the product name)&lt;/li&gt;
&lt;li&gt;150-word description that reads like a human wrote it&lt;/li&gt;
&lt;li&gt;Structured specs extracted from unstructured page content&lt;/li&gt;
&lt;li&gt;Competitive positioning: what makes this product worth buying&lt;/li&gt;
&lt;li&gt;Cross-sell and upsell angles&lt;/li&gt;
&lt;li&gt;Target customer persona&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 10-product run finishes in 90 seconds. A 50-product run costs under $1.00 total.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify account&lt;/a&gt; — $5 free compute to start&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://console.anthropic.com" rel="noopener noreferrer"&gt;Anthropic API key&lt;/a&gt; — Claude Haiku; fractions of a cent per product&lt;/li&gt;
&lt;li&gt;A list of product page URLs to enrich&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The URLs can be your own store, competitor pages, or supplier pages — any publicly accessible product URL works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Collect Your Product URLs
&lt;/h2&gt;

&lt;p&gt;For your own store, pull a CSV export from Shopify, WooCommerce, or your platform of choice. The product URL column is what you need.&lt;/p&gt;

&lt;p&gt;For competitor research, find their product category pages and collect URLs manually or via a link scraper.&lt;/p&gt;

&lt;p&gt;For dropshippers: go to your supplier's catalog pages (AliExpress, CJdropshipping, etc.) and collect the URLs of products you're listing. The actor will turn generic supplier descriptions into differentiated copy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Open the Actor
&lt;/h2&gt;

&lt;p&gt;Go to &lt;strong&gt;&lt;a href="https://apify.com/mozimohidien/ecommerce-product-ai-enricher" rel="noopener noreferrer"&gt;E-commerce Product AI Enricher on Apify&lt;/a&gt;&lt;/strong&gt; and click &lt;strong&gt;Try for free&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Configure Your Run
&lt;/h2&gt;

&lt;p&gt;Here's a sample input for enriching ergonomic office furniture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"productUrls"&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="s2"&gt;"https://www.amazon.com/dp/B08N5WRWNW"&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://somestore.com/products/ergochair-pro"&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://supplier.com/products/mesh-office-chair-lumbar"&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;"anthropicApiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-ant-api03-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targetKeyword"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ergonomic office chair"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputFormat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"full"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxConcurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;productUrls&lt;/strong&gt; — any publicly accessible product page. Amazon, Shopify, WooCommerce, Magento, custom sites all work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;targetKeyword&lt;/strong&gt; — optional, but use it. Claude will optimize the title and description to rank for this term. Use your primary keyword — the one your category targets in Google.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;outputFormat&lt;/strong&gt; — &lt;code&gt;full&lt;/code&gt; gives you everything. &lt;code&gt;copy-only&lt;/code&gt; gives just the title and description if you only need the text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;maxConcurrency&lt;/strong&gt; — how many pages to scrape in parallel. Default 5 is fine for most runs. Raise to 10 for large batches.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Run It
&lt;/h2&gt;

&lt;p&gt;Click &lt;strong&gt;Start&lt;/strong&gt;. The actor visits each URL, extracts the product content, and sends it to Claude AI for enrichment. A 10-product run finishes in 90 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Understand the Output
&lt;/h2&gt;

&lt;p&gt;Here's what a full enrichment record looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&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://www.amazon.com/dp/B08N5WRWNW"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"originalTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ErgoChair Pro Office Chair"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enrichment"&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;"seoTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ergonomic Office Chair with Lumbar Support — Adjustable Armrests, Mesh Back, 8h Comfort"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Built for people who sit 8 hours a day. The ErgoChair Pro combines a breathable mesh back with a 4-way adjustable lumbar support system that actually holds its position. The armrests adjust in height and angle independently — unusual at this price point. Suitable for desks 70–80cm high. Weight capacity 120kg. Available in black and grey."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"specs"&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;"Material"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mesh back, foam seat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Max weight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120kg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Adjustable height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"42–52cm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Armrests"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4D adjustable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Warranty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2 years"&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;"competitivePositioning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Priced below Humanscale and Herman Miller alternatives while offering comparable lumbar adjustment. Lacks the build quality of premium options but outperforms generic mesh chairs at this price range."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"targetPersona"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Remote workers spending 6+ hours seated daily, prioritizing back support over aesthetics"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"crossSellAngles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"monitor arms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standing desk converters"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"desk pads"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;seoTitle&lt;/strong&gt; — leads with the primary keyword, adds specific differentiators ("Adjustable Armrests, Mesh Back") that people actually search for. Better than the original "ErgoChair Pro Office Chair" which doesn't explain what makes it worth buying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;description&lt;/strong&gt; — specific, not generic. It mentions the 4-way armrest adjustment being unusual at this price point. That's the kind of detail that converts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;specs&lt;/strong&gt; — structured from whatever was on the page, even if it was buried in paragraph text. Ready to paste into your product spec table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;competitivePositioning&lt;/strong&gt; — most useful for competitive research. Run a competitor URL and this field tells you where their product is genuinely weaker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;crossSellAngles&lt;/strong&gt; — use these for your "frequently bought with" recommendations or bundles.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Export and Import
&lt;/h2&gt;

&lt;p&gt;Export the Apify dataset as CSV → import into Shopify, WooCommerce, or wherever you manage your catalog. The fields map cleanly to standard product fields.&lt;/p&gt;

&lt;p&gt;For large catalogs, use Apify's webhook or API integration to pipe results directly into your catalog management system as each product completes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use Cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dropshippers&lt;/strong&gt; — supplier descriptions are almost always generic and keyword-stuffed. This actor turns "High Quality Ergonomic Mesh Chair With Lumbar Support Good For Office Use" into copy that actually explains the product. Differentiation at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amazon sellers&lt;/strong&gt; — run your competitor listings through the enricher. The competitive positioning field will tell you where their copy is weak, what they're not saying, and how to write a better listing for the same product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agencies&lt;/strong&gt; — if you manage e-commerce clients, this is a service you can sell. Catalog enrichment at scale is labor-intensive; this makes it fast. Run 100 products, edit the top 10% manually for hero products, ship the rest as-is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price comparison sites&lt;/strong&gt; — if you aggregate product data from multiple retailers, the enricher normalizes it into a consistent schema. Specs, descriptions, and titles are standardized across sources.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Quality is higher when you provide targetKeyword.&lt;/strong&gt; Claude generates more focused copy when it knows what term you're optimizing for. Don't skip it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run your worst listings first.&lt;/strong&gt; Sort your catalog by conversion rate ascending and start with the bottom 10%. Those are the products where better copy will have the most immediate revenue impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the spec output for structured data.&lt;/strong&gt; Google uses structured product data for rich results in shopping. Clean spec tables are also what customers look for on mobile — the description matters less than the bullet points.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For non-English pages:&lt;/strong&gt; Claude AI is multilingual and can extract data from French, German, Spanish, Dutch, and other European-language pages reliably. Set &lt;code&gt;targetKeyword&lt;/code&gt; in English for the output copy even if the source is in another language.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apify compute&lt;/td&gt;
&lt;td&gt;~$0.01–$0.02 per product URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic (Claude Haiku)&lt;/td&gt;
&lt;td&gt;~$0.003–$0.005 per product enriched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 products&lt;/td&gt;
&lt;td&gt;Under $1.00 total&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 500-product catalog enrichment costs under $10. Compare that to a copywriter at $5–$15 per product description.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The actor is live: &lt;strong&gt;&lt;a href="https://apify.com/mozimohidien/ecommerce-product-ai-enricher" rel="noopener noreferrer"&gt;E-commerce Product AI Enricher on Apify&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Start with 10 of your worst-converting product URLs and a target keyword. Review the output — you'll see immediately which fields are ready to publish and which need a quick edit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions or feedback? Drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>ecommerce</category>
      <category>automation</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to Use Job Postings as B2B Lead Signals (with AI)</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 29 Jul 2026 08:06:09 +0000</pubDate>
      <link>https://dev.to/mozimohidien/how-to-use-job-postings-as-b2b-lead-signals-with-ai-37f</link>
      <guid>https://dev.to/mozimohidien/how-to-use-job-postings-as-b2b-lead-signals-with-ai-37f</guid>
      <description>&lt;p&gt;Timing is the part of B2B sales most people get wrong. They reach out to companies that might buy, instead of companies that are buying right now.&lt;/p&gt;

&lt;p&gt;A company that just posted three senior data engineer roles has an open headcount budget, an active hiring cycle, and a head of data with approval authority who is already thinking about their stack. They are in motion. That is the moment to reach out — not six months later when the hiring is done and the budget is spent.&lt;/p&gt;

&lt;p&gt;The problem is that job postings are scattered across LinkedIn, Greenhouse, Lever, Indeed, and dozens of company career pages. Manually checking them isn't realistic at any scale.&lt;/p&gt;

&lt;p&gt;This tutorial shows you how to automate it. The &lt;a href="https://apify.com/mozimohidien/job-posting-ai-analyzer" rel="noopener noreferrer"&gt;Job Posting AI Analyzer&lt;/a&gt; scrapes job posting URLs you feed it and sends each one through Claude AI to extract: tech stack, seniority, what business problem the hire is solving, who likely approved it, and a B2B signal score from 1–10.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;A pipeline that takes a list of job posting URLs and returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;B2B hiring signal score (1–10) for each posting&lt;/li&gt;
&lt;li&gt;Tech stack extracted from the job description&lt;/li&gt;
&lt;li&gt;Seniority level and team context&lt;/li&gt;
&lt;li&gt;Business problem the hire is solving&lt;/li&gt;
&lt;li&gt;Decision-maker title (who probably approved the req)&lt;/li&gt;
&lt;li&gt;Outreach timing recommendation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 50-URL batch runs in under 2 minutes and costs less than $0.75 total.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify account&lt;/a&gt; — free tier includes $5 compute&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://console.anthropic.com" rel="noopener noreferrer"&gt;Anthropic API key&lt;/a&gt; — Claude Haiku handles the analysis; fractions of a cent per posting&lt;/li&gt;
&lt;li&gt;A list of job posting URLs from LinkedIn, Greenhouse, Lever, Indeed, or company career pages&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Find Job URLs
&lt;/h2&gt;

&lt;p&gt;LinkedIn's job search is the easiest source. Filter by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Function (e.g., "Engineering", "Data", "Marketing")&lt;/li&gt;
&lt;li&gt;Company size (e.g., 51–200, 201–500 employees)&lt;/li&gt;
&lt;li&gt;Date posted (past week)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy the job URLs from the results. You can also use Apify's LinkedIn Jobs Scraper if you want to automate the URL collection step.&lt;/p&gt;

&lt;p&gt;For Greenhouse and Lever boards (common at Series A–C companies), the URL format is consistent: &lt;code&gt;boards.greenhouse.io/[company]/jobs/[id]&lt;/code&gt; and &lt;code&gt;jobs.lever.co/[company]/[id]&lt;/code&gt;. Both work with this actor.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Open the Actor
&lt;/h2&gt;

&lt;p&gt;Go to &lt;strong&gt;&lt;a href="https://apify.com/mozimohidien/job-posting-ai-analyzer" rel="noopener noreferrer"&gt;Job Posting AI Analyzer on Apify&lt;/a&gt;&lt;/strong&gt; and click &lt;strong&gt;Try for free&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Configure Your Run
&lt;/h2&gt;

&lt;p&gt;Here's a sample input for a data tooling company targeting companies scaling their data teams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jobUrls"&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="s2"&gt;"https://www.linkedin.com/jobs/view/4253571918"&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://boards.greenhouse.io/databricks/jobs/7614285002"&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://jobs.lever.co/airbyte/abc123"&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://careers.acmecorp.com/jobs/senior-data-engineer"&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;"anthropicApiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-ant-api03-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extractTechStack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxConcurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;jobUrls&lt;/strong&gt; — any publicly accessible job posting URL. Boards requiring login won't work; most don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;minScore&lt;/strong&gt; — posts scoring below this are dropped. 7+ means the company is hiring in or near the function your product serves. 9–10 means the hire directly implies budget for your category.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;extractTechStack&lt;/strong&gt; — keep this true. Even when tech isn't listed explicitly, Claude infers it from context ("experience with our data warehouse" in a Snowflake shop tells you their stack).&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Run It
&lt;/h2&gt;

&lt;p&gt;Click &lt;strong&gt;Start&lt;/strong&gt;. A 5-URL batch finishes in under a minute. A 50-URL batch takes 1–2 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Read a High-Score Output
&lt;/h2&gt;

&lt;p&gt;Here's what a score-9 record looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&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://boards.greenhouse.io/somecompany/jobs/4567890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Analytics"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jobTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Senior Data Engineer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Remote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"analysis"&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;"hiringSignalScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"seniority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Senior (5+ years)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"techStack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dbt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Snowflake"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Airflow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AWS"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"teamContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Third data hire in 60 days — building out platform team, not just backfilling"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"businessProblem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Moving from ad-hoc analytics to a self-serve data platform. Engineering is the current bottleneck for reports."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"decisionMakerTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Head of Data / VP Engineering"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outreachTiming"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ideal — actively scaling, budget open, buying cycle in progress"&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;Score 9 with "actively scaling, budget open" means reach out this week. The Head of Data is approving headcount — they're also thinking about tooling to make that team productive.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;techStack&lt;/code&gt; field tells you exactly what they use. If your product integrates with Snowflake and dbt, lead with that. If they're on a stack you don't support, don't waste their time.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;businessProblem&lt;/code&gt; field gives you your opening line: "Saw you're building out your data platform — specifically around self-serve analytics. Worth a 20-minute call to show you how [X companies your size] handled the engineering bottleneck for reporting?"&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Export and Outreach
&lt;/h2&gt;

&lt;p&gt;Export the dataset as CSV → import into HubSpot, Apollo, or your CRM. Use the &lt;code&gt;decisionMakerTitle&lt;/code&gt; to find the right person on LinkedIn. Use &lt;code&gt;businessProblem&lt;/code&gt; as your first sentence.&lt;/p&gt;

&lt;p&gt;Don't reference that you saw their job posting directly — it can feel intrusive. Use it as intelligence, not as your opening.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hiring patterns that signal open budget:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple hires in the same function within 60 days — budget is flowing&lt;/li&gt;
&lt;li&gt;Senior-level hires (5+ years) — they're paying above median, budget is real&lt;/li&gt;
&lt;li&gt;"Head of" or "Director of" hires — building a new function from scratch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Patterns that signal cost-cutting (avoid):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backfill postings ("joining our existing team of X")&lt;/li&gt;
&lt;li&gt;Junior-heavy postings (they're reducing cost, not scaling)&lt;/li&gt;
&lt;li&gt;Postings that have been live for 60+ days (either very hard to fill or frozen)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How often to run it:&lt;/strong&gt; Weekly. A 7-day cadence catches companies early in their hiring cycle while the budget decision is still fresh. Monthly is too slow — the window closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building your URL list at scale:&lt;/strong&gt; Use LinkedIn job search with filters for your ICP (company size + function + date posted), export the URLs, run them through this actor. Takes 20 minutes to set up a weekly process that would otherwise take hours.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apify compute&lt;/td&gt;
&lt;td&gt;~$0.01–$0.02 per job URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic (Claude Haiku)&lt;/td&gt;
&lt;td&gt;~$0.003 per posting analyzed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 job postings&lt;/td&gt;
&lt;td&gt;Under $0.75 total&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Weekly runs of 100 postings cost about $1.50. You only need one good lead per week to justify it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The actor is live: &lt;strong&gt;&lt;a href="https://apify.com/mozimohidien/job-posting-ai-analyzer" rel="noopener noreferrer"&gt;Job Posting AI Analyzer on Apify&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Start with 10 job URLs from LinkedIn for companies in your ICP. See what scores 8+. Use the business problem field to write your first outreach line.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions or feedback on the actor? Drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>ai</category>
      <category>automation</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Find B2B Leads on Reddit Using AI (No Code Required)</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 29 Jul 2026 08:03:33 +0000</pubDate>
      <link>https://dev.to/mozimohidien/how-to-find-b2b-leads-on-reddit-using-ai-no-code-required-10ab</link>
      <guid>https://dev.to/mozimohidien/how-to-find-b2b-leads-on-reddit-using-ai-no-code-required-10ab</guid>
      <description>&lt;p&gt;Most founders and sales teams who try Reddit for lead gen give up within a week. The manual process doesn't scale: you have to know which subreddits to watch, search the right keywords, scroll through noise, and decide whether each post is worth responding to. By the time you've done that for 5 subreddits, an hour is gone and you've found two maybe-leads.&lt;/p&gt;

&lt;p&gt;This tutorial shows you how to automate the whole thing. You'll use the &lt;a href="https://apify.com/mozimohidien/reddit-lead-ai-finder" rel="noopener noreferrer"&gt;Reddit Lead AI Finder&lt;/a&gt; actor on Apify — it searches subreddits by keyword, then runs each post through Claude AI to score buying intent, extract the pain point, and suggest how to respond.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;A repeatable pipeline that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Searches any combination of subreddits and keywords&lt;/li&gt;
&lt;li&gt;Scores each post 1–10 for buying intent using Claude AI&lt;/li&gt;
&lt;li&gt;Extracts the exact pain point being described&lt;/li&gt;
&lt;li&gt;Suggests how to respond to the post authentically&lt;/li&gt;
&lt;li&gt;Filters out noise below your score threshold&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical run of 3 subreddits x 3 keywords x 20 posts takes under 3 minutes and costs less than $0.35.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify account&lt;/a&gt; — free tier gives you $5 compute credit to start&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://console.anthropic.com" rel="noopener noreferrer"&gt;Anthropic API key&lt;/a&gt; — free to create; Claude Haiku costs fractions of a cent per post&lt;/li&gt;
&lt;li&gt;A list of target subreddits and keywords relevant to your product or service&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Get Your Anthropic API Key
&lt;/h2&gt;

&lt;p&gt;Go to &lt;a href="https://console.anthropic.com" rel="noopener noreferrer"&gt;console.anthropic.com&lt;/a&gt;, create an account, and generate an API key. It looks like &lt;code&gt;sk-ant-api03-...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The actor uses Claude Haiku — the cheapest Claude model — for lead scoring. At roughly $0.002 per post analyzed, you can process 500 posts for about a dollar.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Open the Actor
&lt;/h2&gt;

&lt;p&gt;Go to &lt;strong&gt;&lt;a href="https://apify.com/mozimohidien/reddit-lead-ai-finder" rel="noopener noreferrer"&gt;Reddit Lead AI Finder on Apify&lt;/a&gt;&lt;/strong&gt; and click &lt;strong&gt;Try for free&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Configure Your Run
&lt;/h2&gt;

&lt;p&gt;Here's a concrete example for a CRM or sales tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subreddits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"entrepreneur"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"saas"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"recommend CRM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"looking for tool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alternative to"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPostsPerSearch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minLeadScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"anthropicApiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-ant-api03-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sortBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"relevance"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;subreddits&lt;/strong&gt; — list without the &lt;code&gt;r/&lt;/code&gt; prefix. Start with 2–3 tightly focused communities rather than big general ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;keywords&lt;/strong&gt; — search terms Reddit will match against post titles and bodies. "Looking for a tool", "anyone recommend", "alternative to [competitor]", "how do you handle X" are all strong patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;minLeadScore&lt;/strong&gt; — posts scoring below this are dropped from output. 7+ is the right default — it keeps genuinely interested people and drops rants and tangential mentions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;sortBy&lt;/strong&gt; — &lt;code&gt;relevance&lt;/code&gt; for best signal. &lt;code&gt;new&lt;/code&gt; if you want to catch posts before anyone else replies.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Run It
&lt;/h2&gt;

&lt;p&gt;Click &lt;strong&gt;Start&lt;/strong&gt;. The actor searches Reddit, fetches post content, and sends each post to Claude AI for scoring. A 2-subreddit x 3-keyword x 20-post run finishes in 1–3 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Review Your Results
&lt;/h2&gt;

&lt;p&gt;Here's what a high-scoring output record looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subreddit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"entrepreneur"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Best CRM for a 3-person sales team?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u/startupfounder99"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"upvotes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commentCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&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://reddit.com/r/entrepreneur/comments/abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"leadAnalysis"&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;"leadScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"buyingSignals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"actively evaluating tools"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"has budget context"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"specific use case defined"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"painPoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Current spreadsheet-based process breaking down as team grows. Needs pipeline visibility without enterprise complexity."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outreachAngle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Comment with a specific recommendation based on their 3-person constraint. Lead with the problem, not the product."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;leadScore 9&lt;/strong&gt; means active buying intent — this person is making a decision right now, not just exploring. Score 7–8 means serious consideration. Score 6 means problem-aware but not yet shopping.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;painPoint&lt;/code&gt; field tells you exactly what to address in your response. The &lt;code&gt;outreachAngle&lt;/code&gt; tells you how to frame it. Don't paste your product description — engage with their specific problem first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Export and Act
&lt;/h2&gt;

&lt;p&gt;Click the &lt;strong&gt;Dataset&lt;/strong&gt; tab -&amp;gt; &lt;strong&gt;Export&lt;/strong&gt; -&amp;gt; &lt;strong&gt;CSV&lt;/strong&gt;. You now have a scored lead list with post URLs, pain points, and outreach angles.&lt;/p&gt;

&lt;p&gt;For each lead scoring 8+:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the actual post and the top comments&lt;/li&gt;
&lt;li&gt;Write a reply addressing their specific pain point first&lt;/li&gt;
&lt;li&gt;Mention your product as one option among several if it genuinely fits&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reddit punishes obvious promotion. If your reply leads with value, it will get upvoted and convert. If it looks like an ad, it gets removed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use Cases by Niche
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SaaS tools&lt;/strong&gt; — r/entrepreneur, r/saas, r/startups, r/webdev. Keywords: "anyone use", "recommend", "looking for alternatives to", "how do you handle".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agencies and freelancers&lt;/strong&gt; — r/smallbusiness, r/marketing, r/freelance. Keywords: "looking to hire", "need help with", "should I outsource".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;E-commerce&lt;/strong&gt; — niche subreddits specific to your product category. Someone in r/coffee asking about inventory management is a better lead than the same question in r/entrepreneur.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B2B services&lt;/strong&gt; — r/msp, r/sysadmin, r/devops. Keywords: "looking for a solution", "recommendation for", "has anyone dealt with".&lt;/p&gt;




&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Post age matters.&lt;/strong&gt; Posts under 2 days old are better targets — the person is still checking for replies. A 3-week-old post with 200 comments already has its answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why 7+ is the right threshold.&lt;/strong&gt; Scores below 7 tend to be: venting with no intent to buy, general curiosity questions, or posts where your product is tangentially relevant at best. Keeping the threshold at 7 means every result is worth reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Combine with other signals.&lt;/strong&gt; If you also run the &lt;a href="https://apify.com/mozimohidien/job-posting-ai-analyzer" rel="noopener noreferrer"&gt;Job Posting AI Analyzer&lt;/a&gt;, you can cross-reference: companies where employees are posting Reddit questions about pain your product solves AND who are hiring in the same function have a genuinely open budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it on a schedule.&lt;/strong&gt; Set up a weekly Apify scheduled run on your top subreddits. New posts surface automatically; you review on Monday and respond to whatever scored 8+.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apify compute&lt;/td&gt;
&lt;td&gt;~$0.01 per subreddit/keyword search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic (Claude Haiku)&lt;/td&gt;
&lt;td&gt;~$0.002 per post analyzed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 subreddits x 3 keywords x 20 posts&lt;/td&gt;
&lt;td&gt;Under $0.35 total&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can run this daily for a week for under $3. Compare that to any lead database subscription.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The actor is live: &lt;strong&gt;&lt;a href="https://apify.com/mozimohidien/reddit-lead-ai-finder" rel="noopener noreferrer"&gt;Reddit Lead AI Finder on Apify&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Start with your top 2 subreddits and 3 keywords. See what scores 8+. Reply to 3 of them this week and see what happens.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions or issues? Drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>ai</category>
      <category>automation</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Find Company Emails and Contacts with AI (No Code Required)</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Tue, 28 Jul 2026 15:17:09 +0000</pubDate>
      <link>https://dev.to/mozimohidien/how-to-find-company-emails-and-contacts-with-ai-no-code-required-1h28</link>
      <guid>https://dev.to/mozimohidien/how-to-find-company-emails-and-contacts-with-ai-no-code-required-1h28</guid>
      <description>&lt;h1&gt;
  
  
  How to Find Company Emails and Contacts with AI (No Code Required)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Tags: webdev, tutorial, ai, productivity&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Canonical URL: &lt;a href="https://apify.com/mozimohidien/company-contact-ai-enricher" rel="noopener noreferrer"&gt;https://apify.com/mozimohidien/company-contact-ai-enricher&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;If you do any B2B sales or outreach, you know the bottleneck: you have a list of target companies but you spend half your time just finding who to contact and what to say.&lt;/p&gt;

&lt;p&gt;Tools like Apollo and Hunter help, but they're expensive, they only cover companies already in their database, and they give you raw contacts — not the intelligence you need to make your message land.&lt;/p&gt;

&lt;p&gt;This tutorial shows you how to go from a list of company URLs to enriched, ICP-scored leads with personalised outreach openers — using an AI-powered Apify actor, no code, no subscription required.&lt;/p&gt;


&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;A pipeline that takes company website URLs as input and returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Publicly available emails, phone numbers, LinkedIn, and Twitter profiles&lt;/li&gt;
&lt;li&gt;AI enrichment: industry, estimated team size, tech stack signals, ICP fit score (1–10)&lt;/li&gt;
&lt;li&gt;A personalised outreach opener written specifically for each company&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cost for a 20-company run: under $1. Time: about 3 minutes.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why Not Just Use a Contact Database?
&lt;/h2&gt;

&lt;p&gt;Contact databases (Apollo, ZoomInfo, Hunter) are great — but they have real gaps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Coverage&lt;/strong&gt;: smaller or newer companies often aren't indexed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freshness&lt;/strong&gt;: emails go stale; people change roles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intelligence&lt;/strong&gt;: they give you a contact, not a reason to reach out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: enterprise plans cost hundreds per month&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The actor in this tutorial scrapes directly from company websites, so coverage is whoever has a public web presence — and it adds an AI layer that scores the lead against your specific ICP and writes a first-line that doesn't sound like a template.&lt;/p&gt;


&lt;h2&gt;
  
  
  What You Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify account&lt;/a&gt; (free tier works)&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://console.anthropic.com" rel="noopener noreferrer"&gt;Anthropic API key&lt;/a&gt; (free to create; Claude Haiku costs ~$0.25 per million tokens — fractions of a cent per company)&lt;/li&gt;
&lt;li&gt;A list of company website URLs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. No code, no local setup.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Get Your Anthropic API Key
&lt;/h2&gt;

&lt;p&gt;Go to &lt;a href="https://console.anthropic.com" rel="noopener noreferrer"&gt;console.anthropic.com&lt;/a&gt;, create an account, and generate an API key. It looks like &lt;code&gt;sk-ant-api03-...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The actor uses Claude Haiku — the fastest and cheapest model in the Claude family — for the enrichment step. For a 20-company run, expect to spend $0.01–$0.05 on the AI calls.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 2: Open the Actor
&lt;/h2&gt;

&lt;p&gt;Go to: &lt;strong&gt;&lt;a href="https://apify.com/mozimohidien/company-contact-ai-enricher" rel="noopener noreferrer"&gt;Company Contact Scraper &amp;amp; AI Lead Enricher on Apify&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Try for free&lt;/strong&gt;. You don't need a paid plan — Apify gives you $5 of free compute credit when you sign up, which is enough for hundreds of test runs.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3: Configure Your Run
&lt;/h2&gt;

&lt;p&gt;You'll see a simple input form. Here's what each field does:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Company URLs&lt;/strong&gt;&lt;br&gt;
Paste your target company URLs, one per line. These are the websites you want to scrape and enrich. Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://stripe.com
https://notion.so
https://linear.app
https://figma.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also upload a text file with URLs if you have a long list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anthropic API Key&lt;/strong&gt;&lt;br&gt;
Paste your &lt;code&gt;sk-ant-...&lt;/code&gt; key here. It's stored as a secret — never logged or exposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Max Companies&lt;/strong&gt;&lt;br&gt;
How many companies to process in this run. Default is 10; max is 50 per run. For bigger lists, run multiple batches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy Configuration&lt;/strong&gt; (optional)&lt;br&gt;
Leave this empty for most websites. If you're targeting companies that have aggressive bot protection, enable Apify Residential Proxy here.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 4: Run It
&lt;/h2&gt;

&lt;p&gt;Click &lt;strong&gt;Start&lt;/strong&gt;. The actor will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Visit each company URL&lt;/li&gt;
&lt;li&gt;Extract publicly visible emails, phone numbers, and social links&lt;/li&gt;
&lt;li&gt;Send the scraped content to Claude AI for enrichment&lt;/li&gt;
&lt;li&gt;Store results in a structured dataset&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A 10-company run typically finishes in 60–90 seconds.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 5: Review Your Results
&lt;/h2&gt;

&lt;p&gt;When the run completes, click the &lt;strong&gt;Dataset&lt;/strong&gt; tab. You'll see one row per company.&lt;/p&gt;

&lt;p&gt;Here's what a single output record looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&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://linear.app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hello@linear.app"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phones"&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;"socials"&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;"linkedin"&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://linkedin.com/company/linear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"twitter"&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://twitter.com/linear"&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;"enrichment"&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;"industry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"B2B SaaS / Project Management"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"teamSizeEstimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"50-200"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"icpFitScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"techStackHints"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"React"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TypeScript"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Electron"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"primaryProduct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Issue tracking and project management for software teams"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"decisionMakerTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Head of Engineering / VP Product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outreachHook"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Linear is doing impressive work on keyboard-driven UX — wanted to reach out about how similar dev-tool companies have cut their SDR research time by 60%."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;icpFitScore&lt;/code&gt; is on a 1–10 scale. Sort your results by score and start with the 8+ leads.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Export to Your CRM
&lt;/h2&gt;

&lt;p&gt;Click &lt;strong&gt;Export&lt;/strong&gt; → &lt;strong&gt;CSV&lt;/strong&gt;. The column headers map directly to standard CRM fields. Import into HubSpot, Salesforce, or Apollo with their standard CSV import flow.&lt;/p&gt;

&lt;p&gt;If you want to automate this — e.g., run the actor on a schedule and push results straight to your CRM via webhook — Apify has built-in integrations with Zapier, Make, and its own API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-World Use: Cold Email Sequence
&lt;/h2&gt;

&lt;p&gt;Here's how I'd use this for a cold email campaign targeting B2B SaaS tools:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build a list of 50 company URLs from LinkedIn search, ProductHunt, or a VC portfolio page&lt;/li&gt;
&lt;li&gt;Run the actor — 3 minutes, under $2&lt;/li&gt;
&lt;li&gt;Filter results: &lt;code&gt;icpFitScore &amp;gt;= 8&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Use the &lt;code&gt;outreachHook&lt;/code&gt; as the first line of your cold email&lt;/li&gt;
&lt;li&gt;Add the lead to Instantly or Apollo with the extracted email&lt;/li&gt;
&lt;li&gt;Send&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The personalized opener is the part that moves reply rates. Generic openers ("I came across your company and thought...") get ignored. An opener that references what the company actually does ("Linear is doing impressive work on keyboard-driven UX...") gets opened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;URL quality matters.&lt;/strong&gt; The actor visits the homepage. If a company's contact info is only on their &lt;code&gt;/contact&lt;/code&gt; page, you might miss it. For best results, pass the &lt;code&gt;/contact&lt;/code&gt; URL directly when you know it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't over-batch.&lt;/strong&gt; 20–30 URLs per run gives you fast results and easy debugging if something goes wrong. Scale up once you've validated the output quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Combine with job posting signals.&lt;/strong&gt; Pair this actor with the &lt;a href="https://apify.com/mozimohidien/job-posting-ai-analyzer" rel="noopener noreferrer"&gt;Job Posting AI Analyzer&lt;/a&gt; to time your outreach: companies actively hiring are actively spending. Hit them when their budget is open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reddit for warm leads.&lt;/strong&gt; Use the &lt;a href="https://apify.com/mozimohidien/reddit-lead-ai-finder" rel="noopener noreferrer"&gt;Reddit Lead AI Finder&lt;/a&gt; to surface companies posting about pain your product solves, then run those domains through this actor for contact info.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pricing Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apify compute&lt;/td&gt;
&lt;td&gt;~$0.01–$0.05 per company&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic (Claude Haiku)&lt;/td&gt;
&lt;td&gt;~$0.001–$0.003 per company&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total per company&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$0.02–$0.08&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 50-company run costs $1–$4. Compare that to Apollo Pro at $99/month or ZoomInfo at $15,000/year.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;You now have a repeatable, code-free pipeline to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Find publicly available contact details on any company website&lt;/li&gt;
&lt;li&gt;Score leads against your ICP automatically&lt;/li&gt;
&lt;li&gt;Generate personalised outreach openers at scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The actor is live on Apify: &lt;strong&gt;&lt;a href="https://apify.com/mozimohidien/company-contact-ai-enricher" rel="noopener noreferrer"&gt;Company Contact Scraper &amp;amp; AI Lead Enricher&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Try it free. The first $5 of Apify compute is on them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have questions or want to suggest improvements to the actor? Drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apify</category>
      <category>automation</category>
      <category>leadgeneration</category>
      <category>ai</category>
    </item>
    <item>
      <title>Building an AI Enrichment Pipeline with Airbyte and Claude</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Sat, 25 Jul 2026 09:56:09 +0000</pubDate>
      <link>https://dev.to/mozimohidien/building-an-ai-enrichment-pipeline-with-airbyte-and-claude-4430</link>
      <guid>https://dev.to/mozimohidien/building-an-ai-enrichment-pipeline-with-airbyte-and-claude-4430</guid>
      <description>&lt;p&gt;A while back I was working on a client project where GitHub issues needed to be triaged automatically. The team had hundreds of open tickets — bugs, feature requests, questions, random noise — and wanted a dashboard that grouped them by priority before any human touched them.&lt;/p&gt;

&lt;p&gt;My first instinct was the obvious one: write a script. Hit the GitHub API, send each issue to Claude, write results to a table. Done in two hours. Worked great until GitHub rate-limited me on the second run, the schema drifted, they added a second repo, and I was suddenly maintaining three separate scripts that all did mostly the same thing.&lt;/p&gt;

&lt;p&gt;So I rebuilt it with Airbyte handling the sync and a small Python enrichment layer doing the AI classification. That combination has held up much better, and this is how to set it up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Working code:&lt;/strong&gt; &lt;a href="https://github.com/Mozi1185/airbyte-claude-enrichment" rel="noopener noreferrer"&gt;github.com/Mozi1185/airbyte-claude-enrichment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll build:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Airbyte connection: GitHub issues → Postgres (incremental sync)&lt;/li&gt;
&lt;li&gt;Python enricher: reads unprocessed rows, classifies them with &lt;code&gt;claude-haiku-4-5&lt;/code&gt;, writes structured results back&lt;/li&gt;
&lt;li&gt;SQL queries you can actually use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; ~45 minutes&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Prerequisites:&lt;/strong&gt; Python 3.10+, Docker (for local Postgres), an Airbyte Cloud account (free tier works), an Anthropic API key, a GitHub personal access token&lt;/p&gt;


&lt;h2&gt;
  
  
  Why not just write one script
&lt;/h2&gt;

&lt;p&gt;You could call the GitHub API and Claude in the same script. Most tutorials show exactly that. The problem is everything that happens after it works the first time.&lt;/p&gt;

&lt;p&gt;GitHub rate-limits you. Claude returns something your parser didn't expect. You add a second repo. A colleague wants to add Jira. Now you're maintaining glue code instead of the actual thing you were trying to build.&lt;/p&gt;

&lt;p&gt;Splitting the work cleanly: Airbyte handles connector maintenance, auth refresh, pagination, incremental tracking, schema evolution. That's genuinely annoying to implement correctly and I don't want to do it. The enrichment layer stays stateless and portable — run it on a cron, trigger it from a webhook, drop it into Airflow later. Failed enrichments don't block syncs. Sync failures don't corrupt enriched output. When the client adds Zendesk, I add an Airbyte connection and point the same enricher at a different table.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Stand up Postgres
&lt;/h2&gt;

&lt;p&gt;If you already have a Postgres instance, skip this. For local development:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; enrichment-db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;localdev &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;enrichment &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5432:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  postgres:16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; enrichment-db psql &lt;span class="nt"&gt;-U&lt;/span&gt; dev &lt;span class="nt"&gt;-d&lt;/span&gt; enrichment &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT version();"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Connect GitHub to Postgres via Airbyte
&lt;/h2&gt;

&lt;p&gt;Log into &lt;a href="https://cloud.airbyte.com" rel="noopener noreferrer"&gt;Airbyte Cloud&lt;/a&gt;. Create a new connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source: GitHub&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Sources → + New source → GitHub&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Enter the repository — e.g. &lt;code&gt;airbytehq/airbyte&lt;/code&gt; or your own&lt;/li&gt;
&lt;li&gt;Paste your GitHub personal access token (&lt;code&gt;repo&lt;/code&gt; scope — generate at github.com/settings/tokens)&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Set up source&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Destination: Postgres&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Destinations → + New destination → Postgres&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Fill in your connection details:

&lt;ul&gt;
&lt;li&gt;Host: &lt;code&gt;localhost&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Port: &lt;code&gt;5432&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Database: &lt;code&gt;enrichment&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Username: &lt;code&gt;dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Password: &lt;code&gt;localdev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Default Schema: &lt;code&gt;github_raw&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Set up destination&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; If you're running Postgres locally, Airbyte Cloud can't reach &lt;code&gt;localhost&lt;/code&gt; — they're on different networks. I burned an afternoon on this before realizing it. The fix is ngrok: &lt;code&gt;ngrok tcp 5432&lt;/code&gt;. Copy the tunnel host and port (something like &lt;code&gt;0.tcp.ngrok.io:12345&lt;/code&gt;) and use those in the destination config instead of localhost.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Connection settings&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Under &lt;strong&gt;Streams&lt;/strong&gt;, select &lt;code&gt;issues&lt;/code&gt; only — uncheck everything else&lt;/li&gt;
&lt;li&gt;Sync mode: &lt;strong&gt;Incremental | Append + Deduped&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Schedule: every 6 hours (or trigger manually for now)&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Set up connection&lt;/strong&gt;, then &lt;strong&gt;Sync now&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first sync creates &lt;code&gt;github_raw.issues&lt;/code&gt; in Postgres and populates it. Verify:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;github_raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;body&lt;/code&gt; column is what Claude will read. If it's empty on some rows, that's normal — GitHub allows issues without a body.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Create the enrichment table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;github_raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues_enriched&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;issue_id&lt;/span&gt;         &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;one_line_summary&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;enriched_at&lt;/span&gt;      &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;&lt;code&gt;PRIMARY KEY&lt;/code&gt; on &lt;code&gt;issue_id&lt;/code&gt; makes re-runs safe — it upserts rather than duplicating rows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Install dependencies
&lt;/h2&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;anthropic psycopg2-binary python-dotenv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sk-ant-...&lt;/span&gt;
&lt;span class="py"&gt;DB_HOST&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;
&lt;span class="py"&gt;DB_PORT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5432&lt;/span&gt;
&lt;span class="py"&gt;DB_NAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enrichment&lt;/span&gt;
&lt;span class="py"&gt;DB_USER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;dev&lt;/span&gt;
&lt;span class="py"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;localdev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: Write the enricher
&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;# enricher.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&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;psycopg2&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;DB_CONFIG&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;host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DB_HOST&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;port&lt;/span&gt;&lt;span class="sh"&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DB_PORT&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;dbname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DB_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;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DB_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;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DB_PASSWORD&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;SYSTEM_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;You are a triage assistant for a software project.
Given a GitHub issue title and body, return a JSON object with exactly these fields:
- category: one of &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bug&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="s"&gt;feature_request&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="s"&gt;documentation&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="s"&gt;question&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="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
- priority: one of &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&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="s"&gt;high&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="s"&gt;medium&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="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
- one_line_summary: a single sentence (max 120 chars) describing the issue

Return only valid JSON. No explanation, no markdown fences.&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;classify_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&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;body&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Send one issue to Claude and return structured classification.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;content&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;Title: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&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;Body:&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;body&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;(no body provided)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;message&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&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="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="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;content&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&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="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;text&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;span class="k"&gt;return&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_unenriched_issues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&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;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fetch issues that haven&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t been enriched yet.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            SELECT i.id, i.title, i.body
            FROM github_raw.issues i
            LEFT JOIN github_raw.issues_enriched e ON e.issue_id = i.id
            WHERE e.issue_id IS NULL
              AND i.state = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;open&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
            ORDER BY i.created_at DESC
            LIMIT %s
        &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;limit&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
        &lt;span class="n"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;desc&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&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="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&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;cols&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&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_enrichment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issue_id&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="n"&gt;result&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="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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Upsert enrichment result for one issue.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            INSERT INTO github_raw.issues_enriched
                (issue_id, category, priority, one_line_summary, enriched_at)
            VALUES (%s, %s, %s, %s, now())
            ON CONFLICT (issue_id) DO UPDATE SET
                category         = EXCLUDED.category,
                priority         = EXCLUDED.priority,
                one_line_summary = EXCLUDED.one_line_summary,
                enriched_at      = now()
        &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;issue_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;one_line_summary&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;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&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;run_enrichment_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&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;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;DB_CONFIG&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="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_unenriched_issues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&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;Found &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; unenriched issues&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;issue&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;issue_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;issue&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="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;write_enrichment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issue_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&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;issue_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; → &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;category&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;priority&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;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&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;issue_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; parse error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — skipping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&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;issue_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; API error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — skipping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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="nf"&gt;run_enrichment_batch&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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python enricher.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Found 47 unenriched issues
  #1823 → bug / high
  #1801 → feature_request / medium
  #1799 → documentation / low
  #1788 → question / low
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 6: Query the results
&lt;/h2&gt;

&lt;p&gt;High-priority bugs, newest first:&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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;one_line_summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;github_raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;github_raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues_enriched&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issue_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'bug'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&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;'critical'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'high'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&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;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full breakdown across categories:&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&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;AS&lt;/span&gt; &lt;span class="n"&gt;issue_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;github_raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues_enriched&lt;/span&gt; &lt;span class="n"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 7: Keep it running
&lt;/h2&gt;

&lt;p&gt;The enricher is stateless — run it on any scheduler. Simplest option:&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;# Cron: run enrichment 15 minutes after each Airbyte sync (syncs at :00)&lt;/span&gt;
15 &lt;span class="k"&gt;*&lt;/span&gt;/6 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/project &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python enricher.py &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; enricher.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want tighter coupling, Airbyte has &lt;strong&gt;Webhook Notifications&lt;/strong&gt; under each connection's settings. Point the webhook at an endpoint that calls &lt;code&gt;run_enrichment_batch()&lt;/code&gt; and the enricher fires exactly when a sync completes — no delay, no polling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Eliminating parse errors with tool use
&lt;/h2&gt;

&lt;p&gt;The enricher above parses Claude's JSON from raw text. It works most of the time. But Claude occasionally wraps the output in a markdown code fence, or adds a clarifying sentence before the JSON, and then &lt;code&gt;json.loads&lt;/code&gt; blows up. The &lt;code&gt;json.JSONDecodeError&lt;/code&gt; catch in the loop handles this by skipping the row — which is fine, but skipped rows means gaps in your enrichment.&lt;/p&gt;

&lt;p&gt;The cleaner fix: Claude's tool use API. Instead of asking for JSON in the prompt, you define a tool with the exact schema and force Claude to call it. The response comes back already structured — no parsing step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CLASSIFY_TOOL&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;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;classify_issue&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;description&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;Classify a GitHub issue by category, priority, and summary.&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;input_schema&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;object&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;properties&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;string&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;enum&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bug&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;feature_request&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;documentation&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;other&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;string&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;enum&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&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;high&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;medium&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;low&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;one_line_summary&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;string&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;description&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;Single sentence, max 120 characters&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&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;priority&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;one_line_summary&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;def&lt;/span&gt; &lt;span class="nf"&gt;classify_issue_structured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&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;body&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Classify using tool use — no JSON parsing, no parse errors.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;content&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;Title: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&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;Body:&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;body&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;(no body provided)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;message&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;CLASSIFY_TOOL&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;tool_choice&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;type&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;tool&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;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;classify_issue&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;content&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# content[0] is always a ToolUseBlock when tool_choice forces a specific tool
&lt;/span&gt;    &lt;span class="k"&gt;return&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="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="nb"&gt;input&lt;/span&gt;  &lt;span class="c1"&gt;# already a dict
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;classify_issue&lt;/code&gt; for &lt;code&gt;classify_issue_structured&lt;/code&gt; in &lt;code&gt;run_enrichment_batch&lt;/code&gt; and remove the &lt;code&gt;json.JSONDecodeError&lt;/code&gt; catch — you won't hit it anymore.&lt;/p&gt;




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

&lt;p&gt;&lt;code&gt;claude-haiku-4-5&lt;/code&gt; is $0.80 per million input tokens and $4 per million output tokens. Each issue uses roughly 300–600 input tokens and about 80 output tokens.&lt;/p&gt;

&lt;p&gt;At 10,000 issues:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Tokens&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input (avg 450 × 10k)&lt;/td&gt;
&lt;td&gt;4.5M&lt;/td&gt;
&lt;td&gt;~$3.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output (avg 80 × 10k)&lt;/td&gt;
&lt;td&gt;0.8M&lt;/td&gt;
&lt;td&gt;~$3.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$6.80&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's for the initial backfill. After that, Airbyte only syncs new rows since the last run, so ongoing enrichment costs scale with new volume only — not the full history.&lt;/p&gt;

&lt;p&gt;If you want to cut input costs further, use &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching" rel="noopener noreferrer"&gt;prompt caching&lt;/a&gt;. The system prompt is identical across every call, so caching it drops input costs by roughly 90% on repeated runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;message&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system&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;type&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;text&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;text&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;ephemeral&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;messages&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;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;content&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;Worth doing if you're running this on a large repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Swapping sources
&lt;/h2&gt;

&lt;p&gt;The enrichment code doesn't know anything about GitHub. It reads from a table Airbyte created. To switch to Jira, add a new Airbyte connection with Jira as the source and the same Postgres destination. Airbyte creates a new schema and table. Write a &lt;code&gt;get_unenriched_jira_issues&lt;/code&gt; function pointing at that table. The &lt;code&gt;classify_issue&lt;/code&gt; function is reusable as-is.&lt;/p&gt;

&lt;p&gt;Airbyte has 300+ connectors — Zendesk, HubSpot, Salesforce, Notion, Linear. The enrichment pattern applies to all of them. That's the point of keeping the sync and enrichment separate.&lt;/p&gt;




&lt;p&gt;What you end up with: a Postgres table populated by Airbyte on a schedule, an enrichment layer that classifies each new row after each sync, and structured output queryable by whatever SQL tool your team uses. The sync and enrichment stay independent — neither can break the other, and adding a new source is an Airbyte config change, not a code change.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Mozi Mohidien builds AI automation systems — Claude API, Airbyte, n8n, Telegram. Writing at &lt;a href="https://dev.to/mozimohidien"&gt;dev.to/mozimohidien&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>ai</category>
      <category>python</category>
      <category>data</category>
    </item>
    <item>
      <title>How to find company emails and enrich leads with AI</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Sat, 25 Jul 2026 04:44:50 +0000</pubDate>
      <link>https://dev.to/mozimohidien/how-to-find-company-emails-and-enrich-leads-with-ai-3n77</link>
      <guid>https://dev.to/mozimohidien/how-to-find-company-emails-and-enrich-leads-with-ai-3n77</guid>
      <description>&lt;p&gt;Cold outreach lives or dies on two things: finding the right contact and saying something worth reading. Most tools handle one of those. This article shows you how to do both in a single automated run.&lt;/p&gt;

&lt;p&gt;We'll use an Apify actor — &lt;a href="https://apify.com/reverend_bluebell/company-contact-ai-enricher" rel="noopener noreferrer"&gt;Company Contact Scraper &amp;amp; AI Lead Enricher&lt;/a&gt; — as the worked example. By the end you'll have a repeatable pipeline that takes a list of company URLs and returns contact data plus AI-generated sales intelligence for each one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the pipeline does
&lt;/h2&gt;

&lt;p&gt;The actor runs in two stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scrape&lt;/strong&gt; — visits each company URL, crawls key pages (homepage, contact, about, team), and extracts emails, phone numbers, and social profile links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enrich&lt;/strong&gt; — passes each scraped company through Claude (you supply the API key) to generate: detected industry, team size estimate, ICP fit score from 1–10, tech stack hints, and a personalized cold outreach opener.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You end up with a ranked list. The ICP fit score alone changes how you work: instead of writing to 100 companies equally, you write to the 20 that scored 7+.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify account&lt;/a&gt; (free tier works)&lt;/li&gt;
&lt;li&gt;An Anthropic API key (for Claude enrichment)&lt;/li&gt;
&lt;li&gt;Node.js or any HTTP client to hit the Apify API&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Running it via the Apify API
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Start a run
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/reverend_bluebell~company-contact-ai-enricher/runs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_APIFY_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "companyUrls": [
      "https://acmecorp.io",
      "https://example-saas.com",
      "https://another-b2b.co"
    ],
    "anthropicApiKey": "sk-ant-YOUR_KEY_HERE",
    "maxCompanies": 10
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes a &lt;code&gt;runId&lt;/code&gt;. Save it — you'll need it to poll for results.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Wait for the run to finish
&lt;/h3&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;apifyToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIFY_TOKEN&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;runId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_RUN_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runId&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.apify.com/v2/actor-runs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apifyToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUCCEEDED&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;data&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FAILED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Run failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Status: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — waiting 5s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&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;h3&gt;
  
  
  3. Fetch the results
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runId&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.apify.com/v2/actor-runs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dataset/items`&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apifyToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runId&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;leads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leads&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. What comes back
&lt;/h3&gt;

&lt;p&gt;Each item in the results array looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&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://acmecorp.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hello@acmecorp.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sales@acmecorp.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phones"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"+1-415-555-0192"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"linkedin"&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://linkedin.com/company/acmecorp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"twitter"&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://twitter.com/acmecorp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"industry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"B2B SaaS / HR Tech"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"teamSize"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"11-50"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"icpFitScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"techStack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HubSpot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Intercom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Vercel"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"primaryProduct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Employee onboarding automation platform"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targetDecisionMaker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Head of People or VP HR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outreachHook"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme's self-serve onboarding flow suggests you're scaling sales without adding headcount — exactly the scenario where [your product] cuts ramp time by half."&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;h2&gt;
  
  
  Filtering by ICP fit
&lt;/h2&gt;

&lt;p&gt;Once you have results, filter before you touch your CRM:&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;qualifiedLeads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;leads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;lead&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;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;icpFitScore&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;qualifiedLeads&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="s2"&gt; qualified leads out of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;leads&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="s2"&gt; total`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also sort by score and take the top N:&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;top10&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;leads&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;icpFitScore&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;icpFitScore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Pushing results to a CSV
&lt;/h2&gt;

&lt;p&gt;If you're loading into a spreadsheet or CRM that accepts CSV:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&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="s2"&gt;url&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="s2"&gt;emails&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="s2"&gt;phones&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="s2"&gt;industry&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="s2"&gt;teamSize&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="s2"&gt;icpFitScore&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="s2"&gt;techStack&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="s2"&gt;outreachHook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;leads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;lead&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&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;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;csv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;row&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&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="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;leads.csv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Saved leads.csv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What the AI enrichment actually costs
&lt;/h2&gt;

&lt;p&gt;The actor charges $0.01 per 1,000 results — effectively free at the scale most outbound teams run. The Anthropic API usage on top depends on how many companies you process, but a 50-company run typically costs under $0.05 in Claude tokens. You control this because you supply your own key.&lt;/p&gt;




&lt;h2&gt;
  
  
  How it finds emails
&lt;/h2&gt;

&lt;p&gt;The scraper doesn't rely on a static database. It visits the actual company website — homepage, &lt;code&gt;/contact&lt;/code&gt;, &lt;code&gt;/about&lt;/code&gt;, &lt;code&gt;/team&lt;/code&gt; — and extracts email addresses from page content and &lt;code&gt;mailto:&lt;/code&gt; links. This means you're getting current contact data, not year-old exports.&lt;/p&gt;

&lt;p&gt;For sites that block scrapers, you can pass Apify Proxy config in the &lt;code&gt;proxyConfig&lt;/code&gt; input field.&lt;/p&gt;




&lt;h2&gt;
  
  
  Other actors in the same series
&lt;/h2&gt;

&lt;p&gt;If you're building a broader lead gen stack, these fit the same pattern — scrape then enrich with Claude:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/reverend_bluebell/job-posting-ai-analyzer" rel="noopener noreferrer"&gt;Job Posting AI Analyzer&lt;/a&gt;&lt;/strong&gt; — extract hiring signals from job posts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/reverend_bluebell/ecommerce-product-ai-enricher" rel="noopener noreferrer"&gt;Ecommerce Product AI Enricher&lt;/a&gt;&lt;/strong&gt; — enrich product listings with AI-generated descriptions and categories&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://apify.com/reverend_bluebell/reddit-lead-ai-finder" rel="noopener noreferrer"&gt;Reddit Lead AI Finder&lt;/a&gt;&lt;/strong&gt; — find buying intent posts on Reddit and score them&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The pipeline is: list of URLs → scrape contacts → Claude enrichment → ranked leads with outreach copy. You can run it via the Apify UI or trigger it programmatically from any HTTP client. At $0.01/1,000 results, the bottleneck is your Anthropic API quota, not the platform cost.&lt;/p&gt;

&lt;p&gt;Actor link: &lt;a href="https://apify.com/reverend_bluebell/company-contact-ai-enricher" rel="noopener noreferrer"&gt;https://apify.com/reverend_bluebell/company-contact-ai-enricher&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>automation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Bot-to-Bot Messaging Without a Message Broker: Building a Local Inbox Bus for Claude Agents</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:39:01 +0000</pubDate>
      <link>https://dev.to/mozimohidien/bot-to-bot-messaging-without-a-message-broker-building-a-local-inbox-bus-for-claude-agents-9fc</link>
      <guid>https://dev.to/mozimohidien/bot-to-bot-messaging-without-a-message-broker-building-a-local-inbox-bus-for-claude-agents-9fc</guid>
      <description>&lt;h1&gt;
  
  
  Bot-to-Bot Messaging Without a Message Broker: Building a Local Inbox Bus for Claude Agents
&lt;/h1&gt;

&lt;p&gt;by Mozi Mohidien&lt;/p&gt;




&lt;p&gt;Telegram's Bot API is one-way. A bot can receive messages from users and reply to them. It cannot send messages to another bot. There is no API call for "bot A sends a message to bot B."&lt;/p&gt;

&lt;p&gt;In a multi-bot architecture where bots need to collaborate — one bot asking another for context, one bot routing a task to a specialist — this is a real problem. The usual answer is a message broker: Redis pub/sub, RabbitMQ, something with a queue. That works but adds infrastructure to a system that is already running eight processes on a single machine.&lt;/p&gt;

&lt;p&gt;The simpler answer: a file-based inbox per bot, with atomic writes and a watcher that drains it on every poll cycle.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Design
&lt;/h2&gt;

&lt;p&gt;Each bot has an inbox directory at &lt;code&gt;sessions/[name]/inbox/&lt;/code&gt;. A message is a JSON file dropped into that directory. The bot's watcher script scans the inbox on every 30-second poll, checks whether the bot is idle, and injects waiting messages via &lt;code&gt;tmux send-keys&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No broker. No daemon. No network calls. The filesystem is the queue. The watcher is the consumer.&lt;/p&gt;

&lt;p&gt;Three properties matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic writes.&lt;/strong&gt; Messages are written to a &lt;code&gt;.tmp&lt;/code&gt; file first, then moved into the inbox directory. &lt;code&gt;mv&lt;/code&gt; within the same filesystem is atomic. The receiving bot either sees a complete message file or nothing — never a partial write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idle injection only.&lt;/strong&gt; The watcher checks whether the bot's Claude process is at a ready prompt before injecting. If Claude is mid-task, the message stays in the inbox and gets delivered on the next poll cycle. This prevents injecting a new task into an already-running one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline delivery.&lt;/strong&gt; The inbox persists on disk. If a bot is down when a message arrives, the message sits in the inbox until the bot restarts. The next drain cycle delivers it.&lt;/p&gt;




&lt;h2&gt;
  
  
  send.sh
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;send.sh&lt;/code&gt; takes four arguments: sender bot name, recipient bot name, message content, and an optional &lt;code&gt;reply_to&lt;/code&gt; message ID.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;FROM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;TO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;CONTENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;REPLY_TO&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;4&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;INBOX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Users/mozimohidien/mozi/second_brain/sessions/&lt;/span&gt;&lt;span class="nv"&gt;$TO&lt;/span&gt;&lt;span class="s2"&gt;/inbox"&lt;/span&gt;
&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s%N&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;ID&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;TS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="nv"&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;RANDOM&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;MSG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.msg"&lt;/span&gt;
&lt;span class="nv"&gt;TMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&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;$INBOX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;ESC_CONTENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json,sys; print(json.dumps(sys.stdin.read()))'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{ "id": "%s", "from": "%s", "to": "%s", "ts": "%s", "content": %s }\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ESC_CONTENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MSG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message filename encodes a timestamp, PID, and random number. This prevents collisions if two bots send to the same recipient simultaneously. The timestamp ensures messages drain in arrival order.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'&lt;/code&gt; JSON-encodes the content string, handling quotes, newlines, and special characters. This matters when message content contains multi-line prompts or code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;send.sh&lt;/code&gt; prints the message ID to stdout. The sender can capture it for reply routing.&lt;/p&gt;




&lt;h2&gt;
  
  
  drain.sh
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;drain.sh&lt;/code&gt; is sourced by every bot's &lt;code&gt;watch.sh&lt;/code&gt;. It runs on each poll cycle, after the bot's health check.&lt;/p&gt;

&lt;p&gt;Pseudocode:&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="k"&gt;function &lt;/span&gt;drain_bot_inbox&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    INBOX &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sessions/&lt;/span&gt;&lt;span class="nv"&gt;$BOT_NAME&lt;/span&gt;&lt;span class="s2"&gt;/inbox"&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;inbox is empty:
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="c"&gt;# Check pane readiness&lt;/span&gt;
    PANE_CONTENT &lt;span class="o"&gt;=&lt;/span&gt; tmux capture-pane &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$SESSION&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;PANE_CONTENT matches /&lt;span class="o"&gt;(&lt;/span&gt;esc to interrupt|working&lt;span class="se"&gt;\.\.\.&lt;/span&gt;|tokens used|numbered dialog &lt;span class="se"&gt;\[&lt;/span&gt;1&lt;span class="se"&gt;\]&lt;/span&gt;|&lt;span class="se"&gt;\[&lt;/span&gt;y&lt;span class="se"&gt;\/&lt;/span&gt;n&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;/i:
        &lt;span class="c"&gt;# Bot is busy — leave messages for next cycle&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="c"&gt;# Process messages in order (sorted by filename = arrival order)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;MSG_FILE &lt;span class="k"&gt;in &lt;/span&gt;sorted&lt;span class="o"&gt;(&lt;/span&gt;inbox/&lt;span class="k"&gt;*&lt;/span&gt;.msg&lt;span class="o"&gt;)&lt;/span&gt;:
        CONTENT &lt;span class="o"&gt;=&lt;/span&gt; parse JSON content field from MSG_FILE
        FROM &lt;span class="o"&gt;=&lt;/span&gt; parse JSON from field
        ID &lt;span class="o"&gt;=&lt;/span&gt; parse JSON &lt;span class="nb"&gt;id &lt;/span&gt;field

        &lt;span class="c"&gt;# Build the injected prompt&lt;/span&gt;
        PROMPT &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"[BOT-BUS] Message from &lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt; (id: &lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;):&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="nv"&gt;$CONTENT&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;[Reply via: send.sh &lt;/span&gt;&lt;span class="nv"&gt;$BOT_NAME&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$FROM&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;your reply here&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;

        &lt;span class="c"&gt;# Inject into the tmux session&lt;/span&gt;
        tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$SESSION&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; Enter

        &lt;span class="c"&gt;# Delete the message file&lt;/span&gt;
        &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$MSG_FILE&lt;/span&gt;

        &lt;span class="c"&gt;# Wait for Claude to start processing before injecting the next message&lt;/span&gt;
        &lt;span class="nb"&gt;sleep &lt;/span&gt;3

        &lt;span class="c"&gt;# Re-check pane — if Claude started a task, stop draining&lt;/span&gt;
        PANE_CONTENT &lt;span class="o"&gt;=&lt;/span&gt; tmux capture-pane &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$SESSION&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;PANE_CONTENT matches /&lt;span class="o"&gt;(&lt;/span&gt;esc to interrupt|working&lt;span class="o"&gt;)&lt;/span&gt;/i:
            &lt;span class="nb"&gt;break
    &lt;/span&gt;end
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The readiness check is the critical piece. Claude's pane shows different text depending on state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ready for input: shows the prompt cursor&lt;/li&gt;
&lt;li&gt;Mid-task: shows "esc to interrupt" or streaming output&lt;/li&gt;
&lt;li&gt;Usage modal: shows numbered options&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The drain function only injects when the pane is at a clean prompt. Everything else means Claude is busy. Messages queue and deliver on the next cycle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reply Routing
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;drain.sh&lt;/code&gt; injects a message, it appends instructions for the receiving bot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[BOT-BUS] Message from oper8 (id: 1720000000000-1234-5678):

I need the learning context for Mozi's current AI module — specifically what he's covered so far and what's next on the path. I'm briefing a client on Mozi's technical background.

[Reply via: send.sh life_tutor oper8 "your reply here" 1720000000000-1234-5678]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiving bot sees this as an ordinary prompt. It reads the instructions, forms a response, and calls &lt;code&gt;send.sh&lt;/code&gt; as directed. The reply lands in the sender's inbox, with &lt;code&gt;reply_to&lt;/code&gt; set to the original message ID so the sender can correlate it.&lt;/p&gt;

&lt;p&gt;This requires Claude to follow the reply instruction reliably. In practice it does — the instruction is explicit and the format is consistent. The occasional failure (Claude answers but doesn't send the reply) is handled by the sender timing out and either re-sending or handling the missing reply gracefully.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Real Usage Example: oper8 → life_tutor
&lt;/h2&gt;

&lt;p&gt;Scenario: the oper8 bot is preparing a client briefing for a prospect interested in Mozi's technical background. It needs to know what Mozi has been studying in AI recently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: oper8 sends a message to life_tutor&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;send.sh oper8 life_tutor &lt;span class="s2"&gt;"I need Mozi's current learning context — what AI topics has he covered recently and what is he working on next? I'm briefing Nexus Capital on his technical background."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This writes a file to &lt;code&gt;/Users/mozimohidien/mozi/second_brain/sessions/life_tutor/inbox/1720000120000-4521-8833.msg&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1720000120000-4521-8833"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oper8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"life_tutor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1720000120000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I need Mozi's current learning context — what AI topics has he covered recently and what is he working on next? I'm briefing Nexus Capital on his technical background."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: life_tutor's watcher drain detects the message&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the next 30-second poll, &lt;code&gt;drain_bot_inbox&lt;/code&gt; runs. The pane check passes (life_tutor is idle). The drain function reads the message and injects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[BOT-BUS] Message from oper8 (id: 1720000120000-4521-8833):

I need Mozi's current learning context — what AI topics has he covered recently and what is he working on next? I'm briefing Nexus Capital on his technical background.

[Reply via: send.sh life_tutor oper8 "your reply here" 1720000120000-4521-8833]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: life_tutor processes and replies&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Claude (life_tutor session) receives the prompt, reads &lt;code&gt;LEARNING_PATH.md&lt;/code&gt;, forms a summary, and executes the reply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;send.sh life_tutor oper8 &lt;span class="s2"&gt;"Mozi completed the core Claude API module last week — tool use, multi-turn conversations, context management. Currently working through the agent orchestration patterns module. Next up: MCP server design and multi-agent coordination. Strong foundations; actively building production systems on top."&lt;/span&gt; 1720000120000-4521-8833
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4: oper8's watcher delivers the reply&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reply lands in &lt;code&gt;/Users/mozimohidien/mozi/second_brain/sessions/oper8/inbox/1720000180000-7722-3341.msg&lt;/code&gt;. On oper8's next poll, the drain delivers it. The oper8 Claude session receives the context and continues building the client briefing.&lt;/p&gt;

&lt;p&gt;Total latency: under 60 seconds (two watcher cycles maximum). No external infrastructure. No broker to maintain or fail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Idle Safety in Practice
&lt;/h2&gt;

&lt;p&gt;A bot injected mid-task causes immediate problems. Claude is streaming output for one task and suddenly receives a new prompt — the context merges, the outputs collide, both tasks produce garbage.&lt;/p&gt;

&lt;p&gt;The readiness regex catches the common cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;esc to interrupt&lt;/code&gt; — Claude is actively working&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;working...&lt;/code&gt; — task in progress indicator&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tokens used&lt;/code&gt; — post-response summary, Claude may still be settling&lt;/li&gt;
&lt;li&gt;Numbered dialog &lt;code&gt;[1]&lt;/code&gt; — modal or confirmation prompt, not safe to inject&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[y/n]&lt;/code&gt; — Claude is asking a yes/no question&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these appear in the captured pane content, the drain stops and all messages wait for the next cycle.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;sleep 3&lt;/code&gt; between messages gives Claude time to start processing the first injected message before the next one arrives. Without it, two messages can land in the same prompt buffer and Claude reads them as one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure Modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Message never delivered.&lt;/strong&gt; Check if the target bot's inbox directory exists and is writable. Check that &lt;code&gt;drain_bot_inbox&lt;/code&gt; is being called in the target's &lt;code&gt;watch.sh&lt;/code&gt;. Confirm the pane readiness check is not stuck (e.g., pane permanently showing "esc to interrupt" because Claude froze mid-task).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate delivery.&lt;/strong&gt; The &lt;code&gt;rm -f $MSG_FILE&lt;/code&gt; after injection handles this — once delivered, the file is gone. If you see duplicates, the delete is failing silently. Add error checking around the rm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Garbled content.&lt;/strong&gt; The &lt;code&gt;python3&lt;/code&gt; JSON encoding in &lt;code&gt;send.sh&lt;/code&gt; handles most special characters. The failure mode is content that breaks JSON — usually an unescaped backslash or a very long string. Test with &lt;code&gt;echo "$ID" | python3 -m json.tool&lt;/code&gt; if messages are arriving malformed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reply never received.&lt;/strong&gt; Claude processed the message and answered but did not call &lt;code&gt;send.sh&lt;/code&gt;. This happens when the injected instructions are too buried in a long conversation context. Keep the &lt;code&gt;[Reply via: ...]&lt;/code&gt; instruction at the end of the injection, not the beginning — Claude reads to completion before acting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scaling the Pattern
&lt;/h2&gt;

&lt;p&gt;Eight bots, any one of which can message any other. The inbox pattern scales without modification. Adding a new bot means creating its inbox directory. Nothing else changes.&lt;/p&gt;

&lt;p&gt;The watcher architecture already handles the drain — it is part of every bot's standard &lt;code&gt;watch.sh&lt;/code&gt;. The send and drain scripts are utilities shared across all sessions.&lt;/p&gt;

&lt;p&gt;The pattern also composes with the screen lock and agent spawning patterns. A bot that needs to do something requiring screen access checks the lock, acquires it, does the work, releases it, and reports back via send.sh — all within the same architecture, no new infrastructure required.&lt;/p&gt;

&lt;p&gt;For a system running entirely on a local machine, this is the right level of complexity: just enough to make bots collaborate reliably, nothing more.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>telegram</category>
    </item>
    <item>
      <title>The Orchestrator Pattern for Claude Agents: Delegating Everything, Owning Nothing</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:32:25 +0000</pubDate>
      <link>https://dev.to/mozimohidien/the-orchestrator-pattern-for-claude-agents-delegating-everything-owning-nothing-1n1l</link>
      <guid>https://dev.to/mozimohidien/the-orchestrator-pattern-for-claude-agents-delegating-everything-owning-nothing-1n1l</guid>
      <description>&lt;h1&gt;
  
  
  The Orchestrator Pattern for Claude Agents: Delegating Everything, Owning Nothing
&lt;/h1&gt;

&lt;p&gt;by Mozi Mohidien&lt;/p&gt;




&lt;p&gt;The most common mistake when building multi-agent systems with Claude is turning the orchestrator into a worker. It reads files. It edits code. It calls APIs directly. Within twenty turns, the orchestrator's context is bloated with tool output noise, the session is slow, and every response costs more than it should.&lt;/p&gt;

&lt;p&gt;The fix is a strict rule: the orchestrator never acts. It only plans, delegates, and reviews.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Rule
&lt;/h2&gt;

&lt;p&gt;The orchestrator does exactly three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Understand the task and break it down&lt;/li&gt;
&lt;li&gt;Brief and spawn agents to execute&lt;/li&gt;
&lt;li&gt;Review what comes back and surface the result to the user (or re-brief if it's wrong)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else — file edits, terminal commands, API calls, deployments, web searches, browser navigation — goes through a spawned agent. The orchestrator touches nothing directly.&lt;/p&gt;

&lt;p&gt;This sounds simple. It requires discipline to maintain, because the temptation to "just do it inline" is constant. One inline file read feels harmless. Ten turns later, the session context contains 40KB of raw file content that gets re-read on every subsequent turn.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Context Cost Matters More Than You Think
&lt;/h2&gt;

&lt;p&gt;For Fable (claude-fable-5), cost hits from two directions.&lt;/p&gt;

&lt;p&gt;Input: every turn re-reads the entire session context. A conversation that has accumulated tool outputs from twelve previous turns re-reads all of that on turn thirteen. Context does not get cheaper as the session grows — it gets more expensive per turn.&lt;/p&gt;

&lt;p&gt;Output: Fable's thinking tokens bill as output. Even a short response with a thinking budget burns output tokens. The longer the session, the more the model thinks to navigate the accumulated context.&lt;/p&gt;

&lt;p&gt;This is why the orchestrator rule has financial weight, not just architectural weight. An orchestrator that stays clean — no inline tool calls, no accumulated raw output — keeps per-turn cost low throughout the session. An orchestrator that acts directly starts cheap and gets expensive fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handling Context Overflow
&lt;/h2&gt;

&lt;p&gt;Sessions eventually get long. The right response is not to keep going — it is to hand off cleanly and restart.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Append relevant updates to the brain files (MOZI.md, operate_with_intelligence.md, the relevant brief — whichever apply)&lt;/li&gt;
&lt;li&gt;Write &lt;code&gt;HANDOFF.md&lt;/code&gt; — the baton pass document&lt;/li&gt;
&lt;li&gt;Touch a signal file that the watcher daemon is polling&lt;/li&gt;
&lt;li&gt;The watcher sees the signal file, kills the current session, starts a fresh one
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Trigger the restart&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; ~/mozi/second_brain/.restart_laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The watcher picks this up within 30 seconds. The new session starts with zero tool noise in context. Brain files carry forward the relevant state.&lt;/p&gt;




&lt;h2&gt;
  
  
  HANDOFF.md: Baton Pass, Not Log
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HANDOFF.md&lt;/code&gt; has one job: give the next session exactly what it needs to continue, nothing more.&lt;/p&gt;

&lt;p&gt;Hard constraints: under 30 lines, three fields only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Handoff&lt;/span&gt;

&lt;span class="gu"&gt;## Done this session&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Rebuilt the oper8 bot start.sh after model env var broke Telegram server mode
&lt;span class="p"&gt;-&lt;/span&gt; Updated LEARNING_PATH.md with new MCP module completion
&lt;span class="p"&gt;-&lt;/span&gt; Cleaned stale files from /mozi/university/

&lt;span class="gu"&gt;## Next step&lt;/span&gt;
Deploy the updated general_fable bot and verify it processes messages

&lt;span class="gu"&gt;## Urgent&lt;/span&gt;
None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire document. No audit lists. No issue inventories. No historical context. No "also worth noting" sections.&lt;/p&gt;

&lt;p&gt;The reason for the constraint: &lt;code&gt;HANDOFF.md&lt;/code&gt; gets read on every session start. Every line is input tokens. A 200-line HANDOFF becomes a tax on every single turn of the next session. Keep it short.&lt;/p&gt;

&lt;p&gt;The next session overwrites it entirely. It is never accumulated. It is never a log of what has happened over time. It tells the next session exactly one thing: where to pick up.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Quality Gate
&lt;/h2&gt;

&lt;p&gt;The orchestrator is the last reviewer before output reaches the user. If an agent's output is incomplete, wrong, or mediocre, the user should never see it.&lt;/p&gt;

&lt;p&gt;When an agent returns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the output critically&lt;/li&gt;
&lt;li&gt;Ask: does this fully achieve what was asked?&lt;/li&gt;
&lt;li&gt;If yes, surface it&lt;/li&gt;
&lt;li&gt;If no: diagnose exactly why it failed, refine the brief, re-spawn&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Common failure modes to check for: agent completed the task technically but missed the actual goal; output is correct but needs synthesizing with another agent's output before it's useful; agent hit a blocker and returned a partial result without flagging it clearly.&lt;/p&gt;

&lt;p&gt;"I couldn't do it" from an agent is never an acceptable final answer without investigation. The orchestrator probes: what specifically failed? Was it a missing credential? Wrong file path? Tool error? Then it fixes the brief and tries again, or finds a different approach.&lt;/p&gt;

&lt;p&gt;Only when all paths are genuinely exhausted does the orchestrator surface to Mozi: here is what was tried, here is the blocker, here is what he needs to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Model Selection for Spawned Agents
&lt;/h2&gt;

&lt;p&gt;Not every agent needs the same model. Over-provisioning burns budget. Under-provisioning produces worse results.&lt;/p&gt;

&lt;p&gt;The decision matrix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Haiku&lt;/strong&gt; — single-file reads, classification, simple lookups, anything with a short deterministic answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sonnet&lt;/strong&gt; — coding, file edits, multi-step execution, most tasks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opus&lt;/strong&gt; — complex reasoning, architecture decisions, deep multi-step analysis requiring judgment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fable&lt;/strong&gt; — only when the agent itself needs orchestration capability (rare; spawning a Fable agent to orchestrate sub-agents is usually the wrong design)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Spawning examples
&lt;/span&gt;
&lt;span class="c1"&gt;# Quick lookup — haiku
&lt;/span&gt;&lt;span class="nc"&gt;Agent&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;haiku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Look up contact 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;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Read /Users/mozimohidien/mozi/second_brain/contacts.json and return the phone number for Ayesha Kader.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Code edit — sonnet
&lt;/span&gt;&lt;span class="nc"&gt;Agent&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sonnet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Edit start.sh model env var&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Edit /path/to/sessions/oper8/start.sh. Change ANTHROPIC_MODEL from claude-sonnet-4-6 to claude-fable-5. Test that the file is valid bash syntax after.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Architecture review — opus
&lt;/span&gt;&lt;span class="nc"&gt;Agent&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;opus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review MCP server design&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review the design of the Telegram bridge MCP server at ~/.claude/plugins/telegram_bridge/server.ts. Identify any race conditions or failure modes in the polling loop.&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;h2&gt;
  
  
  The WhatsApp Flow Example
&lt;/h2&gt;

&lt;p&gt;A concrete example of the full pattern: Mozi says "send a WhatsApp to Bilal saying the meeting is confirmed for Thursday at 3pm."&lt;/p&gt;

&lt;p&gt;The orchestrator does not send the message. It does not look up the contact. Here is the flow:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Read &lt;code&gt;contacts.json&lt;/code&gt; to find Bilal's number. This is a simple lookup — haiku agent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent(haiku): Read contacts.json, return phone number for "Bilal"
Result: +27821234567
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Brief a sonnet agent with everything it needs. No back-and-forth.&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="nc"&gt;Agent&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sonnet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Send WhatsApp to Bilal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Send a WhatsApp message to +27821234567 (Bilal).
    Message: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hey — meeting confirmed for Thursday at 3pm.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;

    Use this approach:
    osascript -e &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;open location &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whatsapp://send?phone=27821234567&amp;amp;text=Hey%20%E2%80%94%20meeting%20confirmed%20for%20Thursday%20at%203pm.&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;

    Run it and confirm it opens WhatsApp. Report back with success or failure and the exact error if it fails.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Review the result. Did WhatsApp open? Did the message send? If yes, tell Mozi it is done. If no, diagnose and try another approach (Playwright, direct API if configured).&lt;/p&gt;

&lt;p&gt;The orchestrator never touched the terminal. It never read the contacts file directly. It planned, delegated both tasks cleanly, reviewed results, and reported.&lt;/p&gt;




&lt;h2&gt;
  
  
  Screen Lock Pattern
&lt;/h2&gt;

&lt;p&gt;Some agents need to control the screen — browser navigation, clicking, form submission. Only one agent can do this at a time or they race each other.&lt;/p&gt;

&lt;p&gt;Lock file: &lt;code&gt;/Users/mozimohidien/mozi/second_brain/.screen_agent_active&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Acquire before starting screen work:&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;# Check if another agent has the lock&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"/Users/mozimohidien/mozi/second_brain/.screen_agent_active"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Screen already locked by another agent. Aborting."&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Acquire&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; /Users/mozimohidien/mozi/second_brain/.screen_agent_active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Release when done (always — even on failure):&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;# Release&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /Users/mozimohidien/mozi/second_brain/.screen_agent_active
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The orchestrator must never spawn two screen-active agents simultaneously. Before spawning an agent that needs screen access, check that no other screen agent is running. If the lock file exists, either wait or queue the task.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Discipline Summary
&lt;/h2&gt;

&lt;p&gt;Keep Fable orchestrators cheap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE_CODE_EFFORT_LEVEL=low&lt;/code&gt; in every Fable bot's &lt;code&gt;start.sh&lt;/code&gt;. Never remove it.&lt;/li&gt;
&lt;li&gt;Restart sessions early — after heavy agent cycles, not just when approaching context limits. A short session with a tight handoff costs less per turn than a long one.&lt;/li&gt;
&lt;li&gt;No inline tool calls in the orchestrator — keep its context clean.&lt;/li&gt;
&lt;li&gt;Write tight briefs. Every line in a CLAUDE.md or HANDOFF.md is re-read on every turn. Ruthlessly cut anything stale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The orchestrator's job is thinking, not doing. Keep its context lean so the thinking stays sharp.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>telegram</category>
    </item>
    <item>
      <title>Running a Fleet of Telegram Bots on Claude Code: Per-Bot Sessions, MCP, and Auto-Restart</title>
      <dc:creator>Mozi Mohidien</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:31:49 +0000</pubDate>
      <link>https://dev.to/mozimohidien/running-a-fleet-of-telegram-bots-on-claude-code-per-bot-sessions-mcp-and-auto-restart-2feo</link>
      <guid>https://dev.to/mozimohidien/running-a-fleet-of-telegram-bots-on-claude-code-per-bot-sessions-mcp-and-auto-restart-2feo</guid>
      <description>&lt;h1&gt;
  
  
  Running a Fleet of Telegram Bots on Claude Code: Per-Bot Sessions, MCP, and Auto-Restart
&lt;/h1&gt;

&lt;p&gt;by Mozi Mohidien&lt;/p&gt;




&lt;p&gt;Running one Telegram bot on Claude Code is straightforward. Running eight simultaneously — each with its own model, context, memory, and capabilities — without them trampling each other is an architecture problem most people hit after the second bot.&lt;/p&gt;

&lt;p&gt;This article walks through the full stack: session isolation, the MCP plugin, auto-restart via launchd and tmux, and a subtle confirmation prompt bug that will silently break every bot you start without a workaround.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Claude Code is a process. It loads context, runs tools, maintains state. If you run two bots in the same session, they share context. They see each other's messages. One bot's tool calls pollute the other's reasoning.&lt;/p&gt;

&lt;p&gt;The solution: one bot per Claude Code session. Full isolation. Each bot is its own process, with its own working directory, its own MCP config, its own Telegram token.&lt;/p&gt;

&lt;p&gt;But then you have eight processes to start, monitor, and restart. Manually doing that is not a system — it's a chore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Each bot has its own session directory under &lt;code&gt;second_brain/sessions/[name]/&lt;/code&gt;. Inside that directory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;start.sh&lt;/code&gt; — launches the Claude Code process with the right environment&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;watch.sh&lt;/code&gt; — runs in a loop, monitors the tmux session, restarts if dead&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.mcp.json&lt;/code&gt; — tells Claude Code which MCP servers to load (Telegram bridge, others)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Telegram bridge plugin lives at &lt;code&gt;~/.claude/plugins/telegram_bridge/server.ts&lt;/code&gt;. It is shared across all bots — each bot's &lt;code&gt;.mcp.json&lt;/code&gt; points to the same file, but passes different environment variables (token, state dir). That is how one plugin supports eight bots without duplication.&lt;/p&gt;

&lt;p&gt;Claude Code runs in a special server mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--dangerously-skip-permissions&lt;/span&gt; &lt;span class="nt"&gt;--dangerously-load-development-channels&lt;/span&gt; server:telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This mode keeps Claude running as a long-lived process, receiving Telegram messages via the MCP plugin and responding through it. Without &lt;code&gt;server:telegram&lt;/code&gt;, Claude exits after each response.&lt;/p&gt;




&lt;h2&gt;
  
  
  start.sh
&lt;/h2&gt;

&lt;p&gt;Every bot needs its own &lt;code&gt;start.sh&lt;/code&gt;. The critical details: the bot token, the state directory (unique per bot — this is where Telegram session data lives), and the model.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_BOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"[BOT_TOKEN]"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TELEGRAM_STATE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Users/you/.claude/channels/telegram_[BOTNAME]"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"claude-fable-5"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_EFFORT_LEVEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;low
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"/path/to/sessions/[BOTNAME]"&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;claude &lt;span class="nt"&gt;--dangerously-skip-permissions&lt;/span&gt; &lt;span class="nt"&gt;--dangerously-load-development-channels&lt;/span&gt; server:telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth calling out.&lt;/p&gt;

&lt;p&gt;First: the model is set via &lt;code&gt;export ANTHROPIC_MODEL=...&lt;/code&gt;, not via a &lt;code&gt;--model&lt;/code&gt; flag. The &lt;code&gt;--model&lt;/code&gt; flag breaks Telegram server mode. Claude Code's server mode has specific initialization behavior that the flag interferes with. Use the environment variable.&lt;/p&gt;

&lt;p&gt;Second: &lt;code&gt;CLAUDE_CODE_EFFORT_LEVEL=low&lt;/code&gt; for Fable bots. Fable (claude-fable-5) is the most capable but most expensive model. It bills thinking tokens as output. Setting effort to low reduces the thinking budget per turn, which keeps the weekly allocation from draining on routine bot responses. Do not remove this line.&lt;/p&gt;




&lt;h2&gt;
  
  
  .mcp.json
&lt;/h2&gt;

&lt;p&gt;Each session directory has its own &lt;code&gt;.mcp.json&lt;/code&gt;. Claude Code reads this file when it starts and loads the listed MCP servers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&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;"telegram"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bun"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/.claude/plugins/telegram_bridge/server.ts"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&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;"TELEGRAM_BOT_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[BOT_TOKEN]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"TELEGRAM_STATE_DIR"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/.claude/channels/telegram_[name]"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin is launched via &lt;code&gt;bun&lt;/code&gt;. The &lt;code&gt;env&lt;/code&gt; block passes the bot-specific token and state directory to the plugin process. The plugin handles everything: long-polling Telegram for new messages, surfacing them to Claude as tool results, and providing a &lt;code&gt;reply&lt;/code&gt; tool Claude uses to respond.&lt;/p&gt;

&lt;p&gt;Because each bot's &lt;code&gt;.mcp.json&lt;/code&gt; passes a different &lt;code&gt;TELEGRAM_STATE_DIR&lt;/code&gt;, their session data never collides. Telegram's polling state, message history cursor, and any plugin-side data are fully isolated per bot.&lt;/p&gt;




&lt;h2&gt;
  
  
  tmux + launchd: The Auto-Restart Pattern
&lt;/h2&gt;

&lt;p&gt;Each bot runs inside a named tmux session. This matters for two reasons: tmux sessions survive terminal disconnects, and &lt;code&gt;tmux send-keys&lt;/code&gt; lets a watcher script inject input into the running Claude process without attaching to it.&lt;/p&gt;

&lt;p&gt;Nine launchd plists (&lt;code&gt;com.mozi.[name]-watcher&lt;/code&gt;) run continuously on startup. Each one runs &lt;code&gt;watch.sh&lt;/code&gt; for its corresponding bot. The watcher polls every 30 seconds.&lt;/p&gt;

&lt;p&gt;The core loop in &lt;code&gt;watch.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="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIGNAL_FILE&lt;/span&gt;&lt;span class="s2"&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;then
        &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIGNAL_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="nb"&gt;sleep &lt;/span&gt;60
        &lt;span class="nv"&gt;$TMUX&lt;/span&gt; kill-session &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
        &lt;span class="nv"&gt;$TMUX&lt;/span&gt; new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$START_CMD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="nb"&gt;sleep &lt;/span&gt;15
        &lt;span class="nv"&gt;$TMUX&lt;/span&gt; send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; Enter
        &lt;span class="nb"&gt;sleep &lt;/span&gt;5
        &lt;span class="nv"&gt;$TMUX&lt;/span&gt; send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; Enter
    &lt;span class="k"&gt;fi

    if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$TMUX&lt;/span&gt; has-session &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$TMUX&lt;/span&gt; has-session &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
            &lt;span class="nv"&gt;$TMUX&lt;/span&gt; new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$START_CMD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
            &lt;span class="nb"&gt;sleep &lt;/span&gt;15
            &lt;span class="nv"&gt;$TMUX&lt;/span&gt; send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; Enter
        &lt;span class="k"&gt;fi
    else
        &lt;/span&gt;detect_and_dismiss_usage_modal &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        drain_bot_inbox
    &lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two paths: signal file (intentional restart, triggered by another bot or admin command) and crash detection (tmux session gone). Both paths start a new tmux session running &lt;code&gt;start.sh&lt;/code&gt;, then wait 15 seconds and send &lt;code&gt;"1" Enter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That 15-second wait and keypress is the fix to the most important bug in this setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Confirmation Prompt Bug
&lt;/h2&gt;

&lt;p&gt;When Claude Code starts, it shows a confirmation prompt asking whether to proceed in the current mode. Without user input, it sits there. The bot technically starts but never processes any Telegram messages because Claude is waiting for the human to press a key.&lt;/p&gt;

&lt;p&gt;The naive fix is &lt;code&gt;skipDangerousModePermissionPrompt: true&lt;/code&gt; in &lt;code&gt;settings.json&lt;/code&gt;. This does not reliably suppress the prompt in server mode. Do not rely on it.&lt;/p&gt;

&lt;p&gt;The actual fix: after starting the tmux session, wait long enough for the prompt to appear (15 seconds covers even slow startup), then send the keypress:&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="nv"&gt;$TMUX&lt;/span&gt; new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$START_CMD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;15
&lt;span class="nv"&gt;$TMUX&lt;/span&gt; send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; Enter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tmux send-keys&lt;/code&gt; injects input into the pane as if a user typed it. The bot receives the "1" Enter, dismisses the prompt, and starts processing messages.&lt;/p&gt;

&lt;p&gt;Without this, you get a bot that shows as running in &lt;code&gt;tmux list-sessions&lt;/code&gt; but never responds. It is a silent failure. The logs look clean. Telegram messages arrive and pile up. Nothing processes them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Usage Limit Modal Auto-Dismiss
&lt;/h2&gt;

&lt;p&gt;Fable bots can hit usage limits mid-session. Claude Code displays an interactive modal asking what to do: continue with Fable, switch to Sonnet, or stop. Without auto-dismissal, the bot freezes until a human intervenes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;detect_and_dismiss_usage_modal&lt;/code&gt; function in &lt;code&gt;watch.sh&lt;/code&gt; handles this. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Captures the current tmux pane content&lt;/li&gt;
&lt;li&gt;Looks for phrases like "limit", "credits", or numbered-option markers&lt;/li&gt;
&lt;li&gt;Prefers "Continue with Fable" — extracts the digit from the option line and sends it&lt;/li&gt;
&lt;li&gt;Falls back to "Switch to Sonnet" if Fable continuation is unavailable&lt;/li&gt;
&lt;li&gt;Falls back to sending "1" if no specific option is recognized&lt;/li&gt;
&lt;li&gt;Logs every auto-dismiss event to &lt;code&gt;usage_modal.log&lt;/code&gt; with a timestamp&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This runs on every poll cycle (every 30 seconds). A modal that appears between poll cycles goes undetected for at most 30 seconds, then gets auto-dismissed. The bot continues without human intervention.&lt;/p&gt;




&lt;h2&gt;
  
  
  Access Control
&lt;/h2&gt;

&lt;p&gt;Each bot has an access list at &lt;code&gt;~/.claude/channels/[name]/access.json&lt;/code&gt;. This is an allowlist of Telegram user IDs. Messages from users not on the list are silently ignored by the plugin.&lt;/p&gt;

&lt;p&gt;The structure is straightforward — a JSON object with an array of permitted user IDs. The plugin checks each incoming message against this list before surfacing it to Claude.&lt;/p&gt;

&lt;p&gt;This matters when a bot's token is compromised or shared accidentally. Without the allowlist, anyone who finds the token can interact with your bots and run arbitrary tool calls.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setup Checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per bot:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Create &lt;code&gt;sessions/[name]/&lt;/code&gt; directory&lt;/li&gt;
&lt;li&gt;[ ] Write &lt;code&gt;start.sh&lt;/code&gt; with unique &lt;code&gt;TELEGRAM_BOT_TOKEN&lt;/code&gt;, &lt;code&gt;TELEGRAM_STATE_DIR&lt;/code&gt;, and correct &lt;code&gt;ANTHROPIC_MODEL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Copy &lt;code&gt;watch.sh&lt;/code&gt; from the canonical template — do not write it from scratch&lt;/li&gt;
&lt;li&gt;[ ] Write &lt;code&gt;.mcp.json&lt;/code&gt; pointing to the shared plugin with bot-specific env vars&lt;/li&gt;
&lt;li&gt;[ ] Create &lt;code&gt;~/.claude/channels/[name]/access.json&lt;/code&gt; with permitted Telegram user IDs&lt;/li&gt;
&lt;li&gt;[ ] Create the &lt;code&gt;TELEGRAM_STATE_DIR&lt;/code&gt; directory&lt;/li&gt;
&lt;li&gt;[ ] Test &lt;code&gt;start.sh&lt;/code&gt; manually in a terminal first — confirm Claude starts and the prompt appears&lt;/li&gt;
&lt;li&gt;[ ] Verify the "1" keypress dismisses the prompt and the bot logs message receipt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;For the watcher:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Write a launchd plist &lt;code&gt;com.mozi.[name]-watcher&lt;/code&gt; that runs &lt;code&gt;watch.sh&lt;/code&gt; at startup&lt;/li&gt;
&lt;li&gt;[ ] Load with &lt;code&gt;launchctl load ~/Library/LaunchAgents/com.mozi.[name]-watcher.plist&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Confirm it appears in &lt;code&gt;launchctl list | grep mozi&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Verify everything works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Send a test message from an allowlisted Telegram account&lt;/li&gt;
&lt;li&gt;[ ] Confirm the bot replies&lt;/li&gt;
&lt;li&gt;[ ] Kill the tmux session manually — confirm the watcher restarts it within 60 seconds&lt;/li&gt;
&lt;li&gt;[ ] Check that the restart sends the "1" keypress automatically&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Common Failure Modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bot starts but never responds.&lt;/strong&gt; Almost always the confirmation prompt. Attach to the tmux session (&lt;code&gt;tmux attach -t [name]&lt;/code&gt;) and look. If you see a prompt waiting for input, the &lt;code&gt;send-keys&lt;/code&gt; timing is off. Increase the sleep before the keypress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two bots receiving the same messages.&lt;/strong&gt; &lt;code&gt;TELEGRAM_STATE_DIR&lt;/code&gt; is shared or identical. Each bot must have a unique state directory. Check the &lt;code&gt;env&lt;/code&gt; in &lt;code&gt;.mcp.json&lt;/code&gt; and the export in &lt;code&gt;start.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bot crashes repeatedly.&lt;/strong&gt; Check the launchd logs and the tmux pane output before it exits. Most crashes are MCP server startup failures — usually a missing &lt;code&gt;bun&lt;/code&gt; in PATH, wrong plugin path, or malformed &lt;code&gt;.mcp.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watcher not restarting dead sessions.&lt;/strong&gt; Confirm the launchd plist is loaded and running. Check &lt;code&gt;launchctl list com.mozi.[name]-watcher&lt;/code&gt;. If the &lt;code&gt;tmux&lt;/code&gt; binary path in &lt;code&gt;watch.sh&lt;/code&gt; is wrong (common when tmux is installed via Homebrew and PATH differs in launchd context), set an absolute path.&lt;/p&gt;




&lt;p&gt;The architecture scales linearly. Each new bot is the same pattern: a directory, three files, one launchd plist. The shared plugin means there is no per-bot plugin code to maintain. The watcher handles failures automatically. Once the pattern is running, adding bot number nine is twenty minutes of work.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>telegram</category>
    </item>
  </channel>
</rss>
