AI agent security

How do I add guardrails to an AI agent that calls external APIs?

Enforce on the outbound HTTP call, inside the tool that makes it. A scoped API key proves the agent can reach Stripe. It does not prove this charge, for this user, should happen now.

9 min read
In short: Enforce on the outbound HTTP call, inside the tool that makes it. A scoped API key proves the agent can reach Stripe. It does not prove this charge, for this user, should happen now.

How do I add guardrails to an AI agent that calls external APIs?

Put the check on the outbound HTTP call, inside the tool that makes it. When an agent charges a card, opens a GitHub issue, hits a search API, or sends mail, the control has to run before that request leaves your process, and again on the response before the text re-enters the model.

How to enforce runtime controls on AI agents accessing external APIs

Enforce on that outbound call. Authorize the user and the target object, rate-limit the identity, screen generated arguments, then screen the response.

That job is different from runtime controls on enterprise systems. That page is CRM, warehouse, and internal API access. This page is Stripe, GitHub, search, and send-email: third-party HTTP the agent starts.

A scoped API key answers whether the agent can reach Stripe. It doesn't answer whether this charge, for this user, with these arguments, is allowed on this request. The OWASP Top 10 for LLM Applications treats that gap as excessive agency, and as indirect prompt injection when tool output steers the next call.

This isn't the CRM or warehouse path

Internal systems and outbound APIs fail in different places. The following table compares the two paths:

QuestionEnterprise systems pageThis page
What is the target?CRM, warehouse, internal APIStripe, GitHub, search, email, other third-party HTTP
What does a scoped credential prove?The agent may reach that systemThe agent may present your API key to that vendor
What still needs a runtime check?This read or write, for this user, on this requestThis outbound request, for this user, on this request
What is the usual side effect?A record moves inside your estateMoney, mail, a ticket, or a public post leaves your estate

If the tool reads Salesforce or BigQuery, use the enterprise systems guide. If it calls an external HTTP API, stay here.

What to enforce on an outbound call

Put these checks on the call. Authorization is a separate function on the user and the target object, not a content score. The following table lists each control:

ControlWhat it catchesWhere it runs
Rate limit keyed on the user or sessionA retry loop or injected "try again" that burns vendor quota

Before fetch, shared by identity, not by HTTP route

Auth and object checksA valid Stripe key used on the wrong customer, amount, or tenantBefore the vendor client, against session state you control
Abuse and anomaly signals

Unusual destinations, payload size, or burst shape on your outbound calls

On the shared HTTP wrapper, not on inbound bot detection
Screen tool argumentsModel-generated URLs, bodies, and recipientsBefore the request is sent
Screen tool outputInstructions planted in a search hit, issue body, or API errorBefore the string returns to the model

A clean injection score isn't permission to charge. A clean personally identifiable information (PII) score isn't permission to email a new address. Write the allow as an ordinary function on the authenticated user, the target object, and the arguments. For more information about that control model, see AI agent runtime security. For more information about splitting injection, exfiltration, and unsafe actions, see runtime security for LLM applications.

The check sits in the execute function or in the HTTP client

You can't wrap "the agent" from the outside and catch Stripe. Most hosts pass generated arguments straight into the execute function. The vendor call happens in that function, or in a helper that it calls. If the check isn't on that path, then the request goes out.

If you wrote the tool, put the check at the top of execute (or the Python invoke / _run body), then again on the response. If many tools share one HTTP client, wrap that client once. Every outbound call has to go through it. A tool that imports fetch directly bypasses the wrapper.

Framework callbacks that only log after the tool returns aren't a gate. For more information about that trap on another host, see canUseTool is not a policy gate. MCP tools that you didn't write have no local execute function. Gate them on the host hook that can still deny. For more information about that placement, see secure MCP server and agent tool calls.

Most hosts don't give you a wrapper between generated arguments and the function. If yours doesn't, you have to touch execute.

A generic fetch wrapper

Authorize, limit, screen arguments, call the API, then screen the body. Every outbound tool must call this function (or an equivalent client) instead of raw fetch.

type AgentHttpContext = {
actor: string;
action: string;
customerId?: string;
};
// Narrower than RequestInit on purpose: a Headers instance doesn't survive
// object spread, and a URLSearchParams body doesn't survive JSON.stringify.
type AgentInit = {
method?: string;
headers?: Record<string, string>;
body?: string | URLSearchParams;
};
const buckets = new Map<string, { tokens: number; resetAt: number }>();
function takeToken(key: string, max: number, windowMs: number): boolean {
const now = Date.now();
const current = buckets.get(key);
if (!current || now >= current.resetAt) {
buckets.set(key, { tokens: max - 1, resetAt: now + windowMs });
return true;
}
if (current.tokens <= 0) {
return false;
}
current.tokens -= 1;
return true;
}
export async function agentFetch(
url: URL,
init: AgentInit,
ctx: AgentHttpContext,
authorize: (ctx: AgentHttpContext, url: URL) => void,
isHostile: (text: string) => Promise<boolean>,
): Promise<string> {
authorize(ctx, url);
if (!takeToken(`${ctx.actor}:${ctx.action}`, 30, 60_000)) {
throw new Error("Outbound rate limit exceeded");
}
const allowedHosts = new Set(["api.stripe.com", "api.github.com"]);
if (!allowedHosts.has(url.host)) {
throw new Error("Outbound host is not allowed");
}
// String(body) covers URLSearchParams; JSON.stringify would return "{}" and
// screen nothing.
const outbound = init.body === undefined ? "" : String(init.body);
if (await isHostile(`${url.href}\n${outbound}`)) {
throw new Error("Blocked untrusted tool arguments");
}
const response = await fetch(url, {
method: init.method ?? "GET",
body: init.body,
headers: {
...init.headers,
authorization: secretForHost(url.host),
},
});
const text = await response.text();
if (await isHostile(text)) {
throw new Error("Blocked untrusted tool output");
}
return text;
}
function secretForHost(host: string): string {
if (host === "api.stripe.com") {
return `Bearer ${process.env.STRIPE_SECRET_KEY!}`;
}
if (host === "api.github.com") {
return `Bearer ${process.env.GITHUB_TOKEN!}`;
}
throw new Error("No credential for host");
}
function authorizeCharge(
ctx: AgentHttpContext,
args: { customerId: string; amountCents: number },
) {
if (args.customerId !== ctx.customerId) {
throw new Error("Customer mismatch");
}
if (args.amountCents > 5_000) {
throw new Error("Amount exceeds policy");
}
}
const ctx: AgentHttpContext = {
actor: session.userId,
action: "stripe.charge",
customerId: session.customerId,
};
const args = {
customerId: generated.customerId,
amountCents: generated.amountCents,
};
const charge = await agentFetch(
new URL("https://api.stripe.com/v1/charges"),
{
method: "POST",
body: new URLSearchParams({
amount: String(args.amountCents),
customer: args.customerId,
}),
},
ctx,
() => authorizeCharge(ctx, args),
detectInjection,
);

Hold vendor secrets in the wrapper, never in tool arguments. The model doesn't get to pick the Authorization header. Pass your own detector as the last argument; the wrapper only needs a boolean. An allowlist on host is an abuse signal: a search tool that suddenly posts to an unknown origin is the call you stop.

The in-memory bucket is a sketch. In production, share the counter across instances the same way you would any other rate limit; the rate limiting guide covers algorithms and identifiers. Key on the authenticated user (or session), not on a value the model supplies. A limit on the chat route counts workflow starts. One request can still fire 40 Stripe calls.

One worked example on a GitHub tool

The same checks, using a published Guard client as the decision helper. The check still sits inside the tool, immediately before the GitHub client. You aren't wrapping the agent loop. guard() is the API for tools, MCP, and jobs. It takes no Request. Bots stay on protect().

import {
detectPromptInjection,
launchArcjet,
tokenBucket,
} from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const outbound = tokenBucket({
bucket: "github-create-issue",
refillRate: 30,
intervalSeconds: 60,
maxTokens: 60,
});
const injection = detectPromptInjection();
export async function createGitHubIssue(args: { title: string; body: string }) {
if (!session.canOpenSupportIssues) {
throw new Error("Not allowed to open issues");
}
const before = await arcjet.guard({
label: "tools.github-create-issue",
actor: session.userId,
correlationId: workflowRunId,
rules: [
outbound({ key: session.userId, requested: 1 }),
injection(`${args.title}\n${args.body}`),
],
});
if (before.conclusion === "DENY" || before.hasFailedOpen()) {
throw new Error(`Denied: ${before.reason}`);
}
const issue = await github.rest.issues.create({
owner: "acme",
repo: "support",
title: args.title,
body: args.body,
});
const after = await arcjet.guard({
label: "tools.github-create-issue",
actor: session.userId,
correlationId: workflowRunId,
rules: [injection(JSON.stringify(issue.data))],
});
if (after.conclusion === "DENY" || after.hasFailedOpen()) {
throw new Error(`Denied: ${after.reason}`);
}
return { url: issue.data.html_url };
}

actor comes from the authenticated session, never from a tool argument. A policy can be conditioned on the actor, so a model-supplied value lets the caller escape their own scope. label is validated server-side as a slug: lowercase letters, digits, dash, and dot. tools.github_create_issue is rejected; tools.github-create-issue is not.

The key you pass to a rate-limit rule is SHA-256 hashed before it leaves the process, so a raw user ID never reaches Arcjet. Pass anything you need for later correlation as metadata instead. Name the bucket per use case: counters are keyed by bucket plus a server-side hash of the rule config, so two unrelated tools that both default to default-token-bucket still get separate counters, but a shared name is what makes the dashboard readable.

correlationId tags the run so you can reconstruct it later. It doesn't deny step 3 because of steps 1 and 2. Sequence-aware deny isn't something you can buy here.

A direct guard() call returns ALLOW when evaluation can't be completed: a deadline, a parse failure, a local rule error, or a rule error the server returned. The conclusion alone can't tell you that apart from a real allow, which is what hasFailedOpen() is for. Framework wrappers invert that default and deny unless you opt in. Choose per action: a search timeout can return a placeholder; a Stripe charge must not run. For more information about those APIs, see Agent guards and AI runtime protection.

For more information about prompt-injection wiring on LangChain, LlamaIndex, and the Vercel AI SDK, see prompt injection protection for those frameworks. That page owns the inbound and retriever screens. This document owns the outbound HTTP call.

Checklist

Before you ship an outbound tool, check the following items:

  • Route every outbound vendor call through one client or through the tool execute function that you ship.
  • Authorize the action against session identity and the target object before the client runs.
  • Rate-limit on the user or session at the tool. A limit on the chat route counts workflow starts.
  • Put destination hosts on an allowlist. Treat an unknown origin as an abuse signal.
  • Screen model-generated arguments before the request. Screen the response before it becomes the next prompt.
  • Hold API keys in server code. Don't let the model supply the bearer token.
  • Decide per action what happens if the check can't finish.
  • Tag the run so that an incident is reconstructable. Don't wait for sequence-aware deny.

If the call can't be undone, classify it before you write the allowlist. For more information about that list, see How do I prevent an AI agent from taking irreversible actions?.

Frequently asked questions

How do I add guardrails to an AI agent that calls external APIs?

Put the check inside the tool execute function or in the HTTP client that the tool uses. Authorize the action against session identity, rate-limit the user, screen generated arguments before the request, and screen the response before it returns to the model. A scoped API key isn't that check.

How to enforce runtime controls on AI agents accessing external APIs

Enforce on outbound HTTP the agent starts (Stripe, GitHub, search, send-email), not on the CRM or warehouse path. The control sits before fetch and again on the response. You can't wrap the agent from the outside and catch a client that the tool imports directly.

Is a scoped API key enough for an agent that calls Stripe or GitHub?

No. The key answers whether the agent can present credentials to that vendor. It can't answer whether this charge or issue, for this user, with these arguments, is allowed on this request. Authorize the action separately from any content score.

Where should the runtime check sit for outbound agent API calls?

At the top of the execute function, or in a shared HTTP wrapper that every outbound tool must call. Framework callbacks that only log after the tool returns aren't a gate. MCP tools that you didn't write have no local execute function; use the host hook that can still deny.

AI runtime security in your code

Protect your AI agent workflows with Arcjet

Get allow, deny, and redact on agent actions before the side effect.