An agent that calls tools has stopped being a text generator, it's an actor. Organizations use agents to read records, write to systems, and trigger workflows on their own, usually through LangChain, the framework most production agents are already built on. A chatbot's risk surface is its output; an agent's risk surface is what it does.
In July 2025, a Replit coding agent deleted a production database after being told explicitly not to touch it. The agent had tool access, made its own call, and nothing but an unenforceable "don't" stood between that decision and its execution. That's the failure mode Cognous's Open Control Stack exists to close. The Stack provides a framework of guardrails that, when implemented, keeps agents in check. It's split across four layers: Declare, Control, Replay, Evidence.
Declare comes first because before an agent runs, someone has to decide what it's even allowed to attempt. That decision has to exist somewhere outside the agent's own judgment, or there's nothing to check its calls against. The Agent Action Manifest is where Cognous puts it: a file that lists an agent's tools, the authority each action needs, and which actions can run on their own versus which need a human to sign off first.
Enforcing the Manifest
Of course, a manifest sitting in a JSON file doesn't stop anything by itself. Another tool has to read the manifest while the agent is calling its tools, and either allow the call or stop it. The last post built a guard that wraps each LangChain tool function, looks the call up in the manifest, and raises an exception before the tool runs if the action wasn't declared or needs review first.
That guard is already a control, just a naive one. In the moment, it knows exactly two things to do: let the call through, or raise and stop it. The moment the process moves on, that decision is gone. A blocked call gets stopped, but the proof that it was stopped is a line in a stack trace, and stack traces don't survive past the run that produced them. Ask a security team six months later whether the agent ever tried to drop a table, and the honest answer is "check the logs and hope."
That's the blind spot the Agent Control Plane closes. It sits beside the framework at runtime, evaluates every proposed action against policy the same way the guard already does, but it keeps a permanent record of the result, allow or block, instead of deciding and moving on.
From Decorator to Middleware
The guard from the last post wrapped one Python function at a time: @guard(...) had to be added to every tool a developer wanted checked. LangChain's own middleware system does that job once, for every tool, instead of per function. wrap_tool_call intercepts every tool call an agent makes in one place, so the manifest check moves into a single middleware instance rather than being repeated across every tool definition.
from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool
from agent_action_manifest import load_manifest
from agent_control_plane import RunRecorder
manifest = load_manifest("data_pipeline_agent.manifest.json")
actions_by_name = {a.action_name: a for a in manifest.actions}
# The manifest already says which declared actions can run unattended
# (review_requirement.mode == "none") and which need a human first.
# That split becomes the Control Plane's allow/block list.
allowed_actions = [
a.action_name for a in manifest.actions if a.review_requirement.mode.value == "none"
]
blocked_actions = [
a.action_name for a in manifest.actions if a.review_requirement.mode.value != "none"
]
recorder = RunRecorder()
recorder.start_run(
task="Apply routine schema maintenance to the analytics database.",
actor="data-pipeline-agent",
environment="production",
allowed_tools=allowed_actions,
blocked_tools=blocked_actions,
policy_version=manifest.manifest_id,
)
recorder.add_authority_record(actor="data-pipeline-agent", scope=["write"], source="data-platform-team")
@wrap_tool_call
def manifest_guard(request, handler):
"""Check every tool call the agent makes against the manifest before it runs."""
tool_name = request.tool_call["name"]
action = actions_by_name.get(tool_name)
proposal = recorder.propose_action(
tool_name=tool_name,
action_type=action.action_type if action else "unknown",
target="db_tool",
payload=request.tool_call["args"],
reason=f"Agent requested {tool_name}.",
)
decision, _blocked = recorder.evaluate_action(proposal)
if decision.result != "allow":
return ToolMessage(
content=f"{decision.result}: {decision.reason}",
tool_call_id=request.tool_call["id"],
)
result = handler(request)
recorder.record_reliance(
source_name=tool_name,
source_type="tool",
scope=f"Executed {tool_name}",
referenced_action_id=proposal.action_id,
)
return result
@tool
def schema_add_column(table: str, column: str, column_type: str) -> str:
"""Add a nullable column to a table."""
return f"added column {column} ({column_type}) to {table}"
@tool
def table_drop(table: str) -> str:
"""Drop a table from the database."""
return f"dropped {table}"
@tool
def schema_rename_table(table: str, new_name: str) -> str:
"""Rename a table. Not declared in the manifest."""
return f"renamed {table} to {new_name}"
agent = create_agent(
model=chat_model,
tools=[schema_add_column, table_drop, schema_rename_table],
middleware=[manifest_guard],
)
Each action now has a unique ID: request.tool_call["name"] maps directly to a manifest action, no separate lookup needed.
The Control Plane checks for a literal scope named write. The manifest uses db.schema.write. manifest_guard bridges the two names explicitly. This bridging across manifest to Control Plane is currently a manual part of the ingestion process.
What Gets Recorded
Three tools get called through a real agent run: one declared and cleared for automatic execution, one declared but requiring approval, and one never declared at all.
Running the agent against a task that touches all three produces:
added column loyalty_tier (text) to customers
block: Tool 'table_drop' is explicitly blocked in this frame.
escalate: Tool 'schema_rename_table' is not in the allowed-tools list and requires manual review.
Finished the requested schema maintenance.
All three show the Control Plane enforcing the manifest's guardrails. When the agent attempts to add a column, the Control Plane approves the action, since it's declared and deliberately allowed. When the agent attempts to drop the table, the Control Plane stops the action as blocked, since it's declared but requires human approval. When the agent tries schema_rename_table, the action is stopped too, but as an escalation rather than a block: nobody declared it at all, so the Control Plane's default posture kicks in and sends it for human review instead of assuming it's safe. With a human in the loop, a decision can be made on the table renaming. The manifest can be edited to allow or block the agent's access to schema_rename_table, ensuring clean future runs.
Post-Run Analysis
After the run completes, the exported run record shows the difference between the Control-Plane-backed guard and the original exception-raising guard. A run record isn't a log someone has to remember to add or go hunting for later. Every LangChain agent built on this middleware produces a run record automatically, on every run:
actions 3
decisions 3
blocked 1
reliance 1
bundle f97c5f38-01bf-4ce4-8934-edc5a86d4abe
The blocked record itself is structured and timestamped, not a caught exception:
{
"blocked_id": "96c0ab4c-ca8a-42fc-b7bf-c8ed6d87dc8e",
"action_id": "fa10af91-b3eb-4892-a7bf-a09eabfe29a6",
"run_id": "57604579-477d-43f0-a612-7b1f393ceb8f",
"reason": "Tool 'table_drop' is explicitly blocked in this frame.",
"policy_name": "blocked_tool_policy",
"blocked_at": "2026-09-11T02:58:45.594329+00:00"
}
Six months from now, the answer to "did the agent ever try to drop a table" isn't a memory or a grep through application logs. It's this record, sitting in an exported run next to the one action that actually executed.
Every LangChain agent that calls tools faces the same choice this post started with: enforce nothing and hope, or wire in guardrails before the agent gets access. Skip that choice, and the risk isn't abstract. It's a production database gone the way Replit's did, and a "my agent destroyed my SaaS" story of your own.
The Agent Action Manifest and the Agent Control Plane are two of the four layers Cognous built so that choice doesn't have to be made from scratch. They're open source, and the Open Control Stack repo has both, with examples to build an integration like this one from. Cloning the repo gives a LangChain agent solid guardrails and a record of every decision the agent makes.
Top comments (2)
The wrapper is the part I'd want to pull on. Wrapping each LangChain tool function puts the guard in the call path for every call that goes through the wrapper, which is a narrower claim than it sounds. It holds until something reaches the same effect another way — a raw client the agent can still import, a second service that writes the same row, a retry path built before the manifest existed.
Payments deals with this by refusing to make the caller trustworthy. The service performing the effect requires evidence the authorisation happened, and re-verifies that evidence itself rather than trusting whoever passed it along. So the guard isn't only in front of the tool. The effect side won't act without its output.
Concretely for the Stack: is Evidence checked at the point of effect, or recorded after it? Those give very different guarantees once the manifest and the code drift.
The key distinction here is between a guard decision and durable evidence that the proposed action never crossed the commit boundary. I would give every proposal an ID before middleware runs, record policy version and normalized arguments, then require exactly one terminal state: blocked-before-execution, committed, or outcome-unknown. “Blocked” in a stack trace alone is too easy to lose or misread later.