Metadata-Version: 2.4
Name: nxtid-sdk
Version: 0.1.8
Summary: nxtID Python SDK — AIT-gated AI agent tool permissions
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.27
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.111; extra == "fastapi"
Requires-Dist: pydantic>=2.7; extra == "fastapi"

# nxtID Python SDK

Python SDK for integrating **MetaKeep wallet**, **AuthID biometric IDV**, and **nxtlinq AIT on-chain permissions** into AI agents.

---

## Installation

```bash
pip install nxtid-sdk
```

Only `httpx` is a hard dependency. If your agent is built on FastAPI and you
want the `.mount(app)` HTTP-route adapters (`AITRouter`, `EntraAuth`,
`SessionStore`), install the `fastapi` extra:

```bash
pip install "nxtid-sdk[fastapi]"
```

Not on FastAPI? Skip the extra — every operation is a plain Python method
(see **Framework-agnostic core** below), callable from Flask, Django, a Slack
bot, a background worker, a plain script, whatever your agent is built on.

---

## Framework-agnostic core

`AITRouter`, `EntraAuth`, and `SessionStore` are each split into two layers:

- **Plain methods** — `wallet_connect`, `verify_start`, `verify_complete`,
  `permissions_request`, `permissions_admin_granted`, `permissions_refresh`,
  `session_restore`, `ait_status`, `guard_tool_call`, `login`, `config`,
  `create`/`get`/`get_or_create`/`delete` — take a session object and plain
  Python arguments, return a plain dict, and raise `NxtidHTTPError` (never a
  web-framework exception) on failure. No FastAPI import happens anywhere in
  this layer.
- **`.mount(app)`** — an optional FastAPI adapter built on top of the plain
  methods: it wires them to FastAPI routes with request-body validation and
  converts `NxtidHTTPError` to `fastapi.HTTPException`. FastAPI/pydantic are
  imported lazily *inside* `.mount()`/`._build_router()` — never at module
  import time — so using the plain methods works even if FastAPI isn't
  installed at all.

If your agent uses FastAPI, call `.mount(app)` and never think about this
again — see **Typical Agent Integration Pattern** below. If it doesn't,
call the plain methods directly from whatever request handler your
framework gives you:

```python
from nxtid_sdk import NxtidSDK, AITRouter, EntraAuth, NxtidHTTPError

sdk = NxtidSDK.from_env()
ait_router = AITRouter(sdk, get_session=sessions.get, create_session=sessions.create)
entra = EntraAuth.from_env(get_session=get_or_create_session,
                            on_login=ait_router.auto_grant_by_role)

# e.g. inside a Flask view, a Slack slash-command handler, a plain script...
async def handle_wallet_connect(session_id: str, email: str) -> dict:
    session = sessions.get(session_id)
    if session is None:
        return {"error": "Session not found or expired."}, 404
    try:
        return await ait_router.wallet_connect(session, email=email)
    except NxtidHTTPError as exc:
        return {"error": exc.detail}, exc.status_code   # map to your framework's error shape

async def handle_entra_login(session_id: str, name: str, email: str, roles: list) -> dict:
    session = get_or_create_session(session_id)
    return await entra.login(session, name=name, email=email, roles=roles)

# Sessions: sessions.create()/get()/get_or_create()/delete() are plain dict
# operations, no HTTP involved — call them from anywhere.
```

---

## Quick Start

```python
from nxtid_sdk import NxtidSDK

sdk = NxtidSDK(
    api_key="your-api-key",
    api_secret="your-api-secret",
    env="production",          # "production" (default) or "staging"
    service_id="your-service-id",  # required for log_chat
)

# 1. Register your agent's tools on startup
await sdk.register_tools([
    {"name": "get_account_balance", "description": "Read-only finance access"},
    {"name": "transfer_funds",      "description": "Move money between accounts"},
])

# 2. Connect wallet by email
wallet = await sdk.connect_wallet(email="user@example.com")
# → WalletResult(wallet_address="0x...", email="user@example.com")

# 3. Start AuthID identity verification
verify = await sdk.start_verification(wallet.wallet_address)
# → VerifyStartResult(flow="login"|"onboarding"|"choose_document", widget_url="...", ...)

# 4. After user completes the widget
result = await sdk.complete_verification(
    wallet.wallet_address,
    operation_id=verify.operation_id,   # for onboarding flow
    transaction_id=verify.transaction_id,  # for login flow
)
# → VerifyCompleteResult(is_verified=True)

# 5. Check admin-granted permissions
perms = await sdk.get_permissions(wallet.wallet_address)
if perms.has_permission("transfer_funds"):
    ...
```

---

## Environments

| env           | AIT Service                            | Dashboard                           |
|---------------|----------------------------------------|-------------------------------------|
| `production`  | `https://ait-service.nxtlinq.ai`       | `https://dashboard.nxtlinq.ai`      |
| `staging`     | `https://staging-ait-service.nxtlinq.ai` | `https://dashboard-staging.nxtlinq.ai` |

---

## API Reference

### `NxtidSDK`

```python
NxtidSDK(
    api_key: str,
    api_secret: str,
    env: str = "production",
    service_id: Optional[str] = None,
)
```

Credentials (`api_key`, `api_secret`, `service_id`) are obtained from nxtID Dashboard → **Services → Register Third-party Agent**.

**`NxtidSDK.from_env() → NxtidSDK | None`**

Builds an instance from env vars instead of wiring them by hand — returns `None` if not configured. Pass it straight to `AITRouter` either way; no `if` needed — `AITRouter` is None-safe and mounts unconditionally (see below):

```python
sdk = NxtidSDK.from_env()
AITRouter(sdk, get_session=sessions.get, create_session=sessions.create).mount(app)
```

Reads `NXTLINQ_SERVICE_API_KEY`, `NXTLINQ_SERVICE_API_SECRET`, `NXTLINQ_SERVICE_ID` (optional), `NXTLINQ_ENV` (`"production"` default or `"staging"`).

---

#### `connect_wallet(email) → WalletResult`

Gets or creates a MetaKeep non-custodial wallet for the given email address.

```python
wallet = await sdk.connect_wallet(email="user@example.com")
wallet.wallet_address  # "0xABCDEF..."
wallet.email           # "user@example.com"
```

---

#### `start_verification(wallet_address, id_type=None) → VerifyStartResult`

Starts an AuthID identity verification session.

| `flow`              | Condition                                      |
|---------------------|------------------------------------------------|
| `"login"`           | Returning user (face scan only)                |
| `"onboarding"`      | New user with `id_type` provided               |
| `"choose_document"` | New user without `id_type` (user selects type) |

`id_type` is the AuthID document type code (e.g. `"40"` for passport).

```python
result = await sdk.start_verification(wallet.wallet_address, id_type="40")
result.flow        # "onboarding"
result.widget_url  # URL to open in browser/iframe
result.operation_id   # pass to complete_verification (onboarding)
result.transaction_id # pass to complete_verification (login)
```

---

#### `complete_verification(wallet_address, operation_id=None, transaction_id=None) → VerifyCompleteResult`

Finalises the verification session after the user completes the widget. Polls AuthID for up to 60 s.

```python
done = await sdk.complete_verification(
    wallet.wallet_address,
    operation_id=result.operation_id,
)
done.is_verified  # True | False
```

---

#### `get_permissions(wallet_address) → PermissionsResult`

Queries the AIT on-chain record for this wallet to determine which tools the admin has granted.

```python
perms = await sdk.get_permissions(wallet.wallet_address)
perms.allowed        # True if any permission grant exists
perms.allowed_tools  # ["transfer_funds", "get_account_balance", ...]
perms.denied_tools   # tools explicitly blocked
perms.reason         # optional explanation from nxtlinq

perms.has_permission("transfer_funds")  # True | False
```

---

#### `get_admin_granted_tools(wallet_address) → list[str]`

Returns the list of tools the admin has pre-approved for this user to self-service request. Empty list means the admin hasn't granted any tools yet.

```python
tools = await sdk.get_admin_granted_tools(wallet.wallet_address)
# ["calculate", "get_company_info", "get_account_balance"]
```

---

#### `set_permissions(wallet_address, permissions, granted_by=None, roles=None) → dict`

Writes an AIT on-chain with the given tool list for `wallet_address`. Used for self-service permission requests (user selects a subset of admin-granted tools), and for role-based auto-grant (pass `roles` for audit purposes).

```python
await sdk.set_permissions(
    wallet_address="0x...",
    permissions=["calculate", "get_company_info"],
    granted_by="user@example.com",
)
```

---

#### `get_role_permissions(role) → list[str]`

Returns the default tool list an admin has configured for a given Entra role — used to auto-grant AIT permissions right after IDV completes, without the user having to self-service pick anything.

```python
tools = await sdk.get_role_permissions("Finance")
if tools:
    await sdk.set_permissions(wallet.wallet_address, tools, roles=["Finance"])
```

---

#### `async guard_tool_call(tool_name, session) → None`

Enforces identity and permission checks before a tool executes. Call this inside the agent's tool dispatch loop, before executing each gated tool (`await` it — it's async). Raises `ToolCallBlockedError` if the call should be blocked; returns `None` (no-op) if permitted. `session` needs `wallet_address`, `is_verified`, `allowed_tools`, `admin_allowed_tools`, `permissions_configured` — satisfied by `NxtidSessionMixin`. `entra_roles` is read too, for the role-based fallback (see below).

**This makes a live API call every time** — it re-fetches the current admin grant from the AIT service on every invocation rather than trusting a cached session snapshot, so a revoke takes effect on the very next tool call, not just the next page reload or widget reopen. A network failure here fails closed (`ToolCallBlockedError`), not open.

```python
await sdk.guard_tool_call("transfer_funds", session)
```

Internally this is `resolve_admin_grant` + `apply_active_subset` (below) + a membership check — the exact same two calls `AITRouter`'s `session_restore`/`permissions_refresh` make, so real-time enforcement can never drift out of sync with what those endpoints report.

---

#### `async resolve_admin_grant(session) → bool`

Queries the current admin-granted AIT permission ceiling for `session.wallet_address` and sets `session.permissions_configured` / `session.admin_allowed_tools`: personal grant if present, otherwise role-based group default from `session.entra_roles` (DB read only, no on-chain write), otherwise cleared. Returns `True` if some grant was found. Does **not** touch `session.allowed_tools` — call `apply_active_subset` after.

#### `apply_active_subset(session, requested_active) → None`

Sets `session.allowed_tools` to whichever of `requested_active` is still covered by `session.admin_allowed_tools` — falls back to the full admin grant if nothing (or nothing still valid) was requested. This is how a user's self-service choice (turning a specific admin-granted tool off) survives a refresh instead of being silently re-enabled by the admin ceiling.

#### `async refresh_session_permissions(session) → bool`

Convenience wrapper around `resolve_admin_grant` + `apply_active_subset` — refreshes `session.allowed_tools`/`session.admin_allowed_tools` from the current admin-granted ceiling in one call. No-ops (returns `False`) if wallet/IDV aren't established yet; never raises — keeps whatever was last cached on the session if the underlying call fails.

Call this unconditionally at the start of every turn, before building anything that reads `session.allowed_tools` (e.g. a system prompt's "here's what you can't use" hint). That value is otherwise only refreshed as a side effect of `guard_tool_call`, which only runs if a tool call is actually attempted — if your prompt tells the LLM a tool is blocked based on a stale `allowed_tools`, the LLM has no reason to try it, so `guard_tool_call` never runs, and the stale state never refreshes even after an admin grants new permissions:

```python
await sdk.refresh_session_permissions(session)
allowed = [t for t in TOOL_DEFINITIONS if t["name"] in session.allowed_tools]
system_prompt = build_prompt(allowed)  # now reflects the current grant, not last turn's
```

---

#### `register_tools(tools) → int`

Registers the agent's tool catalogue with nxtID so admins can grant permissions per tool. Call once at agent startup.

```python
count = await sdk.register_tools([
    {"name": "transfer_funds",      "description": "Move money between accounts"},
    {"name": "get_account_balance", "description": "Read-only finance access"},
])
# count = 2
```

---

#### `log_chat(session, user_message, assistant_message, ...) → None`

Sends one conversation turn to nxtID Dashboard for monitoring. Fails silently — logging is non-fatal. Requires `service_id` at SDK init time. The external user identity is derived from the session as `wallet_address or verified_email` / `wallet_email or verified_email`.

```python
await sdk.log_chat(
    session,
    user_message="What's my balance?",
    assistant_message="Your balance is $500.",
    model="meta/llama-3.3-70b-instruct",
    has_tool_call=True,
    tool_name="get_account_balance",
    turn_index=0,
)
```

---

### `EntraAuth`

Optional module for adding Microsoft Entra SSO login to a FastAPI agent. When enabled, mounts two routes onto the host app that the browser MSAL popup calls.

```python
from nxtid_sdk import EntraAuth

entra = EntraAuth(
    client_id=os.getenv("AZURE_AD_CLIENT_ID"),
    tenant_id=os.getenv("AZURE_AD_TENANT_ID"),
    get_session=get_or_create_session,  # callable: session_id → Session object
)
entra.mount(app)  # app is a FastAPI instance
```

Or read the same two env vars in one call: `EntraAuth.from_env(get_session=get_or_create_session, on_login=on_entra_login)`.

If `client_id` or `tenant_id` is empty/`None`, `entra.enabled` returns `False` and the routes respond accordingly — no error is raised.

**Routes mounted (FastAPI) / plain methods (any framework):**

| Method | Path                | Plain method                          | Description                                      |
|--------|---------------------|----------------------------------------|--------------------------------------------------|
| `GET`  | `/auth/entra/config`| `entra.config() → dict`               | Returns `{ enabled, client_id, tenant_id }` for MSAL initialisation |
| `POST` | `/auth/entra`       | `await entra.login(session, name, email, tenant_id=None, roles=None) → dict` | Writes MSAL token claims onto the session, fires `on_login` |

**POST `/auth/entra` body:**

```json
{
  "session_id": "string",
  "name": "string",
  "email": "string",
  "tenant_id": "string (optional)",
  "roles": ["string"]
}
```

The `get_session` callable receives a `session_id` string and must return a session object with writable attributes: `entra_authenticated`, `entra_name`, `entra_email`, `entra_tenant_id`, `entra_roles`.

---

### `AITRouter`

Bundles the wallet / AuthID IDV / AIT-permission HTTP endpoints onto a FastAPI app — the wallet/verify/permissions counterpart to `EntraAuth.mount(app)`. Without this, every consuming app has to hand-write these ~9 routes itself.

```python
from nxtid_sdk import NxtidSDK, AITRouter

sdk = NxtidSDK.from_env()  # may be None if credentials are missing
ait_router = AITRouter(sdk, get_session=sessions.get, create_session=sessions.create)
ait_router.mount(app)
```

`sdk` may be `None` — construct and mount `AITRouter` unconditionally either way, same pattern as `EntraAuth`. When `sdk` is `None`, `/wallet/connect` responds `503` and everything downstream (which all require a connected wallet first) degrades gracefully; check `ait_router.enabled` where your app needs to know (e.g. to decide whether to show the setup widget).

**Registering tools at startup:** if your app has no other startup/shutdown needs, skip writing your own `lifespan` function — `ait_router.lifespan(tools)` returns a ready-made one. Extra keys (e.g. LLM `parameters` schemas) are fine; only `name`/`description` are read server-side, so your tool-calling `TOOL_DEFINITIONS` list can usually be passed straight through.

```python
app = FastAPI(lifespan=ait_router.lifespan(TOOL_DEFINITIONS))
```

Already have a `lifespan` for other startup work? Call `register_tools` inside it instead:

```python
@asynccontextmanager
async def lifespan(app):
    await ait_router.register_tools(TOOL_DEFINITIONS)
    yield
```

**Routes mounted (FastAPI) / plain methods (any framework):**

| Method | Path                          | Plain method                                            | Description                                             |
|--------|-------------------------------|----------------------------------------------------------|----------------------------------------------------------|
| `POST` | `/wallet/connect`             | `await ait_router.wallet_connect(session, email)`         | Connect a MetaKeep wallet by email                       |
| `POST` | `/verify/start`                | `await ait_router.verify_start(session, id_type=None)`     | Start AuthID IDV, returns a widget URL                   |
| `POST` | `/verify/complete`             | `await ait_router.verify_complete(session, operation_id=None, transaction_id=None)` | Complete IDV; auto-loads permissions afterward |
| `POST` | `/permissions/request`         | `await ait_router.permissions_request(session, allowed_tools)` | Self-service: user selects admin-approved tools     |
| `GET`  | `/permissions/admin-granted`   | `await ait_router.permissions_admin_granted(session)`      | Tools admin pre-approved for self-service selection      |
| `POST` | `/permissions/refresh`         | `await ait_router.permissions_refresh(session)`            | Re-pull admin-granted permissions from the AIT service    |
| `POST` | `/session/restore`             | `await ait_router.session_restore(session, data)`          | Restore identity state from browser localStorage (or any client-side cache) |
| `GET`  | `/ait/status/{session_id}`     | `ait_router.ait_status(session)` (sync)                    | Full identity snapshot for the given session               |
| `GET`  | `/nxtlinq/widget.js`           | `ait_router.widget_js()` (sync)                            | Bundled `NxtlinqWidget` setup UI source                    |
| `GET`  | `/nxtlinq/session.js`          | `ait_router.session_js()` (sync)                           | Bundled `NxtlinqSession` localStorage helper source         |

All the async plain methods raise `NxtidHTTPError(status_code, detail)` on failure instead of any FastAPI type — catch that if you're calling them outside `.mount()`.

Unlike `EntraAuth`, `AITRouter` takes two separate callables rather than one get-or-create function:

- `get_session: (session_id: str) -> Session | None` — a plain lookup (e.g. `SessionStore.get`). Every route except `/session/restore` calls this and responds `404` if it returns `None`, rather than silently operating on a brand-new, blank session. This matters in practice: a session that completed IDV can otherwise look "expired" as a confusing downstream error (e.g. `permissions_refresh` failing with "Complete IDV first") instead of a clear "session not found" — which happens whenever the in-memory session store loses state a browser tab's `session_id` still points to (a server restart, a process recycle, an expired TTL in a custom store).
- `create_session: () -> Session` — makes a brand-new session (e.g. `SessionStore.create`). Used only by `/session/restore`, the one route that's expected to run with no session yet.

The session object needs plain, writable attributes (no interface/protocol required):

- Written by `AITRouter`: `wallet_address`, `wallet_email`, `is_verified`, `verified_email`, `permissions_configured`, `allowed_tools`, `admin_allowed_tools`
- Read by `AITRouter` (set these yourself, e.g. via `EntraAuth`'s `on_login`): `entra_roles`, `entra_email` — used to auto-grant AIT permissions by role right after IDV completes

Use `NxtidSessionMixin` (below) instead of hand-declaring these fields.

**Role-based auto-grant with `EntraAuth`:**

`auto_grant_by_role`'s signature — `(session, roles=None)` — matches `on_login` exactly, so it can be wired in directly with no wrapper function:

```python
entra = EntraAuth(..., get_session=get_or_create_session, on_login=ait_router.auto_grant_by_role)
```

---

### `NxtidSessionMixin`

Dataclass mixin providing every field `AITRouter` and `EntraAuth` read/write. Inherit from it instead of hand-declaring the ~12 identity fields on your own `Session` every time you integrate nxtID into a new project.

```python
from dataclasses import dataclass, field
from nxtid_sdk import NxtidSessionMixin

@dataclass
class Session(NxtidSessionMixin):
    messages: list = field(default_factory=list)   # your own app state

session = Session()  # session_id is auto-generated (UUID)
```

All mixin fields have defaults (`session_id` included, via a UUID factory), so — per the usual dataclass inheritance rule — any fields you add in your subclass need defaults too.

---

### `SessionStore`

Generic in-memory session store keyed by `session.session_id` — the create/get/delete dict every nxtID integration otherwise hand-rolls, factored out once.

```python
from nxtid_sdk import SessionStore

sessions = SessionStore(Session)   # Session is your NxtidSessionMixin subclass

session = sessions.create()
session = sessions.get(session_id)             # None if unknown
session = sessions.get_or_create(session_id)   # None/unknown id -> new session
sessions.delete(session_id)
```

**Session lifecycle endpoints:** most frontends need a way to create a fresh session and to reset one (e.g. on sign-out). `sessions.mount(app)` mounts both so you don't have to hand-write them:

```python
sessions.mount(app)   # POST /session/new -> {"session_id": ...}, POST /session/reset {"session_id": ...} -> {"ok": true}
```

---

### `NxtidHTTPError`

Framework-agnostic error raised by `AITRouter`/`EntraAuth`'s plain methods (`wallet_connect`, `verify_start`, `login`, ...) — never a `fastapi.HTTPException` or any other web-framework type. `.mount(app)` catches this internally and converts it for you; call the plain methods directly and you need to catch it yourself.

```python
from nxtid_sdk import NxtidHTTPError

try:
    result = await ait_router.wallet_connect(session, email=email)
except NxtidHTTPError as exc:
    exc.status_code  # e.g. 400, 502, 503
    exc.detail       # user-facing explanation
```

---

### `ToolCallBlockedError`

Exception raised to block a tool execution before it reaches the LLM tool handler.

```python
from nxtid_sdk import ToolCallBlockedError

try:
    await sdk.guard_tool_call(tool_name, session)
except ToolCallBlockedError as exc:
    exc.reason   # "verification_required" | "awaiting_admin_grant" | "permission_denied"
    exc.message  # user-facing explanation
```

| `reason` | Meaning | Fixable via the setup widget? |
|---|---|---|
| `verification_required` | Wallet not connected, IDV not completed, or the admin-grant check itself failed transiently | Yes — `NxtlinqWidget.handleChatEvent` auto-opens the setup modal for this |
| `awaiting_admin_grant` | Wallet + IDV are done, but the admin hasn't granted this account *any* permission yet (no personal or role grant at all) | No — nothing for the end user to click through; only an admin can grant something, so the widget does **not** auto-open |
| `permission_denied` | The account has an admin grant, just not for this specific tool | No — the account is already fully set up, so the widget does **not** auto-open; show `exc.message` in chat instead |

**Factory method:**

```python
raise ToolCallBlockedError.permission_denied(
    tool_name="transfer_funds",
    allowed_tools=["calculate", "get_company_info"],
)
# message: "You don't have permission to use 'transfer_funds'.
#           Your current permissions: [calculate, get_company_info].
#           Contact your administrator."
```

---

### Return Types

| Class                | Fields                                                                 |
|----------------------|------------------------------------------------------------------------|
| `WalletResult`       | `wallet_address: str`, `email: str`                                    |
| `VerifyStartResult`  | `flow: str`, `widget_url: str\|None`, `operation_id: str\|None`, `transaction_id: str\|None` |
| `VerifyCompleteResult` | `is_verified: bool`                                                  |
| `PermissionsResult`  | `allowed: bool`, `allowed_tools: list[str]`, `denied_tools: list[str]`, `reason: str\|None`, `has_permission(tool_name) → bool` |

---

## Frontend Widget Integration

`AITRouter.mount(app)` serves two browser-ready JS files — no build step,
no npm package, just `<script>` tags:

- **`NxtlinqWidget`** (`/nxtlinq/widget.js`) — the setup UI: walks the user
  through Entra SSO → wallet connect → AuthID IDV → AIT permissions,
  calling `AITRouter`'s HTTP routes under the hood.
- **`NxtlinqSession`** (`/nxtlinq/session.js`) — localStorage persistence +
  server-side session restore. Usable standalone even without the widget UI.

### `NxtlinqWidget`

```html
<div id="nxtlinq-widget"></div>
<script src="/nxtlinq/session.js"></script>
<script src="/nxtlinq/widget.js"></script>
<script>
  let sessionId = null;

  NxtlinqWidget.init({
    container: '#nxtlinq-widget',
    apiBase: '',                 // '' = same origin as the page
    mode: 'inline',              // 'inline' (default) or 'modal' (gear-icon trigger + popup)
    steps: ['entra', 'wallet', 'verify', 'permissions'],
    entra: { clientId: '...', tenantId: '...', redirectUri: window.location.origin },
    tools: TOOL_DEFINITIONS,     // [{ name, label, desc }, ...] — same tools you passed to register_tools
    onStepComplete: (step, data) => {
      if (data.sessionId) sessionId = data.sessionId;
      // data is camelCase (walletAddress, allowedTools, ...) — map only the
      // fields you need into NxtlinqSession's snake_case shape.
      if (step === 'entra' && data.roles) NxtlinqSession.update(sessionId, { entra_roles: data.roles });
    },
    onComplete: (data) => {
      // Re-fetch a clean snake_case snapshot instead of hand-mapping the
      // camelCase callback payload — ait_status() already returns exactly
      // what NxtlinqSession.save() expects.
      fetch(`/ait/status/${data.sessionId}`).then(r => r.json())
        .then(snapshot => NxtlinqSession.save(data.sessionId, snapshot));
    },
    onError: (step, message) => console.error(step, message),
  });
</script>
```

**Options:**

| Option | Default | Description |
|---|---|---|
| `container` | — (required) | CSS selector or element to mount into |
| `mode` | `'inline'` | `'inline'` renders the steps directly; `'modal'` renders a trigger button that opens a popup panel |
| `apiBase` | `''` | Base URL of the agent API (same-origin if empty) |
| `sessionId` | `null` | Resume an existing server session instead of creating a new one |
| `steps` | `['entra','wallet','verify','permissions']` | Which steps to show — drop `'entra'` if you don't use `EntraAuth` |
| `entra` | — | `{ clientId, tenantId, redirectUri }` — only needed if `'entra'` is in `steps` |
| `tools` | `[]` | `[{ name, label, desc }, ...]` — tool catalogue rendered as checkboxes in the permissions step (same list you passed to `register_tools`). Without this, the permissions step has nothing to show. Auto-filtered to whatever the admin actually granted. |
| `onStepComplete(step, data)` | — | Fires after each step finishes. `data` uses **camelCase** (`sessionId`, `walletAddress`, `allowedTools`, `deniedTools`, `permissionsConfigured`, ...) — not the snake_case the REST API/`NxtlinqSession` use |
| `onComplete(data)` | — | Fires once every configured step is done. Same camelCase `data` shape as above |
| `onError(step, message)` | — | Fires on any step failure |
| `onRestored(saved)` | — | Fires if a saved session was restored from `NxtlinqSession` on load |

`mode: 'modal'`-only options: `triggerLabel` (default `'⚙️'`), `modalTitle`
(default `'⚙️ Setup'`), `showSignOut` (default `true`), `onSignOut()`.

`init()` returns an instance with `.destroy()`, and in modal mode also
`.open()` / `.close()`.

### `NxtlinqSession`

```html
<script src="/nxtlinq/session.js"></script>
<script>
  // On every page load — restores identity state via POST /session/restore
  NxtlinqSession.restore('').then(result => {
    if (result) console.log('restored session', result.session_id);
  });

  // Read the cached state without hitting the server
  const saved = NxtlinqSession.get();

  // identityData must use the same snake_case field names as ait_status()'s
  // response (wallet_address, allowed_tools, ...) — NOT NxtlinqWidget's
  // camelCase callback payloads, see the onComplete example above.
  NxtlinqSession.save(sessionId, identityData);
  NxtlinqSession.update(sessionId, { allowed_tools: [...] });  // merge a partial update

  // On sign-out
  NxtlinqSession.clear();
</script>
```

`save`/`update`/`get`/`clear` are synchronous localStorage operations.
`restore(apiBase)` is the only one that hits the network — it posts the
locally saved state to `AITRouter`'s `/session/restore` and resolves with
the server's up-to-date identity/permission snapshot, or `null` if nothing
was saved or the request failed.

---

## Typical Agent Integration Pattern

`AITRouter` + `EntraAuth` + `SessionStore` handle all the HTTP surface and
session bookkeeping; your app only needs to write the tool dispatch loop and
call `guard_tool_call` before executing a gated tool.

```python
# session_store.py
from dataclasses import dataclass, field
from nxtid_sdk import NxtidSessionMixin, SessionStore

@dataclass
class Session(NxtidSessionMixin):
    messages: list = field(default_factory=list)   # your own app state

sessions = SessionStore(Session)
get_session, get_or_create_session = sessions.get, sessions.get_or_create

# main.py (FastAPI)
from nxtid_sdk import NxtidSDK, AITRouter, EntraAuth, ToolCallBlockedError

sdk = NxtidSDK.from_env()  # may be None if credentials are missing
ait_router = AITRouter(sdk, get_session=sessions.get, create_session=sessions.create)  # construct/mount unconditionally

# auto_grant_by_role's signature matches on_login's — no wrapper needed.
entra = EntraAuth.from_env(
    get_session=get_or_create_session,
    on_login=ait_router.auto_grant_by_role,
)

# No hand-written lifespan/session endpoints, and no `if ait_router`/`if sdk`
# guards anywhere — AITRouter and its methods are all None-safe internally.
app = FastAPI(lifespan=ait_router.lifespan(TOOL_DEFINITIONS))
ait_router.mount(app)
entra.mount(app)
sessions.mount(app)

# In your tool dispatch loop, before executing a gated tool
# (a chat session is fine to auto-create, unlike the AIT identity routes above):
session = get_or_create_session(session_id)
try:
    await sdk.guard_tool_call(tool_name, session)
except ToolCallBlockedError as exc:
    exc.reason    # "verification_required" | "awaiting_admin_grant" | "permission_denied"
    exc.message   # user-facing explanation

# At the start of every turn, before building a system prompt that lists
# which tools are currently off-limits — see refresh_session_permissions above
# for why this can't just wait for guard_tool_call to run:
await sdk.refresh_session_permissions(session)
```

---

## Requirements

- Python ≥ 3.10
- `httpx >= 0.27`
- `fastapi >= 0.111`, `pydantic >= 2.7` (for `AITRouter` and `EntraAuth`)
