LangGraph agent skills: decisions, not snippets

7 min readSimon BudziakBy Simon Budziak

On this page8 sections
Title card reading LangGraph agent skills: decisions, not snippets. A navy field showing one gold coordinator node fanning out to three dim worker nodes and back again, the returning threads merging into a single thicker gold line.

We built a pre-production research agent for a client in six days: web search, synthesis across sources, fact-checking, and a human approval step before anything published. It runs on our own open-source langchain-agent-skills repository, which loads LangGraph patterns into a coding assistant on demand. The useful finding is not the six days. It is what aged and what did not: every architectural decision those skills made still holds, and four of the code samples around them no longer run at all.

We checked that by executing it, not by reading it. A pattern and a paste age at completely different rates.

If you want what agent skills are and how to install them, the companion post LangChain agent skills for AI coding assistants covers the catalogue and the plugin bundles. This one is about what they changed when we actually built something.

What the skills decided, and what we still owned

Four decisions carried the build, and all four are the kind that cost days when you get them wrong and cost nothing when you get them right the first time.

  • The graph shape. Three workers had to run at once under one coordinator, which is the orchestrator-worker pattern rather than a supervisor or a router.
  • Reducer semantics. Concurrent workers writing to the same key need a declared merge rule, or results are silently lost.
  • Retry boundaries. Retrying a search timeout is free. Retrying a publish is a duplicate report in front of a client.
  • Where observability goes. Tracing decided at the end is tracing you do not have when the failure appears.

What the skills did not decide was the domain logic, the prompts, or the quality bar. That split is the whole value: the assistant arrives already knowing the framework’s failure modes, and you spend your attention on the part that is actually yours.

The graph: parallel workers, one coordinator

The orchestrator dispatches all three workers in a single Command, and a list edge brings them back together. The fan-in waits for all three before the coordinator runs again.

from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command

def orchestrator_node(
    state: ResearchState,
) -> Command[Literal["search_worker", "summarize_worker", "fact_check_worker", "approval"]]:
    if not state.get("workers_started"):
        return Command(
            goto=["search_worker", "summarize_worker", "fact_check_worker"],
            update={"workers_started": True},
        )
    return Command(goto="approval")

builder = StateGraph(ResearchState)
builder.add_node("orchestrator", orchestrator_node)
builder.add_node("search_worker", search_worker)
builder.add_node("summarize_worker", summarize_worker)
builder.add_node("fact_check_worker", fact_check_worker)
builder.add_node("approval", approval_node)

builder.add_edge(START, "orchestrator")
builder.add_edge(["search_worker", "summarize_worker", "fact_check_worker"], "orchestrator")
builder.add_edge("approval", END)

Both halves are documented behaviour. The Command docstring in LangGraph’s shipped source lists “Sequence of node names to navigate to next” among the accepted values for goto, and the list form of add_edge is the fan-in that waits for every named node. We ran this graph on LangGraph 1.2.11 before publishing, and the three workers’ writes merged as expected.

The one line worth reading twice is the checkpointer, because the obvious spelling is wrong:

from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://user:pass@localhost:5432/postgres?sslmode=disable"

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)

PostgresSaver.from_conn_string is declared as returning Iterator[PostgresSaver] in the shipped source. The persistence guide shows it entered with with, because it is a context manager: passing its result straight into compile() hands the graph the context manager instead of the saver. Our own earlier draft of this post did exactly that.

State is the design: reducers decide what survives

A LangGraph state schema is not a type declaration. The annotations are merge rules, and they run every time two nodes write the same key in the same superstep.

import operator
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import MessagesState

class FactCheck(TypedDict):
    claim: str
    verdict: Literal["supported", "refuted", "unverified"]
    sources: list[str]
    confidence: float

class ResearchState(MessagesState):
    query: str
    max_sources: int

    # Concurrent worker output, merged rather than overwritten
    search_results: Annotated[list[dict], operator.add]
    summaries: Annotated[list[str], operator.add]
    fact_checks: Annotated[list[FactCheck], operator.add]

    # Single-writer fields, last write wins
    workers_started: bool
    final_report: str | None
    published: bool

Without operator.add, three workers writing search_results do not race. They overwrite, and two thirds of the evidence disappears with no error anywhere.

The rule the skill encodes is short: operator.add where duplicates are fine, a custom reducer where you need deduplication by URL or entity, and no annotation at all for single-writer fields. Ordering across parallel branches is not guaranteed, so never let position in the list carry meaning.

Retry what is safe, never what publishes

RetryPolicy attaches per node, which is what makes selective retry possible at all, and the LangGraph fault tolerance guide documents it as the supported way to scope retries.

from langgraph.types import RetryPolicy

search_retry = RetryPolicy(
    max_attempts=3,
    initial_interval=1.0,
    backoff_factor=2.0,
    retry_on=[TimeoutError],
)

builder.add_node("search_worker", search_worker, retry_policy=search_retry)

Worth knowing: RetryPolicy sets jitter=True by default, so the intervals are randomised around the backoff curve rather than landing on exactly one, two and four seconds. That is the behaviour you want, and it is not what a comment claiming a fixed sequence tells you.

The publish node gets the opposite treatment, no retry policy and an idempotency key:

A navy card titled Three failures, three mechanisms, listing three error classes and the LangGraph mechanism each one takes. A transient failure such as a search API timeout or a 429 takes a retry policy, because nothing has happened yet. A failure that needs a person, such as approving a report before it goes out, takes interrupt, because no amount of retrying produces a decision. A side effect such as publishing, notifying or charging takes an idempotency key and never a retry policy, because the second attempt is a duplicate in front of the client.
The classification is the decision. Once a failure is in the right row, the code writes itself.
def publish_node(state: ResearchState) -> dict:
    key = f"{state['thread_id']}-{state['turn_id']}"
    if is_already_published(key):
        return {"published": True, "idempotent_skip": True}
    publish_to_external_system(state["final_report"], key)
    return {"published": True}

builder.add_node("publish", publish_node)  # deliberately no retry_policy

The human approval step in front of it uses interrupt(), which we covered in depth in LangGraph human approval: interrupt or middleware?. The short version is that the framework has since grown a middleware for this, and it is worth reading before hand-rolling an approval graph.

What LangGraph absorbed since

The most interesting kind of staleness is the framework growing a first-class version of something you built by hand.

Our fact-check worker timed out on large batches, so we wrapped each batch in asyncio.wait_for with a manual timeout. That was the correct answer at the time and it is no longer the best one. LangGraph added node-level timeouts in version 1.2: TimeoutPolicy is absent from the tagged source at 1.1.0 and present at 1.2.0.

from langgraph.types import RetryPolicy, TimeoutPolicy

builder.add_node(
    "fact_check_worker",
    fact_check_worker,
    timeout=TimeoutPolicy(run_timeout=10),
    retry_policy=RetryPolicy(max_attempts=3),
)

The timeout clock resets on each retry attempt, and there is now a node-level error_handler for returning a Command instead of failing the run. Batching the work is still ours to decide. Enforcing the deadline is not.

What to check before trusting a packaged snippet

The skills are worth using and we still use them. The discipline that goes with them is to treat every snippet as a claim to be executed, because a pattern that is right in principle travels badly in three specific ways.

Paths drift. The initialisation script the earlier version of this post showed as scripts/init_langgraph_project.py actually lives under skills/langgraph-project-setup/scripts/. A validator we referred to as validate_config.py does not exist under that name at all; it is validate_langgraph_config.py.

Configs go out of date quietly. The langgraph.json our earlier draft printed carried graphs and env and nothing else. Run it through the validator that ships in the same repository and it fails:

$ uv run skills/langgraph-project-setup/scripts/validate_langgraph_config.py langgraph.json
🔍 Validating: /path/to/langgraph.json

❌ Validation failed with errors:

  ERROR: Missing required field: 'dependencies'

⚠️  Warnings:

  WARNING: Graph 'research_agent' file not found: ./src/research_agent/graph.py

Code samples rot against the runtime. Our batching fix interpolated a join with an escape inside an f-string expression. That is a SyntaxError on Python 3.11, which is the default the repository’s own initialisation script selects. It parses from Python 3.12 onward, where PEP 701 lifted the backslash restriction, which is exactly the sort of defect that survives review and dies in CI.

Three independent failures, all in a post that read as authoritative, none of which a reader could have spotted without running the code. That is the argument for the executable half of a skill: a validator you can run beats a snippet you can copy.

The six days, honestly

The timeline held: setup and architecture on day one, workers and error handling through day three, testing on day four, trace-driven debugging on day five, deployment on day six. Trace analysis pointed at a single node and one condition, and after the batching fix the error rate in that environment fell from roughly 5% to 0.2%.

Those numbers are ours, from one project’s LangSmith traces over one pre-production window, measured against our own internal estimate of what the same work usually costs us. They are directional and environment-specific, not a benchmark, and we would not want anyone planning a sprint against them. The reproducible part of this post is the code, which is why we ran it.

The honest summary is that skills removed the research, not the engineering. The catalogue has since grown to ten skills across three plugin bundles, and the decisions above are the ones we would make again.

Sources

How we build on LangChain in production