The same GATE scenario in LangGraph, LangChain, OpenAI Agents, and CrewAI

flowchart TD
    subgraph Frameworks["Any Host Framework"]
        direction LR
        LG["LangGraph"] ~~~ LC["LangChain"] ~~~ OA["OpenAI Agents"] ~~~ CW["CrewAI"]
    end

    Frameworks -->|"tool call"| G["GATE Guard
(same enforcement for all)"]
    G --> M["gateMonitor
Privilege → DFA → Contracts → D-CBF → Semantic"]
    M -->|"ALLOW"| T["Tool executes"]
    M -->|"BLOCK"| B["ToolExecutionBlocked
raised into framework"]
    M -->|"ESCALATE"| E["Human approval queue"]

    style Frameworks fill:#f0fbf7,stroke:#2eb85c,stroke-width:1.5px
    style G fill:#fdfaff,stroke:#a855f7,stroke-width:2px
    style T fill:#e6f9ed,stroke:#2eb85c,color:#006622
    style B fill:#fde2e2,stroke:#d62728,color:#990000
    style E fill:#fff3e0,stroke:#f59f00,color:#995c00

This page runs one scenario through all four adapters with the same configuration. It shows the object each adapter expects, how to run a full agent, and a keyless driver that exercises the framework’s real tool-dispatch entry point. Use the keyless driver to verify enforcement without a model or API key; use the full-agent path for a real run.

The scenario

Two tools:

The configuration is identical for every framework:

from gate import AgentProfile, ResourceBudget, gateConfig

config = gateConfig(
    agent_profile=AgentProfile(
        agent_id="scenario-agent",
        role="assistant",
        allowed_tools={"search"},
        resource_budget=ResourceBudget(max_steps=20, max_loops_per_phase=5),
    ),
    enabled_checks={"privilege", "dfa"},
)

The checks are deterministic, so no embedding model is downloaded. The expected outcomes are:

What changes per framework

Framework Wrapped object Key Full-run call Keyless dispatch entry point
LangGraph Compiled StateGraph with a ToolNode "langgraph" secure.invoke({"messages": [...]}) ToolNode through the compiled graph
LangChain AgentExecutor or compiled create_agent graph "langchain" secure.invoke("...") or secure.invoke({"messages": [...]}) Compiled graph with a ToolNode
OpenAI Agents SDK agents.Agent "openai-agents" await secure.invoke("...") FunctionTool.on_invoke_tool
CrewAI crewai.Crew "crewai" secure.invoke({...}) CrewAI function table or BaseTool.run

Only the LangChain and LangGraph adapters accept tools=. OpenAI Agents and CrewAI derive their tool sets from the wrapped object. The same monitor, stages, and ToolExecutionBlocked behaviour apply in every row.

LangGraph

A full graph has a model node and a ToolNode. GATE does not change the graph; it guards the tool objects inside the node.

from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition

from gate import wrap_agent


@tool
def search(query: str) -> str:
    """Search the internal knowledge base."""
    return f"results for {query}"


@tool
def delete_records(table: str) -> str:
    """Delete every row in a table."""
    return f"deleted {table}"


tools = [search, delete_records]
model = ChatOpenAI(model="gpt-4o-mini")


def agent_node(state: MessagesState):
    return {"messages": [model.bind_tools(tools).invoke(state["messages"])]}


builder = StateGraph(MessagesState)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile()

secure = wrap_agent(graph, "langgraph", config)
result = secure.invoke(
    {"messages": [HumanMessage("Find the retention policy")]}
)
secure.terminate()

The model call needs provider credentials. To verify enforcement without a model, drive the same ToolNode with a prepared AIMessage:

from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode

from gate import wrap_agent
from gate.adapters import ToolExecutionBlocked

executed = []


@tool
def search(query: str) -> str:
    """Search the internal knowledge base."""
    executed.append("search")
    return f"results for {query}"


@tool
def delete_records(table: str) -> str:
    """Delete every row in a table."""
    executed.append("delete_records")
    return f"deleted {table}"


builder = StateGraph(MessagesState)
builder.add_node("tools", ToolNode([search, delete_records]))
builder.add_edge(START, "tools")
builder.add_edge("tools", END)
graph = builder.compile()

secure = wrap_agent(graph, "langgraph", config)

permitted = AIMessage(
    content="",
    tool_calls=[
        {"name": "search", "args": {"query": "retention policy"}, "id": "c1", "type": "tool_call"}
    ],
)
output = secure.invoke({"messages": [permitted]})
assert output["messages"][-1].content == "results for retention policy"
assert [call.tool_name for call in secure.get_state().tool_history] == ["search"]

forbidden = AIMessage(
    content="",
    tool_calls=[
        {"name": "delete_records", "args": {"table": "users"}, "id": "c2", "type": "tool_call"}
    ],
)
try:
    secure.invoke({"messages": [forbidden]})
except ToolExecutionBlocked as exc:
    assert exc.result.checks_failed == ["privilege"]
    assert exc.post_execution is False

assert executed == ["search"]
secure.terminate()

The allowed output is results for retention policy. After invoke returns, finish_run() has moved the state to ANSWERING.

LangChain

LangChain 1.x builds an agent graph with create_agent. Pass the compiled graph to the "langchain" adapter:

from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool

from gate import wrap_agent


@tool
def search(query: str) -> str:
    """Search the internal knowledge base."""
    return f"results for {query}"


@tool
def delete_records(table: str) -> str:
    """Delete every row in a table."""
    return f"deleted {table}"


model = ChatOpenAI(model="gpt-4o-mini")
graph = create_agent(model, tools=[search, delete_records])

secure = wrap_agent(graph, "langchain", config)
result = secure.invoke(
    {"messages": [{"role": "user", "content": "Find the retention policy"}]}
)
secure.terminate()

For a legacy AgentExecutor, the object is the executor instead:

from langchain.agents import AgentExecutor

# agent is your existing LangChain agent runnable.
executor = AgentExecutor(agent=agent, tools=[search, delete_records])
secure = wrap_agent(executor, "langchain", config)
result = secure.invoke({"input": "Find the retention policy"})
secure.terminate()

If discovery cannot see the tools, pass tools=[search, delete_records] explicitly. A compiled graph with no discoverable ToolNode is wrapped with no guards unless you do.

Keyless check: build a compiled graph whose only node is a ToolNode, then use the same prepared AIMessage driver shown for LangGraph, with secure = wrap_agent(graph, "langchain", config, tools=[search, delete_records]). The adapter accepts compiled graphs, so the enforcement path is the same. See LangChain adapter.

OpenAI Agents SDK

from agents import Agent, function_tool

from gate import wrap_agent

executed = []


@function_tool
def search(query: str) -> str:
    """Search the internal knowledge base."""
    executed.append("search")
    return f"results for {query}"


@function_tool
def delete_records(table: str) -> str:
    """Delete every row in a table."""
    executed.append("delete_records")
    return f"deleted {table}"


agent = Agent(name="scenario-agent", tools=[search, delete_records])
secure = wrap_agent(agent, "openai-agents", config)

result = await secure.invoke("Find the retention policy")
secure.terminate()

invoke is async because it calls Runner.run. This path needs an OpenAI API key.

Keyless check: the SDK dispatches every function tool through FunctionTool.on_invoke_tool. Call it directly with the JSON arguments the model would produce:

import asyncio
import json

from gate.adapters import ToolExecutionBlocked

by_name = {tool.name: tool for tool in agent.tools}

allowed = asyncio.run(
    by_name["search"].on_invoke_tool(
        None, json.dumps({"query": "retention policy"})
    )
)
assert allowed == "results for retention policy"

try:
    asyncio.run(
        by_name["delete_records"].on_invoke_tool(
            None, json.dumps({"table": "users"})
        )
    )
except ToolExecutionBlocked as exc:
    assert exc.result.checks_failed == ["privilege"]

assert executed == ["search"]

After a direct tool call, the state phase is OBSERVING, not ANSWERING, because SecureAgent.invoke did not run and therefore did not call finish_run(). The enforcement decision is the same.

The adapter refuses hosted tools and MCP servers because their execution happens outside the Python process. See OpenAI Agents adapter.

CrewAI

from crewai import Agent, Crew, Task
from crewai.tools import BaseTool
from pydantic import BaseModel, Field

from gate import wrap_agent


class SearchArgs(BaseModel):
    query: str = Field(description="query text")


class DeleteArgs(BaseModel):
    table: str = Field(description="table to delete from")


class SearchTool(BaseTool):
    name: str = "search"
    description: str = "Search the internal knowledge base."
    args_schema: type[BaseModel] = SearchArgs
    calls: list = Field(default_factory=list)

    def _run(self, query: str) -> str:
        self.calls.append("search")
        return f"results for {query}"


class DeleteTool(BaseTool):
    name: str = "delete_records"
    description: str = "Delete every row in a table."
    args_schema: type[BaseModel] = DeleteArgs
    calls: list = Field(default_factory=list)

    def _run(self, table: str) -> str:
        self.calls.append("delete_records")
        return f"deleted {table}"


search = SearchTool()
delete_records = DeleteTool()

agent = Agent(
    role="assistant",
    goal="answer questions",
    backstory="careful assistant",
    tools=[search, delete_records],
    verbose=False,
    allow_delegation=False,
)
task = Task(
    description="Find the retention policy.",
    expected_output="A policy summary.",
    agent=agent,
)
crew = Crew(agents=[agent], tasks=[task], verbose=False)

secure = wrap_agent(crew, "crewai", config)
result = secure.invoke({"topic": "retention"})
secure.terminate()

secure.invoke calls crew.kickoff(inputs=...). The full run needs a configured CrewAI LLM.

Keyless check: CrewAI builds an OpenAI-style function table for tool dispatch. Calling that table is the same path its executor uses:

from crewai.utilities.agent_utils import convert_tools_to_openai_schema

from gate.adapters import ToolExecutionBlocked

_, functions, _ = convert_tools_to_openai_schema(crew.agents[0].tools)

allowed = functions["search"](query="retention policy")
assert allowed == "results for retention policy"

try:
    functions["delete_records"](table="users")
except ToolExecutionBlocked as exc:
    assert exc.result.checks_failed == ["privilege"]

assert search.calls == ["search"]
assert delete_records.calls == []

convert_tools_to_openai_schema is a CrewAI internal helper, not a GATE API. It is used here because it is the dispatch table the framework itself builds. A direct search.run(query="retention policy") also reaches the guard, because BaseTool.run calls the guarded _run.

Shared behaviour

The following are the same for all four frameworks:

Differences that matter in a scenario:

Verified output

The keyless drivers above were run on 2026-10-25 against the framework versions in Installation. Each produced the same decision, condensed here:

LangGraph:      allowed='results for retention policy'  blocked=ToolExecutionBlocked  checks=['privilege']  executed=['search']
LangChain:      allowed='results for retention policy'  blocked=ToolExecutionBlocked  checks=['privilege']  executed=['search']
OpenAI Agents:  allowed='results for retention policy'  blocked=ToolExecutionBlocked  checks=['privilege']  executed=['search']
CrewAI:         allowed='results for retention policy'  blocked=ToolExecutionBlocked  checks=['privilege']  executed=['search']

executed=['search'] means only the allowed tool body ran. The forbidden body was never called.

The repository test suite exercises these paths in tests/test_framework_adapters.py. The adapter pages document the framework-specific details and limitations.