All articles
October 26, 2025 6 min read

Building with LangChain Core: models, messages, tools, and the loop

langchain-core is the bottom layer, where you control everything. Here's how to actually use its building blocks — one model interface, typed messages, the @tool decorator, the hand-written loop, and structured output.

Written forEngineering
LangChainToolingFundamentals

In the LangChain-ecosystem-layers post, langchain-core sits at the bottom: maximum control, minimum magic. This is the hands-on version — how to build with its five components and write the agent loop yourself. It's worth doing at least once, because everything above it (langgraph, create_agent) is sugar over exactly these parts.

GoalLLM — reason + decideTool call?yesRun toolresultdoneAnswer
At the core layer you write this loop yourself: call the model, run the tools it requests, feed the results back, and repeat until it's done.

Models: one interface to every LLM

A chat model is a single interface to every provider. You invoke it for a full response or stream it for tokens, and you swap providers by swapping the class — the calling code doesn't change.

One interface, any provider
from langchain_openai import ChatOpenAI

model = ChatOpenAI(model='gpt-4o')
model.invoke('Explain embeddings in one line.')   # or .stream(...) for tokens
# switch to ChatAnthropic(...) and the rest of your code is unchanged

Messages: typed objects, not role dicts

Instead of raw {'role': 'user'} dictionaries, langchain-core uses typed message objects — SystemMessage, HumanMessage, AIMessage, ToolMessage. They carry structure (like the tool calls on an AIMessage) that plain dicts can't, which is what makes the loop below clean.

Tools: the @tool decorator

Decorate a plain function with @tool and its docstring and type hints become the schema the model sees — no hand-written JSON Schema.

A function becomes a tool
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Return the weather for a city."""   # docstring + hints = the schema
    return lookup(city)

The loop: bind_tools, read tool_calls, return ToolMessage

At this layer the agent loop is yours. Bind the tools to the model, read the tool calls off the response, run each one, append a ToolMessage, and call the model again until it stops asking for tools. This is the exact loop create_agent and langgraph build for you — here you can see every step.

The loop, by hand
from langchain_core.messages import ToolMessage

model = ChatOpenAI(model='gpt-4o').bind_tools([get_weather])
ai = model.invoke(messages)

for call in ai.tool_calls:                       # the model requested these
    result = run(call['name'], call['args'])
    messages.append(ToolMessage(result, tool_call_id=call['id']))
# loop: invoke again until ai.tool_calls is empty, then return ai.content

Structured output: Pydantic via with_structured_output

When you need a typed object instead of prose, wrap the model with a Pydantic schema and it returns an instance, validated for you (the structured-outputs post covers why this matters).

Typed output, not text
from pydantic import BaseModel

class Extract(BaseModel):
    name: str
    urgency: str

structured = ChatOpenAI(model='gpt-4o').with_structured_output(Extract)
structured.invoke('...')      # returns an Extract instance
Write the loop by hand once, at the core layer, and every agent framework above it stops being magic — it's just this, with the plumbing hidden.
Building something with LLMs?
I help teams ship GenAI that’s reliable and cost-efficient.
Let’s talk