Integration Guide
Foundation
Install the SDK, give the agent a verifiable identity, and route every tool through the secured pipeline.
Prerequisites
What: what you need before Step 1.
How: register an agent to get its
credentials, then put them in .env.
Runtime — enough on its own to run the baseline app:
- Node ≥ 18 (TypeScript) or Python ≥ 3.11
- An API key for your model provider
Register the agent — required before Step 2. From the Agent Trust Dashboard you get:
- a client id and client secret
- an mTLS certificate the SDK authenticates with
The Dashboard is also where you later change FGA policy and read the audit trail.
Environment variables — every variable used in this guide, in one place. Steps 1, 3 and 6 need none of them.
| Variable | Needed from | What it is / where it comes from |
|---|---|---|
ANTHROPIC_API_KEY | baseline | Your model provider's API key. The sample runs on Anthropic; the variable is the sample's, not the platform's. |
AITRUST_MANAGEMENT_URL | Step 2 | Base URL of your platform tenant. Required — AITrust() throws ConfigurationError without it. |
AITRUST_CLIENT_ID / AITRUST_CLIENT_SECRET | Step 2 | Agent credentials issued at registration — registering is the Deploy guide, Step 2; do that first, this guide assumes you already have these. AITRUST_CLIENT_ID is also the agent id the platform knows you by: it is what audit events and authorization rules are written against, so a wrong value fails silently rather than loudly. |
AITRUST_IDP_URL | Step 2 | SHIFT IDP base URL (token acquisition, CIBA). |
AITRUST_REALM | Step 2 | IDP realm. Set it to the realm your contact gives you. The SDK default is superapp, which is a different tenant — leaving it unset issues tokens nobody accepts. |
AITRUST_AUDIT_URL | Step 6 | Audit service base URL. Events are dropped silently if unreachable. |
AITRUST_AGENT_ID | Step 2 | The agent's stable id, written as agent:<client-id> on every audit event and authorization rule. Set it to your client id — not the UUID the Dashboard also shows. Without it the first tool call fails with MISSING_CONFIG: GuardConfig.agentId is required. |
AITRUST_PROXY_URL | Step 2 | Management proxy the SDK acquires tokens through, over mutual TLS. A separate host from AITRUST_MANAGEMENT_URL — ask for it by name. Required for any real tool call — without it the first one fails with proxyUrl is required for clientCredentials. |
AITRUST_PROXY_CERT_PATH / ..._KEY_PATH | Step 2 | The agent's mTLS pair. kobilctl deploy sets both for you; running against the platform from your laptop means pointing them at the PEMs yourself. |
The baseline app runs with just the model provider's key. The rest are only needed from the step named above.
Install the SDK
What: add the three SDK packages to your manifest.
How: the packages are served from KOBIL's own registry, so you point your package manager at it once, then install normally — no vendoring, no build step.
- The SDK ships from KOBIL's own registry, not public npm/PyPI — a plain
npm install @kobil/aitrustfails with a 404. - Ask your KOBIL contact for a package registry token (a read-only deploy token).
- No KOBIL account is needed; the token grants nothing but package downloads.
Configure the registry once, in your project root:
@kobil:registry=https://gitlab.kobil.com/api/v4/groups/3778/-/packages/npm/
//gitlab.kobil.com/api/v4/groups/3778/-/packages/npm/:_authToken=${KOBIL_REGISTRY_TOKEN}
[global]
extra-index-url = https://${KOBIL_REGISTRY_USER}:${KOBIL_REGISTRY_TOKEN}@gitlab.kobil.com/api/v4/groups/3778/-/packages/pypi/simple
Keep the token in an environment variable — don't commit it. Then install:
npm install @kobil/aitrust @kobil/kobil-agent-sdk @kobil/kobil-agent-audit --legacy-peer-deps
pip install kobil-aitrust kobil-agent-sdk kobil-agent-audit
--legacy-peer-deps is required. npm installs the SDK's
optional peers anyway, pulling @anthropic-ai/claude-agent-sdk, which needs
@anthropic-ai/sdk ≥ 0.93.0; an app on an older version fails with
ERESOLVE. The flag also keeps the packages flat in node_modules/@kobil, which the
vendoring step depends on.
Or declare them in your manifest and install as usual:
"dependencies": {
"@anthropic-ai/sdk": "^0.40.0",
"pdf-lib": "^1.17.1"
}
"dependencies": {
"@anthropic-ai/sdk": "^0.40.0",
"pdf-lib": "^1.17.1",
"@kobil/aitrust": "0.2.20-rc.4",
"@kobil/kobil-agent-sdk": "0.2.20-rc.4",
"@kobil/kobil-agent-audit": "0.2.20-rc.4"
}
dependencies = [ "anthropic>=0.40.0", "reportlab>=4.2.0", "pypdf>=5.1.0", ]
dependencies = [ "anthropic>=0.40.0", "reportlab>=4.2.0", "pypdf>=5.1.0", "kobil-aitrust", "kobil-agent-sdk", "kobil-agent-audit", ]
npm install resolves and node -e "require('@kobil/aitrust')" imports without error.
pip install -e . resolves and python -c "import kobil_aitrust" succeeds.
Agent identity
@kobil/aitrustkobil_aitrustWhat: give the agent a verifiable platform identity.
How: create one cached
AITrust instance — it holds the client credentials + platform URLs and is the handle every later step uses.
Before there was no SDK at all.
// No SDK — the agent has no platform identity. // The Anthropic client is the only thing constructed. const anthropic = new Anthropic();
import { AITrust } from "@kobil/aitrust";
import type { AITrustInstance } from "@kobil/aitrust";
let _sdk: AITrustInstance | null = null;
export async function getAgentSDK(): Promise<AITrustInstance> {
if (_sdk) return _sdk;
_sdk = AITrust({
agentId: `agent:${process.env.AITRUST_CLIENT_ID}`, // your client id — no fallback
timeoutMs: 120_000,
platformUrl: process.env.AITRUST_MANAGEMENT_URL,
managementUrl: process.env.AITRUST_MANAGEMENT_URL,
auditUrl: process.env.AITRUST_AUDIT_URL,
idpUrl: process.env.AITRUST_IDP_URL,
clientId: process.env.AITRUST_CLIENT_ID,
clientSecret: process.env.AITRUST_CLIENT_SECRET,
realm: process.env.AITRUST_REALM,
// mutual TLS: a separate host, and the cert pair kobilctl mounts at /certs
proxyUrl: process.env.AITRUST_PROXY_URL,
proxyCertPath: process.env.AITRUST_PROXY_CERT_PATH,
proxyKeyPath: process.env.AITRUST_PROXY_KEY_PATH,
});
return _sdk;
}
# No SDK — the agent has no platform identity. anthropic = AsyncAnthropic()
from kobil_aitrust import AITrust, AITrustConfig, AITrustInstance
_sdk: AITrustInstance | None = None
async def get_agent_sdk() -> AITrustInstance:
global _sdk
if _sdk is not None:
return _sdk
client_id = os.environ["AITRUST_CLIENT_ID"] # your client id — no fallback
_sdk = AITrust(AITrustConfig(
agent_id=f"agent:{client_id}",
guard_policy="standard",
timeout_ms=120_000,
platform_url=os.environ.get("AITRUST_MANAGEMENT_URL"),
management_url=os.environ.get("AITRUST_MANAGEMENT_URL"),
audit_url=os.environ.get("AITRUST_AUDIT_URL"),
idp_url=os.environ.get("AITRUST_IDP_URL"),
client_id=client_id,
client_secret=os.environ.get("AITRUST_CLIENT_SECRET"),
realm=os.environ["AITRUST_REALM"],
))
return _sdk
AITrust(config)AITrust(AITrustConfig(...))| Parameter | Required | What it is |
|---|---|---|
agentIdagent_id | yes | Stable agent identifier used for audit, in the form agent:<client_id>. |
clientIdclient_id / clientSecretclient_secret | yes | OAuth 2.1 credentials issued when you registered the agent. Used to acquire tokens over mTLS. |
managementUrlmanagement_url | yes* | Base URL of the Management API (FGA checks, approver routing, identity badge). |
auditUrlaudit_url | no | Base URL of the Audit API. Omit to disable platform audit. |
idpUrlidp_url · realm | yes* | Identity provider base URL + SHIFT IDP realm (set it; the default is another tenant) for token issuance. |
proxyUrlproxy_url | yes* | Management proxy that issues tokens over mutual TLS — a different host from managementUrlmanagement_url. Without it the first tool call fails with proxyUrl is required for clientCredentials. |
proxyCertPath / proxyKeyPathproxy_cert_path / proxy_key_path | yes* | The agent's mTLS pair. kobilctl deploy mounts them at /certs and sets both for you. |
timeoutMstimeout_ms | no | Per-call timeout in milliseconds (default 120000). |
* Every field also resolves from AITRUST_* env vars (falling back to
KOBIL_*), so you can pass an empty config and configure entirely by environment.
- The env var behind
platformUrlisAITRUST_PLATFORM_URL— notAITRUST_MANAGEMENT_URL. - The snippet above passes
AITRUST_MANAGEMENT_URLto bothplatformUrlandmanagementUrlexplicitly, which is why it works. - If you drop those explicit fields and rely on the environment alone, set
AITRUST_PLATFORM_URLtoo — otherwiseplatformUrlresolves to empty andAITrust()throws.
With valid creds + reachable URLs, getAgentSDK() resolves and await ai.health() returns
sdk: healthy. Compiles offline, but identity is only real against the platform.
health() also lists vault, guard and audit.
vault is gone and guard is a package this guide does not use, so both read "not installed"
on a correct install — expected, and enforcement does not depend on them; the platform decides (Step 4).
audit reads "not installed" too, even when the package is installed and importable:
the SDK loads it through a dynamic import that bundlers do not resolve, so under Next.js that line is uninformative
both locally and in the pod. Do not use health() to judge audit. Test it directly
instead — write an event and read it back (Step 6). Allow a few seconds: the trail is not
read-your-writes, so an immediate query can return an empty list for an event that was accepted.
Secured tool pipeline
@kobil/aitrustkobil_aitrustWhat: run every tool through the SDK's guarded pipeline instead of calling it directly.
How: swap the local tool runtime for the SDK's defineTool / registerTools /
toAnthropicToolsto_anthropic_tools, then dispatch the
secured tools. Your tool definitions don't change.
These samples run on Anthropic, hence the Anthropic converter. The SDK also ships
toOpenAITools and wrappers for OpenAI, LangChain and Vercel AI. The identity, policy and audit
steps are the same either way.
import { defineTool } from "./tools-runtime.js";
import type { ToolDefinition, ToolInput } from "./tools-runtime.js";
import { defineTool } from "@kobil/aitrust";
import type { ToolDefinition, ToolInput } from "@kobil/aitrust";
from .tools_runtime import ToolDefinition, ToolInput, define_tool
from typing import Any
from kobil_aitrust import ToolDefinition, define_tool
ToolInput = dict[str, Any] # convenience alias; the Python package exports the TS
# equivalent of ToolInput as a plain dict
import { toAnthropicTools } from "./tools-runtime.js";
const tools = createInsuranceTools({ /* ctx */ });
const anthropicTools = toAnthropicTools(tools);
// ...
const resultText = await tools[name].execute(input); // runs directly — no checks
import { registerTools, toAnthropicTools } from "@kobil/aitrust";
const ai = await getAgentSDK();
const defs = createInsuranceTools({ /* ctx */ }); // the same 5-field ctx the starter
// already builds at agent.ts:270 —
// claimsCtx, getAnthropic, visionModel,
// getFile, saveFile. Don't paste the
// comment literally; reuse that object.
const securedTools = registerTools(ai, defs); // wraps each tool
const anthropicTools = toAnthropicTools(securedTools) as unknown as Anthropic.Tool[];
// The double cast bridges the SDK's loose return type to Anthropic's stricter Tool[].
// It is safe here (shapes match) but it does switch off type-checking on tool schemas —
// so a typo in a parameter name will surface at runtime, not at compile time.
// ...
const resultText = await securedTools[name].execute(input); // full pipeline
from .tools_runtime import to_anthropic_tools defs = create_insurance_tools(ctx) anthropic_tools = to_anthropic_tools(defs) # ... result_text = await defs[name].execute(tool_input) # runs directly — no checks
from kobil_aitrust import register_tools, to_anthropic_tools ai = await get_agent_sdk() defs = create_insurance_tools(ctx) secured = register_tools(ai, defs) # wraps each tool anthropic_tools = to_anthropic_tools(secured) # ... result_text = await secured[name].execute(tool_input) # full pipeline
Order matters, and it will bite you. Each secured tool runs
token acquire → FGA → CIBA → execute → audit — FGA comes after the token. So if token
acquisition is failing, NotAuthorized is never raised and a denied tool fails exactly like an allowed
one: a network error. Your policy looks unenforced when it is merely unreachable. Put the direct check of
Step 4 in front of dispatch and you get the decision either way — that is what makes a deny observable
in a running app, and it is why the check below is not an optional extra.
Each secured tool now runs: token acquire → FGA → CIBA → execute → audit. The starter's local tools-runtimetools_runtime was built with the same call shape, so this swap is mechanical.
The app compiles and behaves as before (no scopes added yet), and calls now flow through
registerToolsregister_tools.
The first build will fail with
Module not found: Can't resolve '@langchain/core/tools'. The SDK ships adapters for LangChain, OpenAI
and Vercel; your bundler walks them all whether you use them or not. Mark the ones you do not use as external in
next.config.mjs — this is not optional and it is not deploy-specific:
webpack: (config) => {
config.externals = [...(Array.isArray(config.externals) ? config.externals : []),
"@langchain/core", "@langchain/core/tools", "openai", "ai"];
return config;
},
Note: this step calls getAgentSDK() from Step 2, so
AITrust() throws ConfigurationError: platformUrl is required when
AITRUST_MANAGEMENT_URL is unset. Set that and AITRUST_CLIENT_ID before running,
or the first request that builds the SDK will fail.
Enforcement & trust
Decide which tools may run, require a human for sensitive actions, and record everything to a tamper-evident trail.
Authorization (FGA)
@kobil/aitrustkobil_aitrustWhat: decide which tools may run from a central policy.
How: the SDK
checks each tool against OpenFGA by its name — tool:<name> with permission
can_execute (or needs_approval when the tool sets needsApproval) — and throws
NotAuthorized when denied. Catch it in the loop.
allowed_agent to permit, denied_agent to block, require_approval to force CIBA.
The scopes list below is not read by this check — it is metadata used to generate those seed
tuples, and it is the scope set requested at token acquisition (where the platform runs a separate
can_use check on scope:<name>). Editing scopes alone will not change whether
a registered tool runs; change the tuples.
generate_settlement_document: defineTool({
name: "generate_settlement_document",
parameters: { /* ... */ },
execute: async (input) => { /* ... */ },
}),
generate_settlement_document: defineTool({
name: "generate_settlement_document",
scopes: ["insurance:write"], // metadata: seeds FGA tuples + token scope
parameters: { /* ... */ },
execute: async (input) => { /* ... */ },
}),
"generate_settlement_document": define_tool(ToolDefinition(
name="generate_settlement_document",
parameters={...},
execute=_generate_settlement_document,
)),
"generate_settlement_document": define_tool(ToolDefinition(
name="generate_settlement_document",
scopes=["insurance:write"], # metadata: seeds FGA tuples + token scope
parameters={...},
execute=_generate_settlement_document,
)),
// delete_claim was blocked with a hard-coded throw in code:
execute: async () => { throw new Error("Deleting claims is blocked by policy"); }
import { NotAuthorized } from "@kobil/aitrust";
try {
const resultText = await securedTools[name].execute(input);
} catch (e) {
if (e instanceof NotAuthorized) {
// DENIED centrally by FGA — no scope. Tell the model + audit captured it.
} else throw e;
}
# delete_claim was blocked with a hard-coded raise in code:
async def _delete_claim(_inp): raise RuntimeError("Deleting claims is blocked by policy")
from kobil_aitrust import NotAuthorized
try:
result_text = await secured[name].execute(tool_input)
except NotAuthorized:
... # DENIED centrally by FGA — no scope
This moves the delete_claim decision out of code and into a policy you can change
without a redeploy — and the denial is now audited.
- a
denied_agenttuple exists fortool:delete_claim, or - the agent simply has no
allowed_agenttuple.
Seed it with the platform's OpenFGA setup, or flip it in the Agent Trust Dashboard. Without a reachable OpenFGA and seeded tuples, this catch block never fires.
Never cache an authorization decision. Calling the platform directly, send
cache: "no-store". Frameworks cache fetch by default — Next.js does —
and a cached decision means a permission you revoke keeps working until the cache expires.
const res = await fetch(`${process.env.AITRUST_MANAGEMENT_URL}/api/authorization/check`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// bare client id — this endpoint adds the agent: prefix itself.
// Sending "agent:" here yields agent:agent:, which denies everything.
body: JSON.stringify({ agentId: process.env.AITRUST_CLIENT_ID, toolName: tool }),
cache: "no-store", // a revoked permission must stop working now
signal: AbortSignal.timeout(8000),
});
// Fail closed: an authorization service you cannot reach is not consent.
if (!res.ok) return { allowed: false, requiresCIBA: false };
// Three states, not two. A `ciba` rule answers allowed:false + requiresCIBA:true,
// so reading `allowed` alone reports an approval-gated tool as a flat denial.
const { allowed, requiresCIBA, policy } = await res.json(); // policy: "allow" | "ciba" | "deny"
With OpenFGA tuples seeded, a tool whose scope the agent lacks raises NotAuthorized; an allowed tool runs.
Human approval (CIBA)
@kobil/aitrustkobil_aitrustWhat: require a person to approve sensitive actions.
How: mark the tool
needsApprovalneeds_approval and provide an approver +
approvalMessageapproval_message. The SDK pauses, pushes the request
to the approver's phone, and only runs execute on approval.
What "pushes it to the phone" actually means. Three pieces sit behind that sentence, and you need all three for this step to do anything visible:
- SHIFT IDP — initiates the CIBA backchannel request.
- CIBA Backend (
:8092) — tracks the pending approval and delivers the push. - Mobile approver app — where the human actually taps Approve.
The approver is per-deployment, so read it from the environment rather than baking an address into the
image — there is no platform variable for it, pick your own (APPROVER_EMAIL) and set it in
.env.local. Register the tool as ciba and declare
needsApprovalneeds_approval in code: the registration decides
whether approval is demanded, the code decides who is asked, and a tool registered ciba with no approver
has nobody to ask.
The approver must be enrolled on a device with the app installed. Without one the call fails fast with
invalid user — not a timeout, so do not read a quick failure as a wiring problem.
request_signature: defineTool({
name: "request_signature",
scopes: ["claims:sign"],
execute: async (input) => { /* send for signing */ },
}),
request_signature: defineTool({
name: "request_signature",
scopes: ["claims:sign"],
needsApproval: true, // ← gate on a human
approver: resolveApproverForPolicyholder, // string | (input) => string
approvalMessage: (input) => buildCibaBindingMessage("request_signature", input),
execute: async (input) => { /* send for signing */ },
}),
"request_signature": define_tool(ToolDefinition(
name="request_signature",
scopes=["claims:sign"],
execute=_request_signature,
)),
"request_signature": define_tool(ToolDefinition(
name="request_signature",
scopes=["claims:sign"],
needs_approval=True, # ← gate on a human
approver=resolve_approver_for_policyholder, # str | (input) -> str
approval_message=lambda inp: _build_ciba_binding_message("request_signature", inp),
execute=_request_signature,
)),
approverapprover
accepts a plain email string — approver: "approver@example.com"approver="approver@example.com"
is enough to complete this step. The
resolveApproverForPolicyholderresolve_approver_for_policyholder
shown above is not a one-liner: in the reference it is ~50 lines that call the platform's
per-policyholder routing endpoint over mTLS, with its own fail-open/fail-closed policy. Treat per-policyholder
routing as an advanced pattern to add later, not a prerequisite.
On
request_signature: this tool is not in the public starter — we introduce it here to
show the CIBA fields, and its body is completed in Step 9. To apply CIBA to a tool you already have, put the
same fields on generate_settlement_document.
| Field | Type | What it does |
|---|---|---|
needsApprovalneeds_approval | boolean | When true, the SDK opens a CIBA backchannel and waits for a human before executing. |
approver | string | fn(input) | Login hint of who approves — a static email, or a function that resolves it per call (e.g. per-policyholder routing). |
approvalMessageapproval_message | string | fn(input) | The binding message shown on the approver's device. Keep it ≤ 50 chars, no spaces (e.g. Sign-CLM-ABC123) — it is a binding token tying the approval to this one call, not a sentence, so identify the action and the object and let the device render the rest. |
// Nothing — there was no approval step.
import { ApprovalDenied, ApprovalTimeout } from "@kobil/aitrust";
// catch ApprovalDenied (approver said no) and ApprovalTimeout (nobody answered)
# Nothing — there was no approval step.
from kobil_aitrust import ApprovalDenied, ApprovalTimeout # except ApprovalDenied / ApprovalTimeout
Calling the tool pushes a prompt to the approver's mobile approver app; approving lets execute run, denying raises ApprovalDenied.
Audit
kobil-agent-auditWhat: a tamper-evident trail.
How: tool calls via the secured registry are
audited automatically — for events outside the tool loop (inbound messages, signing/payment callbacks),
log them through the SDK. Use the SDK, not a bare fetch: on a deployed external agent
AITRUST_AUDIT_URL is an mTLS gateway, and only the SDK transport presents the client certificate. action and eventType are both required top-level fields — moving
either one into details gets you a 422. Everything else can go in details.
// No audit trail — events were only console.log'd locally.
console.log("[SIGNED]", instanceId);
await ai.audit.logEvent({
action: "document_sign", // required (top-level)
eventType: "claims.document_signed", // required (top-level)
outcome: "success", resourceId: instanceId, details: {},
}); // agentId is filled from the SDK config
# No audit trail — events were only printed locally.
print("[SIGNED]", instance_id)
await ai.audit.log_event({
"action": "document_sign", # required (top-level)
"eventType": "claims.document_signed", # required (top-level)
"outcome": "success", "resourceId": instance_id, "details": {}})
# agentId is filled from the SDK config
A completed tool call (and your manual events) appear in GET {AUDIT_URL}/v1/events?agentId=agent:<id>.
End-user identity & approver routing (optional)
@kobil/kobil-agent-sdk/webWhat: a real logged-in user instead of a hardcoded userId.
How: the SDK's identity helper owns /auth/login and /auth/callback. The user's email becomes the policyholder and drives per-policyholder CIBA routing.
Everything so far ran with a fixed userId. That is fine for development.
For a deployed agent it means two things: every CIBA request goes to one static approver, and the
public *.shift-go.uk chat page is open to anyone. This step closes both.
First, the OIDC client. Register a public client with PKCE in the
agenttrust realm of SHIFT IDP, redirect URI https://<your-host>/auth/callback —
ask your platform admin. This is a different client from your agent's AITRUST_CLIENT_ID, and a
different IDP from the one kobilctl registers apps in: keep "oidc": false in
kobil.json and let the SDK own the login.
OIDC_CLIENT_ID=<your-web-login-client> OIDC_AUTHORIZE_URL=https://<shift-idp-host>/auth/realms/agenttrust/protocol/openid-connect/auth OIDC_TOKEN_URL=https://<shift-idp-host>/auth/realms/agenttrust/protocol/openid-connect/token OIDC_REDIRECT_URI=https://<your-host>/auth/callback KOBIL_SESSION_SECRET=<long random string — must be stable across restarts>
// Who is this? Nobody knows — every request is the same hardcoded user. const userId = "claims-adjuster";
import { createKobilIdentity } from "@kobil/kobil-agent-sdk/web";
// null when OIDC_CLIENT_ID is unset — local runs keep working untouched.
const identity = process.env.OIDC_CLIENT_ID ? createKobilIdentity() : null;
// in the request handler:
if (identity && await identity.handle(req, res)) return; // owns /auth/login + /auth/callback
const user = identity ? await identity.getUser(req) : null; // { sub, email } | null
const userId = user?.email ?? "claims-adjuster"; // the policyholder
# Who is this? Nobody knows — every request is the same hardcoded user. user_id = "claims-adjuster"
from kobil_agent_sdk.web import create_kobil_identity
# None when OIDC_CLIENT_ID is unset — local runs keep working untouched.
identity = create_kobil_identity() if os.environ.get("OIDC_CLIENT_ID") else None
# identity owns /auth/login + /auth/callback (see the module docstring for
# the FastAPI wiring); then per request:
user = await identity.get_user(request) if identity else None
user_id = user.email if user else "claims-adjuster"
Then, approver routing. With a real policyholder id, the management API can resolve who approves this user's payouts instead of one static approver:
GET {AITRUST_MGMT_URL}/agents/{AITRUST_CLIENT_ID}/route-approver?policyholder_email={userId}
# → { "approver_email": "..." } Pass it as the CIBA login hint.
- The endpoint is behind
ROUTING_ENDPOINT_ENABLED=trueon the management API. Off by default — ask your platform admin. - Fallback masks failure. On any routing error, fall back to your default approver — but log it.
If your agent logs a
[ROUTING]warning, CIBA still works, yet every request goes to the static approver. - The routed approver must be a real realm user with the approver app installed and signed in — otherwise the approval has nowhere to land.
Open /auth/login, sign in, request a payout: the CIBA push reaches the approver routed for
your email — and the agent log shows no [ROUTING] warning.