
Seven phases, five free APIs, ₹0 a month, and a design philosophy that runs through every screen.
Eight browser tabs. Every morning. Screener.in for Indian fundamentals, Yahoo Finance for US stocks, Moneycontrol for news, AMFI for mutual fund factsheets that look like they were laid out in 2007 because they were, NSE for IPOs, Chittorgarh for IPOs that NSE forgets about, Zerodha Kite to actually buy things, and Groww because Kite makes me sad.
I'd open all eight, do twenty minutes of actual research, and spend another thirty minutes context-switching between pages with different formatting and update frequencies. Most days I'd close my laptop without buying anything, because by the time I'd compared everything I needed to compare, I was tired.
Earlier this year I decided this was a problem worth solving for myself. Today I have a Telegram bot called investu that runs every morning at 8 AM. It screens 100+ Indian large-caps, the S&P 500 starter set, mutual funds, SIP-eligible picks and live IPOs, ranks whatever passes a quality screen, surfaces a near-pass watchlist of stocks that almost qualified, and pulls sourced news on the top picks. It's a single-user bot. Mine. And it's the best research workflow I've ever had.
This is how I built it. Seven phases, five free APIs, ₹0 a month, and one stubborn refusal that ended up being the only thing that mattered. The bot is allowed to say it doesn't know.
I'm a designer. I'd been wanting to build something with my own hands for a while, partly because I keep talking to engineers about products and noticing how often the conversation skips over the boring decisions that actually shape what a thing feels like. The only way to learn that for real, I figured, was to make something where every decision was mine. The fact that I needed a research bot anyway made it the right project.
What follows is the build, told in order, with the design rationale next to the engineering choices.
Phase 1. The boring core, and what a "screen" actually is

The first version of investu was a Python CLI. No Telegram, no LLM, no scheduler, no scoring dashboard. I ran python main.py stocks in a terminal and it printed a table of Indian large-caps that passed a quality screen.
A quality screen sounds technical but the shape is simple. Three steps.
Load a universe. Just a text file with one ticker per line. universes/nifty100.txt is the Indian universe, about a hundred stocks.
For each ticker, fetch fundamentals and check them against a set of rules. The rules have to be configurable, not hardcoded, because the rules are the product. Mine live in YAML.
indian_large_cap:
rules:
market_cap_cr_min: 20000
revenue_3y_cagr_pct_min: 12
profit_3y_cagr_pct_min: 12
roe_pct_min: 15
debt_to_equity_max: 1.0
pe_max: 60
ranking:
weight_revenue_cagr: 0.30
weight_profit_cagr: 0.30
weight_roe: 0.20
weight_low_debt: 0.10
weight_low_pe: 0.10
output:
top_n: 10
Six hard-pass rules. Market cap above ₹20,000 cr. Revenue and profit 3-year CAGRs both above 12%. ROE above 15%. Debt-to-equity below 1.0. PE below 60. A stock that fails any one of these is out. The composite weighting is heavy on growth (60% combined), medium on quality (20% ROE), light on capital structure (10% D/E plus 10% PE).
Rank the survivors by a composite score. Return the top N.
The composite is a min-max normalized weighted average. For each metric I scale every passing stock onto a [0, 1] range (minimum value in the surviving set becomes 0, maximum becomes 1, everything else interpolated), then take a weighted sum.
I went back and forth on z-scores for a while because z-scores are what statistics textbooks recommend. I landed on min-max because I wanted the score to be readable. "0.82" should mean "this stock is in the 82nd percentile of the surviving set today." Z-scores are unbounded and require remembering a population mean to interpret. Min-max also handles missing data gracefully. A None propagates through and the weighted average is computed on the present terms only, which matters more than I'd expected.
The other Phase 1 design call was about the data, not the rules. Early on, an AUM scrape returned a value off by an order of magnitude: ₹15,000 cr reported as ₹150 cr. A junk fund made it past the screen because of that. The engineering instinct here is to clamp the value, cap it at a maximum, log a warning, move on. The design instinct is the opposite. If you don't trust the data, don't show the answer.
I built sanity ranges instead. Every metric has a plausible range (market_cap_cr is between 100 and 50,00,000, roe_pct is between -200 and 200). Anything outside that range becomes None, and a None metric is a hard fail. The screen surfaces fewer candidates instead of the wrong number.
This sounds like a small call. It runs through the entire product. Sanity ranges were the first time I told the code that uncertainty was a valid output, not an error to suppress.
The most useful feature in Phase 1 wasn't the strict pass/fail. It was the near-pass watchlist.
A ScreenResult records every check it ran, not just whether the stock passed. So when a near-pass ticker fails by one or two criteria, the bot can show exactly why. Reliance: 3Y profit CAGR 8.4% (floor 12%), ROE 11.7% (floor 15%). The strict screen tells you what to buy this quarter. The near-pass watchlist tells you what to check next quarter. Two different products inside the same screen, and the second one is more useful in practice because the strict screen is binary and the watchlist is signal.
The near-pass watchlist isn't a feature I planned. It fell out of writing the screener properly. Once you record per-rule results for diagnostic purposes, surfacing them as a watchlist is fifteen lines of formatting code. A lot of good design works like this. If your code's data structures already know the answer to a question your users are about to ask, expose it. The work is already done.
Phase 2. Cards, not tables (and a cat closing a door)

Phase 2 wrapped Phase 1 in a Telegram bot. Polling app, python-telegram-bot v20, a /stocks command that ran the screen and posted the result.
The first thing I tried was a monospace table. It was a disaster. Phone screens line-wrap monospace tables in the worst possible places, usually the price column, right when the number runs into the next column and you can't tell whether ROE is 16.8% or 168%. Most "developer brain" outputs default to tables because tables are how data looks in a database. The bot isn't running on a database. It's running on a phone screen at 8 AM, and the person reading it has bedhead.
So I rewrote the formatter as cards. One company per card. Labels and values stacked vertically. Two blank lines between cards. The 4096-character Telegram message limit gets handled by splitting between cards, never inside one, which means a card never gets cut in half mid-data.
Cards-over-tables sounds like a tiny call. It changes how the bot's output is composed at every layer. Every screener returns a list of ScreenResult objects. The formatter turns each one into a card. The card splitter respects card boundaries when it hits message limits. The product feels different because of it.
The other Phase 2 thing worth describing is the auth model. I made the bot owner-only. Every command handler is wrapped in a decorator.
@owner_only
async def cmd_stocks(update, context):
...
Anyone whose chat_id doesn't match OWNER_CHAT_ID gets a sassy GIF of a cat closing a door, plus a one-line brush-off, and nothing else. No menu, no help text, no leak about what the bot does or what commands it accepts. The handler itself never runs.
I cared about the rejection screen disproportionately. It's the only thing the rest of the world sees, and I wanted it to feel deliberate, not like "auth failed." Designing the rejection screen of an owner-only bot is the closest thing software gets to designing a closed door. You want the door to look intentional, not like the building is broken. A cat-closing-a-door GIF is roughly fifteen pixels of message, and it's the most personality the bot ever shows to a stranger.
The commands themselves got designed in Phase 2 too. Nine total. /stocks, /us, /mf, /sip, /ipos, /hello (the daily fan-out), /refresh (cache-busting version of /hello), /news <TICKER>, and /schedule (which reports the next scheduled digest time, because I always forget when I last looked at it). Plus an inline keyboard menu, because typing /stocks on mobile is annoying.
Phase 3. Five screens, one shape

Once Phase 1's pattern was solid, adding the other four screens was a copy-the-shape exercise. Each screen has the same three-step contract (load universe, fetch and validate, screen and rank), and what changes is the data source and the rule set.
The US screen uses the S&P 500 starter list. Originally it ran on yfinance, like the Indian screen. yfinance is fine for a hobby project on your laptop. It is not fine on a cloud server. (More on that in Phase 7.) I migrated to Financial Modeling Prep, which has a 250-call-per-day free tier, comfortably enough for thirty US tickers using five endpoints each on a daily refresh. The rules mirror the Indian screen's structure: growth-heavy, ROE floor, D/E ceiling, PE ceiling.
The mutual fund screen is built on MFAPI.in, a free service that exposes AMFI's NAV history as a clean JSON API. For each fund in my universe I pull NAV history, compute 1Y / 3Y / 5Y CAGRs, and rank by the 5Y CAGR. This is the screen with the most awkward universe story. There's no free Indian source for expense ratios or AUM, so I gave up on filtering by those and curated universes/mf_seed.yaml by hand. About fifteen well-known direct plans across categories (equity, hybrid, debt, index). I'm not pretending this scales to thousands of funds. It's a single-user bot, and a curated list is fine for one user. The README documents this limitation explicitly so I don't pretend later that it's a general-purpose MF screener.
The SIP screen is the MF screen with one extra filter: include only funds whose minimum SIP amount is ₹1,000 or below. The flag lives on each fund in mf_seed.yaml. This screen exists because most of my actual investing is via SIPs, and I wanted a one-tap view of "things I could start a SIP into today without rearranging my budget."
The IPO calendar is the odd one out. It's not really a screen, it's a calendar fetch. The data source story here is worth telling. The first version scraped Chittorgarh, the unofficial-but-comprehensive Indian IPO data site. Three weeks in, Chittorgarh changed their HTML layout and broke me. I switched to NSE's official /api/all-upcoming-issues endpoint, which returns active and forthcoming issues as clean JSON. The catch: bare requests.get() calls return 401, because NSE's API checks for cookies that only get set when you visit the homepage first. So the client now hits the homepage to warm up cookies before every API call. The kind of thing you only know because you tried the lazy way first.
The five screens share the same internal structure, the same caching layer, the same formatting pipeline, and the same auth gate. Adding a sixth screen ("Indian small caps" or "REITs" or "thematic baskets") would be writing one new YAML block, one new universe file, and one new client (or reusing an existing one). The shape of the bot is more reusable than the bot itself.
Phase 4. The daily rhythm

A bot that runs when you ask it isn't useful. A bot that runs every morning before you ask is the entire point.
Phase 4 added the daily 8 AM digest. It's a JobQueue.run_daily job inside the same python-telegram-bot event loop. No separate process, no Celery, no cron container. The bot wakes itself up, fans out all five screens with a 0.6-second pause between sections, and posts each one as a card stack.
The 0.6-second pause is a polite Telegram thing. Telegram has per-chat rate limits, and 0.6 seconds is a comfortable margin. A faster fan-out would occasionally trip the limit and lose a section. A slower one would feel like the bot was buffering. 0.6 seconds is the sweet spot I found by trial.
The morning brief opens with a quote. There's a 43-quote investing deck in rotation: Buffett, Munger, Naval, Damani, Vijay Kedia, Morgan Housel. The deck is shuffled at process start and popped one at a time without repetition. Today's quote was Munger:
The big money is not in the buying and selling, but in the waiting.
When the deck cycles through, it reshuffles. Process restart resets it, which doesn't matter because the host I'm using restarts often enough to keep the rotation feeling fresh.
I wrote this part deliberately small in the codebase. About thirty lines. But the quote is the difference between a tool I use and a tool I look forward to opening. There's a lesson in that gap somewhere. I'll let you find it on your own.
There's also a /schedule command that reports the next scheduled digest time. I added it for the same reason every product needs a "when did this last sync" indicator. Humans want to know when the next thing is happening, even when the answer is boring.
Phase 5. Deployment artifacts (or, where most side projects die)

Most side projects die between "it works on my laptop" and "it runs every morning whether I touch it or not." I cared about getting through that gap.
The repo ships four deployment manifests. Dockerfile, render.yaml, railway.json, fly.toml. Same image, four hosts, three modes (webhook on Render, polling on Railway/Fly.io/local). The trick is auto-detection. main.py checks for WEBHOOK_URL (or RENDER_EXTERNAL_URL, which Render auto-sets) at startup and dispatches accordingly. If a webhook URL is present, it runs app.run_webhook() and skips the in-process scheduler. Otherwise, app.run_polling() and the scheduler is registered.
I started with Render's free Web Service because it's free forever. Render's free dynos spin down after fifteen minutes of inactivity, which means the in-process 8 AM scheduler doesn't fire if the dyno is asleep. The fix is a GitHub Actions cron at 02:30 UTC (= 08:00 IST) that POSTs a synthetic /hello update to the bot's webhook endpoint. The HTTP request itself wakes the dyno (about 30 seconds cold start), which then runs the digest. After that, the bot stays warm for fifteen minutes, enough for me to swipe through the brief and ask follow-ups.
This works. It also took me two evenings to figure out, because Render generated my WEBHOOK_SECRET value as a random URL-unsafe string containing + and / and =. Telegram's setWebhook rejected it with HTTP 400, because Telegram's secret token has to match [A-Za-z0-9_-]. Of course it did. I added a comment in render.yaml documenting how to generate a URL-safe secret manually, so future-me doesn't relearn this the hard way.
The polling-mode hosts (Railway and Fly.io) are simpler. Always-on, the in-process scheduler fires directly, no GitHub Actions needed. Fly.io has the additional advantage of a mounted volume, which means the file-based JSON cache survives restarts. Render and Railway lose the cache on every restart, which is fine because the cache is purely a network-saver, never the source of truth.
Four deployment manifests sounds excessive for a personal project. It is, a little. But each one took about an hour to write once the auto-detect block was in place, and the value of being able to migrate hosts without touching code has paid for that hour several times over. If a free tier disappears or a host gets noisy, I move.
Phase 6. News research, cited only with real URLs

The five screens tell me what to buy. They don't tell me why this week is the right week. That's what news research is for.
Phase 6 added a /news <TICKER> command and an automatic news enrichment pass on the daily digest's top picks. For each target stock, the bot pulls catalysts from the last 21 days and analyst price targets from the last 6 weeks, with the constraint that every URL it cites must be a real URL.
That last constraint is the entire engineering story.
LLMs hallucinate URLs. The first time I asked GPT-4 for "three recent news articles about Reliance with sources," it confidently gave me three URLs that all returned 404. Not because the model was malicious, and not because it didn't have good information about Reliance (it did), but because URLs themselves are detail the model is willing to invent to satisfy the citation pattern. The model knows what URLs look like. It doesn't know which ones exist.
The pipeline that solved this has three steps.
Tavily searches. For each ticker, the bot fires two focused Tavily queries: catalysts in the last 21 days, and analyst targets. Each query returns up to five results as snippets with their source URLs. About ten unique URLs per ticker.
Groq extracts. I send the snippets and URLs to Groq's Llama 3.3 70B with a system prompt that says:
Extract catalysts and analyst targets from these snippets, output JSON only. The schema is fixed. There is no "growth potential percent" field, no "expected returns" field, so the LLM physically cannot emit speculation. Mode-specific prompts add hard rules per content type. "Never provide a price target. Mutual funds do not have analyst price targets." "Never recommend Subscribe yourself. Only quote brokers."
Code validates. This is the part that matters. The system prompt asks the model to cite URLs. The model usually does, mostly correctly. But I never trust the model's output directly. I keep the set of URLs Tavily actually returned, and any source_url in the LLM's JSON that isn't in that set gets dropped. With a log line: dropping hallucinated catalyst URL: ....
That's the whole trick.
The model can hallucinate. The pipeline doesn't.
In about four weeks of running, I haven't seen a single fake URL leak into the bot's output. The same pipeline also handles the two adjacent honest-labels cases. If Tavily returns no results in the freshness window, the bot prints "No recent catalysts found." If no broker has published a 24-month or longer target, the bot prints ⚠ No 3–5 year analyst targets found. Bot does not extrapolate. It does not invent one. It does not estimate one. It does not soft-claim "based on current trajectory."
The lesson here is a product lesson, not really an engineering one. You don't get an LLM to be trustworthy by asking it nicely. You give it a smaller world to live in (the snippets), constrain what it's allowed to say (the schema), and verify its output against ground truth (the URL set) before showing it to anyone. The model is the cheapest part of this system. The expensive part is the engineering around the model.
Phase 7. When yfinance stopped working from the cloud

This one's short, but it's the one I want every solo builder to read.
Phase 1 used yfinance for both Indian and US fundamentals. It worked perfectly on my laptop. I deployed to Render. Yahoo started 429-ing me within the hour.
I tried throttling. Didn't help. Tried different user agents. Didn't help. Yahoo penalises the cloud IP, not the request. yfinance worked fine for a hobby project on my laptop because my home IP isn't on a blocklist. As soon as the bot lived on a cloud IP, yfinance was unusable for anything that hit .info or .history. The friendly endpoint, fast_info, mostly survived, so I kept yfinance for the BSE quote line. But the main fundamentals path needed a new source.
I switched the US screen to Financial Modeling Prep. Free tier is 250 calls per day, no card required. Five endpoints per ticker, thirty tickers, one cold run per day comes to about 150 calls. Comfortably under quota with caching.
The fix wasn't be more polite or implement exponential backoff. Yahoo doesn't care. The fix was switch data sources for the affected calls. When a free API stops working from the cloud, your engineering instincts (more retries, better headers, tighter throttling) are usually solving the wrong problem. The right move is to assume the data source has decided you're a bot, accept that they're correct, and find a different supplier.
The honest labels thread

I've called this article a designer's notebook a few times. The design philosophy that ran through all seven phases is what I'd call honest labels. It shows up in five places.
Sanity ranges, not clamps. When data is bad, the bot says it's bad, by surfacing nothing, instead of pretending it's fine.
Explicit timeframes on every metric. Every number the bot prints carries its timeframe and its source. PE current. ROE TTM. Revenue 3Y CAGR. Source: Screener.in. Source: NSE official. As of 06 May 2026 11:13 IST. When something is missing, the missingness has a label too. Market cap (₹ cr): n/a is information, not an error.
The near-pass watchlist. The strict screen is binary. The watchlist is signal. Near-passes are labelled with exactly which criteria they failed and by how much, so I know what to check next quarter without confusing the watchlist with the strict winners.
Cross-check prices. Every Indian stock card shows both NSE and BSE prices side by side, with a small footer explaining why: "BSE quote shown for cross-check before buying." Most retail traders buy through Groww or Kite, both of which can route to either exchange. A 0.3% spread between NSE and BSE at the open is real, and the bot shows both so I can spot it before I tap buy.
The "no long-term target" disclaimer. When no broker has published a 24-month or longer analyst target, the bot prints ⚠ No 3–5 year analyst targets found. Bot does not extrapolate. It does not invent one.
All five of these cost the product very little to build, and they add disproportionately to how much I trust my own bot. I'm the only user. I built it for me. And I trust the cards I read because the bot has never lied to me about what it doesn't know.
There's a version of investu that's identical in every other way and prints invented analyst targets, glosses over missing data with sensible defaults, and shows only the strict winners with no near-pass context. That version would feel slicker. It would also be useless, because every number on it would carry the same epistemic weight as every other, and I'd have to verify each one before acting. The honest version is less impressive and more useful.
What it costs

investu costs me ₹0 a month. Steady state.
The economics work because the bot is single-user and caches aggressively. Tavily's 1000-search-per-month free tier sounds tight until you realise that with six-hour caching, the same ticker doesn't get re-searched within the same business day. Groq's 14,400-request-per-day limit on Llama 3.3 70B is absurdly generous for a bot that fires maybe fifty LLM extractions across a normal day. FMP's 250-call-per-day limit is the tightest, and the math is comfortable: five endpoints times thirty US tickers times one cold run per day equals 150 calls. Screener.in, MFAPI.in, NSE's IPO endpoint, and Telegram are all free with no quotas worth worrying about.
"Free tier only, single user" was a constraint, not an outcome. I made the constraint upfront and let it shape every decision. Caching aggressively, scraping politely (one request per second to Screener.in is slower than necessary but it's the right rate), throttling per-process, picking data sources that play well with cloud IPs. None of these were nice-to-haves. They were the only way the bot could exist for ₹0.
Constraints are clarifying. The version of investu that costs ₹500 a month would be technically more capable and worse-designed in every way that matters, because the design pressure of "this has to be free" forced me to think harder about what the bot actually needs to do.
What I'd do differently
Three things I documented as deferrals in the README rather than faking, because faking them would have undermined the rest.
Promoter pledge percentage. Screener.in shows this. My parser doesn't extract it yet. Promoter holding percentage is extracted, which covers most of what I need. But if a promoter pledged 60% of their stake to lenders last quarter, that's information that should affect a screen, and I don't have it.
PE versus 3-year median. The current screen uses an absolute PE ceiling (pe_max: 60) which is a blunt instrument. PE-relative-to-its-own-3Y-median would be a much better quality signal. It needs a historical PE source, which Screener.in shows on individual stock pages but doesn't expose in any structured way.
Mutual fund expense ratios and AUM. No free Indian source. The workaround is the curated mf_seed.yaml list. This is the deferral I'm least happy about because it doesn't scale, but I refuse to ship a screen that pretends to filter by expense ratio if it can't actually see the field.
These aren't bugs. They're things I refused to fake, with a clear path to fix once a free source lands or I decide to pay. I write them down explicitly because pretending the bot is more than it is would defeat the entire point of the project.
The other thing I'd do differently is open with the daily digest from day one. I built the screens first and the digest second. The digest is what makes the bot useful. Without it, I have to remember to run it, and on most mornings I won't. If I were starting again, I'd have written the scheduler before the second screen.
What's next
The bot has been my morning research routine for about six weeks. It works for me. The next step is to find out whether it works for anyone else.
I'm planning to put it in front of two groups. First, friends who do equity research professionally, broker-side or buy-side. They'll spot whatever I've missed. They'll tell me which screens are noisy, which metrics are weak, which "honest labels" are actually just annoying labels, and where my YAML rules are oversimplified for the way real retail investing works. The bot was designed for me, which means it inherits all my biases. The fastest way to find those biases is to hand it to people whose job is research and watch them squint at the cards.
Second, a small group of retail investor friends who don't have time for the eight-tab routine themselves. They're the real user this bot was supposed to serve. If it saves them ten minutes in the morning, the design works. If they go back to their tabs, something is wrong with the experience and I need to hear what.
The other big "next" is paid data sources.
The current build is "free tiers only" for a reason. That constraint clarified every decision in Phases 1 through 7. It also created the deferred items in the section above. Mutual fund expense ratios and AUM have no free Indian source. Historical PE for 3Y median comparison would need a paid feed. Some of the better catalyst data lives behind paywalls. And Yahoo Finance hates cloud IPs and doesn't seem to be changing its mind.
Moving to paid tiers on a couple of sources (FMP at the next price tier, and a paid Indian fundamentals API once I find one I trust) would unlock those deferred items and add headroom for real scale. The trade-off is that the bot's cost goes from ₹0 to roughly $20–50 a month. Fine if more than one person is using it. Silly if it's just me.
So the order is testing first, paid sources after the broker and retail-user feedback says the bot is worth the cost. If both signals come back positive, the phase after that is multi-user mode, which is a meaningful rebuild rather than a feature add. Postgres for per-user config. Tier-aware rate limiting. Owner-isolated caches. A real auth model that isn't "is your chat_id mine."
That's a long way from where the bot is today, which is exactly the right distance. For now: it runs at 8 AM, the cards are honest, the URLs are real. That's enough to ship at the level it's designed for.
Five takeaways from one codebase
If you've made it this far, here's what I'd take from the build, condensed.
Constraints are clarifying. "Free tier only, single user, Telegram-delivered" forced every architectural decision worth making. The bot exists in this specific shape because it had to.
Honest labels beat clever features. The bot saying "⚠ No 3–5 year analyst targets found. Bot does not extrapolate" is more useful than the bot inventing a number. I trust the cards I read because the bot has never lied to me about what it doesn't know.
Phase the build. Phase 1 was a 250-line script. Shippable on day one. Every later phase had the option of building on something real. The "let's start with the AI feature" version of this project would still be in design.
Cloud-IP rate-limiting is real. yfinance worked perfectly on my laptop and stopped working an hour after I deployed. The fix wasn't more retries, it was a different supplier. Most of your engineering instincts about rate limits assume the API is acting in good faith. When it has decided you're a bot, your instincts are solving the wrong problem.
The model is the cheapest part. Groq's Llama 3.3 70B free tier is generous beyond the point of usefulness for a single-user bot. The expensive part of "AI features" isn't the model. It's the schema lock, the mode-specific prompts, the URL validation pass, the cache key design, the sanity ranges that keep bad data out of the model in the first place. The engineering around the model is the entire job.
investu has run my morning research routine since launch. Every brief sourced, every limit labelled, every URL real, ₹0 a month, on a cloud server that's asleep most of the time. It's not a product. It's a tool I built for myself, and the constraint of building it only for me is what made the design work.
The hardest part wasn't the code. It was deciding what to leave out. What to label as missing instead of faking. What to defer instead of half-shipping. What to refuse to print because the bot doesn't actually know it.
If you're building something like this, a personal research tool, an LLM-powered feature, anything where trust matters, the design move that pays off the most is the one that costs the least. Let the thing admit when it doesn't know. Most of the value of an honest tool is the trust you don't have to spend verifying its output.
That's the whole article. Now go build the thing you've been putting off because you don't have a "production-ready architecture" for it. You don't need one. You need a script, a sanity range, and a cat closing a door.
The bot is mine. The lessons are free.