Integrate Arcjet Guard into a Mastra agent
@arcjet/guard's Mastra v1 namespace wraps the agent's existing Arcjet
client. It never talks to the Arcjet API itself. Four surfaces, one
decision rule:
- An authored tool (
createTool({ execute })) →guardTool(). DENY is a structured tool result. Do not throw. - Inbound / outbound text (
inputProcessors/outputProcessors) →guardProcessor().processInput+abort()on DENY raises a tripwire.processInputStepscreens later agentic steps (tool continuations). Channels already hitprocessInput, so there is noguardInbound. - MCP / workspace / toolsets you did not wrap →
guardHooks().beforeToolCallcan return{ proceed: false, output }. - Correlation →
mastraAgentContext()readsMASTRA_THREAD_ID_KEY, then resource, then run. It never mints a new id.
Mastra requireApproval is human HITL, not policy — same trap as
Google ADK requireConfirmation. There is no
guardApproval. Do not also wrap these tools with
@arcjet/guard/vercel-ai/v7 or @arcjet/guard/claude-managed-agents/v0.
Questions to ask the human first
Ask only what you cannot infer from the code; suggest defaults.
- Which tools are risky (external side effects, irreversible, spends
money, sends messages)? Those get
guardTool. Purely informational tools can be left unguarded or gated with norules. - What limits? (e.g. "10 lookups/min per order" →
tokenBucket.) - Who is the user for metadata — an opaque user/tenant ID (never PII)?
Default: Mastra's resource id (
MASTRA_RESOURCE_ID_KEY). - Is an Arcjet outage unacceptable? Every helper defaults to
onGuardError: "deny". Ask explicitly about the inbound processor: failing closed there means the agent stops answering for the duration of the outage, so"allow"is a routine and legitimate choice at that one call site.
The six things readers get wrong
- There is no
guardInbound. Mastra channels already run throughprocessInput. Screen prompt injection onguardProcessorininputProcessors. - There is no
guardApproval. MastrarequireApprovalis a human in-the-loop pause, not a policy gate. UseguardToolorguardHooks. - The import path is versioned and there is no alias.
@arcjet/guard/mastra/v1.@arcjet/guard/mastradoes not resolve. - Correlation is read, never minted. Do not call
createAgentContextinside a Mastra callback — that generates a second id and splits the Sequence.mastraAgentContextreads thread / resource / run and omitscorrelationIdwhen none of those is a valid id. - Do not double-wrap with
@arcjet/guard/vercel-ai/v7. Mastra tools arecreateTool, not AI SDKtool().guardToolthrows if the tool already carries the Arcjet protection brand. - A denial from
guardToolis a structured result, not a throw. Prefer omittingoutputSchemaon guarded tools, or verify the schema acceptsArcjetDenialResult. IfonDenythrows, the tool still does not run and the model still receives the default denial object.
Step 1: Install and find the guard client
Install @arcjet/guard (required), plus @mastra/core (optional peer,
needed for @arcjet/guard/mastra/v1). Always use the versioned path:
@arcjet/guard/mastra/v1 resolves; @arcjet/guard/mastra throws
ERR_PACKAGE_PATH_NOT_EXPORTED.
npm install @arcjet/guard @mastra/core
If the agent has no guard client yet, launch one once at module scope:
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
Step 2: Gate authored tools
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/mastra/v1";
import { tokenBucket, localDetectSensitiveInfo, policyInput } 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)`.
// Scan free-text args (a note, reason, body). An opaque `orderId` will
// not trip EMAIL / phone / card / IP, so do not pass it here.
const detectPii = localDetectSensitiveInfo();
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",
actor: userId,
inputs: (input) => ({
orderId: policyInput.server.string(input.orderId),
}),
rules: (input) => [
lookupLimit({ key: input.orderId, requested: 1 }),
// Right: factory already bound above; pass free text, not orderId.
detectPii(input.note),
],
},
);
- Omit
rulesto submit none. The guard call still happens. - Optional
actorandinputs(static, or a resolver over this adapter's native call — parsed input plus trusted runtime/context) are forwarded on the guard call so a remote policy that declares those names can evaluate. Build each input withpolicyInput. - On DENY the tool's
executenever runs. The model receives{ arcjetDenied: true, reason, message, retryable }. - Default
onGuardError: "deny"blocks the tool if Arcjet is unreachable.
Step 3: Screen inbound (and optional outbound) text
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],
});
- On DENY,
processInput/processInputStepcallabort()and Mastra raises a tripwire. Ifabort()were to return, the processor still throws so the turn cannot fail open. - The same processor implements
processOutputResultso it can sit onoutputProcessorsas well. Use a separate action name for outbound. - Default
onGuardError: "deny"— if the guard cannot be evaluated, the turn is aborted. Use"allow"when the human cost of rejecting a legitimate message exceeds the security cost of an outage.
Step 4: Gate tools you did not wrap
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).
beforeToolCall returns { proceed: false, output } on DENY so MCP /
workspace / toolset calls never execute. afterToolCall is observe-only.
Use this for tools you did not pass through guardTool. Applying both
to the same authored tool double-calls the guard.
Step 5: Correlation
Set Mastra's reserved keys on RequestContext before generate / stream.
mastraAgentContext reads them; it never calls createAgentContext.
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 });
Preference order: thread id, then resource id, then workflow.runId. If
none is a valid 1–256 printable-ASCII string, the call is uncorrelated
rather than joined to a generated id nobody has.
Verify the integration
npm run typecheckpasses.- Exercise inbound PI, a tool deny, PII on args, a rate limit, and fail-closed (an unreachable guard).
- Confirm in the Arcjet dashboard that decisions share the thread id as their correlation id.
- Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
A full working demo lives in
arcjet/examples mastra-agent
(lands with arcjet/examples#193).
Do not add an example under examples/ in the JS SDK repo.
Note: capture events are fire-and-forget and batched, so events can lag the decisions they accompany by a few seconds. A dropped event is diagnosed, never thrown.