A menu-bar honesty meter for solo founders. Are you shipping, or just coding?
~4,000 lines of Swift · zero dependencies · plain JSONL files · private by default, optional cloud sync
miniowl is a small macOS app that quietly records which application has focus, what its window title is, when you're at the keyboard versus away, and (for whitelisted browsers) the URL of your current tab. It writes everything to a plain JSON-Lines file under ~/Library/Application Support/miniowl/. By default, nothing leaves your Mac.
v2 adds an opt-in cloud-sync mode. When you turn it on, miniowl sends a compact summary of each 20-minute window to a categorization service that returns strategic buckets — Product, GTM, Strategy, Learning, Admin, Operations, Personal — and persists one daily-rollup row per user so you can review your week from a web dashboard. Cloud sync is off until you flip it on, and toggling it off purges every saved row from the server in the same call. See Privacy contract → v2 cloud sync for the exact wire shape.
It exists because the only honest way to know where your time goes is to measure it. Self-reports are flattering and inaccurate. Calendars are aspirational. Miniowl just records what actually happened — and v2 helps you see it as the categories that decide whether you ship or stall.
- You want to answer "where did the week go?" with data instead of memory.
- You're a solo founder trying to escape the Joy+Skill trap (coding when you should be talking to users) and need to see your time honestly grouped by strategic category.
- You don't want a SaaS time tracker reading your screen, screenshotting your work, or showing you a dashboard designed to keep you engaged.
- You want a small, source-visible tool you can audit and trust on a privacy-sensitive product.
If those don't resonate, you probably want something else — RescueTime, Toggl, Timing, or ActivityWatch are all good. Miniowl is deliberately narrow: built for one persona, on one platform, with one opinion about what time data is for.
These are code-enforced invariants. The build fails if any banned API appears in the source tree (see scripts/check-privacy.sh and Sources/miniowl/Privacy/ForbiddenImports.swift).
| miniowl can read | miniowl cannot read |
|---|---|
| Frontmost app name + bundle ID | Keystrokes (no CGEventTap, no global event monitors) |
| Title of the focused window (via the Accessibility API) | Clipboard contents (no NSPasteboard) |
| One number: seconds since last input | Screen pixels (no CGWindowListCreateImage, no ScreenCaptureKit) |
| Browser current-tab URL via AppleScript (whitelisted browsers only) | Page content, form data, browsing history |
NSWorkspace notifications: sleep / wake / lock / unlock |
— |
The audit surface is small enough that a skeptic can read every line of Swift in under an hour and verify these claims.
v2 adds two opt-in network paths, both off by default and both gated by an explicit user action.
1. Pairing (one-time, RFC 8628 device-code flow). Click Connect account… in the menu bar. miniowl asks the server for a short-lived device_code and user_code, opens your default browser to the dashboard, and you log in with your Supabase account and confirm. The server issues a long-lived device token (miniowl_sk_…) which is stored in the macOS Keychain — never on disk in plaintext, never in source. You can revoke any device from the dashboard at any time.
2. Categorization (every 20 minutes, only when cloud sync is on). Once paired AND the per-user cloud_sync_enabled flag is true (the dashboard toggle), miniowl POSTs a compact summary of the last window to the categorization API. The response is a list of strategic buckets — Product, GTM, Strategy, Learning, Admin, Operations, Personal — plus one short founder-honest summary line. The server stores one row per user per day in a day_rollups table; raw window/event data never leaves your Mac.
Cloud sync OFF (default): the categorize call still works locally so the menu bar can show category bars in real time, but the server does not persist anything. Your day_rollups row is never created. Toggling OFF after it was on triggers a hard-delete of every saved row for your user, in the same call. The dashboard goes empty.
What miniowl sends (per call, batched, only when cloud sync is on):
What miniowl does NOT send, ever: keystrokes, clipboard, screen pixels, page content / HTML / text, form data, full URLs (only host + first path segment), file contents, audio, network packets from other apps, anything from outside the existing v1 watchers.
Auth seam: the device token rides in Authorization: Bearer miniowl_sk_<…> over HTTPS. The server validates it against a hashed copy in miniowl.devices.token_hash (we never store the plaintext) and binds the call to your user_id before any business logic runs. The token is the only credential miniowl carries — there is no shared client secret in the source tree.
Allowlisted file: URLSession is allowed in exactly one file (Sources/miniowl/Categorization/CategorizationClient.swift). The privacy-check script enforces this — any other file that imports URLSession fails the build. The whole network surface is auditable in <100 lines.
Outbox + idempotency: when a categorize call fails (network, server down), the payload is appended to a local outbox in ~/Library/Application Support/miniowl/sync_state.json. The next successful call replays it. The server is keyed on last_window_end so the same window can be sent twice without producing a duplicate row.
- App + window title capture via
NSWorkspaceactivation events and the Accessibility API - Browser URL capture for Chrome, Safari, Arc, and Brave via pre-compiled AppleScript
- AFK detection via
CGEventSource.secondsSinceLastEventType(one integer, no event taps) - Sleep / wake / lock events via
NSWorkspacenotifications - Live menu bar popover with today's top apps + AFK total, refreshed every 30 s
- Pause / resume with honest
paused/resumedmarkers in the data - Auto-launch at login via
SMAppService.mainApp - Daily rotation + gzip: today's file is plain JSONL, older days get compressed at midnight
- Crash recovery via a 10 s heartbeat to
state.json— at most 10 seconds of pending work is lost onkill -9 - Compact storage: ~5–8 KB per day gzipped, ~10–15 MB for five years
- Zero third-party dependencies — only Foundation, AppKit, ApplicationServices, ServiceManagement, SwiftUI
- v2 strategic categorization (opt-in) — every 20 minutes the window summary is sent to a categorization API which returns founder-strategic bucket totals (Product / GTM / Strategy / Learning / Admin / Operations / Personal) plus a one-line founder-honest summary, mapped onto the 3-circles palette (Sweet Spot · Joy+Skill trap · Need-only · Personal). Disabled by default.
- v2 device pairing (RFC 8628) — one-time browser-based pairing flow, device tokens stored in the macOS Keychain, revocable from the dashboard at any time. No password ever lives in the app.
- v2 cloud-sync toggle — single dashboard toggle. ON = daily summaries persist on the server. OFF = nothing is stored, and flipping from ON→OFF hard-deletes every saved row for your user immediately.
- v2 web dashboard — see miniowl.me/dashboard for today's bars, last-7-days 3-circles stacked chart, and 30-day category split.
- Resilient sync — failed categorize calls go to a local outbox and replay on the next successful call. Server-side idempotency keyed on
last_window_endmeans duplicate sends never create duplicate rows.
Miniowl is in private beta. While we make sure the category view actually changes how founders work — not just how their menu bar looks — the whole product is free, no card required.
| Today (private beta) | At public launch | |
|---|---|---|
| Local tracking + menu bar | Free | Free, forever |
| Cloud sync + web dashboard | Free | $5/mo |
Founding-user perk: if you join during the beta and give feedback, your first 3 months of cloud sync stay free after we open to the public. Our way of saying thanks for the brutally honest feedback.
We are deliberately not building Stripe or a billing flow yet — we want to validate that the dashboard changes behavior before charging. If that bet is wrong, the price was always going to be wrong too.
- macOS 14 (Sonoma) or later
Grab the latest release from the Releases page:
- Download
miniowl-<version>.dmg - Open the
.dmg - Drag
miniowl.appto Applications - Open Applications → miniowl
The .dmg is signed by JOURNELY LLC and notarized by Apple, so Gatekeeper will not show "unidentified developer" warnings.
Requires Swift 5.9+ (ships with Xcode 15+, or via Command Line Tools — xcode-select --install).
git clone https://github.com/journelyme/miniowl.git
cd miniowl
./scripts/build-app.sh
cp -R build/miniowl.app /Applications/
open /Applications/miniowl.app./scripts/build-app.sh auto-detects a signing identity from your keychain (Developer ID Application → Apple Development → ad-hoc fallback). For day-to-day local use, ad-hoc is fine — it just means you'll need to re-grant Accessibility after every rebuild because macOS tracks ad-hoc-signed apps by code hash.
miniowl appears as an eye icon in your menu bar (top-right of the screen).
macOS will ask for two permissions on the first launch. Both are required for the features to work but neither lets miniowl read content — see the privacy contract above for what each one actually allows.
- Click the eye icon in the menu bar — the popover will say "Accessibility permission required"
- Click Open Settings
- In System Settings → Privacy & Security → Accessibility, click the + button, navigate to
/Applications/miniowl.app, and add it - Toggle the new
miniowlentry on - Click the menu bar icon again — the banner should clear within ~1 second
If the banner stays after granting, click Restart miniowl in the same banner. This is a known macOS quirk with newly signed binaries — see Limitations below.
The first time you focus Chrome, Safari, Arc, or Brave after granting Accessibility, macOS will show:
"miniowl" wants to control "Google Chrome". Allowing control will provide access to documents and data in "Google Chrome", and to perform actions within that app.
Click OK. miniowl uses this only to read the URL of the active tab via AppleScript — it never reads page content, form data, or other tabs. Each browser prompts independently the first time it gains focus.
Miniowl calls SMAppService.mainApp.register() on first launch. macOS may show a prompt; you can also verify (or toggle) it manually under System Settings → General → Login Items & Extensions → Open at Login.
There is no dashboard, by design. Today's data lives at ~/Library/Application Support/miniowl/YYYY-MM-DD.mow and older days at YYYY-MM-DD.mow.gz. Use the canned report script or your own jq recipes.
# Top apps by active time today
./scripts/query-today.shSample output:
Miniowl — 2026-04-10
─────────────────────────────────────
Top apps by active time
142.3m IntelliJ IDEA
63.1m Google Chrome
45.0m Preview
28.4m Slack
9.2m Terminal
AFK: 37.2 min
System markers
09:02:11 launch
10:14:33 sleep
10:38:02 wake
...
The data is plain JSON-Lines. Anything that reads text can query it. The schema lives in the Data format section below.
DATA="$HOME/Library/Application Support/miniowl"
TODAY=$(date +%F)
# Total time in IntelliJ today
jq -r 'select(.t=="w" and .b=="com.jetbrains.intellij") | .e - .s' "$DATA/$TODAY.mow" \
| awk '{s+=$1} END {printf "%.1fh\n", s/3600000}'
# Top URLs visited in Chrome today
jq -r 'select(.t=="w" and .b=="com.google.Chrome" and .u != null) | "\(.e-.s)\t\(.u)"' "$DATA/$TODAY.mow" \
| awk -F'\t' '{sum[$2]+=$1} END {for(k in sum) printf "%4.0fs %s\n", sum[k]/1000, k}' \
| sort -rn | head -10
# How much "coding time" (your favorite IDEs/terminals) vs "talking time"
# (Slack/Zoom/Meet) so far this month
for f in "$DATA"/$(date +%Y-%m)-*.mow*; do
case "$f" in *.gz) gunzip -c "$f" ;; *) cat "$f" ;; esac
done | jq -r 'select(.t=="w") | "\(.e-.s)\t\(.b)"' \
| awk -F'\t' '
/jetbrains|iTerm|Terminal|VSCode/ {code += $1}
/slack|zoom|Meet|Messages|Discord/ {talk += $1}
END {printf "code: %5.1fh\ntalk: %5.1fh\n", code/3600000, talk/3600000}'Each day gets one file: YYYY-MM-DD.mow. It's plain JSON-Lines — you can cat, tail -f, jq, or grep it. At local midnight the file is gzipped (.mow.gz) and a new one is opened.
{"d":"2026-04-10","start":1712707200000,"tz":"Asia/Tokyo","v":1}| Key | Meaning |
|---|---|
v |
Format version. Bumped on breaking changes. |
d |
Local calendar date for this file. |
tz |
IANA time zone at rotation time. |
start |
Unix milliseconds at which this file was opened. |
Single-character keys to keep the uncompressed file small (gzip mostly erases the difference, but the raw file is also useful in flight).
{"t":"w","s":1712739600000,"e":1712739720000,"b":"com.jetbrains.intellij","n":"IntelliJ IDEA","ti":"workspace – main.go","u":null}| Key | Meaning |
|---|---|
s |
start_ts — Unix ms UTC |
e |
end_ts — Unix ms UTC |
b |
bundle identifier |
n |
app display name |
ti |
window title (may be null for secure-input fields) |
u |
active tab URL (only set for whitelisted browsers; null otherwise) |
Consecutive observations with the same (b, ti, u) triple are merged in memory by the coordinator and written as a single row when the state changes.
{"t":"a","s":1712739900000,"e":1712740080000,"k":1}| Key | Meaning |
|---|---|
s, e |
Unix ms UTC start / end of the AFK interval |
k |
1 = away. Only away intervals are written; "active" is implied as the complement. |
The threshold for "AFK" is 180 seconds of no input. Start timestamps are back-dated: if you walk away at 10:00:00 and the threshold trips at 10:03:00, the row's s is 10:00:00.
{"t":"s","s":1712740100000,"k":"sleep"}k value |
Meaning |
|---|---|
launch |
miniowl process started |
quit |
miniowl process exited cleanly |
sleep |
system going to sleep |
wake |
system woke from sleep |
lock |
screens locked |
unlock |
screens unlocked |
paused |
tracking paused by user |
resumed |
tracking resumed |
| Period | Size (rotated + gzipped) |
|---|---|
| 1 day | 5–8 KB |
| 1 month | 150–250 KB |
| 1 year | 2–3 MB |
| 5 years | 10–15 MB |
┌────────────── miniowl (single process) ──────────────┐
│ │
│ ┌─ MenuBarExtra (SwiftUI, main thread) ──────┐ │
│ │ Status · Pause/Resume · Open data folder │ │
│ │ Today's totals · Permission state · Quit │ │
│ └────────────────┬────────────────────────────┘ │
│ │ │
│ ┌─ EventCoordinator (actor, single writer) ─┐ │
│ │ Buffers a PendingEvent in memory. │ │
│ │ Decides when to extend / close / persist. │ │
│ │ Writes to EventLog on close or heartbeat. │ │
│ └──┬─────┬─────┬─────┬─────┬─────────────────┘ │
│ │ │ │ │ │ │
│ ┌──▼──┐ ┌▼──┐ ┌▼───┐ ┌▼────┐ │
│ │Win │ │AFK│ │Sys │ │Brwsr│ │
│ │Watch│ │ │ │Watc│ │ │ │
│ └─────┘ └───┘ └────┘ └─────┘ │
│ │
│ ┌─ EventLog (serial JSONL writer + rotation) ─┐ │
│ │ ~/Library/Application Support/miniowl/ │ │
│ │ YYYY-MM-DD.mow (today, plain) │ │
│ │ YYYY-MM-DD.mow.gz (yesterday, gzipped) │ │
│ │ state.json (10 s pending snap) │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
One writer. Every disk write funnels through the EventCoordinator actor — no races on the file handle.
Watchers are dumb producers. They emit raw observations; the coordinator decides whether the new observation extends the pending event in memory or closes it and opens a new one.
Merging happens before disk. Staying in IntelliJ on the same file for an hour writes one row, not 720.
Crash safety. A 10-second heartbeat mirrors the in-memory pending event to state.json via atomic write-then-rename. On the next launch, the coordinator recovers any stale pending into the log.
miniowl/
├── Package.swift # SPM manifest (Swift 5.9, macOS 14+)
├── miniowl-bundle/
│ ├── Info.plist # bundle metadata, LSUIElement
│ ├── miniowl.entitlements # only automation.apple-events
│ └── AppIcon.icns # generated by tools/make-icon.sh
├── Sources/miniowl/
│ ├── miniowlApp.swift # @main + AppDelegate + signal handlers
│ ├── Coordinator/
│ │ ├── EventCoordinator.swift # single-writer actor + merging logic
│ │ ├── SyncCoordinator.swift # outbox-pattern actor for v2 sync lifecycle
│ │ └── PendingEvent.swift # in-memory value type
│ ├── Storage/
│ │ ├── EventLog.swift # JSONL append + daily gzip rotation
│ │ ├── StateFile.swift # atomic state.json for crash recovery
│ │ ├── LogReader.swift # streaming JSONL → daily totals
│ │ ├── CategorizationLog.swift # v2 cumulative day-level rollup (local)
│ │ ├── ContextStore.swift # user-supplied context.md → prompt context
│ │ └── SyncState.swift # outbox JSONL + last_window_end anchor
│ ├── Watchers/
│ │ ├── WindowWatcher.swift # NSWorkspace + Accessibility API
│ │ ├── AFKWatcher.swift # CGEventSource idle state machine
│ │ ├── BrowserWatcher.swift # pre-compiled AppleScript per browser
│ │ └── SystemWatcher.swift # sleep / wake / lock notifications
│ ├── Categorization/ # v2 — opt-in network layer
│ │ ├── CategorizationClient.swift # ONLY URLSession site (privacy-check enforced)
│ │ ├── CategoryRollup.swift # v2 cumulative day rollup logic
│ │ ├── DayCategorization.swift # in-memory day model
│ │ └── Models.swift # request/response wire shapes
│ ├── Pairing/ # v2 — RFC 8628 device-code flow
│ │ ├── PairingFlow.swift # state machine for /pair/start, /pair/poll
│ │ ├── DeviceTokenStore.swift # macOS Keychain (kSecClassGenericPassword)
│ │ └── PairingModels.swift # wire shapes
│ ├── Permissions/
│ │ └── AccessibilityPermission.swift # AX trust check + open Settings helper
│ ├── UI/
│ │ ├── AppState.swift # @MainActor orchestrator + timers
│ │ ├── MenuContent.swift # popover layout
│ │ ├── MenuRowButton.swift # shared menu row component
│ │ ├── CategoryBarsView.swift # v2 cumulative day bars + 3-circles colors
│ │ └── TodaySummary.swift # v1 raw-app fallback view
│ └── Privacy/
│ └── ForbiddenImports.swift # banned-symbol manifest (non-executable)
├── scripts/
│ ├── build-app.sh # build + bundle + ad-hoc sign
│ ├── check-privacy.sh # CI grep for banned APIs
│ └── query-today.sh # canned jq report
├── tools/
│ ├── make-icon.swift # render the master 1024 px PNG
│ └── make-icon.sh # PNG → iconset → .icns
└── assets/ # README icon previews
# Hot loop (run the binary directly, ctrl-C to stop)
swift run -c release
# Full bundled build
./scripts/build-app.sh
# Privacy check (also runs as the first step of build-app.sh)
./scripts/check-privacy.sh
# Regenerate the app icon from scratch
./tools/make-icon.shThis is a one-time setup for whoever maintains miniowl. Once it's done, every release is a single command.
- Have a paid Apple Developer Program membership
- In Keychain Access, generate a CSR via Certificate Assistant → Request a Certificate From a Certificate Authority… and save it to disk
- At https://developer.apple.com/account/resources/certificates/list, click + → Developer ID Application → upload the CSR → download the resulting
.cer→ double-click to install in Keychain Access - At https://appleid.apple.com → Sign-In and Security → App-Specific Passwords, generate a new app-specific password (label it
miniowl notarization) and copy thexxxx-xxxx-xxxx-xxxxstring - Run
./tools/setup-notary.sh <your-apple-id-email>and paste the app-specific password when prompted — it gets stored in the macOS keychain under the profile nameminiowl-notary
- Bump
CFBundleShortVersionStringinminiowl-bundle/Info.plist - Add an entry under
[Unreleased]inCHANGELOG.mdand rename it to the new version - Commit, tag, push:
git commit -am "release v0.1.0" git tag v0.1.0 git push origin main --tags - Build the signed + notarized
.dmg:This runs the privacy check, builds the./tools/make-dmg.sh
.app, signs it with your Developer ID, packages it into a.dmg, signs the.dmg, submits it to Apple's notary service, waits for theAcceptedstatus, and staples the ticket. Output:release/miniowl-<version>.dmg. - Create the GitHub release with the
.dmgattached:gh release create v0.1.0 release/miniowl-0.1.0.dmg \ --title "miniowl 0.1.0" \ --notes-file <(awk '/^## \[0.1.0\]/,/^## \[/ {if (!/^## \[/) print}' CHANGELOG.md)
The privacy check is just a grep for banned API names. Banned symbols live in Sources/miniowl/Privacy/ForbiddenImports.swift and the script greps the rest of the source tree for them. If you need to add a new API surface, audit it against the privacy contract first — and please don't bypass the check.
- Create
Sources/miniowl/Watchers/MyWatcher.swiftwith a class that takes anEventCoordinatorand exposesstart(). Use a dedicatedDispatchQueuefor sampling — never the main thread. - Add a new
EventKindcase inSources/miniowl/Coordinator/PendingEvent.swiftif your data shape doesn't fit the existing.windowor.afkcases. - Teach
EventCoordinator.encodeIntervalhow to serialize it. - Wire the watcher into
AppState.initandAppState.start. - Document the new event type in this README's Data format section.
- Ad-hoc signing breaks TCC across rebuilds. Every
swift buildproduces a new code-signature hash, and macOS Accessibility/Automation grants are tied to that hash. After rebuilding, you may need to re-grant permission via System Settings → Privacy & Security → Accessibility (remove the oldminiowlentry with−, add the freshly built/Applications/miniowl.appwith+). The fix is to sign with a real Apple Developer ID; left as a future improvement. - Spotlight does not index ad-hoc-signed apps. You won't find miniowl via Cmd+Space — that's expected. Use the menu bar icon, or use Raycast / Alfred which have their own indexers.
- Watching a video reads as AFK. No mouse or keyboard input → idle counter ticks → AFK threshold trips. We don't try to fix this; the honest signal is "no input at the keyboard."
- Rotation polls every 60 seconds. A new day's file opens within ~60 seconds of midnight, not exactly at midnight.
- No exports / calendar overlay yet. v2 ships LLM categorization, the web dashboard, and opt-in cloud sync. Calendar overlay, CSV export, and integrations with other trackers are deliberately deferred until we see whether the dashboard alone changes founder behavior.
- Single-machine only. No multi-device sync of raw events. If you use multiple Macs, you get one local log per machine. Cloud sync rolls them up by user, but the raw JSONL stays per-machine.
These are explicit maybes — they ship only if the private beta validates that the category view actually changes founder behavior.
- Per-user category taxonomy (let users edit the 7 buckets to fit their own work)
- Weekly Sunday-retro email summarizing the past week's 3-circles split
- Calendar.app overlay so meetings align with focus blocks
- Sparkle auto-update so beta users don't have to redownload
.dmgfor each version - A "kill criteria" page in the dashboard that flags weeks ≥50% Joy+Skill
Explicitly not on the roadmap, ever: team plans, screenshots, keystroke logging, mobile app, Windows or Linux ports, social-sharing of categories, gamification / streaks. These are commitments, not omissions — listing them prevents drift.
If you'd find any of the maybes useful, open an issue and say so.
Issues and pull requests welcome. Please:
- Run
./scripts/check-privacy.shbefore pushing — the build script does this, but it's faster to catch locally. - Don't add third-party dependencies. The project's whole shape is "auditable in one sitting." If you need a library, the bar is "I rewrote it twice and still got it wrong."
- Don't add new
URLSessionsites. v2 has exactly one —Sources/miniowl/Categorization/CategorizationClient.swift— and the privacy script enforces that. New network paths must either route through that file or land in a new dedicated file with explicit privacy review. - Keep watchers off the main thread. AX and AppleScript calls can block for hundreds of milliseconds — main is reserved for SwiftUI.
Inspired by ActivityWatch. miniowl is a deliberately smaller, single-user reimagining in native Swift — none of ActivityWatch's source code is used.
MIT — do what you want, no warranty, attribute if you fork for distribution.
{ "tz": "Asia/Tokyo", "window_start": 1712739600000, "window_end": 1712740800000, "events": [ { "b": "com.jetbrains.intellij", // bundle id "n": "IntelliJ IDEA", // app display name "ti": "miniowl – CategorizationClient.swift", // window title (truncated to 120 chars) "u": null, // URL host+first-path (truncated to 120 chars), null unless browser "ms": 480000 // duration in ms within this window } // ... up to 400 deduped events per call ] }