DEV Community

Hantz Peter Thermoris
Hantz Peter Thermoris

Posted on

Learn the Harness Concepts Before You Learn the Framework

You spent six months getting good at LangGraph. Then your company standardizes on Google ADK because the platform team runs on Vertex AI. Or a client ships on AWS and wants Strands. Or the data team already runs CrewAI. If your expertise lives in import paths and decorator names, you start from zero. If it lives in the design of the harness, you rewrite a few adapters and move on.

Build that second kind of expertise. LangChain/LangGraph, Strands Agents, CrewAI and Google ADK all expose the same control points around the model loop. Each one names and wires them its own way. The engineering decisions you make at those points carry across frameworks, and the syntax does not.

What a harness is

An agent is a loop. The model reads context, proposes a tool call or an answer, the runtime executes the tool, appends the result, and calls the model again. The harness is everything you put around that loop to make it safe, cheap, observable and correct:

  • the points where you intercept model calls and tool calls (callbacks, hooks, middleware)
  • the logic that corrects the agent mid-run (steering, guardrails, human approval)
  • the way you load instructions and procedures into context (system prompts, skills)
  • the state you keep between turns and the order in which your interceptors run

The model provider and the framework both change faster than these four concerns. Design them first.

Same lifecycle, four vocabularies

The four frameworks expose the interception points as follows, per their mid-2026 documentation:

Harness concept LangChain v1 / LangGraph Google ADK Strands Agents CrewAI
Before/after a model call before_model, after_model, wrap_model_call middleware before_model_callback, after_model_callback BeforeModelCallEvent, AfterModelCallEvent hooks @before_llm_call, @after_llm_call hooks
Before/after a tool call wrap_tool_call middleware before_tool_callback, after_tool_callback BeforeToolCallEvent, AfterToolCallEvent hooks @before_tool_call, @after_tool_call hooks
Run-level start/end before_agent, after_agent before_agent_callback, after_agent_callback invocation events @before_kickoff, @after_kickoff
Global, cross-agent policy middleware list on the agent Plugins (BasePlugin) on the Runner hook providers / plugins global hook registry, crew-scoped hooks
Just-in-time correction custom middleware callbacks returning replacement values steering handlers (Proceed / Guide / Interrupt) hooks that block or rewrite
Skills (SKILL.md) Deep Agents skills SkillToolset (experimental) AgentSkills plugin skills= on Agent

Read that table as a translation dictionary. The left column holds the knowledge you keep.

Concept 1: Interception and its return contract

All four frameworks let you run code before a tool executes. The part that matters for design is the return contract: what your function returns decides whether the loop continues, skips the step, or substitutes a result.

Take a support agent with two dangerous tools, issue_refund and delete_customer. You want to block them until someone approves. The rule is identical in all four frameworks.

LangChain v1 middleware. A wrap_tool_call hook receives the request and a handler. Call the handler to execute the tool. Return your own ToolMessage to skip it.

from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
from langchain.messages import ToolMessage

DESTRUCTIVE = {"issue_refund", "delete_customer"}

@wrap_tool_call
def gate_destructive(request, handler):
    call = request.tool_call
    if call["name"] in DESTRUCTIVE and not request.state.get("approved"):
        return ToolMessage(
            content=f"{call['name']} is blocked until the user approves. Ask for confirmation.",
            tool_call_id=call["id"],
        )
    return handler(request)

agent = create_agent(model="...", tools=[...], middleware=[gate_destructive])
Enter fullscreen mode Exit fullscreen mode

Google ADK callback. ADK uses a return-value convention: None continues, a dict replaces the tool result and the tool does not run.

from google.adk.agents import LlmAgent

def gate_destructive(tool, args, tool_context):
    if tool.name in DESTRUCTIVE and not tool_context.state.get("approved"):
        return {"error": f"{tool.name} is blocked until the user approves."}
    return None  # run the tool

agent = LlmAgent(
    name="support",
    model="gemini-2.5-flash",
    tools=[...],
    before_tool_callback=gate_destructive,
)
Enter fullscreen mode Exit fullscreen mode

CrewAI execution hook. Returning False blocks the call.

from crewai.hooks import before_tool_call

@before_tool_call
def gate_destructive(context):
    if context.tool_name in DESTRUCTIVE:
        return False
    return None
Enter fullscreen mode Exit fullscreen mode

Strands hook provider. You subscribe to a typed event and set a cancellation message on it.

from strands import Agent
from strands.hooks import HookProvider, HookRegistry, BeforeToolCallEvent

class GateDestructive(HookProvider):
    def register_hooks(self, registry: HookRegistry, **kwargs):
        registry.add_callback(BeforeToolCallEvent, self.check)

    def check(self, event: BeforeToolCallEvent):
        if event.tool_use["name"] in DESTRUCTIVE:
            event.cancel_tool = "Blocked until the user approves."

agent = Agent(tools=[...], hooks=[GateDestructive()])
Enter fullscreen mode Exit fullscreen mode

Four syntaxes, one decision set. You had to answer the same questions each time:

  1. Does a blocked call raise an error or return a message the model can read and act on? A readable message lets the model recover by asking the user. An exception ends the run.
  2. Where does the "approved" flag live? In LangChain it sits in graph state, in ADK in session state, and in Strands or CrewAI you pick where. The flag needs to survive a pause for human input.
  3. Does the gate apply to one agent or to all of them? That question leads to the next concept.

A note on versions: treat these snippets as mid-2026 references. The APIs move, which proves the point of this article (see the "churn" section below).

Concept 2: Node-style versus wrap-style hooks

LangChain's documentation draws a distinction worth carrying into any framework. Node-style hooks (before_model, after_model) run at a point and return a state update. Wrap-style hooks (wrap_model_call, wrap_tool_call) sit around the call and decide if, when and how many times the call happens.

You need wrap-style semantics for retries, fallbacks, caching and timeouts:

from langchain.agents.middleware import AgentMiddleware

class RetryThenFallback(AgentMiddleware):
    def __init__(self, fallback_model, max_retries=2):
        super().__init__()
        self.fallback_model = fallback_model
        self.max_retries = max_retries

    def wrap_model_call(self, request, handler):
        for _ in range(self.max_retries):
            try:
                return handler(request)
            except Exception:
                pass
        return handler(request.override(model=self.fallback_model))
Enter fullscreen mode Exit fullscreen mode

ADK gets you the caching half of this with a before_model_callback that returns a cached LlmResponse, which skips the LLM call. Strands exposes a retry_model flag on its after-model event: set it in a hook and the agent discards the response and calls the model again. Once you know you need "around" semantics, you go looking for the equivalent, and you find it.

Concept 3: Steering instead of front-loaded prompts

Picture a refund agent with a 40-line system prompt: verify the order, check the 30-day window, confirm the amount, use a friendly tone, never refund twice. By step 20 of a long run, the model forgets line 31. Teams tend to respond in one of two ways. They write a longer prompt, or they break the agent into a rigid graph of nodes. The first stays unreliable. The second kills the adaptive reasoning you wanted from an agent.

Steering gives you a third option. You keep the prompt short and attach small handlers that inspect what the agent is about to do and inject guidance at that moment. Strands names this pattern outright. Its steering handlers evaluate the agent before a tool call or after a model response and return one of three actions: Proceed lets the tool run, Guide cancels it and feeds the agent a correction, and Interrupt pauses for a human. A context provider such as the tool ledger records the call history the handler reasons over. In a March 2026 post on the Strands blog, the team compared prompt-only, SOP, graph workflow and steering versions of a library renewal agent and reported that the steering version hit its accuracy targets where the others missed.

In Strands you can express the rule in natural language with the LLM-backed handler (sketch; check the import path for your version):

from strands import Agent
from strands.vended_plugins.steering import LLMSteeringHandler

refund_policy = LLMSteeringHandler(
    system_prompt=(
        "Before issue_refund runs, confirm the agent called get_order for the "
        "same order_id and that the order is less than 30 days old. "
        "If not, guide the agent to do that first."
    )
)

agent = Agent(tools=[get_order, issue_refund], plugins=[refund_policy])
Enter fullscreen mode Exit fullscreen mode

The concept does not depend on Strands. You can write the same Guide behavior as deterministic LangChain middleware that reads the message history as its ledger:

@wrap_tool_call
def refund_requires_lookup(request, handler):
    call = request.tool_call
    if call["name"] != "issue_refund":
        return handler(request)

    order_id = call["args"].get("order_id")
    looked_up = any(
        tc["name"] == "get_order" and tc["args"].get("order_id") == order_id
        for msg in request.state["messages"]
        for tc in (getattr(msg, "tool_calls", None) or [])
    )
    if looked_up:
        return handler(request)  # Proceed

    return ToolMessage(  # Guide
        content=(
            f"Refund not executed. Call get_order for {order_id} first "
            "and confirm it is within the 30-day window."
        ),
        tool_call_id=call["id"],
    )
Enter fullscreen mode Exit fullscreen mode

In ADK you write the same check in before_tool_callback and return a dict with the guidance. In CrewAI you block in @before_tool_call and surface the reason. For Interrupt, you reach for LangGraph's interrupt() with a checkpointer, Strands' interrupt system, or ADK's human-in-the-loop tooling.

The transferable design questions:

  • Do you enforce this rule with code (cheap, deterministic, brittle to phrasing) or with an LLM judge (flexible, slower, costs tokens)? A hard business constraint like the 30-day window belongs in code or a policy engine. Tone belongs with an LLM judge.
  • What goes in the ledger? Tool names and arguments cover most rules. Timing and results cover rate limits and retries.
  • After a Guide, how many retries do you allow before you escalate to a human?

Concept 4: Skills and progressive disclosure

Skills solve the context problem from the other direction. A skill is a folder with a SKILL.md file (name, description, instructions) plus optional scripts, references and assets. The agent sees only the name and description up front, and loads the body when the task calls for it. Anthropic introduced the format, and it has since become a shared specification.

---
name: refund-policy
description: Rules and steps for processing customer refunds. Use when a user asks for a refund, return or chargeback.
---

1. Call get_order with the order_id the user provides.
2. Refuse if the order is older than 30 days; offer store credit instead.
3. Refunds above 500 EUR need human approval.
4. See references/edge-cases.md for partial shipments.
Enter fullscreen mode Exit fullscreen mode

That one folder now loads in all four ecosystems:

# CrewAI
from crewai import Agent
agent = Agent(role="Support", goal="Resolve tickets", backstory="...", skills=["./skills"])

# Google ADK (experimental)
import pathlib
from google.adk import Agent
from google.adk.skills import load_skill_from_dir
from google.adk.tools import skill_toolset

refund = load_skill_from_dir(pathlib.Path("skills/refund-policy"))
agent = Agent(name="support", model="gemini-2.5-flash",
              tools=[skill_toolset.SkillToolset(skills=[refund])])

# Strands: attach the AgentSkills plugin
# LangChain: pass skill directories to a Deep Agent
Enter fullscreen mode Exit fullscreen mode

The portable asset here is the skill folder, written once and versioned in git. Your skill-writing judgment carries even further: a sharp description field decides whether the agent picks the skill at the right moment, and putting rarely needed detail in references/ keeps the base context small. CrewAI's docs offer a useful rule of thumb: a process belongs in a skill, while reference data belongs in knowledge/retrieval.

Skills and steering complement each other. The skill tells the agent how to do refunds. The steering handler catches it when it skips step 1 anyway.

Concept 5: Scope and execution order

Many production harness bugs come from interceptors interacting in an order you didn't expect. Each framework has a rule, and you need to know yours:

Framework Ordering rule
LangChain v1 Middleware composes in list order; the first one defined sits outermost around the call.
Google ADK Plugin callbacks run before agent-level callbacks. If a plugin returns a non-None value, ADK skips the agent callback for that step.
CrewAI Hooks run in registration order. If one returns False, later hooks at that point do not run.
Strands Hooks subscribe to typed events; some after-events run callbacks in reverse registration order so cleanup unwinds.

Put your PII redaction inside your logging middleware in LangChain and your logs contain raw emails. Register an ADK plugin that returns a cached response and the per-agent guardrail you wrote does not run on cache hits. The concept to own is layering: global policy (security, cost limits, audit) goes in the outer, cross-agent layer; task-specific rules go close to the agent. Then check how your framework maps "outer" and "inner."

The churn is real

Framework-specific knowledge expires on a schedule of months. A few examples from the last year:

  • LangChain shipped v1.0 in October 2025 with create_agent and the middleware system, replacing the pre_model_hook / post_model_hook style of LangGraph's prebuilt create_react_agent as the recommended way to customize the loop.
  • Strands first shipped steering under strands.experimental.steering. The current API reference lists it under strands.vended_plugins.steering, and the TypeScript SDK exposes it through a separate "interventions" framework.
  • CrewAI's current docs introduce a unified @on(InterceptionPoint.PRE_TOOL_CALL, ...) decorator that aborts with a HookAborted carrying a reason and source, and describe the older point-specific decorators as covering only the four model/tool points with a bare return False for blocking.
  • Google ADK marks its Skills support as experimental, so expect the SkillToolset surface to shift.

If you memorized the old signatures, each of these changes cost you. If you understood "intercept before tool, return a readable reason, let the agent recover," each change took an afternoon.

Where framework knowledge still pays off

Framework depth has real value, and dismissing it would mislead you. You need it for:

  • Execution semantics. LangGraph's checkpointing and resumable interrupts, ADK's session and event model, and CrewAI's Flows differ in ways that change how you build long-running and human-in-the-loop agents.
  • Deployment and ops. ADK's path to Vertex AI Agent Engine, Strands' fit with Bedrock and AgentCore, and LangSmith tracing for LangChain decide a lot about your week-to-week operations.
  • Sharp edges. CrewAI's issue tracker, for example, holds a report that registering a pass-through after_llm_call hook stopped an agent from executing a tool call. You catch bugs like that by knowing the framework's internals.

Use concepts to design the harness and pick the framework. Use framework depth to ship and operate it.

A framework-neutral design checklist

Answer these before you write an import statement:

Question Why it matters
Which tool calls need a gate, and what does the model see when you block one? Readable denials let the agent recover; exceptions end runs.
Which rules do you enforce in code, which with an LLM judge, which with a policy engine? Cost, latency and determinism differ by an order of magnitude.
What goes in the system prompt, what goes in skills, what gets injected by steering? This split sets your base context size and reliability on long tasks.
Where does run state live, and does it survive a human-approval pause? Interrupts without durable state lose work.
Which interceptors are global and which are per agent, and in what order do they run? Ordering bugs leak data and skip guardrails.
What do you log at each hook, and can you replay a failed run from it? You debug agents from traces, not from reruns.

Write the answers down as a harness spec. Then open the docs of whichever framework your team picked and fill in the translation table. Keep the spec in the repo next to the code, and reuse it at the next migration.


References

  • LangChain, Custom middleware and middleware API reference: docs.langchain.com/oss/python/langchain/middleware/custom
  • LangChain, Deep Agents skills: docs.langchain.com/oss/javascript/deepagents/skills
  • Google ADK, Callbacks: google.github.io/adk-docs/callbacks/
  • Google ADK, Plugins: google.github.io/adk-docs/plugins/
  • Google ADK, Skills for ADK agents: google.github.io/adk-docs/skills
  • Strands Agents, Steering (Plugins): strandsagents.com/docs/user-guide/concepts/plugins/steering/
  • Strands Agents, How Steering Hooks Achieved 100% Agent Accuracy Where Prompts and Workflows Failed (March 2026): strandsagents.com/blog/steering-accuracy-beats-prompts-workflows/
  • CrewAI, Execution Hooks: docs.crewai.com/en/learn/execution-hooks
  • CrewAI, Skills: docs.crewai.com/en/concepts/skills
  • Agent Skills specification: agentskills.io

Top comments (0)