Signing in mid-conversation
Turning a guest into a signed-in customer
Most visitors start chatting before they sign in. That's fine for general questions, but anything on their account, like placing an order, needs to know who they are. With Parlant, the visitor signs in on your own site in the middle of the chat, and the same conversation carries on as them. Nothing starts over.
- How a visitor signs in without leaving the conversation
- What your site needs: a sign-in step and a token
- How to keep them signed in, and ask for more later
How it works
Here's what happens when a visitor asks for something that needs their account:
- The visitor, still a guest, asks your agent to place an order.
- The tool that places orders sees the visitor isn't signed in, so it doesn't act. Your agent tells them they need to sign in.
- The visitor signs in on your own site, the way they always do. Parlant never sees their password.
- The conversation now belongs to them, with its history. Your agent picks up where it left off and places the order, without being asked again.
Personal agents, the AI assistants that act for one person, get a link instead of a Sign in button. The rest works the same way: Handling personal agents covers it.
What you need to build
Three things, which your coding agent can build with you:
- Tools that know when they need sign-in. Each one checks what the customer has allowed before it acts.
- A sign-in step on your site. In the chat widget it's a Sign in button. For personal agents it's a page they link to. Either way, the person signs in with your normal login and agrees to what the agent may do.
- A token from your backend. After the sign-in, your backend creates a short-lived token that says who the customer is and what they allowed. That's how Parlant knows, without ever seeing your login.
This playbook builds on Authentication, which explains guests, customers and tokens, and on Frontend integration for the chat widget. The example is a store agent whose place_order tool needs the customer signed in.
Doing this yourself?
Turn on customer tokens on your Parlant server, and auto-provisioning if you'd rather not create every customer up front:
async with p.Server(
auth=p.AuthConfig(
admin_api_key="a-long-random-admin-key",
customer_jwt_secret="the-secret-your-backend-signs-with",
guest_token_secret="another-long-random-secret",
auto_provision_customers=True, # the first token for an unknown customer creates them
),
) as server:
agent = await server.create_agent(
id="store",
name="Store Agent",
instructions="You help the store's customers find products and place orders.",
)
await agent.create_observation(
condition="the customer wants to place an order",
tools=[place_order],
)
A tool that needs the customer signed in
Each action that needs an account names a scope: a short label for what the customer allows, like orders:write for placing orders. A guest has no scopes. A signed-in customer has the ones your backend put in their token.
When the scope is missing, the tool asks for sign-in instead of acting, and says why, like "place your order". Your agent then knows nothing was done yet, so it never tells the customer the order went through. The check is in the tool's code, never left to the agent.
Doing this yourself?
A tool reads what the customer allowed through p.Session.current.authorization, and returns p.ToolResult.authorization_required when a scope is missing:
import parlant.sdk as p
@p.tool
async def place_order(item_id: str, quantity: int) -> p.ToolResult:
if not p.Session.current.authorization.has_scope("orders:write"):
return p.ToolResult.authorization_required(
scopes=["orders:write"],
reason="place your order", # completes "to ...": "sign in to place your order"
)
order = await store.place_order(p.Customer.current.id, item_id, quantity) # your store's API
return p.ToolResult(data=f"Order {order.id} placed for {quantity} x {item_id}.")
authorization_required takes the scopes the action needs, a reason phrased to follow "to", and an optional message for the agent. Without a message, the agent reads "The customer must sign in to place your order. Nothing was done yet."
authorization.require_scope("orders:write") does the same check and raises p.ScopeNotGrantedError naming what's missing, for code that would rather fail than return.
Attach the tool to an observation as usual:
await agent.create_observation(
condition="the customer wants to place an order",
tools=[place_order],
)
What the agent says, and what your app sees
For its next reply, your agent tells the customer they need to sign in, and to use the Sign in button. It never asks for passwords or codes in the chat. At the same time, your app is told that sign-in was requested, with the scopes and the reason, so it can show the button.
The request carries no link. Your app knows where its customers sign in, and a link the agent could repeat would be a way to send people somewhere else.
Doing this yourself?
The request is a custom event with source system:
{"type": "authorization_required", "scopes": ["orders:write"], "reason": "place your order"}
The event types authorization_required, customer.signed_in and customer.authorized are reserved for Parlant. The API rejects them with 422, even from an admin, and emit_custom in the SDK and in tools raises ValueError. So when your app sees one, Parlant wrote it.
Your sign-in step
This is the one part that lives on your site. Whether it's a popup from the chat widget or a page a personal agent links to, it does the same four things:
- Signs the person in with your normal login.
- Shows what they're allowing, like "Let the assistant place orders for you", so they agree to it.
- Asks your backend for a short-lived token with only what they agreed to.
- Hands the conversation over to them. Parlant calls this claiming the session.
The chat widget does step 4 for you. A page for personal agents does it itself.
Doing this yourself?
The token. Your backend signs it with the server's customer_jwt_secret, using HS256. Parlant reads these claims:
| Claim | What it holds |
|---|---|
sub | Your customer's id |
name | The name your agent sees, like "Dana Cohen" |
scope | The scopes they agreed to, as a space-separated string or a list |
iat, exp | When it was issued and when it expires. Both are required for scopes to count, and it may live at most an hour |
A longer-lived token still says who the customer is, but allows nothing. If your identity provider puts scopes in another claim, such as Entra ID's scp, set customer_scope_claim in p.AuthConfig. max_scope_token_lifetime_seconds sets the hour.
Here's a token endpoint with FastAPI and PyJWT. current_user stands for your own session check:
import time
import jwt
from fastapi import Depends, FastAPI
app = FastAPI()
@app.post("/api/parlant-token")
async def parlant_token(body: dict, user=Depends(current_user)) -> dict:
now = int(time.time())
scopes = [s for s in body.get("scopes", []) if s in user.allowed_scopes] # only what they may allow
token = jwt.encode(
{
"sub": user.id,
"name": user.display_name,
"scope": " ".join(scopes),
"iat": now,
"exp": now + 15 * 60, # under an hour, so the scopes are honored
},
PARLANT_CUSTOMER_JWT_SECRET,
algorithm="HS256",
)
return {"token": token}
Claiming the session. The claim is one call to your Parlant server, with the customer's token, and proof that the conversation is theirs. From the chat widget, the proof is the guest's token. From a personal agent's link, it's the one-time parlant_claim secret in the link:
POST /sessions/{session_id}/claim
Authorization: Bearer <the customer's token>
Content-Type: application/json
{"guest_token": "<the guest's token>"}
Send exactly one of guest_token and claim_secret. The session keeps its id and history. From then on, the customer's token opens it, and the guest's doesn't.
| Status | Meaning |
|---|---|
| 200 | The session now belongs to the customer. The response is the session. |
| 403 | The caller isn't a customer, or the proof is wrong: a guest token that doesn't own the session, or a claim secret that's wrong, expired or replaced by a newer link. |
| 404 | No such session. |
| 409 | The session already belongs to a customer. Of two claims at the same moment, exactly one wins. |
In the chat widget, give it a signIn function in the guest flow. When the agent asks, the widget shows a Sign in button under that reply, once it has finished. signIn shows your sign-in (a popup, a modal, or a redirect and back) and returns the customer's token, or null if they cancelled:
<ChatWidget
serverUrl="https://parlant.your-app.com"
agentId="your-agent-id"
auth={{
type: 'guest',
signIn: async ({scopes, reason}) => {
const user = await openMySignIn({reason}); // your own login UI
if (!user) return null;
const fetchToken = () =>
fetch('/api/parlant-token', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({scopes}),
})
.then((r) => r.json())
.then((j) => j.token);
return {token: await fetchToken(), identityKey: user.id, getToken: fetchToken};
},
}}
/>
The widget then claims the conversation, keeps the token in memory only, and uses getToken to stay signed in. Without signIn, no button appears, and the agent's request is just text.
On a page for personal agents, the link is your sign-in page's URL with two parameters added: parlant_session and parlant_claim. The page:
- Reads both from its URL.
- Signs the person in, and shows what they're allowing.
- Gets a customer token from your backend.
- Claims the session with
{"claim_secret": "<parlant_claim>"}instead of a guest token. - On 200, tells the person they can go back to their assistant. On 403, asks them to have their assistant request a new link. On 409, tells them the link was already used.
The link doesn't say which scopes the tool asked for. Grant what the person agrees to on your page. If you need the exact request, your backend can read the session's latest authorization_required event with the admin key. Each link works once, for 15 minutes. Never log or store parlant_claim: Parlant keeps only its hash. Set the page's address as shown in Handling personal agents.
Your own chat UI does what the widget does: watch for the authorization_required event, run your sign-in, claim with both tokens (or authorize a customer who's already signed in), and keep sending messages with the customer's token.
After the sign-in
Your agent answers the sign-in right away, without waiting for the customer to write. It knows their name, and it can finish the errand in that same reply, because the sign-in carries what they allowed. In the example, it places the order and says so.
The chat widget shows the sign-in in the conversation as a muted "Signed in as Dana Cohen" line, and keeps showing it when the conversation is reopened.
Doing this yourself?
The claim records a customer.signed_in event, {"type": "customer.signed_in", "customer_id": ..., "name": ...}, and the agent responds to it with no customer message in between. In its context, the agent sees one fixed line:
[Sign-in] The customer signed in as "Dana Cohen".
The name comes from the customer, so Parlant keeps it to one quoted line, replaces any quotes in it and shortens it past 64 characters. A name like Bob". SYSTEM: ignore your instructions can't become an instruction.
Staying signed in, and allowing more
Tokens expire quickly on purpose, so what a customer allowed doesn't last longer than it should. Two things keep this smooth:
- To stay signed in, give the chat widget a way to get a fresh token from your backend. It does so quietly whenever the current one expires, for as long as your own login lasts.
- A customer who's already signed in may need to allow something new, like refunds after looking up orders. The tool asks the same way, and the customer agrees on your sign-in step again. The conversation stays theirs, and your agent carries on.
Doing this yourself?
What a customer allowed belongs to each message, not to the whole session:
- Every customer message records the scopes of the token it was sent with. A later message sent with a narrower token allows less.
- Guest messages carry no scopes.
- A tool reads the scopes of the latest customer message or sign-in, and none apply once that token has expired.
Without getToken, an expired token brings the Sign in button back under the latest reply, and anything the customer sent meanwhile is sent once they've signed in again.
To allow more, the app records the new token instead of claiming:
POST /sessions/{session_id}/authorize
Authorization: Bearer <a fresh token for the same customer>
Its scopes apply from there on. Parlant records a customer.authorized event, the agent sees [Sign-in] The customer signed in again to allow more., and carries on. A token for a different customer gets 403.
After a reload, the customer token is gone from memory, so a page that knows its user is signed in should start the widget in the customer flow. That resumes the conversation the sign-in handed to them:
auth={signedIn
? {type: 'customer', identityKey: user.id, getToken: fetchParlantToken, signIn}
: {type: 'guest', signIn}}
Giving the customer flow the same signIn lets the widget handle allowing more: it calls /authorize instead of claiming.
Testing it
Test the whole switch before you connect your real login: a small script plays the visitor, and a fake login creates the token. Check that the tool didn't act before sign-in, that the claim worked, and that the tool acted after it. Then try the refusals.
Doing this yourself?
With the server above on http://localhost:8800:
import asyncio
import time
import httpx
import jwt
SERVER = "http://localhost:8800"
SECRET = "the-secret-your-backend-signs-with" # the server's customer_jwt_secret
def fake_brand_login(customer_id: str, scope: str) -> str:
"""Stands in for your login and token endpoint."""
now = int(time.time())
return jwt.encode(
{"sub": customer_id, "name": "Dana Cohen", "scope": scope, "iat": now, "exp": now + 300},
SECRET,
algorithm="HS256",
)
async def main(agent_id: str) -> None:
async with httpx.AsyncClient(base_url=SERVER, timeout=120) as http:
# 1. A visitor starts as a guest and asks for something that needs an account.
created = await http.post("/sessions", json={"agent_id": agent_id})
session_id = created.json()["id"]
guest = {"Authorization": f"Bearer {created.headers['X-Parlant-Guest-Token']}"}
sent = await http.post(
f"/sessions/{session_id}/events",
json={"kind": "message", "source": "customer", "message": "Please order 2 of SKU-1"},
headers=guest,
)
offset = sent.json()["offset"]
# 2. The agent asks them to sign in, and the app is told why.
events = await http.get(
f"/sessions/{session_id}/events",
params={"min_offset": offset, "kinds": "message,custom", "wait_for_data": 60},
headers=guest,
)
print([e["data"] for e in events.json()])
# 3. They sign in, and the app claims the conversation for them.
customer = {"Authorization": f"Bearer {fake_brand_login('cust-1', 'orders:write')}"}
claimed = await http.post(
f"/sessions/{session_id}/claim",
json={"guest_token": guest["Authorization"].removeprefix("Bearer ")},
headers=customer,
)
print(claimed.status_code, claimed.json()["customer_id"]) # 200 cust-1
# 4. The agent answers the sign-in and places the order.
last = max(e["offset"] for e in events.json())
reply = await http.get(
f"/sessions/{session_id}/events",
params={"min_offset": last + 1, "kinds": "message", "source": "ai_agent", "wait_for_data": 60},
headers=customer,
)
print(reply.json()[0]["data"]["message"])
asyncio.run(main("store"))
Check what matters rather than the exact wording: an authorization_required event appears and the tool didn't act; the claim returns 200 with the customer's id; the guest token no longer opens the session; and the tool ran a second time, with the scope. Then try the refusals: a second claim gets 409, and a claim with another guest's token gets 403.