Govern an agent you already have
You do not have to write your agent around Watchlight. A plugin governs a framework agent: it authorizes before the call executes, and it does not change how the agent is built.
pip install 'watchlight[langgraph]' # or [pydantic-ai], [claude-agent]
from watchlight.langgraph import governed_plugin # .pydantic_ai / .claude_agent
plugin = governed_plugin("watchlight.policy.json") # in-process, zero infra
async with await plugin.start_run("research-agent") as handle:
if not await handle.authorize_action("read", "tool/web_search"):
raise PermissionError("denied before it executed")
... # your tool runs, every action recorded to .watchlight/audit.jsonl
This is the same plugin you ship to production. Going there is one environment
variable — set WATCHLIGHT_APDP_URL and the identical code authorizes against a
running policy service.
The backend comes from the environment
governed_plugin chooses the backend itself: the networked policy service when
WATCHLIGHT_APDP_URL is set, the in-process engine when it is not. A call site
cannot replace that choice. Since 0.13.0, passing governance= or apdp_url=
raises TypeError naming the keyword, before anything is built:
plugin = governed_plugin("watchlight.policy.json") # WATCHLIGHT_APDP_URL set → networked; unset → in-process
To run a plugin against a backend object of your own, construct the framework
plugin directly rather than through governed_plugin. A plugin built this way
uses the governance= object you pass and ignores WATCHLIGHT_APDP_URL:
| Module | Construct instead |
|---|---|
watchlight.langgraph | watchlight_langgraph.WatchlightLangGraphPlugin(governance=...) |
watchlight.pydantic_ai | watchlight_pydantic_ai.WatchlightPydanticAIPlugin(governance=...) |
watchlight.claude_agent | watchlight_claude_agent.WatchlightClaudeAgentSDKPlugin(governance=...) |
If the extra is not installed, the ImportError says which one to install
(langgraph, pydantic-ai or claude-agent). Any other error raised while the
plugin imports surfaces as itself.
Runnable agents for all three frameworks are in
examples/.
Framework-created sub-agents inherit framework tools
Some frameworks auto-register a general-purpose sub-agent with the parent's tool
list. Watchlight cannot narrow a scope the framework never asks it to authorize.
In deepagents, pass an explicit same-named spec as subagents to
create_deep_agent(...) so the default is not added:
subagents=[{
"name": "general-purpose",
"description": "General-purpose agent without parent custom tools.",
"system_prompt": "Complete the delegated task.",
"tools": [],
}]
An empty tools list does not remove tools added by the framework's middleware,
including deepagents' filesystem tools. Configure those separately when the
sub-agent needs tighter confinement.
Per-call terms go on the run handle
governed_plugin() is constructor wiring. The terms of a single call belong to
the handle:
await handle.authorize_action(
"read_ticket", "tool/read_ticket",
principal=f'User::"{user_id}"', # who the call is for
context={"caller": caller, "owner": owner}, # what a policy reads
)
permit(principal, action == Action::"read_ticket", resource)
when { context has owner && context has caller && context.caller == context.owner };
principal defaults to the agent that runs, so existing calls are unchanged.
Write has before the comparison: a rule that reads a key nothing supplied
falls through to Deny.
Pass principal=, context= or resource= to governed_plugin() and it
refuses them by name rather than dropping them silently — a term you believe is
reaching the decision, and is not, is a denial that explains nothing.
Name the entity type
User::"u-1" is not matched by a policy naming Agent::"u-1". A bare u-1 is
not inert: it matches that id under User, Agent, Group or Role, and when
it matches more than one, an allow beats a forbid — the opposite of Cedar's
usual rule. Always write the type. The action is always type Action; a policy
that gives it another type is rejected.
A plugin run that names no principal is attributed to the agent it runs as,
which is a generated Agent::"<uuid>". Gate those policies on the action, the
resource, or principal is Agent — never on a principal id you cannot know in
advance.
Confine a sub-agent
When a governed agent spawns children, hand each one a strict subset of its own authority. That is scope attenuation, and the engine enforces it in-process.
Worth knowing
- The plugin's own
tenant_idis authoritative and overrides one passed on a call. - Every action lands in
.watchlight/audit.jsonl. Runwatchlight devbeside the agent to watch the decisions and the attenuation tree. - All three integrations are built on one contract inside the package, so they
govern identically. That contract,
watchlight.integrations, is internal and may change in any release; importwatchlight.<framework>.governed_plugin, which is stable. To add a framework, see Adding a framework integration in the contributing guide. - For a policy that must name the person a call is made for, govern that
call with
govern.tool(..., principal=...)orgovern.authorize(principal=...)alongside the plugin. See using the governor.
Next
- Govern an MCP server — when the server, not the agent, is the thing you cannot rewrite.
- TypeScript / Node —
governedHooks()andgovernTool()for the Node frameworks. - The identity model — what
principal, the actor and the actor chain mean on a run handle.