Metadata-Version: 2.4
Name: lime-sites-sdk
Version: 2.0.0
Summary: Official Python site SDK for LIME: AI agent authentication via site login requests, SSE passport delivery, and JWKS verification for site backends (FastAPI, Django ASGI, workers).
Project-URL: Homepage, https://github.com/Mawyxx/lime-site-sdk
Project-URL: Repository, https://github.com/Mawyxx/lime-site-sdk
Project-URL: Documentation, https://lime-sites-sdk.readthedocs.io/
License: MIT
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: cryptography>=41.0.0
Requires-Dist: httpx<1,>=0.27.0
Requires-Dist: pyjwt>=2.8.0
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: docs
Requires-Dist: griffe>=0.40.0; extra == 'docs'
Requires-Dist: mkdocs-material>=9.5.0; extra == 'docs'
Requires-Dist: mkdocs>=1.5.3; extra == 'docs'
Requires-Dist: mkdocstrings[python]>=0.24.0; extra == 'docs'
Description-Content-Type: text/markdown

# lime-sites-sdk — Accept AI Agents on Your Site (JWT + JWKS)

**`lime-sites-sdk`** is the official **Python site SDK** for [LIME](https://lime.pics) — **headless AI agent login** for backends that want to accept autonomous agents without browsers, OAuth redirects, or QR codes. Create a login request, receive a signed **agent passport JWT** over **SSE events**, and **verify** it offline with **JWKS** (`aud=lime-site-login`) — all with `X-Site-Token` and a small async API.

Use this package on **site backends** (FastAPI, Django ASGI, workers). Pair with [`lime-agents-sdk`](https://github.com/Mawyxx/lime-agents-sdk) on the agent worker that calls `login(request_id)`.

[![PyPI version](https://img.shields.io/pypi/v/lime-sites-sdk)](https://pypi.org/project/lime-sites-sdk/)
[![Python versions](https://img.shields.io/pypi/pyversions/lime-sites-sdk)](https://pypi.org/project/lime-sites-sdk/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![CI](https://github.com/Mawyxx/lime-site-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/Mawyxx/lime-site-sdk/actions/workflows/ci.yml)
[![Documentation](https://readthedocs.org/projects/lime-sites-sdk/badge/?version=latest)](https://lime-sites-sdk.readthedocs.io/)
[![MCP compatible](https://img.shields.io/badge/MCP-compatible-00C853)](https://modelcontextprotocol.io/)

**📖 Python API (Read the Docs):** [lime-sites-sdk.readthedocs.io](https://lime-sites-sdk.readthedocs.io/)  
**📖 Platform HTTP docs:** [lime.pics/docs#guide-siteSdk](https://lime.pics/docs#guide-siteSdk)  
**📦 This SDK:** [github.com/Mawyxx/lime-site-sdk](https://github.com/Mawyxx/lime-site-sdk)  
**🌐 Platform:** [https://lime.pics](https://lime.pics)

---

## Why lime-sites-sdk?

| Problem | SDK solution |
|---------|----------------|
| Manual site login API + SSE parsing | `create_login_request()` + background **SSE dispatcher** |
| JWKS fetch, kid cache, RS256 checks | `verify_passport()` with in-memory JWKS cache |
| Blocking wait per HTTP request | `@site.on_login` handlers — map `request_id` → session |
| Fragile site credentials | Env-based `LIME_SITE_TOKEN`, typed errors, `py.typed` |

### Site passport JWT flow (this SDK)

LIME delivers the **cryptographic passport** to the **site backend**, not to the agent worker.

| Step | Who | What happens |
|------|-----|----------------|
| 1 | **Site** (`lime-sites-sdk`) | `create_login_request()` → `request_id` |
| 2 | **Your app** | Hand `request_id` to the agent (queue, RPC, UI) |
| 3 | **Agent** ([`lime-agents-sdk`](https://github.com/Mawyxx/lime-agents-sdk)) | `await agent.login(request_id)` — PoW + approve |
| 4 | **Site** (`@site.on_login`) | SSE `approved` → **passport JWT** string |
| 5 | **Site** | `verify_passport(jwt, expected_request_id=…)` → claims → session |

| Artifact | Audience | TTL (typical) | Verified by |
|----------|----------|---------------|-------------|
| **Site passport JWT** | Site backend (SSE) | Short-lived signed passport (`aud=lime-site-login`) | **`lime-sites-sdk`** via Core JWKS |

> **Not this SDK:** MCP access JWTs (`aud=mcp`, ~5 min) are issued to **agent workers** via [`lime-agents-sdk`](https://github.com/Mawyxx/lime-agents-sdk). Sites do not receive or verify MCP tokens.

---

## Installation

```bash
pip install lime-sites-sdk
```

Latest from GitHub:

```bash
pip install git+https://github.com/Mawyxx/lime-site-sdk.git
```

**Requirements:** Python 3.10+ · runtime deps: `httpx`, `PyJWT`, `cryptography`

---

## Quick start

### Scenario A — FastAPI site backend (production pattern)

**Story:** One `LimeSite` per process starts a perpetual SSE connection. When an agent approves login, your `@site.on_login` handler receives the passport JWT, verifies it, and binds claims to the user session.

```python
from contextlib import asynccontextmanager

from fastapi import FastAPI
from lime_sites import InvalidPassportError, LimeSite

site: LimeSite
pending_logins: dict[str, object] = {}


@asynccontextmanager
async def lifespan(app: FastAPI):
    global site
    site = LimeSite()  # LIME_SITE_TOKEN=st_... — server-side secret only

    @site.on_login
    async def handle_login(request_id: str, passport: str | None) -> None:
        if passport is None:
            pending_logins.pop(request_id, None)  # expired — no JWT delivered
            return
        try:
            verified = await site.verify_passport(
                passport,
                expected_request_id=request_id,
            )
        except InvalidPassportError:
            pending_logins.pop(request_id, None)
            return
        pending_logins[request_id] = verified.claims  # issue session / cookie

    yield
    await site.aclose()


app = FastAPI(lifespan=lifespan)


@app.post("/login/start")
async def start_login() -> dict[str, str]:
    req = await site.create_login_request()
    # Return request_id to client; agent worker calls login(req.request_id)
    return {"request_id": req.request_id}
```

**Rules:**

| Rule | Why |
|------|-----|
| **One `LimeSite` per site token per process** | One SSE connection per site |
| Construct inside a **running asyncio loop** | Dispatcher uses `asyncio.create_task` |
| Keep `@site.on_login` handlers **fast** | Events are dispatched sequentially |
| `passport is None` → **expired** | Clear pending state for that `request_id` |

---

### Scenario B — Minimal loop + full cycle with `lime-agents-sdk`

**Story:** End-to-end headless login — site creates request, agent approves, site verifies passport JWT.

```python
import asyncio

from lime_agents import LimeAgent
from lime_sites import InvalidPassportError, LimeSite

async def main() -> None:
    received = asyncio.Event()
    box: dict[str, str] = {}

    site = LimeSite()  # LIME_SITE_TOKEN — must be inside async main (running loop)

    @site.on_login
    async def handle_login(request_id: str, passport: str | None) -> None:
        if passport:
            box["jwt"] = passport
            received.set()

    req = await site.create_login_request()

    async with LimeAgent() as agent:  # LIME_AGENT_TOKEN
        approve = await agent.login(req.request_id)
        print(approve.status)  # APPROVED — passport JWT is delivered to site via SSE, not to agent

    await asyncio.wait_for(received.wait(), timeout=120)

    try:
        verified = await site.verify_passport(
            box["jwt"],
            expected_request_id=req.request_id,
        )
    except InvalidPassportError as exc:
        print(f"passport invalid: {exc}")
        await site.aclose()
        return

    print(verified.claims["agent_id"])  # verified.valid is always True on success
    await site.aclose()


asyncio.run(main())
```

**SSE dispatcher (automatic):**

1. `GET /api/v1/modules/agent-login/events` (`text/event-stream`, `X-Site-Token`)
2. Parse `approved` / `expired` / `keepalive` with reconnect + backoff
3. Call registered handlers: `(request_id, passport | None)`
4. Stop on `await site.aclose()`

**Agent side (separate package):** [`lime-agents-sdk`](https://github.com/Mawyxx/lime-agents-sdk) → `await agent.login(request_id)` — PoW + approve.

---

## Agent Binding (hosted connect)

Bind a LIME agent to a site user via the hosted portal — no SSE. Persist `binding_id` **before** redirect; verify the callback passport with `aud=lime-binding`.

```python
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from lime_sites import LimeSite

site: LimeSite
pending_bindings: dict[str, str] = {}  # binding_id -> your user_id
# Also store binding_id on the browser session / signed cookie so the callback can load it.


@asynccontextmanager
async def lifespan(app: FastAPI):
    global site
    site = LimeSite()  # LIME_SITE_TOKEN — server-side only
    yield
    await site.aclose()


app = FastAPI(lifespan=lifespan)


@app.post("/bind/start")
async def bind_start(user_id: str) -> RedirectResponse:
    req = await site.create_binding_request(
        redirect_uri="https://your.app/bind/callback",
    )
    # CRITICAL: persist before redirect — LIME does not host your user mapping.
    pending_bindings[req.binding_id] = user_id
    # Set a short-lived cookie/session value for binding_id as well.
    return RedirectResponse(req.connect_url, status_code=302)


@app.get("/bind/callback")
async def bind_callback(request: Request) -> dict[str, str]:
    passport = request.query_params["passport"]
    # Crypto only — signature, aud, TTL, non-empty binding_id claim.
    verified = await site.verify_binding_passport(passport)
    binding_id = verified.claims["binding_id"]
    # Business logic: load PENDING by claims.binding_id, enforce ownership.
    user_id = pending_bindings.pop(binding_id)
    agent_id = verified.claims["agent_id"]  # JWT sub
    # UPSERT user_id <-> agent_id in your DB
    return {"agent_id": agent_id, "user_id": user_id}
```

| Step | SDK / app |
|------|-----------|
| 1 | `create_binding_request(redirect_uri=…)` → `binding_id`, `connect_url` |
| 2 | Persist `binding_id` ↔ `user_id` server-side |
| 3 | `302` to `connect_url` (use API value as-is) |
| 4 | Callback `?passport=` → `verify_binding_passport(jwt)` (crypto only) |
| 5 | Load PENDING by `claims["binding_id"]`; UPSERT `agent_id`; clear pending |

| Check | Value |
|-------|-------|
| Audience | `aud == "lime-binding"` |
| Claim | JWT must include non-empty `binding_id` (match to pending is app-owned) |
| TTL | passport `exp - iat` ≤ **60s** |
| Failures | raise `InvalidPassportError` (no soft `valid=False`) |

Portal `/public` and `/complete` are **not** wrapped by this SDK.

---

## Features

- **Headless AI agent login** — no browser, QR, or OAuth redirect on the site
- **Background SSE dispatcher** — perpetual event stream with auto-reconnect (310s read timeout)
- **`@site.on_login` handlers** — `approved` → JWT string; `expired` → `passport=None`
- **JWKS passport verification** — RS256, `aud=lime-site-login`, cached keys, `kid` refresh
- **`create_login_request()`** — `POST /modules/agent-login/requests` with `X-Site-Token`
- **Agent Binding** — `create_binding_request()` + `verify_binding_passport()` (`aud=lime-binding`, TTL ≤ 60s)
- **Typed results** — `LoginRequestResult`, `PassportVerificationResult`, mypy-clean public API

---

## API reference (summary)

### `LimeSite`

Construct **inside a running asyncio loop** (e.g. FastAPI lifespan, `asyncio.run`).

| Method | Description |
|--------|-------------|
| `@site.on_login` / `site.on_login(handler)` | Register handler for SSE login events |
| `await site.create_login_request()` | Start login → `LoginRequestResult` |
| `await site.create_binding_request(*, redirect_uri)` | Start binding → `BindingRequestResult` |
| `await site.verify_passport(jwt, *, expected_request_id=None)` | JWKS RS256 verify (`aud=lime-site-login`) → `PassportVerificationResult` |
| `await site.verify_binding_passport(jwt)` | JWKS RS256 verify (`aud=lime-binding`) → `PassportVerificationResult` |
| `await site.aclose()` | Stop dispatcher + close HTTP client |

**Constructor highlights:** `site_token` / `LIME_SITE_TOKEN`, `base_url` / `LIME_API_BASE` (default `https://lime.pics/api/v1`), `timeout`, `max_retries`, `sse_backoff_base`, injectable `http_client`.

### `verify_passport` checks

- Signature valid against `GET /api/v1/core/.well-known/jwks.json`
- `aud == "lime-site-login"`
- `exp` / `iat` within platform TTL
- Optional `expected_request_id` matches JWT `request_id` claim

**Claims** (typical): `agent_id`, `user_id`, `user_kyc_level`, `agent_reputation`, `request_id`, `exp`, `iat`.

### Environment variables

| Variable | Required | Description |
|----------|----------|-------------|
| `LIME_SITE_TOKEN` | Yes* | Site integration token (`st_...`) from the LIME portal |
| `LIME_API_BASE` | No | API root, e.g. `https://lime.pics/api/v1` |

\*Unless `site_token=` is passed to the constructor.

### Errors

All inherit from `LimeError`: `AuthenticationError`, `InvalidPassportError`, `RequestExpiredError`, `RateLimitError`, `ApiError`.

`RuntimeError` if `LimeSite()` is constructed without a running event loop.

---

## Production notes

- Create **one** `LimeSite` at worker startup — not per HTTP request.
- nginx `proxy_read_timeout` on `GET .../events` should be **≥ 310s** (matches SDK SSE read timeout).
- Store `request_id` → pending session in Redis/DB; complete session in `@site.on_login`.
- Never expose `LIME_SITE_TOKEN` to frontend JavaScript — server-side only.

---

## Related packages

| Package | Role |
|---------|------|
| [`lime-agents-sdk`](https://github.com/Mawyxx/lime-agents-sdk) | Agent worker: `login(request_id)`, MCP OAuth client |
| [`lime-mcp-server-sdk`](https://github.com/Mawyxx/lime-mcp-server-sdk) | MCP resource server: verify MCP Bearer JWT (separate from site passport) |

---

## Contributing

Issues and pull requests: [github.com/Mawyxx/lime-site-sdk](https://github.com/Mawyxx/lime-site-sdk)

```bash
git clone https://github.com/Mawyxx/lime-site-sdk.git
cd lime-site-sdk
pip install -e ".[dev]"
ruff check src tests
mypy src/lime_sites
pytest --cov=lime_sites --cov-fail-under=100
```

CI runs on Python 3.10–3.13 with **100% line coverage** on `src/lime_sites`.

---

## License

MIT — see [LICENSE](LICENSE).
