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])
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,
)
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
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()])
Four syntaxes, one decision set. You had to answer the same questions each time:
- 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.
- 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.
- 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))
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])
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"],
)
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.
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
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_agentand the middleware system, replacing thepre_model_hook/post_model_hookstyle of LangGraph's prebuiltcreate_react_agentas the recommended way to customize the loop. -
Strands first shipped steering under
strands.experimental.steering. The current API reference lists it understrands.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 aHookAbortedcarrying a reason and source, and describe the older point-specific decorators as covering only the four model/tool points with a barereturn Falsefor blocking. -
Google ADK marks its Skills support as experimental, so expect the
SkillToolsetsurface 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_callhook 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)