When I first tried to let Claude schedule my daily report, the CLI kept printing “thinking…” for minutes while the terminal was frozen. I had no way to see which file was opened, abort the action, or know if Claude was still alive after my laptop slept. The result? A half‑generated PDF and a very nervous mind.
What you really need is a tiny, always‑on‑screen companion that lets Claude run in the background, stream its thoughts, and expose a cancel button the moment something looks off. A system‑tray app gives you exactly that: a persistent UI that lives in the taskbar, can survive sleep/wake cycles, and talks to a separate worker process so the UI never blocks.
- Use a decoupled QThread worker to keep the GUI responsive while Claude streams responses.
- Persist conversation state and tool‑use progress in a local SQLite cache keyed by session ID.
- Handle Anthropic rate limits with exponential back‑off and a per‑minute token budget.
- Secure API keys with the OS keychain (keyring on macOS/Windows, secret‑service on Linux).
- Package the final tray app with PyInstaller or Tauri for a single‑click installer.
Before you start: Python 3.11+, Anthropic SDK ≥ 0.20, PyQt6 ≥ 6.5, keyring ≥ 24.0, SQLite bundled with Python, Git ≥ 2.40. Grab a Claude 3.5‑Sonnet API key from Anthropic Console and store it with your OS keychain.
Beyond the Chatbox: Why a System Tray Agent for Claude AI?
A Claude Agent System Tray Application is a persistent AI automation tool that lives in your OS taskbar. Built using a stack like Python (Anthropic SDK), PyQt6 for the GUI, and IPC for communication, it runs background tasks, handles event‑driven prompts, and automates workflows via Claude’s Computer Use and Tool Use APIs, all without a browser tab.
Core CLI Agent Backbone (SDK)
The Anthropic SDK gives us `ClaudeClient`, a thin wrapper over the HTTP endpoint. In 2026 the SDK ships with native streaming support (`client.messages.create(…, stream=True)`) and a helper for the *Computer Use* toolset. Using the SDK directly from the GUI thread is a recipe for a frozen tray icon, so we isolate it in a worker thread.
Tray & GUI Layer (Most Practical 2026 Choice)
PyQt6 remains the most pragmatic way to embed a system‑tray icon on Windows, macOS, and Linux while still writing pure‑Python code. Its `QSystemTrayIcon` class lets us add a context menu, show balloon notifications, and pop up a small window with live logs.
**My take:** If you already have a Python‑centric agent core, avoid the overhead of Electron or Tauri unless you need a web‑based UI. PyQt gives you native look‑and‑feel, low memory footprint, and direct access to Qt’s signal/slot IPC.
The Communication Bridge (IPC/Event Loop)
Qt’s signal/slot mechanism works across threads, so the UI thread can emit a *start‑task* signal that the worker thread listens to. The worker streams Claude’s tokens back via a custom `responseChunk` signal, which the UI app appends to a read‑only `QTextEdit`. For more complex scenarios (multiple agents, external plugins) you can replace the in‑process bridge with a tiny ZeroMQ socket or a named‑pipe, but the built‑in slot system is sufficient for most desktop apps.
# worker.py – dedicated QThread for Claude I/O
# Requires: PyQt6>=6.5, anthropic>=0.20, keyring>=24.0
import sys, json, time, queue
from PyQt6.QtCore import QThread, pyqtSignal
from anthropic import Anthropic, BAD_REQUEST
import keyring
class ClaudeWorker(QThread):
# Emitted for each token chunk
responseChunk = pyqtSignal(str)
# Emitted when the task finishes (success or error)
taskFinished = pyqtSignal(bool, str) # success, message
def __init__(self, parent=None):
super().__init__(parent)
self._task_queue = queue.Queue()
self._stop_requested = False
self.client = Anthropic(
api_key=keyring.get_password("anthropic", "api_key")
)
def run(self):
while not self._stop_requested:
try:
task = self._task_queue.get(timeout=0.2)
self._handle_task(task)
except queue.Empty:
continue
except Exception as e:
self.taskFinished.emit(False, f"Worker crash: {e}")
break
def submit(self, prompt: str, tools: list = None):
"""Public API for the GUI to enqueue a new request."""
self._task_queue.put({"prompt": prompt, "tools": tools or []})
def _handle_task(self, task: dict):
prompt, tools = task["prompt"], task["tools"]
try:
# Stream tokens; each chunk is a dict per SDK spec
for chunk in self.client.messages.create(
model="claude-3-5-sonnet-202406",
max_tokens=1024,
temperature=0.0,
system="You are a helpful assistant.",
messages=[{"role": "user", "content": prompt}],
tools=tools,
stream=True,
):
if "type" in chunk and chunk["type"] == "content_block_delta":
self.responseChunk.emit(chunk["delta"]["text"])
self.taskFinished.emit(True, "Done")
except BAD_REQUEST as e:
# Handles rate‑limit or invalid request errors
self.taskFinished.emit(False, f"Bad request: {e}")
except Exception as e:
self.taskFinished.emit(False, f"Unexpected error: {e}")
def stop(self):
self._stop_requested = True
self.wait()
The worker uses **keyring** to fetch the API key at runtime, keeping secrets out of source control. It also respects a simple stop flag for graceful shutdown.
—
2026 Architecture Stack: Essential Tech for Tray Agents & GUI Automation
graph TD
A[GUI Thread (PyQt6)] -->|Signal: startTask| B[Worker Thread (ClaudeWorker)]
B -->|Signal: responseChunk| C[Live Log Widget]
B -->|Signal: taskFinished| D[State Manager (SQLite)]
D -->|Persist| E[Session DB]
A -->|User Interaction| F[System Tray Icon]
F -->|Menu Actions| A
B -->|Tool Calls| G[Computer Use API]
G -->|Results| B
**Data flow** – The GUI emits `startTask`. The worker streams Claude’s output back to the UI, which appends each token to a scrolling text area. When Claude requests a computer‑use tool, the worker calls the *Computer Use* endpoint, receives the result, and feeds it back into the same conversation. All intermediate state (prompt, tool‑call ID, partial transcript) lands in a tiny SQLite file so the app can restart after a system sleep without losing context.
Communication Details
| Layer | Mechanism | Why |
|---|---|---|
| GUI ↔ Worker | Qt `pyqtSignal` / `pyqtSlot` | Low‑latency, thread‑safe, no extra dependencies |
| Worker ↔ Claude | Anthropic SDK streaming | Gets token‑by‑token updates, reduces latency |
| Worker ↔ Computer Use | Direct SDK call (`client.computer.use`) | Secure sandboxed OS actions |
| Persistence | SQLite (`session_id` primary key) | Atomic writes, cross‑platform, no external DB |
—
Step‑by‑Step Build: Creating Your Claude Tray Agent Project
Step 1: Project Setup & Agent Core (`anthropic>=0.20`)
Open a fresh directory and create a virtual environment.
# Terminal
python3.11 -m venv .venv
source .venv/bin/activate
pip install "anthropic>=0.20" "PyQt6>=6.5" keyring==24.2 tqdm
Create a tiny `config.py` to centralise constants and secret handling.
# config.py
import keyring
from pathlib import Path
API_SERVICE = "anthropic"
API_USER = "api_key"
def get_api_key() -> str:
key = keyring.get_password(API_SERVICE, API_USER)
if not key:
raise RuntimeError("Claude API key not found in OS keychain")
return key
# Path for the SQLite cache (located next to the script)
DB_PATH = Path(__file__).with_name("agent_state.db")
Run `keyring.set_password(“anthropic”, “api_key”, “sk-…”)` once from a REPL to seed the keychain. Never commit secrets.
Step 2: Building the Minimal GUI Tray with PyQt6
Create `gui.py`. The UI consists of a tray icon, a pop‑up window with a text field for user prompts, a *Send* button, and a scrollable log view.
# gui.py
# Requires: PyQt6>=6.5
import sys
from PyQt6.QtWidgets import (
QApplication, QWidget, QSystemTrayIcon, QMenu,
QTextEdit, QLineEdit, QPushButton, QVBoxLayout,
QMessageBox,
)
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import Qt, pyqtSlot
from worker import ClaudeWorker
TRAY_ICON = "🧠"
class TrayApp(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Claude Tray Agent")
self.resize(460, 300)
# UI widgets
self.log = QTextEdit(readOnly=True)
self.input = QLineEdit()
self.send_btn = QPushButton("Send")
self.send_btn.clicked.connect(self.on_send)
layout = QVBoxLayout()
layout.addWidget(self.log)
layout.addWidget(self.input)
layout.addWidget(self.send_btn)
self.setLayout(layout)
# System tray
self.tray = QSystemTrayIcon(QIcon.fromTheme("applications-education"))
self.tray.setToolTip("Claude Automation Agent")
self.tray.setIcon(QIcon.fromTheme("applications-education"))
self.tray.setVisible(True)
tray_menu = QMenu()
tray_menu.addAction("Show", self.show)
tray_menu.addAction("Hide", self.hide)
tray_menu.addSeparator()
tray_menu.addAction("Quit", self.close)
self.tray.setContextMenu(tray_menu)
# Worker thread
self.worker = ClaudeWorker()
self.worker.responseChunk.connect(self.append_chunk)
self.worker.taskFinished.connect(self.on_finish)
self.worker.start()
@pyqtSlot()
def on_send(self):
prompt = self.input.text().strip()
if not prompt:
QMessageBox.warning(self, "Empty", "Prompt cannot be empty")
return
self.log.append(f"> {prompt}")
self.input.clear()
# Submit to worker; no tools for now
self.worker.submit(prompt)
@pyqtSlot(str)
def append_chunk(self, text):
self.log.moveCursor(Qt.TextCursor.MoveOperation.End)
self.log.insertPlainText(text)
self.log.moveCursor(Qt.TextCursor.MoveOperation.End)
@pyqtSlot(bool, str)
def on_finish(self, success, msg):
if success:
self.log.append("\n✅ Task completed")
else:
self.log.append(f"\n❌ Error: {msg}")
QMessageBox.critical(self, "Error", msg)
def closeEvent(self, event):
self.worker.stop()
super().closeEvent(event)
if __name__ == "__main__":
app = QApplication(sys.argv)
win = TrayApp()
win.hide() # Start hidden; only tray visible
sys.exit(app.exec())
This skeleton already streams Claude’s response token‑by‑token. Run `python gui.py` and you’ll see a tray icon; clicking *Show* opens the log window. Type a sentence, hit **Send**, and watch the reply appear live.
Tip: Use `QPlainTextEdit` instead of `QTextEdit` if you don’t need rich text – it renders faster for high‑frequency updates.
Step 3: Constructing a Robust IPC System (Signal/Slot)
The simple signal flow works for a single prompt, but a real automation loop may involve multiple tool calls. We extend the worker to emit a *toolRequested* signal that the GUI can optionally show a confirmation dialog.
# In worker.py (add after class definition)
toolRequested = pyqtSignal(dict) # payload includes tool name, args
def _handle_task(self, task: dict):
...
for chunk in self.client.messages.create(..., stream=True):
if chunk.get("type") == "tool_use":
# Pause streaming, ask UI to confirm
self.toolRequested.emit(chunk["tool_use"])
# Wait for UI to send back the result via a slot
result = self._await_tool_result(chunk["tool_use"]["id"])
# Feed result back into the conversation
self.client.messages.create(
model="claude-3-5-sonnet-202406",
messages=[{"role": "assistant", "content": result}],
stream=False,
)
elif chunk.get("type") == "content_block_delta":
self.responseChunk.emit(chunk["delta"]["text"])
...
@pyqtSlot(str, dict)
def provide_tool_result(self, tool_id: str, result: dict):
"""Called by GUI when user approves a tool action."""
self._tool_results[tool_id] = result
In `gui.py` we add a slot to receive the request and display a modal.
# Inside TrayApp.__init__
self.worker.toolRequested.connect(self.on_tool_requested)
@pyqtSlot(dict)
def on_tool_requested(self, payload):
tool = payload["name"]
args = json.dumps(payload["input"], indent=2)
reply = QMessageBox.question(
self,
"Tool Request",
f"Claude wants to run **{tool}** with arguments:\n{args}\nAllow?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
# For the Computer Use API we just forward the payload directly
self.worker.provide_tool_result(payload["id"], {"status": "approved"})
else:
self.worker.provide_tool_result(payload["id"], {"status": "rejected"})
Now the UI can intervene whenever Claude tries to move the mouse or read a file, preventing rogue automation.
Warning: Never auto‑approve a tool call in production. Always surface a confirmation UI or enforce a policy whitelist.
Step 4: Integrating Tool Use & Computer Use for Automation
Claude’s *Computer Use* API offers a sandboxed “virtual machine” that can read screen pixels, click, type, and even run shell commands. We expose a helper function in `utils.py`.
# utils.py
import json
from anthropic import Anthropic
import keyring
def computer_use(action: str, arguments: dict) -> dict:
client = Anthropic(api_key=keyring.get_password("anthropic", "api_key"))
response = client.computer.use(
model="claude-3-5-sonnet-202406",
action=action,
arguments=arguments,
timeout=30,
)
return response # dict with "status" and optional "output"
When the worker receives a `tool_use` of type `”computer.use”`, it calls `computer_use` and sends the result back. The GUI can also expose a *Run Script* button that directly routes user‑supplied shell snippets to the same helper, making the tray app a low‑code automation hub.