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.
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.
- 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
brew install moeki0/tunr/tunrGrant these permissions to your terminal app (System Settings > Privacy & Security):
- Accessibility — Required for reading window text
To capture web page text from Chrome (not just tab titles), enable AppleScript JS execution:
defaults write com.google.Chrome AllowJavaScriptAppleEvents -bool trueThis 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 trueSecurity 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.
Register the MCP server with Claude Code:
claude mcp add -s user tunr -- tunr mcpStart 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:tunrAudio capture requires BlackHole as a virtual audio loopback device.
- Install BlackHole:
brew install --cask blackhole-2ch- Open Audio MIDI Setup (in /Applications/Utilities)
- Click + at the bottom left and select Create Multi-Output Device
- Check both your speakers/headphones and BlackHole 2ch
- 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.binInstall whisper-cpp (if not already installed):
brew install whisper-cpptunr 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.
| 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 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 startrestart. Reassign as needed (or pipe through fzf as below)
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 {} HobbyBlock 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 indexPipe 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.
# 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"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/hooksRecord 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.shThen 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.
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.shRun 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.plistRequires gh auth login (uses your existing GitHub CLI auth). The since cursor at ~/.cache/tunr-github-since ensures no duplicate ingestion.
Capture the focused window into tunr's DB so subscribed Claude Code sessions see it:
tunr captureAdd --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 --imageManual-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 sendcontinues to work as an alias fortunr capture.
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 devThe 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.gzImports 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.
These tools are available to Claude Code when the MCP server is running:
| 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 |
| 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) |
| Tool | Description |
|---|---|
recent_audio(channel?, minutes?, limit?) |
Get recent audio transcriptions |
search_audio(query, channel?, minutes?, limit?) |
Search audio transcriptions by keyword |
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┌─────────────────────────────────────────────────────┐
│ 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)
| 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 |
All data is stored locally at ~/Library/Application Support/tunr/:
tunr.db— SQLite database with screen states, audio transcripts, and ingested recordssettings.json— Recording settings, deny list rules
MIT