TL;DR
- A Cloudflare deployment is a privileged mutation that directs live traffic to a version; it is a different object from a version, which is an immutable code snapshot that touches no traffic. Cloudflare's own API history conflated the two, which is why an agent that "just deploys" can silently ship code to production.
- A shared Cloudflare API token is a single-engineer solution. It collapses author attribution to the token, inflates scope to whatever the token was provisioned with, and shares one blast radius across every zone it can reach. It does not survive a second engineer.
- The Cloudflare MCP connector exposes exactly two tools, cloudfaremcp_search and cloudfaremcp_execute, mirroring Cloudflare's Code Mode. The agent runs model-written JavaScript through execute; scoping that surface to a per-engineer credential is what keeps an injected payload from becoming an account-wide primitive.
- Scope is a function of identity, not connector configuration: what the engineer can't do in Cloudflare, the agent can't do. Scalekit resolves the engineer's vaulted Cloudflare credential server-side on every call; it never enters the prompt, the model context, or your logs.
- The approval gate is a Mastra workflow suspend/resume boundary. On resume, the deployment must execute as the authorizing engineer, not the approver and not a service account, or attribution and scope break exactly where they matter most.
An engineer types one line into your infrastructure agent: "Deploy the latest rate-limiter-v2 version to production." The agent finds the newest uploaded version, routes 100% of production traffic to it, and replies that the Worker is live across 310 PoPs. In a one-engineer demo, this works. One Cloudflare API token sits in an environment variable, the agent uses it, traffic shifts.
Then a second engineer starts using the same agent. Now every deploy in the Cloudflare audit log carries the same author, the shared token. The token was provisioned with account-wide Workers Scripts:Edit because that was convenient, so the agent can push code to any Worker in any zone the token reaches, on behalf of anyone. And the single most consequential call in the entire flow, the one that directs live traffic, runs with no boundary in front of it.
The hard part of this agent is not calling Cloudflare. It is that "deploy to production" is the one mutating, irreversible, traffic-directing action in the workflow, and the moment more than one engineer is involved, the question stops being how do I deploy and becomes whose authority is the agent acting under, and when is it allowed to cross that line.
Why the naive Worker deployment agent fails at the second engineer
The naive build is a Mastra tool that holds one CLOUDFLARE_API_TOKEN and calls the Cloudflare API. It fails on three structural axes, and none of them are carelessness.
- Attribution collapses. Cloudflare tracks a Source and an Author on every deployment; that is how you answer "who shipped rate-limiter-v2 to prod at 18:29." With a shared token, the author of every deployment is the token. A practitioner asking on the Cloudflare community forum how to audit which token acted and from where is asking the question a shared credential structurally cannot answer. For an agent making live infrastructure changes, that is the difference between answering an auditor in one query and opening a multi-week investigation.
- Scope inflates. Shared automation tokens get provisioned broad because narrowing them per action is tedious. So the agent that should only deploy one Worker can edit every script in the account. This is the inversion Scalekit's connected-account model is built to prevent: scope should be derived from what the individual engineer authorized, nothing more.
- Blast radius is shared. Code Mode has the model write JavaScript that runs against the credential. A prompt-injected execute payload backed by an account-wide token is an account-wide primitive. Backed by one engineer's scoped grant, the same injected payload cannot exceed what that engineer could do by hand. The containment is the credential, not the prompt.
There is a fourth failure that is specific to Workers and specific to agents.
"Deploy the latest version" is not a read. In the current Workers model, a Version is an immutable snapshot of code and configuration; creating one is safe and touches no traffic. A Deployment is an explicit action that directs traffic to a version. Cloudflare has stated plainly that earlier endpoints "implicitly created deployments", changing a secret or a script would create a version and immediately deploy it, and that this ambiguity "made it difficult for human developers (and even more so for AI agents) to reliably update a Worker via API." An agent that does not model this distinction will treat a routine edit as a production release. The authorization boundary you actually need sits on exactly one call: creating a deployment that routes production traffic.
Authorization treatment in this agent
Immutable snapshot of code and config
Safe; agent may read and list freely
Directs live traffic to one or two versions
Gated; requires human approval and the engineer's own scoped credential
Deployment pointing at a prior version
Same gate as a deployment; limited to the 100 most recent versions
What the agent is: a deterministic pipeline with one gated mutation
This agent is not an open-ended autonomous loop that decides what to do to your infrastructure. It is a deterministic pipeline with a single point of autonomy (which version is "latest") and a single privileged mutation (the deployment), and the mutation is the only step behind a human gate.
The stages, in fixed order:
- Resolve the engineer's identity and their Cloudflare connected account.
- Load the scoped tool surface for that engineer.
- Read: discover the Worker's versions and select the latest (non-mutating).
- Suspend for human approval, showing the exact version and traffic change.
- On approval, create the deployment at 100%, as the engineer, and log it.
Mastra is the right host for this. It is a TypeScript-native agent framework built from the start for the Vercel and Cloudflare Workers deployment model, so the agent that deploys your Workers can itself run on Workers. It gives you three primitives: createTool() for typed tools, Agent for the reasoning loop, and createWorkflow() / createStep() for multi-step orchestration that can suspend and resume without losing state.
The load-bearing detail for agent auth is where identity lives. A Mastra tool's inputSchema is a Zod schema that defines exactly what the model is asked to supply, so it is visible to the model. Identity must never be there. It travels in requestContext, a typed per-request store that flows through every tool call but is never serialized into the LLM prompt. The model decides which version to deploy; it never sees, supplies, or influences whose credential executes the call.
Two identity layers meet in this agent, and only one of them is in scope here. Platform identity (who is calling your Mastra server) is answered by your identity provider through JWT verification; that is a solved problem and not the subject of this post. Connector identity (what this engineer has authorized in Cloudflare, and with which permissions) is a separate question that no JWT can answer, because the Cloudflare grant lives nowhere near your server's session. Scalekit fills that connector-identity layer. Everything below stays in it.
Prerequisites
- Node.js 20+ and a Mastra project (@mastra/core, @ai-sdk/openai, zod).
- The Scalekit Node SDK: npm install @scalekit-sdk/node.
- A Scalekit environment. Set these in .env; find the values in app.scalekit.com > Developers > API Credentials:
SCALEKIT_ENVIRONMENT_URL=<your-environment-url>
SCALEKIT_CLIENT_ID=<your-client-id>
SCALEKIT_CLIENT_SECRET=<your-client-secret>
- The Cloudflare MCP connector enabled in your Scalekit dashboard (AgentKit > Connections). One note that causes more failed integrations than any other: the connection_name you pass in code must match the connection name configured in your dashboard exactly. The authoritative docs use cloudfaremcp; confirm the exact connection name and the exact tool names in your own dashboard before shipping, because the tool names are derived from the connection.
// lib/scalekit.ts
// One shared client per process, so no module ends up with a divergent credential path.
import { ScalekitClient } from '@scalekit-sdk/node'
export const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENVIRONMENT_URL!,
process.env.SCALEKIT_CLIENT_ID!,
process.env.SCALEKIT_CLIENT_SECRET!,
)
// This string MUST match the connection name in your Scalekit dashboard exactly.
// It is passed as `connectionName` to auth calls and as `connector` to executeTool.
export const CLOUDFLARE_CONNECTION = 'cloudfaremcp'
Connect the engineer's Cloudflare account
Before the agent can act, the engineer authorizes Cloudflare through their own account once. The Cloudflare MCP connector uses OAuth 2.1/DCR, so this is a hosted consent flow; Scalekit stores the resulting credential in an AES-256 vault, namespaced per tenant, and your application never receives or persists a raw token.
// connect.ts
import { scalekit, CLOUDFLARE_CONNECTION } from './lib/scalekit'
// `identifier` is the engineer's stable ID from YOUR authenticated session
// (session cookie, JWT sub, or DB lookup). Never accept it from client input,
// it is the only link between your app's user and Scalekit's vault record.
export async function ensureCloudflareConnected(identifier: string) {
// getOrCreateConnectedAccount returns the current state for this engineer + connector.
const { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({
connectionName: CLOUDFLARE_CONNECTION,
identifier,
})
if (connectedAccount.status === 'ACTIVE') return
// Not connected yet: mint an authorization link for the engineer to complete OAuth.
const { link } = await scalekit.actions.getAuthorizationLink({
connectionName: CLOUDFLARE_CONNECTION,
identifier,
})
// In a web app, redirect the engineer to `link`. After they consent, their
// Cloudflare credential is vaulted under `identifier`. From here on, every
// executeTool call with this identifier resolves that credential server-side.
throw new Error(`Cloudflare not connected. Authorize here: ${link}`)
}
The principle this encodes is the one that survives a second engineer: scope is a function of identity, not connector configuration. Engineer A's Cloudflare permissions become the ceiling for the agent when it acts for A. Engineer B gets B's ceiling. The same agent, no new auth code.
Discovery, scope, then execution: load only what this engineer may call
Before showing code, be precise about what this step is. It is not loading a flat catalog of everything Cloudflare can do. listScopedTools returns the tools this engineer's connected account is authorized to call, a scoped, deterministic surface. For the Cloudflare MCP connector, that surface is two tools:
Query the Cloudflare OpenAPI spec to find the right endpoint, path, and schema
Run a JavaScript async function against the Cloudflare API via the cloudflare client
The scoping surface is also your read-only enforcement point. An engineer who should never deploy gets a surface that excludes cloudfaremcp_execute entirely; the model is never offered it, and a call to it is blocked before it reaches Cloudflare. Cloudflare's own token permissions remain a second enforcement layer beneath this.
This is the same principle behind agent tool calling auth patterns: the surface the model sees is the surface the identity permits, nothing more.
// tools.ts
import { scalekit, CLOUDFLARE_CONNECTION } from './lib/scalekit'
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
// canDeploy decides the SURFACE, not just a UI flag: a read-only engineer never
// receives the execute tool, so scope is enforced before the model can select it.
export async function buildCloudflareTools(identifier: string, canDeploy: boolean) {
const toolNames = canDeploy
? ['cloudfaremcp_search', 'cloudfaremcp_execute']
: ['cloudfaremcp_search'] // read-only engineers get discovery only
// Retrieve the authorized surface for THIS engineer's connected account.
const { tools: scoped } = await scalekit.tools.listScopedTools(identifier, {
filter: { connectionNames: [CLOUDFLARE_CONNECTION], toolNames },
pageSize: 100,
})
// Wrap each Scalekit tool as a native Mastra tool. Note what is absent from
// inputSchema: no identifier, no token. Identity is bound below in execute(),
// from a closure over `identifier`, and is never exposed to the model.
return scoped.map((s: any) => {
const def = s.tool.definition
return createTool({
id: def.name,
description: def.description,
inputSchema: z.object({}).passthrough(), // schema comes from the connector
execute: async ({ context }) => {
// Scalekit resolves the vaulted Cloudflare credential for `identifier`
// server-side, scopes the call, executes it, and returns the result.
// The credential never touches this function, the prompt, or your logs.
const result = await scalekit.actions.executeTool({
connector: CLOUDFLARE_CONNECTION,
identifier,
toolName: def.name,
toolInput: context,
})
return result
},
})
})
}
The read path: find the latest version without touching traffic
Finding the latest version is the safe half of the workflow, so it can run through the agent's reasoning loop. The agent uses cloudfaremcp_search to confirm the versions endpoint, then cloudfaremcp_execute to list versions. Listing is non-mutating; no traffic moves.
// read-latest-version.ts
import { scalekit, CLOUDFLARE_CONNECTION } from './lib/scalekit'
// Deterministic read: we construct the exact code payload rather than letting
// the model author it, because the version_id it returns feeds a production
// mutation and must be trustworthy. account_id is passed as its own tool param
// (auto-selected by the connector if the engineer has a single account).
export async function findLatestVersion(
identifier: string,
workerName: string,
accountId: string,
) {
const listVersionsCode =
`async () => cloudflare.workers.scripts.versions.list(` +
`${JSON.stringify(workerName)}, { account_id: ${JSON.stringify(accountId)} })`
const result: any = await scalekit.actions.executeTool({
connector: CLOUDFLARE_CONNECTION,
identifier,
toolName: 'cloudfaremcp_execute',
toolInput: { code: listVersionsCode, account_id: accountId },
})
// Cloudflare returns versions newest-first; take the first item's id.
const versions = result?.data?.result ?? result?.result ?? []
if (!versions.length) throw new Error(`No versions found for ${workerName}`)
return {
versionId: versions[0].id as string,
createdOn: versions[0].metadata?.created_on ?? versions[0].created_on,
}
}
The approval gate: authorization at the deployment boundary
This is the step the whole agent exists to protect. Creating a deployment at 100% is the mutation that directs live traffic, so it sits behind a Mastra suspend/resume boundary. The workflow pauses, surfaces exactly what will change, and only the human decision to approve lets it proceed.
Two authorization details make this correct rather than theatrical. First, the deploy code is deterministic, we construct the exact deployments.create payload; the model never authors the mutating call. Second, on resume the deployment executes under the authorizing engineer's identifier, not the approver's. The approver's identity is recorded in the deployment annotation for audit, but the credential that acts is the engineer's own vaulted grant. Attribution and scope stay correct at the exact point they matter most.
This pattern — gating mutations behind human approval — is central to understanding credential ownership across agent tool-calling patterns, where the question of who holds the token determines what the agent can safely do.
// deploy-workflow.ts
import { createWorkflow, createStep } from '@mastra/core/workflows'
import { z } from 'zod'
import { scalekit, CLOUDFLARE_CONNECTION } from './lib/scalekit'
import { findLatestVersion } from './read-latest-version'
// Step 1: resolve the latest version (safe, non-mutating).
const resolveVersionStep = createStep({
id: 'resolve-latest-version',
inputSchema: z.object({
identifier: z.string(), // the authorizing engineer
workerName: z.string(),
accountId: z.string(),
}),
outputSchema: z.object({
identifier: z.string(),
workerName: z.string(),
accountId: z.string(),
versionId: z.string(),
}),
execute: async ({ inputData }) => {
const { versionId } = await findLatestVersion(
inputData.identifier, inputData.workerName, inputData.accountId,
)
// identifier travels forward explicitly so the deploy step acts as the SAME engineer.
return { ...inputData, versionId }
},
})
// Step 2: suspend for approval, then deploy on resume.
const approveAndDeployStep = createStep({
id: 'approve-and-deploy',
inputSchema: z.object({
identifier: z.string(),
workerName: z.string(),
accountId: z.string(),
versionId: z.string(),
}),
// What the approver is shown while the workflow is paused.
suspendSchema: z.object({
workerName: z.string(),
versionId: z.string(),
change: z.literal('route 100% of production traffic to this version'),
}),
// What the approver must supply to resume.
resumeSchema: z.object({
approved: z.boolean(),
approver: z.string(),
}),
outputSchema: z.discriminatedUnion('status', [
z.object({ status: z.literal('deployed'), deploymentId: z.string() }),
z.object({ status: z.literal('rejected') }),
z.object({ status: z.literal('auth_error'), reason: z.string() }),
]),
execute: async ({ inputData, resumeData, suspend }) => {
// First pass: no decision yet, so pause and show the exact change.
if (!resumeData) {
return await suspend({
workerName: inputData.workerName,
versionId: inputData.versionId,
change: 'route 100% of production traffic to this version',
})
}
// Resumed with a decision.
if (!resumeData.approved) return { status: 'rejected' as const }
// Deterministic deploy payload. version_id is the only variable, and it is
// the value the approver just saw. percentage: 100 is a full production cut.
const deployCode =
`async () => cloudflare.workers.scripts.deployments.create(` +
`${JSON.stringify(inputData.workerName)}, {` +
` account_id: ${JSON.stringify(inputData.accountId)},` +
` strategy: "percentage",` +
` versions: [{ version_id: ${JSON.stringify(inputData.versionId)}, percentage: 100 }],` +
` annotations: { "workers/message": ${JSON.stringify(
`Deployed on approval by ${resumeData.approver}`,
)}, "workers/triggered_by": "api" }` +
`})`
try {
// Executes as `inputData.identifier`, the engineer, NOT the approver.
// Scalekit re-resolves the vaulted credential now, so a token that was
// refreshed during the pause is still valid at execution time.
const result: any = await scalekit.actions.executeTool({
connector: CLOUDFLARE_CONNECTION,
identifier: inputData.identifier,
toolName: 'cloudfaremcp_execute',
toolInput: { code: deployCode, account_id: inputData.accountId },
})
const deploymentId = result?.data?.result?.id ?? result?.result?.id ?? 'unknown'
return { status: 'deployed' as const, deploymentId }
} catch (err: any) {
// Scalekit surfaces auth failures as typed codes the workflow can branch on.
if (err.code === 'TOKEN_EXPIRED') return { status: 'auth_error' as const, reason: 'token_expired' }
if (err.code === 'SCOPE_INSUFFICIENT') return { status: 'auth_error' as const, reason: 'scope_insufficient' }
throw err
}
},
})
export const deployWorkflow = createWorkflow({
id: 'cloudflare-worker-deploy',
inputSchema: z.object({
identifier: z.string(),
workerName: z.string(),
accountId: z.string(),
}),
outputSchema: approveAndDeployStep.outputSchema,
})
.then(resolveVersionStep)
.then(approveAndDeployStep)
.commit()
Triggering the run pauses at the gate; approving resumes it. The resume can be called from anywhere: an HTTP endpoint, a Slack action handler, a review UI.
// run.ts
import { mastra } from './mastra' // your Mastra instance with deployWorkflow registered
import { ensureCloudflareConnected } from './connect'
export async function startDeploy(identifier: string, workerName: string, accountId: string) {
await ensureCloudflareConnected(identifier) // fail fast if not authorized
const run = await mastra.getWorkflow('deployWorkflow').createRun()
// Suspends at approve-and-deploy and returns a snapshot with the pending change.
const result = await run.start({ inputData: { identifier, workerName, accountId } })
return { runId: run.runId, result }
}
// Called after a human clicks Approve. resumeData must match resumeSchema.
export async function approveDeploy(runId: string, approver: string) {
const run = await mastra.getWorkflow('deployWorkflow').getRun(runId)
return run.resume({
step: 'approve-and-deploy',
resumeData: { approved: true, approver },
})
}
What holds when production does not cooperate
The gate is only worth building if it holds under the failures that actually occur. Four are worth stating precisely.
- A revoked Cloudflare grant. If the engineer's credential is revoked, the next executeTool for that engineer fails closed and is logged; other engineers on the same connection are unaffected. Revocation is a per-identity event, not a shared-token outage.
- Token expiry during a long suspend. A deployment can sit awaiting approval for hours. Scalekit resolves the credential at execution time on resume, not at suspend time, so a token refreshed during the pause is current when the deploy runs. The Mastra snapshot persists the run across restarts and redeploys, so the pause survives an edge cold start. This is the same token lifecycle challenge covered in depth in the guide on how to handle token refresh for AI agents.
- The shared rate-limit budget. Cloudflare enforces roughly 1,200 requests per five minutes per user, counted across the dashboard, API keys, and tokens. An agentic deploy issues several sequential calls per action. A shared credential burns one budget for the whole team; per-engineer connected accounts distribute it across identities.
- Insufficient scope. If the engineer's Cloudflare permissions do not allow a deployment, Scalekit returns a typed SCOPE_INSUFFICIENT the workflow branches on, rather than a stack trace. What the engineer can't do, the agent can't do; the failure is legible.
Every one of these calls lands in the agent auth log with the engineer who authorized it, the operation that ran, and the result, retained for 90 days and exportable to your SIEM. That log answers the attribution question the shared token could not: not "the bot deployed rate-limiter-v2," but "engineer A deployed version 847f3c to 100% of production at 18:29, approved by engineer B." For a deeper look at why these audit trails matter, see the guide on audit trails for agent auth in B2B SaaS.
FAQ
Does the deployment execute as the approver or as the engineer who triggered it?
As the engineer who triggered it. The identifier carried through the workflow is the authorizing engineer's, and executeTool resolves that engineer's vaulted Cloudflare credential. The approver's identity is recorded in the deployment annotation for audit only; it is not the credential that acts.
How do I make an engineer read-only?
Exclude cloudfaremcp_execute from the toolNames filter in listScopedTools for that engineer. The model is never offered the tool, and a call to it is blocked before it reaches Cloudflare. The engineer's Cloudflare token permissions are a second enforcement layer beneath the scoped surface.
Why is the deploy code constructed by your code instead of written by the model?
Because it is the one mutating, production-impacting call. Code Mode lets the model author JavaScript, which is correct for read and discovery, but the deployment payload is deterministic here: the version_id is the only variable, and it is the value the approver saw. Determinism at the mutation makes the action auditable and the gate meaningful.
Can the same agent also touch GitHub or PagerDuty in one workflow?
Yes. Each connector resolves under the same engineer identifier with its own vaulted credential, and listScopedTools returns only the surface that engineer is authorized to call for each. Deploy a Worker from a commit and open an incident if it fails, all under one identity, with no new auth code per connector. This is exactly the multi-connector pattern explored in the DevOps assistant agent for GitHub, Linear, and Slack.
Does this work for a multi-tenant, customer-facing product, not just an internal team?
Yes. Pass a tenant or customer-scoped identifier instead of an internal engineer ID. The executeTool call is identical; only the identifier changes. Cross-tenant tool calling requires per-tenant authorization, and per-identity connected accounts make that the default rather than something you bolt on. See the full breakdown in the post on how tool calling auth changes when you move from single-tenant to multi-tenant.
Where does the Cloudflare credential live?
In Scalekit's AES-256 vault, namespaced per tenant, resolved server-side before each call. It never appears in the prompt, the model context, or your application logs.
Start building your Worker deployment agent
- Enable the Cloudflare MCP connector in your Scalekit dashboard and confirm the exact connection and tool names: docs.scalekit.com/agentkit/connectors/cloudfaremcp/
- Follow the Mastra integration reference for the connected-account and scoped-tool wiring: docs.scalekit.com/agentkit/examples/mastra/
- Set your three environment variables from app.scalekit.com > Developers > API Credentials and run the AgentKit quickstart: docs.scalekit.com/agentkit/quickstart/
Wire in the connector, point it at an authorized engineer, and you have a Worker deployment agent that reads freely, mutates only behind a human gate, and attributes every production change to the engineer who owns it — running on the same Workers platform it deploys to. For a broader view of how API access patterns for AI agents apply identity architecture to B2B SaaS, that post covers the structural decisions that underpin everything built here.