When I first tried to run a Claude‑powered desktop assistant on a timer, the app froze the moment the schedule fired. The UI went black, the thread dead‑locked, and Claude never sent a response. The root cause was obvious in hindsight: I mixed a blocking `requests` call with PyQt’s event loop. The fix isn’t a tiny tweak—it requires a clean separation between the scheduler, the agent’s state, and the GUI.
Below is a production‑ready walk‑through that keeps the UI snappy, persists conversation history across runs, and lets you hook Claude’s Computer Use tools into any cron‑style schedule.
- Use APScheduler + Qt‑compatible thread pool to fire tasks without blocking the UI.
- Persist Claude’s memory and tool results in a JSON + SQLite store.
- Wrap every Anthropic SDK call in a `QThread`‑based worker and emit signals for progress.
- Combine `croniter` for conditional logic (weekday‑only, success‑dependent runs).
- Package the whole stack with PyInstaller and run it as a system service.
Before you start: Python 3.12+, Anthropic Python SDK v1.15+, PyQt6 v6.7, APScheduler v4.3+, `croniter` v2.0, a Claude API key (Sonnet 3.7), Git, and a recent Windows 11 or macOS 14 desktop. Install with pip install -r requirements.txt.
How to implement time‑based triggers for a Desktop Claude Agent
To implement time‑based triggers for a Desktop Claude Agent, you need a core scheduler (like APScheduler), a non‑blocking GUI (PyQt), and the Anthropic SDK. The scheduler executes tasks, the agent uses Claude’s API for reasoning, and the GUI provides control and monitoring — all communicating via a thread‑safe event loop for reliability.
1. Anatomy of a 2026 Desktop Claude Agent
A modern Claude desktop agent consists of three logical layers:
| Layer | Responsibility | Typical tech |
|---|---|---|
| **SDK / Tool Loop** | Talk to Anthropic, format tool calls, stream responses | `anthropic` Python SDK (v1.15+) |
| **State & Persistence** | Keep conversation context, tool results, and schedule metadata across reboots | SQLite + JSON blobs (via `sqlalchemy`) |
| **GUI + Scheduler** | Render controls, show logs, fire jobs on schedule without freezing | PyQt6, APScheduler, `croniter` |
The agent can either use **Claude Computer Use** (Claude drives the desktop directly) or a **pure API** loop that returns action plans for an external executor like `pyautogui`. The former is more integrated but requires sandboxing; see the **[Secure File System Access for Claude Agents (Sandboxing & Permissions Guide)](https://nileshblog.tech/?p=7203)** for the security model.
Core Components: SDK, GUI Framework, Event Loop
- **Anthropic SDK** – async client (`AsyncAnthropic`) that streams `completion` objects. It automatically retries on 429/500 and respects `max_tokens`.
- **PyQt6** – provides a native‑looking UI and a `QThreadPool` that plays nicely with the Qt event loop.
- **APScheduler** – runs background jobs in a separate thread pool; we configure it with `BackgroundScheduler` and a `ThreadPoolExecutor` that matches the `QThreadPool` size.
Architecting for State and Task Persistence
Persistence is a frequent blind spot. If Claude needs to remember the outcome of yesterday’s market scrape, you must store the **conversation ID**, **tool output**, and **schedule status**. The following schema works well:
# models.py – SQLAlchemy v2.0+
from sqlalchemy import create_engine, Column, Integer, String, JSON, DateTime, Boolean
from sqlalchemy.orm import declarative_base, sessionmaker
from datetime import datetime
Base = declarative_base()
engine = create_engine("sqlite:///agent_state.db", echo=False, future=True)
Session = sessionmaker(bind=engine)
class ScheduleRecord(Base):
__tablename__ = "schedules"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
cron = Column(String, nullable=False) # e.g. "0 9 * * MON-FRI"
last_success = Column(Boolean, default=False)
last_run = Column(DateTime, default=datetime.min)
context = Column(JSON, default=dict) # Claude conversation memory
Base.metadata.create_all(engine)
Every time a job finishes, we update `last_success` and dump the current Claude `messages` list into `context`. On restart, the scheduler reads the rows and rehydrates the conversation.
Claude Computer Use vs. Direct API Integration
- **Computer Use** – Claude receives a `computer_use` tool, can open apps, move the mouse, etc. It requires granting the agent a sandboxed desktop environment (see the guide above). Use this mode when the schedule must interact with local software (e.g., open Excel, click a button).
- **Direct API** – Claude only returns plan JSON; you execute actions yourself. Simpler, works on headless machines, less permission hassle.
Both modes share the same scheduling backbone; you only swap the worker’s `run_task()` implementation.
—
2. Setting Up Your Scheduled Trigger Core
Choosing Your Clock: APScheduler vs. asyncio + croniter
`APScheduler` runs jobs in **real OS threads**, giving us isolated workers that can block on I/O without harming the UI. `asyncio` + `croniter` runs in a single thread, which would force you to make every SDK call non‑blocking (hard with streaming). In practice, the hybrid:
- `APScheduler` for cron parsing and job persistence.
- `croniter` for conditional “run only if previous succeeded AND it’s a weekday”.
Building the Heart: The Trigger & State Management Class
Create a reusable `SchedulerEngine` that knows how to:
- Load schedule rows from SQLite.
- Resolve conditional expressions (`prev_success and is_weekday`).
- Dispatch a `ClaudeWorker` in a `QThread`.
# scheduler_engine.py – Python 3.12
import logging
from datetime import datetime
from sqlalchemy.orm import Session
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from croniter import croniter
from PyQt6.QtCore import QObject, pyqtSignal, QThreadPool, QRunnable
log = logging.getLogger(__name__)
class ClaudeWorker(QRunnable):
"""Runs a Claude task in a background thread and emits Qt signals."""
def __init__(self, schedule_id: int, context: dict, ui_signal: QObject):
super().__init__()
self.schedule_id = schedule_id
self.context = context
self.ui_signal = ui_signal # QObject with custom signals
def run(self) -> None:
from claude_worker import ClaudeTask
task = ClaudeTask(self.schedule_id, self.context, self.ui_signal)
task.execute() # handles its own retry & persistence
class SchedulerEngine:
def __init__(self, ui_signal: QObject, thread_pool: QThreadPool):
self.ui_signal = ui_signal
self.thread_pool = thread_pool
self.scheduler = BackgroundScheduler()
self.scheduler.start()
self._load_jobs()
def _load_jobs(self) -> None:
sess = Session()
for rec in sess.query(ScheduleRecord).all():
trigger = CronTrigger.from_crontab(rec.cron)
self.scheduler.add_job(
self._run_job,
trigger,
args=[rec.id],
id=f"job-{rec.id}",
replace_existing=True,
misfire_grace_time=300,
)
sess.close()
log.info("All scheduled jobs loaded")
def _run_job(self, schedule_id: int) -> None:
sess = Session()
rec: ScheduleRecord = sess.get(ScheduleRecord, schedule_id)
# Conditional logic: only run if previous succeeded AND weekday
now = datetime.now()
is_weekday = now.weekday() < 5
if rec.last_success and is_weekday:
log.info(f"Launching job {rec.name}")
worker = ClaudeWorker(schedule_id, rec.context, self.ui_signal)
self.thread_pool.start(worker)
else:
log.info(
f"Skipping job {rec.name}: last_success={rec.last_success}, is_weekday={is_weekday}"
)
sess.close()
Key points:
- `ClaudeWorker` inherits `QRunnable` and runs inside the `QThreadPool`, so the Qt main loop stays responsive.
- The `ui_signal` object is a thin `QObject` subclass exposing `started`, `progress`, `finished` signals (see next section).
- The conditional clause (`rec.last_success and is_weekday`) demonstrates how to embed complex logic without leaving the scheduler.
Integrating the 2026 Anthropic Claude SDK for Tool Loops
The worker delegates to a `ClaudeTask` that handles streaming, tool execution, and state persistence.
# claude_worker.py
import json, logging, asyncio
from anthropic import AsyncAnthropic, APIError
from PyQt6.QtCore import QObject, pyqtSignal
from datetime import datetime
from models import Session, ScheduleRecord
log = logging.getLogger(__name__)
class ClaudeTaskSignals(QObject):
started = pyqtSignal(int) # schedule_id
progress = pyqtSignal(int, str) # schedule_id, partial text
finished = pyqtSignal(int, bool) # schedule_id, success flag
class ClaudeTask:
def __init__(self, schedule_id: int, context: dict, ui_signal: QObject):
self.schedule_id = schedule_id
self.context = context
self.signals = ClaudeTaskSignals()
# Forward signals to the external UI object
self.ui_signal.started.connect(self.signals.started)
self.ui_signal.progress.connect(self.signals.progress)
self.ui_signal.finished.connect(self.signals.finished)
self.client = AsyncAnthropic(api_key="YOUR_CLAUDE_API_KEY")
self.max_retries = 3
async def _call_claude(self, messages):
for attempt in range(1, self.max_retries + 1):
try:
async with self.client.messages.stream(
model="claude-3-7-sonnet-202406",
max_tokens=2048,
messages=messages,
) as stream:
async for event in stream:
if event.type == "content_block_delta":
delta = event.delta["text"]
self.signals.progress.emit(self.schedule_id, delta)
return True
except APIError as exc:
log.warning(f"Claude API error (attempt {attempt}): {exc}")
if attempt == self.max_retries:
raise
await asyncio.sleep(2 ** attempt) # exponential back‑off
def _persist_state(self, success: bool, new_context: dict):
sess = Session()
rec = sess.get(ScheduleRecord, self.schedule_id)
rec.last_success = success
rec.last_run = datetime.now()
rec.context = new_context
sess.commit()
sess.close()
def execute(self) -> None:
"""Entry point called from QThreadPool (runs in a pure thread)."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self.signals.started.emit(self.schedule_id)
# Rehydrate Claude messages from persisted context
messages = self.context.get("messages", [
{"role": "user", "content": "Run the scheduled task."}
])
try:
success = loop.run_until_complete(self._call_claude(messages))
except Exception as exc:
log.exception("Fatal error in ClaudeTask")
success = False
finally:
# Store the full conversation (including Claude's reply)
new_context = {"messages": messages}
self._persist_state(success, new_context)
self.signals.finished.emit(self.schedule_id, success)
loop.close()
**My take:** I always prefer the `AsyncAnthropic` client even when I run it inside a `QThread`. The async API lets me use exponential back‑off, streaming, and built‑in retries without resorting to a custom thread‑per‑call model.
—
3. Building the GUI Control Panel
Framework Choice: PyQt, Tkinter, or Tauri in 2026
- **PyQt6** – richest native widgets, excellent thread‑safety via signals/slots, and integrates cleanly with APScheduler.
- **Tkinter** – lightweight but lacks sophisticated threading helpers.
- **Tauri (Rust + Webview)** – great for web‑centric UI but adds Rust compilation overhead for a pure‑Python stack.
For this guide we stick with **PyQt6** because it gives us a single‑language codebase and a proven `QThreadPool`.
Crafting a Non‑Blocking UI: QtThreads & Signal‑Slots
The UI consists of three panels:
- **Schedule List** – TableView showing name, cron, last run, status, and a manual “Run Now” button.
- **Log Viewer** – `QPlainTextEdit` that receives `progress` signals.
- **Manual Trigger** – A button that forces a job to run irrespective of the schedule.
All UI updates happen via signals emitted from `ClaudeTaskSignals`. No direct GUI manipulation occurs inside the worker thread.
# gui_main.py
import sys, logging
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QTableWidget, QTableWidgetItem,
QPlainTextEdit, QMessageBox,
)
from PyQt6.QtCore import QObject, pyqtSignal, QThreadPool
from scheduler_engine import SchedulerEngine
from models import Session, ScheduleRecord
log = logging.getLogger(__name__)
class UIEventBus(QObject):
started = pyqtSignal(int)
progress = pyqtSignal(int, str)
finished = pyqtSignal(int, bool)
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Claude Desktop Scheduler")
self.resize(900, 600)
self.bus = UIEventBus()
self.thread_pool = QThreadPool.globalInstance()
self.scheduler = SchedulerEngine(self.bus, self.thread_pool)
self._setup_ui()
self._connect_signals()
self._refresh_schedule_table()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Schedule Table
self.table = QTableWidget(0, 5)
self.table.setHorizontalHeaderLabels(
["Name", "Cron", "Last Run", "Status", "Run Now"]
)
layout.addWidget(self.table)
# Log Viewer
self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True)
layout.addWidget(QLabel("Live Log"))
layout.addWidget(self.log_view)
# Manual Add Button (demo purpose)
add_btn = QPushButton("Add Example Schedule")
add_btn.clicked.connect(self._add_example_schedule)
layout.addWidget(add_btn)
def _connect_signals(self):
self.bus.started.connect(self._on_job_started)
self.bus.progress.connect(self._on_job_progress)
self.bus.finished.connect(self._on_job_finished)
# ---------- UI callbacks ----------
def _refresh_schedule_table(self):
sess = Session()
records = sess.query(ScheduleRecord).all()
self.table.setRowCount(len(records))
for row, rec in enumerate(records):
self.table.setItem(row, 0, QTableWidgetItem(rec.name))
self.table.setItem(row, 1, QTableWidgetItem(rec.cron))
self.table.setItem(row, 2, QTableWidgetItem(str(rec.last_run)))
status = "✅" if rec.last_success else "❌"
self.table.setItem(row, 3, QTableWidgetItem(status))
run_btn = QPushButton("▶")
run_btn.clicked.connect(lambda _, sid=rec.id: self._force_run(sid))
self.table.set