Skip to content

moeki0/tunr

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

186 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tunr

Screen & audio context provider for Claude Code.

tunr captures your macOS screen and system audio, then delivers it to Claude Code through MCP — so Claude can see what you're looking at and hear what you're listening to.

Install & set up via Claude Code

Let Claude Code do the install and onboarding for you. In any Claude Code session, ask:

Follow https://github.com/moeki0/tunr/blob/main/docs/claude-code-onboarding.md to install and onboard me onto tunr.

Claude Code will run through the runbook — installing tunr, registering the MCP server, starting the daemon, creating channels, and verifying end-to-end. It will pause and ask you when a step requires GUI interaction (Accessibility permission, Audio MIDI setup) or a privacy decision (deny list, Chrome JS).

Prefer manual setup? Continue reading.

What it does

  • Screen capture — Reads visible text from macOS windows via the Accessibility API. Debounce-based recording captures settled content, and channel notifications use compact unified diffs to minimize context usage
  • Audio capture (optional) — System audio via BlackHole + local transcription with whisper.cpp
  • Channels — Group windows into named channels, subscribe from Claude Code for real-time updates
  • Deny list — Glob-based rules to block specific apps, titles, or URLs from ever being captured
  • Ingest — Pipe arbitrary text from external sources (git hooks, shell history, RSS, etc.) via tunr ingest
  • Search — Vector similarity search and keyword search over screen, audio, and ingested history
  • Privacy-first — All data stays local in SQLite. Only assigned windows are captured. Deny list rules override all channel assignments. No data leaves your machine unless Claude Code reads it

Install

brew install moeki0/tunr/tunr

Permissions

Grant these permissions to your terminal app (System Settings > Privacy & Security):

  • Accessibility — Required for reading window text

Chrome web content (optional)

To capture web page text from Chrome (not just tab titles), enable AppleScript JS execution:

defaults write com.google.Chrome AllowJavaScriptAppleEvents -bool true

This persists across Chrome restarts. To disable: defaults delete com.google.Chrome AllowJavaScriptAppleEvents

Other Chromium browsers work too:

defaults write com.microsoft.edgemac AllowJavaScriptAppleEvents -bool true
defaults write com.brave.Browser AllowJavaScriptAppleEvents -bool true

Security note: This allows any app with macOS Automation permission to execute JavaScript in your Chrome tabs via AppleScript. macOS TCC requires explicit per-app Automation access, so only apps you approve can use this. If you're concerned, leave this off — tunr will still capture window titles and native app text via the Accessibility API.

MCP server setup

Register the MCP server with Claude Code:

claude mcp add -s user tunr -- tunr mcp

Start Claude Code with channels enabled (required for real-time streaming). The flag name sounds scary but it just enables the MCP channel protocol — no security risk:

claude --dangerously-load-development-channels server:tunr

Audio setup (optional — skip this if you only need screen capture)

Audio capture requires BlackHole as a virtual audio loopback device.

  1. Install BlackHole:
brew install --cask blackhole-2ch
  1. Open Audio MIDI Setup (in /Applications/Utilities)
  2. Click + at the bottom left and select Create Multi-Output Device
  3. Check both your speakers/headphones and BlackHole 2ch
  4. Set the multi-output device as your system output

Download the whisper.cpp model for transcription:

curl -L -o ~/.cache/whisper-cpp-small.bin \
  https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin

Install whisper-cpp (if not already installed):

brew install whisper-cpp

Usage

Start the engine

tunr start            # spawn the engine as a background daemon
tunr status           # check whether it's running
tunr stop             # stop it
tunr start --foreground   # run inline (debug)

tunr start daemonizes: it writes a PID file to <DATA_DIR>/tunr.pid, redirects stdout/stderr to <DATA_DIR>/tunr.log, and returns immediately. The engine polls windows, records assigned sources, and streams audio.

All controls — channels, source assignment, deny rules, settings — are CLI subcommands you run anytime while the daemon is up.

CLI

Command Description
tunr start [--foreground] Start the daemon (or run inline for debug)
tunr stop Stop the daemon
tunr status Print daemon status
tunr log [-f|--follow] Print recent captures (tail with --follow)
tunr sources [list] [--json] List currently detected windows + their channel assignments (TSV)
tunr sources assign <window-key> <channel> Assign a window to a channel
tunr sources unassign <window-key> <channel> Unassign
tunr channels List channels
tunr channels add <name> Create a channel
tunr channels rm <name> Delete a channel
tunr deny List deny rules
tunr deny add [--app G] [--title G] [--url G] Add a deny rule (glob)
tunr deny rm <index> Remove a deny rule
tunr config get [<key>] Read settings (dot-notation key)
tunr config set <key> <value> Write settings
tunr config unset <key> Remove a setting

Channels

Channels are named groups of windows. Create one with tunr channels add <name>, then assign windows to it. Only windows assigned to a channel are captured and broadcast to Claude Code.

  • A window can belong to multiple channels
  • Claude Code subscribes via subscribe(channel) to receive real-time updates
  • Unassigned windows are never captured
  • Source assignments are ephemeral — keyed by current window IDs, they may not survive a tunr start restart. Reassign as needed (or pipe through fzf as below)

Assigning sources via fzf

tunr sources outputs TSV starting with the window key, which is exactly what fzf wants:

tunr sources | fzf --bind 'enter:execute(tunr sources assign {1} Hobby)'
tunr sources | fzf -m | awk '{print $1}' | xargs -n1 -I{} tunr sources assign {} Hobby

Deny list

Block specific apps, window titles, or URLs from ever being captured. Rules use glob matching (* as wildcard, exact match otherwise). Multi-field rules use AND logic — all specified fields must match.

tunr deny add --app 1Password
tunr deny add --url '*mail.google.com*'
tunr deny add --app 'Google Chrome' --url '*private*'
tunr deny           # list
tunr deny rm 0      # remove by index

Ingest (pipe external data)

Pipe arbitrary text into tunr from any source:

echo "some text" | tunr ingest --source <name> [--channel <name>] [--meta key=value]

Ingested data is stored with embeddings and becomes searchable alongside screen and audio history via MCP tools.

Examples

# Git post-commit hook — record every commit
git log -1 --stat --format="%h %s%n%n%b" | tunr ingest --source git --meta "repo=$(basename $(git rev-parse --show-toplevel))"

# Shell command output
kubectl get pods | tunr ingest --source kubectl --channel dev

# Pipe anything
curl -s https://example.com/api | tunr ingest --source api --meta "endpoint=/api"

Global git hook

To automatically record all commits across all repos:

# Create global hooks directory
mkdir -p ~/.config/git/hooks

# Create post-commit hook
cat > ~/.config/git/hooks/post-commit << 'HOOK'
#!/bin/sh
git log -1 --stat --format="%h %s%n%n%b" | tunr ingest --source git --meta "repo=$(basename $(git rev-parse --show-toplevel))"
HOOK
chmod +x ~/.config/git/hooks/post-commit

# Set global hooks path
git config --global core.hooksPath ~/.config/git/hooks

Claude Code hooks

Record your own prompts, tool uses, and turn completions from Claude Code into tunr — searchable later via search_screen_history.

Create a hook script that forwards Claude Code's hook payload (JSON on stdin) to tunr ingest:

mkdir -p ~/.claude/hooks
cat > ~/.claude/hooks/tunr-ingest.sh << 'HOOK'
#!/bin/bash
set -euo pipefail

event="${1:-unknown}"
payload="$(cat)"
session=$(printf '%s' "$payload" | jq -r '.session_id // ""')

case "$event" in
  user_prompt)
    body=$(printf '%s' "$payload" | jq -r '.prompt // ""')
    ;;
  tool_use)
    tool=$(printf '%s' "$payload" | jq -r '.tool_name // ""')
    body=$(printf '%s' "$payload" | jq -r '"Tool: \(.tool_name)\nInput: \(.tool_input | tostring)"')
    printf '%s' "$body" | tunr ingest \
      --source claude-code \
      --meta "event=tool_use" \
      --meta "tool=$tool" \
      --meta "session=$session"
    exit 0
    ;;
  stop)
    transcript=$(printf '%s' "$payload" | jq -r '.transcript_path // ""')
    if [ -n "$transcript" ] && [ -f "$transcript" ]; then
      body=$(tail -n 50 "$transcript" \
        | jq -rs 'map(select(.type=="assistant")) | last
                  | (.message.content // [])
                  | map(select(.type=="text") | .text) | join("\n")')
    else
      body="Claude Code turn completed"
    fi
    ;;
  *)
    body="$payload"
    ;;
esac

printf '%s' "$body" | tunr ingest \
  --source claude-code \
  --meta "event=$event" \
  --meta "session=$session"
HOOK
chmod +x ~/.claude/hooks/tunr-ingest.sh

Then wire the hooks into ~/.claude/settings.json:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/tunr-ingest.sh user_prompt" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/tunr-ingest.sh tool_use" }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/tunr-ingest.sh stop" }
        ]
      }
    ]
  }
}

Adjust the PostToolUse matcher (Bash, Edit, Write, etc.) to record only the tools you care about.

GitHub notifications

Poll your GitHub notifications (PR reviews, mentions, assignments, etc.) and stream them into tunr.

mkdir -p ~/.local/bin ~/.cache
cat > ~/.local/bin/tunr-github-poll.sh << 'POLL'
#!/bin/bash
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"

since_file="$HOME/.cache/tunr-github-since"
since=$(cat "$since_file" 2>/dev/null || date -u -v-10M +%Y-%m-%dT%H:%M:%SZ)
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)

gh api "notifications?since=${since}&all=true" 2>/dev/null \
  | jq -c '.[]?' \
  | while read -r n; do
      reason=$(printf '%s' "$n" | jq -r '.reason // ""')
      repo=$(printf '%s' "$n" | jq -r '.repository.full_name // ""')
      type=$(printf '%s' "$n" | jq -r '.subject.type // ""')
      title=$(printf '%s' "$n" | jq -r '.subject.title // ""')
      url=$(printf '%s' "$n" | jq -r '.subject.url // ""')
      updated=$(printf '%s' "$n" | jq -r '.updated_at // ""')

      printf '[%s] %s — %s\n%s\n%s\n' "$reason" "$repo" "$title" "$type" "$url" \
        | tunr ingest --source github \
            --meta "reason=$reason" \
            --meta "repo=$repo" \
            --meta "type=$type" \
            --meta "updated=$updated" || true
    done

printf '%s' "$now" > "$since_file"
POLL
chmod +x ~/.local/bin/tunr-github-poll.sh

Run it every 5 minutes via launchd:

<!-- ~/Library/LaunchAgents/com.you.tunr-github-poll.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.you.tunr-github-poll</string>
  <key>ProgramArguments</key>
  <array><string>/Users/YOU/.local/bin/tunr-github-poll.sh</string></array>
  <key>StartInterval</key><integer>300</integer>
  <key>RunAtLoad</key><true/>
</dict>
</plist>
launchctl load ~/Library/LaunchAgents/com.you.tunr-github-poll.plist

Requires gh auth login (uses your existing GitHub CLI auth). The since cursor at ~/.cache/tunr-github-since ensures no duplicate ingestion.

Capture (one-shot)

Capture the focused window into tunr's DB so subscribed Claude Code sessions see it:

tunr capture

Add --image (or -i) to also save a screenshot of that window. The image lives at <DATA_DIR>/screenshots/<timestamp>.png and its path is stored on the record:

tunr capture --image

Manual-only by design — keep the auto-recording loop text-driven, and use --image when GUI nuance or atmosphere matters (UI bug, dashboard layout, design review).

Bind these to a keyboard shortcut (e.g. via Raycast or macOS Shortcuts) for quick screen sharing.

tunr send continues to work as an alias for tunr capture.

Export / Import

Move captures between machines, or archive a day's worth of context:

# Export today's captures (default: tar.gz bundling data.jsonl + screenshots/)
tunr export --date today

# Export an arbitrary day
tunr export --date 2026-04-27

# Custom range / channel filter
tunr export --since 2026-04-01 --until 2026-04-07 --channel dev

The default output is tunr-export-<date>.tar.gz. Pass --out path.jsonl.gz for the legacy single-file format (no screenshots).

tunr import tunr-export-2026-04-27.tar.gz

Imports are idempotent — re-importing the same archive (or merging archives from another machine) won't duplicate rows. UNIQUE constraints on (timestamp, pid, window_id, window_title) and friends drop the duplicates silently. Screenshots from a .tar.gz archive are copied into the local <DATA_DIR>/screenshots/ and paths rewritten to local absolute paths.

MCP Tools

These tools are available to Claude Code when the MCP server is running:

Channel controls

Tool Description
list_channels() List available channels and subscription status
subscribe(channel) Subscribe to a channel for real-time notifications
unsubscribe(channel) Stop receiving from a channel
pause() Pause all subscriptions (remembered for resume)
resume() Resume all paused subscriptions

Screen tools

Tool Description
search_screen_history(query, channel?, app?, minutes?, limit?) Search screen and ingested text (vector similarity + keyword fallback)
recent_screens(channel?, app?, minutes?, limit?) Get recent screen states and ingested records
page_history(title, minutes?, limit?) Change history of a page (initial capture + diffs)

Audio tools

Tool Description
recent_audio(channel?, minutes?, limit?) Get recent audio transcriptions
search_audio(query, channel?, minutes?, limit?) Search audio transcriptions by keyword

Plugin

tunr includes a Claude Code plugin with slash commands for channel management:

Command Description
/tunr:subscribe <channels> Subscribe to channels (e.g. dev or dev,research)
/tunr:unsubscribe <channels> Unsubscribe from channels
/tunr:pause Pause all subscriptions (remembered for resume)
/tunr:resume Resume paused subscriptions
/plugin marketplace add moeki0/tunr-skill && /plugin install tunr@tunr

Architecture

┌─────────────────────────────────────────────────────┐
│           tunr start (foreground engine)            │
│  polls windows · records assigned · audio capture   │
└──────────────────┬──────────────────────────────────┘
                   │ reads/writes
                   ▼
        ┌──────────────────────────────┐
        │     SQLite (local DB)        │◀──── tunr assign / channels / deny
        └──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────────┐
│  Subscribed channels → channel events               │
└──────────────────┬──────────────────────────────────┘
                   │
                   ▼
          ┌────────────────┐
          │ mcp-server.ts  │
          │  (MCP/stdio)   │──────▶  Claude Code
          │                │  tools / channels
          └────────────────┘

tunr send ──────────────────────▶  SQLite (direct write)
  (one-shot, AX API)

echo | tunr ingest ─────────────▶  SQLite (direct write)
  (stdin, any source)

Components

File Description
start.ts Foreground engine entry — wires lib/engine.ts and handles signals
lib/engine.ts Capture engine: window polling, recording, audio/mic loops
lib/sources.ts Live sources table + channel assignment helpers
commands.ts CLI subcommands (sources, channels, assign, deny, log, config)
mcp-server.ts MCP server. Provides search/history tools and channel event polling
cli.ts CLI entry point — dispatches all subcommands
ingest.ts Stdin ingestion. Reads text, generates embedding, writes to ingested table
ax_text.swift Accessibility API text extractor. --all returns all windows as JSON with URLs for browser tabs. Uses AppleScript JS for Chrome web content
send.ts One-shot screen capture. Reads frontmost window via ax_text and writes directly to DB
embed.swift NLEmbedding (macOS NaturalLanguage framework) for 512-dim sentence embeddings used in vector search
audio_capture.swift System audio capture via AVFoundation + BlackHole. Records WAV chunks at 16kHz mono

Data storage

All data is stored locally at ~/Library/Application Support/tunr/:

  • tunr.db — SQLite database with screen states, audio transcripts, and ingested records
  • settings.json — Recording settings, deny list rules

License

MIT

About

Screen context provider for Claude Code via MCP

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors