When you tried to stitch together a desktop assistant that could browse the web, edit files, and answer follow‑up questions, the first thing that broke was the event loop. The CLI version kept choking on the Claude 3.5 Computer‑Use stream, and every retry banged into a 429 rate‑limit error. You end up with an agent that “works” in a notebook but freezes the UI the moment a tool call needs a second round‑trip.
That pain point is the real divider between the three ways to build Claude agents in 2026. Do you want a thin wrapper that gives you raw control, a ready‑made chain that saves you boilerplate, or a workflow engine that orchestrates dozens of tools without you writing a state‑machine? The answer determines how fast you prototype, how stable the production service is, and whether you can ship a responsive GUI without a separate thread fighting the main loop.
- Raw Anthropic SDK gives ultimate control, perfect for custom GUIs and fine‑grained error handling.
- LangChain’s AgentExecutor cuts boilerplate but adds a dependency‑driven abstraction layer.
- LlamaIndex shines for multi‑step, data‑centric workflows and built‑in memory.
- All three support Claude 3.5‑Computer‑Use; choose based on velocity vs. control vs. orchestration complexity.
- Never block the UI thread – stream Claude’s token flow with asyncio and a non‑blocking GUI event loop.
Before you start: Python 3.11+, `anthropic` SDK v0.8+, `langchain` v0.1.12+, `llama-index` v0.10.3+, Pydantic v2, an Anthropic API key with Claude 3.5/4.0 access, and a GUI framework of your choice (Streamlit, Tauri, or PyQt6). Install with pip install -r requirements.txt where the file pins the versions above.
Choosing a Claude Agent Framework in 2026
Choosing a Claude agent framework in 2026 depends on control versus speed. The Raw Anthropic SDK offers maximum control for custom state and GUIs. LangChain provides pre‑built chains for rapid prototyping. LlamaIndex excels at structuring complex, multi‑step workflows. For production GUI agents with custom tools, the Raw SDK often wins, while LlamaIndex suits intricate business logic automation.
System Architecture & Data Flow
Below is a high‑level view of the three architectures we’ll compare. Each diagram shows the **agent core**, **tool orchestration**, **state persistence**, and the **GUI front‑end**. The arrows represent async message flow; note the “Stream → UI” path that must stay non‑blocking.
graph TD
A[User UI] -->|Input Event| B[Agent Core]
B -->|Tool Request| C[Claude API]
C -->|Tool Call| D[Tool Service(s)]
D -->|Result| C
C -->|Response Stream| B
B -->|State Update| E[Persistence Layer]
B -->|UI Update| A
The three implementations differ only in **Agent Core** and **Tool Orchestration** layers:
| Layer | Raw SDK | LangChain | LlamaIndex |
|---|---|---|---|
| Agent Core | Hand‑crafted state machine | `AgentExecutor` + `LCEL` | `AgentRunner` + workflow nodes |
| Tool Orchestration | Manual `tool_registry` dict | `Tool` objects + `AgentExecutor` | `ToolSpec` + `Workflow` |
| Memory | Optional `ConversationMemory` you write | Built‑in `ConversationBufferMemory` | `VectorStoreIndex` for context |
| Persistence | You persist to SQLite/Redis | `PersistedState` (experimental) | `StatefulIndex` with versioning |
—
The Raw Anthropic SDK: Granular Control & Minimal Dependency Approach
Direct Prompt Chaining & State Management
With the raw SDK you own the full request/response loop. Below is a minimal **stateful agent** that can:
- Send a system prompt.
- Parse Claude’s `tool_use` messages.
- Dispatch to a Python function.
- Feed the result back into the same conversation.
# requirements.txt
# anthropic==0.8.0
# pydantic==2.5
# asyncio
# python-dotenv (for API key)
import os
import asyncio
from anthropic import Anthropic, AsyncAnthropic
from anthropic.types import Message, ToolUseBlock, TextBlock
from pydantic import BaseModel, ValidationError
from dotenv import load_dotenv
load_dotenv()
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
client = AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
class AgentState(BaseModel):
conversation_id: str
messages: list[Message] = []
tool_results: dict[str, str] = {}
async def call_claude(state: AgentState, user_input: str) -> AgentState:
# Append user message
user_msg = Message(role="user", content=[TextBlock(text=user_input)])
state.messages.append(user_msg)
try:
response = await client.messages.create(
model="claude-3-5-sonnet-202406",
max_tokens=1024,
temperature=0.0,
messages=state.messages,
tools=[
{
"name": "read_file",
"description": "Read a file from the local filesystem",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
{
"name": "search_web",
"description": "Run a DuckDuckGo search",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
],
)
except Exception as exc:
# Full‑stack rate‑limit handling
if isinstance(exc, client.RateLimitError):
await asyncio.sleep(2) # exponential back‑off in production
return await call_claude(state, user_input)
raise
state.messages.append(response)
# Detect tool usage
for block in response.content:
if isinstance(block, ToolUseBlock):
tool_name = block.name
tool_input = block.input
result = await dispatch_tool(tool_name, tool_input)
# Append tool result as a "tool_result" block
tool_msg = Message(
role="assistant",
content=[{
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
}],
)
state.messages.append(tool_msg)
# Recursively continue the conversation
return await call_claude(state, "")
return state
async def dispatch_tool(name: str, payload: dict) -> str:
if name == "read_file":
path = payload["path"]
try:
async with aiofiles.open(path, "r") as f:
return await f.read()
except Exception as e:
return f"Error reading {path}: {e}"
elif name == "search_web":
query = payload["query"]
# Very simplistic HTTP request; replace with your own search client
async with aiohttp.ClientSession() as s:
async with s.get("https://duckduckgo.com/html/", params={"q": query}) as resp:
txt = await resp.text()
return txt[:2000] # truncate for brevity
else:
return f"Unknown tool {name}"
# Example entry point
async def main():
state = AgentState(conversation_id="demo")
while True:
user = input("👤 ")
if user.strip().lower() in {"exit", "quit"}:
break
state = await call_claude(state, user)
# Print the latest assistant message (excluding tool result)
last_msg = state.messages[-1]
for block in last_msg.content:
if isinstance(block, TextBlock):
print(f"🤖 {block.text}")
if __name__ == "__main__":
asyncio.run(main())
**Why this works:** *All network calls are `await`‑ed, so the coroutine never blocks the event loop.* *Tool dispatch is isolated in `dispatch_tool`, making it trivial to add new functions.* *State persists in a Pydantic model, which can be serialized to JSON for crash recovery (see later).*
Tip: Store `state.json` after each user turn. On restart, load it back to resume the exact conversation without re‑prompting the model.
Custom Tool Calling Implementation Logic
Claude’s `tool_use` blocks come with a **unique `id`** you must echo back in the `tool_result`. The raw SDK does not abstract this, giving you the flexibility to batch multiple tool calls in one response loop. Below is a hardened helper that groups tool calls and retries them independently.
async def handle_tool_calls(blocks: list[ToolUseBlock]) -> list[dict]:
results = []
for block in blocks:
try:
result = await dispatch_tool(block.name, block.input)
results.append({
"tool_use_id": block.id,
"content": result,
"status": "ok",
})
except Exception as exc:
results.append({
"tool_use_id": block.id,
"content": f"Tool failed: {exc}",
"status": "error",
})
return results
Notice the granular `try/except` – a single failing tool no longer kills the whole loop.
Warning: Claude will re‑activate the same tool if you forget to include the `tool_result` payload. Always ensure every `tool_use_id` is answered exactly once.
Building a Long‑Running Agent Without External Frameworks
Production agents often need **persistence across process restarts**. The Raw SDK approach makes this straightforward: serialize the `AgentState` to a file after each turn.
import json
from pathlib import Path
STATE_PATH = Path("agent_state.json")
def save_state(state: AgentState) -> None:
STATE_PATH.write_text(state.model_dump_json())
def load_state() -> AgentState:
if STATE_PATH.exists():
return AgentState.model_validate_json(STATE_PATH.read_text())
return AgentState(conversation_id="restarted")
Combine this with the `main` loop:
state = load_state()
while True:
# ... same as before
save_state(state)
The result is a **crash‑tolerant desktop assistant** that can pick up exactly where it left off – essential for GUI apps where the user may close the window unexpectedly.
—
LangChain for Claude: Evaluation in 2026
LangChain’s Agent Executor: Boilerplate vs. Benefit
LangChain’s `AgentExecutor` hides the recursion you wrote manually above. It automatically:
- Detects `tool_use` blocks.
- Calls a registered `Tool` object.
- Re‑injects the tool result into the next model call.
Setup is concise:
# requirements.txt addition
# langchain==0.1.12
# langchain-community==0.0.31
from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain.tools import BaseTool
from langchain.message_loaders import AnthropicMessageLoader
from langchain.schema import HumanMessage, AIMessage, ToolMessage
import aiofiles
import aiohttp
class ReadFileTool(BaseTool):
name = "read_file"
description = "Read a file from local disk."
def _run(self, path: str) -> str:
# Synchronous fallback for LangChain’s internal use
try:
with open(path, "r") as f:
return f.read()
except Exception as e:
return f"Error: {e}"
class SearchWebTool(BaseTool):
name = "search_web"
description = "Search the web via DuckDuckGo."
async def _arun(self, query: str) -> str:
async with aiohttp.ClientSession() as s:
async with s.get("https://duckduckgo.com/html/", params={"q": query}) as resp:
txt = await resp.text()
return txt[:2000]
tools = [ReadFileTool(), SearchWebTool()]
# LLM wrapper that uses Anthropic
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-3-5-sonnet-202406")
prompt = """You are an assistant that can read files and browse the web. Use the provided tools when needed."""
agent = create_openai_tools_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
**Benefit:** All the plumbing (tool id management, retries, logging) is baked in. **Boilerplate cost:** You inherit LangChain’s type system and its `BaseTool` hierarchy, which adds a ~2 kB dependency tree. If the Anthropic API changes (e.g., new `tool_use` schema), LangChain may lag behind, forcing you to pin a version.
Tip: Freeze `langchain` at the minor version that matches the Anthropic SDK you’re using. Upgrade deliberately after testing the new wrapper against the official API changelog.
State Hydration, Memory, and Complex Chains
LangChain ships with several memory back‑ends (`ConversationBufferMemory`, `VectorStoreRetrieverMemory`). For a GUI you can attach a Redis‑backed memory that survives app restarts:
from langchain.memory import RedisChatMessageHistory
from langchain.chains import ConversationChain
redis_history = RedisChatMessageHistory(url="redis://localhost:6379/0")
memory = ConversationChain(memory=redis_history, llm=llm)
The **LCEL** (LangChain Expression Language) lets you compose conditional routing without writing Python if‑else statements:
from langchain.schema import SystemMessage
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
SystemMessage(content="You have access to `read_file` and `search_web`."),
("human", "{input}"),
])
chain = prompt | llm
LCEL shines when you need **branching** based on the presence of a tool call, but the added DSL can be a mental overhead if your workflow is simple.
Warning: LCEL currently does not expose the raw `tool_use_id`. If you need to correlate multiple tool calls in a single turn, fall back to the raw SDK or extend `ToolMessage` manually.
When LCEL Still Makes Sense for Claude
- Rapid prototypes where you want a memory cache “out of the box”.
- Multi‑modal agents that combine LLM calls with external APIs (e.g., Google Cloud Vertex AI) in a single expression.
- Teams already standardized on LangChain for OpenAI; reusing the same codebase reduces onboarding friction.
—
LlamaIndex for Claude: The 2026 Capability Set
Beyond RAG: Agent and Tool Orchestration Modules
LlamaIndex’s **AgentRunner** (introduced in v0.10) decouples **retrieval**, **tool execution**, and **prompt orchestration** into separate, composable nodes.
# requirements.txt addition
# llama-index==0.10.3
# llama-index-tools==0.