diff --git a/public/images/blog/langchain-createagent-middleware.png b/public/images/blog/langchain-createagent-middleware.png new file mode 100644 index 0000000..afa3380 Binary files /dev/null and b/public/images/blog/langchain-createagent-middleware.png differ diff --git a/public/images/blog/langchain-ecosystem-map-2026.png b/public/images/blog/langchain-ecosystem-map-2026.png new file mode 100644 index 0000000..72da485 Binary files /dev/null and b/public/images/blog/langchain-ecosystem-map-2026.png differ diff --git a/public/images/blog/langgraph-stateful-orchestration.png b/public/images/blog/langgraph-stateful-orchestration.png new file mode 100644 index 0000000..b1db92f Binary files /dev/null and b/public/images/blog/langgraph-stateful-orchestration.png differ diff --git a/src/app/experiments/[slug]/page.tsx b/src/app/experiments/[slug]/page.tsx index 173bcef..f3a37db 100644 --- a/src/app/experiments/[slug]/page.tsx +++ b/src/app/experiments/[slug]/page.tsx @@ -4,7 +4,7 @@ import { MDXRemote } from 'next-mdx-remote/rsc'; import Image from 'next/image'; import { Section } from '@/components/sections'; import { Button, Badge } from '@/components/ui'; -import { ReadingProgressBar } from '@/components/blog'; +import { ReadingProgressBar, SeriesNav, SeriesPager } from '@/components/blog'; import { contentService } from '@/lib/services'; import { ROUTES } from '@/lib/constants'; import type { Metadata } from 'next'; @@ -60,6 +60,16 @@ export default async function ExperimentPage({ params }: BlogPostPageProps) { const post = postResult.data; + const seriesResult = post.series ? await contentService.getPostsBySeries(post.series) : null; + const seriesPosts = seriesResult?.success ? seriesResult.data : []; + + const currentIndex = seriesPosts.findIndex((p) => p.slug === post.slug); + const previousPart = currentIndex > 0 ? seriesPosts[currentIndex - 1] : null; + const nextPart = + currentIndex >= 0 && currentIndex < seriesPosts.length - 1 + ? seriesPosts[currentIndex + 1] + : null; + return (
{/* Reading Progress Bar */} @@ -125,6 +135,16 @@ export default async function ExperimentPage({ params }: BlogPostPageProps) { + {/* Series Navigation */} + {post.series && seriesPosts.length > 1 && ( + + )} + {/* Article Content */}
+ + {/* Series part navigation */} + {post.series && seriesPosts.length > 1 && ( + + )} diff --git a/src/components/blog/SeriesNav/SeriesNav.tsx b/src/components/blog/SeriesNav/SeriesNav.tsx new file mode 100644 index 0000000..ed4fb09 --- /dev/null +++ b/src/components/blog/SeriesNav/SeriesNav.tsx @@ -0,0 +1,79 @@ +import Link from 'next/link'; +import { Layers } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SeriesNavProps } from './SeriesNav.types'; + +/** + * SeriesNav Component + * Renders the full table of contents for a multi-part series, + * highlighting the part currently being read. + */ +export function SeriesNav({ series, posts, currentSlug, className }: SeriesNavProps) { + if (posts.length < 2) { + return null; + } + + const currentIndex = posts.findIndex((p) => p.slug === currentSlug); + + return ( + + ); +} diff --git a/src/components/blog/SeriesNav/SeriesNav.types.ts b/src/components/blog/SeriesNav/SeriesNav.types.ts new file mode 100644 index 0000000..72aef43 --- /dev/null +++ b/src/components/blog/SeriesNav/SeriesNav.types.ts @@ -0,0 +1,15 @@ +export interface SeriesNavItem { + slug: string; + title: string; + seriesOrder?: number; +} + +export interface SeriesNavProps { + /** Series name (frontmatter `series`) */ + series: string; + /** All posts in the series, ordered by `seriesOrder` */ + posts: readonly SeriesNavItem[]; + /** Slug of the post currently being viewed */ + currentSlug: string; + className?: string; +} diff --git a/src/components/blog/SeriesNav/index.ts b/src/components/blog/SeriesNav/index.ts new file mode 100644 index 0000000..460765f --- /dev/null +++ b/src/components/blog/SeriesNav/index.ts @@ -0,0 +1,2 @@ +export { SeriesNav } from './SeriesNav'; +export type { SeriesNavProps, SeriesNavItem } from './SeriesNav.types'; diff --git a/src/components/blog/SeriesPager/SeriesPager.tsx b/src/components/blog/SeriesPager/SeriesPager.tsx new file mode 100644 index 0000000..043afb8 --- /dev/null +++ b/src/components/blog/SeriesPager/SeriesPager.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; +import { ROUTES } from '@/lib/constants'; +import { cn } from '@/lib/utils'; +import type { SeriesPagerItem, SeriesPagerProps } from './SeriesPager.types'; + +const partLabel = (item: SeriesPagerItem) => + item.seriesOrder ? `Part ${item.seriesOrder}` : 'In this series'; + +/** + * SeriesPager Component + * Previous / Next navigation between parts of a series, + * shown at the end of a series post so the reader can keep going. + */ +export function SeriesPager({ previous, next, className }: SeriesPagerProps) { + if (!previous && !next) { + return null; + } + + return ( + + ); +} diff --git a/src/components/blog/SeriesPager/SeriesPager.types.ts b/src/components/blog/SeriesPager/SeriesPager.types.ts new file mode 100644 index 0000000..2acd9d2 --- /dev/null +++ b/src/components/blog/SeriesPager/SeriesPager.types.ts @@ -0,0 +1,13 @@ +export interface SeriesPagerItem { + slug: string; + title: string; + seriesOrder?: number; +} + +export interface SeriesPagerProps { + /** Previous part in the series, if any */ + previous?: SeriesPagerItem | null; + /** Next part in the series, if any */ + next?: SeriesPagerItem | null; + className?: string; +} diff --git a/src/components/blog/SeriesPager/index.ts b/src/components/blog/SeriesPager/index.ts new file mode 100644 index 0000000..3f0a7a0 --- /dev/null +++ b/src/components/blog/SeriesPager/index.ts @@ -0,0 +1,2 @@ +export { SeriesPager } from './SeriesPager'; +export type { SeriesPagerProps, SeriesPagerItem } from './SeriesPager.types'; diff --git a/src/components/blog/index.ts b/src/components/blog/index.ts index 14fbd0c..eceeb9f 100644 --- a/src/components/blog/index.ts +++ b/src/components/blog/index.ts @@ -1 +1,3 @@ export { ReadingProgressBar } from './ReadingProgressBar'; +export { SeriesNav } from './SeriesNav'; +export { SeriesPager } from './SeriesPager'; diff --git a/src/components/sections/ExperimentsGrid/ExperimentsGrid.tsx b/src/components/sections/ExperimentsGrid/ExperimentsGrid.tsx index ab1064c..958052a 100644 --- a/src/components/sections/ExperimentsGrid/ExperimentsGrid.tsx +++ b/src/components/sections/ExperimentsGrid/ExperimentsGrid.tsx @@ -1,11 +1,11 @@ 'use client'; import { useState, useMemo } from 'react'; -import { ArrowRight } from 'lucide-react'; +import { ArrowRight, Layers } from 'lucide-react'; import { Card, Button, Badge } from '@/components/ui'; import { FilterBar } from '@/components/sections'; import { ROUTES } from '@/lib/constants'; -import { formatDate } from '@/lib/utils'; +import { formatDate, groupPostsIntoSeries } from '@/lib/utils'; import type { Post } from '@/lib/types'; import type { FilterState } from '../FilterBar/FilterBar.types'; @@ -15,7 +15,9 @@ export interface ExperimentsGridProps { /** * ExperimentsGrid Component - * Client-side grid with filtering for blog posts (experiments) + * Client-side grid with filtering for blog posts (experiments). + * Multi-part series are grouped: a full-width summary header spans the row, + * followed by that series' part cards, then the remaining standalone posts. */ export function ExperimentsGrid({ posts }: ExperimentsGridProps) { const [filters, setFilters] = useState({ @@ -27,26 +29,26 @@ export function ExperimentsGrid({ posts }: ExperimentsGridProps) { // Extract unique categories and tags const categories = useMemo(() => { const cats = new Set(); - posts.forEach(post => cats.add(post.category)); + posts.forEach((post) => cats.add(post.category)); return Array.from(cats).sort(); }, [posts]); const tags = useMemo(() => { const allTags = new Set(); - posts.forEach(post => post.tags.forEach(tag => allTags.add(tag))); + posts.forEach((post) => post.tags.forEach((tag) => allTags.add(tag))); return Array.from(allTags).sort(); }, [posts]); // Filter posts based on active filters const filteredPosts = useMemo(() => { - return posts.filter(post => { + return posts.filter((post) => { // Search filter if (filters.search) { const searchLower = filters.search.toLowerCase(); const matchesSearch = post.title.toLowerCase().includes(searchLower) || post.description.toLowerCase().includes(searchLower) || - post.tags.some(tag => tag.toLowerCase().includes(searchLower)); + post.tags.some((tag) => tag.toLowerCase().includes(searchLower)); if (!matchesSearch) return false; } @@ -57,7 +59,7 @@ export function ExperimentsGrid({ posts }: ExperimentsGridProps) { // Tags filter if (filters.tags.length > 0) { - const hasMatchingTag = filters.tags.some(tag => post.tags.includes(tag)); + const hasMatchingTag = filters.tags.some((tag) => post.tags.includes(tag)); if (!hasMatchingTag) return false; } @@ -65,6 +67,61 @@ export function ExperimentsGrid({ posts }: ExperimentsGridProps) { }); }, [posts, filters]); + // Split the visible posts into series groups (rendered first, each behind a + // full-width header) and the remaining standalone posts. + const seriesGroups = useMemo( + () => groupPostsIntoSeries(filteredPosts).filter((s) => s.parts.length > 1), + [filteredPosts] + ); + const nonSeriesPosts = useMemo( + () => filteredPosts.filter((post) => !post.series), + [filteredPosts] + ); + + const renderCard = (post: Post) => ( + + +
+ {post.category} + {post.featured && Featured} + {post.series && post.seriesOrder && ( + + + Part {post.seriesOrder} + + )} +
+

+ {post.title} +

+

+ {formatDate(post.publishedAt)} · {post.readTime} +

+
+ +

{post.description}

+
+ {post.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+
+ + + +
+ ); + return ( <> {/* Filter Bar */} @@ -78,50 +135,24 @@ export function ExperimentsGrid({ posts }: ExperimentsGridProps) { {/* Posts Grid */} {filteredPosts.length > 0 ? (
- {filteredPosts.map((post) => ( - - -
- {post.category} - {post.featured && ( - Featured - )} -
-

- {post.title} -

-

- {formatDate(post.publishedAt)} · {post.readTime} -

-
- -

{post.description}

-
- {post.tags.slice(0, 3).map((tag) => ( - - {tag} - - ))} -
-
- - - -
+ {/* Series groups: full-width summary header, then the part cards */} + {seriesGroups.map(({ name, slug, parts, totalMinutes }) => ( +
+
+ + + {name} + + + {parts.length} parts · ~{totalMinutes} min + +
+ {parts.map(renderCard)} +
))} + + {/* Standalone posts */} + {nonSeriesPosts.map(renderCard)}
) : (
diff --git a/src/components/sections/FilterBar/FilterBar.tsx b/src/components/sections/FilterBar/FilterBar.tsx index 78a0593..3c20613 100644 --- a/src/components/sections/FilterBar/FilterBar.tsx +++ b/src/components/sections/FilterBar/FilterBar.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useRef, useEffect } from 'react'; -import { Search, X } from 'lucide-react'; +import { Search, X, ChevronDown, ChevronUp } from 'lucide-react'; import gsap from 'gsap'; import { Badge } from '@/components/ui/primitives'; import { cn, prefersReducedMotion } from '@/lib/utils'; @@ -21,6 +21,16 @@ export function FilterBar({ const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState(null); const [selectedTags, setSelectedTags] = useState([]); + const [tagsExpanded, setTagsExpanded] = useState(false); + + // Number of tags shown before the list collapses. When collapsed, any + // selected tag beyond the limit stays visible so it can be deselected. + const TAG_LIMIT = 10; + const visibleTags = tagsExpanded + ? tags + : Array.from( + new Set([...tags.slice(0, TAG_LIMIT), ...selectedTags.filter((t) => tags.includes(t))]) + ); const searchRef = useRef(null); const categoriesRef = useRef(null); @@ -127,8 +137,8 @@ export function FilterBar({ />
- {/* Category Filters */} - {categories.length > 0 && ( + {/* Category Filters — only meaningful with more than one category */} + {categories.length > 1 && (

Categories

@@ -161,8 +171,8 @@ export function FilterBar({ {tags.length > 0 && (

Tags

-
- {tags.map((tag) => ( +
+ {visibleTags.map((tag) => ( ))} + + {tags.length > TAG_LIMIT && ( + + )}
)} diff --git a/src/content/blog/langchain-createagent-middleware.mdx b/src/content/blog/langchain-createagent-middleware.mdx new file mode 100644 index 0000000..93be223 --- /dev/null +++ b/src/content/blog/langchain-createagent-middleware.mdx @@ -0,0 +1,224 @@ +--- +title: 'createAgent + Middleware: How LangChain 1.0 Killed Chain Spaghetti' +description: 'Build a real support-triage agent with LangChain 1.0, then layer middleware — summarization, human-in-the-loop, PII redaction, and a custom guardrail — to get production control over the agent loop. Part 2 of a hands-on TypeScript series.' +publishedAt: '2026-06-10' +category: 'ai' +tags: ['langchain', 'ai-agents', 'middleware', 'typescript', 'langchain-series'] +featured: true +type: 'experiment' +author: 'Esteban Estrada' +thumbnail: '/images/blog/langchain-createagent-middleware.png' +series: 'Building AI Products with LangChain' +seriesOrder: 2 +--- + +In [Part 1](/experiments/langchain-ecosystem-map-2026) we drew the map: LangGraph is the runtime, LangChain is the agent on top of it, LangSmith watches, deepagents is the heavy harness. Now we stop drawing and start building. + +The thing people remember about old LangChain is the **chain spaghetti** — `LCEL`, `RunnablePassthrough`, `RunnableMap`, pipes inside pipes, a `|` operator gluing together six abstractions to do "call the model, then maybe call a tool." It worked until you needed to do something _between_ the steps. Then you were monkey-patching the chain. + +LangChain 1.0 replaced all of that with two ideas: **`createAgent`** for the loop, and **middleware** for everything you used to hack into the loop. This post builds a support-triage agent and uses it to show why that swap matters. + +## The agent in 12 lines + +`createAgent` gives you a production-ready ReAct agent — reason, pick a tool, act, repeat — running on the LangGraph runtime under the hood. + +```typescript +import { createAgent } from 'langchain'; +import { ChatAnthropic } from '@langchain/anthropic'; +import { tool } from '@langchain/core/tools'; +import { z } from 'zod'; + +const lookupOrder = tool( + async ({ orderId }) => { + const order = await db.orders.find(orderId); + return order ? JSON.stringify(order) : `No order ${orderId}`; + }, + { + name: 'lookup_order', + description: 'Look up an order by ID', + schema: z.object({ orderId: z.string() }), + } +); + +const agent = createAgent({ + model: new ChatAnthropic({ model: 'claude-sonnet-4-6' }), + tools: [lookupOrder], + prompt: 'You are a support agent. Be concise. Use tools before answering.', +}); + +const res = await agent.invoke({ + messages: [{ role: 'user', content: "Where's order A-4471?" }], +}); +console.log(res.messages.at(-1)?.content); +``` + +That's a working agent. No chain, no pipes. The interesting part is everything you'd want to bolt _onto_ this loop in production — and that's middleware. + +## The problem middleware solves + +Real support agents need things that aren't "the model" and aren't "a tool": + +- Conversations get long → context blows past the window. +- A `refund` tool exists → you do **not** want it firing without a human nod. +- Users paste emails and card numbers → that shouldn't hit your logs or the model provider. +- You want every tool call logged and a hard ceiling on model calls so a loop can't bankrupt you. + +In old LangChain each of these meant restructuring the chain. In 1.0 they're entries in a `middleware: []` array. + + + +## Layering prebuilt middleware + +LangChain ships production-ready middleware. Four cover most of what a support agent needs: + +```typescript +import { createAgent } from 'langchain'; +import { + summarizationMiddleware, + humanInTheLoopMiddleware, + piiMiddleware, + modelCallLimitMiddleware, +} from 'langchain'; +import { ChatAnthropic } from '@langchain/anthropic'; + +const agent = createAgent({ + model: new ChatAnthropic({ model: 'claude-sonnet-4-6' }), + tools: [lookupOrder, issueRefund], + prompt: 'You are a support agent. Be concise.', + middleware: [ + // 1. Keep long chats inside the context window + summarizationMiddleware({ + model: new ChatAnthropic({ model: 'claude-haiku-4-5' }), + maxTokensBeforeSummary: 4000, + }), + + // 2. Strip PII before it reaches the model or your traces + piiMiddleware({ patterns: ['email', 'credit_card'] }), + + // 3. Pause for a human before any refund actually fires + humanInTheLoopMiddleware({ + interruptOn: { issue_refund: true }, + }), + + // 4. Hard ceiling — a runaway loop can't make 200 model calls + modelCallLimitMiddleware({ maxCalls: 8 }), + ], +}); +``` + +Each of these is a one-liner that used to be a project. A few worth calling out: + +- **`summarizationMiddleware`** runs on the way _in_ to the model. Once messages cross the token threshold it summarizes the old ones with a cheap model (note: Haiku here, not Sonnet — you don't pay Sonnet rates to compress history) and keeps recent AI/Tool message pairs intact. +- **`humanInTheLoopMiddleware`** runs _after_ the model proposes a tool call. If the model wants to call `issue_refund`, execution **pauses** instead of firing. +- **`piiMiddleware`** redacts matched patterns so they never reach the provider or LangSmith traces. +- **`modelCallLimitMiddleware`** is the seatbelt — a cyclic agent that misbehaves stops at 8 calls instead of looping forever. + +Other prebuilt ones you'll meet later in the series: `toolCallLimitMiddleware`, `modelFallbackMiddleware`, `toolRetryMiddleware`, `contextEditingMiddleware`, and the Deep Agents pair `createFilesystemMiddleware` / `createSubAgentMiddleware`. + +## Human-in-the-loop, concretely + +When the agent proposes a refund, `invoke` returns with an `__interrupt__` instead of a final answer. You surface that to a human, then resume with their decision. + +```typescript +import { Command } from '@langchain/langgraph'; + +const res = await agent.invoke( + { messages: [{ role: 'user', content: 'Refund order A-4471, it arrived broken.' }] }, + { configurable: { thread_id: 'ticket-A-4471' } } +); + +if (res.__interrupt__) { + // Agent paused before issue_refund — show the proposed call to a human + const decision = await askHuman(res.__interrupt__); + + // Resume the SAME thread with approve / edit / reject + const final = await agent.invoke( + new Command({ resume: { type: 'approve' } }), // or 'edit' / 'reject' + { configurable: { thread_id: 'ticket-A-4471' } } + ); +} +``` + + + Human-in-the-loop needs a `thread_id` and a checkpointer — that's the LangGraph runtime persisting + state so the conversation can survive the pause and resume exactly where it stopped. This is the + first place the runtime from Part 1 pokes through. We go deep on checkpointing in Part 3. + + +## Order matters: the middleware sandwich + +Multiple middleware aren't independent — they nest. On the way **in** to the model they run top-to-bottom; on the way **back out** they run bottom-to-top. It's a sandwich, not a queue. + +>> MODEL CALL <<<' }, + { type: 'comment', content: 'OUT ← back from the model (bottom to top)' }, + { type: 'text', content: ' humanInTheLoop.afterModel (intercept tool call)' }, + ]} +/> + +Practical consequence: put **PII redaction before** anything that logs or calls out, and put **limits early** so you bail before doing expensive work. The array order _is_ the policy. + +## When prebuilt isn't enough: createMiddleware + +Eventually you need your own logic in the loop — a domain guardrail, custom logging, a tenant tag. That's `createMiddleware`. It takes a `name`, optional `stateSchema` / `tools`, and hooks: + +- **`beforeModel`** — before each model call (inspect/modify state, or short-circuit) +- **`afterModel`** — after each model response +- **`wrapModelCall`** — wrap the call itself (retries, fallbacks, swap the model/prompt/tools per call) +- **`beforeAgent` / `afterAgent`** — once per invocation, at the edges + +A guardrail that blocks refunds over a threshold, plus lightweight tracing: + +```typescript +import { createMiddleware } from 'langchain'; +import { z } from 'zod'; + +const refundGuardrail = createMiddleware({ + name: 'RefundGuardrail', + stateSchema: z.object({ refundCeiling: z.number().default(100) }), + + afterModel: (state) => { + const last = state.messages.at(-1); + const calls = last?.tool_calls ?? []; + + for (const call of calls) { + if (call.name === 'issue_refund' && call.args.amount > state.refundCeiling) { + // Cancel the tool call and force the model to escalate instead + return { + messages: [ + { + role: 'tool', + tool_call_id: call.id, + content: `Refund $${call.args.amount} exceeds the $${state.refundCeiling} auto-limit. Escalate to a manager.`, + }, + ], + }; + } + } + }, +}); +``` + +Drop `refundGuardrail` into the same `middleware: []` array and it composes with the prebuilt ones. No chain rewrite — that's the whole pitch. Your business rules live _next to_ the model call instead of tangled _through_ it. + +## What you've got now + +A support agent that compresses long chats, redacts PII, pauses for human approval on refunds, enforces a domain ceiling, and can't run away. Every one of those is a line in an array, and the core agent is still ~12 lines. + +But notice what we kept hand-waving: the `thread_id`, the checkpointer, "the runtime persists state so it can resume." That's not LangChain — that's **LangGraph** underneath, and it's where the real power of stateful, resumable, branching agents lives. + + + Next up — **Part 3: LangGraph and stateful orchestration.** We drop below `createAgent` into + `StateGraph`: nodes, cycles, checkpointing, and durable resume. The loop stops being a black box. + diff --git a/src/content/blog/langchain-ecosystem-map-2026.mdx b/src/content/blog/langchain-ecosystem-map-2026.mdx new file mode 100644 index 0000000..bc26cc3 --- /dev/null +++ b/src/content/blog/langchain-ecosystem-map-2026.mdx @@ -0,0 +1,172 @@ +--- +title: 'The 2026 LangChain Map: LangChain vs LangGraph vs LangSmith vs deepagents' +description: 'A field map of the LangChain ecosystem after the 1.0 reset — what each piece is for, when to reach for it, and how the parts fit into a real AI product. Part 1 of a hands-on TypeScript series.' +publishedAt: '2026-06-03' +category: 'ai' +tags: ['langchain', 'langgraph', 'langsmith', 'ai-agents', 'typescript', 'langchain-series'] +featured: true +type: 'experiment' +author: 'Esteban Estrada' +thumbnail: '/images/blog/langchain-ecosystem-map-2026.png' +series: 'Building AI Products with LangChain' +seriesOrder: 1 +--- + +Every six months someone declares LangChain dead. Every six months it ships another major release and posts another download record. As of 2026 it sits around **90M monthly downloads**, in production at Uber, JP Morgan, Blackrock, and Cisco. + +So the honest question isn't "is LangChain still relevant." It's: **the ecosystem fragmented into four named products — which one am I actually supposed to use, and when?** + +This series answers that by building. Every example is TypeScript (`langchain.js` / `langgraph.js` / `deepagentsjs`), because that's the stack this blog runs on and the one most product teams ship on. This first post is the map. The rest of the series is the territory. + +## Why the confusion exists + +LangChain in 2022 was a grab-bag: chains, agents, memory, retrievers, 600+ integrations, all in one import. It got a reputation for too many abstractions wrapping one `fetch` call. + +Then **October 2025 happened**: LangChain 1.0 and LangGraph 1.0 shipped on the same day. This was not a version bump — it was a re-architecture. The old chain spaghetti got deprecated, and the framework was rebuilt around a single idea: **an agent is a loop over a state machine.** Everything else is layers on top of that loop. + +Once you see the layering, the four products stop competing in your head and snap into a stack. + +## The four pieces + + + +### LangChain — the fast path to an agent + +The headline API is now `createAgent`. Give it a model and some tools, get back a working agent. Under the hood it runs on the LangGraph runtime — so the "easy" path and the "powerful" path are the same engine. + +```typescript +import { createAgent } from 'langchain'; +import { ChatAnthropic } from '@langchain/anthropic'; +import { tool } from '@langchain/core/tools'; +import { z } from 'zod'; + +const getWeather = tool(async ({ city }) => `It's 22°C and clear in ${city}.`, { + name: 'get_weather', + description: 'Get current weather for a city', + schema: z.object({ city: z.string() }), +}); + +const agent = createAgent({ + model: new ChatAnthropic({ model: 'claude-sonnet-4-6' }), + tools: [getWeather], +}); + +const result = await agent.invoke({ + messages: [{ role: 'user', content: 'Weather in Bogotá?' }], +}); +``` + +The big 1.0 additions worth knowing now: + +- **Middleware** — fine-grained hooks at every step of the agent loop. Human-in-the-loop, summarization, and PII redaction ship built in. This is what replaced the old chain-composition mess. +- **Structured output in the loop** — schema-constrained output is part of the main agent loop now, not a second LLM call. Lower latency, lower cost. +- **600+ integrations** — models, vector stores, tools. This is still the real moat. You rarely write a provider client by hand. + +**Reach for LangChain when** the work is mostly linear: RAG pipelines, single-turn Q&A, "call a model with these tools," rapid prototyping. + +### LangGraph — the runtime when the loop gets weird + +LangGraph is the low-level engine: a `StateGraph` with nodes, edges, and — critically — **cycles**. Plus checkpointing, durable state, streaming, and time-travel debugging. + +LangChain agents _run on_ LangGraph internally. So you don't choose between them; you drop down to LangGraph when the default agent loop is the wrong shape. + + + Rule of thumb: if your agent needs to loop, branch, retry on failure, pause for human approval, or + resume a session days later — that's a LangGraph job, not a plain chain. + + +```typescript +import { StateGraph, START, END } from '@langchain/langgraph'; + +const graph = new StateGraph(StateAnnotation) + .addNode('plan', planNode) + .addNode('act', actNode) + .addNode('review', reviewNode) + .addEdge(START, 'plan') + .addEdge('plan', 'act') + // cycle: review can send us back to act until the work passes + .addConditionalEdges('review', (s) => (s.approved ? END : 'act')) + .compile({ checkpointer }); +``` + +That conditional edge looping back to `act` is the whole point. Chains can't do it; graphs can. 2026 is, in LangChain's own framing, "the year of stateful orchestration" — systems that reason across stages, recover from failure, and persist across sessions. + +### deepagents — the batteries-included harness + +`deepagents` (and `deepagentsjs` for TypeScript) is the newest layer. It's an opinionated harness for "deep" agents — the Claude-Code / Deep-Research shape: **plan before acting, spawn sub-agents, read/write a virtual filesystem, manage long context.** + +```typescript +import { createDeepAgent } from 'deepagents'; + +const agent = createDeepAgent({ + tools: [searchTool, ...otherTools], + instructions: 'You are a research agent. Plan, then execute, then verify.', +}); +// returns a compiled LangGraph graph — streaming, checkpointers, Studio all work +``` + +`createDeepAgent` returns a compiled LangGraph graph, so everything below it (streaming, checkpointers, LangGraph Studio) still applies. It just hands you the planning + delegation + filesystem scaffolding instead of making you wire it. + +The three-tier decision, top to bottom: + +| You want… | Use | Why | +| ------------------------------------------------ | --------------- | --------------------------------------- | +| Full harness: planning, sub-agents, context mgmt | `deepagents` | Batteries included, opinionated prompts | +| A light agent loop with your own tools | `createAgent` | Minimal harness, you stay in control | +| A custom loop the default shape can't express | raw `LangGraph` | Hand-built nodes, edges, and cycles | + +### LangSmith — the part that makes it shippable + +The first three build the agent. **LangSmith is how you find out why it's misbehaving in production.** It traces every LLM call, tool invocation, and reasoning step, so you debug from data instead of guessing from logs. + +In 2026 it carries the observability _and_ the eval surface: + +- Traces with dashboards: token usage, latency (P50/P99), error rates, cost breakdowns, feedback scores. +- **30+ eval templates** for safety, response quality, trajectory, and multimodal — usable for both offline experiments and online monitoring. +- Capture production traces, **replay them against a new model version** to catch regressions before you deploy. Your prod behavior literally becomes your eval dataset. + +One naming gotcha that trips people up: **"LangGraph Platform" was renamed to "LangSmith Deployment" in October 2025.** Same managed runtime for shipping agents — Cloud, self-hosted, or standalone server. The CLI moved too: `langgraph deploy` superseded `langgraph up` for cloud deploys in March 2026. + +## How it fits a real product + +A production AI feature in 2026 usually touches all four: + + + +You don't adopt them in order of fame. You adopt them in order of _pain_: start with LangChain to get something working, drop to LangGraph the first time you need a loop or a pause, add LangSmith the first time something breaks in prod and you can't tell why, and pull in deepagents when the task is genuinely "research-grade" multi-step work. + +## What this series will build + +This was the map. Each following post is hands-on TypeScript, building one real thing: + +1. **The 2026 LangChain map** — _you are here._ +2. **`createAgent` + middleware** — a first real agent, and why middleware killed chain spaghetti. +3. **LangGraph: stateful orchestration** — `StateGraph`, cycles, checkpointing, human-in-the-loop, durable resume. + +Three posts that take you from "what even is this ecosystem" to building real, stateful, resumable agents — enough to ship. (LangSmith observability/evals and deepagents are where you go next; Part 3 points the way.) + + + Next up — **Part 2: `createAgent` + middleware.** We stop drawing maps and start writing the agent + loop. + diff --git a/src/content/blog/langgraph-stateful-orchestration.mdx b/src/content/blog/langgraph-stateful-orchestration.mdx new file mode 100644 index 0000000..dac0536 --- /dev/null +++ b/src/content/blog/langgraph-stateful-orchestration.mdx @@ -0,0 +1,190 @@ +--- +title: 'LangGraph: When the Agent Loop Becomes a State Machine' +description: 'Drop below createAgent into raw LangGraph — StateGraph, cycles, checkpointing, and durable human-in-the-loop resume. We rebuild the support agent as an explicit graph and watch the black box open up. Part 3 (finale) of a hands-on TypeScript series.' +publishedAt: '2026-06-17' +category: 'ai' +tags: ['langgraph', 'ai-agents', 'state-machines', 'typescript', 'langchain-series'] +featured: true +type: 'experiment' +author: 'Esteban Estrada' +thumbnail: '/images/blog/langgraph-stateful-orchestration.png' +series: 'Building AI Products with LangChain' +seriesOrder: 3 +--- + +[Part 1](/experiments/langchain-ecosystem-map-2026) mapped the ecosystem. [Part 2](/experiments/langchain-createagent-middleware) built a support agent with `createAgent` and middleware — and kept hand-waving at a `thread_id`, a "checkpointer," and a runtime that "persists state so it can resume." + +That runtime is **LangGraph**, and this post opens the box. We rebuild the same support flow as an explicit `StateGraph` so you can see the thing `createAgent` was generating for you all along — and, more importantly, so you can build the loops `createAgent` _can't_ express. + +## Why drop down at all + +`createAgent` is one fixed shape: a ReAct loop. Model → tools → model → tools → answer. That covers a huge amount of ground. You drop to LangGraph the moment your control flow stops being that shape: + + + +The three things LangGraph gives you that a plain agent doesn't: + +- **Cycles** — a node can route back to an earlier node. Real loops, not just tool-call repetition. +- **Explicit branching** — `addConditionalEdges` routes on _state_, deterministically, in code you can test. +- **Durable state** — checkpointing means the graph can pause mid-run and resume hours later on a different request. + +## State is the whole game + +A graph is just nodes that read and write a shared **state** object. You declare its shape with `Annotation.Root`, and `MessagesAnnotation` gives you the standard chat-history field (with the right append reducer) for free. + +```typescript +import { Annotation, MessagesAnnotation } from '@langchain/langgraph'; + +const TriageState = Annotation.Root({ + ...MessagesAnnotation.spec, // messages: BaseMessage[] with append reducer + category: Annotation<'billing' | 'technical' | 'other'>, + attempts: Annotation({ + reducer: (_prev, next) => next, + default: () => 0, + }), + resolved: Annotation({ reducer: (_p, n) => n, default: () => false }), +}); +``` + + + LangGraph.js 1.0 also ships a Zod-based `StateSchema` API (`MessagesValue`, `ReducedValue`) if you + prefer Zod over `Annotation`. They're equivalent — `MessagesZodState` is just the Zod twin of + `MessagesAnnotation`. Pick one; this post uses `Annotation` because it's the most widely + documented. + + +The `reducer` is the key idea: when a node returns `{ attempts: 3 }`, the reducer decides how that merges into existing state. `messages` uses an append reducer (new messages add to the list); `attempts` just overwrites. State updates are **merges, not replacements** — that's what makes concurrent and resumed runs behave. + +## Building the graph + +Nodes are plain functions: `(state) => partialStateUpdate`. Edges wire them. Here's the support flow as a real cycle — triage the ticket, act on it, review the result, and **loop back to act** if it's not resolved yet. + +```typescript +import { StateGraph, START, END } from '@langchain/langgraph'; +import { ChatAnthropic } from '@langchain/anthropic'; + +const model = new ChatAnthropic({ model: 'claude-sonnet-4-6' }); + +const triage = async (state: typeof TriageState.State) => { + const res = await model.invoke([ + { role: 'system', content: 'Classify: billing | technical | other. One word.' }, + ...state.messages, + ]); + return { category: res.content.toString().trim() as typeof state.category }; +}; + +const act = async (state: typeof TriageState.State) => { + const res = await model.invoke([ + { role: 'system', content: `You handle ${state.category} tickets. Resolve it.` }, + ...state.messages, + ]); + return { messages: [res], attempts: state.attempts + 1 }; +}; + +const review = async (state: typeof TriageState.State) => { + const res = await model.invoke([ + { role: 'system', content: 'Did the last reply fully resolve the ticket? Answer yes or no.' }, + ...state.messages, + ]); + return { resolved: res.content.toString().toLowerCase().includes('yes') }; +}; + +const graph = new StateGraph(TriageState) + .addNode('triage', triage) + .addNode('act', act) + .addNode('review', review) + .addEdge(START, 'triage') + .addEdge('triage', 'act') + .addEdge('act', 'review') + // the cycle: unresolved → back to act, but cap attempts so it can't spin forever + .addConditionalEdges('review', (state) => (state.resolved || state.attempts >= 3 ? END : 'act')); +``` + +That conditional edge is the payoff. `review` routes back to `act` until the ticket is resolved or we hit the attempt cap. You **cannot** express that with `createAgent` — its loop only repeats tool calls, not arbitrary nodes. Here the loop is a literal edge in a graph you can draw, test, and reason about. + +## Checkpointing: state that survives + +Compile with a checkpointer and the graph persists its state after every step, keyed by `thread_id`. Same thread → conversation continues. New thread → fresh state. + +```typescript +import { MemorySaver } from '@langchain/langgraph'; + +const app = graph.compile({ checkpointer: new MemorySaver() }); + +const config = { configurable: { thread_id: 'ticket-A-4471' } }; +await app.invoke( + { messages: [{ role: 'user', content: 'Double charged on my last invoice.' }] }, + config +); + +// Days later, SAME thread — full history and state are restored automatically +await app.invoke({ messages: [{ role: 'user', content: 'Any update?' }] }, config); +``` + + + `MemorySaver` is in-process — great for development, gone on restart. In production you swap it + for a durable checkpointer (Postgres / Redis), which is exactly what **LangSmith Deployment** + provisions for you. The graph code doesn't change; only the checkpointer does. + + +This is the same `thread_id` + checkpointer that Part 2's human-in-the-loop relied on. Now you can see _why_ it worked: HITL is just a graph that pauses and persists. + +## Human-in-the-loop, from the inside + +In Part 2, `humanInTheLoopMiddleware` handed us an `__interrupt__`. Underneath, it calls LangGraph's `interrupt()` — pause the graph, persist everything, surface a payload, and wait. You resume with a `Command`. + +```typescript +import { interrupt, Command } from '@langchain/langgraph'; + +const refundReview = async (state: typeof TriageState.State) => { + // pauses here; the value comes from the human on resume + const decision = interrupt({ + question: 'Approve this refund?', + proposed: state.messages.at(-1)?.content, + }); + + if (decision === 'reject') { + return { messages: [{ role: 'assistant', content: 'Refund declined — escalating.' }] }; + } + return { resolved: true }; +}; + +// First invoke runs until interrupt(), then returns paused +await app.invoke({ messages: [...] }, config); + +// Human decides → resume the SAME thread, exactly where it stopped +await app.invoke(new Command({ resume: 'approve' }), config); +``` + +No queue, no polling loop, no re-running from the top. The checkpointer froze the graph mid-execution; the `Command` thaws it. That's "stateful orchestration" in one mechanism — and it's why 2026's agents can pause for a human and pick up days later without losing their place. + +## The series in one mental model + +Three posts, one stack, from the top down: + +| Layer | What it owns | Reach for it when | +| --------------------------- | ------------------------------------------ | ------------------------------------------------------ | +| `createAgent` + middleware | The ReAct loop, plus cross-cutting control | You want an agent fast and the loop is the right shape | +| `LangGraph` | State, nodes, cycles, durable resume | The flow branches, loops, or must survive a pause | +| `Annotation` / checkpointer | The state contract and its persistence | Always — it's the substrate the other two stand on | + +If you internalize one thing: **`createAgent` is a LangGraph graph with the loop pre-wired.** Everything in Part 2 was this, generated for you. Now you can write it by hand when the generated shape doesn't fit. + +## Where to go from here + +This is the finale of the series for now — three posts that take you from "what even is this ecosystem" to building real, stateful, resumable agents in TypeScript. The two layers we deliberately left for later: + +- **LangSmith** — trace every node and model call, score outputs against eval templates, and replay production traces against new models before you ship. The moment one of these graphs misbehaves in prod, this is where you'll live. +- **deepagents** — when the task is genuinely "research-grade" (planning, sub-agents, a virtual filesystem), `createDeepAgent` hands you that harness — and it returns a compiled LangGraph graph, so everything in this post still applies underneath it. +- **LangSmith Deployment** — `langgraph deploy` to take the graph to production with a real checkpointer, then watch it with the tracing above. + + + You now have the spine of the whole ecosystem: an agent loop you can build fast, a state machine + you can drop to when the loop won't bend, and a persistence layer that makes both resumable. + That's enough to ship a real AI product — the rest is observability and scale. + diff --git a/src/content/blog/mcpx-cli-over-mcp.mdx b/src/content/blog/mcpx-cli-over-mcp.mdx index f5a9503..fea90e2 100644 --- a/src/content/blog/mcpx-cli-over-mcp.mdx +++ b/src/content/blog/mcpx-cli-over-mcp.mdx @@ -4,7 +4,7 @@ description: 'How MCPX transforms MCP servers into zero-overhead CLI tools, and publishedAt: '2026-03-09' category: 'ai' tags: ['mcpx', 'mcp', 'cli', 'token-efficiency', 'claude-code', 'ai-agents'] -featured: true +featured: false type: 'experiment' author: 'Esteban Estrada' --- diff --git a/src/content/blog/mcpx-mcp-gateway.mdx b/src/content/blog/mcpx-mcp-gateway.mdx index 076585f..31159a4 100644 --- a/src/content/blog/mcpx-mcp-gateway.mdx +++ b/src/content/blog/mcpx-mcp-gateway.mdx @@ -4,7 +4,7 @@ description: 'How a single Go binary turned MCP from a context-burning liability publishedAt: '2026-03-30' category: 'ai' tags: ['mcp', 'mcpx', 'cli', 'ai-tooling', 'golang', 'developer-tools'] -featured: true +featured: false type: 'experiment' author: 'Esteban Estrada' thumbnail: '/images/blog/mcpx-mcp-gateway.png' diff --git a/src/content/blog/stop-vibe-coding-start-vibe-engineering.mdx b/src/content/blog/stop-vibe-coding-start-vibe-engineering.mdx index 9ce2d75..ee5d8f5 100644 --- a/src/content/blog/stop-vibe-coding-start-vibe-engineering.mdx +++ b/src/content/blog/stop-vibe-coding-start-vibe-engineering.mdx @@ -12,7 +12,7 @@ tags: 'software-engineering', 'agentic-development', ] -featured: true +featured: false type: 'experience' author: 'Esteban Estrada' thumbnail: '/images/blog/stop-vibe-coding-start-vibe-engineering.png' diff --git a/src/content/blog/why-ai-defaults-to-typescript.mdx b/src/content/blog/why-ai-defaults-to-typescript.mdx index 29a2765..831e369 100644 --- a/src/content/blog/why-ai-defaults-to-typescript.mdx +++ b/src/content/blog/why-ai-defaults-to-typescript.mdx @@ -4,7 +4,7 @@ description: "AI coding tools are converging on two outputs -- React + TypeScrip publishedAt: '2026-03-23' category: 'ai' tags: ['ai-convergence', 'typescript', 'ai-development', 'llm', 'developer-tools', 'web-dev'] -featured: true +featured: false type: 'experiment' author: 'Esteban Estrada' thumbnail: '/images/blog/ai-convergence.png' diff --git a/src/lib/repositories/content.repository.interface.ts b/src/lib/repositories/content.repository.interface.ts index e20a371..b5877c2 100644 --- a/src/lib/repositories/content.repository.interface.ts +++ b/src/lib/repositories/content.repository.interface.ts @@ -10,6 +10,7 @@ export interface IPostRepository { findAll(): Promise>; findBySlug(slug: string): Promise>; findByCategory(category: string): Promise>; + findBySeries(series: string): Promise>; findFeatured(limit?: number): Promise>; } diff --git a/src/lib/repositories/post.repository.ts b/src/lib/repositories/post.repository.ts index 61f5011..1635e10 100644 --- a/src/lib/repositories/post.repository.ts +++ b/src/lib/repositories/post.repository.ts @@ -60,11 +60,28 @@ export class PostRepository extends BaseRepository implements IPostReposit readTime: calculateReadingTime(content), content, type: frontmatter.type || 'experiment', + series: frontmatter.series as string | undefined, + seriesOrder: + typeof frontmatter.seriesOrder === 'number' ? frontmatter.seriesOrder : undefined, }; return { success: true, data: post }; } + async findBySeries(series: string): Promise> { + const allPostsResult = await this.findAll(); + + if (!allPostsResult.success) { + return allPostsResult; + } + + const filtered = allPostsResult.data + .filter((post: Post) => post.series === series) + .sort((a: Post, b: Post) => (a.seriesOrder ?? 0) - (b.seriesOrder ?? 0)); + + return { success: true, data: filtered }; + } + async findByCategory(category: string): Promise> { const allPostsResult = await this.findAll(); diff --git a/src/lib/services/content.service.interface.ts b/src/lib/services/content.service.interface.ts index 4c30440..c604d0f 100644 --- a/src/lib/services/content.service.interface.ts +++ b/src/lib/services/content.service.interface.ts @@ -12,6 +12,7 @@ export interface IContentService { getPostBySlug(slug: string): Promise>; getFeaturedPosts(limit?: number): Promise>; getPostsByCategory(category: string): Promise>; + getPostsBySeries(series: string): Promise>; // Project operations getAllProjects(): Promise>; diff --git a/src/lib/services/content.service.ts b/src/lib/services/content.service.ts index 026563c..2671b3e 100644 --- a/src/lib/services/content.service.ts +++ b/src/lib/services/content.service.ts @@ -56,6 +56,17 @@ class ContentService implements IContentService { return await contentRepository.posts.findByCategory(category); } + async getPostsBySeries(series: string): Promise> { + if (!series || series.trim() === '') { + return { + success: false, + error: new Error('Series is required'), + }; + } + + return await contentRepository.posts.findBySeries(series); + } + // Project operations async getAllProjects(): Promise> { diff --git a/src/lib/types/content.types.ts b/src/lib/types/content.types.ts index e893bc2..ab5f5b5 100644 --- a/src/lib/types/content.types.ts +++ b/src/lib/types/content.types.ts @@ -17,6 +17,8 @@ export interface Post { readonly readTime: string; readonly content: string; readonly type: 'experience' | 'experiment'; + readonly series?: string; + readonly seriesOrder?: number; } export interface Project { diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index e5d5504..e0ba353 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,4 +1,5 @@ export * from './string.utils'; +export * from './series.utils'; export { getRelativeTime } from './date.utils'; export * from './date-format.utils'; export * from './style.utils'; diff --git a/src/lib/utils/series.utils.ts b/src/lib/utils/series.utils.ts new file mode 100644 index 0000000..f327f9c --- /dev/null +++ b/src/lib/utils/series.utils.ts @@ -0,0 +1,38 @@ +/** + * Series grouping utilities + * Pure functions for grouping posts into multi-part series + */ +import type { Post } from '@/lib/types'; +import { slugify } from './string.utils'; + +export interface PostSeries { + /** Series name (frontmatter `series`) */ + readonly name: string; + /** URL slug derived from the series name */ + readonly slug: string; + /** Parts ordered by `seriesOrder` ascending */ + readonly parts: readonly Post[]; + /** Sum of each part's reading time, in minutes */ + readonly totalMinutes: number; +} + +/** + * Group posts into series, ordered by seriesOrder. + * Posts without a `series` field are ignored. + */ +export function groupPostsIntoSeries(posts: readonly Post[]): PostSeries[] { + const map = new Map(); + + for (const post of posts) { + if (!post.series) continue; + const list = map.get(post.series) ?? []; + list.push(post); + map.set(post.series, list); + } + + return Array.from(map.entries()).map(([name, parts]) => { + const sorted = [...parts].sort((a, b) => (a.seriesOrder ?? 0) - (b.seriesOrder ?? 0)); + const totalMinutes = sorted.reduce((sum, p) => sum + (parseInt(p.readTime, 10) || 0), 0); + return { name, slug: slugify(name), parts: sorted, totalMinutes }; + }); +}