When an LLM‑driven IT bot runs on the command line, the moment it decides to fire an nmap scan or an ssh command you stare at a black screen waiting for something to happen. There’s no way to say “stop that” without killing the whole process, and you never know if the agent’s reasoning was sound until the log rolls in minutes later. The pain points multiply when the same script is used across dozens of switches, firewalls, and Windows servers in a LAN—state is lost, UI feedback is missing, and a single rate‑limit hit on the Claude API throws the whole workflow into a deadlock.
Enter a GUI‑backed Claude agent. By giving the LLM a persistent window, a live log pane, and an explicit “approve this SSH command” button, you turn a stateless chat into an interactive, human‑in‑the‑loop operator. The result is a tool that can discover devices, run diagnostics, and fetch configs while you stay in control and see every reasoning step as it streams from Claude.
- A PyQt6 desktop app can host a Claude 3.5‑Sonnet agent that drives nmap, SSH, SNMP, and WinRM tools.
- Use the Claude Computer Use API (v1.8+) to let the model request tool calls.
- Stream token‑by‑token responses into a
QTextEditvia Qt signals to keep the UI fluid. - Wrap every external command in an async wrapper with retries, timeout handling, and rate‑limit back‑off.
- Package the final product with Poetry + PyInstaller or ship it as a Tauri‑based web UI.
Before you start: Python ≥3.11, Poetry 1.8+, Claude Python SDK 1.8+, PyQt6 6.7+, Scapy 2.6, Paramiko 3.5, Netmiko 4.5, SQLite 3 (built‑in), and an active Anthropic API key with Computer Use access.
Introduction: Why a GUI Agent Beats Chat‑Only Tools
A Claude Agent for Local Network Device Discovery & Management is a Python GUI application that uses the Claude API to understand natural language requests, then executes approved network tools (like Nmap or SSH) through a user interface. It manages state across tasks, preventing errors from stateless chatbots, and provides logs and control for IT administrators.
The Problem of Stateless Network Interaction
Chat‑only bots forget what host they just scanned, what credentials were reused, and whether a previous command timed out. When you ask “run a config dump on the switch that failed last night”, the model has no persistent memory and will either ask you to re‑specify details or, worse, act on stale data.
Human‑in‑the‑Loop State Management for IT Ops
Embedding the LLM inside a GUI gives you a visual state board: a tree of discovered devices, a history of tool calls, and a checkpoint system where every action must be explicitly approved. This makes audits trivial and lets you intervene before a rogue command is sent to production.
—
Technical Stack & 2026 Prerequisites
| Layer | Recommended Tech (2026) |
|---|---|
| LLM Engine | Claude Python SDK 1.8+, Computer Use beta |
| GUI | PyQt6 for native desktop or React + Tauri 2.0 for a web‑wrapped app |
| Network Automation | Scapy, Paramiko, Netmiko, pywinrm (WinRM) |
| Persistence | SQLite 3 + cryptography for encrypted logs |
| Build & Packaging | Poetry 1.8, PyInstaller 6.5 (desktop) or Tauri 2.0 (web) |
| Credential Store | keyring library (OS‑level vault) |
| Rate‑limit Guard | asyncio + custom back‑off logic |
My take: For most internal IT teams, PyQt6 wins on speed and simplicity. Tauri shines when you already have a React team and need cross‑platform web tech, but it adds an extra compile step and sometimes limited native threading support.
—
Agent Architecture: Brain, Tools, and Interface Loop
The agent consists of three tightly coupled subsystems:
- Claude Brain – a
ClaudeClientinstance that receives user prompts, decides which tool to call, and streams its reasoning back to the UI. - Tool Registry – a JSON‑based schema defining each network command (nmap, ssh, snmpwalk, winrm) with required parameters, timeout, and safety flags.
- UI Layer – a Qt window hosting a chat view, a log pane, and a “Approve” modal that blocks tool execution until the operator clicks “Allow”.
Synchronous vs. Asynchronous LLM Agents
A naive implementation calls client.messages.create() synchronously, which blocks the event loop and freezes the UI. The right pattern spins the Claude call into a QThreadPool worker that emits incremental tokens through a pyqtSignal. The UI stays responsive, and the user can cancel mid‑stream.
Defining Tool Schema
{
"tools": [
{
"name": "nmap_scan",
"description": "Run an nmap ping sweep on a subnet.",
"parameters": {
"subnet": {"type": "string", "description": "CIDR, e.g. 192.168.1.0/24"},
"options": {"type": "string", "default": "-sn", "description": "Additional nmap flags"}
},
"timeout_sec": 30,
"requires_approval": true
},
{
"name": "ssh_exec",
"description": "Execute a command over SSH.",
"parameters": {
"host": {"type": "string"},
"username": {"type": "string"},
"command": {"type": "string"}
},
"timeout_sec": 20,
"requires_approval": true
}
// …more tools (snmpwalk, winrm, etc.)…
]
}
The schema lives in tools_schema.json and is loaded at startup. Adding a new tool is just a matter of extending the JSON and writing a small wrapper function.
Central State Management & User‑Approval UI Layer
class AgentState:
def __init__(self, db_path: str = "agent_state.sqlite"):
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self._ensure_tables()
def _ensure_tables(self):
self.conn.execute("""CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT,
user_msg TEXT,
claude_reply TEXT,
tool_name TEXT,
tool_params TEXT,
approved INTEGER
)""")
self.conn.commit()
def log_interaction(self, **kwargs):
self.conn.execute(
"INSERT INTO history (timestamp,user_msg,claude_reply,tool_name,tool_params,approved) VALUES (datetime('now'),?,?,?,?,?)",
(kwargs.get("user_msg"), kwargs.get("claude_reply"),
kwargs.get("tool_name"), json.dumps(kwargs.get("tool_params")), int(kwargs.get("approved")))
)
self.conn.commit()
The UI reads from this table to populate a “Session History” tab and can export it as JSONL for compliance audits.
graph TD
A[User Input] --> B[Claude Brain]
B --> C{Tool Needed?}
C -->|Yes| D[Tool Registry]
D --> E[Approval Dialog]
E -->|Approved| F[Async Worker]
F --> G[Network Tool (nmap/ssh/...)]
G --> H[Result → UI Log]
F -->|Rejected| I[Abort & Notify]
B -->|No| J[Plain Text Reply]
J --> H
—
Step‑by‑Step: Building the Core GUI Application
1. Project Scaffolding and Dependencies
# Create a fresh Poetry project
poetry new claude-net-agent
cd claude-net-agent
# Add core deps
poetry add "anthropic>=1.8,<2.0" pyqt6 scapy paramiko netmiko pywinrm keyring cryptography aiohttp
# Dev tools
poetry add --dev black isort flake8 mypy
Create the following layout:
claude-net-agent/
│
├─ claude_net_agent/
│ ├─ __init__.py
│ ├─ main_window.py
│ ├─ agent.py
│ ├─ tools.py
│ ├─ state.py
│ └─ schema/
│ └─ tools_schema.json
├─ tests/
└─ pyproject.toml
2. Main Window: UI for Jobs, Logs, and Agent Chat
# claude_net_agent/main_window.py
# -*- coding: utf-8 -*-
# Python 3.11+ | PyQt6 6.7.0
import sys
import json
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QTextEdit, QLineEdit, QPushButton, QMessageBox, QSplitter
)
from PyQt6.QtCore import Qt, QObject, pyqtSignal, QThreadPool, QRunnable
from .agent import ClaudeAgent
from .state import AgentState
class StreamSignal(QObject):
token = pyqtSignal(str)
done = pyqtSignal()
class ClaudeWorker(QRunnable):
"""Runs a Claude request in a thread and streams tokens."""
def __init__(self, agent: ClaudeAgent, prompt: str, signal: StreamSignal):
super().__init__()
self.agent = agent
self.prompt = prompt
self.signal = signal
def run(self):
try:
for token in self.agent.stream_chat(self.prompt):
self.signal.token.emit(token)
except Exception as e:
self.signal.token.emit(f"\n[Error] {e}")
finally:
self.signal.done.emit()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Claude Network Agent")
self.resize(1024, 720)
# UI components
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
self.chat_view = QTextEdit(readOnly=True)
self.input_line = QLineEdit()
self.send_btn = QPushButton("Send")
self.send_btn.clicked.connect(self.handle_send)
splitter = QSplitter(Qt.Orientation.Vertical)
splitter.addWidget(self.chat_view)
bottom = QWidget()
bottom_layout = QVBoxLayout(bottom)
bottom_layout.addWidget(self.input_line)
bottom_layout.addWidget(self.send_btn)
splitter.addWidget(bottom)
layout.addWidget(splitter)
# Core objects
self.state = AgentState()
self.agent = ClaudeAgent()
self.threadpool = QThreadPool.globalInstance()
self.stream_sig = StreamSignal()
self.stream_sig.token.connect(self.append_token)
self.stream_sig.done.connect(self.on_done)
def append_token(self, txt: str):
self.chat_view.moveCursor(Qt.TextCursor.MoveOperation.End)
self.chat_view.insertPlainText(txt)
self.chat_view.ensureCursorVisible()
def on_done(self):
self.append_token("\n---\n")
def handle_send(self):
user_msg = self.input_line.text().strip()
if not user_msg:
return
self.chat_view.append(f"You: {user_msg}\n")
self.input_line.clear()
self.state.log_interaction(user_msg=user_msg, approved=False)
# Spin off Claude worker
worker = ClaudeWorker(self.agent, user_msg, self.stream_sig)
self.threadpool.start(worker)
if __name__ == "__main__":
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
Key points:
ClaudeWorkerlives in a thread pool, never blocks the Qt main thread.StreamSignalemits each token as it arrives, feeding theQTextEditin real time.- The state logger records the raw user message; approval status will be updated later.
3. Integrating the Claude Agent with Tool Registry
# claude_net_agent/agent.py
# -*- coding: utf-8 -*-
# Claude Python SDK 1.8+
import json
import pathlib
import asyncio
from typing import Generator, Dict, Any
from anthropic import Anthropic
from .tools import ToolExecutor
class ClaudeAgent:
def __init__(self):
api_key = pathlib.Path.home() / ".anthropic_api_key"
if not api_key.exists():
raise FileNotFoundError("API key file not found")
self.client = Anthropic(api_key=api_key.read_text().strip())
self.tool_executor = ToolExecutor()
self.schema = json.load(open(pathlib.Path(__file__).parent / "schema" / "tools_schema.json"))
def _build_system_prompt(self) -> str:
return (
"You are an IT operations assistant. Use the provided tools only when "
"the user explicitly asks for network actions. Always ask for confirmation "
"before executing any command that changes state."
)
async def _call_tool(self, name: str, params: Dict[str, Any]) -> str:
"""Execute a tool, handling retries and rate limits."""
try:
result = await self.tool_executor.run(name, params)
return result
except Exception as exc:
return f"Tool error: {exc}"
def _tool_wrapper(self, name: str, params: Dict[str, Any]) -> str:
"""Synchronous shim for the Claude SDK (which expects a function)."""
loop = asyncio.get_event_loop()
return loop.run_until_complete(self._call_tool(name, params))
def stream_chat(self, user_prompt: str) -> Generator[str, None, None]:
"""Yield Claude tokens, intercept tool calls, and stream back results."""
messages = [
{"role": "system", "content": self._build_system_prompt()},
{"role": "user", "content": user_prompt}
]
# Register tools dynamically based on schema
for tool in self.schema["tools"]:
messages.append({
"role": "assistant",
"content": None,
"tool_calls": [{
"name": tool["name"],
"arguments": {}
}]
})
# Use the new streaming API (v1.8+)
stream = self.client.messages.create(
model="claude-3-5-sonnet-20241001",
max_tokens=1024,
temperature=0.0,
messages=messages,
tools=[{
"name": t["name"],
"description": t["description"],
"input_schema": {
"type": "object",
"properties": t["parameters"]
}
} for t in self.schema["tools"]],
stream=True,
)
for fragment in stream:
if fragment.type == "tool_use":
tool_name = fragment.name
args = json.loads(fragment.input)
# Block UI for approval; emit a message to UI (simplified)
yield f"\n[Tool Request] {tool_name} with {args}\n"
# In a real app, you would open a modal; here we auto‑approve for demo
result = self._tool_wrapper(tool_name, args)
yield f"\n[Tool Result] {result}\n"
else:
# Regular token
yield fragment.text
Explanation of the streaming loop
- The SDK returns a generator (
stream) that yields mixed objects:content_block_deltafor regular text andtool_usefor a requested function call. - When a
tool_usearrives we pause the stream, surface the request in the UI, and wait for human approval. The example auto‑approves, but the UI can hook intoToolRequestSignalto pop a dialog. - The tool execution itself runs in an async
ToolExecutor(see next section) so the blockingrun_until_completeonly lasts as long as the tool does, not the whole chat.
4. Wiring Tool Callbacks to GUI Logs and Prompts
# claude_net_agent/tools.py
# -*- coding: utf-8 -*-
import asyncio
import subprocess
import json
import shlex
from typing import Dict, Any
import paramiko
import netmiko
import scapy.all as scapy
import winrm
from cryptography.fernet import Fernet
class ToolExecutor:
"""Centralized async wrapper for all network tools."""
def __init__(self):
# Load a symmetric key from a safe location – demo uses env var
key = os.getenv("AGENT_ENCRYPT_KEY")
if not key:
raise RuntimeError