When I first glued a Claude 3.7 Sonnet agent to a desktop app, the UI froze the moment the agent hit a long‑running `computer.use` tool call. I watched the progress bar stall, the cancel button become unresponsive, and the whole process get killed by the OS watchdog. The root cause? Mixing a blocking HTTP client with the GUI’s single‑threaded event loop.

The fix is to treat the agent worker pool as a first‑class citizen: spin it off into a thread pool, funnel status through thread‑safe queues, and let the GUI react only to lightweight signals. The result is a responsive desktop controller that can chew through hundreds of PDFs, HTML reports, or CSV extracts while you watch a live progress feed and intervene at any moment.

—

⚡ TL;DR — Key takeaways
  • Separate the GUI event loop from Claude‑agent workers using `ThreadPoolExecutor`.
  • Persist queue state in SQLite/JSONL so a crash never loses work.
  • Centralize rate‑limit logic to keep every agent under the RPM/TPM caps.
  • Use PyQt6 signals (or Tkinter `after`) for thread‑safe UI updates.
  • Add idempotent retries and timeout recovery for production reliability.

Before you start: Python 3.12, Anthropic Python SDK 0.6+, PyQt6 6.6 (or Tkinter bundled with Python), SQLite3, a Claude 3.7 Sonnet API key, and a folder of .txt/.pdf documents you want to analyze.

Batch processing with Claude Agents: a quick definition

Batch processing with Claude Agents involves building a parallel execution system, typically in Python, where a pool of agent instances processes multiple documents simultaneously via the Anthropic API. A controller GUI (e.g., PyQt) manages the queue, monitors real‑time progress, and handles errors. The key is thread‑safe communication between the agents and the interface, along with robust rate limit and state management for production reliability.

The 2026 Batch Processing Landscape: Beyond Simple Scripts

The GUI/Agent Mandatory Pairing for Production

A pure‑script approach works for one‑off experiments, but production teams demand visibility, cancellation, and audit trails. Pairing a GUI with a set of autonomous Claude agents gives you live progress, manual overrides, and a place to surface tool‑call results without digging through log files.

Why Parallel Processing is Non‑Negotiable for Document Analysis

Claude can summarize a 2 KB paragraph in ~0.3 s, but a 500‑page PDF needs dozens of calls (chunking, summarization, extraction). Running those calls sequentially would waste minutes per doc and blow your token budget. Parallelism multiplies throughput while keeping per‑call latency low.

Common Architectural Pitfalls in Tutorials (And How to Avoid Them)

  • **UI thread blockage** – Mixing blocking `requests` calls with the Qt event loop freezes the UI.
  • **Stateless workers** – If the app crashes, the batch restarts from scratch, wasting credits.
  • **Global rate limit bypass** – Each thread independently throttles, leading to sudden 429 errors.

**My take:** Model your system as three layers—Orchestrator, Worker Pool, and Persistence. Keep each layer thin, test it in isolation, and only then wire them together.

Core Architecture: Designing for Parallelism and User Control

Orchestrator, Worker Pool, and State Management Components

  • **Orchestrator** – Holds the master queue (SQLite + JSONL), enforces global rate limits, and emits UI events.
  • **Worker Pool** – A `ThreadPoolExecutor` with a configurable max‑workers count; each worker runs a single Claude agent loop.
  • **State Store** – Two tables: `jobs(id, path, status, attempts, result_path)` and `metadata(key, value)`. The `status` column drives UI colour coding (queued, running, success, failed).

Choosing Your Tech Stack: Anthropic SDK 2026 vs. Desktop vs. Computer Use

  • **Anthropic SDK 0.6+** ships a native async client, but the `client.messages.create` call is still blocking when the tool use flow triggers `computer.use`. Stick to the sync client inside a thread; it sidesteps the GIL‑related race conditions.
  • **Claude Desktop** – Great for local GPU inference, but the API‑only path offers better scaling for batch jobs.
  • **Computer Use API** – Required for on‑screen actions (e.g., OCR on a PDF viewer). The SDK automatically marshals tool calls, you just need to implement the `tool_handler`.

Building the Central Work Queue and Real‑Time Progress Feed

# queue.py – SQLite + JSONL hybrid
import sqlite3, json, pathlib, uuid, datetime

DB_PATH = pathlib.Path("data/jobs.db")
JSONL_PATH = pathlib.Path("data/results.jsonl")

def init_db():
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute("""CREATE TABLE IF NOT EXISTS jobs (
        id TEXT PRIMARY KEY,
        path TEXT NOT NULL,
        status TEXT NOT NULL,
        attempts INTEGER DEFAULT 0,
        result_path TEXT
    )""")
    conn.commit()
    conn.close()

def enqueue_job(file_path: str):
    job_id = str(uuid.uuid4())
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute(
        "INSERT INTO jobs (id, path, status) VALUES (?,?,?)",
        (job_id, file_path, "queued")
    )
    conn.commit()
    conn.close()
    return job_id

def update_job(job_id: str, **kwargs):
    fields = ", ".join(f"{k}=?" for k in kwargs)
    values = list(kwargs.values()) + [job_id]
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute(f"UPDATE jobs SET {fields} WHERE id=?", values)
    conn.commit()
    conn.close()

The SQLite DB gives you ACID guarantees; the JSONL file appends each agent’s final JSON result for downstream analytics.

graph TD
    UI[GUI] -->|push job| Orchestrator
    Orchestrator -->|store| SQLite[(SQLite DB)]
    Orchestrator -->|dispatch| WorkerPool
    WorkerPool -->|call| ClaudeAgent[Claude Agent]
    ClaudeAgent -->|tool use| ComputerAPI[Computer Use API]
    ClaudeAgent -->|result| SQLite
    SQLite -->|emit| UI

Step‑by‑Step Build: The Worker‑Agent Implementation

Coding the Isolated Claude Document Analyzer Agent

# agent_worker.py – Python 3.12
import os, time, json
from anthropic import Anthropic, RateLimitError, APIError
from pathlib import Path
from queue import Queue
from typing import Dict

# SDK version pinned for reproducibility
# pip install "anthropic==0.6.2"

client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

# Global rate limiter (RPM = 30, TPM = 45000)
RPM = 30
TPM = 45000
INTERVAL = 60.0 / RPM
last_call = 0.0

def rate_limit():
    global last_call
    now = time.time()
    elapsed = now - last_call
    if elapsed < INTERVAL:
        time.sleep(INTERVAL - elapsed)
    last_call = time.time()

def analyze_document(job: Dict, result_queue: Queue):
    """Runs a single Claude agent on a local text/PDF file."""
    file_path = Path(job["path"])
    content = file_path.read_text(encoding="utf-8", errors="ignore")
    # Chunking – simple 2 k token windows
    chunks = [content[i:i+2000] for i in range(0, len(content), 2000)]

    summary_parts = []
    for idx, chunk in enumerate(chunks):
        attempt = 0
        while attempt < 3:
            try:
                rate_limit()
                response = client.messages.create(
                    model="claude-3-7-sonnet-20240620",
                    max_tokens=1024,
                    temperature=0.0,
                    messages=[{
                        "role": "user",
                        "content": f"Summarize the following excerpt (part {idx+1}/{len(chunks)}):\n\n{chunk}"
                    }],
                    tools=[{
                        "name": "computer.use",
                        "description": "Interact with the OS to open a file or run a CLI command.",
                        "input_schema": {
                            "type": "object",
                            "properties": {
                                "command": {"type": "string"}
                            },
                            "required": ["command"]
                        }
                    }]
                )
                # Detect tool call
                if response.stop_reason == "tool_use":
                    tool = response.tool_calls[0]
                    cmd = tool["input"]["command"]
                    # Simple echo for demo – replace with real OCR, etc.
                    os.system(cmd)
                    # Re‑invoke with tool result (empty for now)
                    response = client.messages.create(
                        model="claude-3-7-sonnet-20240620",
                        max_tokens=512,
                        temperature=0.0,
                        messages=[
                            {"role": "assistant", "content": response.content},
                            {"role": "tool", "content": "Command executed", "tool_use_id": tool["id"]}
                        ]
                    )
                summary_parts.append(response.content[0].text)
                break   # success → exit retry loop
            except RateLimitError as e:
                # Back‑off with jitter
                backoff = min(30, (2 ** attempt) + (0.1 * attempt))
                time.sleep(backoff)
                attempt += 1
                continue
            except APIError as e:
                # Log and re‑raise after attempts
                result_queue.put({"job_id": job["id"], "error": str(e)})
                raise
    # Collate final summary
    final_summary = "\n".join(summary_parts)
    result = {
        "job_id": job["id"],
        "summary": final_summary,
        "tokens_used": response.usage.total_tokens,
        "timestamp": datetime.datetime.utcnow().isoformat()
    }
    result_queue.put(result)

Key points in the snippet above:

  • **Rate‑limit gate** – Guarantees we never exceed the global RPM.
  • **Chunked processing** – Avoids the 100 k token ceiling per request.
  • **Tool‑use handling** – Recognises `computer.use` and executes a safe shell command (replace with your own OCR pipeline).
  • **Retry logic** – Exponential back‑off for 429/RateLimitError.

Integrating Tool Calls for Summarization, Q&A, and Data Extraction

You can extend the `tools` array to include a `json_extractor` tool that returns structured fields. The pattern stays the same: detect `tool_use`, run the local code, then feed the result back as a `tool` message.

# Example tool payload for extracting tables from CSV
tools.append({
    "name": "csv.extract",
    "description": "Return a JSON array representing the first N rows of a CSV file.",
    "input_schema": {
        "type": "object",
        "properties": {
            "path": {"type": "string"},
            "limit": {"type": "integer", "default": 10}
        },
        "required": ["path"]
    }
})

Implementing Resilient Error Handling & API Rate Limit Management

All network calls are wrapped in `try/except` blocks that:

  • Capture `RateLimitError` and sleep with jitter.
  • Capture generic `APIError` (network hiccups, 5xx) and retry up to three times.
  • On permanent failure, the worker reports a JSON payload to the `result_queue` with `error` and `attempts` fields. The orchestrator updates the SQLite `status` to `failed` and logs the incident.

Building the Real‑Time Controller GUI (PyQt 6 Focus)

**Tip:** If you prefer Tkinter, replace the `Signal`/`Slot` pattern with `queue.Queue` + `after` callbacks. The core threading model stays identical.

Thread‑Safe Widgets: Updating Progress Bars & Logs from the Worker Pool

# gui.py – PyQt6
import sys, json, pathlib
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QPushButton, QListWidget,
    QProgressBar, QTextEdit, QVBoxLayout, QWidget, QFileDialog
)
from PyQt6.QtCore import Qt, pyqtSignal, QObject
from concurrent.futures import ThreadPoolExecutor
from queue import Queue
from queue import Empty as QueueEmpty

# Signal bridge object
class Bridge(QObject):
    job_updated = pyqtSignal(str, str)  # job_id, new_status
    progress = pyqtSignal(int)          # percent
    log = pyqtSignal(str)

bridge = Bridge()

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Claude Batch Processor")
        self.resize(800, 600)

        # UI elements
        self.file_list = QListWidget()
        self.start_btn = QPushButton("Start Batch")
        self.pause_btn = QPushButton("Pause")
        self.progress_bar = QProgressBar()
        self.log_view = QTextEdit(readOnly=True)

        layout = QVBoxLayout()
        layout.addWidget(self.file_list)
        layout.addWidget(self.start_btn)
        layout.addWidget(self.pause_btn)
        layout.addWidget(self.progress_bar)
        layout.addWidget(self.log_view)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        # Wire signals
        bridge.job_updated.connect(self.on_job_updated)
        bridge.progress.connect(self.progress_bar.setValue)
        bridge.log.connect(self.append_log)

        # Thread pool
        self.executor = ThreadPoolExecutor(max_workers=5)
        self.result_queue = Queue()
        self.running = False

        # Button actions
        self.start_btn.clicked.connect(self.start_batch)
        self.pause_btn.clicked.connect(self.toggle_pause)

    def append_log(self, text: str):
        self.log_view.append(text)

    def on_job_updated(self, job_id: str, status: str):
        # Simple colour coding of list items
        for i in range(self.file_list.count()):
            item = self.file_list.item(i)
            if item.data(Qt.ItemDataRole.UserRole) == job_id:
                item.setForeground({
                    "queued": Qt.GlobalColor.gray,
                    "running": Qt.GlobalColor.blue,
                    "success": Qt.GlobalColor.darkGreen,
                    "failed": Qt.GlobalColor.red
                }[status])
                break

    def start_batch(self):
        files, _ = QFileDialog.getOpenFileNames(self, "Select Documents")
        if not files:
            return
        for f in files:
            job_id = enqueue_job(f)  # from queue.py
            item = self.file_list.addItem(pathlib.Path(f).name)
            self.file_list.item(self.file_list.count() - 1).setData(
                Qt.ItemDataRole.UserRole, job_id
            )
        self.running = True
        self.poll_results()
        bridge.log.emit(f"Enqueued {len(files)} jobs.")
        self.dispatch_jobs()

    def dispatch_jobs(self):
        # Pull queued jobs from SQLite and submit to pool
        conn = sqlite3.connect(DB_PATH)
        cur = conn.cursor()
        cur.execute("SELECT id, path FROM jobs WHERE status='queued'")
        rows = cur.fetchall()
        conn.close()
        for job_id, path in rows:
            bridge.job_updated.emit(job_id, "running")
            self.executor.submit(
                analyze_document,
                {"id": job_id, "path": path},
                self.result_queue
            )

    def poll_results(self):
        # Non‑blocking poll; schedule next call via QTimer
        try:
            while True:
                result = self.result_queue.get_nowait()
                if "error" in result:
                    bridge.log.emit(f"Job {result['job_id']} failed: {result['error']}")
                    update_job(result["job_id"], status="failed")
                    bridge.job_updated.emit(result["job_id"], "failed")
                else:
                    # Write JSONL line
                    with open(JSONL_PATH, "a", encoding="utf-8") as f:
                        f.write(json.dumps(result) + "\n")
                    update_job(result["job_id"], status="success", result_path="...")
                    bridge.job_updated.emit(result["job_id"], "success")
        except QueueEmpty:
            pass

        # Update overall progress
        total = self.file_list.count()
        done = sum(
            1 for i in range(total)
            if self.file_list.item(i).foreground().color().name() == Qt.GlobalColor.darkGreen.name()
        )
        percent = int((done / total) * 100) if total else 0
        bridge.progress.emit(percent)

        # Schedule next poll
        if self.running:
            QTimer.singleShot(500, self.poll_results)

    def toggle_pause(self):
        self.running = not self.running
        self.pause_btn.setText("Resume" if not self.running else "Pause")
        bridge.log.emit("Batch paused." if not self.running else "Batch resumed.")

if __name__ == "__main__":
    app = QApplication(sys.argv)
    win = MainWindow()
    win.show()
    sys.exit(app.exec())

**Warning:** Directly updating widgets from a worker thread will raise `QObject::connect: Cannot queue arguments of

Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.