When you first plug Claude into a spreadsheet, the obvious win is speed: “What’s the YoY growth on Q2?”—Claude pulls the numbers, runs a regression, spits out a chart. But the moment you try that from a terminal, the experience shatters. No progress bar, no way to abort a runaway loop, and every tool call feels like a cold‑start HTTP request that leaves the console hanging. What you really need is a **desktop GUI** that keeps the conversation alive, shows the sheet state, and lets you cancel or tweak actions on the fly.
That’s the problem this guide solves. We’ll build a **Claude‑powered desktop agent** that talks to Google Sheets, streams responses, and stays responsive even when the agent chains three or more tool calls (find a sheet → read data → generate a chart). The result is a native‑feel app you can ship to teammates without a web server, and it runs on Python 3.11+, PyQt6 (or PySide6) with full‑async integration.
—
- Combine Claude’s Computer Use API with Google Sheets v4 using Python 3.11+.
- Wire an async agent loop into PyQt6’s event loop via qasync, eliminating UI freezes.
- Implement a cancellable, chain‑aware tool‑calling workflow (find → read → chart).
- Package the app with Poetry + PyInstaller for a single‑click executable.
- Secure API keys with OS keyring and handle rate‑limit retries automatically.
—
Before you start: Install Python 3.11+, Poetry 1.7+, Anthropic SDK 0.12+, PyQt6 6.6+, qasync 0.27+, google‑auth 2.35+, pandas 2.2+, and ensure you have a Google Cloud project with Sheets API enabled. You’ll also need an Anthropic API key (Claude 3.5‑Sonnet) and an OAuth client ID for Sheets.
What is a Claude Agent for Spreadsheet Automation?
A Claude Agent for Spreadsheet Analysis is a desktop GUI application that connects Claude AI’s reasoning to Google Sheets’ data. Using Python and the Anthropic SDK, it provides an interactive chat interface where users ask questions, and the agent executes read/write/format tool calls in real-time, automating complex analysis without manual scripting.
1. High‑level Architecture
At a glance the system consists of three logical layers:
- **Presentation Layer** – PyQt6 UI (chat pane, sheet preview, controls).
- **Agent Core** – Async loop that sends user messages to Claude, receives tool‑call suggestions, dispatches the appropriate Google Sheets operation, and streams the response back.
- **Integration Layer** – Thin wrappers around the Anthropic Computer Use API and Google Sheets API, plus a small in‑memory state store to keep conversation history and sheet cache.
The flow looks like this:
flowchart LR
UI[Chat UI] -->|User input| Agent[Async Agent Loop]
Agent -->|Claude request| Claude[Claude API]
Claude -->|Tool call| Sheets[Sheets Wrapper]
Sheets -->|Result| Agent
Agent -->|Streamed response| UI
UI -->|Cancel| Agent
*The diagram is deliberately simple; each node is a Python class we’ll flesh out later.*
—
2. Setting Up the Project Skeleton
**My take:** Poetry’s deterministic lockfile saves you from the “it works on my machine” nightmare when you later embed the app with PyInstaller. Stick with it.
# Create project directory
mkdir claude-sheets-agent && cd $_
# Initialise Poetry
poetry init --no-interaction
poetry add python="^3.11" \
anthropic==0.12.* \
pyqt6==6.6.* \
qasync==0.27.* \
google-auth==2.35.* \
google-auth-oauthlib==1.2.* \
pandas==2.2.* \
keyring==24.* \
tqdm==4.66.* \
requests==2.32.*
# Activate virtualenv
poetry shell
Create the following package layout:
claude_sheets_agent/
├─ __init__.py
├─ ui.py # PyQt widgets
├─ agent.py # Async loop + Claude integration
├─ sheets.py # Google Sheets thin client
├─ config.py # Constants, env loading
└─ main.py # Entry point
—
3. Authenticating with Claude and Google Sheets
3.1 Claude API Key
Store the Anthropic key in an OS‑protected environment variable or, better, in the keyring.
# config.py
import os, keyring
CLAUDE_API_KEY = os.getenv("ANTHROPIC_API_KEY") or keyring.get_password("anthropic", "api_key")
if not CLAUDE_API_KEY:
raise RuntimeError("Claude API key not found. Set ANTHROPIC_API_KEY or store it via keyring.")
3.2 Google OAuth for Sheets
We’ll use the OAuth 2.0 “installed app” flow. The refresh token is saved in the keyring so users only consent once.
# sheets.py
import json, os, pathlib
import google.auth.transport.requests
from google_auth_oauthlib.flow import InstalledAppFlow
import google.oauth2.credentials
import keyring
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
TOKEN_KEY = "google_sheets_refresh"
def get_credentials() -> google.oauth2.credentials.Credentials:
token = keyring.get_password("google_sheets", TOKEN_KEY)
if token:
creds = google.oauth2.credentials.Credentials(
token=token,
refresh_token=token,
token_uri="https://oauth2.googleapis.com/token",
client_id=os.getenv("GOOGLE_CLIENT_ID"),
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
scopes=SCOPES,
)
if not creds.valid:
request = google.auth.transport.requests.Request()
creds.refresh(request)
keyring.set_password("google_sheets", TOKEN_KEY, creds.refresh_token)
return creds
# First‑time run – launch local server
flow = InstalledAppFlow.from_client_secrets_file(
pathlib.Path(__file__).with_name("client_secret.json"), SCOPES
)
creds = flow.run_local_server(port=0)
keyring.set_password("google_sheets", TOKEN_KEY, creds.refresh_token)
return creds
**Tip:** Keep `client_secret.json` outside the repo or load it from an environment variable to avoid accidental commits.
—
4. Building the Agent Core
The agent must do three things concurrently:
- **Receive UI events** (new user message, cancel request).
- **Talk to Claude** (streaming response, parse tool calls).
- **Execute tool calls** (Google Sheets reads/writes) and feed their results back.
We solve the concurrency problem with **asyncio** and integrate it into PyQt’s event loop using **qasync**. This prevents the UI from freezing while Claude streams tokens.
# agent.py
import asyncio, json, logging
from anthropic import AsyncClient, HUMAN_PROMPT, AI_PROMPT
from anthropic.types import MessageDelta
from .sheets import SheetsClient
from .config import CLAUDE_API_KEY
log = logging.getLogger(__name__)
class ClaudeAgent:
def __init__(self, ui_callback, cancel_event: asyncio.Event):
"""
ui_callback: async callable that receives a string chunk to display.
cancel_event: set by the UI when the user clicks “Cancel”.
"""
self.client = AsyncClient(api_key=CLAUDE_API_KEY, timeout=120)
self.ui_callback = ui_callback
self.cancel_event = cancel_event
self.sheets = SheetsClient()
self.conversation = [] # List[dict] for Anthropic messages
async def chat(self, user_msg: str):
self.conversation.append({"role": "user", "content": user_msg})
# Reset cancel flag
self.cancel_event.clear()
# Start the streaming request
async with self.client.messages.stream(
model="claude-3-5-sonnet-202406",
max_tokens=1024,
temperature=0.0,
system="You are an AI assistant that can read/write Google Sheets via provided tool calls.",
messages=self.conversation,
) as stream:
async for delta in stream:
# 1️⃣ Check for cancellation
if self.cancel_event.is_set():
await self._send_stop()
break
# 2️⃣ Handle tool calls embedded in delta
if delta.type == "tool_use":
await self._handle_tool(delta)
continue
# 3️⃣ Normal text streaming
if delta.type == "content_block_delta" and delta.text:
await self.ui_callback(delta.text)
# Append final assistant message (empty placeholder for now)
self.conversation.append({"role": "assistant", "content": []})
async def _handle_tool(self, tool_delta: MessageDelta):
"""
Parses the tool request, runs the right Sheets API call, and feeds the result back.
"""
try:
tool_name = tool_delta.name
input_json = json.loads(tool_delta.input)
log.info("Tool call %s with %s", tool_name, input_json)
if tool_name == "find_sheet":
result = self.sheets.find_sheet(input_json["spreadsheet_id"], input_json["title"])
elif tool_name == "read_range":
result = self.sheets.read_range(
input_json["spreadsheet_id"], input_json["sheet_id"], input_json["range"]
)
elif tool_name == "write_range":
result = self.sheets.write_range(
input_json["spreadsheet_id"], input_json["sheet_id"], input_json["range"], input_json["values"]
)
else:
raise ValueError(f"Unsupported tool: {tool_name}")
# Send tool result back to Claude
await self.client.messages.create(
model="claude-3-5-sonnet-202406",
max_tokens=0,
temperature=0.0,
system="",
messages=self.conversation,
tool_results=[{
"tool_use_id": tool_delta.id,
"content": json.dumps({"result": result}),
}],
)
except Exception as e:
log.exception("Tool execution failed")
# Propagate error to Claude
await self.client.messages.create(
model="claude-3-5-sonnet-202406",
max_tokens=0,
temperature=0.0,
system="",
messages=self.conversation,
tool_results=[{
"tool_use_id": tool_delta.id,
"content": json.dumps({"error": str(e)}),
}],
)
async def _send_stop(self):
"""Send a stop token to Claude – the API treats it as a cancellation."""
await self.client.beta.messages.cancel()
await self.ui_callback("\n[Action cancelled by user]\n")
**Key points addressing Gap 1**
- The UI pushes `user_msg` into `ClaudeAgent.chat`.
- `qasync` runs `ClaudeAgent.chat` in the same asyncio loop as Qt, so the UI stays interactive.
- The `cancel_event` is a clean, thread‑safe way for the UI to abort the streaming coroutine without killing the whole process.
4.1 Chaining Dependent Tool Calls (Gap 2)
Claude can emit multiple tool_use deltas in one response. Our `_handle_tool` method runs each call synchronously, feeds the result back, and then **continues streaming**. Because the loop only yields to the UI after each tool result, you can see intermediate updates (e.g., “Found sheet ‘Sales‑2025’, reading A1:D20…”) and still hit “Cancel” while the chain is executing. The cancel flag is checked *before* each new tool execution, guaranteeing immediate abort.
—
5. The PyQt6 UI
5.1 Layout Overview
- **Chat Panel** – QTextEdit (read‑only) with markdown rendering.
- **Sheet Preview** – QTableView bound to a pandas DataFrame.
- **Controls** – QLineEdit for user input, “Send” button, “Cancel” button, and a status bar.
# ui.py
from PyQt6 import QtWidgets, QtCore
from qasync import asyncSlot, QEventLoop
import asyncio
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Claude + Sheets Agent")
self.resize(960, 640)
central = QtWidgets.QWidget()
self.setCentralWidget(central)
layout = QtWidgets.QVBoxLayout(central)
# Chat display
self.chat_display = QtWidgets.QTextEdit()
self.chat_display.setReadOnly(True)
layout.addWidget(self.chat_display, stretch=3)
# Sheet preview
self.table = QtWidgets.QTableView()
layout.addWidget(self.table, stretch=2)
# Controls
ctrl_layout = QtWidgets.QHBoxLayout()
self.input_line = QtWidgets.QLineEdit()
self.send_btn = QtWidgets.QPushButton("Send")
self.cancel_btn = QtWidgets.QPushButton("Cancel")
ctrl_layout.addWidget(self.input_line)
ctrl_layout.addWidget(self.send_btn)
ctrl_layout.addWidget(self.cancel_btn)
layout.addLayout(ctrl_layout)
# Status bar
self.status = self.statusBar()
self._setup_signals()
# Async plumbing
self._cancel_event = asyncio.Event()
self.agent = None # Assigned later in main()
def _setup_signals(self):
self.send_btn.clicked.connect(self.on_send)
self.cancel_btn.clicked.connect(self.on_cancel)
self.input_line.returnPressed.connect(self.on_send)
@asyncSlot()
async def on_send(self):
msg = self.input_line.text().strip()
if not msg:
return
self.chat_display.append(f"<b>You:</b> {msg}")
self.input_line.clear()
self.status.showMessage("Thinking…", 2000)
# Stream Claude response to UI
async def stream_chunk(chunk):
self.chat_display.moveCursor(QtGui.QTextCursor.MoveOperation.End)
self.chat_display.insertPlainText(chunk)
self.chat_display.moveCursor(QtGui.QTextCursor.MoveOperation.End)
await self.agent.chat(msg)
@QtCore.pyqtSlot()
def on_cancel(self):
self._cancel_event.set()
self.status.showMessage("Cancellation requested…", 1500)
5.2 Wiring the Async Loop
# main.py
import sys, asyncio
from qasync import QEventLoop, asyncSlot
from PyQt6 import QtWidgets
from .ui import MainWindow
from .agent import ClaudeAgent
def main():
app = QtWidgets.QApplication(sys.argv)
loop = QEventLoop(app)
asyncio.set_event_loop(loop)
win = MainWindow()
# Pass UI callback and cancel flag into agent
async def ui_callback(chunk: str):
win.chat_display.moveCursor(QtGui.QTextCursor.MoveOperation.End)
win.chat_display.insertPlainText(chunk)
win.agent = ClaudeAgent(ui_callback, win._cancel_event)
win.show()
with loop:
loop.run_forever()
if __name__ == "__main__":
main()
**Warning:** Never call `asyncio.run()` inside a Qt app; it creates a second event loop that steals the thread. `qasync` is the only officially‑supported bridge for PyQt6 as of 2026.
—
6. Implementing the Google Sheets Wrapper
# sheets.py (continued)
import pandas as pd
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
class SheetsClient:
def __init__(self):
self.creds = get_credentials()
self.service = build("sheets", "v4", credentials=self.creds)
def find_sheet(self, spreadsheet_id: str, title: str) -> dict:
"""Return sheetId for a given title."""
try:
meta = self.service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
for sheet in meta["sheets"]:
if sheet["properties"]["title"] == title:
return {"sheet_id": sheet["properties"]["sheetId"]}
raise ValueError(f"Sheet titled '{title}' not found.")
except HttpError as e:
raise RuntimeError(f"Google Sheets API error: {e}")
def read_range(self