Your screen
has a witness.

ApexCompanion looks at your screen every 10 seconds with Gemma 4, running entirely on your laptop. Drift off task and it calls you out out loud, in the voice of a coach with no patience for you.

A personal trainer who watches every rep and never posts the footage.

$ HF_HUB_OFFLINE=1 .venv/bin/python apex.py --persona closer
apex.py --persona closer
loaded gemma-4-e4b-it-8bit (mlx) · offline
check app=VS Code content="editing apex.py"
      category=coding → working
check app=Safari content="YouTube: cat compilation"
      category=entertainment_video → distracted (1/2)
check app=Safari content="YouTube: cat compilation"
      category=entertainment_video → distracted (2/2)
roast persona=closer · cooldown clear · speaking

Mock UI. The roast text is real output from the pipeline.

Watch it work

89 seconds, all real output: actual screenshots of public pages, actual verdicts from the classifier, actual roasts in each coach's voice.

The insight

Site blockers see URLs. Gemma sees context.

The same domain can be your best study tool or your worst habit. A blocklist keyed on URLs or window titles can't tell the difference. Gemma 4 looks at the actual pixels on every check, so it can.

Blocklist What a blocker sees

A URL or a window title. Pattern-match it and hope.

youtube.com/watch?v=…
Blocked matches "youtube.com"
youtube.com/watch?v=…
Blocked matches "youtube.com"
docs.python.org/3/library/asyncio.html
Allowed not on the list

Gemma 4 What ApexCompanion sees

The screen itself: app, content, intent.

YouTube: a Python asyncio tutorial, code on screen
educational_video Working
YouTube: a cat compilation, autoplay queue
entertainment_video Distracted
Python docs open beside the editor
docs_reading Working

The tutorial stays. The cats get called out. No allowlist to maintain.

How it works

One model call. Plain code decides.

Gemma 4 describes what's on screen and picks a category. Ordinary Python maps that category to a verdict. The model never gets the final say on whether you're slacking.

Describe first, label second

Asking "working or distracted?" directly got a cat compilation labeled "working". Making the model first say what it sees, then pick a category, fixed that case.

Code owns the verdict

Categories map to verdicts in a lookup table you can read and edit.

codingdocs_readingnotes_writingeducational_videomessagingnews_readingother entertainment_videosocial_mediashoppinggamingfeed_browsingsports

Never roast on garbage

Malformed JSON or an unknown category counts as "working". One bad frame can't trigger a call-out, and two bad checks in a row are required anyway.

Air-gapped by design

Your screen is the most private thing you own.

A coach that watches your screen can only be trusted if the footage goes nowhere. That's why this has to run on-device, and why an open-weight model makes it possible.

  • Weights load from local cache. Run with HF_HUB_OFFLINE=1; the model never phones home.
  • Screenshots stay on the machine, then get deleted. Each capture is removed after its check. Inference, roast and voice all run locally on Apple silicon.
  • The live demo runs with Wi-Fi off. So does this page: no fonts, scripts or images fetched from anywhere.
Pick your coach

Two coaches. Zero mercy.

Each roast is generated fresh by Gemma 4 and refers to what's actually on your screen. Switch with one flag.

Persona 01

The Closer

An arrogant corporate dealmaker. Bills by the minute and thinks you should too.

--persona closer
  • on a cat compilation · real pipeline outputAre you actually watching cat videos when I'm trying to close a deal? Focus, or this conversation is over.
  • on a shopping cartYou're comparing sneakers like it's a merger. Nobody's acquiring you at this rate. Close the tab.
  • after a long focus streakForty minutes of actual work. Don't let it go to your head. Keep closing.
Persona 02

The Whip

A cold Southern political operator. Soft voice, long memory, counts every vote and every minute.

--persona whip
  • on a social feedDarlin', I've ended careers over less than twenty minutes of scrolling. Put that feed down.
  • on a game launcherNow I know you didn't open that game on my time. Close it, and we'll pretend I didn't see.
  • after a long focus streakWell, look at you, actually earning your keep. I'll remember that. I remember everything.

Except the line marked as real pipeline output, these are sample lines written for this page to show each voice. Live roasts are generated on-device and vary. Original characters; no real people.

Measured, not promised

The numbers we actually measured.

M3 Max, mlx-community/gemma-4-e4b-it-8bit via mlx-vlm 0.7.10, offline. Anything not yet measured is marked as pending, not guessed.

Measured
0.87s
Screen check, warm (median)
Screenshot to category over 28 real web pages, model already loaded. p95 0.96 s. Well inside the 10 s interval.
Measured
~2.0s
Roast generation
Gemma 4 text, persona voice, before it's spoken aloud.
Measured
3.7s
Model load
Paid once per session, from local cache, offline.
Measured
11.2GB
Peak memory
8-bit Gemma 4 E4B, vision + text from one load.
Measured
25/25
Clear-cut real web pages classified correctly, 0 false roasts
28 public pages captured in a headless browser, labels confirmed by a person, run through eval.py. 26/28 overall: both misses were cases labeled ambiguous. Distracted recall 11/11. Held-out 6/6 (5 fully clean).
10 scheck interval (setting)
2 checksin a row before a call-out (setting)
60 sminimum gap between roasts (setting)

Caveat, stated plainly: 25 clear-cut cases is a small set, so one miss would cost 4 points. Only web pages were tested; native desktop apps (VS Code, the terminal, native Spotify, games) are untested, and anything filed as "other" counts as working, so a native game there won't be roasted. We checked the eval can fail: moving entertainment_video or coding to the wrong side of the map turns it red.