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
Section titled “Install”Install the Guard SDK and Eve:
npm install @arcjet/guard eveImport 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! });Helpers
Section titled “Helpers”The integration exposes four surfaces:
guardTool()wraps an authored tool when you need the execution outcome. OnDENYit throwsArcjetDeniedError. Eve projects that as a failedaction.result. Returning theArcjetDenialResultpayload is opt-in withonDeny: "result"so anoutputSchemais not silently violated.guardApproval()gates a tool or a connection before it runs. Setresponseto 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:
mkdir -p ~/.claude/skillscp -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.
Wrap an authored tool
Section titled “Wrap an authored tool”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.
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | All | Guard label and capture name. Use resource.verb in the past tense. |
rules | All | SDK rules, or a function of the tool input or Eve context. Omit to submit none. The Guard call still happens. |
metadata | guardTool, guardApproval, guardInbound | Nested JSON merged into the decision. |
correlationId | guardInbound | Required. Use a stable conversation ID, not crypto.randomUUID(). |
onGuardError | All | "deny" (default) or "allow". |
onAllow | guardApproval | "user-approval" parks the call for a human after the request-time gate. |
response | guardApproval | Authorizes who may approve a parked request. |
onDeny | guardTool | Reshape the denial, or pass "result" to return ArcjetDenialResult instead of throwing. |
Inbound screening
Section titled “Inbound screening”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" } }, ); }), ],});Gate connections with no authored tools
Section titled “Gate connections with no authored tools”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"], },});Hooks cannot enforce policy
Section titled “Hooks cannot enforce policy”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));Common patterns
Section titled “Common patterns”- Prompt injection on a channel:
guardInboundwithdetectPromptInjection()(message)beforeargs.from().send(). - Rate limit a connection:
guardApprovalwith atokenBucketkeyed onctx.session.id. - Human approval after policy:
onAllow: "user-approval"plus aresponsepolicy that rate limitsctx.responder.principalId. - Observe without blocking:
arcjetHooks(arcjet)ondefineHook.