I Built an AI Code Reviewer That Uses Any LLM to Review Claude Code Output — Zero Dependencies, 7 Commands, Infinite Engines

I Built an AI Code Reviewer That Uses Any LLM to Review Claude Code Output — Zero Dependencies, 7 Commands, Infinite Engines

TL;DR: I built cc-review — a pure bash Claude Code skill that spins up any external LLM (Gemini, Ollama, DeepSeek, OpenAI) to independently review Claude’s own code output. No npm. No pip. Just 7 slash commands, a YAML config, and an uncomfortable truth about trusting AI to review itself. Repo here.

Here’s the uncomfortable truth nobody talks about: Claude is reviewing Claude’s code.

You vibe-code a multi-phase feature. You run /review. Claude reads its own output and says “looks good!” You ship. Two days later you’re debugging a race condition that any second pair of eyes would have caught in 30 seconds.

You didn’t get a code review. You got a mirror.

I hit this exact wall while building a multi-phase AI Second Brain project — an agentic system with memory modules, knowledge indexing, scheduled tasks, and a self-learning loop. Each phase produced hundreds of lines of generated code. I was using Claude Code for everything: architecting, implementing, reviewing. The confirmation bias was baked in.

I needed a reviewer with zero loyalty to the original author.

So I built cc-review: an open-source Claude Code skill that outsources your code review to any external LLM engine. Gemini reviews Claude’s work. Ollama stays local and private. DeepSeek brings a different training distribution. The engine is pluggable. The bash is pure. The cost, with Gemini’s free tier, is zero.

Here’s exactly how it works and how you can add it to your own Claude Code setup in under 10 minutes.

What We’re Building

cc-review is a Claude Code skill — a bash-powered plugin that extends Claude Code with new slash commands. When you trigger a review, it:

  1. Grabs your recent git diff (staged + unstaged changes)
  2. Routes the diff to an external LLM engine of your choice
  3. Scores the code on four dimensions: Completeness, Correctness, Quality, Security
  4. Returns structured feedback with line-level comments
  5. Optionally runs an adversarial review mode that actively tries to break your assumptions
Your Code Changes (git diff)
        │
        ▼
┌───────────────────┐
│   cc-review skill │  ← Pure bash, reads engines.yaml
└────────┬──────────┘
         │
    ┌────▼─────────────────────────────────┐
    │         engines.yaml router          │
    └──┬──────────┬───────────┬────────────┘
       │          │           │
   ┌───▼───┐  ┌───▼───┐  ┌───▼───────┐
   │Gemini │  │Ollama │  │ DeepSeek  │  ← any LLM, zero code changes
   └───────┘  └───────┘  └───────────┘
       │          │           │
       └──────────┼───────────┘
                  │
         ┌────────▼────────┐
         │ Scored Report   │  Completeness / Correctness
         │ (4 dimensions)  │  Quality / Security
         └─────────────────┘

Seven commands ship out of the box:

Command What It Does
/review Standard review using your default engine
/review-adversarial Skeptic mode — actively challenges your code
/review-result Show full output of last review
/review-status Check running/recent jobs
/review-setup Verify engine auth and readiness
/review-cancel Kill a running background job
/review-rescue Delegate investigation/fix to external engine

Prerequisites

  • Claude Code installed and running (>= 0.2.x)
  • git available in your shell
  • At least one of: a Gemini API key (free tier), Ollama running locally, or an OpenAI/DeepSeek key
  • Basic comfort with YAML config files

Step-by-Step

1. Install the Skill

Clone cc-review into your Claude Code skills directory:

git clone https://github.com/mudavathsrinivas/cc-review ~/.claude/skills/cc-review

Claude Code auto-discovers skills in ~/.claude/skills/. No import step. No config file edit. The skill system reads the directory on startup.

Verify it loaded:

# Inside Claude Code
/review-setup

You’ll see output like:

cc-review v1.0.0
─────────────────────────────────
Engine Check:
  gemini    ✓  (GEMINI_API_KEY set)
  ollama    ✓  (http://localhost:11434 reachable)
  deepseek  ✗  (DEEPSEEK_API_KEY not set)
  openai    ✗  (OPENAI_API_KEY not set)

Default engine: gemini
Ready to review.

2. Configure Your Engines

This is the part I’m most proud of. Every engine lives in a single YAML file — engines.yaml. Adding a new LLM requires zero code changes. You just describe it:

# ~/.claude/skills/cc-review/engines.yaml

default: gemini

engines:
  gemini:
    provider: google
    model: gemini-2.0-flash
    api_key_env: GEMINI_API_KEY
    endpoint: https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
    max_tokens: 8192
    temperature: 0.3
    enabled: true

  ollama:
    provider: ollama
    model: llama3.2
    endpoint: http://localhost:11434/api/generate
    max_tokens: 4096
    temperature: 0.2
    enabled: true
    private: true   # flag: never send to external APIs

  deepseek:
    provider: deepseek
    model: deepseek-coder
    api_key_env: DEEPSEEK_API_KEY
    endpoint: https://api.deepseek.com/v1/chat/completions
    max_tokens: 8192
    temperature: 0.2
    enabled: false  # flip to true when key is set

  openai:
    provider: openai
    model: gpt-4o
    api_key_env: OPENAI_API_KEY
    endpoint: https://api.openai.com/v1/chat/completions
    max_tokens: 8192
    temperature: 0.2
    enabled: false

The router reads this at runtime. Set default to swap your primary reviewer. Set enabled: false to disable an engine without deleting its config. The private: true flag on Ollama is a guardrail — the skill will refuse to send that diff to any external endpoint even if you fat-finger the engine flag.

Want to add a brand new LLM? Add a YAML block. Done.

3. Run Your First Review

Make some changes in your project, then:

/review

The skill captures git diff HEAD (staged + unstaged), structures a review prompt, sends it to your default engine, and streams back a scored report. A real output looks like this:

cc-review | engine: gemini-2.0-flash | 2026-04-11 09:14 CST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SCORES
  Completeness  8/10  — Core logic is present; error branches for
                        empty API response are missing.
  Correctness   7/10  — Line 84: off-by-one in pagination cursor.
                        Will silently drop the last record.
  Quality       9/10  — Clean separation of concerns. Good.
  Security      6/10  — API key interpolated directly into log
                        string at line 112. Rotate and fix.

CRITICAL (fix before merge)
  [security] src/client.ts:112
  → `console.log(`Auth: ${apiKey}`)` logs the raw key.
    Replace with a masked version: apiKey.slice(0,4) + '****'

  [correctness] src/paginator.ts:84
  → Cursor offset is `page * limit` but should be
    `(page - 1) * limit` for 1-indexed pagination.
    Current code skips page 1 entirely.

SUGGESTIONS
  [completeness] src/client.ts:67
  → No handling for HTTP 429 (rate limit). Add exponential
    backoff or surface the error to caller.

  [quality] src/types.ts:23
  → ApiResponse<T> type is wide. Consider discriminated union
    for success/error states.

SUMMARY
  Solid implementation with two ship-blockers. The security
  issue is trivial to fix. The pagination bug would have caused
  silent data loss in production. Review cost: $0.00 (free tier).

That pagination bug? 100% something Claude wrote and Claude would have rubber-stamped. Gemini caught it because it has no attachment to the original decision.

4. Use Adversarial Mode for Critical Phases

Standard review finds bugs. Adversarial mode finds assumptions you didn’t know you were making.

/review-adversarial

The prompt instructs the external engine to play the role of a skeptical senior engineer who actively looks for: race conditions, wrong abstractions, over-engineering, security footguns, and implicit dependencies that will break in production.

Real example output from my Second Brain project’s memory indexer:

ADVERSARIAL REVIEW | engine: gemini-2.0-flash
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CHALLENGED ASSUMPTIONS

  1. "Files are processed sequentially"
     → Your glob pattern returns files in filesystem order.
       On macOS this is usually alphabetical. On Linux ext4
       it's creation order. On network mounts, undefined.
       Your tests will pass locally and break in CI.

  2. "MEMORY.md is always writable"
     → No lock file, no atomic write. Two agents running
       concurrently will corrupt this file. You mentioned
       scheduled tasks — this WILL happen.

  3. "The embedding model is stable"
     → You hardcode 'text-embedding-3-small' but never pin
       the version. OpenAI has silently updated embeddings
       before. Your similarity scores will drift over time
       and you won't know why.

VERDICT
  Ship with fixes for #1 and #2. #3 is acceptable risk
  for a solo project but document the assumption explicitly.

None of those were in the standard review output. Adversarial mode thinks differently.

5. Keep Private Code Private with Ollama

If you’re working on proprietary code and can’t send diffs to Google or OpenAI, flip to Ollama:

/review --engine ollama

Or set default: ollama in engines.yaml for all reviews. Everything stays on your machine. The private: true config flag means the router will hard-fail rather than accidentally route to an external API.

# Verify your Ollama setup first
/review-setup --engine ollama

# Output:
# ollama: ✓ (llama3.2 loaded, 8B params, ~6GB RAM used)
# private mode: ON — external routing disabled for this engine

Quality is lower than frontier models, but for security-sensitive codebases or pure sanity checks, local Ollama at zero cost is a real option.

The Result

After integrating cc-review into my Second Brain project workflow, here’s what changed:

  • Gemini free tier handles ~1,000 reviews/day at $0.00. For a solo developer shipping phases one at a time, this is effectively unlimited.
  • 5 real bugs caught in 3 weeks that I confirmed would have reached production — 2 correctness issues, 2 security issues, 1 missing error handler.
  • Review latency: 8-14 seconds per phase diff using Gemini Flash. Fast enough to run after every significant change without breaking flow.
  • Adversarial mode changed how I think about code I generate. I now proactively consider race conditions and assumption brittleness because I’ve seen the engine surface them repeatedly.

The workflow became:

Implement phase with Claude → /review → fix blockers → /review-adversarial → fix assumptions → commit

Key Takeaway

An LLM cannot objectively review its own output. Not because the model is bad — because the training distribution, the context window, and the confirmation bias are all pointing the same direction. Independent review means a genuinely different model, with different training data, reading your code cold.

cc-review is a 20-minute setup that gives you that independence, at zero cost, with full control over which engine reviews which code and whether anything ever leaves your machine.

The irony is: the better your AI coding assistant gets, the more you need this. The faster Claude ships code, the faster bugs accumulate without a second opinion.

Star the repo if this is useful: github.com/mudavathsrinivas/cc-review

Pull requests welcome — especially new engine configs for engines.yaml. If you’ve got a working block for Mistral, Cohere, or any local model, open a PR and I’ll merge it.

Follow me here on Dev.to — I’m documenting the full AI Second Brain build in public, including the infrastructure, the failures, and the moments where the AI confidently wrote something completely wrong.

I Rewrote My Portfolio From Scratch — Here’s What Actually Changed (And Why)

My old portfolio wasn’t bad. It had all the things that feel polished when you first build them: card grids, gradient overlays, staggered animations, rounded-3xl corners everywhere. You look at it and think: yeah, that looks like a modern site.

Then six months pass and you keep opening it and something feels off. Nothing is obviously wrong. But every section is quietly competing for attention. There’s no hierarchy. It’s just noise.

So I started over on the design language, rewrote most of the components, added a couple of features I’d been putting off for too long, and somewhere in the middle of all this, switched my entire dev setup from Windows to macOS. This post is the full breakdown.

The Design Shift: From “Modern SaaS” to Editorial

The old design lived in a very specific aesthetic bucket I’d call modern SaaS: shadows on cards, borders everywhere, lots of colour, hover-lift animations. It’s a style that works great for product landing pages. For a personal portfolio it ends up feeling generic.

The new direction is closer to editorial design. Think tech publication meets printed magazine. The changes sound small individually but add up fast.

No more rounded corners. I removed rounded-3xl from basically everything. Flat, sharp edges give the layout a much more intentional, structured feel.

Left accent bars instead of cards. Instead of wrapping content in bordered card boxes, list rows now have a thin vertical bar on the left that scales in from the center on hover:

<div className="absolute left-0 inset-y-0 w-0.5 bg-gray-900 dark:bg-white origin-center scale-y-0 group-hover:scale-y-100 transition-transform duration-200 rounded-sm" />

Mono eyebrow labels. Every section heading now has a small uppercase label above it. The kind of detail you don’t consciously notice, but that makes everything feel considered:

<span className="font-mono text-[9px] tracking-[0.45em] uppercase text-gray-500">
  {eyebrow}
</span>

Section number watermarks. Big faded numbers (01, 02, 03) sit behind each section. More editorial energy, less web-app energy.

Invert-fill buttons. The hover state on CTAs now runs a fill layer up from the bottom of the button rather than just swapping the background colour:

<button className="group relative inline-flex items-center gap-2 px-7 py-3.5 border border-gray-400 overflow-hidden hover:text-white transition-colors duration-300">
  <span className="absolute inset-0 bg-gray-900 translate-y-full group-hover:translate-y-0 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" />
  <span className="relative z-10">View Work</span>
</button>

Border dividers instead of card wrappers. Lists are flat rows with border-b separators. The content breathes. You can actually read it.

The whole palette simplified down to gray-900 and white in dark mode, with emerald only for “currently available” indicators.

The Navbar

Two things had been bugging me about the old navbar: it added a drop shadow on scroll, and the hamburger icon was just two static SVGs swapping in and out.

The scroll behaviour now switches to a border-b instead:

// Before
navRef.current!.classList.add("shadow", "backdrop-blur-xl", "bg-white/70");

// After
navRef.current!.classList.add(
  "border-b",
  "border-gray-200",
  "dark:border-neutral-700",
  "backdrop-blur-xl",
  "bg-white/80",
  "dark:bg-darkPrimary/90"
);

The hamburger became three motion.span lines that animate into an X:

<motion.span
  animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
  transition={{ duration: 0.2 }}
  className="block w-5 h-px bg-gray-900 dark:bg-white origin-center"
/>
<motion.span
  animate={open ? { opacity: 0, scaleX: 0 } : { opacity: 1, scaleX: 1 }}
  className="block w-5 h-px bg-gray-900 dark:bg-white"
/>
<motion.span
  animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
  className="block w-5 h-px bg-gray-900 dark:bg-white origin-center"
/>

It’s a detail that takes twenty minutes to implement and immediately makes the site feel more alive.

On desktop, nav items use layoutId="nav-underline" for a shared-element animated underline that slides between links. On mobile, the menu now has large numbered links and a “MENU” watermark sitting in the background.

The Hero Section (Built From Zero)

There wasn’t really a proper hero component before, just the first section with some text in it. I built HeroSection.tsx from scratch.

The dot grid background is a single CSS radial-gradient at 12% opacity:

<div
  style={{
    backgroundImage: "radial-gradient(circle, #6b7280 1px, transparent 1px)",
    backgroundSize: "28px 28px",
    opacity: 0.12,
  }}
/>

The “JS” watermark is my initials in massive gradient-clipped text, sitting to the right of the content area. It’s not readable, it’s just a shape:

<div
  className="absolute -right-4 top-1/2 -translate-y-1/2 font-black select-none pointer-events-none bg-gradient-to-b from-gray-200 to-gray-50 dark:from-[#232628] dark:to-darkPrimary bg-clip-text text-transparent"
  style={{ fontSize: "clamp(8rem, 24vw, 22rem)" }}
>
  JS
</div>

The profile image is grayscale at rest and transitions to full colour on hover. I genuinely love this one. It’s the kind of detail that makes a visitor do a double-take the first time they see it:

<Image
  className="grayscale hover:grayscale-0 transition-all duration-500"
  src="/profile.jpg"
  alt="Jatin Sharma"
  width={300}
  height={300}
/>

Corner cross-tick marks at the four corners of the content area give that blueprint feel without being heavy-handed about it.

Blog Cards: Stripping It All Back

This was probably the most dramatic visual change on the site.

The old blog card was tall, image-dominant, and wrapped in a rounded bordered box. It looked fine. But it was heavy. Reading the blog index felt like scrolling through a gallery.

// Before: big card with image and rounded corners
<motion.article className="group bg-white dark:bg-darkSecondary rounded-3xl overflow-hidden border-2 border-gray-100">
  <div className="grid md:grid-cols-2 gap-6 p-6">
    {/* image + content */}
  </div>
</motion.article>

// After: flat row with accent bar
<motion.article className="group relative border-b border-gray-300 dark:border-neutral-700 last:border-0">
  <div className="absolute left-0 inset-y-0 w-0.5 bg-gray-900 dark:bg-white origin-center scale-y-0 group-hover:scale-y-100 transition-transform duration-200" />
  <Link className="flex items-center gap-4 py-6 pl-4 pr-2">
    {/* index + title + arrow */}
  </Link>
</motion.article>

No images. No author avatar. No “Read more” button. Just the content. The result is a blog index you can actually scan. You can read ten titles in the time it used to take to read three.

Skills Section: Marquee and Denser Grid

The skill cards went from tall centered icon boxes to compact horizontal rows in a grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 grid.

The new addition I’m happiest about is the marquee ticker, a continuous horizontal scroll of all skills sitting between the section header and filter buttons:

<div className="flex animate-marquee gap-10 w-max">
  {[...skills, ...skills].map((skill, i) => {
    const Icon = skill.Icon;
    return (
      <div key={i} className="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 select-none">
        <Icon className="w-3.5 h-3.5 flex-shrink-0" />
        <span className="text-[10px] font-mono uppercase tracking-[0.25em] whitespace-nowrap">
          {skill.name}
        </span>
      </div>
    );
  })}
</div>

The trick is duplicating the array so the scroll loops seamlessly. No library needed, just a CSS @keyframes translate.

The filter also now shows a count next to each category name, and switching categories triggers a full stagger re-entry on the grid rather than trying to animate individual items in and out.

Table of Contents: Panel to Drawer

The old TOC was a fixed left panel on desktop. It worked, but it was always there, always occupying space, always creating layout tension with the article content.

The new version is a FAB button that opens a left-side drawer:

{/* FAB */}
<motion.button
  onClick={() => setOpen((o) => !o)}
  className="fixed bottom-6 left-6 z-40 flex items-center gap-2 px-3 h-9 bg-gray-900 dark:bg-white text-white dark:text-gray-900 font-mono text-[10px] tracking-[0.35em] uppercase"
>
  <BsListUl className="w-3.5 h-3.5" />
  <span className="hidden sm:inline">Contents</span>
</motion.button>

{/* Drawer */}
<motion.aside
  initial={{ x: "-100%" }}
  animate={{ x: 0 }}
  exit={{ x: "-100%" }}
  transition={{ type: "spring", stiffness: 340, damping: 32 }}
  className="fixed top-0 left-0 bottom-0 w-full sm:w-80 bg-white dark:bg-darkPrimary border-r border-gray-200 dark:border-neutral-700 flex flex-col"
>

Removing the fixed panel also let me drop four dependencies I didn’t need anymore: useScrollPercentage, useWindowSize, lockScroll, and removeScrollLock. The drawer just uses a backdrop click to close.

The Books Page

This is the biggest entirely new feature.

I’ve been tracking my reading on Hardcover and wanted to surface that data on the portfolio. Hardcover has a GraphQL API so I built a small client:

async function hardcoverQuery<T>(
  query: string,
  variables?: Record<string, unknown>,
): Promise<T> {
  const res = await fetch("https://api.hardcover.app/v1/graphql", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      authorization: `Bearer ${process.env.HARDCOVER_API_KEY}`,
    },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();
  return json.data as T;
}

The API route caches for 24 hours with stale-while-revalidate so it doesn’t hammer the endpoint:

res.setHeader(
  "Cache-Control",
  "public, s-maxage=86400, stale-while-revalidate=43200"
);

The page has reading stats, a year-goal progress bar that animates in on scroll, a tabbed shelf for the three reading statuses, and a debounced search across title and author. It’s the kind of page that only makes sense on a personal portfolio.

siteConfig.ts: One Source of Truth

My name, email, job title, section copy, and social links were scattered across maybe a dozen different files. When I needed to update something, even just my job title, I had to grep for it and hope I caught every instance.

Now there’s a single content/siteConfig.ts:

const siteConfig = {
  person: {
    name: "Jatin Sharma",
    email: "work.j471n@gmail.com",
    location: "Based in India",
  },
  home: {
    hero: {
      rolePrefix: "Tech Lead at",
      companyName: "KonnectNXT",
      primaryCta: { label: "Download Resume", url: "https://bit.ly/j471nCV" },
    },
  },
} as const;

socialMedia.ts and user.ts both derive from siteConfig now. One change propagates everywhere. It’s the kind of refactor you keep putting off until you finally do it and immediately wonder why you waited.

Syntax Highlighting: Light and Dark

Previously everything used one-dark-pro regardless of colour scheme. Now code blocks adapt properly:

// Before
[rehypePrettyCode, { theme: "one-dark-pro" }]

// After
[rehypePrettyCode, {
  theme: {
    dark: "one-dark-pro",
    light: "github-light",
  },
}]

For MDX local content I went with andromeeda (dark) and catppuccin-latte (light), a slightly warmer combination.

The CodeTitle component also got redesigned. The old bordered box is now a top accent line and a clean mono label:

// Before
<div className="bg-white rounded-tl-md rounded-tr-md p-3 border border-black">

// After
<div className="!mt-4 mb-[14px]">
  <div className="h-0.5 w-full bg-gray-900 dark:bg-white" />
  <div className="bg-white dark:bg-darkSecondary border border-b-0 border-gray-200 dark:border-neutral-700 px-4 py-2 flex items-center gap-2 font-mono overflow-x-auto">
    <Icon className="w-3.5 h-3.5 text-gray-400" />
    <span className="text-[10px] tracking-[0.35em] uppercase text-gray-600 dark:text-gray-400">
      {title || lang}
    </span>
  </div>
</div>

The Uses Page: Full Windows to macOS Migration

I switched from Windows to macOS this year and my /uses page was so out of date it was almost embarrassing.

Gone: Windows 11, Edge, Sublime Text, ShareX, Ditto, 7-Zip, Flameshot, Notepad++, Google Keep, Microsoft Todo.

In: macOS, Homebrew, Raycast (the thing I miss most when I’m on any other machine), Rectangle, iTerm2, Warp, Oh My Zsh, Arc as my primary browser, CleanShot X, Obsidian.

The whole category structure got reworked too: System and OS, Terminal and CLI, Development, Design and Creativity, Productivity, Browsers, Communication.

The Smaller Stuff

A reusable PageHeader component: the watermark + eyebrow + title + description pattern was copy-pasted into every page. Extracted it once, imported it everywhere.

fallback: "blocking" on the blog: changed from fallback: false so newly published posts go live immediately without requiring a full rebuild.

BlogLayout cleanup: removed the Newsletter section, share buttons, and bookmark feature from individual post pages. Just the article now.

Animation cleanup: replaced dozens of imported FramerMotionVariants with simple inline transitions. Less code, same feel, easier to read:

// Before: importing complex variants from a separate file
import { FadeContainer, popUp } from "@content/FramerMotionVariants";

// After: simple inline
<motion.div
  initial={{ opacity: 0, y: 12 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ type: "spring", stiffness: 300, damping: 24 }}
>

What’s Next

The Projects page still needs the same treatment the blog got. I’ve already plumbed in the featured prop on Project.tsx, just haven’t gotten there yet. There’s also a /stats page refresh on the list.

But the core is done and it finally feels like mine.

All the code is open source at github.com/j471n/j471n.in. If you want to lift any of the patterns, the watermark technique, the invert-fill buttons, the marquee, the accent bars, go for it.

FlutterとSupabaseでNotion風ノートコメント機能を実装した話

FlutterとSupabaseでNotion風ノートコメント機能を実装した話

はじめに

自分株式会社(https://my-web-app-b67f4.web.app/)はFlutter Web + Supabaseで構築したAI統合ライフマネジメントアプリです。Notionの機能ギャップを埋めるべく、今回「コメント機能」を実装しました。

Notionではページに対してコメントを残す機能が標準搭載されています。自分のノートに「後で確認」「ここ重要」「アイデアメモ」といった付箋代わりのコメントを追加できるあの機能です。

実装内容

1. DBスキーマ(マイグレーション)

CREATE TABLE IF NOT EXISTS note_comments (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  note_id    bigint NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
  user_id    uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  content    text NOT NULL CHECK (length(trim(content)) > 0),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

ALTER TABLE note_comments ENABLE ROW LEVEL SECURITY;

-- RLS: ユーザーは自分のコメントのみ操作可能
CREATE POLICY "Users can view own note comments"
  ON note_comments FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "Users can insert own note comments"
  ON note_comments FOR INSERT WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can update own note comments"
  ON note_comments FOR UPDATE
  USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can delete own note comments"
  ON note_comments FOR DELETE USING (auth.uid() = user_id);

CREATE INDEX IF NOT EXISTS note_comments_note_id_created_at_idx
  ON note_comments (note_id, created_at ASC);

ポイントは RLS(Row Level Security)。SupabaseのRLSを使うと、SQLレイヤーで「自分のデータしか見えない」を強制できます。フロントエンドでのフィルタリングに頼らず、DBレベルで保護されるのが安心です。

2. Supabase Edge Function (Deno)

// note-comments/index.ts
serve(async (req) => {
  const userId = getUserIdFromJwt(req);
  if (!userId) return json({ error: "unauthorized" }, 401);

  if (req.method === "GET") {
    // note_id でコメント一覧取得(ノート所有確認付き)
    const { data } = await client
      .from("note_comments")
      .select("id, content, created_at, updated_at")
      .eq("note_id", noteId)
      .eq("user_id", userId)
      .order("created_at", { ascending: true });
    return json({ comments: data ?? [] });
  }

  if (req.method === "POST") {
    // コメント追加(内容2000字制限)
    const { data } = await client
      .from("note_comments")
      .insert({ note_id: noteId, user_id: userId, content })
      .select("id, content, created_at, updated_at")
      .single();
    return json({ ok: true, comment: data });
  }

  if (req.method === "DELETE") {
    // コメント削除(自分のコメントのみ)
    await client.from("note_comments")
      .delete()
      .eq("id", commentId)
      .eq("user_id", userId);
    return json({ ok: true });
  }
});

Edge Functionではサービスロールキーを使いつつ、JWTからuser_idを取り出してすべての操作に .eq("user_id", userId) を付与しています。二重の安全策です。

3. Flutter UI

// NoteEditorPage の AppBar に追加
if (_currentNoteId != null)
  Stack(
    alignment: Alignment.topRight,
    children: [
      IconButton(
        icon: const Icon(Icons.comment_outlined),
        onPressed: _showComments,
        tooltip: 'コメント',
      ),
      if (_commentCount > 0)
        Positioned(
          top: 6, right: 6,
          child: Container(
            // バッジ表示
            child: Text('$_commentCount', ...),
          ),
        ),
    ],
  ),

コメント数をバッジ表示することで、ノートにコメントがあることを一目で把握できます。

BottomSheetでコメント一覧と入力フィールドを表示し、DraggableScrollableSheetで高さ調整可能にしています。

詰まったポイント

withOpacitywithValues への移行

Flutter 3.x では withOpacity が deprecated になっており、withValues(alpha: x) を使う必要があります。flutter analyze で検出して修正しました。

// ❌ deprecated
color: Colors.indigo.withOpacity(0.05),

// ✅ 正しい
color: Colors.indigo.withValues(alpha: 0.05),

unawaited の活用

バックグラウンドでコメント数をロードする際、unawaited() を使って非同期処理を明示的に「待たない」ことを示します。

// dart:async の unawaited で fire-and-forget
unawaited(_loadCommentCount());

まとめ

  • PostgreSQL RLS + Supabase で安全なマルチテナントデータ管理
  • Edge Function でノート所有権チェックを二重実装
  • Flutter の DraggableScrollableSheet でスムーズなBottomSheet UI
  • flutter analyze 0件 を維持しながら実装完了

次は チームワークスペース(リアルタイム共同編集)の基盤整備に取り組みます。

URL: https://my-web-app-b67f4.web.app/

FlutterWeb #Supabase #buildinpublic #Dart

Building a Local AI Assistant on Linux — Recent Progress on Echo

Building a Local AI Assistant on Linux — Recent Progress on Echo

Last week, I made significant strides in building my local AI assistant, Echo, on my Ubuntu machine. This article covers the recent updates, including how I refined my AI’s content strategy, improved my trading bots, and enhanced the session checkpoint system.

2026-04-01 — Publisher Wired to Content Strategy

I’ve been working on making my content more dynamic and relevant by integrating it with a content strategy file. Here’s how I did it:

# echo_devto_publisher.py
import json
import os

def read_content_strategy():
    with open('content_strategy.json', 'r') as f:
        return json.load(f)

def update_publisher():
    strategy = read_content_strategy()
    if strategy.get('next'):
        next_topic = strategy['next']
        # Use next_topic to set content for the publisher
    elif strategy.get('queued'):
        queued_topic = strategy['queued'][0]
        # Use queued_topic to set content for the publisher
    else:
        # Use generic content for the publisher
        pass

update_publisher()

After making this change, I reset my content queue to ensure all my topics are ready to publish. I also deleted any generic articles from March 31 to keep my feed fresh. Next Tuesday, I’ll be sharing how I built a two-way phone bridge for my AI using ntfy.sh.

2026-04-01 — Trade Brain v2

I’ve been working on my trading bots, specifically the Trade Brain, which has seen a few updates. Here are the key changes:

  • Increased Position Sizing: I’ve increased the position size to 10% per trend trade and 8% for momentum trades, with a maximum of 8 positions.
  • Added Trailing Stop: This feature protects gains after a 2% upward movement.
  • Sector Awareness: The bot now prevents over-concentration in the same sector.
  • Updated Watchlists: I’ve added XOM (energy), IWM (small cap), RKLB, and IONQ to the watchlist.
  • Fixed Take Profit: For trend trades, the take profit is set to 5%, and for momentum trades, it’s 3%.

The first v2 cycle saw the entry of XOM (energy trend) and RKLB (momentum).

2026-04-01 — Crypto Brain Live

I’ve also made progress on my Crypto Brain, a 24/7 trading bot for cryptocurrencies. Here are the details:

# core/crypto_brain.py
import alpaca_trade_api as tradeapi
import pandas as pd
import talib

API_KEY = 'your_api_key'
API_SECRET = 'your_api_secret'
BASE_URL = 'https://paper-api.alpaca.markets'

api = tradeapi.REST(API_KEY, API_SECRET, BASE_URL, api_version='v2')

def get_cryptos_data():
    assets = ['BTC/USD', 'ETH/USD', 'SOL/USD', 'AVAX/USD']
    dfs = [pd.DataFrame(api.get_bars(asset, '1H', limit=720).df) for asset in assets]
    return pd.concat(dfs, keys=assets)

def crypto_strategy(df):
    indicators = talib.RSI(df['close'], timeperiod=14)
    mean_reversion = (indicators < 30) & (df['close'].pct_change() > 0.04)
    momentum = (df['close'].pct_change() > 0.06)
    return mean_reversion & momentum

data = get_cryptos_data()
trades = data[data.apply(crypto_strategy, axis=1)]
print(trades)

This bot uses the RSI mean reversion strategy combined with a 6-hour momentum check. The take profit is set to 4%, and the stop loss is 2%. The first scan revealed that all coins were in the oversold RSI range (31-33).

2026-04-02 — Session Checkpoint Upgraded

To ensure a smooth session summary, I upgraded the session checkpoint system:

# session_checkpoint.py
import re

def collect_session_focus():
    headers = []
    with open('session_summary.json', 'r') as f:
        session_summary = json.load(f)
        if 'override_focus' in session_summary:
            return session_summary['override_focus']
        headers = [line.strip() for line in f.readlines() if re.match(r'^##s', line)]
        return ' + '.join(headers[:4])

focus = collect_session_focus()
print(focus)

This script now collects all ## headers from the session summary and filters out noise. The focus is then joined into a natural-sounding briefing by the LLM, which speaks the joined focus at 8am.

2026-04-02 — Briefing Fixed — Direct Ollama Call

Finally, I fixed the daily briefing by calling Ollama directly via HTTP:


python
# daily_briefing.py
import requests

def call_ollama():
    url = 'https://ollama.com/api/v1/brief'
    headers = {'Content-Type': 'application/json'}
    data = {

How I Built a Lightning-Fast Face Recognition Batch Processor using Python & Docker

Introduction

Have you ever tried to find a specific person in a folder containing thousands of event photos? Whether it’s a wedding, a graduation, or a corporate event, photographers spend hours manually sifting through galleries to deliver personalized photo sets to their clients.

I wanted to automate this, but I quickly ran into a wall: performing deep learning face recognition on thousands of high-resolution images is computationally expensive and memory-hungry.

So, I built Py_Faces, a batch face recognition system that solves this by separating the heavy lifting from the actual search process. Here is how I designed the architecture to search through thousands of photos in seconds, and how I tamed Docker memory limits along the way.

The Architecture: Calculate Once, Search Instantly
The biggest mistake I could have made was scanning the entire photo folder every time the user wanted to search for a new person. Instead, I split the system into three completely independent steps:

  • Step 1: The Heavy Lifting (Encoding Extraction)
    The first script (escaner_encodings.py) scans every photo in the batch just once. It detects the faces, applies a CLAHE filter (Contrast Limited Adaptive Histogram Equalization) to handle bad lighting, and extracts a 128-dimension facial encoding vector using the face_recognition library.

These vectors—along with metadata and file paths—are saved into a binary .pkl file. This process can take around an hour depending on the CPU, but it’s a one-time cost.

  • Step 2: Defining the Target
    When the user wants to find someone, they drop a few clear photos of that person into a persona_objetivo folder. The second script (definir_objetivo.py) extracts the encodings from these reference photos and averages them out to create a highly accurate “Target Profile”.

  • Step 3: The Lightning-Fast Search
    Here is where the magic happens. The third script (buscador_objetivo.py) doesn’t look at images at all. It simply loads the massive .pkl file from Step 1 and uses NumPy to calculate the Euclidean distance between the “Target Profile” and every face in the batch.

Because it’s just comparing arrays of numbers, searching through thousands of photos takes about 2 seconds. The script then automatically copies the matching photos into a new folder and generates a detailed Excel report using pandas.

Taming the Docker & Memory Beast
To make this tool accessible, I wrapped it in a Docker container (python:3.11-slim). This avoids the nightmare of making users install C++ build tools, CMake, and dlib natively on Windows.

However, this introduced a massive challenge: Memory Management.
Docker Desktop on Windows (WSL2) limits memory usage. Processing high-res images with HOG or CNN models in parallel quickly leads to BrokenExecutor crashes because the container runs out of RAM.

To fix this, I implemented a dynamic worker calculation function that checks the actual available RAM inside the Linux container (/proc/meminfo) before launching the ProcessPoolExecutor:

`def calcular_workers():
    """Estimates safe workers based on free memory in the container."""
    import os
    memoria_por_worker_gb = 1.2  # Estimated RAM per parallel process
    try:
        if os.path.exists('/proc/meminfo'):
            with open('/proc/meminfo') as f:
                for linea in f:
                    if linea.startswith('MemAvailable:'):
                        mem_kb = int(linea.split()[1])
                        mem_gb = mem_kb / (1024 * 1024)
                        # Reserve at least 0.8 GB for the OS and main orchestrator
                        mem_disponible_gb = max(0.5, mem_gb - 0.8)
                        workers_por_ram = int(mem_disponible_gb / memoria_por_worker_gb)
                        return max(1, min(workers_por_ram, os.cpu_count() or 4))
    except Exception:
        pass

    # Safe fallback
    cpus = os.cpu_count() or 2
    return max(1, int(cpus / 2))`

This ensures the script scales perfectly. If you run it on a 16GB machine, it maximizes the workers; if you run it on a constrained Docker environment, it dials it back to prevent crashes. Furthermore, images are resized (e.g., max 1800px or 2400px width) before processing to keep memory spikes in check.

Dealing with Real-World Dirty Data
When dealing with raw client photos, you learn quickly that data is never clean. I had to implement several fallbacks:

EXIF Orientations: Photos taken vertically often appear horizontal to dlib. I wrote a utility using Pillow (PIL) to read EXIF tags and physically rotate the arrays before detection.

Sequential Retries: If the multiprocessing pool does crash, the script catches the BrokenExecutor error, rescues the failed batch, and processes them sequentially so the user doesn’t lose an hour of progress.

Conclusion
Building Py_Faces taught me that sometimes the best way to optimize a slow process isn’t to write faster algorithms, but to change the architecture entirely. By decoupling the extraction from the comparison, a heavy machine-learning task became an instant search tool.

You can check out the full code on my GitHub: https://github.com/daws-4/pyfaces

Have you ever dealt with memory leaks or dlib crashes in Docker? I’d love to hear how you solved them in the comments!

How To Improve UX In Legacy Systems

Imagine that you need to improve the UX of a legacy system. A system that has been silently working in the background for almost a decade. It’s slow, half-broken, unreliable, and severely outdated — a sort of “black box” that everyone relies upon, but nobody really knows what’s happening under the hood.

Where would you even start? Legacy stories are often daunting, adventurous, and utterly confusing. They represent a mixture of fast-paced decisions, quick fixes, and accumulating UX debt.

There is no one-fits-all solution to tackle them, but there are ways to make progress, albeit slowly, while respecting the needs and concerns of users and stakeholders. Now, let’s see how we can do just that.

The Actual Challenges Of Legacy UX

It might feel that legacy products are waiting to be deprecated at any moment. But in reality, they are often critical for daily operations. Many legacy systems are heavily customized for the needs of the organization, often built externally by a supplier and often without rigorous usability testing.

It’s common for enterprises to spend 40–60% of their time managing, maintaining, and fine-tuning legacy systems. They are essential, critical — but also very expensive to keep alive.

1. Legacy Must Co-Exist With Products Built Around Them

Running in a broken, decade-old ecosystem, legacy still works, yet nobody knows exactly how and why it still does. People who have set it up originally probably have left the company years ago, leaving a lot of unknowns and poorly documented work behind.

With them come fragmented and inconsistent design choices, stuck in old versions of old design tools that have long been discontinued.

Still, legacy systems must neatly co-exist within modern digital products built around them. In many ways, the end result resembles a Frankenstein — many bits and pieces glued together, often a mixture of modern UIs and painfully slow and barely usable fragments here and there — especially when it comes to validation, error messages, or processing data.

2. Legacy Systems Make or Break UX

Once you sprinkle a little bit of quick bugfixing, unresolved business logic issues, and unresponsive layouts, you have a truly frustrating experience, despite the enormous effort put into the rest of the application.

If one single step in a complex user flow feels utterly broken and confusing, then the entire product appears to be broken as well, despite the incredible efforts the design teams have put together in the rest of the product.

Well, eventually, you’ll have to tackle legacy. And that’s where we need to consider available options for your UX roadmap.

UX Roadmap For Tackling Legacy Projects

Don’t Dismiss Legacy: Build on Existing Knowledge

Because legacy systems are often big unknowns that cause a lot of frustration to everyone, from stakeholders to designers to engineers to users. The initial thought might be to remove it entirely and redesign it from scratch, but in practice, that’s not always feasible. Big-bang-redesign is a remarkably expensive and very time-consuming endeavor.

Legacy systems hold valuable knowledge about the business practice, and they do work — and a new system must perfectly match years of knowledge and customization done behind the scenes. That’s why stakeholders and users (in B2B) are typically heavily attached to legacy systems, despite all their well-known drawbacks and pains.

To most people, because such systems are at the very heart of the business, operating on them seems to be extremely risky and will require a significant amount of caution and preparation. Corporate users don’t want big risks. So instead of dismissing legacy entirely, we might start by gathering existing knowledge first.

Map Existing Workflows and Dependencies

The best place to start is to understand how and where exactly legacy systems are in use. You might discover that some bits of the legacy systems are used all over the place — not only in your product, but also in business dashboards, by external agencies, and by other companies that integrate your product into their services.

Very often, legacy systems have dependencies on their own, integrating other legacy systems that might be much older and in a much worse state. Chances are high that you might not even consider them in the big-bang redesign — mostly because you don’t know just how many black boxes are in there.

Set up a board to document current workflows and dependencies to get a better idea of how everything works together. Include stakeholders, and involve heavy users in the conversation. You won’t be able to open the black box, but you can still shed some light on it from the perspectives of different people who may be relying on legacy for their work.

Once you’ve done that, set up a meeting to reflect to users and stakeholders what you have discovered. You will need to build confidence and trust that you aren’t missing anything important, and you need to visualize the dependencies that a legacy tool has to everyone involved.

Replacing a legacy system is never about legacy alone. It’s about the dependencies and workflows that rely on it, too.

Choose Your UX Migration Strategy

Once you have a big picture in front of you, you need to decide on what to do next. Big-bang relaunch or a small upgrade? Which approach would work best? You might consider the following options before you decide on how to proceed:

  • Big-bang relaunch.
    Sometimes the only available option, but it’s very risky, expensive, and can take years, without any improvements to the existing setup in the meantime.
  • Incremental migration.
    Slowly retire pieces of legacy by replacing small bits with new designs. This offers quicker wins in a Frankenstein style but can make the system unstable.
  • Parallel migration.
    Run a public beta of the replacement alongside the legacy system to involve users in shaping the new design. Retire the old system when the new one is stable, but be prepared for the cost of maintaining both.
  • Incremental parallel migration.
    List all business requirements the legacy system fulfills, then build a new product to meet them reliably, matching the old system from day one. Test early with power users, possibly offering an option to switch systems until the old one is fully retired.
  • Legacy UI upgrade + public beta.
    Perform low-risk fine-tuning on the legacy system to align UX, while incrementally building a new system with a public beta. This yields quicker and long-term wins, ideal for fast results.

Replacing a system that has been carefully refined and heavily customized for a decade is a monolithic task. You can’t just rebuild something from scratch within a few weeks that others have been working on for years.

So whenever possible, try to increment gradually, involving users and stakeholders and engineers along the way — and with enough buffer time and continuous feedback loops.

Wrapping Up

With legacy projects, failure is often not an option. You’re migrating not just components, but users and workflows. Because you operate on the very heart of the business, expect a lot of attention, skepticism, doubts, fears, and concerns. So build strong relationships with key stakeholders and key users and share ownership with them. You will need their support and their buy-in to bring your UX work in action.

Stakeholders will request old and new features. They will focus on edge cases, exceptions, and tiny tasks. They will question your decisions. They will send mixed signals and change their opinions. And they will expect the new system to run flawlessly from day one.

And the best thing you can do is to work with them throughout the entire design process, right from the very beginning. Run a successful pilot project to build trust. Report your progress repeatedly. And account for intense phases of rigorous testing with legacy users.

Revamping a legacy system is a tough challenge. But there is rarely any project that can have so much impact on such a scale. Roll up your sleeves and get through it successfully, and your team will be remembered, respected, and rewarded for years to come.

Meet “Measure UX & Design Impact”

Meet Measure UX & Design Impact, Vitaly’s practical guide for designers and UX leads on how to track and visualize the incredible impact of your UX work on business — with a live UX training later this year. Jump to details.


Meet Measure UX and Design Impact, a practical video course for designers and UX leads.

  • Video + UX Training
  • Video only

Video + UX Training

$ 495.00 $ 799.00

Get Video + UX Training

25 video lessons (8h) + Live UX Training.
100 days money-back-guarantee.

Video only

$ 250.00$ 350.00

Get the video course

25 video lessons (8h). Updated yearly.
Also available as a UX Bundle with 3 video courses.

Useful Resources

  • UX Migration Strategy For Legacy Apps, by Tamara Chehayeb Makarem
  • How To Improve Legacy Systems, by Christopher Wong
  • Designing With Legacy, by Peter Zalman
  • Redesigning A Large Legacy System, by Pawel Halicki
  • How To Manage Legacy Code, by Nicolas Carlo
  • How To Transform Legacy, by Bansi Mehta
  • Design Debt 101, by Alicja Suska
  • Practical Guide To Enterprise UX, by Yours Truly
  • Healthcare UX Design Playbook, by Yours Truly

Citation Needed: Structured data extraction workflows

In the previous article we explored how to generate and use structured data in a workflow. Now, let’s take it a step further.

We’ll build a workflow that checks whether an article provides evidence to support its claims (but not whether the evidence itself is valid). Rather than using this to fact check articles in the wild, this might be useful for critiquing your own writing before submission or checking generated text for hallucinations.

This task is impractical to automate without generative language models. Natural language processing pipelines might be able to extract or categorize entities and phrases from a text, but this task requires a degree of reading comprehension not available without larger language models.

Furthermore, while many language models are capable of performing individual steps, the overall process requires more rigor and discipline than they are trained for. Frontier models might handle moderately complex tasks, but verifying that they haven’t hallucinated the results requires additional work on par with this workflow.

What we can do instead is split the task into distinct steps: extracting claims then checking each of them. In this article we’ll look into the first part using our old friend the LLM › Structured node.

Claims Schema

In the Structured Generation tutorial we saw how to generate a single structured entry from scratch. LLMs are capable of handling much more complexity. This time we will ask the model to determine which phrases in a text are factual claims and place them into a list. Furthermore, we ask the model to rank the importance of each claim, holistically, when deciding whether to include it.

structured Like before, create a new workflow and swap out the normal Chat for a Structured node.

Create a Parse JSON node and connect it to the schema input of the Structured node. Fill it with this schema conveniently generated by an LLM:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "ClaimsList",
  "type": "object",
  "properties": {
    "claims": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "minItems": 1,
      "maxItems": 5,
      "description": "A list of claim strings. The list must contain at least one and at most five items."
    }
  },
  "required": [
    "claims"
  ],
  "additionalProperties": false
}

📢 important
Technically, an array at the top-level would be a valid schema.

However, many models have trouble generating data with that format. To ensure compatibility between providers, wrap the array in an object. Then extract the list later using JSON transformations.

Instructions

In the previous example we combined instructions with dynamic data into the prompt. This time we’ll reserve the system message for instructions and inject the data in a separate step.

instructions

By partitioning the instructions and the data it becomes much easier to reuse the workflow on new inputs. We can use the system message field of the Agent node for instructions:

Follow these instructions exactly.
Do not respond directly to the user.
Do not hallucinate the final answer.

## Instructions

Extract the key factual claims in the user's statement and format them into a list (5 items or fewer).
Ensure that each claim can stand alone without additional context to make sense of it.

💡 tip
You should experiment with variations on the instructions, particularly the preamble to optimize it for your preferred language model. I find this combination effective with the nemotron family and various other open models.

The system message is sent once at the beginning of each request. Theoretically, the LLM should pay special attention to it. Regardless, this avoids sending repeat instructions with every prompt of a conversation, even when the entire conversation is sent with every request. 1

Input Document

The input document for a workflow will typically be supplied by the runner. While developing a workflow, however, it’s convenient to create a node for a predefined text to take advantage of iterative execution. In the final version of the workflow we can delete this node and connect to the input of the Start node.

input doc

Create a Value › Plain Text node to hold the article content.

Connect it to the prompt input of the Structured node.

Paste the contents of an article into the text field. I’m using a Wikipedia article about apiaries (artificial beehives).

Claim Checking

We now have a workflow that generates a list of claims from a text. Our eventual goal is to have each claim checked individually against the original text, which will be supplied to the language model in a context document.

However, before learning how to check every item, we should first explore how to check a single item.

list indexing

First, let’s pull a single claim out of the structured generation using JSON › Transform JSON. This node uses a jq filter to manipulate JSON.

The filter .claims[1] tells it to access the “claims” field and return the second element (0-indexed).

💡 tip
Ask your favorite frontier LLM for help writing jq filters from sample data.

Add a second Agent node with these instructions:

Follow these instructions exactly.
Do not respond directly to the user.
Do not hallucinate the final answer.

## Instructions

Help the user analyze the article in the context file.
The user is examining individual claims that the article makes.

Determine whether the context provides supporting evidence for the claim stated by the user.
List the reference or citation provided by the article.

DO NOT interpret the article as evidence for a claim made by the user.
The user is simply examining a claim made by the article.

context documents

How can we provide the article as context for the LLM? There are several ways:

  • Inject it into the system message using templating
  • Provide it as a user message in the conversation
  • Use a LLM › Context node

The third option is cleanest since it provides a clear demarcation between instructions, context and prompt. The Context node sits between the agent and a chat node, augmenting the agent by injecting its contents into requests made by the agent.

Connect the Plain Text node containing the article to the context input. In the final version of the workflow, this should be connected to the input pin of the Start node.

unstructured check

We can use a simple Chat node to do a quick spot check on how the context affects the language model response. However, to facilitate checking the entire collection, the responses for each item should be structured.

Structured Check

Replace the Chat node with a Structured node, connecting it to the Context and Transform nodes.

Use this schema for the claims checking Structured node:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "description": "A factual claim with evidence from citations or references",
  "type": "object",
  "required": [
    "claim",
    "grounding"
  ],
  "properties": {
    "claim": {
      "type": "string",
      "description": "the original claim made by the article"
    },
    "grounding": {
      "enum": [
        "not a claim",
        "unsupported",
        "fully supported"
      ],
      "description": "The level of support for the claim provided by citations and references. If the provided text is actually a definition or something other than a claim, then "not a claim""
    },
    "evidence": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "The citations and references that support the claim. Empty if the claim is not supported."
    }
  }
}

structured check

Connect the unwrapped claim to the prompt and run.

By changing the claim index we can see how it handles different claims and statements.

Conclusion

In this tutorial we’ve explored using language models to extract structured data from plain text, then transforming data for further processing. The workflow is still incomplete since we’ve only checked one claim.

Before we can go any further, we’ll need to learn about iterating over lists using subgraphs. This will allow us to check every claim individually, then draw a conclusion by combining all results.

  1. Some LLM providers support caching portions of the request. However, since this behavior isn’t standardized across providers yet, aerie does not support it. ↩

How (Not) to Learn Python

While listening to Mark Smith’s inspirational talk for Python Unplugged on PyTV about How to Learn Python, what caught my attention was that Mark suggested turning off some of PyCharm’s AI features to help you learn Python more effectively.

As a PyCharm user myself, I’ve found the AI-powered features beneficial in my day-to-day work; however, I never considered that I could turn certain features on or off to customize my experience. This can be done from the settings menu under Editor | General | Code Completion | Inline.

While we are at it, let’s have a look at these features and investigate in more detail why they are great for professional developers but may not be ideal for learners.

Local full line code completion suggestions

JetBrains AI credits are not consumed when you use local line completion. The completion prediction is performed using a built-in local deep learning model. To use this feature, make sure the box for Enable inline completion using language models is checked, and choose either Local or Cloud and local in the options. To show the complete results using the local model alone, we will look at the predictions when only Local is selected.

When it’s selected, you see that the only code completion available out of the box in PyCharm is for Python. To make suggestions available for CSS or HTML, you need to download additional models.

When you are writing code, you will see suggestions pop up in grey with a hint for you to use Tab to complete the line. 

After completing that line, you can press Enter to go to the next one, where there may be a new suggestion that you can again use Tab to complete. As you see, this can be very convenient for developers in their daily coding, as it saves time that would otherwise be spent typing obvious lines of code that follow the flow naturally. 

However, for beginners, mindlessly hitting Tab and letting the model complete lines may discourage them from learning how to use the functions correctly. An alternative is to use the hint provided by PyCharm to help you choose an appropriate method from the available list, determine which parameters are needed, check the documentation if necessary, and write the code yourself. Here is what the hint looks like when code completion is turned off:

Cloud-based completion suggestions

Let’s have a look at cloud-based completion in contrast to local completion. When using cloud-based completion, next-edit suggestions are also available (which we will look at in more detail in the next section).

Cloud-based completion comes with support for multiple languages by default, and you can switch it on or off for each language individually.

Cloud-based completion provides more functionality than local model completion, but you need a JetBrains AI subscription to use it.

You may also connect to a third-party AI provider for your cloud-based completion. Since this support is still in Beta in PyCharm 2026.1, it is highly recommended to keep your JetBrains AI subscription active as a backup to ensure all features are available.

After switching to cloud-based completion, one of the differences I noticed was that it is better at multiple-line completion, which can be more convenient. However, I have also encountered situations where the completion provided too much for me, and I had to jump in to make my own modifications after accepting the suggestions.

For learners of Python, again, you may want to disable this functionality or have to audit all the suggestions in detail yourself. In addition to the danger of relying too heavily on code completion, which removes opportunities to learn, cloud code completion poses another risk for learners. Because larger suggestions require active review from the developer, learners may not be equipped to fully audit the wholesale suggestions they are accepting. Disabling this feature for learners not only encourages learning, but it can also help prevent mistakes.

Next edit suggestions

In addition to cloud-based completion, JetBrains AI Pro, Ultimate, and Enterprise users are able to take advantage of next edit suggestions.

When they are enabled, every time you make changes to your code, for example, renaming a variable, you will be given suggestions about other places that need to be changed.

And when you press Tab, the changes will be made automatically. You can also customize this behavior so you can see previews of the changes and jump continuously to the next edit until no more are suggested.

This is, no doubt, a very handy feature. It can help you avoid some careless mistakes, like forgetting to refactor your code when you make changes. However, for learners, thinking about what needs to be done is a valuable thought exercise, and using this feature can deprive them of some good learning opportunities.

Conclusion

PyCharm offers a lot of useful features to smooth out your day-to-day development workflow. However, these features may be too powerful, and even too convenient, for those who have just started working with Python and need to learn by making mistakes. It is good to use AI features to improve our work, but we also need to double-check the results and make sure that we want what the AI suggests.

To learn more about how to level up your Python skills, I highly recommend watching Mark’s talk on PyTV and checking out all the AI features that JetBrains AI has to offer. I hope you will find the perfect way to integrate them into your work while remaining ready to turn them off when you plan to learn something new.

How to Build a Multiplayer Lobby in Unity with Socket.IO

Building a multiplayer lobby is where most Unity real-time projects fall apart.

Not the networking part — that’s the easy bit. The hard part is everything around it: players joining and leaving mid-session, the host disconnecting and taking the room with them, a player’s phone screen locking for 30 seconds and losing their slot forever. Most tutorials stop at “connected”. This one doesn’t.

This is a complete walkthrough of building a production-ready lobby in Unity using socketio-unity — room creation, join by code, ready states, host migration, and reconnect with a grace window. Every line of code is taken from the real Samples~/Lobby/ sample in the repo, so you can run it locally and pull it apart.

Prerequisites

  • Unity 2020.1+
  • Node.js (for the test server)
  • socketio-unity installed via Window → Package Manager → Add package from git URL:
https://github.com/Magithar/socketio-unity.git

Architecture Overview

Before writing a line of code, the architecture matters. A lobby has three distinct concerns — networking, state, and UI — and mixing them is how you end up with spaghetti that breaks every time the server sends an unexpected event.

The sample uses a clean three-layer split:

LobbyNetworkManager   ← Transport: connects, emits, receives
        ↓ writes
LobbyStateStore       ← State: single source of truth, fires C# events
        ↓ events
LobbyUIController     ← View: subscribes to events, drives UI

The UI never touches the socket directly. The network layer never knows about GameObjects. When something breaks — and in multiplayer, something always breaks — you know exactly which layer to look at.

Step 1 — Connect to the /lobby Namespace

Create a LobbyNetworkManager MonoBehaviour. Use a namespace socket (_root.Of("/lobby")) rather than the root socket — this lets the server scope events cleanly per feature without event name collisions.

private SocketIOClient _root;
private NamespaceSocket _lobby;

private void Start()
{
    _root = new SocketIOClient(TransportFactoryHelper.CreateDefault());
    _root.ReconnectConfig = new ReconnectConfig { autoReconnect = false };

    _lobby = _root.Of("/lobby");
    _root.Connect("http://localhost:3001");
}

Set autoReconnect = false so you control reconnect logic — this is essential for restoring sessions with saved credentials rather than connecting as a fresh player.

Step 2 — Define Your Data Models

The server sends JSON snapshots of the room state. Two things are critical here:

[Preserve] prevents IL2CPP from stripping fields that are only referenced via JSON deserialization — without this, WebGL builds will silently lose data. [JsonProperty] ensures the fields survive minification.

[Serializable, Preserve]
public class RoomState
{
    [Preserve, JsonProperty("roomId")]  public string roomId;
    [Preserve, JsonProperty("hostId")]  public string hostId;
    [Preserve, JsonProperty("version")] public int version;
    [Preserve, JsonProperty("players")] public List<LobbyPlayer> players;
}

[Serializable, Preserve]
public class LobbyPlayer
{
    [Preserve, JsonProperty("id")]     public string id;
    [Preserve, JsonProperty("name")]   public string name;
    [Preserve, JsonProperty("ready")]  public bool ready;
    [Preserve, JsonProperty("status")] public string status; // "connected" | "disconnected"
}

Step 3 — Build the State Store

LobbyStateStore holds authoritative state and exposes C# events. The UI subscribes here — never to the socket directly.

public class LobbyStateStore : MonoBehaviour
{
    public RoomState CurrentRoom   { get; private set; }
    public string LocalPlayerId    { get; private set; }
    public string SessionToken     { get; private set; }
    public bool IsHost             => CurrentRoom != null && CurrentRoom.hostId == LocalPlayerId;

    public event Action OnConnected;
    public event Action OnDisconnected;
    public event Action<RoomState>             OnRoomStateChanged;
    public event Action<LobbyPlayer>           OnPlayerJoined;
    public event Action<string>                OnPlayerLeft;       // playerId
    public event Action<string, string, string> OnPlayerRemoved;   // id, name, reason
    public event Action<SocketError>           OnError;
    public event Action<string>                OnMatchStarted;     // sceneName

    private int _lastRoomVersion;

    public void ApplyRoomState(RoomState newState)
    {
        if (newState == null) return;
        if (newState.version > 0 && newState.version <= _lastRoomVersion) return; // dedupe
        _lastRoomVersion = newState.version;

        DiffAndFirePlayerEvents(CurrentRoom, newState);
        CurrentRoom = newState;
        OnRoomStateChanged?.Invoke(CurrentRoom);
    }

    private void DiffAndFirePlayerEvents(RoomState old, RoomState next)
    {
        if (old == null) return;
        var oldIds = new HashSet<string>(old.players.Select(p => p.id));
        var newIds = new HashSet<string>(next.players.Select(p => p.id));

        foreach (var p in next.players)
            if (!oldIds.Contains(p.id)) OnPlayerJoined?.Invoke(p);
        foreach (var p in old.players)
            if (!newIds.Contains(p.id)) OnPlayerLeft?.Invoke(p.id);
    }

    public void Reset()
    {
        CurrentRoom = null; LocalPlayerId = null; SessionToken = null; _lastRoomVersion = 0;
    }
}

The version check is not optional. During reconnect, the server broadcasts the current room snapshot to the rejoining client — which means the same state can arrive twice. Without the version guard, your UI fires double join/leave events and gets out of sync.

DiffAndFirePlayerEvents lives on LobbyStateStore, called by ApplyRoomState — not on the UI controller. Keep all player diffing logic inside the store.

Step 4 — Wire Up Incoming Events

Subscribe to the four server events in LobbyNetworkManager:

// Server identifies you before room_state arrives
_lobby.On("player_identity", (string json) =>
{
    var obj = JObject.Parse(json);
    store.SetLocalPlayerId(obj.Value<string>("playerId"));
    store.SetSessionToken(obj.Value<string>("sessionToken"));
});

// Authoritative full snapshot
_lobby.On("room_state", (string json) =>
{
    var state = JsonConvert.DeserializeObject<RoomState>(json);
    store.ApplyRoomState(state);
});

// Host started the match
_lobby.On("match_started", (string json) =>
{
    string scene = JObject.Parse(json)["sceneName"]?.ToString();
    store.FireMatchStarted(scene);
});

// Player permanently removed (left or reconnect window expired)
_lobby.On("player_removed", (string json) =>
{
    var obj = JObject.Parse(json);
    store.FirePlayerRemoved(
        obj["playerId"]?.ToString(),
        obj["name"]?.ToString(),
        obj["reason"]?.ToString()
    );
});

Identity ordering matters: the server sends player_identity before the ACK and before room_state. By the time ApplyRoomState fires, IsHost evaluates correctly.

Step 5 — Emit Lobby Actions

Use ACK callbacks for create_room and join_room — they confirm success and return credentials:

Note on ParseAck: The real LobbyNetworkManager.cs handles three response shapes — raw string, JSON array, and JSON object. The snippets below use a simplified version for readability. For production use, copy the full ParseAck implementation directly from the sample to avoid silent failures on the array case.

public void CreateRoom(string playerName)
{
    _lobby.Emit("create_room", new { name = playerName }, ack =>
    {
        var result = ParseAck(ack);
        if (result?.Value<bool>("ok") == true)
        {
            store.SetLocalPlayerId(result.Value<string>("playerId"));
            store.SetSessionToken(result.Value<string>("sessionToken"));
        }
    });
}

public void JoinRoom(string roomId, string playerName)
{
    _lobby.Emit("join_room", new { roomId = roomId.ToUpper(), name = playerName }, ack =>
    {
        var result = ParseAck(ack);
        if (result?.Value<bool>("ok") != true)
            store.FireError(new SocketError(ErrorType.Auth, result?.Value<string>("error")));
    });
}

public void SetReady(bool ready)     => _lobby.Emit("player_ready", new { ready });
public void StartMatch(string scene) => _lobby.Emit("start_match", new { sceneName = scene });

public void LeaveRoom()
{
    _lobby.Emit("leave_room", new { }, _ => store.Reset());
}

Step 6 — Build the Player List UI

Instantiate one prefab row per player. Update without full rebuilds by diffing — recreating all rows on every state update causes flicker and loses UI state:

private readonly Dictionary<string, GameObject> _playerRows = new();

private void HandlePlayerJoined(LobbyPlayer player)
{
    var row = Instantiate(playerRowPrefab, playerListContent);
    _playerRows[player.id] = row;
    UpdateRow(row, player);
}

private void HandlePlayerLeft(string playerId)
{
    if (_playerRows.TryGetValue(playerId, out var row)) Destroy(row);
    _playerRows.Remove(playerId);
}

// Called on every room_state to reconcile the authoritative list
private void RefreshPlayerRows(RoomState state)
{
    var stateIds = new HashSet<string>(state.players.Select(p => p.id));

    foreach (var id in _playerRows.Keys.Except(stateIds).ToList())
    {
        Destroy(_playerRows[id]);
        _playerRows.Remove(id);
    }

    foreach (var player in state.players)
    {
        if (!_playerRows.TryGetValue(player.id, out var row))
            row = _playerRows[player.id] = Instantiate(playerRowPrefab, playerListContent);
        UpdateRow(row, player);
    }
}

private void UpdateRow(GameObject row, LobbyPlayer player)
{
    bool disconnected = player.status == "disconnected";
    var nameText = row.transform.Find("NameText").GetComponent<TextMeshProUGUI>();
    nameText.text = player.id == store.CurrentRoom?.hostId
        ? $"{player.name} [Host]{(disconnected ? " (Reconnecting...)" : "")}"
        : $"{player.name}{(disconnected ? " (Reconnecting...)" : "")}";
    nameText.color = disconnected ? Color.gray : Color.white;

    var icon = row.transform.Find("ReadyIcon").GetComponent<Image>();
    icon.color = disconnected ? Color.yellow : (player.ready ? Color.green : Color.gray);
}

Step 7 — Handle Disconnects and Session Restore

This is where most lobby implementations give up. The server holds a player’s slot for 10 seconds after disconnect — their row stays visible with status = "disconnected". On reconnect, send saved credentials to reclaim the slot:

private const string PREF_ROOM_ID       = "Lobby_LastRoomId";
private const string PREF_PLAYER_ID     = "Lobby_PlayerId";
private const string PREF_SESSION_TOKEN = "Lobby_SessionToken";

// Save on every room_state
PlayerPrefs.SetString(PREF_ROOM_ID,       state.roomId);
PlayerPrefs.SetString(PREF_PLAYER_ID,     store.LocalPlayerId);
PlayerPrefs.SetString(PREF_SESSION_TOKEN, store.SessionToken);

// On reconnect — try to restore
private void HandleConnected()
{
    string pid   = PlayerPrefs.GetString(PREF_PLAYER_ID, "");
    string room  = PlayerPrefs.GetString(PREF_ROOM_ID,   "");
    string token = PlayerPrefs.GetString(PREF_SESSION_TOKEN, "");

    if (!string.IsNullOrEmpty(pid) && !string.IsNullOrEmpty(token))
    {
        networkManager.ReconnectSession(pid, room, token);
        StartCoroutine(RejoinTimeout(5f)); // give up after 5 sec
    }
}
public void ReconnectSession(string playerId, string roomId, string sessionToken)
{
    _lobby.Emit("reconnect_player", new { playerId, roomId, sessionToken }, ack =>
    {
        var result = ParseAck(ack);
        if (result?.Value<bool>("ok") != true)
        {
            // Room expired — clear credentials and return to lobby selection
            PlayerPrefs.DeleteKey(PREF_ROOM_ID);
            store.FireError(new SocketError(ErrorType.Auth, result?.Value<string>("error")));
        }
    });
}

The 5-second rejoin timeout is important. If the room expired while the player was disconnected, you don’t want to hang on a reconnect attempt forever — clear the stale credentials and return them to the lobby selection screen.

Step 8 — Start the Match

Only the host sees the Start button. On match_started, all clients load the scene:

// In Update() — simpler than event-driven for a toggle
startMatchButton.gameObject.SetActive(store.IsHost);

private void HandleMatchStarted(string sceneName)
{
    if (!string.IsNullOrEmpty(sceneName))
        SceneManager.LoadScene(sceneName);
}

Scene Setup

Canvas
├── LobbySelectionPanel
│   ├── PlayerNameInput (TMP_InputField)
│   ├── CreateRoomButton
│   ├── JoinRoomCodeInput (TMP_InputField)
│   └── JoinRoomButton
├── RoomPanel
│   ├── RoomCodeText (TextMeshProUGUI)
│   ├── CopyRoomCodeButton
│   ├── LeaveRoomButton
│   ├── ReadyButton
│   ├── StartMatchButton        ← toggled by IsHost
│   ├── PlayerList / Content    ← Vertical Layout Group
│   └── ReconnectPanel (overlay)
└── ConnectionStatusText

Assign LobbyNetworkManager and LobbyStateStore to LobbyUIController in the Inspector. Both should be on a DontDestroyOnLoad GameObject if you carry them across scenes.

Key Patterns

Pattern Why
room_state.version check Ignores duplicate snapshots during reconnect
player_identity before ACK Ensures IsHost is correct by the first room_state
_joinInFlight guard Prevents double-emit during reconnect sequence
PlayerPrefs session storage Survives app backgrounding, not just network blips
5-second rejoin timeout Clears stale credentials if the room already expired

Running the Demo

The Samples~/Lobby/ sample includes a full working server:

cd TestServer~
npm install
npm run start:lobby   # or: npm run dev:lobby (auto-restart via nodemon)

The server lives at TestServer~/lobby-server.js. The package.json at TestServer~/package.json already declares express and socket.io as dependencies — no manual installs needed.

Then open the Unity scene and press Play. The sample is the fastest way to see all of this in action before adapting it to your own project.

The Repo

github.com/Magithar/socketio-unity

MIT licensed. Socket.IO v4 only. WebGL verified. Zero paid dependencies.

What’s the hardest part of multiplayer lobbies you’ve had to solve? Host migration, latency compensation, or something else entirely? Drop it in the comments.

5 Functions, 1 Route, $0/Month: My Entire SaaS Background Job Architecture

BullMQ needs Redis. Cron has no retries. I needed background jobs that can sleep for 48 hours, fan out to parallel workers, and survive server restarts — without paying for infrastructure I don’t have users to justify yet.

I replaced the entire Redis + BullMQ + cron + monitoring stack with five Inngest functions. Total cost: $0.

AI as a Solo Founder’s Tool

A 5-part email series on using AI as more than a search engine — for building, marketing, and shipping alone.

favicon
drippery.app

The Full Picture

Drippery — my drip email SaaS — runs five background functions. Together they cover scheduled email delivery, DNS domain verification polling, orphaned file cleanup, and beta user lifecycle.

All five are registered in one Next.js API route. Inngest calls it via webhook. No Redis, no worker process, no cron container.

Here’s what that replaces:

Without Inngest With Inngest
Redis instance (~$10/month on Render) Nothing
BullMQ worker process Nothing
Cron container or external service Nothing
Manual retry logic Built-in, per-step
Observability dashboard Inngest dashboard (free)
Distributed locking for cron Handled automatically
Long-running job persistence step.sleep()
Fan-out worker pool step.sendEvent()

Let me walk through each function.

The Email Pipeline: Scheduler + Parallel Sender

The core email system is a two-function pipeline. The scheduler runs every 15 minutes and asks: which subscribers are due for their next drip email?

export const sendPendingEmails = inngest.createFunction(
  { id: 'send-pending-emails', triggers: [cron('*/15 * * * *')] },
  async ({ step }) => {
    const subscribers = await step.run('fetch-subscribers', async () =>
      findActiveSubscribersWithSequences()
    );

    // For each subscriber: find next due email, check dayOffset + send window
    // Dispatch one event per match
    await step.sendEvent('dispatch', eventsToDispatch);
  }
);

It walks through each active subscriber, finds their position in the sequence, and checks if enough days have passed.

Creators in different timezones shouldn’t get emails at 3 AM — the scheduler verifies the current time matches the tenant’s preferred send window before dispatching anything.

The key decision: fan-out. Instead of sending emails inside the loop, the scheduler dispatches email/send events. Each event triggers a separate function invocation.

50 subscribers due = 50 parallel runs. No worker pool, no concurrency config.

The other critical detail: drip semantics. One email per subscriber per tick, never the whole sequence at once.

Each email/send event triggers the sender — one subscriber, one email, full isolation:

export const sendSingleEmail = inngest.createFunction(
  { id: 'send-single-email', triggers: [{ event: 'email/send' }] },
  async ({ event, step }) => {
    const { subscriberId, emailId, tenantId } = event.data;

    const [subscriber, email, tenant] = await step.run('fetch-data', async () =>
      fetchEmailContext(subscriberId, emailId, tenantId)
    );

    const finalHtml = replaceMergeTags(email.html, mergeTags);

    await step.run('send-email', async () =>
      sendEmail(subscriber.email, finalSubject, finalHtml)
    );

    await step.run('record-sent', async () =>
      db.insert(sentEmails).values({ subscriberId, emailId, sentAt: new Date() })
    );
  }
);

Each step.run() is independently retryable. If the Resend API times out on send-email, only that step retries — the DB fetch doesn’t re-execute.

If the server crashes after sending but before recording, Inngest resumes at record-sent. No double-sends, no lost records.

The 48-Hour Function: Domain Verification Polling

This is the function I couldn’t have built with BullMQ — at least not without a lot of scheduling glue.

When a user adds a custom sender domain, Drippery registers it with Resend and starts polling for DNS verification. This can take 5 minutes or 24 hours — you don’t know upfront.

export const checkDomainVerification = inngest.createFunction(
  { id: 'check-domain-verification', triggers: [{ event: 'domain/check-verification' }] },
  async ({ event, step }) => {
    const { senderDomainId, attempt = 1 } = event.data;
    const MAX_ATTEMPTS = 576; // 48 hours at 5-minute intervals

    const result = await step.run('check-resend', async () => {
      const resend = new Resend(process.env.RESEND_API_KEY);
      return resend.domains.get(domain.resendDomainId);
    });

    if (isVerified) return { status: 'verified', attempts: attempt };

    await step.sleep('wait', '5m');
    await step.sendEvent('next-check', {
      name: 'domain/check-verification',
      data: { senderDomainId, attempt: attempt + 1 },
    });
  }
);

This function can run for up to 48 hours. step.sleep() suspends the function, frees server resources, then resumes exactly where it left off.

With BullMQ, you’d need explicit delayed job scheduling, persistence logic, and retry handling. Here it’s one line: await step.sleep('5m').

The Maintenance Jobs: Cleanup + Beta Lifecycle

The remaining two functions are simpler but follow the same pattern.

Orphan image cleanup runs every Sunday at 3 AM. It compares images in Cloudflare R2 against database references and deletes anything unreferenced:

export const cleanupOrphanImages = inngest.createFunction(
  { id: 'cleanup-orphan-images', triggers: [cron('0 3 * * 0')] },
  async ({ step }) => {
    const r2Keys = await step.run('list-r2', async () => listAllObjects());
    const dbKeys = await step.run('get-refs', async () => extractReferencedUrls());
    const orphans = r2Keys.filter(k => !dbKeys.has(k));
    await step.run('delete', async () => deleteObjects(orphans));
  }
);

Email images are excluded — once delivered, they can’t be deleted without breaking past recipients’ inboxes.

Beta expiry runs daily at 9 AM. It warns testers 3 days before their 14-day trial ends, then downgrades them:

export const checkBetaExpiry = inngest.createFunction(
  { id: 'check-beta-expiry', triggers: [cron('0 9 * * *')] },
  async ({ step }) => {
    const warningTesters = await step.run('find-warning', async () =>
      findTenantsByBetaWindow(3, 4)
    );
    for (const tenant of warningTesters) {
      await step.run(`warn-${tenant.id}`, async () => {
        await sendWarningEmail(tenant);
        await markWarned(tenant.id);
      });
    }
  }
);

Each tenant gets its own step.run(). If one tenant’s email lookup fails, only that step retries — the rest still get processed.

The Cost

Inngest’s free tier gives you 50,000 executions per month. My actual usage is well under 10,000 — scheduler ticks, dispatched sends, daily and weekly jobs.

For an early-stage SaaS, the free tier has plenty of headroom.

When I outgrow it, paid plans start at $75/month. By that point, I’ll have enough paying users to cover it. That’s the scaling curve I wanted.

What’s Next

Five functions today. The next one will probably be a subscriber re-engagement job — if someone hasn’t opened the last 3 emails, automatically pause their sequence and notify the tenant.

Same pattern: one more function file, zero infrastructure changes.

The entire background job layer of my SaaS is five TypeScript functions, one API route, and zero managed services. When you’re building alone, that’s the architecture that lets you ship.

If you’re curious about Drippery — dead-simple drip email sequences for creators, starting at $0/month — check it out at drippery.app.

I write weekly about what I’m shipping and what’s breaking on Substack.