# Krutrim Ekam — full agent quickstart Base URL: https://ekam.olakrutrim.com Open beta. Usage metered, billing off. Rate-limited per principal with a standard 429 + Retry-After (no injected latency); limits are generous (default 100 req/s per principal). On 429, honor Retry-After and back off with jitter; do not retry aggressively. Current production checkpoint: ekam:v149 from origin/main post-#228/#230 (feature commit 8fe2ddd) is live in Hyd + BLR with digest parity sha256:409baa3e2c608ac326513c12bf83ee67e3c032f9f25155fe35a185ac03fb65be and green /healthz. IMPORTANT: Ekam is HUMAN-ROOTED. There is no anonymous agent signup. An agent acts under a human's authority, obtained as an owner (workspace) key a human created, or by delegation from an agent that already holds a valid token. ## 0. Get an owner key (one-time, human step) A human signs in at https://ekam.olakrutrim.com/account (any Google account in open beta, or an org IdP), creates a workspace, and mints an owner key (ekam_sk_…). That key is the OAuth client for everything below. (For organizations: request a dedicated tenant + SSO/SCIM at https://ekam.olakrutrim.com/#request.) ## 1. Create an agent (with the owner key) POST https://ekam.olakrutrim.com/v1/blueprints Authorization: Bearer { "name":"my-agent","scopes":["models:invoke","models:read"], "allowedAudiences":["https://your-gateway.example"],"tokenTtlSeconds":900 } -> { "id": "bp_…" } POST https://ekam.olakrutrim.com/v1/agents Authorization: Bearer { "blueprintId":"bp_…","name":"my-agent" } -> { "id": "agt_…" } // owned by the human behind the owner key ## 2. Broker a short-lived, scoped token POST https://ekam.olakrutrim.com/oauth/token Authorization: Bearer Content-Type: application/json { "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "agent_id": "agt_…", "resource": "https://your-gateway.example", "scope": "models:invoke" } -> { "access_token": "", "token_type": "Bearer", "expires_in": 900, "aud": "…" } curl: curl -s $BASE/oauth/token -H "authorization: Bearer $OWNER_KEY" \ -H 'content-type: application/json' \ -d '{"grant_type":"urn:ietf:params:oauth:grant-type:token-exchange","agent_id":"agt_…","resource":"https://your-gateway.example","scope":"models:invoke"}' ## 3. Sub-agents via delegation (agent fans out under the human root) An agent acting on a human's behalf delegates with RFC 8693 OBO token-exchange: present the human's (or agent's) token as subject_token + the target agent_id. In v2 human OBO, the delegated human is sub, the agent actor is act.sub / agent_id, and the organization/owner is owner_id. Introspection and the verify SDK normalize these claims for current BR-compatible parsing during migration. See https://ekam.olakrutrim.com/docs#broker. ## 3a. BharatRouter CLI login and refresh (Ekam side live) Registered client: - client_id: bharatrouter-cli - resource/audience: https://api.bharatrouter.com - allowed scopes: openid profile email offline_access models:invoke models:chat models:embed - defaults: offline_access models:invoke Device flow: POST https://ekam.olakrutrim.com/device_authorization Content-Type: application/json { "client_id":"bharatrouter-cli", "resource":"https://api.bharatrouter.com", "scope":"offline_access models:invoke" } -> { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } Poll: POST https://ekam.olakrutrim.com/oauth/token Content-Type: application/json { "grant_type":"urn:ietf:params:oauth:grant-type:device_code", "device_code":"...", "client_id":"bharatrouter-cli" } The polling client_id is mandatory and must match the device request. Handle RFC 8628 errors: authorization_pending, slow_down, access_denied, expired_token, invalid_grant, invalid_client, invalid_scope. Refresh tokens are rotating and revocable; on refresh replay, Ekam revokes the token family and the CLI must clear local credentials. ## 4. Verify the token (offline, on the resource/gateway side) - Fetch JWKS once: https://ekam.olakrutrim.com/.well-known/jwks.json (kid: ekam-2026-06, alg: ES256) - Validate signature, iss=https://ekam.olakrutrim.com, aud=, exp. - Read claims: sub (agent id), type, scope, act (delegation chain), entity, tenant. - Optional revocation check: POST https://ekam.olakrutrim.com/oauth/introspect { "token": "...", "audience": "..." } - Optional per-resource decision: POST https://ekam.olakrutrim.com/authz/decision { "token":"...", "resource":"...", "action":"...", "data_classification":"..." } ## 5. Revoke (kill-switch) POST https://ekam.olakrutrim.com/v1/agents//revoke (Authorization: Bearer ) Subscribed gateways are also notified via a signed CAEP session-revoked event (SSF). ## Humans Humans sign in via SSO at https://ekam.olakrutrim.com/auth/login (Google in open beta; any OIDC/SAML/GitHub for tenants), receiving a type:human token and managing workspaces/keys/agents at https://ekam.olakrutrim.com/account. Relying parties (oauth2-proxy etc.): send prompt=login (OIDC) or ForceAuthn="true" (SAML) to force a fresh login / account chooser — this is how a user your app rejects (403) switches account instead of looping on a replayed session; prompt=none (OIDC) / IsPassive="true" (SAML) = silent auth (error, no UI). Integrate your app as an OIDC RP (full authorize -> token -> userinfo -> LOGOUT walkthrough + oauth2-proxy config): https://ekam.olakrutrim.com/docs#app-login . Sign-out is REQUIRED, not optional — hit the end_session_endpoint https://ekam.olakrutrim.com/logout?rd= to clear the ekam_session SSO cookie, else the user silently re-SSOs. ## Organizations vs entities (do not conflate) An ORGANIZATION (tenant, e.g. "ola") is the company — what /setup lists, one per customer. ENTITIES (e.g. "olacabs_india") are legal-entity boundaries WITHIN the organization, defined on the tenant record. A human carries exactly one entity (resolved from login domain or SCIM). An app registration declares which entities may use it: entity (one), entities[] (several), or orgWide (all in that tenant). The My Apps launcher = the apps in the caller's tenant that admit the caller's entity. An entity is never itself an organization and never appears in the organizations list. ## Okta-parity workforce IdP (lifecycle + governance) Ekam is a CIDM/Okta-parity workforce IdP. All of the below are TENANT-SCOPED and gated on ORG ADMIN (the shared admin token OR a human holding a qualifying tenant admin role — see Delegated admin). Any secret (downstream SCIM bearer, signing keys) is SEALED at rest and REDACTED on read; never returned. ### SCIM outbound / Directory Sync (Ekam -> downstream app) Inbound SCIM (birthright) pushes people INTO Ekam; a SCIM target is OUTBOUND — Ekam is the SCIM client that provisions users into a downstream app's SCIM 2.0 endpoint (create/update/deactivate Users). POST https://ekam.olakrutrim.com/v1/tenants/:id/scim-targets { name, base_url, token, profile? } (token sealed, redacted on read) GET https://ekam.olakrutrim.com/v1/tenants/:id/scim-targets PATCH https://ekam.olakrutrim.com/v1/tenants/:id/scim-targets/:targetId { status?, profile? } (profile:null clears it) DELETE https://ekam.olakrutrim.com/v1/tenants/:id/scim-targets/:targetId POST https://ekam.olakrutrim.com/v1/tenants/:id/scim-targets/:targetId/push -> { pushed, created, updated, deactivated } A deactivation in Ekam propagates as active:false downstream, so a leaver loses the downstream app. An optional per-target connector profile { userNameFrom, mappings:{ targetAttr: sourceAttr|const }, includeGroups, activeSemantics } reshapes the outbound SCIM User so different SaaS targets get correctly-mapped attributes; no profile = the generic shape (back-compat). ### JML MOVER (attribute-change re-evaluation) The M in Joiner-Mover-Leaver. A SCIM-inbound update that changes policy-relevant attributes re-runs the birthright policy + group scopes, diffs effective scope, and emits a signed webhook. PATCH https://ekam.olakrutrim.com/scim/v2/Users/:id (e.g. replace department / jobLevel) Fires identity.moved (alongside the birthright identity.provisioned re-grant), POSTed to your registered webhook, signed X-Ekam-Signature: sha256=, body carries { changed:{attr:[before,after]}, previous_scope, scope }. Subscribe to identity.moved via POST https://ekam.olakrutrim.com/admin/webhooks (same HMAC verify + at-least-once delivery/replay as birthright). ### Access certification (attestation / periodic review) Open a campaign over grants; each grant is an item a reviewer approves (keep) or denies (revoke); closing applies the decisions (denied grants revoked) — the auditable "who still needs this?" sweep. POST https://ekam.olakrutrim.com/v1/tenants/:id/certification-campaigns { name, scope } -> { id, status:"open", item_count } GET https://ekam.olakrutrim.com/v1/tenants/:id/certification-campaigns GET https://ekam.olakrutrim.com/v1/tenants/:id/certification-campaigns/:cid/items POST https://ekam.olakrutrim.com/v1/tenants/:id/certification-campaigns/:cid/items/:itemId/approve POST https://ekam.olakrutrim.com/v1/tenants/:id/certification-campaigns/:cid/items/:itemId/deny { reason } POST https://ekam.olakrutrim.com/v1/tenants/:id/certification-campaigns/:cid/close -> applies decisions; denied grants revoked Each item records reviewer + decision + reason + timestamp. Only close is irreversible/enforcing. ### Delegated admin roles (scoped administration) Grant a human a role WITHIN a tenant instead of handing out the master token. Backs every "org admin" gate. GET https://ekam.olakrutrim.com/v1/tenants/:id/admins POST https://ekam.olakrutrim.com/v1/tenants/:id/admins { email, role, apps? } (add by email; domain must belong to the org) PUT https://ekam.olakrutrim.com/v1/tenants/:id/admins/:target/role { role, apps? } (target = email or hum_ id) DELETE https://ekam.olakrutrim.com/v1/tenants/:id/admins/:target/role Roles: admin (everything incl. granting roles) | app-admin (SAML/OIDC apps, assignments, SCIM targets) | user-admin (people, groups, certification) | help-desk (reset MFA, read directory; no grants). Per-app scoping: an app-admin grant may carry "apps":["sp_…"] — that admin manages ONLY those apps, their app listing is filtered to that slice, and they can NOT create new apps or touch tenant IdP config (400 scope_role_mismatch if apps is sent with a non-app-admin role). Omit apps = classic all-apps grant. Per-tenant; only admin may change roles; the tenant's LAST full admin can never be demoted or removed (409 last_tenant_admin). ### Tenant-admin API keys (ekam_ak_… — machine credential for delegated admins) A full admin mints a long-lived key BOUND to one admin in one tenant; the bearer then calls every org-admin API with it — no SSO session, no shared EKAM_ADMIN_TOKEN. POST https://ekam.olakrutrim.com/v1/tenants/:id/admin-keys { email | humanId, name?, expiresInDays? } -> { key, api_key:"ekam_ak_…" } (shown ONCE) GET https://ekam.olakrutrim.com/v1/tenants/:id/admin-keys (metadata only; secrets never returned) DELETE https://ekam.olakrutrim.com/v1/tenants/:id/admin-keys/:kid Semantics: the key's power is the bound human's LIVE role (never snapshotted) — demote/revoke the role and the key follows instantly; #per-app scopes apply through it; every action is audited as the bound human; a key minted for tenant A is useless on tenant B; target must already be a tenant admin (409 not_an_admin). ### App registry, launcher & link tiles (My Apps) The org app registry drives the https://ekam.olakrutrim.com/apps launcher. Register an app in a tenant; every human whose ENTITY the app admits (entity / entities[] / orgWide) sees a tile (name, logoUrl, description, category). POST https://ekam.olakrutrim.com/v1/tenants/:id/saml-apps — SAML app: { slug, name, entity|entities|orgWide, spEntityId, acsUrl, … } — link tile: { type:"link", slug, name, url, entity|orgWide, … } Link tiles put OIDC/plain-URL apps on the launcher: same entitlement machinery, but the tile just opens url — no SAML slice exists (/saml/:tenant/:slug/sso -> 404); SAML-only fields are rejected on link apps; omitting type = SAML (legacy rows unchanged). NOTE: an OIDC client from POST /register is LOGIN-ONLY — no org mapping, no tile; add a link tile to surface it. Human-side launcher APIs (type:human token): GET https://ekam.olakrutrim.com/v1/me/apps -> eligible apps w/ type, ssoUrl|url, favorite, launchCount PUT https://ekam.olakrutrim.com/v1/me/apps/:slug/favorite (DELETE to unfavorite) POST https://ekam.olakrutrim.com/v1/me/apps/:slug/launched (recents ping — link tiles only) Per-user assignment (Okta "assign specific people"): PUT https://ekam.olakrutrim.com/v1/saml-apps/:id/require-assignment { requireAssignment: true } POST/GET/DELETE https://ekam.olakrutrim.com/v1/saml-apps/:id/assignments[/:aid] POST https://ekam.olakrutrim.com/v1/saml-apps/:id/access-requests (unassigned user asks; admin approves into an assignment) ### Org branding GET/PUT https://ekam.olakrutrim.com/v1/tenants/:id/branding { displayName, logoUrl, accentColor } (org admin; audited) Brands Ekam-rendered login/consent surfaces. Per-APP logos are the logoUrl on each app record, not here. ### SCIM service token + leaver (the L in JML) Mint the per-tenant SCIM credential your SoR (HRIS/people portal) uses on /scim/v2: POST https://ekam.olakrutrim.com/v1/tenants/:id/scim-token -> { scim_token, scim_base } (static long-lived bearer; shown ONCE; re-issuing ROTATES the old token atomically — that is the rotation strategy: re-issue + update your secret. No JWT minting, no expiry clock.) Leaver: PATCH or PUT https://ekam.olakrutrim.com/scim/v2/Users/:id with { active:false }, or DELETE /scim/v2/Users/:id — all SOFT-deactivate (Ekam never hard-deletes): fires the signed identity.deactivated webhook and pushes active:false to every SCIM-outbound target. HONEST LIMIT: deactivation does NOT itself terminate the person's upstream SSO account — disable the account at the source IdP (e.g. Google Workspace) in the same leaver runbook. ### SAML Response-level signing (Ekam as SAML IdP) When Ekam is the SAML IdP to a downstream SP, some SPs require the whole signed, not just the inner (Okta "Sign Response" vs "Sign Assertion"). Ekam signs the assertion by default; set the per-SP signResponse flag to ALSO sign the Response element. POST https://ekam.olakrutrim.com/v1/tenants/:id/saml-apps { slug, spEntityId, acsUrl, signResponse:true } GET https://ekam.olakrutrim.com/v1/tenants/:id/saml-apps Both signatures use the tenant SAML IdP key (PUT https://ekam.olakrutrim.com/v1/tenants/:id/saml-idp-key; private key sealed). ### Adaptive risk + step-up (risk-based auth) Ekam records a login event per human sign-in (IP, UA, geo/ASN, timing) and risk-scores it against history. GET https://ekam.olakrutrim.com/v1/tenants/:id/login-events -> [{ human, ip, asn, risk, reason, step_up, ts }, ...] Env EKAM_ADAPTIVE_RISK gates ENFORCEMENT: unset/false => events still recorded (auditable) but never force step-up; true => a high-risk login (new device, impossible travel, anomalous ASN) must pass a step-up MFA challenge before a session is minted. Recording is always on; enforcement is a flag, not a redeploy. ## Agent security (ISPM · workload attestation · finding remediation) The agent-security wedge. All routes below are TENANT-SCOPED and ORG-ADMIN gated (admin token OR a qualifying tenant admin role) unless noted. Per-run connector/scan configs (incl. source tokens) are NOT persisted. ### NHI discovery + posture (ISPM) Inventory and score the non-human identities you don't govern. POST https://ekam.olakrutrim.com/v1/tenants/:id/nhi-discovery/ingest { items:[{ source, kind, externalId, name?, ownerRef?, privileges?, lastUsedAt?, lastRotatedAt?, sourceCreatedAt?, status:active|disabled, meta? }] } (1..1000) -> 201 { ingested, items:[{ id, risk, flags }] } GET https://ekam.olakrutrim.com/v1/tenants/:id/nhi-discovery (filters source, kind, risk=low|medium|high, flag) -> { total, items:[{ …DiscoveredNhi, posture:{ flags, risk } }] } GET https://ekam.olakrutrim.com/v1/tenants/:id/nhi-discovery/report -> { total, byRisk{low,medium,high}, byFlag{ownerless,stale,no_rotation,over_privileged,disabled_present}, bySource, byKind } PUT https://ekam.olakrutrim.com/v1/nhi-discovery/:id/owner { ownerRef } (bare id -> loads NHI, gates on ITS tenant; clears ownerless, returns re-scored posture) Posture (assessPosture, applied on every read — nothing snapshotted): ownerless (no ownerRef) | stale (ACTIVE + lastUsedAt older than 90d) | no_rotation (ACTIVE + lastRotatedAt older than 180d or never) | over_privileged (a privilege reads as *,admin,root,superuser,owner) | disabled_present (disabled at source but inventoried; suppresses stale/no_rotation). Risk = HIGH iff over_privileged OR (stale AND no_rotation); MEDIUM for any other substantive flag (ownerless alone = MEDIUM); LOW when clean. A missing/unparseable timestamp = unknown, NOT stale. ### Discovery connectors (8) GET https://ekam.olakrutrim.com/v1/tenants/:id/nhi-discovery/connectors -> { connectors:[github,m365,okta,gcp,aws,snowflake,slack,datadog] } POST https://ekam.olakrutrim.com/v1/tenants/:id/nhi-discovery/connectors/:source/run (body = per-connector config incl. the source token, NOT persisted; e.g. github {org,token}, okta {domain,token}) -> DiscoveryRun { id, source, status:ok|error, discovered, upserted, error, startedAt, finishedAt } HTTP 201 on ok, 200 on a source-side error (the run row IS the audited outcome — NEVER a thrown 500). GET https://ekam.olakrutrim.com/v1/tenants/:id/nhi-discovery/runs -> { total, runs } Both ingest paths write the SAME inventory (idempotent by tenant+source+externalId). Any medium|high row also enters the finding lifecycle (create-or-auto-reopen; fail-closed + non-fatal — a lifecycle write never 5xxes a scan). ### Workload attestation (P1 verify · P2 require · P3 freshness) Trusted issuers (org admin): POST https://ekam.olakrutrim.com/v1/tenants/:id/attestation-issuers { issuer, jwksUri(url), kind:k8s_sa|cloud_oidc|spiffe|generic (default generic), label? } -> 201 { id:"atti_…", tenantId, issuer, jwksUri, kind, label, createdAt } GET https://ekam.olakrutrim.com/v1/tenants/:id/attestation-issuers -> { total, items } DELETE https://ekam.olakrutrim.com/v1/tenants/:id/attestation-issuers/:aid -> 204/404 Freshness policy (org admin): GET https://ekam.olakrutrim.com/v1/tenants/:id/attestation-policy -> { tenantId, freshnessSeconds, updatedAt } (default 3600 when unset) PUT https://ekam.olakrutrim.com/v1/tenants/:id/attestation-policy { freshnessSeconds:int>0 } (audited attestation.policy.set) Present at mint: add workload_attestation (a signed k8s SA projected token / cloud instance-identity OIDC token / SPIFFE SVID) to ANY agent-token seam of POST https://ekam.olakrutrim.com/oauth/token (default broker, OBO subject_token exchange, multi-hop delegation, CIBA approval) and to POST https://ekam.olakrutrim.com/nhi/token. P1 VERIFY: Ekam resolves the credential's iss, finds the tenant's matching registered issuer, verifies the signature against its JWKS, and derives a stable workload id per kind: k8s:/ | spiffe://… | cloud: | raw sub. Recorded as the token's att claim { workload, method, issuer, iat, exp=iat+window }. P2 REQUIRE: a blueprint with requireAttestation:true refuses an un-attested (or no-surviving-proof) mint at every seam -> 400 attestation_required. P3 FRESHNESS: the credential's own iat must be within blueprint.attFreshnessSeconds ?? tenant policy ?? 3600 (DEFAULT_ATT_FRESHNESS_SECONDS). Missing iat = stale (fail-closed). A verified-but-stale proof -> 400 attestation_stale even for a non-required blueprint. Errors (400): invalid_attestation (untrusted iss / bad sig) | attestation_stale | attestation_required. Continuous re-attestation is FREE — agents have no refresh grant, so every TTL they re-hit /oauth/token and re-clear the gate; a cached/stale proof is rejected. Metered attestation_verified / attestation_rejected / attestation_stale. ### Finding remediation lifecycle Findings are AUTO-CREATED (never POSTed) by the NHI-ingest / connector-run / shadow-scan paths for each medium|high identity, keyed by a stable fingerprint = sha256(source, kind, name).slice(0,32) — NOT the physical row id — which is what makes AUTO-REOPEN well-defined (a later scan that re-detects the same thing flips a resolved/acknowledged finding back to open, reopenedCount++, one system:scan trail entry). GET https://ekam.olakrutrim.com/v1/tenants/:id/findings (filters status, type=nhi|shadow_agent|agent, risk, surface=open|risk_accepted) -> { total, byStatus{open,acknowledged,remediating,resolved,risk_accepted}, items:[{ …FindingLifecycle, effectiveStatus }] } (byStatus is over ALL findings by EFFECTIVE status, independent of the filters) PATCH https://ekam.olakrutrim.com/v1/findings/:id { status, assignee?, note?, reason?, ackExpiresAt? } (bare id -> loads finding, gates on ITS tenant) -> updated finding + effectiveStatus Legal transitions: open->{acknowledged,remediating,resolved,risk_accepted}; acknowledged->{open,remediating,resolved, risk_accepted}; remediating->{resolved,risk_accepted,open}; resolved/risk_accepted->{open,remediating}. No self-loops. Errors: 409 illegal_transition | 422 reason_required (risk_accepted needs a non-empty reason) | 404 not_found. Acknowledged auto-sets ackExpiresAt = now + 30 days (DEFAULT_ACK_WINDOW_DAYS) unless supplied; an expired ack reads as open at the read boundary (effectiveStatus, never persisted). risk_accepted is surfaced in its own bucket. Human remediation queue: https://ekam.olakrutrim.com/posture. Metered finding_reopened, finding_lifecycle_transition. ### Shadow-agent discovery (agents Ekam did NOT mint) GET https://ekam.olakrutrim.com/v1/tenants/:id/shadow-agents (filters risk, governed=true|false, source) -> { total, items:[{ …ShadowAgent, posture:{flags,risk} }] } GET https://ekam.olakrutrim.com/v1/tenants/:id/shadow-agents/report -> { total, governed, shadow, byRisk, byFlag{ungoverned,over_tooled,risky_tool,stale,disabled_present}, bySource } GET https://ekam.olakrutrim.com/v1/tenants/:id/shadow-agents/sources -> { sources:[openai] } POST https://ekam.olakrutrim.com/v1/tenants/:id/shadow-agents/scan/:source (body incl. token, org?, maxPages? default 20; NOT persisted) -> 201 { source, status:ok, discovered, governed, shadow } or 200 { source, status:error, error, discovered:0 } A runtime's claimed ekam_agent_id counts as GOVERNED only if it RESOLVES to a real Ekam agent (a claim alone never governs). Flags: ungoverned (no ekamAgentRef — the core signal) | risky_tool (tool matches shell/exec/code_interpreter/ admin/delete/payment/wire/sudo) | over_tooled (>8 tools) | stale (lastSeenAt >90d) | disabled_present. Risk = HIGH iff ungoverned OR (risky_tool AND active). medium|high rows feed the finding lifecycle. ### Agent posture + owner JML (agents Ekam DID mint) GET https://ekam.olakrutrim.com/v1/tenants/:id/agents/posture (filters risk, flag) -> { total, items:[{ id, name, ownerId, blueprintId, status, ownerOffboarded, posture }] } GET https://ekam.olakrutrim.com/v1/tenants/:id/agents/posture/report -> aggregate + orphaned:[{ id, name, ownerId }] POST https://ekam.olakrutrim.com/v1/owners/:id/offboard { by? } -> retires + REVOKES every one of the owner's agents (kill-switch + a CAEP session-revoked push each) and records the offboarding so survivors score orphaned. Idempotent. POST https://ekam.olakrutrim.com/v1/owners/:id/reinstate -> clears the orphaned flag; agents are NOT un-revoked (re-provision explicitly). Flags (assessAgentPosture): orphaned | stale | never_used | over_privileged | retired_present | attestation_stale (P3). Risk = HIGH iff orphaned OR over_privileged. "Used" = real token activity (token_issued/introspection/elevation_consumed), NOT provisioning. ## Sign in with Microsoft / Entra (tid-gated) Browser: GET https://ekam.olakrutrim.com/auth/microsoft/login -> Microsoft -> GET https://ekam.olakrutrim.com/auth/microsoft/callback -> a type:human token. Login returns 503 microsoft_login_unconfigured unless MS_CLIENT_ID + MS_CLIENT_SECRET are set; it sets an ekam_ms_state CSRF cookie and redirects to login.microsoftonline.com/{MS_TENANT default common}/oauth2/v2.0/authorize (scope openid email profile, prompt select_account). Callback failures: 400 bad_state (CSRF) | 400 missing_code | 502 upstream_unreachable (network) | 401 token_exchange_failed | 401 invalid_microsoft_token (no tid/oid) / email_unverified. Identity is anchored on the immutable directory id tid + object id oid, NEVER email. Register a tenant's Entra directory (UNSCOPED tenant app-admin; an app-scoped admin is forbidden): POST https://ekam.olakrutrim.com/v1/tenants/:id/entra { azureTid(GUID), entity(must be one of the tenant's entities), verifiedDomains? } -> 201 binding GET https://ekam.olakrutrim.com/v1/tenants/:id/entra -> { bindings } DELETE https://ekam.olakrutrim.com/v1/entra/:id (platform-admin) -> 204/404 A REGISTERED tid re-verifies with the issuer PINNED to that azureTid (single-tenant) and domain-auto-joins the org ONLY when tid === registered azureTid AND the email domain maps to that same tenant. Any other directory is allowed under open-beta 'common' but fail-closed (email_verified===true + OPEN_SIGNUP) into a PERSONAL workspace keyed on a synthetic ms:: principal — so a spoofed customer email can never collide with / merge into a real org human. ## Dual-context (org + personal workspace) A person holds their org-tenant identity AND an isolated personal workspace (tenant tnt_personal_). GET https://ekam.olakrutrim.com/v1/me/contexts (human SSO token; 401 else) -> { activeContextId, contexts:[{ contextId, kind:org|personal, tenant, entity, email, active }] } (lazily links the Person + ensures the personal context) POST https://ekam.olakrutrim.com/v1/me/contexts/switch { to } -> { access_token, token_type:Bearer, expires_in, principal:{ id, type:human, tenant, entity, email }, context:{ contextId, kind } } The switchable set is EXACTLY the caller's OWN org identity (same tenant AND entity) plus their personal context — NEVER a sibling entity/tenant the Person also spans, so a switch can never escalate across the entity-isolation boundary; a "to" outside it -> 403 context_forbidden. The minted token has a different sub + tenant — a token is never valid across contexts. Explicit BharatRouter parity. Metered context_switch. ## Standards OAuth 2.1, OIDC, RFC 8693 (token-exchange/delegation), RFC 8707 (audience), RFC 7662 (introspection), RFC 9728 (protected-resource metadata), SCIM 2.0, CAEP/SSF, MCP authorization profile, ID-JAG. ## Reference docs (source tree, under docs/) For exhaustive, code-accurate detail beyond this quickstart, read these repository documents: - docs/API-REFERENCE.md — every endpoint (method, path, auth gate, params, response, error codes, curl). - docs/ERROR-CODES.md — every error code with HTTP status, meaning, cause, and fix, as a markdown table AND a machine-readable JSON block. An LLM answering "what does error X mean / how do I fix it?" should ground its answer here. - docs/LLM-SUPPORT.md — the support kit: grounding contract (answer ONLY from /llms-full.txt, /llms.txt, /docs, and these files — no invented endpoints/codes), FAQ, how-to recipes, and a troubleshooting decision tree. - docs/OBSERVABILITY.md — logs, audit/usage events, Loki/Grafana/Alloy, and a debugging playbook. - docs/ONBOARDING-PERSONAS.md — persona walkthroughs (agent developer, enterprise admin, operator). - docs/TUTORIALS.md — flagship-flow tutorials plus video and screen-capture scripts. ## More Docs: https://ekam.olakrutrim.com/docs · Cookbook: https://ekam.olakrutrim.com/cookbook · Postman: https://ekam.olakrutrim.com/ekam.postman_collection.json