Infra, Illustrated.
AI · AGENT LOOP CONSOLE

LangChain: From One Model Call to an Agent

Start with one HTTP call to a model. Add the message list, a tool, and a loop, one idea at a time, then see which of those pieces LangChain names for you.

QUESTION · Why is payments-api failing?FOUNDATIONAL · DEEP DIVEPublished Oct 11, 2026 · Reviewed Oct 11, 2026
ACTIVE VIEWOne call
namespace payments · pod payments-api-7d9f · tools list_pods, get_pod_details
your code model (remote) Kubernetes happening now no such path stopped
01 / ONE CALL

One question, one answer

Your code sends some text to a model over HTTPS and gets some text back.

STEP 1 OF 3 · Send

Your code sends a request. Inside it is a list with one item: the question.

What sits between your code and the modelShown: your code, model. your code to model: request: a list of messages.

Python

response = client.chat.completions.create(    model=MODEL,    messages=[        {"role": "user",         "content": "What is a Kubernetes Pod?"},    ],)

What goes over the wire

POST /api/v1/chat/completions{  "model": "…",  "messages": [    {"role": "user",     "content": "What is a Kubernetes Pod?"}  ]}
STEP 2 OF 3 · Receive

The model sends back one item of the same shape, written by it. Then it forgets the exchange.

What sits between your code and the modelShown: your code, model. model to your code: reply: one message.

Python

message = response.choices[0].messageprint(message.content) # A Pod is the smallest unit Kubernetes # schedules: one or more containers that # share a network address and storage.

What came back

{  "choices": [{    "message": {      "role": "assistant",      "content": "A Pod is the smallest unit…"    },    "finish_reason": "stop"  }],  "usage": {"prompt_tokens": 14,            "completion_tokens": 31}}
STEP 3 OF 3 · Look inside the arrow

That arrow was three things. A library in your process built the request, a routing service received it, and a provider ran the model.

What sits between your code and the modelShown: your code, OpenAI SDK, OpenRouter, model. your code to OpenAI SDK. OpenAI SDK to OpenRouter. OpenRouter to model.

Where each one is named

from openai import OpenAI                       # the SDK client = OpenAI(    base_url="https://openrouter.ai/api/v1",    # the router    api_key=os.environ["OPENROUTER_API_KEY"],)MODEL = "anthropic/…"    # the model, at its provider
INSPECTOR

A model is an HTTP endpoint

Nothing in this exchange is specific to AI plumbing. Your code makes a POST request and reads JSON. The reply has a role and some content, and the provider keeps no record of the conversation for the next call.

You send
A list of messages
You get
One message, plus token counts
Kept between calls
Nothing
Where the text isresponse.choices[0].message.content
WORTH KNOWING

The SDK, the router and the model fail separately: a bad key is rejected by the router, a rate limit comes from the provider, a timeout can be any of them. The wire format shown here is Chat Completions, which OpenRouter accepts. OpenAI's newer Responses API names the fields differently.

OpenRouter: quickstart ↗

THE POINT TO KEEPUnder every framework there is one HTTP call: a list of messages goes out, one message comes back.

02 / ONE CALL

The request is a list, and each item says who wrote it

Three kinds of item are enough for a conversation.

You wrote the first item, the user wrote the second, and the model wrote the third. The label on each one is called its role.

Your code and the modelShown: your code, model. your code to model: request: the whole list.

messages

  1. systemwritten by youYou are a Kubernetes troubleshooting assistant. Answer briefly.
  2. userwritten by the userWhat is a Pod?
  3. assistantwritten by the modelA Pod is the smallest unit Kubernetes schedules: one or more containers that share a network address.

The same list in Python

messages = [    {"role": "system",    "content": "You are a Kubernetes…"},    {"role": "user",      "content": "What is a Pod?"},    {"role": "assistant", "content": "A Pod is the smallest…"},]
INSPECTOR

Three roles, three authors

The system message is where you say how the model should behave. The user message carries what the person asked. An assistant message is something the model said earlier, which your code chose to put back into the list.

system
Your standing instructions
user
The person's words
assistant
The model's earlier replies
Every item has{"role": …, "content": …}
WORTH KNOWING

A system message is instructions the model usually follows. It is not memory, not a database, and not a permission check: text arriving later in the list can talk the model out of it.

LangChain: messages ↗OpenRouter: quickstart ↗

THE POINT TO KEEPA conversation is a list of role-tagged messages that your code assembles before every call.

03 / ONE CALL

The model remembers nothing, so you send everything again

Ask three questions in a row and watch what each request has to carry.

STEP 1 OF 3 · Request 1

The first question goes out on its own.

Your code and the modelShown: your code, model. your code to model: request 1: 2 items.

Request 1 carries

  1. systemwritten by youYou are a Kubernetes troubleshooting assistant.
  2. userwritten by the userWhat is a Pod?new

Tokens sent

  • Request 130

Example token counts. Real counts depend on the model's tokenizer.

STEP 2 OF 3 · Request 2

"Can it contain two containers?" only makes sense after the first exchange, so your code sends that exchange again with the new question on the end.

Your code and the modelShown: your code, model. your code to model: request 2: 4 items.

Request 2 carries

  1. systemwritten by youYou are a Kubernetes troubleshooting assistant.
  2. userwritten by the userWhat is a Pod?
  3. assistantwritten by the modelA Pod is the smallest unit Kubernetes schedules…
  4. userwritten by the userCan it contain two containers?new

Tokens sent

  • Request 130
  • Request 295

Example token counts. Real counts depend on the model's tokenizer.

STEP 3 OF 3 · Request 3

The third request carries both earlier exchanges. Each call is billed for the whole list, not for the new line.

Your code and the modelShown: your code, model. your code to model: request 3: 6 items.

Request 3 carries

  1. systemwritten by youYou are a Kubernetes troubleshooting assistant.
  2. userwritten by the userWhat is a Pod?
  3. assistantwritten by the modelA Pod is the smallest unit Kubernetes schedules…
  4. userwritten by the userCan it contain two containers?
  5. assistantwritten by the modelYes. Containers in one Pod share its network and can share volumes.
  6. userwritten by the userWhy would I do that?new

Tokens sent

  • Request 130
  • Request 295
  • Request 3170

Example token counts. Real counts depend on the model's tokenizer.

The whole of "memory"

messages.append(reply)       # what the model just saidmessages.append(question)    # what the user asks nextreply = call_model(messages) # everything, every time
INSPECTOR

Your list is the memory

The model answers a follow-up correctly because the earlier turns are in front of it again. If your code drops a turn, the model never saw it. If the process exits, the list is gone unless you stored it.

Who keeps history
Your code
What grows
Input tokens, on every call
What limits it
The model's context window
Watch it growresponse.usage.prompt_tokens
WORTH KNOWING

Long conversations are trimmed or summarised for this reason. Whatever stays in the list also stays in front of the model, including anything a tool returned earlier.

LangChain: messages ↗LangChain: models and bind_tools ↗

THE POINT TO KEEPHistory is context you resend, and you pay for all of it on every call.

04 / TOOLS

The model asks for a function. Your code runs it.

Give the model one function it could use, ask a question that needs it, and follow what happens.

STEP 1 OF 6 · Describe it

There are two things here: a function that runs, and a description of it. Only the description will be sent.

Your code, the model, a tool function and the clusterShown: your code, model, tool function.

The function: runs in your process

def get_cluster_name():    return "kind-dev"

Its description: shown to the model

{"type": "function", "function": {   "name": "get_cluster_name",   "description": "Get the current Kubernetes cluster",   "parameters": {"type": "object", "properties": {}} }}
STEP 2 OF 6 · Ask

The request now carries the question and the description.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. your code to model: messages + description.

messages

  1. userwritten by the userWhich cluster am I connected to?new

Python

response = client.chat.completions.create(    model=MODEL,    messages=messages,    tools=TOOLS,        # the descriptions)
STEP 3 OF 6 · The reply

The reply has no answer in it. It names the function and stops.

NOTHING HAS RUNThe model wrote a request. It has no connection to your function, and until your code acts, nothing happens.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. model to your code: a request to call a function. model to tool function: the model runs the tool? (no such path).

messages

  1. userwritten by the userWhich cluster am I connected to?
  2. assistantwritten by the modeltool_calls: get_cluster_name() id=call_1new

What came back

{"role": "assistant", "content": null, "tool_calls": [{   "id": "call_1",   "type": "function",   "function": {"name": "get_cluster_name",                "arguments": "{}"} }]}
STEP 4 OF 6 · Run it

Your code reads the name, finds the function, and calls it.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. your code to tool function: call. tool function to your code: "kind-dev".

messages

  1. userwritten by the userWhich cluster am I connected to?
  2. assistantwritten by the modeltool_calls: get_cluster_name() id=call_1

Python

call = message.tool_calls[0]function = TOOLS_BY_NAME[call.function.name]result = function()            # "kind-dev"
STEP 5 OF 6 · Send the result

The result goes back as a new item, tagged with the id of the request it answers. The model is called a second time.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. your code to model: the list, now 3 items.

messages

  1. userwritten by the userWhich cluster am I connected to?
  2. assistantwritten by the modeltool_calls: get_cluster_name() id=call_1
  3. toolwritten by your code"kind-dev" (answers call_1)new

Python

messages.append(message)       # the model's requestmessages.append({    "role": "tool",    "tool_call_id": call.id,   # which request this answers    "content": result,})
STEP 6 OF 6 · The answer

With the result in the list, the model can answer in words.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. model to your code: plain text.

messages

  1. userwritten by the userWhich cluster am I connected to?
  2. assistantwritten by the modeltool_calls: get_cluster_name() id=call_1
  3. toolwritten by your code"kind-dev" (answers call_1)
  4. assistantwritten by the modelYou are connected to the kind-dev cluster.new
INSPECTOR

Tool calling is a reply format

A model with tool descriptions has two ways to reply: text, or a structured request naming a function and its arguments. This is called tool calling, or function calling. The request is data. Your process decides whether to act on it.

The model produces
A name and arguments
Your code does
The lookup, the call, the result message
A fourth role
tool: a result written by your code
Where requests arriveresponse.choices[0].message.tool_calls
WORTH KNOWING

Order matters. The assistant message holding the request must be in the list before the tool message that answers it, and the ids must match, or the provider rejects the next call. One reply can also ask for several tools at once; each gets its own result message.

OpenAI: function calling guide ↗LangChain: models and bind_tools ↗

THE POINT TO KEEPThe model never executes a tool. It writes a request, and your application decides what happens next.

05 / TOOLS

Arguments arrive as text, and text can be wrong

A tool that takes arguments gets them as a string the model wrote. Three things can come back.

OPTION 1 OF 3 · A good call

The string parses, the names and types match the description, and the function runs.

RUNSget_pod_status(namespace="payments", pod_name="payments-api-7d9f")

From the model's text to a function callShown: JSON string, json.loads, valid?, get_pod_status. JSON string to json.loads. json.loads to valid?. valid? to get_pod_status.

What the model sent

"arguments": "{\"namespace\": \"payments\",               \"pod_name\": \"payments-api-7d9f\"}"

Python

arguments = json.loads(call.function.arguments)check(arguments, SCHEMA)           # names, types, requiredresult = get_pod_status(**arguments)
OPTION 2 OF 3 · Missing argument

The string parses, but pod_name is missing. Your check catches it, and the error goes back to the model as the tool's result.

DOES NOT RUNThe model reads the error on its next call and can try again with both arguments.

From the model's text to a function callShown: JSON string, json.loads, valid?, get_pod_status. JSON string to json.loads. json.loads to valid?. valid? to get_pod_status (stopped).

What the model sent

"arguments": "{\"namespace\": \"payments\"}"

What your code sends back

{"role": "tool", "tool_call_id": "call_2", "content": "error: pod_name is required"}
OPTION 3 OF 3 · Cut-off JSON

The reply hit its length limit halfway through the arguments, so the string is not JSON at all.

DOES NOT RUNjson.loads raises. Catch it and return an error, or the whole run ends on one malformed reply.

From the model's text to a function callShown: JSON string, json.loads, valid?, get_pod_status. JSON string to json.loads. json.loads to valid? (stopped).

What the model sent

"arguments": "{\"namespace\": \"payments\", \"pod_na""finish_reason": "length"

Python

try:    arguments = json.loads(call.function.arguments)except json.JSONDecodeError:    result = "error: arguments were not valid JSON"
INSPECTOR

The description guides. Your check decides.

The description uses JSON Schema to say which arguments exist, their types, and which are required. It steers what the model writes. It does not make the output correct, so the code that receives the string has to parse it and validate it before anything runs.

A schema is
Guidance to the model, and a contract you can check against
Valid arguments are
Well-formed, not necessarily permitted
Errors go
Back to the model, as the tool result
The contract"required": ["namespace", "pod_name"]
WORTH KNOWING

Some providers offer a strict mode that constrains the reply to the schema. Chat Completions requests are not strict by default. Either way, a well-formed call can still name a namespace this user should never read. That check comes later on this page.

OpenAI: function calling guide ↗

THE POINT TO KEEPA schema shapes what the model writes. Validation in your code decides what runs.

06 / TOOLS

Behind the function is the Kubernetes API

Swap the toy function for one that lists pods. The model's side does not change at all.

STEP 1 OF 3 · Call the API

The function makes the same request kubectl get pods -n payments would make, with whatever credential your process holds.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. tool function to API server: GET …/pods.

Python

from kubernetes import client, config config.load_kube_config()        # or the pod's ServiceAccountcore = client.CoreV1Api() def list_pods(namespace: str):    pods = core.list_namespaced_pod(namespace).items
STEP 2 OF 3 · Read the answer

The API server replies with complete Pod objects: spec, status, managed fields, every condition.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. API server to tool function: full JSON.

If you returned it as is

  • Full Pod objects5,200

Example size for a handful of pods. All of it would be model input, on this call and every later one.

STEP 3 OF 3 · Return four fields

The function keeps what a diagnosis needs and returns that.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. tool function to your code: compact.

What the model is sent

  • Full Pod objects5,200
  • Four fields per pod45

Example sizes. The result becomes a message, so it is resent on every later call.

Python

    return [        {"name": pod.metadata.name,         "phase": pod.status.phase,         "ready": all(c.ready for c in statuses(pod)),         "restarts": sum(c.restart_count                         for c in statuses(pod))}        for pod in pods    ]

Tool result

[{"name": "payments-api-7d9f",  "phase": "Running",  "ready": false,  "restarts": 8}]
INSPECTOR

A tool is ordinary code with ordinary credentials

The function reaches the cluster the way any client does: an HTTPS request to the API server, authenticated with a kubeconfig or a ServiceAccount token. Kubernetes authorises that credential. It has no idea a model was involved.

Same API as
kubectl get pods -n payments
Authorised by
RBAC, for the credential your process uses
The model sees
Only what the function returns
The request underneathGET /api/v1/namespaces/payments/pods
WORTH KNOWING

A pod reported as Running can still be failing: Running is the phase, and it only says a container has started at least once. The ready flag and restart count are what show the trouble here.

Kubernetes: API concepts ↗Kubernetes: Pod lifecycle ↗LangChain: tools ↗

THE POINT TO KEEPReturn the few facts the model needs. Every byte a tool returns is input on every later call.

07 / THE LOOP

A real question takes several round trips

Ask why payments-api is failing. The model has two tools and decides what to look at.

STEP 1 OF 6 · Model call 1

The question goes out with the system message and both tool descriptions.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. your code to model: model call 1.

messages

  1. systemwritten by youYou are a read-only Kubernetes assistant for the payments namespace.
  2. userwritten by the userWhy is payments-api failing?new

What model call 1 receives

  • System prompt40
  • Tool schemas160
  • Conversation10

Example token counts. Real counts depend on the model's tokenizer.

STEP 2 OF 6 · It asks for the pods

The model wants to see what is in the namespace first.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. model to your code: asks for list_pods.

messages

  1. systemwritten by youYou are a read-only Kubernetes assistant for the payments namespace.
  2. userwritten by the userWhy is payments-api failing?
  3. assistantwritten by the modeltool_calls: list_pods(namespace="payments") id=call_1new

What model call 1 receives

  • System prompt40
  • Tool schemas160
  • Conversation10

Example token counts. Real counts depend on the model's tokenizer.

STEP 3 OF 6 · Your code lists them

Your code runs list_pods and appends the result. One pod is not ready and has restarted eight times.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. your code to tool function: call. tool function to your code: result. tool function to API server: HTTPS GET. API server to tool function: JSON.

messages

  1. systemwritten by youYou are a read-only Kubernetes assistant for the payments namespace.
  2. userwritten by the userWhy is payments-api failing?
  3. assistantwritten by the modeltool_calls: list_pods(namespace="payments") id=call_1
  4. toolwritten by your codepayments-api-7d9f phase=Running ready=0/1 restarts=8 (answers call_1)new

What model call 2 receives

  • System prompt40
  • Tool schemas160
  • Conversation70

Example token counts. Real counts depend on the model's tokenizer.

STEP 4 OF 6 · Model call 2

The whole list goes out again. This time the model asks for details of the pod that looked wrong.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. your code to model: model call 2. model to your code: asks for get_pod_details.

messages

  1. systemwritten by youYou are a read-only Kubernetes assistant for the payments namespace.
  2. userwritten by the userWhy is payments-api failing?
  3. assistantwritten by the modeltool_calls: list_pods(namespace="payments") id=call_1
  4. toolwritten by your codepayments-api-7d9f phase=Running ready=0/1 restarts=8 (answers call_1)
  5. assistantwritten by the modeltool_calls: get_pod_details(namespace="payments", pod_name="payments-api-7d9f") id=call_2new

What model call 2 receives

  • System prompt40
  • Tool schemas160
  • Conversation70

Example token counts. Real counts depend on the model's tokenizer.

STEP 5 OF 6 · Your code gets details

The container is waiting in CrashLoopBackOff, and its previous run ended with exit code 137, reason OOMKilled.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. your code to tool function: call. tool function to your code: result. tool function to API server: HTTPS GET. API server to tool function: JSON.

messages

  1. systemwritten by youYou are a read-only Kubernetes assistant for the payments namespace.
  2. userwritten by the userWhy is payments-api failing?
  3. assistantwritten by the modeltool_calls: list_pods(namespace="payments") id=call_1
  4. toolwritten by your codepayments-api-7d9f phase=Running ready=0/1 restarts=8 (answers call_1)
  5. assistantwritten by the modeltool_calls: get_pod_details(namespace="payments", pod_name="payments-api-7d9f") id=call_2
  6. toolwritten by your codestate=waiting reason=CrashLoopBackOff restarts=8 last_exit_code=137 last_reason=OOMKilled (answers call_2)new

What model call 3 receives

  • System prompt40
  • Tool schemas160
  • Conversation165

Example token counts. Real counts depend on the model's tokenizer.

STEP 6 OF 6 · Model call 3

Third call, six items. The model has what it needs and answers in text.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. your code to model: model call 3. model to your code: plain text.

messages

  1. systemwritten by youYou are a read-only Kubernetes assistant for the payments namespace.
  2. userwritten by the userWhy is payments-api failing?
  3. assistantwritten by the modeltool_calls: list_pods(namespace="payments") id=call_1
  4. toolwritten by your codepayments-api-7d9f phase=Running ready=0/1 restarts=8 (answers call_1)
  5. assistantwritten by the modeltool_calls: get_pod_details(namespace="payments", pod_name="payments-api-7d9f") id=call_2
  6. toolwritten by your codestate=waiting reason=CrashLoopBackOff restarts=8 last_exit_code=137 last_reason=OOMKilled (answers call_2)
  7. assistantwritten by the modelpayments-api-7d9f is crash-looping. Its last run was killed for exceeding its memory limit (OOMKilled, exit code 137), and it has restarted 8 times.new

What model call 3 receives

  • System prompt40
  • Tool schemas160
  • Conversation165

Example token counts. Real counts depend on the model's tokenizer.

INSPECTOR

One question, three model calls

Nobody told the model to list pods before asking for details. It chose that order from the tool descriptions and from what the first result showed. Your code did the same thing each time round: run what was asked, append the result, call again.

Model calls
3
Tool runs
2, both by your code
Items in the list at the end
7
The evidence it usedlast_exit_code=137 last_reason=OOMKilled
WORTH KNOWING

CrashLoopBackOff is what the container is doing now: waiting before its next restart. Why it crashed is recorded on the previous run, as the last termination reason. OOMKilled there means the container went over its memory limit.

Kubernetes: Pod lifecycle ↗OpenAI: function calling guide ↗

THE POINT TO KEEPThe model picks the next tool from what it has seen so far. Your code runs it and reports back.

08 / THE LOOP

Fold those round trips up and you have a loop

The three calls you just followed are one pattern repeated: call the model, run what it asks for, call it again.

OPTION 1 OF 3 · A normal run

Round the left side while the model keeps asking for tools, out through the right side when it answers in text.

The loop around a model and its toolsShown: model, tool calls?, run the tools, return the answer. model to tool calls?: reply. tool calls? to run the tools: yes. run the tools to model: results.

The loop

messages = [system, question]for _ in range(MAX_STEPS):                # your limit    reply = call_model(messages, TOOLS)    messages.append(reply)                # keep the request    if not reply.tool_calls:        return reply.content              # plain text: done    for call in reply.tool_calls:         # may be several        result = run_tool(call)           # your code runs it        messages.append(tool_message(call.id, result))return "Stopped: step limit reached."
OPTION 2 OF 3 · A tool fails

Kubernetes refuses the request. The function returns the error as text, and the model reads it like any other result and explains it.

THE LOOP KEEPS GOINGAn exception here would end the run. An error string gives the model something to report: "I can't read pods in that namespace."

The loop around a model and its toolsShown: model, tool calls?, run the tools, return the answer. run the tools to model: "error: 403 Forbidden". run the tools: failed.

The loop

messages = [system, question]for _ in range(MAX_STEPS):                # your limit    reply = call_model(messages, TOOLS)    messages.append(reply)                # keep the request    if not reply.tool_calls:        return reply.content              # plain text: done    for call in reply.tool_calls:         # may be several        result = run_tool(call)           # your code runs it        messages.append(tool_message(call.id, result))return "Stopped: step limit reached."
OPTION 3 OF 3 · It never stops asking

A confused model can keep requesting tools. The loop has no natural end, so yours needs a limit.

STOPPED BY YOUR CODEWithout MAX_STEPS this run would continue, and bill, until something outside it gave up.

The loop around a model and its toolsShown: model, tool calls?, run the tools, return the answer. tool calls? to run the tools: yes (stopped). tool calls? to return the answer: limit reached. tool calls?: call 6 of 6.

The loop

messages = [system, question]for _ in range(MAX_STEPS):                # your limit    reply = call_model(messages, TOOLS)    messages.append(reply)                # keep the request    if not reply.tool_calls:        return reply.content              # plain text: done    for call in reply.tool_calls:         # may be several        result = run_tool(call)           # your code runs it        messages.append(tool_message(call.id, result))return "Stopped: step limit reached."
INSPECTOR

This loop is what people call an agent

An agent is a model, some tools, the message list, and a loop that keeps calling the model until it stops asking for tools. The model steers. Everything that actually happens is done by the code around it.

The model decides
Which tool, which arguments, when it has enough
Your loop decides
Whether to run it, how often, when to give up
State
The message list
The exit conditionif not reply.tool_calls: return reply.content
WORTH KNOWING

The limit, the tool lookup, argument checks, timeouts and permissions all live in this loop. A prebuilt agent still has them; they are just configured instead of written.

LangChain: agents ↗LangChain: models and bind_tools ↗

THE POINT TO KEEPAn agent is a loop around a model and tools, and the loop belongs to your code.

09 / THE LOOP

The model proposes. Four checks decide.

Assume the model can be talked into requesting anything. Then look at what each request has to get past.

OPTION 1 OF 4 · Read its own namespace

An ordinary request passes every check and runs.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. model to your code: a request. your code to tool function: call. tool function to your code: return. tool function to API server: HTTPS GET. API server to tool function: JSON.

What the request has to pass

list_pods(namespace="payments")
  1. Is this a tool we registered?yeslist_pods is one of two tools this agent was given.
  2. Do the arguments match the schema?yesnamespace is a string.
  3. Is it inside the scope the server set?yesThe server fixed this session to payments.
  4. Does Kubernetes allow our credential?yesThe ServiceAccount may list pods in payments.

RUNSThe pods are listed.

OPTION 2 OF 4 · Delete a system pod

Text in a log line persuaded the model to ask for a deletion. There is no such tool to run.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. model to your code: a request. your code to tool function: no such tool (stopped).

What the request has to pass

delete_pod(namespace="kube-system", pod_name="coredns-5d78c")
  1. Is this a tool we registered?noThis agent has list_pods and get_pod_details. Nothing that writes.
  2. Do the arguments match the schema?not reached
  3. Is it inside the scope the server set?not reached
  4. Does Kubernetes allow our credential?not reached

DOES NOT RUNYour code returns "error: unknown tool" and the model has to say it cannot do that.

OPTION 3 OF 4 · Read another namespace

A real tool, well-formed arguments, wrong scope.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. model to your code: a request. your code to tool function: out of scope (stopped).

What the request has to pass

list_pods(namespace="kube-system")
  1. Is this a tool we registered?yeslist_pods is registered.
  2. Do the arguments match the schema?yesnamespace is a string.
  3. Is it inside the scope the server set?noThe server set namespace=payments for this session. The model's value is not used to widen it.
  4. Does Kubernetes allow our credential?not reached

DOES NOT RUNThe call is refused before it reaches Kubernetes.

OPTION 4 OF 4 · Your check has a gap

Suppose the scope check was never written. The request reaches the API server, and the credential itself is refused.

Your code, the model, a tool function and the clusterShown: your code, model, tool function, API server, pods. model to your code: a request. your code to tool function: call. tool function to API server: 403 (stopped).

What the request has to pass

list_pods(namespace="kube-system")
  1. Is this a tool we registered?yeslist_pods is registered.
  2. Do the arguments match the schema?yesnamespace is a string.
  3. Is it inside the scope the server set?yesNo scope check exists in this version.
  4. Does Kubernetes allow our credential?noRBAC: this ServiceAccount cannot list pods in kube-system.

DOES NOT RUNKubernetes answers 403. The Role only grants reads in payments.

INSPECTOR

Authority lives in tools and credentials

A prompt cannot stop a model from asking. Any text it reads, from a user, a document or a tool result, can change what it requests. What you control is which tools exist, what your code checks before running one, and what the credential underneath is allowed to do.

The model controls
Which registered tool it asks for, with which arguments, and when it stops
Your code controls
Which tools exist, validation, scope, timeouts, retries, loop limits, approval
The platform controls
What the credential may do: IAM, RBAC
The last line of defencekubectl auth can-i list pods -n kube-system --as=system:serviceaccount:payments:agent
WORTH KNOWING

Take values that carry authority, such as the namespace or tenant, from server context instead of from the model's arguments. Then a successful injection changes what is asked for, and nothing else.

Kubernetes: RBAC authorization ↗LangChain: tools ↗OpenAI: function calling guide ↗

THE POINT TO KEEPThe system prompt asks nicely. Tools, checks and least-privilege credentials are what enforce.

10 / LANGCHAIN

LangChain gives the same pieces one spelling

Nothing new happens in this view. The request, the reply and the tool result get standard classes.

OPTION 1 OF 2 · By hand

Everything so far, written against one provider's JSON.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. your code to model: list of dicts + JSON schemas. model to your code: provider JSON.

By hand

messages = [{"role": "user", "content": question}]response = client.chat.completions.create(    model=MODEL, messages=messages, tools=SCHEMAS)message = response.choices[0].messagecall = message.tool_calls[0]arguments = json.loads(call.function.arguments)result = TOOLS_BY_NAME[call.function.name](**arguments)messages.append(message)messages.append({"role": "tool",                 "tool_call_id": call.id,                 "content": result})
OPTION 2 OF 2 · With LangChain

The same exchange. The picture has not changed; the objects travelling along it have names.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. your code to model: messages + bound tools. model to your code: AIMessage.
What replaced what
By handLangChainWhat you stop writing
{"role": …} dictsSystemMessage, HumanMessage, AIMessage, ToolMessagePer-provider message shapes
A JSON schema per function@toolThe schema
Provider client and response parsingA chat model class with .invoke()Digging through choices[0]
json.loads on the argumentsAIMessage.tool_calls, already parsedThe parse step
A {"role": "tool"} dictToolMessage from tool.invoke(call)Matching the id by hand

With LangChain

messages = [HumanMessage(question)]reply = model_with_tools.invoke(messages)   # an AIMessagecall = reply.tool_calls[0]     # {"name", "args", "id"}result = TOOLS_BY_NAME[call["name"]].invoke(call)messages.append(reply)messages.append(result)        # already a ToolMessage
INSPECTOR

A standard layer over many provider APIs

Each provider names these things differently on the wire. LangChain gives messages, models and tools one interface, with a provider package underneath that does the translation. Change the provider and the loop you wrote stays the same.

langchain-core
Messages, tools, the Runnable interface
Provider packages
langchain-openai, langchain-anthropic, langchain-openrouter, …
Still yours
The loop
One constructor for any providerinit_chat_model("…", model_provider="openrouter")
WORTH KNOWING

LangChain's documentation recommends the dedicated langchain-openrouter package for OpenRouter. Pointing ChatOpenAI at OpenRouter's base URL works for plain chat, but fields a router adds may not survive.

LangChain: messages ↗LangChain: models and bind_tools ↗LangChain: tools ↗

THE POINT TO KEEPLangChain standardises the pieces around the model call. It does not change what the call is.

11 / LANGCHAIN

@tool writes the description from the function

The JSON you wrote by hand earlier is built from three things the function already has.

STEP 1 OF 3 · The name

The function's name becomes the tool's name.

You write

@tooldef get_pod_details(namespace: str, pod_name: str) -> str:    """Show one pod's container state, last exit code    and reason."""    …

The model is sent

{"type": "function", "function": {   "name": "get_pod_details",   "description": "Show one pod's container state,                   last exit code and reason.",   "parameters": {     "type": "object",     "properties": {"namespace": {"type": "string"},                    "pod_name":  {"type": "string"}},     "required": ["namespace", "pod_name"]}}}
STEP 2 OF 3 · The docstring

The docstring becomes the description. The model reads it to decide when to use the tool, so it is prompt text.

You write

@tooldef get_pod_details(namespace: str, pod_name: str) -> str:    """Show one pod's container state, last exit code    and reason."""    …

The model is sent

{"type": "function", "function": {   "name": "get_pod_details",   "description": "Show one pod's container state,                   last exit code and reason.",   "parameters": {     "type": "object",     "properties": {"namespace": {"type": "string"},                    "pod_name":  {"type": "string"}},     "required": ["namespace", "pod_name"]}}}
STEP 3 OF 3 · The type hints

The type hints become the argument schema. An argument with no default is required.

You write

@tooldef get_pod_details(namespace: str, pod_name: str) -> str:    """Show one pod's container state, last exit code    and reason."""    …

The model is sent

{"type": "function", "function": {   "name": "get_pod_details",   "description": "Show one pod's container state,                   last exit code and reason.",   "parameters": {     "type": "object",     "properties": {"namespace": {"type": "string"},                    "pod_name":  {"type": "string"}},     "required": ["namespace", "pod_name"]}}}
INSPECTOR

Three parts of a function become three parts of a schema

The decorator turns the function into a tool object. Calling tool.invoke(call) with a tool-call dict validates the arguments against this schema, runs the function, and wraps the return value in a ToolMessage with the matching id.

Function name
Tool name
Docstring
Description the model reads
Type hints
Argument schema, checked before the function runs
See what was builtget_pod_details.name, .description, .args
WORTH KNOWING

Write the docstring for the model: say when to call the tool and what it returns. A missing argument raises a validation error before your function body runs.

LangChain: tools ↗

THE POINT TO KEEPName, docstring and type hints are the tool's contract. Write them for the model that will read them.

12 / LANGCHAIN

bind_tools attaches descriptions. It runs nothing.

This is the same step you saw with one toy function, under its LangChain name.

STEP 1 OF 3 · Bind

bind_tools returns a copy of the model that will attach the tool descriptions to every request.

Your code, the model, a tool function and the clusterShown: your code, model, tool function.

Python

model_with_tools = model.bind_tools(    [list_pods, get_pod_details])
STEP 2 OF 3 · Invoke

The request carries messages and descriptions. The reply is an AIMessage with empty content and one tool call.

NOTHING HAS RUNlist_pods has not been called. The reply only says the model would like it to be.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. your code to model: messages + descriptions. model to your code: AIMessage(tool_calls=[…]). model to tool function: the model runs the tool? (no such path).

Python

reply = model_with_tools.invoke(messages)

reply

AIMessage(  content="",  tool_calls=[{    "name": "list_pods",    "args": {"namespace": "payments"},    "id": "call_1",  }],)
STEP 3 OF 3 · Run it yourself

Outside an agent, running the tool and appending its ToolMessage is still your code's job.

Your code, the model, a tool function and the clusterShown: your code, model, tool function. your code to tool function: call. tool function to your code: ToolMessage.

Python

messages.append(reply)for call in reply.tool_calls:    tool = TOOLS_BY_NAME[call["name"]]    messages.append(tool.invoke(call))   # a ToolMessage
INSPECTOR

What bind_tools does and does not do

It makes the tool descriptions part of each request, so the model can reply with a structured tool call. It does not connect the model to your functions. The arguments in reply.tool_calls are already parsed into a dict, which is one step fewer than the raw API.

Does
Send the descriptions with every request
Does not
Execute a tool, ever
tool_choice
Can force a tool call, or one specific tool
Where the request landsreply.tool_calls[0]["args"]
WORTH KNOWING

Some providers offer tools they run on their own servers, such as web search. Those come back already executed inside the reply. Tools you define are always run by you.

LangChain: models and bind_tools ↗

THE POINT TO KEEPBinding a tool gives the model a way to ask. Execution stays in your process.

13 / LANGCHAIN

The pieces snap together in a line, and only in a line

Prompts, models and parsers share one interface, so they can be joined with a pipe.

STEP 1 OF 3 · One interface

Each box takes an input and returns an output through the same three methods. LangChain calls anything with this interface a Runnable.

A chain of RunnablesShown: input, prompt, model, parser, output.
What every Runnable has
MethodDoes
invoke(input)One input, one output
stream(input)Yields the output in chunks as it is produced
batch([inputs])Runs several inputs concurrently
STEP 2 OF 3 · Join them

The pipe feeds each output into the next input. The result is itself a Runnable, so the whole chain can be invoked, streamed or batched.

A chain of RunnablesShown: input, prompt, model, parser, output. input to prompt. prompt to model. model to parser. parser to output.

Python

chain = prompt | model | parser chain.invoke({"question": "What is a Pod?"}) # "A Pod is one or more containers…"
STEP 3 OF 3 · Now draw the agent

The agent needs to go from the model to the tools and back to the model, as many times as it takes. A pipe only runs forward.

A PIPE CANNOT LOOPprompt | model | tools runs each step once and ends. "Again, until the model stops asking" has nowhere to go.

A chain of RunnablesShown: input, prompt, model, parser, output, tools. model to tools. tools to model: no way back (stopped).
INSPECTOR

Good for pipelines, silent about loops

Composing Runnables with | is called LCEL, the LangChain Expression Language. It describes data flowing one way: fill a prompt, call a model, parse the result. That covers a lot of useful programs. It has no way to repeat a step until a condition holds.

Fits
Fixed steps known in advance
Does not fit
Repeating, branching on results, pausing
Also Runnables
Tools, retrievers, and compiled LangGraph graphs
The same thing without the pipeparser.invoke(model.invoke(prompt.invoke(x)))
WORTH KNOWING

This is why the loop earlier was a plain Python for loop even though every piece inside it was a LangChain object.

LangChain reference: Runnable ↗LangChain: models and bind_tools ↗

THE POINT TO KEEPLCEL composes straight pipelines. An agent needs a cycle, which is a different kind of structure.

14 / LANGCHAIN

create_agent is the loop, already written

Hand it a model and tools, and it builds the loop you wrote by hand.

STEP 1 OF 3 · Your loop

The loop from earlier: call the model, run the tools it asks for, repeat until it answers.

The loop around a model and its toolsShown: model, tool calls?, run the tools, return the answer.

By hand

messages = [system, question]for _ in range(MAX_STEPS):                # your limit    reply = call_model(messages, TOOLS)    messages.append(reply)                # keep the request    if not reply.tool_calls:        return reply.content              # plain text: done    for call in reply.tool_calls:         # may be several        result = run_tool(call)           # your code runs it        messages.append(tool_message(call.id, result))return "Stopped: step limit reached."
STEP 2 OF 3 · One call

The same loop, built for you. The picture is identical.

The loop around a model and its toolsShown: model, tool calls?, run the tools, return the answer.

With create_agent

from langchain.agents import create_agent agent = create_agent(    model,    tools=[list_pods, get_pod_details],    system_prompt="You are a read-only Kubernetes…",)result = agent.invoke({"messages": [    {"role": "user",     "content": "Why is payments-api failing?"}]})result["messages"][-1].text
STEP 3 OF 3 · Look at what it built

What comes back is a graph with a model node and a tools node. The loop was not removed. It was drawn for you.

The loop around a model and its toolsShown: model, tool calls?, run the tools, return the answer. model: node: model. run the tools: node: tools.

Python

type(agent).__name__ # 'CompiledStateGraph'list(agent.get_graph().nodes) # ['__start__', 'model', 'tools', '__end__']

Your limits become middleware

from langchain.agents.middleware import (    ModelCallLimitMiddleware, HumanInTheLoopMiddleware) agent = create_agent(model, tools=TOOLS, middleware=[    ModelCallLimitMiddleware(run_limit=6),  # MAX_STEPS    HumanInTheLoopMiddleware(        interrupt_on={"set_memory_limit": True}),], checkpointer=checkpointer)
INSPECTOR

The standard loop as a compiled graph

create_agent returns a compiled LangGraph graph. Its model node calls the model; if the reply contains tool calls, the tools node runs them and appends the results; then the model node runs again. The run ends when a reply has no tool calls.

Input and output
A dict with a messages list
Customised with
Middleware: hooks around the model call and each tool call
Still yours
Which tools exist and what they are allowed to do
Nodes in the graph it returns['__start__', 'model', 'tools', '__end__']
WORTH KNOWING

Older tutorials use AgentExecutor, or create_react_agent from langgraph.prebuilt. create_agent in the langchain package replaces both. Pausing for approval needs a checkpointer, which is where LangGraph comes in.

LangChain: agents ↗LangChain reference: create_agent ↗LangChain: human-in-the-loop middleware ↗

THE POINT TO KEEPcreate_agent packages the model-and-tools loop as a graph. The graph is still there to inspect and extend.

15 / ALL THE LAYERS

The same question at five levels

"Why is payments-api failing?" runs the same way at every level. What changes is how much of it you write.

LEVEL 1 OF 5 · Raw HTTP

You build the JSON, send it, parse the reply, and write the loop.

What each layer adds
LayerIt gives youYou still write
Model APIMessages in, one message outEverything else
SDKBuilds the HTTP call, parses the JSONMessages, tools, the loop
Tool callingLets the reply be a structured requestRunning the tool, sending the result
A loop you wroteRepeats until the model answers in textLimits, checks, errors
LangChain partsOne spelling for messages, models and toolsThe loop
create_agentThe standard loop, with hooksTools, limits, approvals
LangGraphThe loop as a graph you can change, save and pauseDesigning the graph

Raw HTTP

POST https://openrouter.ai/api/v1/chat/completionsAuthorization: Bearer $OPENROUTER_API_KEY {"model": "…", "messages": [{"role": "user",               "content": "Why is payments-api failing?"}], "tools": [{"type": "function", "function": {…}}]}
LEVEL 2 OF 5 · SDK

The SDK builds the request and gives you objects back. Messages, tool schemas and the loop are still yours.

What each layer adds
LayerIt gives youYou still write
Model APIMessages in, one message outEverything else
SDKBuilds the HTTP call, parses the JSONMessages, tools, the loop
Tool callingLets the reply be a structured requestRunning the tool, sending the result
A loop you wroteRepeats until the model answers in textLimits, checks, errors
LangChain partsOne spelling for messages, models and toolsThe loop
create_agentThe standard loop, with hooksTools, limits, approvals
LangGraphThe loop as a graph you can change, save and pauseDesigning the graph

OpenAI SDK

response = client.chat.completions.create(    model=MODEL, messages=messages, tools=SCHEMAS)message = response.choices[0].messageif message.tool_calls:    …   # parse arguments, run, append, call again
LEVEL 3 OF 5 · LangChain parts

Messages, the model and the tools are standard objects. The loop is still a few lines of your Python.

What each layer adds
LayerIt gives youYou still write
Model APIMessages in, one message outEverything else
SDKBuilds the HTTP call, parses the JSONMessages, tools, the loop
Tool callingLets the reply be a structured requestRunning the tool, sending the result
A loop you wroteRepeats until the model answers in textLimits, checks, errors
LangChain partsOne spelling for messages, models and toolsThe loop
create_agentThe standard loop, with hooksTools, limits, approvals
LangGraphThe loop as a graph you can change, save and pauseDesigning the graph

LangChain parts

model_with_tools = model.bind_tools(TOOLS)for _ in range(MAX_STEPS):    reply = model_with_tools.invoke(messages)    messages.append(reply)    if not reply.tool_calls:        break    for call in reply.tool_calls:        messages.append(TOOLS_BY_NAME[call["name"]].invoke(call))
LEVEL 4 OF 5 · create_agent

The loop disappears from your code. Limits and approvals become configuration.

What each layer adds
LayerIt gives youYou still write
Model APIMessages in, one message outEverything else
SDKBuilds the HTTP call, parses the JSONMessages, tools, the loop
Tool callingLets the reply be a structured requestRunning the tool, sending the result
A loop you wroteRepeats until the model answers in textLimits, checks, errors
LangChain partsOne spelling for messages, models and toolsThe loop
create_agentThe standard loop, with hooksTools, limits, approvals
LangGraphThe loop as a graph you can change, save and pauseDesigning the graph

create_agent

agent = create_agent(model, tools=TOOLS,                     system_prompt=SYSTEM)result = agent.invoke({"messages": [question]})
LEVEL 5 OF 5 · LangGraph

The loop comes back into view as a graph, so you can change its shape, save its state and pause it.

What each layer adds
LayerIt gives youYou still write
Model APIMessages in, one message outEverything else
SDKBuilds the HTTP call, parses the JSONMessages, tools, the loop
Tool callingLets the reply be a structured requestRunning the tool, sending the result
A loop you wroteRepeats until the model answers in textLimits, checks, errors
LangChain partsOne spelling for messages, models and toolsThe loop
create_agentThe standard loop, with hooksTools, limits, approvals
LangGraphThe loop as a graph you can change, save and pauseDesigning the graph

LangGraph

graph = StateGraph(MessagesState)graph.add_node("model", call_model)graph.add_node("tools", ToolNode(TOOLS))graph.add_edge(START, "model")graph.add_conditional_edges("model", tools_condition)graph.add_edge("tools", "model")app = graph.compile()
INSPECTOR

Pick the lowest level that does the job

A single call needs no framework. A fixed pipeline fits a chain. A tool loop you want to ship fits create_agent. Reach for a hand-built graph when you need your own control flow, saved state, or a pause for a person.

Before shipping
Bound the loop, cap tool output, return errors as text
Watch
Model calls per question, input tokens per call, tool errors
Never rely on
The system prompt as a permission check
What one reply costreply.usage_metadata["input_tokens"]
WORTH KNOWING

Moving a tool behind an MCP server changes where the function runs, from your process to another one over the network. The loop on this page is unchanged, and so are the four checks.

LangChain: agents ↗LangChain: models and bind_tools ↗LangChain: Model Context Protocol ↗

THE POINT TO KEEPEach layer formalises plumbing you could write yourself. The model call underneath is the same at every level.