# 打开托管对话 (/docs/developers/agent/embed/hosted)



给你的网站添加一个按钮，让已登录用户进入 Praxis 的独立 Hosted 对话页。宿主后端验证用户、签发链接并立即跳转；不需要在宿主页安装聊天组件。

## 准备智能体 [#prepare]

准备 Bun、同组织的 API Key，以及一个已配置指令、运行环境和嵌入策略的服务智能体。智能体必须有短号；在 Develop 控制台的**服务智能体**中打开它，确认顶部显示短号，并复制公开 ID `agent_…`。

尚未保存嵌入策略时，进入**接入与配置 → 配置 → 安全**，选择**运行环境**，点**保存安全设置**。嵌入策略目前只能在控制台配置。以下示例使用 Develop API 和本地宿主 `http://localhost:4242`，对话则在托管域名中打开。

Hosted 是独立页面，不能作为 iframe 嵌进宿主。需要留在当前页面时，选择[一行嵌入](/docs/developers/agent/embed/iframe)或 [Kit 组件](/docs/developers/agent/embed/quickstart)。

## 签发链接并跳转 [#issue]

把以下完整服务器保存为 `server.ts`。它只绑定本机，用 HTTP Basic 验证两个测试账户，并检查表单请求来源。`PRAXIS_API_KEY` 只存在后端；`external_user_id` 来自通过验证的账户，而不是表单字段。

```ts
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);
```

`hosted: true` 让签发响应额外包含 `hosted_url`。成功签发的 HTTP 状态为 201；宿主返回 HTTP 302，并把该 URL 放进 `Location`，浏览器随即进入对话页。宿主不把 API Key 或签发响应中的 `token` 发给表单页面。

把实际智能体 ID、API Key 和你设置的本地测试密码放入服务端环境。下一节保存页面后，从同目录启动：

```bash
export PRAXIS_AGENT_ID="agent_REPLACE_ME"
# Set PRAXIS_API_KEY and DEMO_PASSWORD in this server process, then run:
bun server.ts
```

期望：终端显示本地地址。未经认证或来源不匹配的请求无法签发。用于已有应用时，将测试账户验证换成你的登录会话验证，把路由置于登录与 CSRF 保护之后；不要把这套测试账户机制用于线上登录。

## 添加打开按钮 [#open]

保存为同目录的 `index.html`。普通表单提交可以直接跟随后端的跳转；不要通过后台 `fetch` 请求后就假设浏览器已经导航。

```html
<!doctype html>
<html lang="zh">
<meta charset="utf-8">
<title>Order support</title>
<form action="/api/praxis-hosted" method="post">
  <button type="submit">Open order assistant</button>
</form>
</html>
```

打开 `http://localhost:4242`，以 `customer_123` 和所设测试密码登录，然后点按钮。期望：浏览器进入 `chat-develop.praxisusercontent.com` 下的对话页。每次点击都向宿主重新请求链接，不缓存上一次的结果。浏览器地址变化后，再检查是否出现可输入的对话区。

## 理解链接寿命 [#lifetime]

`hosted_url` 的 `#c=…` 是一次性兑换凭证，不是嵌入令牌。它在签发后 60 秒内有效，只能成功兑换一次。不要记录、收藏或分享带凭证的链接；每个用户应从宿主验证身份后进入。

托管页读取凭证后先移除 URL 片段，再自动向 `POST /agent/v1/embed/exchange` 提交 `code`，取得 `token` 和 `expires_at`。你的宿主不需要手动兑换，否则浏览器拿到的链接已被消费。

链接的 60 秒期限与对话令牌有效期是两件事。兑换成功后，页面在令牌到期前使用该令牌；Hosted 不自动刷新令牌。到期或收到吊销错误后，页面会提示回到原网站重新进入。刷新当前托管页也不能重新使用已经移除的一次性凭证，应回宿主重新点击按钮。

## 验证接入 [#verify]

在服务端可以用下面的请求检查签发形状。它只输出记录 ID、到期时间和是否存在链接，不输出凭证；这是独立的接口检查，不会替你导航浏览器。测试用户 ID 只用于这次受控检查，应用必须使用已验证身份。

```bash
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")}'
```

再从按钮执行完整路径：网络面板应看到宿主 POST 返回 302，随后托管页兑换成功并显示对话。发送一条订单问题，确认收到真实回复；只看到加载界面不算成功。

用独立浏览器以 `customer_456` 进入，确认无法读取第一个账户的会话。无凭据访问宿主端点应返回 HTTP 401。验收链接寿命时，使用单独的测试签发，让链接超过 60 秒后再打开，期望要求回到宿主重新进入，不在原链接上反复重试。

## 失败处理与外观 [#troubleshoot]

兑换接口对过期、已用和未知凭证均返回 `NOT_FOUND`（HTTP 404）。这些情况都应重新从宿主进入。缺少短号或嵌入策略会使签发返回 HTTP 400；在控制台补齐前提后再试。出现 HTTP 429 时按 `Retry-After` 等待，再由宿主签发新链接。

外观在**接入与配置 → 接入方式与外观 → iframe**保存，Hosted 共享名称、主题和欢迎语等设置。持久化外观目前只能在控制台配置。Hosted 始终全页显示，不使用浮窗启动按钮。控制台的 **Hosted → 打开预览**用于预览，不替代宿主对真实登录用户的签发。

## 下一步 [#next]

* [嵌入令牌与用户](/docs/developers/agent/embed/tokens)
* [自定义外观](/docs/developers/agent/embed/appearance)
* [智能体 API 参考](/docs/api/native/agent)
* [Cookbook](/docs/cookbook)
