Content
# tunr
Screen & audio context provider for [Claude Code](https://docs.anthropic.com/en/docs/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](docs/claude-code-onboarding.md) — 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
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
claude --dangerously-load-development-channels server:tunr
```
### Audio setup (optional — skip this if you only need screen capture)
Audio capture requires [BlackHole](https://github.com/ExistentialAudio/BlackHole) as a virtual audio loopback device.
1. Install BlackHole:
```bash
brew install --cask blackhole-2ch
```
2. Open **Audio MIDI Setup** (in /Applications/Utilities)
3. Click **+** at the bottom left and select **Create Multi-Output Device**
4. Check both your speakers/headphones and **BlackHole 2ch**
5. Set the multi-output device as your system output
Download the whisper.cpp model for transcription:
```bash
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):
```bash
brew install whisper-cpp
```
## Usage
### Start the engine
```bash
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:
```bash
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.
```bash
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:
```bash
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
```bash
# 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:
```bash
# 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`:
```bash
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`:
```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.
```bash
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:
```xml
<!-- ~/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>
```
```bash
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:
```bash
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:
```bash
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:
```bash
# 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).
```bash
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 |
```bash
/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
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
markitdown
MarkItDown-MCP is a lightweight server for converting URIs to Markdown.
markitdown
Python tool for converting files and office documents to Markdown.
Filesystem
Node.js MCP Server for filesystem operations with dynamic access control.
TrendRadar
TrendRadar: Your hotspot assistant for real news in just 30 seconds.
mempalace
The highest-scoring AI memory system ever benchmarked. And it's free.
mempalace
The highest-scoring AI memory system ever benchmarked. And it's free.