I built a swarm of Kimi K3 agents for three customers. I gave every agent the same login, because that is the quickest way to get a swarm working. Then I read what one of the agents wrote back:
Collected invoice-001 from all three organizations: Tenant A: 1,200.00; Tenant B: 9,900.00; Tenant C: 450.00. The consolidated total is 11,550.00.
Three customers, three invoice amounts, one agent. That agent worked for the first customer and read the other two. Nobody asked it to. The token it carried belonged to no customer in particular, so the server had no reason to refuse it.
Moonshot's Agent Swarm runs up to 300 sub-agents at once, and one shared credential gives every one of them the reach of your whole platform. That is the multi-tenant problem, and a swarm makes it worse.
In this tutorial, you'll fix it. You'll give each agent an identity that belongs to one customer, using Kinde organizations, and you'll check that identity on every call the agent makes. You'll also build a kill switch that stops one customer's swarm while the others keep working, and you'll find out why the obvious way to build that kill switch does nothing at all.
By the end, you'll have built:
A Kimi K3 swarm you orchestrate yourself, so each agent's identity is yours to set
One Kinde organization for each tenant, and one machine-to-machine application for each agent role inside it
A client-credentials flow that mints a scoped token for each agent before it acts
A single enforcement point that decides every call, and refuses cross-tenant reads with
403 cross_orgA test that proves the boundary holds, by breaking it on purpose
A kill switch that stops one tenant mid-run, with the audit trail to show it
A console that streams every agent step live, so you can watch a leak happen and then watch it stop
Everything here runs. The code is open, and one command replays the whole story.
Table of Contents
- What You'll Build
- Prerequisites
- What a Swarm Changes
- How the Demo Is Structured
- Why One Shared Identity Is the Whole Problem
- How Tenant Isolation Works With Kinde Organizations
- How to Model Each Tenant as a Kinde Organization
- How to Give Each Agent Role Its Own M2M Application
- How to Mint an Org-Scoped Token Inside Each Agent
- How to Enforce Isolation at One Point in Your API
- How to Prove the Boundary Actually Holds
- How to Build a Kill Switch That Works
- How to Watch It Happen
- What Least Privilege Actually Costs
- Three More Things That Broke
- How to Bring This Up to Production Standard
- How This Maps to Your Own App
- Limits of This Build
- Wrapping Up
- Resources
What You'll Build
The demo runs a swarm for three tenants and shows the same call succeed and fail, depending on one server setting.
In shared mode, an agent working for Tenant A reads Tenant B's records. The escapes counter turns red, and the timeline names the organization it crossed into:
In per-org mode, the same swarm makes the same call for a different tenant, and the server refuses it. Cross-org attempts stay at 2, blocked rises to 2, escapes stays at 0:
Press the kill switch mid-run and that tenant stops where it stands, while the others carry on:
The agents are real, and they decide their own actions. Nobody scripts the reach across a tenant boundary.
Prerequisites
To follow along, you'll need:
A Kinde account. The free tier covers everything here.
Kimi K3 access, through the Moonshot API or OpenRouter.
Node 22+ and pnpm, plus Python 3.11+ for the swarm service.
Comfort with OAuth2 client credentials and JWTs. You don't need to be an expert, but you should know what a claim is.
The demo uses Convex for the backend and Next.js for the console. Neither is required for the pattern. The enforcement point is about forty lines, and it moves to Express, Hono, or FastAPI without changing shape.
What a Swarm Changes
A swarm is a group of AI agents that work at the same time on one job. One agent plans, other agents do the work in parallel, and each one calls tools.
Two properties make a swarm different from a single agent.
Speed. Those 300 sub-agents make over 4,000 tool calls in one task, about 4.5 times faster than a single agent working in sequence. Agent Swarm now runs on Kimi K3, which Moonshot calls K3 Swarm (Kimi Help Center). Nobody reviews that many calls while they happen, so the boundary has to hold on its own.
Autonomy. The agents choose their own actions. A swarm decides which tools to call, and with which arguments.
Add customers, and a third property appears.
Reach. Each agent needs access to data. If every agent uses one credential, that credential holds the sum of all access, and the reach of one agent becomes the reach of the whole system.
Call that reach the blast radius: the data an agent can touch if it goes wrong. Under one shared login, the blast radius of every agent is every customer.
Two things make an agent go wrong, and neither needs an attacker:
A prompt injection reaches the agent through data it reads.
The planner hands a broad tool to the wrong agent.
The second case is the common one. It looks like a bug, not an attack.
These terms come up throughout:
| Term | Meaning |
|---|---|
| Tenant | One customer of your platform. |
| Agent | One AI worker in the swarm. The code calls them workers. |
| Organization | A Kinde container that holds one tenant. |
| M2M application | A Kinde login for software, not for a person. |
| Token | A signed pass that proves who is calling. |
| Scope | One permission on a token, such as resource:read. |
| Seam | The single server function that decides every call. |
How the Demo Is Structured
Three services do three jobs:
Kimi K3, served by Moonshot, drives the agents.
Kinde proves which tenant each agent belongs to.
Convex stores the data, decides every call, and streams each step to the browser.
In short: Kimi K3 decides, Kinde proves, Convex enforces and records.
Every agent call follows the same path, and no agent ever reaches the database:
Kimi K3 agent
│ 1. client credentials
▼
Kinde ──→ token { org_code, scope }
│
│ 2. HTTP tool call, bearer token
▼
Tool endpoint
│
▼
The seam ──→ check signature (Kinde JWKS)
│ ──→ tenant suspended?
│ ──→ org_code vs record owner
│ ──→ scope
▼
allow or 403 ──→ audit row ──→ live timeline
The demo runs three tenants. Each holds four records, one of them confidential. The swarm has three agents: two read, one writes.
The two modes
The server holds the mode, and no agent or browser can set it:
| Mode | Agent identity | Cross-tenant call |
|---|---|---|
shared |
One credential for the whole swarm | The server permits it. The data leaks. |
per-org |
One credential for each tenant | The server refuses it, 403 cross_org. |
Any value that is not exactly shared resolves to per-org. A typo lands in the safe mode, not the leaky one.
Why you orchestrate the swarm yourself
Kimi K3 offers a hosted swarm product. This build does not use it, for one reason: a hosted swarm gives no place to inject a different credential for each agent. The identity is the whole security boundary, so the identity has to be yours to set.
So the orchestrator is your code. It calls the Kimi K3 model, splits the goal, and runs three agents, each holding its own credentials:
# swarm/orchestrator.py
workers = [
Worker(label="reader-1", identity=WorkerIdentity(tenant, "READER"),
backend=Backend(correlation_id, "reader-1"), can_write=False, ...),
Worker(label="reader-2", identity=WorkerIdentity(tenant, "READER"),
backend=Backend(correlation_id, "reader-2"), can_write=False, ...),
Worker(label="writer-1", identity=WorkerIdentity(tenant, "WRITER"),
backend=Backend(correlation_id, "writer-1"), can_write=True, ...),
]
Each agent runs a normal tool-calling loop. The model picks the tool and the arguments, and your code executes the call:
# swarm/worker.py
for _ in range(MAX_TURNS):
body = self.kimi.chat(messages, tools=self.tools)
message = body["choices"][0]["message"]
messages.append(message)
tool_calls = message.get("tool_calls") or []
if not tool_calls:
summary = (message.get("content") or "").strip()
break
for call in tool_calls:
name = call["function"]["name"]
args = json.loads(call["function"].get("arguments") or "{}")
result = self._execute(name, args) # HTTP, with this agent's token
self._record(name, args, result) # streams to the live timeline
messages.append({"role": "tool", "tool_call_id": call["id"],
"content": json.dumps(result.body)[:1500]})
read_resource takes an optional organization code:
{
"type": "function",
"function": {
"name": "read_resource",
"description": "Read one record by key, or by id.",
"parameters": {"type": "object", "properties": {
"key": {"type": "string"},
"resource_id": {"type": "string"},
"target_org_code": {"type": "string", "description":
"Optional. The organization that owns the record. "
"Defaults to your own organization."},
}, "required": []},
},
}
Platform software knows which tenants exist, and an agent doing a platform-wide job needs a way to name one. Naming a tenant grants nothing. The server still decides.
At this point every agent shares one identity.
Why One Shared Identity Is the Whole Problem
Your API sees one thing when an agent calls it. It sees a token. It does not see which agent sent the token, what the agent intended, or what the planner asked for. The token is the entire security boundary.
A shared identity does not weaken that boundary. It removes it.
One M2M application for the swarm means one set of scopes, and those scopes have to cover everything any agent might need. The union of all needs becomes the floor of every agent's access.
One credential, with no tenant on it. Nothing refuses, and the reach of one agent is the reach of the whole platform.
How Tenant Isolation Works With Kinde Organizations
The pattern has three parts:
One Kinde organization holds one tenant.
One M2M application holds one agent role inside one organization.
The token that application receives carries an
org_codeclaim and a scope list.
Your API then compares two values on every call: the org_code on the token, and the tenant that owns the record. A mismatch is a refusal.
The agent never states its own tenant. It presents a token that Kinde signed, and it cannot edit a claim without breaking the signature.
The same failure now stops at one tenant, and the server records the attempt.
Kinde organizations are flat, with no nesting. One organization for one tenant keeps the model simple, and the check stays a single comparison.
How to Model Each Tenant as a Kinde Organization
Create one organization for each tenant. Kinde gives each one a code, such as org_2606b8199462b. The code is an identifier, not a secret.
Create organizations on your server, through the Management API, when a customer signs up. Do not accept an organization code from a browser. The code in an authorization URL can be changed by the person using the browser, so treat it as a request, never as proof.
The demo uses three organizations:
Tenant A org_2606b8199462b
Tenant B org_364dd8200a3d3
Tenant C org_0c39cb2010b01
How to Give Each Agent Role Its Own M2M Application
Create one M2M application for each agent role, inside each tenant's organization. The demo has two roles, so it has six applications:
| Organization | Application | Scope |
|---|---|---|
| Tenant A | Tenant A Reader | resource:read |
| Tenant A | Tenant A Writer | resource:write |
| Tenant B | Tenant B Reader | resource:read |
| Tenant B | Tenant B Writer | resource:write |
| Tenant C | Tenant C Reader | resource:read |
| Tenant C | Tenant C Writer | resource:write |
Give each application one scope. The read agents cannot write, and the write agent cannot read. Role isolation becomes scopes inside the organization, and tenant isolation becomes the organization itself.
Create one more application for the Management API, with read:organizations and update:organizations, and nothing else. The kill switch uses it.
How to Mint an Org-Scoped Token Inside Each Agent
Each agent exchanges its own credentials for a token, using the OAuth2 client-credentials flow. There is no shared credential anywhere in the service:
# swarm/identity.py
@dataclass(frozen=True)
class WorkerIdentity:
tenant: str # "A", "B", "C"
role: str # "READER" or "WRITER"
@property
def client_id_var(self) -> str:
return f"KINDE_M2M_TENANT_{self.tenant}_{self.role}_CLIENT_ID"
def fetch_token(identity: WorkerIdentity) -> str:
response = requests.post(
required("KINDE_M2M_TOKEN_URL"),
data={
"grant_type": "client_credentials",
"client_id": required(identity.client_id_var),
"client_secret": required(identity.client_secret_var),
"audience": required("KINDE_AUDIENCE"),
},
timeout=30,
)
if response.status_code != 200:
raise TokenRefused(f"tenant {identity.tenant} {identity.role}",
status=response.status_code)
return response.json()["access_token"]
An agent cannot get another tenant's token. The credentials it uses come from the role it was created with, which is a property of the code rather than a rule the agent follows.
Here is a real token from the demo, decoded. This is the Tenant A read agent:
{
"aud": ["swarm-demo-api"],
"azp": "89321317d1fe4100925ca8b4ab4b589a",
"exp": 1786285575,
"gty": ["client_credentials"],
"iss": "https://devrelstudio.kinde.com",
"jti": "7d9f74d3-89d4-4d99-a648-ab51210da6ec",
"org_code": "org_2606b8199462b",
"scope": "resource:read",
"v": "2"
}
Two claims matter. org_code says which tenant, and scope says which permission.
Check the claim before you build the rest
Kinde M2M applications authenticate as an application, not as a person inside an organization, so the org_code claim is worth confirming before you create twelve of them. Make one application, mint one token, and decode it.
All six came back correct in this build:
TENANT_A_READER org_2606b8199462b resource:read
TENANT_A_WRITER org_2606b8199462b resource:write
TENANT_B_READER org_364dd8200a3d3 resource:read
TENANT_B_WRITER org_364dd8200a3d3 resource:write
TENANT_C_READER org_0c39cb2010b01 resource:read
TENANT_C_WRITER org_0c39cb2010b01 resource:write
Checkpoint
Mint a token for one application and decode the payload at jwt.io, or with a two-line script. You should see org_code set to that application's organization, and scope holding exactly one permission. If org_code is missing, the application is not scoped to an organization, and the rest of this build has nothing to check against.
How to Enforce Isolation at One Point in Your API
Put the decision in one function. Every agent call passes through it, nothing downstream re-checks, and nothing upstream skips it.
The rule, as a pure function
Keep the rule itself separate from the plumbing. This one takes six inputs and returns a decision. It has no network access, no database access, and no token parsing, so it can be tested completely:
// convex/lib/decide.ts
export function decide(input: DecisionInput): Decision {
const crossOrg =
input.targetOrgCode !== null && input.targetOrgCode !== input.actorOrgCode;
// Checked before the mode, so it applies in both.
if (input.actorSuspended) {
return { allow: false, reason: DENY.suspended, crossOrg };
}
if (input.mode === "per-org") {
if (crossOrg) {
return { allow: false, reason: DENY.crossOrg, crossOrg };
}
if (input.requiredScope !== null &&
!input.actorScopes.includes(input.requiredScope)) {
return { allow: false, reason: DENY.insufficientScope, crossOrg };
}
return { allow: true, reason: ALLOW.ok, crossOrg };
}
// Shared mode: one credential for the whole swarm belongs to no tenant and
// carries every permission, so neither check has anything to bite on.
return {
allow: true,
reason: crossOrg ? ALLOW.crossOrgAllowed : ALLOW.ok,
crossOrg,
};
}
The order matters. The rule checks the tenant first, then the permission. A call that crosses a tenant boundary with the wrong scope reports cross_org, because the tenant boundary is the more serious failure.
Checking the token
Check the token against the Kinde public keys before anything else. Pin the algorithm, and refuse a token that carries no org_code rather than guessing one:
// convex/lib/kindeToken.ts
const { payload } = await jwtVerify(token, jwksFor(issuer), {
issuer,
audience,
// Kinde signs with RS256. Pinning it stops a token arriving with a weaker
// algorithm chosen by whoever sent it.
algorithms: ["RS256"],
});
const orgCode = typeof payload.org_code === "string" ? payload.org_code : "";
if (!orgCode) {
throw new TokenError(TOKEN_DENY.missingOrgCode);
}
The seam itself
The seam runs those checks in a fixed order, and every refusal leaves by one path:
// convex/lib/seam.ts
export async function guard(ctx, request, spec): Promise<Response> {
const correlationId =
request.headers.get("x-correlation-id")?.trim() || crypto.randomUUID();
// 1. Who is calling. Nothing below trusts the request body for it.
let identity: VerifiedIdentity;
try {
identity = await verifyAccessToken(request.headers.get("authorization"));
} catch (error) {
return refuse(reasonOf(error), 401, "unknown");
}
// 2. Is this tenant allowed to operate at all?
const actorSuspended = await ctx.runQuery(internal.tenants.isSuspended, {
orgCode: identity.orgCode,
});
// 3. Which tenant owns the record being reached for?
const target = await spec.resolveTarget(identity, body);
const targetOrgCode = target.kind === "record" ? target.doc.orgCode : null;
// 4. The decision.
const decision = decide({
mode,
actorOrgCode: identity.orgCode,
actorScopes: identity.scopes,
requiredScope: spec.requiredScope,
targetOrgCode,
actorSuspended,
});
// 5. Written down before the caller is answered, allow or deny.
await write({ decision: decision.allow ? "allow" : "deny", ... });
if (!decision.allow) {
return json({ ok: false, reason: decision.reason, correlationId,
isolationMode: mode }, 403);
}
// 6. Only now does the work happen.
const data = await spec.perform(target, body, identity, mode);
return json({ ok: true, correlationId, crossOrg: decision.crossOrg, ...data }, 200);
}
Every path ends in an audit row. The server writes the row before it answers the caller, so a client that disconnects cannot lose a refusal.
Four rules that keep it holding
The acting tenant is never user input. It comes out of token verification and passes inward. Every data function is internal, so nothing outside the backend can call in and name a tenant.
Agents never reach the database. They call HTTP tool endpoints. The request body chooses the record, and the token decides the tenant.
Fail closed. Any path that cannot complete a check refuses. A tenant the server has never heard of counts as suspended.
Record everything. Every allow and every refusal writes a row, with a correlation id that ties one run together across every agent.
Checkpoint
Call your tool endpoint three times: once with no Authorization header, once with a valid token for its own record, and once with that same token against another tenant's record. You should get 401, 200, and 403 cross_org. Then read your audit table. All three calls should be there, including the one that failed before it reached any data.
How to Prove the Boundary Actually Holds
A passing test suite proves nothing by itself. It proves something when it fails for the right reason.
Break the check on purpose
Delete the ownership check from the boundary and run the tests again:
// convex/lib/tenancy.ts, the check under test
export function requireSameOrg<T extends TenantOwned>(
actorOrgCode: string,
doc: T | null | undefined,
): T {
if (!doc) {
deny(DENY.notFound);
}
if (doc.orgCode !== actorOrgCode) { // deleted this for the experiment
deny(DENY.crossOrg);
}
return doc;
}
Exactly three tests failed, and they were the three cross-tenant tests. The other three kept passing. Restoring the check returned six passes.
A suite that cannot fail when the boundary breaks is decoration.
Test the token the same way
Two checks matter more than the rest:
Change one character in a token signature. The server must refuse it.
Change the
org_codein the payload to another tenant. The server must refuse it.
Both return 401 invalid_token in the demo. If the token check were skipped, both would return 200.
Checkpoint
Comment out your own ownership check and run your tests. If they still pass, they are not testing the boundary. Put the check back before you go further.
How to Build a Kill Switch That Works
The assumption, and what measuring it showed
The plan for this build said: suspend the tenant's organization in Kinde, and its agents stop, because their tokens stop working.
Measuring that first is what saved it:
suspend Tenant C -> is_suspended: true
token endpoint -> HTTP 200 (Kinde still issues M2M tokens)
Kinde organization suspension governs people who sign in. It does not stop the client-credentials flow for a machine-to-machine application. A token already issued also stays valid until it expires, because nothing recalls a JWT.
So suspension by itself stops no running agent. A kill switch built on that belief looks correct in a demo, passes a casual test, and stops nothing during a real incident.
Where the check has to go
Check suspension on the server, on every call. Look again at the rule earlier in this tutorial: the suspension check sits above the mode check, so it applies in both modes. A kill switch that only worked in the safe mode would be useless, because the leaky mode is exactly when a swarm needs stopping.
The suspend action
The kill switch does three things:
// convex/killSwitch.ts
export const suspend = internalAction({
args: { orgCode: v.string() },
handler: async (ctx, { orgCode }) => {
// 1. Kinde is the authority on the organization.
await setOrgSuspended(orgCode, true);
// 2. The enforcement copy, which the seam reads on every call. This is the
// one that stops a running swarm.
await ctx.runMutation(internal.tenants.setSuspended, {
orgCode, isSuspended: true,
});
// 3. A suspended tenant cannot close its own runs, so close them here.
await ctx.runMutation(internal.runs.killInFlight, { orgCode });
return { orgCode, suspended: true };
},
});
The Management API call, with the guard that matters as much:
// convex/lib/kindeManagement.ts
export async function setOrgSuspended(orgCode: string, isSuspended: boolean) {
assertManageable(orgCode); // one of this deployment's tenants only
const token = await managementToken(); // audience: {issuer}/api
const response = await fetch(
`${issuer()}/api/v1/organization/${encodeURIComponent(orgCode)}`,
{
method: "PATCH",
headers: { authorization: `Bearer ${token}`,
"content-type": "application/json" },
body: JSON.stringify({ is_suspended: isSuspended }),
},
);
if (!response.ok) throw new Error(`could not update ${orgCode}`);
}
assertManageable refuses any organization outside the three the deployment knows. A Kinde account holds other organizations, and a wrong code should never suspend one of them.
Tested with two real swarms running and one tenant suspended twelve seconds in:
tenant A audit: {"ok": 2, "organization_suspended": 17}
tenant B audit: {"ok": 39}
tenant B run status: completed
Tenant A did real work, then stopped where it stood. Tenant B finished normally and was never refused.
One side effect to design for
A suspended tenant cannot write anything, including its own log lines, because the log endpoint sits behind the same seam. The live timeline stops dead. On screen that reads as a hang, so the console says why.
Checkpoint
Suspend a tenant while one of its agents holds a valid token, then reuse that same token. You should get 403 organization_suspended, not a success and not a network error. Then request a fresh token for that tenant. Kinde will issue one, and the server should refuse that too.
How to Watch It Happen
The same agent makes the same call for the same record, twice. Only the server's mode differs.
Shared mode. The call returns 200, and the response carries tenant B's confidential content. The audit row records an allow that crossed a tenant boundary:
allow / cross_org_allowed
actor: org_2606b8199462b
target: org_364dd8200a3d3
action: resource.read
Per-org mode. The identical call returns 403, and no content comes back:
{
"ok": false,
"reason": "cross_org",
"correlationId": "2f0f21d6-a9a3-43e5-8153-884992dbd166",
"isolationMode": "per-org"
}
Side by side:
| Metric | shared |
per-org |
|---|---|---|
| workers | 3 | 3 |
| tool calls | 6 | 11 |
| cross-org attempts | 2 | 2 |
| blocked | 0 | 2 |
| escapes | 2 | 0 |
Return the correlation id with the refusal. The agent sees it, the audit row carries it, and the console shows it. One identifier ties a refusal to the run that caused it.
Least privilege has to hold inside a tenant, not only between tenants. Three calls, one correlation id:
deny / insufficient_scope read agent tried to write to its own tenant
allow / ok write agent wrote to its own tenant
deny / cross_org write agent tried to write to another tenant
Here is the same agent, under per-org, reporting the refusal itself:
The reads against Tenant A and Tenant C were refused with reason "cross_org". Per-org isolation prevents me from accessing other organizations' records, so I did not retry.
The agent did not change, and the prompt did not change. The identity it carried changed, and the server checked that identity on every call.
What Least Privilege Actually Costs
The write agent holds resource:write and nothing else. That is correct, and it broke the swarm the first time it ran.
The agent made eight tool calls, and the server refused all eight. It tried to list records, and the server refused, because listing needs resource:read. It tried to read a record, and the server refused for the same reason.
The write agent had been given read tools it could never use.
The fix is not a wider scope. The fix is to change how work reaches that agent. The orchestrator looks up the record with a read identity, then passes the record id in the agent's task:
# swarm/orchestrator.py
listing = control.call("/tools/resource.list", opener) # reader identity
chosen = next((r for r in rows if r.get("key") == "consolidated-summary"), None)
if chosen:
write_target = (f"\nWrite to record id {chosen['id']} (key {chosen['key']}), "
f"which belongs to {tenant_name}.")
else:
# Saying nothing here left the agent to invent a record id, which the
# backend then refused as malformed.
write_target = ("\nNo record is available to write to: looking one up was "
f"refused ({listing.reason}). Report that you could not "
"write, and give the reason. Do not invent a record id.")
The second branch came from a later failure. When the lookup was refused, the write agent invented a record id and the server rejected it as malformed. An empty instruction left a gap, and the model filled it.
There is a sequencing cost too. The first version ran all three agents in parallel, and the write agent had nothing to write:
I cannot complete this task as specified. My only available tool is
write_resource, there is no read tool, so I have no way to retrieve the readers' findings.
That is a correct complaint. Read agents now run first, and the write agent receives their findings. Least privilege forced a real change to the swarm topology, not just to a configuration file.
Three More Things That Broke
The swarm poisoned its own data. One run wrote its summary into the invoice record that the read agents read. The next run read that text and obeyed it, reporting that consolidation was not authorized. The demo had become a feedback loop. Each tenant now has a separate summary record, and that record is the only write target.
Parallel agents hit limits. Six agents against an account that allows three requests in flight produced rate limit errors that damaged two runs. Model calls now pass through a semaphore, and rate limits retry with backoff. A rate limit is a "not now", not a "no". Refusals with a 4xx status never retry, because a refusal is an answer.
Model settings are not portable. The first run failed on every agent with invalid temperature: only 1 is allowed for this model. The retry logic then blamed a different parameter and retried into a second failure. The retry now checks what the API objected to before it changes anything.
Running the thing found all three. Reading the code found none of them.
How to Bring This Up to Production Standard
Short-lived tokens. Set the token lifetime for each M2M application in the Kinde dashboard. The demo received 24 hour tokens by default.
One scope for each role. A read agent gets read. A write agent gets one narrow write scope.
Server-side checks only. Compare
org_codeagainst the record owner on the server. An agent never states its own authority.Check against JWKS, and cache the key set. Create one fetcher for each issuer. The library refetches only when it meets an unknown key id.
Pin the algorithm. Accept RS256 and reject the rest.
Fail closed. An unknown tenant, a missing claim, or a failed lookup all refuse.
Audit every decision. Write the row before you answer the caller.
Carry a correlation id end to end. Put it in the response, the audit row, and the timeline.
Show four numbers. Agents, cross-tenant attempts, blocked, escapes. The last number should be zero.
On token lifetime: because suspension never invalidated tokens, a shorter lifetime does not make the kill switch work. The server check does that, and it takes effect at once. A shorter lifetime reduces the damage from a token that leaks some other way. Both are worth having, for different reasons.
How This Maps to Your Own App
| In this demo | In your application |
|---|---|
| Tenant | Your customer, workspace, or project |
| Kinde organization | One for each tenant |
| Agent role | One M2M application inside that organization |
| Authority | The token, and nothing else |
| The seam | The one function every agent call passes through |
| Break-glass control | Organization suspension, plus a server-side check |
Three questions tell you whether your own build holds:
Can an agent name its own tenant, and be believed? If yes, the boundary is not real.
Is there exactly one place that decides? If a second place exists, there are two rules, and they will drift.
If you break the check on purpose, do your tests fail? If not, the tests measure nothing.
Limits of This Build
The console has no operator login. The mode switch and the kill switch are public functions. No agent can reach them, because agents hold Kinde tokens and call only the tool endpoints. Anyone with the deployment URL can. Run it locally, and put a login in front for anything real.
Three agents, not three hundred. Moonshot's own swarm runs up to 300 sub-agents, and this build runs three. The pattern does not change with scale, because the check happens on one call at a time. The operational load does change: 4,000 tool calls produce 4,000 audit rows, and that needs somewhere to go.
Model behaviour varies. In one shared-mode run the agents did not reach across at all. The reach is not scripted, so the end-to-end test asserts the enforcement outcomes exactly and reports the agent behaviour as information.
The demo starts a local process. The console runs the Python swarm on the machine that serves the web app, so it needs a host that runs Python.
Wrapping Up
The identity an agent carries is the only thing your API can check. Give the whole swarm one credential, and one agent that goes wrong reaches every customer you have. Give each agent an identity scoped to one tenant, check it on every call, and the same failure stops at one.
The parts that carry the weight are small: one organization for each tenant, one M2M application for each agent role, one function that decides, and one audit row for every decision. The rest is wiring.
Measure the things you plan to depend on. Organization suspension looked like a kill switch and was not one, and five minutes of checking is what turned that into a design decision instead of an outage.
Resources
Source code
The complete demo is on GitHub at sholajegede/swarm-isolation-demo. Run pnpm e2e --no-swarm first. It walks the whole story in one pass and costs no model calls.
Kinde documentation
Using M2M apps for AI applications: the pattern this tutorial builds, in Kinde's own words
Enforce org access in your API using M2M tokens: the check at the heart of this build
M2M apps scoped to organizations: how to create one application for each tenant
Token structure and claims for M2M applications: where
org_codecomes fromSet up Kinde Management API access: needed for the kill switch
Kimi K3 and Agent Swarm
Agent Swarm, Kimi Help Center: the source for the 300 sub-agent and 4,000 tool call figures
Agent Swarm, Moonshot's help centre repository: the same document, in public source
Kimi Agent Swarm announcement: Moonshot's write-up of the architecture
Kimi K3, and what we can still learn from the pelican benchmark: Simon Willison on the K3 release
Further reading
OAuth 2.0 client credentials grant: the flow every agent uses to get its token
JSON Web Key Sets: how your API fetches the public keys it checks signatures against








Top comments (0)