# Embed an authenticated SaaS assistant (/en/docs/cookbook/scenarios/saas-embedded-assistant)



Embed an order assistant in a SaaS workspace: alice can read her orders, bob can read his, and returns require confirmation. This runnable example connects host sign-in, trusted token issuance, page context, and MCP authorization. Its orders are in-memory fixtures, not a connection to a real order system.

## Prepare the application and agent [#prepare]

You need Bun, an available Develop service agent, an organization API Key, and a test domain that proxies HTTPS requests to local port 4242. Praxis must be able to reach `/mcp` on that domain. Follow [Web integration](/en/docs/developers/agent/embed/quickstart) to prepare Kit, the environment, and origin policy. Shared fields are documented in [Embed tokens](/en/docs/developers/agent/embed/tokens) and the [component reference](/en/docs/developers/agent/embed/component-reference).

Basic authentication below is only for two test accounts sharing your chosen test password. An existing SaaS should replace `authenticate` with server-side session verification and retain origin and CSRF protection. Never select `external_user_id` from the request body. The server binds to loopback and is exposed only through your HTTPS proxy.

Provide five server environment variables: `PRAXIS_API_KEY` for the organization Key, `PRAXIS_AGENT` for the actual `agent_…` ID, `PRAXIS_MCP_SECRET` for your order-service credential, `DEMO_PASSWORD` for test sign-in, and `APP_ORIGIN` for the HTTPS test origin without a path. Keep them out of page source and version control.

## Implement order tools and authorization [#tools]

In a new example directory, run `bun add @modelcontextprotocol/server@2.0.0`. Save this complete module as `orders.ts`. `list_orders` has no user argument: identity comes from a request header only after the service credential is verified. `request_return` checks ownership before changing state. It does not rely on the model to supply an authorized order.

```ts
import { McpServer, WebStandardStreamableHTTPServerTransport, fromJsonSchema } from "@modelcontextprotocol/server";

const orders = [
  { id: "order_a", user: "alice", item: "Headphones", status: "delivered" },
  { id: "order_b", user: "bob", item: "Keyboard", status: "delivered" },
];
const returns = new Set<string>(); // Demo memory only; production needs durable storage.
const knownUser = (user: string | null): user is string => user === "alice" || user === "bob";
export function listOrders(user: string) {
  return orders.filter(order => order.user === user).map(order => ({
    id: order.id, item: order.item, status: returns.has(order.id) ? "return_requested" : order.status,
  }));
}
export function requestReturn(user: string, orderId: string, reason: string) {
  const order = orders.find(order => order.id === orderId && order.user === user);
  if (!order) return { error: "Order missing or not owned by this user" };
  if (!reason.trim() || reason.length > 500) return { error: "Provide a reason of 1–500 characters" };
  if (returns.has(order.id)) return { error: "Return already requested" };
  returns.add(order.id);
  return { order_id: order.id, status: "return_requested" };
}
const result = (value: unknown, isError = false) => ({
  content: [{ type: "text" as const, text: JSON.stringify(value) }], ...(isError ? { isError: true } : {}),
});
export async function handleMcp(request: Request, secret: string): Promise<Response> {
  if (!secret || request.headers.get("Authorization") !== `Bearer ${secret}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  const user = request.headers.get("X-Praxis-External-User-Id");
  if (!knownUser(user)) return new Response("Unknown end user", { status: 403 });
  // A fresh closure per request prevents concurrent users from sharing identity.
  const server = new McpServer({ name: "orders", version: "1.0.0" });
  server.registerTool("list_orders", {
    description: "List orders owned by the current user.",
    inputSchema: fromJsonSchema<Record<string, never>>({ type: "object", properties: {}, additionalProperties: false }),
    annotations: { readOnlyHint: true },
  }, async () => result(listOrders(user)));
  server.registerTool("request_return", {
    description: "Request a return after the user confirms the order and reason.",
    inputSchema: fromJsonSchema<{ order_id: string; reason: string }>({
      type: "object", properties: {
        order_id: { type: "string", minLength: 1, maxLength: 100 },
        reason: { type: "string", minLength: 1, maxLength: 500 },
      }, required: ["order_id", "reason"], additionalProperties: false,
    }),
    annotations: { readOnlyHint: false, destructiveHint: true },
  }, async ({ order_id, reason }) => {
    const value = requestReturn(user, order_id, reason);
    return result(value, "error" in value);
  });
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined, enableJsonResponse: true,
  });
  await server.connect(transport);
  try { return await transport.handleRequest(request); }
  finally { await server.close(); }
}
```

**Expected:** Queries return only the current user's orders. bob cannot return `order_a`; alice can. Repeating the return produces an error. Replace the in-memory collection with durable transactions and business deduplication before production. Restarting this example resets every return.

## Connect sign-in and token issuance [#server]

Save this as `server.ts`. Page and token routes share the same sign-in check. The MCP route uses a separate service credential. Retrieve trusted customer information from your backend; this example uses the verified test username. Keep secrets out of `system_message`.

```ts
import { timingSafeEqual } from "node:crypto";
import { handleMcp } from "./orders";

const { PRAXIS_API_KEY, PRAXIS_AGENT, PRAXIS_MCP_SECRET, DEMO_PASSWORD, APP_ORIGIN } = process.env;
if (!PRAXIS_API_KEY || !PRAXIS_AGENT || !PRAXIS_MCP_SECRET || !DEMO_PASSWORD || !APP_ORIGIN) {
  throw new Error("Set all five server variables");
}
const origin = new URL(APP_ORIGIN).origin;
const noStore = { "Cache-Control": "no-store" };
function authenticate(request: Request) {
  const actual = Buffer.from(request.headers.get("Authorization") ?? "");
  for (const id of ["alice", "bob"]) {
    const expected = Buffer.from("Basic " + Buffer.from(`${id}:${DEMO_PASSWORD}`).toString("base64"));
    if (actual.length === expected.length && timingSafeEqual(actual, expected)) return id;
  }
}
Bun.serve({
  hostname: "127.0.0.1", port: 4242,
  async fetch(request) {
    const path = new URL(request.url).pathname;
    if (path === "/mcp") return handleMcp(request, PRAXIS_MCP_SECRET);
    const user = authenticate(request);
    if (!user) return new Response("Sign in", {
      status: 401, headers: { ...noStore, "WWW-Authenticate": 'Basic realm="Order demo"' },
    });
    if (request.method === "GET" && path === "/") {
      return new Response(Bun.file("index.html"), { headers: noStore });
    }
    if (request.method === "POST" && path === "/api/praxis-token") {
      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 ${PRAXIS_API_KEY}`, "Content-Type": "application/json" },
          body: JSON.stringify({
            agent: PRAXIS_AGENT, external_user_id: user,
            system_message: [{ type: "text", text: `Signed-in customer: ${user}. Use order tools for order facts.` }],
          }),
          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.token !== "string" || typeof data.expires_at !== "string") throw new Error("Invalid response");
        return Response.json({ token: data.token, expires_at: data.expires_at }, { headers: noStore });
      } catch { return new Response("Assistant unavailable", { status: 502, headers: noStore }); }
    }
    return new Response("Not found", { status: 404 });
  },
});
```

After saving `index.html` from the next section, run `bun server.ts` with all five variables injected into its server process. Proxy the test domain to `127.0.0.1:4242`, preserving `Authorization`, `Origin`, and MCP request headers. Do not log credentials.

In **Service agents → your agent → Integration and configuration → Configuration**:

1. Set instructions to use order tools for queries and returns, never invent orders, and confirm the order and reason before returning. Select an available `deepseek-flash` model.
2. Under **MCP and credentials**, add an HTTP server named `orders` at your test domain plus `/mcp`. Store the credential matching `PRAXIS_MCP_SECRET` in a vault, restrict its allowed host to the MCP domain, and save the vault selection.
3. Under **Tools and authorization**, set `list_orders` to automatic execution and `request_return` to ask every time. Save a new configuration version and ensure embedding uses it.
4. Under **Security**, select an available environment and the same HTTPS origin as `APP_ORIGIN`, then save. Embed policy and vault selection currently can only be configured in the dashboard.

**Expected:** Unsigned host token requests return 401. Only authenticated, same-origin requests issue a Praxis token. The host returns `token` and `expires_at` without caching. Praxis issuance succeeds with 201; this host projects success as 200.

## Pass the current order page [#browser]

Save this as `index.html`. The input tells the assistant which order is being discussed. It cannot change identity or grant access. `setReadable` values accompany later user messages, while the order service always checks ownership independently.

```html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Order workspace</title>
<h1>Order workspace</h1>
<label>Order to discuss <input id="order" placeholder="order_a or order_b"></label>
<p id="status" role="status"></p>
<script type="module" src="https://kit-develop.praxisusercontent.com/v0.2.8/praxis-kit.js" integrity="sha384-vey+VZZcFIWJq9Fmv/nmLxspKtXRiL8XiDOfg2g2QFvwZi5MA+tkiQvPBtoMS8eU" crossorigin="anonymous"></script>
<script type="module">
  await customElements.whenDefined("praxis-agent");
  const agent = document.createElement("praxis-agent");
  agent.setOptions({
    api: {
      url: "https://api-develop.deepraxis.com",
      getToken: async () => {
        const response = await fetch("/api/praxis-token", {
          method: "POST", credentials: "same-origin", cache: "no-store",
        });
        if (!response.ok) throw new Error(`Token request failed: ${response.status}`);
        const data = await response.json();
        if (typeof data.token !== "string") throw new Error("Missing token");
        return data.token;
      },
    },
    agent: { name: "Order assistant" },
    layout: "popup", launcher: { type: "bubble", position: "bottom-right", label: "Ask about orders" },
    header: { title: "Order assistant" },
  });
  agent.setReadable("page", "Order workspace");
  document.querySelector("#order").addEventListener("input", event => {
    agent.setReadable("selected_order", event.target.value);
  });
  agent.addEventListener("praxis.error", () => {
    document.querySelector("#status").textContent = "Assistant unavailable. Check your sign-in or try later.";
  });
  document.body.appendChild(agent);
  window.addEventListener("pagehide", () => agent.remove(), { once: true });
  window.addEventListener("pageshow", event => { if (event.persisted) location.reload(); });
</script>
</html>
```

**Expected:** Sign-in shows the order page and a bubble at the bottom right. Asking for your orders calls `list_orders`. Entering `order_a` and requesting a return prompts for confirmation before executing the write tool. Denying confirmation must leave the order unchanged.

Use separate browser profiles or private windows for the test accounts to avoid cached Basic credentials. In production, sign-out must end the host session and destroy the component. Mount a fresh component and issue fresh credentials for the next user. See the full [token lifecycle](/en/docs/developers/agent/embed/tokens#refresh).

## Automated checks and two-user acceptance [#verify]

Save this as `orders.test.ts` and run `nice -n 10 bun test orders.test.ts`. It uses a local fake credential, makes no Praxis calls, and changes no real orders.

```ts
import { test, expect } from "bun:test";
import { handleMcp, listOrders, requestReturn } from "./orders";

test("orders remain scoped to the authenticated user", () => {
  expect(listOrders("alice").map(order => order.id)).toEqual(["order_a"]);
  expect(listOrders("bob").map(order => order.id)).toEqual(["order_b"]);
  expect(requestReturn("bob", "order_a", "Wrong item")).toHaveProperty("error");
  expect(listOrders("alice")[0].status).toBe("delivered");
  expect(requestReturn("alice", "order_a", "Wrong item")).toEqual({ order_id: "order_a", status: "return_requested" });
  expect(requestReturn("alice", "order_a", "Try again")).toHaveProperty("error");
});
test("an identity header without the service credential grants no access", async () => {
  const forged = new Request("https://example.test/mcp", {
    method: "POST", headers: { "X-Praxis-External-User-Id": "alice" },
  });
  expect((await handleMcp(forged, "local-test-only")).status).toBe(401);
  const missingUser = new Request("https://example.test/mcp", {
    method: "POST", headers: { Authorization: "Bearer local-test-only" },
  });
  expect((await handleMcp(missingUser, "local-test-only")).status).toBe(403);
});
```

**Expected:** Both tests pass. Cross-user returns fail, and a forged identity header without the service credential returns 401. Then verify the real integration in separate browsers:

| Action                                          | Expected result and verification                                                                                                 |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| alice and bob each list orders                  | Only `order_a` and `order_b`, respectively; replay shows the matching end user                                                   |
| bob enters `order_a` and requests a return      | The tool denies access without changing alice's order; neither page context nor model arguments replace identity                 |
| alice requests a return but denies confirmation | No return executes; a new query still reports delivered                                                                          |
| alice requests again and confirms               | The return succeeds; refreshing and querying shows its state within this server process                                          |
| bob opens conversation history                  | No alice conversations; also test session access using the [lifecycle checks](/en/docs/developers/agent/embed/testing#lifecycle) |

Confirmation expresses user intent. Trust `X-Praxis-External-User-Id` for authorization only after verifying the MCP caller's credential. Trusting an identically named header from an arbitrary internet request lets an attacker impersonate users.

## Failures, cleanup, and extensions [#failures]

For MCP 401, compare the vault credential with the order-service configuration and check the allowed host. For 403, confirm that the issued identity is alice or bob. For origin refusal, compare the HTTPS origins. If tools are missing, check the saved version and MCP configuration. Record statuses and request identifiers, never Keys or tokens.

After testing, revoke test-user tokens, disable test credentials, stop the server, and remove the test proxy. Return state disappears with the process. Before connecting real business data, replace storage, authentication, and business rules, then complete the [go-live checklist](/en/docs/developers/agent/embed/go-live).

The four files on this page form the complete example. Use [Pass context](/en/docs/developers/agent/embed/context) for page changes and [Client tools](/en/docs/developers/agent/embed/client-tools) for browser actions. For public material, use the [documentation Q\&A recipe](/en/docs/cookbook/scenarios/docs-qa-assistant).
