Open hosted chat
Issue a single-use link on your backend and redirect signed-in users to a separate Hosted chat page.
Add a button that takes signed-in users to a separate Praxis Hosted chat page. Your backend authenticates the user, issues a link, and redirects immediately. No chat component is installed on your host page.
Prepare your agent
You need Bun, an API key in the agent's organization, and a service agent with instructions, a runtime environment, and an embed policy. The agent must have a short number. Open it under 服务智能体 (Service agents) in the Develop Dashboard, check the short number in its header, and copy its public agent_… ID.
If the embed policy is not saved, open 接入与配置 → 配置 → 安全, select 运行环境, and click 保存安全设置. Embed policies can currently only be configured in the Dashboard. This example uses the Develop API and a local host at http://localhost:4242; chat opens on the hosted domain.
Hosted is a separate page and cannot be embedded in an iframe. To keep users inside the current page, choose iframe or the Kit component.
Issue a link and redirect
Save this complete server as server.ts. It binds only to loopback, authenticates two test accounts through HTTP Basic, and checks the form request's origin. PRAXIS_API_KEY stays on the backend. external_user_id comes from the authenticated account, never a form field.
import { timingSafeEqual } from "node:crypto";
const apiKey = process.env.PRAXIS_API_KEY;
const agentId = process.env.PRAXIS_AGENT_ID;
const password = process.env.DEMO_PASSWORD;
if (!apiKey || !agentId || !password) throw new Error("Set the three server variables");
const origin = "http://localhost:4242";
const noStore = { "Cache-Control": "no-store" };
function authenticate(request: Request): string | undefined {
const supplied = Buffer.from(request.headers.get("Authorization") ?? "");
for (const id of ["customer_123", "customer_456"]) {
const expected = Buffer.from("Basic " + Buffer.from(`${id}:${password}`).toString("base64"));
if (supplied.length === expected.length && timingSafeEqual(supplied, expected)) return id;
}
}
Bun.serve({
hostname: "127.0.0.1", port: 4242,
async fetch(request) {
const url = new URL(request.url);
if (url.origin !== origin) return new Response("Use localhost:4242", { status: 403 });
const userId = authenticate(request);
if (!userId) return new Response("Sign in", {
status: 401, headers: { ...noStore, "WWW-Authenticate": 'Basic realm="Local demo"' },
});
if (request.method === "GET" && url.pathname === "/") {
return new Response(Bun.file("index.html"), { headers: noStore });
}
if (request.method !== "POST" || url.pathname !== "/api/praxis-hosted") {
return new Response("Not found", { status: 404 });
}
if (request.headers.get("Origin") !== origin) {
return new Response("Origin rejected", { status: 403 });
}
try {
const response = await fetch("https://api-develop.deepraxis.com/agent/v1/embed_tokens", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ agent: agentId, external_user_id: userId, hosted: true }),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) return new Response("Token issuance failed", {
status: response.status, headers: noStore,
});
const data = await response.json();
if (typeof data.hosted_url !== "string") throw new Error("Missing URL");
return new Response(null, {
status: 302, headers: { ...noStore, Location: data.hosted_url },
});
} catch {
return new Response("Token service unavailable", { status: 502, headers: noStore });
}
},
});
console.log(origin);Setting hosted: true adds hosted_url to the issuance response. Successful issuance returns HTTP 201. The host responds with HTTP 302 and puts that URL in Location, taking the browser to chat. It does not return the API key or the issuance response's token to the form page.
Configure the actual agent ID, API key, and your chosen local test password in the server environment. Save the page in the next section, then start from the same directory:
export PRAXIS_AGENT_ID="agent_REPLACE_ME"
# Set PRAXIS_API_KEY and DEMO_PASSWORD in this server process, then run:
bun server.tsExpected: the terminal shows the local address. Unauthenticated requests and requests from a different origin cannot issue links. In an existing application, replace test-account authentication with session validation and put the route behind login and CSRF protection. Do not use this test-account mechanism for production login.
Add an opening button
Save this as index.html in the same directory. A normal form submission follows the backend redirect. A background fetch request alone does not navigate the browser.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Order support</title>
<form action="/api/praxis-hosted" method="post">
<button type="submit">Open order assistant</button>
</form>
</html>Open http://localhost:4242, sign in as customer_123 using your test password, and click the button. Expect navigation to a chat page on chat-develop.praxisusercontent.com. Each click requests a fresh link from your host; do not cache the previous result. After navigation, check that the chat input becomes available.
Understand link lifetime
The #c=… fragment in hosted_url contains a single-use exchange credential, not an embed token. It is valid for 60 seconds after issuance and can be redeemed successfully only once. Do not log, bookmark, or share credential-bearing links. Authenticate each user on your host before entry.
The hosted page reads the credential, removes the fragment, and automatically submits code to POST /agent/v1/embed/exchange. It receives token and expires_at. Your backend must not redeem the credential first, which would consume the browser's link.
The 60-second link lifetime is separate from the embed token's lifetime. After exchange, the page uses that token until expiration. Hosted does not refresh tokens automatically. Expiration or a revocation error replaces chat with a notice to return to the original website. Reloading the hosted page cannot reuse the removed credential; return to the host and click again.
Verify the integration
Use this server-side request to check issuance shape. It prints only the grant ID, expiration, and whether a link exists, without printing credentials. This independent API check does not navigate a browser. Its fixed user ID is for controlled testing; your application must use authenticated identity.
set -o pipefail
curl --fail-with-body --silent --show-error \
-X POST https://api-develop.deepraxis.com/agent/v1/embed_tokens \
-H "Authorization: Bearer $PRAXIS_API_KEY" \
-H "Content-Type: application/json" \
--data '{"agent":"agent_REPLACE_ME","external_user_id":"customer_123","hosted":true}' \
| jq '{id, expires_at, has_hosted_url: (.hosted_url | type == "string")}'Then exercise the complete button flow. The network panel should show a host POST returning 302, followed by successful exchange on the hosted page. Send an order question and confirm a real reply. A loading interface alone does not establish success.
Enter as customer_456 in a separate browser and confirm it cannot read the first account's conversations. Unauthenticated host requests should return HTTP 401. To test link expiration, create a separate test link and open it after 60 seconds. Expect instructions to return to the host, rather than successful access through repeated retries.
Handle failures and appearance
Exchange returns NOT_FOUND (HTTP 404) for expired, used, and unknown credentials. Restart from the host in all three cases. Missing short numbers or embed policies cause HTTP 400 during issuance; complete those prerequisites in the Dashboard before retrying. For HTTP 429, wait according to Retry-After, then have the host issue a new link.
Save appearance under 接入与配置 → 接入方式与外观 → iframe. Hosted shares settings such as name, theme, and greeting. Persistent appearance can currently only be configured in the Dashboard. Hosted always displays a full-page conversation without a floating launcher. The Dashboard's Hosted → 打开预览 button is a preview and does not replace host issuance for real signed-in users.