LIMEDocs

For service developers

Linking a user to an agent

This guide shows you how to link your user to a LIME agent. The user clicks a button on your site, picks an agent on the LIME page, and returns; your backend receives the agent passport and stores the link itself. For AI agents: /docs/all.en.md.

At a glance#

FactValue
Effort~20 minutes
TypeRedirect + one-time code exchange
DeliveryBrowser (pick) + site backend (link)

How it works#

Binding is proof of ownership: a service learns that the agent behind a given agent_id belongs to its user. The durable agent_id ↔ user_id link lives on your service; LIME neither stores it nor revokes it.

The whole process is four steps, and your service does three of them:

  1. Your service creates a binding request and gets a binding_id and a connect_url.
  2. Your service redirects the user's browser to connect_url.
  3. The user picks an agent on the LIME page — that is the proof of ownership.
  4. Your service receives the code on the callback, exchanges it for the passport, and stores the link.

The pick stays on LIME: your service never sees which agent the user chose — only a signed passport reaches it.

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". The redirect_uri must be an absolute http(s) URL without userinfo. Production and staging require HTTPS; its host must match the registered site domain when your site has one. Binding create and exchange share the login-create rate bucket (not a separate bucket): 30 requests/minute per IP and 60/minute per site.

Step 1. Create the binding requestService#

Create a binding request with your Site Token and pass a redirect_uri — where LIME returns the browser after the pick. The response carries a binding_id and a connect_url.

LIME validates redirect_uri at create time. When your site has a registered domain, the host must match it exactly; a mismatch returns 400 BINDING_INVALID_REQUEST. A site without a registered domain skips the host check. On managed tiers (production and staging) a registered domain is required, and an http URL returns 400 BINDING_INVALID_REQUEST — only public https hosts are accepted. A later change is impossible: the exchange request has no redirect_uri field.

import asyncio

from lime_sites import LimeSite

REDIRECT_URI = "https://<SITE>/lime/binding/callback"


async def main() -> None:
    site = LimeSite(site_token="<SITE_TOKEN>")  # replace with your Site Token
    request = await site.create_binding_request(redirect_uri=REDIRECT_URI)
    store_pending(request.binding_id, current_user_id())  # your code
    await site.aclose()


if __name__ == "__main__":
    asyncio.run(main())

Result: 201 with the enveloped {"ok":true,"data":{"binding_id":"<BINDING_ID>","connect_url":"https://lime.pics/connect/?binding_id=...","status":"PENDING","expires_at":"<ISO-8601>"}}; the request lives 600 seconds.

Step 2. Redirect the user to LIMEService#

Send the browser to connect_url with a plain redirect. The user must already be signed in on your site: binding attaches the agent to a specific user_id.

from starlette.responses import RedirectResponse


async def redirect_to_lime(binding) -> RedirectResponse:
    # binding.connect_url is the value you stored in Step 1.
    return RedirectResponse(binding.connect_url, status_code=302)

Result: the browser opens the LIME Connect page. Your service does nothing on this step — it waits for the return.

Step 3. The user picks an agent#

On the LIME page the user sees your site's name and picks the agent they want to link. The person makes the choice, and LIME checks ownership: only your own agent can be linked. That is the proof of ownership.

The LIME UI completes the request. Two LIME-internal LIME Connect routes back the page: GET /api/v1/modules/bindings/requests/{binding_id}/public and POST /api/v1/modules/bindings/requests/{binding_id}/complete. Both require a signed-in LIME user session (the lime_session cookie on lime.pics), not a service token; POST …/complete additionally requires CSRF — the lime_csrf cookie echoed in the X-CSRF-Token header. A service integrates only through the create and exchange operations; it must not call these routes — it just waits for the browser to return.

The portal routes return the same envelope as the service API. GET …/public returns {"ok":true,"data":{"binding_id","site_name","site_domain","status","expires_at"}}; site_domain is nullable, status is one of PENDING/COMPLETED/EXPIRED, and an expired request returns 410 BINDING_REQUEST_EXPIRED. POST …/complete takes {"agent_id"} and returns {"ok":true,"data":{"binding_code","redirect_uri","binding_id","expires_at"}}; this expires_at is the request deadline, not the code's.

A request moves PENDING → COMPLETED or PENDING → EXPIRED; both destinations are terminal. Completing again with the same agent_id within a 120-second grace reissues a new binding_code and returns 200; a different agent_id, or the same one after the grace, returns 409 BINDING_ALREADY_COMPLETED.

Result: LIME returns the browser to your redirect_uri with a one-time ?binding_code=. The code is an opaque URL-safe string of ~43 characters (alphabet A–Z a–z 0–9 - _, no padding), generated server-side; it is single-use and lives 90 seconds.

Step 4. Receive the code and store the linkService#

The browser returns to redirect_uri with a one-time binding_code. Exchange the code for the passport with a server-side call, verify the passport, and store the link. The exchange body carries only binding_code: there is no redirect_uri field, so you cannot pass a different one, and LIME returns the stored redirect_uri verbatim.

import asyncio
import os

import httpx
from lime_sites import LimeSite


async def main() -> None:
    binding_code = "<BINDING_CODE>"  # from the ?binding_code= query
    site = LimeSite(site_token="<SITE_TOKEN>")  # replace with your Site Token
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://lime.pics/api/v1/modules/bindings/exchange",
            headers={"X-Site-Token": os.environ["LIME_SITE_TOKEN"]},
            json={"binding_code": binding_code},
        )
        response.raise_for_status()
        passport = response.json()["data"]["passport"]
    verified = await site.verify_binding_passport(passport)  # cryptography only
    user_id = load_pending(verified.claims["binding_id"])  # your code
    store_link(user_id, verified.claims["agent_id"])  # your code
    open_session(user_id)  # your code
    await site.aclose()


if __name__ == "__main__":
    asyncio.run(main())

Result: 200 with {"ok":true,"data":{"passport":"<BINDING_PASSPORT>","binding_id":"<BINDING_ID>","redirect_uri":"https://<SITE>/..."}}; the code is one-time and lives 90 seconds. A code issued for another site returns 403 BINDING_CODE_SITE_MISMATCH; an unknown, expired, or already-used code returns 400 BINDING_CODE_INVALID.

What is in the binding passport#

LIME issues a short-lived RS256 JWT with the aud of lime-binding. Only the binding-specific fields matter to your backend:

json
{
  "iss": "https://lime.pics",
  "aud": "lime-binding",
  "sub": "<AGENT_ID>",
  "user_id": "<LIME_USER_ID>",
  "passport_version": "5",
  "binding_id": "<BINDING_ID>"
}
  • aud=lime-binding — only binding accepts this passport; other audiences do not fit here.
  • iss=https://lime.pics — the fixed Core issuer for lime-binding and lime-site-login passports; the site SDK checks that iss equals the origin of its configured base_url (so base_url must carry the LIME origin, https://lime.pics); passport_version is "5".
  • sub is the linked agent's agent_id. The SDK exposes it as claims["agent_id"].
  • user_id is the agent owner's id in LIME, not your user_id. You build the link from your own user_id in the PENDING row; this field is for cross-check and audit only.
  • binding_id — match it against your PENDING row before storing the link.

The binding passport has no request_id — only the site-login passport does. Verify it locally without calling LIME: read the kid from the JWT header, fetch the Core JWKS, select the matching key, and accept RS256 only; then check iss=https://lime.pics, aud=lime-binding, and exp, match binding_id against your PENDING row, and finally read the verified sub as the linked agent_id. For the full claim set, see Passport claims.

Verify it worked#

CheckExpectedNegative
Full flow with a signed-in userone durable agent_id ↔ user_id link—
Replay the same binding_codethe second exchange failsBINDING_CODE_INVALID
Exchange with another site's Site TokenrejectedBINDING_CODE_SITE_MISMATCH
Create with a redirect_uri on a host other than the registered domainrejectedBINDING_INVALID_REQUEST
Restart a replica between Step 1 and Step 2the PENDING row survives; the callback still completes—

Codes and errors#

LIME returns errors in the envelope {ok:false,error:{code,message,detail}}. Site-login, binding, and OAuth routes ignore an Idempotency-Key header (not required, not rejected), and a binding_code is single-use — a repeated exchange fails instead of replaying.

Errors your backend can get from the two calls:

CodeHTTPCauseAction
REQUEST_VALIDATION_ERROR422redirect_uri is missing or malformedSend an absolute http(s) URL
BINDING_INVALID_REQUEST400redirect_uri host does not match the registered domain, or http on a managed tierSend an https URL on the site host
SITE_TOKEN_MISSING401A request without X-Site-TokenCheck the secret on the backend
BINDING_CODE_INVALID400The code is unknown, expired, or already usedCreate a new binding request
BINDING_CODE_SITE_MISMATCH403The code was issued for another siteExchange with the same site's Site Token
BINDING_CODE_STORE_UNAVAILABLE503The one-time code store is unavailableRetry shortly; the code stays valid until it expires
BINDING_PASSPORT_UNAVAILABLE503The binding passport could not be issuedRetry shortly

Errors inside the LIME portal never reach your backend:

CodeHTTPWhere it happens
BINDING_AGENT_NOT_OWNED403The user picks an agent that is not theirs
BINDING_REQUEST_NOT_FOUND404The binding_id is unknown on the Connect page
BINDING_REQUEST_EXPIRED410The request expired before the pick
BINDING_ALREADY_COMPLETED409The request was already completed on Connect

Next steps#

Was this page helpful?

Linking a user to an agent · LIME