Why Agents? From Chatbot to Doer
Why a 2022-era LLM could write a poem but never save it to a file — and how tool calling turns a model that only talks into an agent that acts. The client–server loop behind every agent, drawn from first principles.
What you'll learn
- Understand why plain LLMs cannot act on the world
- See the LLM ↔ client ↔ server tool-calling loop
- Meet JSON-RPC 2.0: the 4-key request and result-or-error response
A model that only talks
Ask a 2022-era LLM to "write a song and save it as song.txt" and it will happily write the song — as text, into the chat — because generating text is the only thing it can do. To act on the world, something else must run real functions on the model's behalf. That something is the agent loop, and it needs a wire format both sides agree on.
Chatbot
Text in → text out. It can describe saving a file, booking a hotel, or running code. It can never do any of them.
Agent
LLM ↔ client ↔ server. The model asks for a tool by name, a server actually runs it, and the result flows back into the conversation. The model talks; the loop acts.
The request: 4 keys
JSON-RPC 2.0 is that wire format — one JSON object per call. Every request an agent will ever make, under any framework, reduces to these four keys:
"jsonrpc"Version. Always the exact string "2.0".
"id"Your number, so you can match the reply to the request.
"method"WHICH function to run — as text, not code.
"params"Its arguments, as a JSON object.
From the notebook
request = {
"jsonrpc": "2.0", # version. always this exact string.
"id": 1, # your number, so you can match the reply to the request
"method": "add", # WHICH function to run
"params": {"a": 2, "b": 3}, # its arguments
}The response: result or error. Never both.
ok = {"jsonrpc": "2.0", "id": 1, "result": 5}
bad = {"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}A response echoes your id and carries exactly one of the two payload keys. If the client sees result, the call worked; if it sees error, it didn't — there is no third state and no ambiguity. That XOR rule is what makes the protocol machine-checkable.
Try it: build a request
This playground runs the notebook's handle() server, ported to JavaScript, right in your browser. Pick a method, fill in params, and send.
→ request (client sends)
{
"jsonrpc": "2.0",
"id": 1,
"method": "add",
"params": {
"a": 40,
"b": 2
}
}← response (server replies)
The server: a lookup and a call
The entire "remote procedure call" machinery is a dictionary lookup followed by **params unpacking:
def add(a, b):
return a + b
def shout(text):
return text.upper() + "!"
FUNCTIONS = {"add": add, "shout": shout} # name (text) -> real Python function
def handle(req):
fn = FUNCTIONS.get(req["method"]) # look up by NAME
if fn is None: # no such function -> error reply
return {"jsonrpc": "2.0", "id": req["id"],
"error": {"code": -32601, "message": "Method not found"}}
result = fn(**req["params"]) # <-- THE REMOTE CALL.
# ** turns {"a":2,"b":3} into add(a=2, b=3)
return {"jsonrpc": "2.0", "id": req["id"], "result": result}Hold on to this shape. In the next lesson, MCP turns out to be exactly this server with three agreed-upon method names — nothing more.