Agent provisioning
Stand up new billable child tenants with a single authenticated API call — no human, no inbox.
Normal account creation needs a human to read an OTP email. That's a wall for an agent, a CI job, or a fleet that needs to provision its own isolated workspaces on demand. Agent provisioning removes the human from every account after the first: one human-onboarded, payment-backed parent tenant mints a provisioning key once, and from then on an agent can create as many child tenants as it needs with a single API call.
This is Tier-1: operator/internal provisioning. Provisioning keys are minted by an org owner/admin
(SETTINGS_UPDATE) for that org's own use — it is not yet a self-serve, publicly documented
reseller product.
Minting a provisioning key
A provisioning key (tt_prov_…) is minted from an already-authenticated,
cookie-based session — never from a PAT or another provisioning key (that
privilege-chaining path is closed):
mutation {
createProvisioningKey(input: { name: "sandbox-fleet", maxTenants: 50, expiresInDays: 90 }) {
id
secret # shown once
}
}maxTenants and expiresInDays are both optional; omitting maxTenants
means unlimited child tenants for that key. The key carries exactly one
scope, provision:write, and is bound to the tenant and user that minted
it. Treat it as a high-privilege, server-side-only secret — it can mint
billable tenants and must never ship in client code.
Provisioning a tenant
curl https://api.aisetu.ai/provision/tenant \
-H "Authorization: Bearer $AI_SETU_PROVISIONING_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Child Co",
"region": "in",
"billingMode": "delegated"
}'{
"tenantId": "...",
"workspaceId": "...",
"apiKey": "tt_live_...",
"topUpUrl": "https://..."
}region (eu | in) defaults to the pod's own region if omitted.
billingMode defaults to delegated. Secrets in the response are shown
once — there is no way to retrieve apiKey again after this call.
Programmatically, the same call is admin.tenants.provision({...}) from
@ai-setu/admin, which throws a clear error if the client wasn't
constructed with a provisioning key — a PAT alone cannot provision tenants,
by design. An MCP provision_tenant tool is also available and registers
only when AI_SETU_PROVISIONING_KEY is set on the MCP server; see
MCP for the full tool list and setup.
Billing modes
| Mode | Who pays |
|---|---|
delegated (default) | The child tenant funds itself via the returned topUpUrl — identical to a normal new org that starts at $0. It has its own Stripe customer and wallet. |
pooled | The parent's wallet. The child gets no Stripe customer and no top-up link (topUpUrl is null); top-ups on the child are rejected with a message pointing at the parent. Usage is still recorded and reported per child tenant and workspace. |
Pooled billing
Use pooled when every end-customer needs its own organisation but you want
one billing account. (If one organisation with a workspace per customer is
enough, those workspaces already share one wallet.)
POST /provision/tenant
Authorization: Bearer tt_prov_…
{ "name": "Tenant 42", "billingMode": "pooled" }Each request a pooled child makes is reserved against and debited from the parent's wallet; the usage record keeps the child's tenant and workspace, and the parent's ledger rows are tagged with the child they were for. The response's balance headers describe the parent's wallet.
Manage children with GraphQL on /graphql (a PAT with billing:manage; the
tenantId argument is your own organisation as the parent), or the admin SDK:
| GraphQL | @ai-setu/admin | |
|---|---|---|
childOrgs(tenantId, first, after, from, to) | admin.childOrgs.listPage / iterate | Your child orgs with per-child usage for the period (default: this UTC month to date). |
parentUsageRollup(tenantId, from, to) | admin.childOrgs.usageRollup | One row per organisation + workspace across you and all your children. |
adoptChildOrg(tenantId, input) | admin.childOrgs.adopt | Attach an existing organisation (you must be its owner), or switch a child between POOLED and DELEGATED. |
detachChildOrg(tenantId, childTenantId) | admin.childOrgs.detach | The child becomes a standalone, self-funded organisation. |
import { AiSetuAdmin } from '@ai-setu/admin';
const admin = new AiSetuAdmin(); // AI_SETU_PAT
await admin.childOrgs.adopt(parentTenantId, {
childTenantId,
billingMode: 'POOLED',
transferBalance: true, // move the child's remaining balance into your wallet
});
for (const row of await admin.childOrgs.usageRollup(parentTenantId)) {
console.log(row.tenantName, row.workspaceName, row.totalMicros);
}Rules:
- Pooling an organisation whose wallet is not empty requires
transferBalance: true; the balance moves into the parent's wallet in the same transaction (the total across both wallets is unchanged). - One level deep: a pooled child cannot pay for pooled children of its own. Parent and child must be in the same region.
- Changes reach the gateway within about 10 seconds.
- Spend caps bind to the child: a budget on the child's workspace keeps working under pooled billing, so with one workspace per customer it is the per-customer cap.
Quotas
maxTenants on the key (if set) is enforced atomically per key: the slot
is reserved in a single guarded update before any tenant is created, and
handed back if creation fails. Concurrent calls can never overshoot the
limit, and only tenants created by that specific key count, never a
parent-wide or cross-key total. The N+1 call over the limit is refused with
QUOTA_EXCEEDED before anything is created.
Limits
| Limit | Value | When exceeded |
|---|---|---|
POST /provision/tenant rate, per provisioning key | 60 / minute and 600 / hour (self-hosted: PROVISION_RATE_LIMIT_PER_MINUTE, PROVISION_RATE_LIMIT_PER_HOUR) | 429 with Retry-After (seconds). Nothing is created. Keyed by the key id, not the IP. |
maxTenants per key | Optional, up to 100,000 | QUOTA_EXCEEDED |
| Workspaces per organisation | 5,000 live workspaces (self-hosted: MAX_WORKSPACES_PER_TENANT) | QUOTA_EXCEEDED, details.reason = "workspace_limit_reached" |
createWorkspaces batch | 1 to 100 items per call | VALIDATION_ERROR |
workspacesPage page size | default 50, max 200 | clamped to 200 |
| Every API route (global) | 300 requests / 60 s per user or IP | 429 |
POST /provision/tenant is not idempotent: every successful call creates a
tenant, so keep your own dedupe key and store the returned tenantId.
createWorkspaces is idempotent on slug.
One organisation, many workspaces (backfill)
If you want one workspace per your customer inside a single AI Setu
organisation (one account, one invoice, isolation per workspace), you don't
need provisioning keys. Use a PAT with workspace:create and
settings:update and the bulk workspace API. This is also the path for
backfilling existing customers.
Put your tenants in a CSV with a stable slug (the slug is the idempotency key; lowercase letters, digits and hyphens, max 64 characters):
name,slug
Acme Retail,saar-1001
Beta Foods,saar-1002export AI_SETU_PAT=tt_pat_...
ai-setu workspaces create --from-file=tenants.csv --out=keys.json
echo $? # 0 = all rows created or already existed, 1 = at least one row failedThe CLI sends batches of 100. Each row is its own transaction, so a failure
never aborts the rest, and re-running the same file is safe: a slug that
already exists comes back as exists with its workspace id and no new key.
--out writes the one-time API keys to a 0600 file instead of printing
them. Store them in your secret store straight away; they are never shown
again (mint another with ai-setu keys create --workspace=<id> --name=<n>).
JSON input ([{"name": "...", "slug": "..."}]) and --no-key are also
supported.
The same operation from code:
import { AiSetuAdmin } from '@ai-setu/admin';
const admin = new AiSetuAdmin();
const results = await admin.workspaces.createMany(
tenants.map((t) => ({ name: t.name, slug: `saar-${t.id}` })),
);
for (const r of results) {
if (r.apiKey)
await secretStore.put(tenants[r.index].id, r.apiKey); // shown once
else if (r.error && r.error !== 'exists') console.error(r.index, r.error);
}or as GraphQL:
mutation ($input: CreateWorkspacesInput!) {
createWorkspaces(input: $input) {
index
workspaceId
slug
apiKey
error
}
}Per-row error is null on success, or one of exists (an idempotent
no-op, not a failure), workspace_limit_reached, invalid_name,
invalid_slug, slug_unavailable (slug held by a deleted workspace) or
internal_error (retry). Run batches sequentially: creation is
serialised per organisation, so parallel batches only queue and spend your
request budget.
For the ongoing path (a customer signs up in your product), call the same
operation with one item. To list every workspace, page with
workspacesPage(first, after, search) or for await (const w of admin.workspaces.iterate()); the older unpaginated workspaceList is
deprecated and capped at 1,000 rows.
Scope and isolation
- The provisioning verifier only ever accepts the
tt_prov_prefix; it is a separate credential kind from both workspace API keys (tt_live_/tt_test_) and PATs (tt_pat_). Att_prov_key is rejected on every ordinary workspace/PAT-authenticated route, and a PAT is rejected onPOST /provision/tenant. - The
provision:writescope is checked twice — once when the credential is resolved, once again inside the provisioning service — so a bug in one layer can't turn into a privilege escalation in the other. - Every child tenant is tagged with the provisioning key and parent tenant that created it, and every provisioning call writes an audit log entry.
- Workspaces remember the personal access token that created them. Give a
runtime token's role
workspace:delete_owninstead ofworkspace:delete: it can then delete only the workspaces it created (its own end tenants), never the platform workspace or another token's. The same rule applies topurgeWorkspaceData, so the token can also erase the data of the workspaces it deleted. Fullworkspace:deleteremains the operator-level grant. This narrows workspace deletion only: the same token still needssettings:updateto mint keys, and that permission reaches every workspace's keys and configuration, so run it under a dedicated service member with a custom role.
What's next
Key rotation/revocation UI, an IP allowlist, a max-spend cap, an
organisation-level spend cap for pooled children, a console view of child
organisations, and parent-side suspend of children are not available yet. (The per-key rate limit and the atomic
maxTenants quota have shipped; see Limits.)