The Vercel security incident earlier this month leaked customer environment variables, including API keys for payment processors and databases. A month before that, compromised LiteLLM packages on PyPI stole AWS, GCP, and SSH credentials from developer machines. Secrets stored as env vars or in plaintext keep ending up in the wrong hands.
If you're building AI agents that call sensitive APIs, the question is: does the agent actually need to see the API key, or just the ability to call the API?
Example: "Did Acme pay?"
A Discord bot where your team asks billing questions and gets answers from Stripe:

The bot runs as a single Windmill script connected to Discord via a WebSocket trigger. The agent has two tools:
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
const toolServer = createSdkMcpServer({
name: "billing-tools",
tools: [
tool(
"search_invoices",
"Search Stripe invoices by customer email",
{ email: z.string().email() },
async (args) => {
// Secret fetched server-side, the agent never sees this key
const stripe = await wmill.getResource("f/bot/stripe");
const resp = await fetch(
`https://api.stripe.com/v1/invoices/search?query=customer_email:'${encodeURIComponent(args.email)}'`,
{ headers: { Authorization: `Bearer ${stripe.apiKey}` } }
);
const data = await resp.json();
return { content: [{ type: "text", text: JSON.stringify(data.data?.slice(0, 5), null, 2) }] };
}
),
tool(
"get_customer",
"Look up a Stripe customer by email or name",
{ query: z.string().describe("Customer name or email") },
async (args) => {
const stripe = await wmill.getResource("f/bot/stripe");
const resp = await fetch(
`https://api.stripe.com/v1/customers/search?query=name~'${encodeURIComponent(args.query)}' OR email~'${encodeURIComponent(args.query)}'`,
{ headers: { Authorization: `Bearer ${stripe.apiKey}` } }
);
const data = await resp.json();
return { content: [{ type: "text", text: JSON.stringify(data.data?.slice(0, 5), null, 2) }] };
}
),
],
});
Someone asks "did Acme pay?" and the agent calls get_customer, then search_invoices, then responds in plain English. It decides which tools to call based on the question, but it never sees the Stripe secret key.
How credentials stay isolated
The tool implementation fetches secrets from Windmill resources at runtime. The agent only sees the tool signature ("give me an email, I'll return invoice data"). The credential itself lives in Windmill's encrypted resource store and is injected inside the tool function, not passed to the agent.
In the typical setup, STRIPE_SECRET_KEY sits in an env var and the whole process has access to it. In Windmill, process.env in the agent's sandbox only contains runtime variables like workspace ID and job ID. No secrets.
The sandbox
Windmill's AI sandboxes (introduced during launch week) use nsjail for process-level isolation:
- Credential isolation: API keys live in the encrypted resource store, not in the agent's environment. Secrets can be managed directly in Windmill with workspace encryption, or fetched at runtime from an external KMS like Hashicorp Vault via OIDC. Either way, the agent never sees them.
- Filesystem isolation: scripts run in an isolated mount namespace with access only to their job directory and explicitly mounted volumes.
- Resource limits: CPU, memory, and process limits per job.
- Network isolation (optional): outbound network access is on by default (tools need it to call APIs). For stricter setups, you can enable network namespace isolation via unshare flags (
--net) to block all outbound connections.
Even without network isolation, the agent can't authenticate to Stripe or any other service because it doesn't have the key. It can only use what you explicitly wire up as tools.
Try it
Full setup (Discord Gateway connection, heartbeat config, handler script with tool use):
To add Stripe tools: create a stripe resource in your workspace with your API key, define the tools in your handler script, and deploy.
You can self-host Windmill using a
docker compose up, or go with the cloud app.