Integrate Arcjet Guard into a Vercel Eve agent
@arcjet/guard's Vercel Eve v0 namespace wraps the agent's existing Arcjet
client. It never talks to the Arcjet API itself. Four surfaces, one decision
rule:
- An authored tool (
agent/tools/*.ts) →guardTool()if you need its execution outcome at the call site, orguardApproval()if you only need to gate it. OnlyguardToolobserves success or failure. - A connection's operations (
agent/connections/*.ts) →guardApproval()on the connection'sapprovalfield. There is no localexecute; nothing else can gate these. - An inbound message (
agent/channels/*.ts) →guardInbound()to screen text before the agent sees it. This is the only place a turn can be declined before it starts. - Everything else →
arcjetHooks()to observe agent lifecycle events. Hooks are observe-only by design and cannot block.
The three in-session helpers correlate by session id, so their decisions land
on one Sequence. guardInbound runs before the session exists and correlates
by whatever identity the channel has, so its decision lands on a second
Sequence. arcjetHooks emits an eve.session-started record carrying both, which
is what lets you pivot from one to the other. This is not hosted Claude
Managed Agents (@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 and connections are risky (external side effects,
irreversible, spends money, sends messages)? Those get gates. Purely
informational tools can be left unguarded or gated with no
rules. - What limits? (e.g. "10 lookups/min per order" →
tokenBucket; "5 integrations/hour" →slidingWindow.) - Who is the user for metadata — an opaque user/tenant/installation ID (never PII)? Default: the Eve principal from the session context.
- Is an Arcjet outage unacceptable? Should the agent be blocked if the guard
is unavailable? Every helper defaults to
onGuardError: "deny", including the channel. Ask explicitly about the channel anyway: failing closed there means the agent stops answering entirely 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
State plainly why each applies to Eve, not other frameworks:
Hooks cannot reject a turn. Their handlers return
void. If the request is "block prompt injection", the answer isguardInboundat the channel, not a hook. Hooks are for audit trails, not enforcement.The import path is versioned and there is no alias.
@arcjet/guard/vercel-eve/v0.@arcjet/guard/vercel-evedoes not resolve, and neither does/v1. The segment tracks Eve's major, and Eve is pre-1.0, so it getsv0. When Eve ships 1.0, a/v1path will be added alongside this one.Correlation is not passed; it is read from the session. Never call
createAgentContextinside an Eve callback — the session id already is the run identity, and generating a second one splits the Sequence.eveAgentContextis exported for callers who need the context explicitly. Three of the four helpers call it themselves;guardInboundruns before the session exists, so it takes an explicitcorrelationIdinstead.approvalis one field per tool or connection. It can be a function (request-time only) or{ request, response }. You still cannot assignalways()/once()/never()fromeve/tools/approvalalongsideguardApproval— the slot holds one value.onAllow: "user-approval"is how you require a human after the request-time gate. The optionalresponsepolicy is how you authorize who may approve the parked request. A rejected response leaves the approval pending; it does not deny the tool. HITL clients answer withcancel, notdeny. Request-time denials are still{ type: "denied" }. Same trap as Google ADKrequireConfirmation: HITL is not a policy gate.defineDynamictools are not covered. Eve's compiler hoists a dynamic tool's inlineexecuteto a module-scope step function, so a wrapper is not visible to it. Gate those withguardApproval()instead — the approval gate runs at decision time.A denial from
guardToolthrows (Eve projects it as a failedaction.result), whereas a denial fromguardApprovalis adeniedstatus carrying a reason the model reads. Prefer the gate when you want the model to adapt; useguardToolwhen you need the outcome.
Step 1: Install and find the guard client
Install @arcjet/guard (required), plus eve (optional peer, needed for
@arcjet/guard/vercel-eve/v0 and must be on Node 24+). Every agent helper lives
on that one path. Always use explicit versions: @arcjet/guard/vercel-eve/v0
resolves, but @arcjet/guard/vercel-eve does not — omitting the version is
deliberate (it prevents silent API breaking changes when a new major version is
supported). Attempting to import from an unversioned path throws
ERR_PACKAGE_PATH_NOT_EXPORTED.
npm install @arcjet/guard eve
Note: Eve requires Node.js >= 24. @arcjet/guard supports Node >= 22, but
the Eve integration does not. Verify the agent's engines declares ">=24" or
note the floor in deployment docs.
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 { defineTool } from "eve/tools";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/vercel-eve/v0";
import { tokenBucket, policyInput } from "@arcjet/guard";
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",
actor: userId,
inputs: (input) => ({
orderId: policyInput.server.string(input.orderId),
}),
rules: (input) => [lookupLimit({ key: input.orderId, requested: 1 })],
},
);
- Omit
rulesto submit none. The guard call still happens, so the decision is correlatable and the tool can be managed via policy configured outside the code. - 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. rulesmay be a callback over the tool's parsed input, computed from the data being acted on.- On DENY the tool's
executenever runs; Eve projects it as a failedaction.result. The model receives details about the denial. - Guard policy unavailability: if the guard cannot be evaluated (e.g. Arcjet
API unreachable), the default is
onGuardError: "deny"— the tool is blocked and Eve reports the error. For read-only operations like lookups, setonGuardError: "allow"if availability matters more than enforcement.
Tool-only: guardTool is called at tool invocation time and observes the
outcome. If you only need to gate the tool without observing its result, use
guardApproval() instead. To require a human after the request-time gate,
set onAllow: "user-approval" and, when you need to authorize the responder,
add a response policy.
Step 3: Gate connection operations
import { defineOpenAPIConnection } from "eve/connections";
import { guardApproval } from "@arcjet/guard/vercel-eve/v0";
import { tokenBucket } from "@arcjet/guard";
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 })],
onAllow: "user-approval",
response: {
action: "orders-api.approved",
rules: (ctx) => [apiLimit({ key: ctx.responder.principalId, requested: 1 })],
},
}),
operations: {
allow: ["GetOrder"],
},
});
- The request-time callback receives the
ApprovalContextwhich carriessession.id, so you can key limits per user/session. - On request-time DENY the operation is blocked; Eve returns a
deniedstatus the model can read and adapt to. ContrastguardTool, which throws. onAllow: "user-approval"parks the call for a human. HITL clients answer withcancel(notdeny). A request-time denial is still{ type: "denied" }.- The optional
responsepolicy authorizes the responder. ALLOW returns{ status: "allowed" }. DENY returns{ status: "rejected", reason }and leaves the approval pending. - This gate is the only way to protect connection operations — there is no middleware or hook alternative.
Step 4: Screen inbound messages
import { defineChannel, POST } from "eve/channels";
import { guardInbound } from "@arcjet/guard/vercel-eve/v0";
import { detectPromptInjection } from "@arcjet/guard";
import { arcjet } from "../arcjet.js";
export default defineChannel({
routes: [
POST("/webhook", async (req, args) => {
const body = (await req.json()) as Record<string, unknown>;
const message = body.message as string | undefined;
const conversationId = body.conversationId as string | undefined;
if (!message || typeof message !== "string") {
return new Response(JSON.stringify({ error: "Missing message" }), { status: 400 });
}
// Require a stable conversation identity. A generated or per-request id
// joins to nothing, and `from()` would mint a new continuation every
// time, so no session is ever resumed.
if (!conversationId || typeof conversationId !== "string") {
return new Response(JSON.stringify({ error: "Missing conversationId" }), { status: 400 });
}
// Authenticate the caller before trusting a body-supplied conversation
// id: `from()` resolves it to whichever session currently owns that
// address, so an unauthenticated route lets anyone post into — and read
// the decisions of — a conversation whose id they can guess.
//
// The same value is the guard's correlation id and the channel-local
// continuation address, which is what makes the two Sequences joinable.
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. The rule category that fired ("PROMPT_INJECTION")
// is `verdict.decision?.reason`, matching every other Arcjet surface.
// `verdict.reason` is a deprecated alias for `outcome`; do not return
// it as if it were the category.
return new Response(
JSON.stringify({
error: verdict.message,
outcome: verdict.outcome,
reason: verdict.decision?.reason ?? "UNKNOWN",
}),
{ status: 403 },
);
}
// Message passed; create a session and run the agent.
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" },
});
}),
],
});
guardInboundis the only place in the agent's lifecycle where a turn can be declined before it starts. Hooks are observe-only.- A guarded tool can be invoked directly (no Eve execution context) for
verification:
tool.execute(input, undefined)runs the guard and skipsexecuteon DENY. - The
correlationIdis passed explicitly and should be a value the app already has (request ID, session ID, a derived identifier). Pass it toargs.from()to join the inbound decision with the agent's session in the Arcjet Console. - On DENY the handler returns an HTTP error; the agent never runs.
- Guard policy unavailability: default is
onGuardError: "deny"— if the guard cannot be evaluated, the message is rejected. This is the safe choice where the agent stops answering during an Arcjet outage. For channels where the human cost of rejecting a legitimate message exceeds the security cost of an outage, useonGuardError: "allow"to let it through anyway.
Step 5: Record agent lifecycle events
import { defineHook } from "eve/hooks";
import { arcjetHooks } from "@arcjet/guard/vercel-eve/v0";
import { arcjet } from "../arcjet.js";
export default defineHook(arcjetHooks(arcjet));
This hook registers for Eve's session and tool lifecycle events and emits capture events joined to the session's correlation ID. The hook is observe-only and cannot block anything.
Verify the integration
npm run typecheckpasses;npm run build(oreve build) succeeds.- Exercise the agent with a test message or tool call.
- Confirm in the Arcjet dashboard (
list-requests,list-guards) that the tool and connection gate decisions and the lifecycle captures share the session id as their correlation id. The inbound decision is on its own Sequence, correlated by the conversation id; find theeve.session-startedcapture to pivot between the two. Eve namespaces continuation tokens per channel, so that record'seve.continuation-tokenreads<channel-name>:<conversation-id>rather than the bare id. - Trip a rate limit deliberately; confirm the model receives the denial and does not loop on retries (tools that throw) or attempts the operation (gates that deny).
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.