Agent-driven onboarding & IAM-as-code
An AI agent in your editor can open a Zynth Auth organization on your behalf and configure its IAM — roles, resource permissions, invitations, an SSO scaffold — with exactly one human review. The human is always the owner; the agent never sets or sees a human credential; everything the agent does is audited from birth.
Availability: the bootstrap surface is deployment-gated (
ONBOARD_BOOTSTRAP_ENABLED). When off, the endpoints answer 404.Private beta: while public sign-up is closed (
PUBLIC_SIGNUP_ENABLED=false, the default), the bootstrap request must carry the tester's beta invite code asinvite_code— the code issued to the human's email by the operator (see Joining the private beta). Without a valid one the door answers 403 with a reason naming the early-access page; every other cause (unknown, expired, revoked, already used, wrong email, wrong door) answers the same 403. The invite is consumed when the organization is created, not at the door.
1. The bootstrap handshake (RFC 8628-shaped)
The agent starts with no credential — no account exists yet:
curl -X POST https://auth.zynthmedia.com/api/v1/onboard/bootstrap \
-H "Content-Type: application/json" \
-d '{
"organization_name": "Acme Robotics",
"human_email": "founder@acme.example",
"client_info": {"name": "claude-code", "platform": "cli", "model": "claude-fable-5"},
"manifest": { "manifest_version": 1, "roles": [] }
}'
The response carries a device_code (the agent's polling handle — shown once, ever), a
short user_code for the human, a verification_uri, expires_in, and the polling
interval. The agent shows the human the code and polls:
curl -X POST https://auth.zynthmedia.com/api/v1/onboard/poll \
-H "Content-Type: application/json" -d '{"device_code": "…"}'
Poll answers: authorization_pending → keep polling at interval (faster gets a
429 slow_down); denied; 410 when the request expired (ask again — a repeat request
for the same email rotates fresh codes, it never stockpiles); and on approval, one
poll — exactly one, ever — carries the agent's own credential:
{"status": "approved", "agent": {"agent_id": "…", "name": "claude-code", "credential": "agt_…"}}
Meanwhile the human opens the verification page, enters the code, confirms their
email (the emailed link is spent by an explicit click — never by a scanner opening it),
reads the resolved plan of whatever manifest the agent parked, sets a passkey
(or password), and approves — or denies, which tears everything back down. Until approval the new tenant is
quarantined: invitations, OAuth clients, webhooks, and API keys answer
403 with the reason; signing in and deciding stay available.
Abuse posture (why your first request may be refused): fail-closed per-IP, per-network,
and global rate budgets; disposable email domains refused; optionally a proof-of-work
challenge (POST /api/v1/onboard/pow; solve sha256(challenge + "." + nonce) to the
stated leading-zero-bits difficulty when pow_required is true).
2. The manifest — IAM-as-code, one review
Instead of asking a human to approve N separate actions, the agent declares one
manifest (manifest_version: 1):
{
"manifest_version": 1,
"roles": [
{"code": "support-desk", "name": "Support Desk", "permissions": ["command:read", "members:read"]}
],
"resources": [
{"oauth_resource_identifier": "https://api.acme.example", "namespace": "billing",
"permissions": [{"action": "read"}, {"action": "refund", "risk": 12}]}
],
"invites": [{"email": "teammate@acme.example", "role": "member"}],
"sso": {"display_name": "Okta", "issuer": "https://acme.okta.com", "client_id": "…"}
}
Rules of version 1, all refusals actionable: role permissions are exact codes (no
wildcards, and codes the same manifest's resources declare are grantable); resources
bind existing OAuth resources by identifier (v1 never creates them); invites can
never carry owner or customer (both are refused at parse time, and the SSO block's
default_role obeys the same rule — it lands in a membership exactly like an invite);
unknown keys are refused by name rather than silently dropped; the SSO block has no secret field — the connection scaffolds
disabled and a human completes the secret in the console; nothing is ever updated in
place (a collision is a conflict you resolve, not a silent overwrite).
The choreography (/api/v1/manifest/*, permissions manifest:plan / manifest:apply):
POST /plan— read-only diff against live state, in apply order, each step verdictedcreate/noop/conflict/invalid. Re-submitting a manifest later is your drift answer.POST /request-approval— parks the ask carrying the resolved plan, so the human decides on what the manifest would do rather than on its hash. The binding is the document + that resolved plan + the permission catalogue's version. Editing the manifest invalidates the ask (an edited manifest is a different manifest) — and so does live state moving so that the same document now resolves to a different plan.POST /apply— re-plans, refuses before spending the approval if anything now conflicts, if the resolved plan no longer matches the approved one, or if the manifest would confer — through a new role or an invitation — a permission the approver does not hold; then executes stepwise. A concurrent apply of the same manifest answers 409 (nothing was executed; wait and retry the identical manifest). Every step is audited under the manifest hash as its correlator (GET /api/v1/command/events?correlation_id=<hash>is the full story). Re-applying the unchanged manifest is a no-op that confirms the original spend; a crashed apply resumes by simply re-running.
During bootstrap this is one click — over a real review. A manifest parked with the
bootstrap is rendered on the approval screen as its resolved plan: every role with
the exact permission codes it would carry (write codes marked), every invitee with the
role they would hold and what that role confers, every resource binding, and the SSO
scaffold's issuer / default role / JIT flag. The approve carries the hash of that exact
plan, and the release mints the decided approval only for it — if the plan has moved,
or a client approves without naming it, no pre-approval is granted and the agent asks
through the ordinary approval queue instead. The released agent also holds time-boxed
manifest:plan / manifest:apply delegations either way. One review, one click, full
audit; the apply is bounded by what the approving human holds, exactly like any other
approval.
3. Over MCP: the toolset and the dry-run convention
Agents drive all of this through the first-party MCP server (POST /api/v1/mcp,
authenticated with the agent credential from the bootstrap — exchange it for a token and
point any streamable-HTTP MCP client at the endpoint, as in
Operating Zynth over MCP):
plan_manifest— the diff/drift answer (read-only).request_manifest_approval— parks the ask, carrying the resolved plan the human reads.apply_manifest— executes against the approval; refuses without one, and refuses withapproval_state: "plan-changed"when the same document would now do something else.
Every write tool answers a dry run: pass "dry_run": true in the tools/call
params to get "what would this do" with zero side effects and no approval spent —
apply_manifest's dry run returns the plan. Validate before you ask a human for
anything; refusals always say the fix.