Skip to content

Mastra agent guard

Mastra agents run authored tools, processors, and MCP/workspace/toolset tools. Arcjet Guard sits at those boundaries so a remote policy can allow or deny the action before a side effect runs.

What is Arcjet? Arcjet is the AI agent runtime security platform. Discover the agents running in your organization, enforce policy across every action, prompt, and tool call, and keep the evidence to prove what happened. Detect prompt injection, authorize agent tool calls, redact PII, and block bots and abuse.

Use protect() on HTTP routes. Use the helpers on this page for agent tools and other actions that have no HTTP request.

You need an Arcjet account and an ARCJET_KEY. Launch one client at module scope and reuse it.

Framework wrappers take an action string such as email.sent. That slug selects the matching remote policy and names the event in the Arcjet Console. Direct guard() calls use the field name label for the same slug. Don’t pass label to a wrapper such as guardTool().

You can submit SDK rules in code, rely on a published remote policy, or combine both. For more information about the decision model, see Agent guards.

Vercel AI SDK, LangChain, CrewAI, Eve, and LangGraph wrappers are on Framework integrations.

Install the Guard SDK and Mastra:

Terminal window
npm install @arcjet/guard @mastra/core

Import helpers from the versioned path @arcjet/guard/mastra/v1. There is no unversioned alias. @arcjet/guard/mastra does not resolve. The Mastra integration requires @mastra/core 1.x and Node.js >=22.21.0 <23 || >=24.5.0.

Launch one client at module scope:

import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });

The integration exposes four surfaces:

  • guardTool() wraps a Mastra createTool({ execute }). On DENY the tool never runs. The model receives a structured ArcjetDenialResult ({ arcjetDenied, reason, message, retryable, retryAfterSeconds? }). Don’t throw. A throw becomes a generic tool error and drops the fields.
  • guardProcessor() is a Mastra Processor for inputProcessors / outputProcessors. On DENY, processInput / processInputStep call abort() (tripwire). processInputStep screens later agentic steps so tool continuations cannot skip the inbound gate.
  • guardHooks() provides beforeToolCall / afterToolCall for MCP / workspace or toolset tools that were not authored through guardTool. beforeToolCall can return { proceed: false, output } on DENY. afterToolCall is observe-only.
  • mastraAgentContext() reads MASTRA_THREAD_ID_KEY, then resource, then workflow.runId. It never mints an ID.

There is no guardInbound. Mastra channels already hit processInput. There is no guardApproval. Mastra requireApproval is human-in-the-loop (HITL) confirmation, not policy.

Never call createAgentContext inside a Mastra callback. Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.

The Mastra integration skill ships with @arcjet/guard. After npm install, copy or symlink it into your coding agent’s skills directory:

Terminal window
mkdir -p ~/.claude/skills
cp -r node_modules/@arcjet/guard/skills/integrate-arcjet-guard-mastra ~/.claude/skills/

Then ask the agent to add Arcjet Guard to this Mastra project. In Claude Code, run /integrate-arcjet-guard-mastra. The skill source is integrate-arcjet-guard-mastra.

OptionHelpersDescription
actionAllGuard label and capture name. A string, or for hooks a function of { toolName }.
rulesAllSDK rules, or a function of processor { text } or tool input. Omit to submit none.
actorAllTrusted identity, or a function resolved per call. Take it from authenticated application state, never from a model-produced argument.
inputsAllNamed values built with policyInput.server.* or policyInput.local.*, or a function resolved per call.
metadataAllNested JSON, or a function of the same input as rules.
onGuardErrorAll"deny" (default) or "allow".

Mastra channels already run through processInput. There is no guardInbound. Screen for prompt injection with guardProcessor in inputProcessors.

On DENY, processInput / processInputStep call abort() and Mastra raises a tripwire. Use a separate action name for outbound text on outputProcessors. processInputStep screens later agentic steps so a tool continuation cannot skip the inbound gate.

Helpers default to onGuardError: "deny". "allow" is a legitimate choice on the inbound processor, because failing closed there stops the agent answering during an outage.

import { Agent } from "@mastra/core/agent";
import { guardProcessor } from "@arcjet/guard/mastra/v1";
import { detectPromptInjection } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const inbound = guardProcessor(arcjet, {
action: "message.received",
rules: ({ text }) => [detectPromptInjection()(text)],
});
const outbound = guardProcessor(arcjet, {
action: "message.completed",
rules: ({ text }) => [detectPromptInjection()(text)],
});
export const agent = new Agent({
id: "support-agent",
name: "support-agent",
instructions: "Help the user.",
model: "openai/gpt-4o",
inputProcessors: [inbound],
outputProcessors: [outbound],
});

Mastra requireApproval pauses for a human. It asks a person rather than evaluating a policy, so it is not a Guard enforcement point. For more information, see Human approval is not a policy gate.

Use guardTool for authored tools, or guardHooks for unwrapped tools.

On DENY the tool’s execute never runs. The model receives { arcjetDenied: true, reason, message, retryable, retryAfterSeconds? }. Don’t throw. A throw becomes a generic tool error and drops the fields. Scan free-text args (a note, reason, or body). An opaque orderId doesn’t trip email, phone, card, or IP detection, so don’t pass it to localDetectSensitiveInfo.

import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/mastra/v1";
import { tokenBucket, localDetectSensitiveInfo } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
// Factory then text – same shape as `detectPromptInjection()(text)`.
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
export const lookupOrder = guardTool(
arcjet,
createTool({
id: "lookup-order",
description: "Look up an order by ID",
inputSchema: z.object({
orderId: z.string(),
note: z.string(),
}),
async execute({ orderId, note }) {
return { orderId, note, status: "shipped" };
},
}),
{
action: "order.looked-up",
rules: (input) => [
lookupLimit({ key: input.orderId, requested: 1 }),
detectPii(input.note),
],
},
);

Unlike Eve (observe-only hooks), Mastra beforeToolCall can stop a call. Use guardHooks for MCP, workspace, or toolset tools you did not pass through guardTool. Applying both to the same authored tool double-calls the guard.

beforeToolCall returns { proceed: false, output } on DENY so those tools never execute. afterToolCall is observe-only.

import { guardHooks } from "@arcjet/guard/mastra/v1";
import { tokenBucket } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const mcpLimit = tokenBucket({
bucket: "mcp-access",
refillRate: 20,
intervalSeconds: 60,
maxTokens: 20,
});
export const hooks = guardHooks(arcjet, {
action: ({ toolName }) => `${toolName}.invoked`,
rules: ({ toolName }) => [mcpLimit({ key: toolName, requested: 1 })],
});

Pass hooks to the Agent constructor (or to generate / stream).

Mastra helpers default to onGuardError: "deny". If Guard cannot be evaluated, the tool does not run and inbound processInput aborts the turn. "allow" is a legitimate choice on the inbound processor because failing closed there stops the agent answering during an outage.

Set MASTRA_THREAD_ID_KEY / MASTRA_RESOURCE_ID_KEY on RequestContext before generate / stream. mastraAgentContext() reads them. It never calls createAgentContext. Preference order is thread, then resource, then workflow.runId. If none is a valid 1-256 printable-ASCII string, the call is uncorrelated rather than joined to a generated ID.

import {
RequestContext,
MASTRA_THREAD_ID_KEY,
MASTRA_RESOURCE_ID_KEY,
} from "@mastra/core/request-context";
const requestContext = new RequestContext();
requestContext.set(MASTRA_THREAD_ID_KEY, conversationId);
requestContext.set(MASTRA_RESOURCE_ID_KEY, userId);
await agent.generate(message, { requestContext });
  • There is no guardInbound. Channels already hit processInput.
  • There is no guardApproval. requireApproval is human HITL confirmation, not policy.
  • Don’t call createAgentContext inside a Mastra callback.
  • Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Don’t throw from guardTool to signal a denial. Return the payload so the model can inspect it.
  • Don’t import @arcjet/guard/mastra. The path is @arcjet/guard/mastra/v1.

guardTool takes actor and inputs, each as a value or a function resolved per call. A resolver receives the parsed tool input, then the agent context.

const sendEmail = guardTool(arcjet, sendEmailTool, {
action: "email.sent",
actor: currentUser.id,
inputs: ({ recipient, body }) => ({
recipient: policyInput.server.string(recipient),
allowed_recipients: policyInput.server.stringList(
currentUser.allowedRecipients,
),
body: policyInput.local.string(body),
}),
});

Build every input explicitly with policyInput.server.* or policyInput.local.*. Plain values are rejected, and the adapter never discovers arguments for you.

guardHooks resolvers receive { toolName, input }, and guardProcessor resolvers receive { text, messages }, each followed by the Mastra request context.

The policy these calls feed declares recipient as a SERVER string, allowed_recipients as a SERVER string list, and body as a LOCAL string, then denies a recipient that isn’t on the list:

package arcjet.guard
import rego.v1
deny contains "external-recipient" if {
not input.values.recipient in input.values.allowed_recipients
}

Map only what a policy needs. Take the actor and any allow list from authenticated application state, never from the model’s arguments: a policy can be conditioned on the actor, so a model that controls it can leave its own policy scope.

For the names, kinds, and exposures a policy declares, see Policy contract. For more worked policies, see Policy examples.

  • Prompt injection on every step: guardProcessor in both inputProcessors and, if you want later tool continuations screened, rely on processInputStep (the same processor object handles both).
  • Rate limit plus PII on a tool: tokenBucket keyed on a trusted ID, plus localDetectSensitiveInfo() on a free-text note.
  • MCP tools you did not wrap: guardHooks with an action function of { toolName } that returns a past-tense slug per tool.