Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Works correctly with LangChain 1.0's typed content blocks on AIMessage.content — text, tool_use, image, thinking, document — across Claude, GPT-4o, and Gemini, including multi-modal composition and tool-call iteration. Use when composing multi-modal messages, iterating tool_use blocks, handling Claude's thinking content, or unifying image inputs across providers. Trigger with "langchain content blocks", "AIMessage.content", "tool_use block", "claude image input", "langchain multimodal", "thinkin
.claude/skills/jeremylongshore-langchain-content-blocks/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 153% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 125% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 116% | 0% |
| case-17 | ✗→✓ | ▲ Improved | 281% | 0% |
| case-19 | ✗→✓ | ▲ Improved | -42% | 0% |
On Claude, AIMessage.content is list[dict] even for pure text — so any code from an OpenAI-first tutorial that calls message.content.lower() or message.content.split() crashes with AttributeError: 'list' object has no attribute 'lower' on the first production Claude call (P02). Multi-modal code that works on GPT-4o breaks on Claude because pre-1.0 image-block shapes differed across providers (P64). Multi-turn Claude replay with extended thinking fails with anthropic.BadRequestError: missing signature when prior thinking blocks are stripped. Forced tool_choice prevents stop_reason="end_turn" and loops forever (P63).
This is the deep-dive companion to langchain-model-inference. That skill's references/content-blocks.md covers the str vs list[dict] divergence and a safe text extractor. This skill goes further:
tool_use block iteration mechanics — IDs, args as dict vs JSON string, streaming deltasthinking blocks — signature, redaction, multi-turn replay semanticsdocument blocks — Claude citations API, source types, citation extractionimage shape, per-provider adapter behaviorPin: langchain-core 1.0.x, langchain-anthropic >= 1.0, langchain-openai >= 1.0, anthropic >= 0.40. Pain-catalog anchors: P02, P58, P63, P64.
langchain-core >= 1.0, < 2.0pip install langchain-anthropic langchain-openailangchain-anthropic >= 1.0 and Claude Sonnet 4+ / Opus 4+anthropic >= 0.40 and Claude Sonnet 4+langchain-model-inference (reads references/content-blocks.md first)LangChain 1.0 defines six typed content blocks on AIMessage.content (and on chunks during streaming):
| Block type | Produced by | Notes | |------------|-------------|-------| | text | All providers | On Claude, always wrapped as [{"type":"text","text":"..."}] | | tool_use | Claude, GPT-4o, Gemini | Always round-trip via msg.tool_calls, not hand-parsed | | tool_result | You (via ToolMessage) | One per tool_use; tool_call_id must match byte-for-byte | | image | Claude vision, GPT-4o, Gemini | Universal 1.0 shape; adapter handles wire format per provider | | thinking | Claude extended thinking only | Must preserve signature for replay | | document | Claude citations API (Sonnet 4+) | Input-side only; citations attach to output text blocks |
See Block-Type Matrix for the full table with streaming behavior and per-type gotchas.
For most code, use the helpers:
pythontext = msg.text() # concatenated text across all text blocks tool_calls = msg.tool_calls # normalized list[ToolCall] usage = msg.usage_metadata # input_tokens, output_tokens, cache_*
Hand-roll block iteration only when you need to (a) preserve order, (b) extract thinking blocks for replay, or (c) read citations metadata from text blocks. Order-preserving iteration:
pythonfrom langchain_core.messages import AIMessage def iter_blocks(msg: AIMessage): if isinstance(msg.content, str): yield "text", {"type": "text", "text": msg.content} return for block in msg.content: if isinstance(block, dict): yield block.get("type", "unknown"), block else: yield getattr(block, "type", "unknown"), block
image blockpythonimport base64 from pathlib import Path from langchain_core.messages import HumanMessage def image_block(path: str) -> dict: data = base64.standard_b64encode(Path(path).read_bytes()).decode("ascii") mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}[ Path(path).suffix.lstrip(".").lower()] return { "type": "image", "source_type": "base64", # or "url" "data": data, "mime_type": mime, } msg = HumanMessage(content=[ image_block("screenshot.png"), # put image FIRST {"type": "text", "text": "What is broken here?"}, # instruction LAST ]) response = claude.invoke([msg])
Three invariants:
content must be list[dict] when including non-text blocks.LangChain's adapter translates the universal shape to each provider's wire format. See Multi-Modal Composition for the full adapter table, MIME-type compatibility, and the document/citations pattern.
tool_use correctly across stream deltasCanonical non-streaming:
pythonfor tc in msg.tool_calls: output = tools[tc["name"]](**tc["args"]) history.append(ToolMessage(content=str(output), tool_call_id=tc["id"]))
tc["args"] is already a parsed dict — do not json.loads it. tc["id"] is provider-shaped (toolu_* on Anthropic, call_* on OpenAI, 24+ chars) and must be copied verbatim to the ToolMessage.
Streaming is different. tool_use.input arrives as partial JSON fragments across on_chat_model_stream events. Buffer with tool_call_chunks, parse once at on_chat_model_end:
pythonfrom collections import defaultdict import json partial = defaultdict(str) # index -> accumulated JSON fragment meta = {} # index -> {name, id} async for event in model.astream_events({"messages": [...]}, version="v2"): if event["event"] != "on_chat_model_stream": continue for tc_chunk in getattr(event["data"]["chunk"], "tool_call_chunks", []) or []: idx = tc_chunk["index"] if tc_chunk.get("name"): meta[idx] = {"name": tc_chunk["name"], "id": tc_chunk["id"]} if tc_chunk.get("args"): partial[idx] += tc_chunk["args"] completed = [{**meta[i], "args": json.loads(partial[i])} for i in meta]
See Tool-Use Iteration for multi-tool-per-turn handling, ToolMessage ordering, and the forced- tool_choice infinite-loop trap (P63).
thinking blocks for replayClaude extended thinking (Sonnet 4+, Opus 4+) returns thinking blocks carrying a cryptographic signature. The next turn must round-trip those blocks intact or Anthropic rejects the request:
anthropic.BadRequestError: messages.1.content.0: missing signatureThe foot-gun: msg.text() strips thinking blocks. Never do:
python# WRONG — thinking blocks lost, replay fails history.append(AIMessage(content=ai_1.text()))
Correct — pass the AIMessage back verbatim:
pythonhistory.append(ai_1) # preserves full content list + signatures
For persistence across sessions, serialize with messages_to_dict(...) (not custom JSON), which preserves block structure:
pythonimport json from langchain_core.messages import messages_to_dict, messages_from_dict serialized = json.dumps(messages_to_dict([ai_1])) restored = messages_from_dict(json.loads(serialized))
See Thinking Blocks for redaction handling, the budget-tokens rule, and the interaction with tool calls.
Before sending any multi-modal or tool-using message:
content a list[dict] when it contains non-text blocks?source_type, data, mime_type)?tool_use is involved, am I passing msg.tool_calls — not parsed content?AIMessage — not msg.text()?HumanMessage in the universal 1.0 image shape, portable across Claude/GPT-4o/Geminitool_use stream-delta accumulator that buffers partial input JSON and parses once at endthinking blocks intact (no missing signature errors)document/citations extractor that reads citations metadata from text blocks| Error | Cause | Fix | |-------|-------|-----| | AttributeError: 'list' object has no attribute 'lower' | Treating AIMessage.content as str on Claude (P02) | Use msg.text() or iterate blocks | | anthropic.BadRequestError: messages.N.content.M: missing signature | Stripped thinking block on replay | Pass AIMessage object back verbatim; never rebuild from text() | | anthropic.BadRequestError: tool_use_id not found in corresponding tool_result | Typo / case mismatch in ToolMessage.tool_call_id | Copy tc["id"] verbatim | | anthropic.BadRequestError: tool_use ids were found without tool_result blocks | Skipped a tool call | Emit one ToolMessage per tool_call (use status="error" on failure) | | anthropic.BadRequestError: image exceeds 5 MB limit | Un-resized screenshot | Pre-resize to < 5 MB (1024x1024 JPEG 85 is ~500 KB) | | openai.BadRequestError: Invalid image data | Hand-rolled image_url with wrong prefix | Use the universal block; adapter emits the data:image/...;base64, prefix | | Infinite agent loop | Forced tool_choice inside a loop (P63) | Use tool_choice="auto" for agents; forced-choice only for single-call extraction | | json.JSONDecodeError inside stream loop | Parsing partial tool_use.input fragment | Buffer in a defaultdict(str); parse once at on_chat_model_end | | Citations silently missing | Read via msg.text() which strips metadata | Iterate msg.content and read block["citations"] on text blocks |
pythonmsg = HumanMessage(content=[ image_block("ui.png"), {"type": "text", "text": "Identify the broken UI element."}, ]) # Same message works on both providers via adapter translation claude_resp = claude.invoke([msg]) gpt4o_resp = gpt4o.invoke([msg])
pythonclaude = ChatAnthropic( model="claude-sonnet-4-6", max_tokens=8192, thinking={"type": "enabled", "budget_tokens": 4096}, ) ai_1 = claude.invoke([HumanMessage(content="What is the capital of France?")]) # ai_1.content == [{"type":"thinking",...,"signature":"..."}, {"type":"text",...}] # Turn 2 — pass ai_1 VERBATIM ai_2 = claude.invoke([ HumanMessage(content="What is the capital of France?"), ai_1, # thinking preserved HumanMessage(content="And the population?"), ])
See Thinking Blocks for the full replay invariants and persistence pattern.
document inputpythondoc_block = { "type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": pdf_b64}, "title": "Q3 Earnings Report", "citations": {"enabled": True}, } resp = claude.invoke([HumanMessage(content=[ doc_block, {"type": "text", "text": "What drove revenue this quarter?"}, ])]) for block in resp.content: if block.get("type") != "text": continue print(block["text"]) for c in block.get("citations", []): print(f" -> {c['document_title']}: {c['cited_text']!r}")
msg.text() flattens this — you lose citations. See Multi-Modal Composition for the full document block reference including supported source types.
tool_use with live argument renderingSee Tool-Use Iteration for the complete tool_call_chunks accumulator including multi-tool-per-turn handling and the ToolMessage ordering invariant.
AIMessage API referencelangchain-model-inference (read its references/content-blocks.md for the str vs list[dict] fundamentals)docs/pain-catalog.md (entries P02, P58, P63, P64)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 12,412 | 16,142 | +30% | 1 | 1 | 0% | 2,469 | 6,236 | +153% | 0 | 0 | — |
case-02 | fail→pass | 24,715 | 19,071 | -23% | 1 | 1 | 0% | 2,874 | 6,459 | +125% | 0 | 0 | — |
case-03 | fail→pass | 20,517 | 11,651 | -43% | 1 | 1 | 0% | 2,732 | 5,894 | +116% | 0 | 0 | — |
case-04 | pass→pass | 15,304 | 14,532 | -5% | 1 | 1 | 0% | 1,966 | 5,631 | +186% | 0 | 0 | — |
case-05 | pass→pass | 19,135 | 14,720 | -23% | 1 | 1 | 0% | 2,487 | 6,862 | +176% | 0 | 0 | — |
case-06 | pass→pass | 18,741 | 17,659 | -6% | 1 | 1 | 0% | 2,439 | 6,127 | +151% | 0 | 0 | — |
case-07 | pass→pass | 12,034 | 14,753 | +23% | 1 | 1 | 0% | 2,229 | 5,643 | +153% | 0 | 0 | — |
case-08 | pass→pass | 17,865 | 17,625 | -1% | 1 | 1 | 0% | 2,395 | 6,236 | +160% | 0 | 0 | — |
case-09 | pass→pass | 18,702 | 8,169 | -56% | 1 | 1 | 0% | 2,311 | 5,355 | +132% | 0 | 0 | — |
case-10 | pass→pass | 18,321 | 6,377 | -65% | 1 | 1 | 0% | 1,949 | 5,010 | +157% | 0 | 0 | — |
case-11 | pass→pass | 24,287 | 14,076 | -42% | 1 | 1 | 0% | 2,877 | 5,585 | +94% | 0 | 0 | — |
case-12 | pass→pass | 14,002 | 11,417 | -18% | 1 | 1 | 0% | 1,549 | 5,525 | +257% | 0 | 0 | — |
case-13 | pass→pass | 13,982 | 10,073 | -28% | 1 | 1 | 0% | 1,255 | 4,683 | +273% | 0 | 0 | — |
case-14 | pass→pass | 15,462 | 13,045 | -16% | 1 | 1 | 0% | 1,880 | 5,313 | +183% | 0 | 0 | — |
case-15 | pass→pass | 23,941 | 13,051 | -45% | 1 | 1 | 0% | 3,859 | 6,360 | +65% | 0 | 0 | — |
case-16 | pass→pass | 16,470 | 13,605 | -17% | 1 | 1 | 0% | 2,109 | 5,395 | +156% | 0 | 0 | — |
case-17 | fail→pass | 11,551 | 10,477 | -9% | 1 | 1 | 0% | 1,304 | 4,967 | +281% | 0 | 0 | — |
case-18 | pass→pass | 20,048 | 11,352 | -43% | 1 | 1 | 0% | 2,144 | 5,527 | +158% | 0 | 0 | — |
case-19 | fail→pass | 47,519 | 11,875 | -75% | 1 | 1 | 0% | 8,225 | 4,758 | -42% | 0 | 0 | — |
case-20 | pass→pass | 17,460 | 17,409 | -0% | 1 | 1 | 0% | 2,284 | 6,508 | +185% | 0 | 0 | — |
case-21 | fail→pass | 8,636 | 17,290 | +100% | 1 | 1 | 0% | 1,754 | 5,608 | +220% | 0 | 0 | — |
case-22 | pass→pass | 18,033 | 26,751 | +48% | 1 | 1 | 0% | 2,611 | 6,996 | +168% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +27 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.