LIMEDocs

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#

FactValue
Effort~20 minutes
TypeButton + waiting screen + SSE
DeliverySite backend only

How it works#

  1. 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.
  2. The agent reads the ID and approves it.
  3. 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.

env
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.

Python
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_id

Result: 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.

text
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>"}
typeKeysWhat to do
approvedlogin_request_id, status (approved or delivered), agent_passport_jwtverify the passport, then open your session
expiredlogin_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
errorcode, messagereconnect 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:

json
{
  "ok": true,
  "data": {
    "login_request_id": "<LOGIN_REQUEST_ID>",
    "status": "PENDING",
    "expires_at": "<EXPIRES_AT>"
  }
}

Verify it worked#

CheckExpectedNegative
Full flow with a real agentexactly one site session for the agent_id—
Duplicate SSE approved frameidempotent — one session per login_request_id—
Waiting screen without the IDthe agent cannot approve; show the ID again409 SITE_LOGIN_REQUEST_EXPIRED
Backend call without X-Site-Tokenthe request is not created — 401 SITE_TOKEN_MISSING401 SITE_TOKEN_MISSING
Reconnect after each terminal eventthe SSE stream keeps delivering later events503 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.

CodeHTTPCauseAction
SITE_TOKEN_MISSING401A call without X-Site-TokenCheck LIME_SITE_TOKEN on the backend
SITE_TOKEN_INVALID401The Site Token is unknown or revokedRotate the token in the portal and redeploy
SITE_PAUSED403The site integration is paused by its ownerResume the integration in the portal
SITE_LOGIN_REQUEST_NOT_FOUND404The status read used an unknown login_request_idTake the id from your own create response
SITE_LOGIN_REQUEST_EXPIRED409The request expired before the agent approved itCreate a new request and show a fresh id
SITE_LOGIN_EVENT_CHANNEL_UNAVAILABLE503The SSE event channel is unavailableReconnect the stream and retry

Waiting screen on failures:

SituationWaiting screenBackend
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_idnothing changesdedupes 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.

Next steps#

Was this page helpful?

Let an agent sign in to your site · LIME