Skip to content

Vercel Eve agent guard

Vercel Eve agents call authored tools, OpenAPI connections, and MCP connections. 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 runtime security platform that ships with your code. Enforce budgets, stop prompt injection, detect bots, and protect personal information with Arcjet's AI security building blocks.

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, and LangGraph wrappers are on Framework integrations.

Install the Guard SDK and Eve:

Terminal window
npm install @arcjet/guard eve

Import helpers from the versioned path @arcjet/guard/vercel-eve/v0. There is no unversioned alias. @arcjet/guard/vercel-eve and @arcjet/guard/vercel-eve/v1 do not resolve. Eve is pre-1.0, so the segment is v0. eve is an optional peer (>=0.34.0 <1). The Eve integration requires Node.js 24 or later.

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 an authored tool when you need the execution outcome. On DENY it throws ArcjetDeniedError. Eve projects that as a failed action.result. Returning the ArcjetDenialResult payload is opt-in with onDeny: "result" so an outputSchema is not silently violated.
  • guardApproval() gates a tool or a connection before it runs. Set response to authorize who may approve a parked request.
  • guardInbound() screens inbound text on a channel.
  • arcjetHooks() observes agent lifecycle events.

guardTool(), guardApproval(), and arcjetHooks() correlate by session ID. guardInbound() runs before the session exists, so you pass an explicit correlationId.

Never call createAgentContext inside an Eve callback. The session ID is already the run identity. Use eveAgentContext only when you need the context object explicitly.

The Eve 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-eve ~/.claude/skills/

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

Use guardTool() when you own execute and need the outcome. On DENY the helper throws ArcjetDeniedError. Eve projects that as a failed action.result. Returning the payload is opt-in with onDeny: "result" so an outputSchema is not silently violated. Prefer guardApproval() when you only need to gate the call and want the model to read a denied status.

import { defineTool } from "eve/tools";
import { z } from "zod";
import { tokenBucket } from "@arcjet/guard";
import { guardTool } from "@arcjet/guard/vercel-eve/v0";
import { arcjet } from "../arcjet.js";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
export default guardTool(
arcjet,
defineTool({
description: "Look up an order by ID",
inputSchema: z.object({ orderId: z.string() }),
async execute(input) {
return { orderId: input.orderId, status: "shipped" };
},
}),
{
action: "order.looked-up",
rules: (input) => [lookupLimit({ key: input.orderId, requested: 1 })],
},
);

defineDynamic tools cannot be wrapped with guardTool(). Gate those with guardApproval() instead.

OptionHelpersDescription
actionAllGuard label and capture name. Use resource.verb in the past tense.
rulesAllSDK rules, or a function of the tool input or Eve context. Omit to submit none. The Guard call still happens.
metadataguardTool, guardApproval, guardInboundNested JSON merged into the decision.
correlationIdguardInboundRequired. Use a stable conversation ID, not crypto.randomUUID().
onGuardErrorAll"deny" (default) or "allow".
onAllowguardApproval"user-approval" parks the call for a human after the request-time gate.
responseguardApprovalAuthorizes who may approve a parked request.
onDenyguardToolReshape the denial, or pass "result" to return ArcjetDenialResult instead of throwing.

guardInbound() on an Eve channel (agent/channels/*.ts) screens inbound text before the agent sees it. This is the only place a turn can be declined before it starts.

On DENY, return an HTTP error and don’t start the agent. Pass an explicit correlationId. Use a value the app already has, such as a conversation ID. Don’t generate a per-request ID. Reuse that same value with args.from() so the inbound decision can join the session later.

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

import { defineChannel, POST } from "eve/channels";
import { detectPromptInjection } from "@arcjet/guard";
import { guardInbound } from "@arcjet/guard/vercel-eve/v0";
import { arcjet } from "../arcjet.js";
export default defineChannel({
routes: [
POST("/webhook", async (req, args) => {
const body = (await req.json()) as {
message?: string;
conversationId?: string;
};
const { message, conversationId } = body;
if (!message || !conversationId) {
return new Response(JSON.stringify({ error: "Missing fields" }), {
status: 400,
});
}
// correlationId must be stable across retries so the inbound decision
// correlates with the session. Don't use crypto.randomUUID() here.
const correlationId = conversationId;
const verdict = await guardInbound(arcjet, message, {
rules: [detectPromptInjection()(message)],
action: "message.received",
correlationId,
});
if (!verdict.allowed) {
// `verdict.outcome` is "DENY" | "UNAVAILABLE" – a policy denial versus
// an Arcjet outage. Answer them differently: a denial is the caller's
// problem (403), an outage is ours (503, which is also retryable).
// The rule category that fired lives on `verdict.decision?.reason`,
// the same place as every other Arcjet surface. `verdict.reason` is a
// deprecated alias for `outcome`.
return new Response(
JSON.stringify({
error: verdict.message,
outcome: verdict.outcome,
reason: verdict.decision?.reason ?? "UNKNOWN",
}),
{ status: verdict.outcome === "UNAVAILABLE" ? 503 : 403 },
);
}
const session = await args.from(correlationId).send(message, {
auth: null,
});
return new Response(
JSON.stringify({ success: true, sessionId: session.id }),
{ headers: { "Content-Type": "application/json" } },
);
}),
],
});

An Eve agent can have zero authored tools. OpenAPI and MCP connections (agent/connections/*.ts) have no local execute. The only enforcement point is guardApproval() on the connection’s approval field. There is no middleware or hook alternative. defineDynamic tools also have no wrappable execute. Gate those with guardApproval() on the tool’s approval field.

On request-time DENY, Eve returns { type: "denied", reason } the model can read. That differs from guardTool(), which throws ArcjetDeniedError unless you pass onDeny: "result". Human-in-the-loop (HITL) clients answer a parked request with cancel, not deny.

approval is one field. It can be a function or { request, response }. Request-time options live on GuardApprovalPolicy: action, plus optional rules, metadata, onGuardError, onAllow, and onDeny. Omit response and guardApproval() returns Eve’s function form (ApprovalPolicy). Set response and it returns { request, response } (ApprovalConfiguration), where request is Eve’s ApprovalPolicy function. Don’t compose the returned value with Eve’s always(), once(), or never(). To also require a human after the request-time gate, use onAllow: "user-approval". The response policy authorizes the responder.

The following connection omits response, so guardApproval() returns Eve’s function form:

import { defineOpenAPIConnection } from "eve/connections";
import { tokenBucket } from "@arcjet/guard";
import { guardApproval } from "@arcjet/guard/vercel-eve/v0";
import { arcjet } from "../arcjet.js";
const apiLimit = tokenBucket({
bucket: "api-access",
refillRate: 30,
intervalSeconds: 60,
maxTokens: 30,
});
export default defineOpenAPIConnection({
description: "Orders API",
spec: "https://api.example.com/openapi.json",
approval: guardApproval(arcjet, {
action: "orders-api.read",
rules: (ctx) => [apiLimit({ key: ctx.session.id, requested: 1 })],
}),
operations: {
allow: ["GetOrder"],
},
});

Set response when you need to authorize who may approve a parked request. The response object is a GuardApprovalResponsePolicy with action and optional rules, metadata, and onGuardError. Rules and metadata run against Eve’s ApprovalResponseContext, which includes responder, request.toolName, request.toolInput, request.callId, request.requestId, and session.

Response-time ALLOW returns { status: "allowed" }. If the response policy denies the responder, or if Arcjet is unreachable and onGuardError is "deny" (the default), the response policy returns { status: "rejected", reason } and the approval stays pending. A rejection does not deny the tool.

The following example parks the call for a human after the request-time gate, then rate limits the responder:

import { defineOpenAPIConnection } from "eve/connections";
import { tokenBucket } from "@arcjet/guard";
import { guardApproval } from "@arcjet/guard/vercel-eve/v0";
import { arcjet } from "../arcjet.js";
const sessionLimit = tokenBucket({
bucket: "weather-session",
refillRate: 5,
intervalSeconds: 60,
maxTokens: 5,
});
const approverLimit = tokenBucket({
bucket: "weather-approver",
refillRate: 5,
intervalSeconds: 60,
maxTokens: 5,
});
export default defineOpenAPIConnection({
description: "Weather API",
spec: "https://api.example.com/openapi.json",
approval: guardApproval(arcjet, {
action: "weather.fetched",
rules: (ctx) => [sessionLimit({ key: ctx.session.id, requested: 1 })],
onAllow: "user-approval",
response: {
action: "weather.approved",
rules: (ctx) => [
approverLimit({ key: ctx.responder.principalId, requested: 1 }),
],
},
}),
operations: {
allow: ["GetForecast"],
},
});

arcjetHooks() and Eve hooks are observe-only. Handlers return void and cannot reject a turn. If the request is to block prompt injection, the answer is guardInbound at the channel, not a hook.

Hooks emit lifecycle captures joined to the session ID. The eve.session-started event carries both inbound and session correlation. Use hooks for audit trails, not enforcement.

import { defineHook } from "eve/hooks";
import { arcjetHooks } from "@arcjet/guard/vercel-eve/v0";
import { arcjet } from "../arcjet.js";
export default defineHook(arcjetHooks(arcjet));
  • Prompt injection on a channel: guardInbound with detectPromptInjection()(message) before args.from().send().
  • Rate limit a connection: guardApproval with a tokenBucket keyed on ctx.session.id.
  • Human approval after policy: onAllow: "user-approval" plus a response policy that rate limits ctx.responder.principalId.
  • Observe without blocking: arcjetHooks(arcjet) on defineHook.