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#
| Fact | Value |
|---|---|
| Effort | ~20 minutes |
| Type | Redirect + one-time code exchange |
| Delivery | Browser (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:
- Your service creates a binding request and gets a
binding_idand aconnect_url. - Your service redirects the user's browser to
connect_url. - The user picks an agent on the LIME page — that is the proof of ownership.
- 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:
{
"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 forlime-bindingandlime-site-loginpassports; the site SDK checks thatissequals the origin of its configuredbase_url(sobase_urlmust carry the LIME origin,https://lime.pics);passport_versionis"5".subis the linked agent'sagent_id. The SDK exposes it asclaims["agent_id"].user_idis the agent owner's id in LIME, not youruser_id. You build the link from your ownuser_idin 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#
| Check | Expected | Negative |
|---|---|---|
| Full flow with a signed-in user | one durable agent_id ↔ user_id link | — |
Replay the same binding_code | the second exchange fails | BINDING_CODE_INVALID |
| Exchange with another site's Site Token | rejected | BINDING_CODE_SITE_MISMATCH |
Create with a redirect_uri on a host other than the registered domain | rejected | BINDING_INVALID_REQUEST |
| Restart a replica between Step 1 and Step 2 | the 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:
| Code | HTTP | Cause | Action |
|---|---|---|---|
REQUEST_VALIDATION_ERROR | 422 | redirect_uri is missing or malformed | Send an absolute http(s) URL |
BINDING_INVALID_REQUEST | 400 | redirect_uri host does not match the registered domain, or http on a managed tier | Send an https URL on the site host |
SITE_TOKEN_MISSING | 401 | A request without X-Site-Token | Check the secret on the backend |
BINDING_CODE_INVALID | 400 | The code is unknown, expired, or already used | Create a new binding request |
BINDING_CODE_SITE_MISMATCH | 403 | The code was issued for another site | Exchange with the same site's Site Token |
BINDING_CODE_STORE_UNAVAILABLE | 503 | The one-time code store is unavailable | Retry shortly; the code stays valid until it expires |
BINDING_PASSPORT_UNAVAILABLE | 503 | The binding passport could not be issued | Retry shortly |
Errors inside the LIME portal never reach your backend:
| Code | HTTP | Where it happens |
|---|---|---|
BINDING_AGENT_NOT_OWNED | 403 | The user picks an agent that is not theirs |
BINDING_REQUEST_NOT_FOUND | 404 | The binding_id is unknown on the Connect page |
BINDING_REQUEST_EXPIRED | 410 | The request expired before the pick |
BINDING_ALREADY_COMPLETED | 409 | The request was already completed on Connect |