Multi Tenant
tenant is one of three dimensions on ActAuth's Scope — alongside
environment and agent — that permission rules resolve against.
run-agent.ts builds it as { tenant: options.tenant ?? 'default', environment: process.env.LOOPENGINE_ENV ?? 'production', agent: config.name } on every call, so an agent invoked with no tenant
resolution at all (a standalone script, a CLI call) always runs as the
plain 'default' tenant — the same outcome as omitting tenantFor
entirely.
Resolving a tenant: tenantFor
Only adapters/http.ts can call AgentConfig.tenantFor — it alone has
a request's headers to call it with. It reads from headers, never the
request body: tenant feeds permission decisions directly, so it has to
come from something verified (an API key, an Authorization header)
rather than a raw client-asserted field anyone could set to claim a
different tenant's rules.
import type { AgentConfig } from 'loopengine'
// Stands in for a real lookup (a database, an auth provider) a
// production deployment would use instead.
const API_KEY_TENANTS: Record<string, string> = {
'acme-trusted-key': 'acme-corp',
}
function tenantFor(headers: Record<string, string | string[] | undefined>): string | undefined {
const apiKey = headers['x-api-key']
if (apiKey === undefined) return 'default' // no key at all -> the plain default tenant, not a rejection
if (typeof apiKey !== 'string') return undefined
return API_KEY_TENANTS[apiKey] // unrecognized key -> undefined -> rejected
}
export const config: AgentConfig = {
// ...
tenantFor,
}
(agents/customer-service/index.ts's real version.) Returning
undefined here is a genuine auth failure — adapters/http.ts responds
401 — not "fall back to 'default'"; a caller presenting a key at all
is asserting an identity, and silently ignoring a bad one is exactly the
kind of thing that's confusing to debug. There's no equivalent
environment field: environment is a deployment-wide
LOOPENGINE_ENV setting, not something that varies by agent or by
request.
How tenant actually changes behavior
actauth.yml scopes are written tenant/environment, matched
most-specific-first — an exact tenant beats a * wildcard for the same
tool. Same rule set, same agent code; the scope resolution alone is
what picks a different decision per tenant:
- name: refund-staging-always-allowed
scope: "*/staging"
tool: issue_refund
decision: allow
- name: refund-acme-corp-production-allowed
scope: acme-corp/production
tool: issue_refund
decision: allow
- name: refund-production-needs-approval
scope: "*/production"
tool: issue_refund
decision: ask
(agents/customer-service/actauth.yml's real rules.) Every tenant skips
approval in staging; in production, acme-corp — a caller presenting
the trusted API key above — gets the same auto-allow a trusted,
established account might earn in a real deployment, while every other
tenant still needs a human to approve an actual refund.
Tenant also namespaces session storage — see Session
Persistence's ${tenant}:${environment}:${agentName}:${rawSessionId}
key — so two tenants whose session id happens to collide never read or
write the same underlying conversation log.