Agent Governance Foundation

How to Add Authorization Gates to LangGraph Agents

Agent Governance Foundation
langgraphauthorizationpythonintegration

LangGraph agents aren't a single loop calling a flat list of tools — they're a graph. A node can be a tool-calling step wired through ToolNode, or it can be an arbitrary function that does whatever the graph's author wrote: call an LLM, hit a database, dispatch to another agent. That flexibility is the whole point of LangGraph, and it's also why "just wrap the tools" isn't a complete answer for authorization here.

This post covers the two real governance surfaces in a LangGraph agent, plus a third shape — a single dispatcher node — that neither one fits, and what to do about it instead.

Why you need this

A StateGraph node can do anything a Python function can do. Unlike a tool the model explicitly decides to call, a graph node just runs when the graph reaches it — there's no model-level "should I do this?" moment to intercept. If that node writes to a database, sends an email, or calls a payment API, the only place left to ask "is this allowed?" is inside the node itself, before the real work happens.

Prerequisites

pip install agf-sdk[langgraph]
export AGF_API_KEY=agfk_your_key_here

Pattern 1 — Tool-calling nodes

If your tools are wired through ToolNode or create_react_agent, you don't need anything LangGraph-specific. AGFGuardedTool — the same wrapper the LangChain integration uses — is already a BaseTool, so it works unmodified:

import os
from langgraph.prebuilt import ToolNode
from agf import AGFClient
from agf.langchain import AGFGuardedTool
from langchain_community.tools import ShellTool

client = AGFClient(api_key=os.environ["AGF_API_KEY"])

guarded_shell = AGFGuardedTool(
    tool=ShellTool(),
    client=client,
    agent_id="did:agf:agt_01abc",
    action_type="exec:shell",
    resource="local-shell",
)

tool_node = ToolNode([guarded_shell])

No new adapter, no new code — the existing LangChain pattern already covers this.

Pattern 2 — Graph nodes

A plain function passed to StateGraph.add_node() isn't a BaseTool, so AGFGuardedTool doesn't apply. Use guard_node — same decorator shape as every other AGF SDK guard, applied directly to the node function:

import os
from agf.client import AGFClient
from agf.keys import build_self_signed_chain
from agf.langgraph import guard_node
from langgraph.graph import StateGraph

client = AGFClient(api_key=os.environ["AGF_API_KEY"])
AGF_AGENT_ID = "did:agf:agt_01abc"


def _refund_chain_provider(state):
    # Built fresh per call — a chain built once expires in 5 minutes.
    return build_self_signed_chain(AGF_PRIVATE_KEY_PEM, AGF_AGENT_ID, "node:issue_refund", "agf")


@guard_node(
    client,
    agent_id=AGF_AGENT_ID,
    action_type="node:issue_refund",
    chain_provider=_refund_chain_provider,
    validate_execution=True,
    report_outcome=True,
)
def issue_refund(state: RefundState) -> dict:
    result = process_refund(state["order_id"])
    return {"result": result}


graph = StateGraph(RefundState)
graph.add_node("issue_refund", issue_refund)

guard_node forwards a node's real arguments untouched — state, plus whatever keyword-only config/writer/store/runtime the node declares — so it applies to any real StateNode shape without assumptions about arity.

Handling REVIEW_REQUIRED

guard_node doesn't catch this itself — LangGraph has no native "requires approval" state to map it onto, so it propagates like any other exception the node body might raise:

from agf.exceptions import AGFDeniedError, AGFReviewRequiredError

try:
    graph.compile().invoke({"order_id": "ord_123"})
except AGFDeniedError as e:
    print(f"Denied: {e}")
except AGFReviewRequiredError as e:
    print(f"Approval required: {e.approval_request_id}")
    # Pause execution, notify reviewer, resume after approval

A freshly-enrolled agent commonly sees REVIEW_REQUIRED on its very first action, before it's accrued any trust — plan for it as a real, expected outcome, not an edge case.

The third shape — a single dispatcher node

Not every LangGraph agent gives each action its own node. Some route every tool call through one generic node that looks up the tool by name at runtime — a common pattern for hand-rolled tool-calling loops. guard_node's action_type is fixed when you apply the decorator, so wrapping the whole dispatcher can only express "may this agent call some tool," never which one.

For real per-tool authorization on a dispatcher node, call the client directly inside it instead, parameterized by the tool's actual name at call time:

async def _execute_tool(tool_call: dict):
    action_type = f"tool:{tool_call['name']}"
    decision = await client.decide(
        action_type,
        tool_call["name"],
        chain=build_self_signed_chain(AGF_PRIVATE_KEY_PEM, AGF_AGENT_ID, action_type, "agf"),
    )
    return await tools_by_name[tool_call["name"]].ainvoke(tool_call["args"])

This is the same direct-call pattern used for A2A's AgentExecutor.execute() — a decorator resolved once at wire-up time doesn't fit a call site that's inherently dynamic.

Which pattern to use

| | Tool-calling node | Graph node | Dispatcher node | |---|---|---|---| | Uses | AGFGuardedTool (existing) | guard_node | Direct client.decide() | | Action known at | Wrap time | Decoration time | Call time | | New code required | None | agf.langgraph | None — just the SDK client |

Next steps


The AGF Python SDK is open source. GitHub →