Lesson 1 · Phase 1 · The Protocol

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.

1 · Pick a method
2 · Type the params

→ request (client sends)

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "add",
  "params": {
    "a": 40,
    "b": 2
  }
}

← response (server replies)

Press Send request to see the reply.

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.