For site developers
Let an agent sign in to your site
This guide shows you how to let an agent sign in to your site in about 20 minutes. Your backend creates the login request, shows the ID on a waiting screen, waits on SSE, verifies the passport and opens your own session. For AI agents: /docs/all.en.md.
At a glance#
| Fact | Value |
|---|---|
| Effort | ~20 minutes |
| Type | Button + waiting screen + SSE |
| Delivery | Site backend only |
How it works#
- The agent opens your site in a browser and clicks Sign in with LIME; your backend creates a PENDING request and the waiting screen shows
login_request_id. - The agent reads the ID and approves it.
- The backend receives the passport over SSE, verifies it and sets its own session.
What you need#
Register a site in the LIME portal and keep LIME_SITE_TOKEN in a .env file. Install the site SDK with pip install "lime-sites-sdk>=2.0.0". Your page needs a waiting screen that can show the login_request_id to the agent.
LIME_SITE_TOKEN=<SITE_TOKEN>Step 1. Create the login request on button click#
When the agent clicks Sign in with LIME in its browser, the backend creates a PENDING request with the Site Token.
import asyncio
from lime_sites import LimeSite
async def main() -> None:
site = LimeSite(site_token="<SITE_TOKEN>") # replace with your Site Token
req = await site.create_login_request() # 201, status=PENDING
print("login_request_id:", req.request_id) # show it on the waiting screen
await site.aclose()
if __name__ == "__main__":
asyncio.run(main())Result: 201 with a login_request_id (a UUID v4) and status = PENDING; the request lives 120 seconds. The create response names it login_request_id; the status read and the approve response return the same UUID as request_id.
Step 2. Show the ID on the waiting screen#
The agent reads the ID from your page — LIME does not push it to the agent. Expose the login_request_id in a machine-readable form (a JSON field the agent can fetch, or parseable text in the waiting screen); an id shown only as an image is not enough.
Result: the waiting page exposes the ID in a machine-readable form, and the agent reads it from there to approve.
Step 3. Wait for the passport over SSE#
Construct LimeSite inside a running event loop: its constructor starts the SSE dispatcher, which keeps one GET …/events connection and reconnects it after each drop.
The handler signature is async def handler(request_id: str, passport: str | None). On an approved frame passport is the JWT string; on an expired frame it is None. If the handler raises, the SDK logs the error and swallows it — the stream keeps running, so never rely on the handler to stop the loop.
The SDK does not deduplicate frames: delivery is at-least-once, so the caller must dedupe by login_request_id. Treat login_request_id as the idempotency key — a duplicate approved frame must not open a second session.
import asyncio
from lime_sites import LimeSite
async def main() -> None:
site = LimeSite(site_token="<SITE_TOKEN>") # replace with your Site Token
@site.on_login
async def handle_login(request_id: str, passport: str | None) -> None:
verified = await site.verify_passport(passport, expected_request_id=request_id)
# set YOUR site session for the verified sub claim (SDK alias: verified.claims["agent_id"])
try:
await asyncio.sleep(float("inf")) # keep alive
finally:
await site.aclose()
if __name__ == "__main__":
asyncio.run(main())The SSE wire field agent_passport_jwt maps to passport, which goes to verify_passport: agent_passport_jwt → passport → verify_passport.
Result: an approved event carrying agent_passport_jwt; the passport lives 60 seconds (lifetimes).
Step 4. Verify the passport and set your session#
Verify the RS256 signature locally via JWKS, check iss=https://lime.pics and aud=lime-site-login (passport_version="5"), and match the request_id field against your own login_request_id. A request_id mismatch raises InvalidPassportError with the message JWT request_id claim does not match expected request, so wrap the call. Only then set your own session cookie for the verified sub claim (the agent's agent_id) — LIME never writes a cookie in the browser.
from lime_sites import InvalidPassportError
login_request_id = req.request_id # the id you created and showed in Step 1
try:
verified = await site.verify_passport(
passport, # agent_passport_jwt from the SSE event
expected_request_id=login_request_id,
)
except InvalidPassportError:
return # request_id mismatch or bad signature — ignore this frame
agent_id = verified.claims["agent_id"] # SDK alias of the verified sub claim — set YOUR site session for this agent_idResult: your session cookie is set for the verified agent_id; a duplicate frame for the same login_request_id changes nothing.
The event frames#
A login request is PENDING, APPROVED, DELIVERING, DELIVERED, EXPIRED or CANCELLED: PENDING → APPROVED → DELIVERING → DELIVERED, or PENDING → EXPIRED; EXPIRED and CANCELLED are terminal.
GET …/events is a text/event-stream scoped to your site, sent with the headers Cache-Control: no-cache and X-Accel-Buffering: no. Frames carry no event:, id:, or retry: line — each frame is exactly one data: {json} line followed by a blank line (\n\n), and the JSON carries the kind in type:
The SSE client reconnects with exponential backoff: it starts at sse_backoff_base (default 0.5 s), doubles after each failed attempt (×2) up to a cap of 8 s, and adds no jitter. The backoff resets after any clean close. CancelledError stops the client — no reconnect.
The server does not cap the stream at ~300 s: that 300 s setting only bounds the TTL of an undelivered orphan event key. The edge proxy closes an idle connection after proxy_read_timeout 310s, so give the SSE client a read timeout of at least that.
data: {"type":"keepalive"}
data: {"type":"approved","login_request_id":"<LOGIN_REQUEST_ID>","status":"approved","agent_passport_jwt":"<PASSPORT>"}
data: {"type":"expired","login_request_id":"<LOGIN_REQUEST_ID>","status":"expired"}
data: {"type":"error","code":"SITE_LOGIN_EVENT_CHANNEL_UNAVAILABLE","message":"<TEXT>"}type | Keys | What to do |
|---|---|---|
approved | login_request_id, status (approved or delivered), agent_passport_jwt | verify the passport, then open your session |
expired | login_request_id, status (expired or cancelled) | show a fresh waiting screen and create a new request |
keepalive | — | ignore; sent on idle about every 20 s |
error | code, message | reconnect and retry |
Your backend owns the connection: on approved it verifies the passport and sets your session cookie. The browser sits on your waiting page and picks up the session through your own endpoint (for example polling your /session) — LIME never writes a cookie or talks to the browser.
The Idempotency-Key header is ignored on site-login, binding, and OAuth routes (not required, not rejected): every create call makes a new request, the approve call is idempotent, and SSE delivery is at-least-once — dedupe frames by login_request_id.
Example response (JSON)#
The create call returns 201:
{
"ok": true,
"data": {
"login_request_id": "<LOGIN_REQUEST_ID>",
"status": "PENDING",
"expires_at": "<EXPIRES_AT>"
}
}Verify it worked#
| Check | Expected | Negative |
|---|---|---|
| Full flow with a real agent | exactly one site session for the agent_id | — |
Duplicate SSE approved frame | idempotent — one session per login_request_id | — |
| Waiting screen without the ID | the agent cannot approve; show the ID again | 409 SITE_LOGIN_REQUEST_EXPIRED |
Backend call without X-Site-Token | the request is not created — 401 SITE_TOKEN_MISSING | 401 SITE_TOKEN_MISSING |
| Reconnect after each terminal event | the SSE stream keeps delivering later events | 503 SITE_LOGIN_EVENT_CHANNEL_UNAVAILABLE |
Codes and errors#
LIME returns errors in the envelope {ok:false,error:{code,message,detail}}; the code column is error.code.
| Code | HTTP | Cause | Action |
|---|---|---|---|
SITE_TOKEN_MISSING | 401 | A call without X-Site-Token | Check LIME_SITE_TOKEN on the backend |
SITE_TOKEN_INVALID | 401 | The Site Token is unknown or revoked | Rotate the token in the portal and redeploy |
SITE_PAUSED | 403 | The site integration is paused by its owner | Resume the integration in the portal |
SITE_LOGIN_REQUEST_NOT_FOUND | 404 | The status read used an unknown login_request_id | Take the id from your own create response |
SITE_LOGIN_REQUEST_EXPIRED | 409 | The request expired before the agent approved it | Create a new request and show a fresh id |
SITE_LOGIN_EVENT_CHANNEL_UNAVAILABLE | 503 | The SSE event channel is unavailable | Reconnect the stream and retry |
Waiting screen on failures:
| Situation | Waiting screen | Backend |
|---|---|---|
SSE approved | "approved — finishing" | verifies the passport and sets your cookie |
SSE expired or status 409 | "expired — click Sign in with LIME again" | creates a new request on the next click |
SSE error frame or channel 503 | "temporary problem — reconnecting" | reconnects the stream and retries |
Duplicate approved for the same login_request_id | nothing changes | dedupes by login_request_id |
Approve failures (INVALID_POW, MISSING_POW_NONCE, cross-account 404) are returned by LIME to the agent directly — the waiting screen does not relay them; the agent re-solves or waits for a fresh id.