Skip to content
Agentic Control Plane

Per-user auth, policy & audit for Mastra agents

TL;DR. npm i @agenticcontrolplane/governance, wrap each tool’s execute with governed(...), bind the end user’s identity via withContext — and every Mastra tool call gets per-user identity, policy enforcement (allow / deny / redact), PII detection, and an audit trail. Same control model as Claude Code.

Mastra is a TypeScript framework for building agents with first-class tools, workflows, and multi-provider model routing. Out of the box, a production deployment shares one backend API key across every end user’s request — no per-user policy enforcement, no per-user audit trail, no way to tell downstream systems which human triggered which action.

@agenticcontrolplane/governance closes that gap. Wrap each tool’s execute callback with governed(...); bind the end user’s identity per request via withContext. Same control model as Claude Code — same /govern/tool-use endpoint, same workspace policies.

Starter · 5-minute install. No framework-specific adapter needed — the base governed() from @agenticcontrolplane/governance composes cleanly with Mastra’s createTool(). See the runnable starter, how a call is decided, or the frameworks index.

Install

npm install @agenticcontrolplane/governance @mastra/core zod

Minimal policy-enforced agent

import { Mastra } from "@mastra/core";
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import {
  configure,
  governed,
  withContext,
} from "@agenticcontrolplane/governance";

configure({ baseUrl: "https://api.agenticcontrolplane.com" });

// Wrap the execute callback with governed(name, fn). Mastra calls the
// wrapped version transparently; governance runs on every dispatch.
const lookupRecord = createTool({
  id: "lookup_record",
  description: "Look up a record by ID.",
  inputSchema: z.object({ id: z.string() }),
  outputSchema: z.object({ id: z.string(), data: z.any() }),
  execute: governed("lookup_record", async ({ context }) => {
    return { id: context.id, data: await db.records.findOne({ id: context.id }) };
  }),
});

const agent = new Agent({
  id: "my-mastra-agent",
  name: "My Mastra Agent",
  instructions: "You are an ACP-governed agent. Use the tools available.",
  model: "openai/gpt-4o-mini",
  tools: { lookupRecord },
});

const mastra = new Mastra({ agents: { agent } });

app.post("/run", async (req, res) => {
  const userToken = req.header("authorization")!.replace(/^Bearer /, "").trim();
  await withContext(
    { userToken, agentName: "my-mastra-agent", agentTier: "interactive" },
    async () => {
      const result = await mastra.getAgentById("my-mastra-agent")!.generate(req.body.prompt);
      res.json({ result: result.text });
    },
  );
});

What governed does

Wraps any async function with ACP’s pre/post hook protocol:

  1. POSTs to /govern/tool-use with the tool name, input, and the user JWT bound by withContext.
  2. If ACP denies, returns "tool_error: <reason>" — the model sees this as a tool result and adapts.
  3. If ACP allows, runs your function.
  4. POSTs the output to /govern/tool-output for audit logging and PII scanning.
  5. If ACP redacts, replaces the output with the redacted version.
  6. If ACP blocks the output post-hoc (a leaked secret pattern, for example), returns "tool_error: <reason>".

Mastra’s createTool({...execute}) receives the wrapped function — control is invisible to Mastra.

Per-tier policy

withContext binds an agentTier to the request scope:

  • interactive — human at the keyboard, permissive default.
  • subagent — invoked by another agent, no human in the immediate loop.
  • background — autonomous, no human anywhere — most restrictive.
  • api — programmatic call from your backend.

A destructive tool denied in background can be allowed in interactive. Match the tier to actual deployment reality.

Mastra-specific notes

  • No framework-specific ACP adapter needed. The base @agenticcontrolplane/governance package composes with Mastra directly. No acp-mastra shim required.
  • Mastra’s requireApproval: true on a tool gates with a stream-level approval event — orthogonal to ACP. Use for human-in-the-loop on sensitive tools; complementary to per-call ACP policy.
  • Mastra processors (inputProcessors / outputProcessors) handle message-content guardrails (PII detection, prompt-injection scanning, moderation) — complementary to tool-layer control, not a replacement.
  • No tool-dispatch middleware in @mastra/core 1.28. Inline governed(execute) is the documented way to add per-tool control.

Adding more tools

const sendEmail = createTool({
  id: "send_email",
  description: "Send an email.",
  inputSchema: z.object({
    to: z.string(),
    subject: z.string(),
    body: z.string(),
  }),
  execute: governed("send_email", async ({ context }) => {
    return await mailer.send({ to: context.to, subject: context.subject, body: context.body });
  }),
});

const agent = new Agent({
  ...,
  tools: { lookupRecord, sendEmail },
});

Wrap each execute with governed("..."). Tools outside this pattern bypass control.

Price and meter the model calls

governed is the interception plane — what your agent does. The proxy plane covers what it spends. Both run against the same gateway, and init() wires them together:

import { init } from "@agenticcontrolplane/governance";

init();                          // call before you construct any model client

Model calls now land on the same trail as tool calls, carrying real cost, tokens, and cache economics. Constructing clients explicitly instead? Pass the options rather than calling init():

import { modelClientOptions } from "@agenticcontrolplane/governance";
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic(modelClientOptions("anthropic"));

Shapes are "anthropic", "openai" (chat completions), and "openai-responses". The last two are not interchangeable — /v1 serves chat completions, /openai/v1 serves responses.

init() is all-or-nothing per provider: it sets the base URL and the key, or it leaves that provider completely alone and tells you which one it skipped. If OPENAI_BASE_URL already points at your own gateway, ACP won’t silently reroute you.

Your coverage: interception ✓ (decorated tools) · proxy ✓ (model calls).

Limitations

  • Only tools wrapped with governed are covered. Plain execute callbacks bypass control.
  • The two planes are wired separately. The decorator covers tool calls; the proxy covers model calls. Both ship, but each is its own edit — see Price and meter the model calls above. Decorating tools without repointing the model client gives you control with no cost data.
  • Pre-release. @agenticcontrolplane/governance is on 0.x. Pin exact versions.