diff --git a/README.md b/README.md
index 9238dbc..f10e4c1 100644
--- a/README.md
+++ b/README.md
@@ -166,6 +166,7 @@ AWS_SECRET_ACCESS_KEY=
| `AWS_REGION` | AWS region for Secrets Manager | AWS console |
| `AWS_ACCESS_KEY_ID` | AWS IAM access key | [AWS IAM](https://console.aws.amazon.com/iam) → Security credentials |
| `AWS_SECRET_ACCESS_KEY` | AWS IAM secret key | [AWS IAM](https://console.aws.amazon.com/iam) → Security credentials |
+| `CLERK_GUEST_USER_ID` | Clerk user ID of the pre-provisioned demo account used by "Continue as Guest" | See [Guest Sign-In](#guest-sign-in-recruiter-demo) below |
### `apps/embed` (build-time only)
@@ -179,6 +180,31 @@ VITE_WIDGET_URL=http://localhost:3001
---
+## Guest Sign-In (Recruiter Demo)
+
+The dashboard sign-in page has a "Continue as Guest" button so people evaluating the project (e.g. recruiters) can explore it without creating an account. It signs the visitor into a single pre-provisioned demo account whose write access is disabled at the API layer, so it's safe to share — guests can view everything but can't send messages, change settings, disconnect plugins, upload/delete files, or overwrite secrets.
+
+**One-time setup in the Clerk Dashboard:**
+
+1. **Users → Create user** — make a dedicated demo account (e.g. `guest-demo@yourdomain.com`), no password required.
+2. Sign in as that user once and create/join an **Organization** for it — this becomes the shared demo workspace. Populate it from the dashboard with a few example conversations, widget settings, etc. so guests see a populated demo instead of an empty one.
+3. **Organizations → Roles** — create a custom role with the key `org:guest` (name/permissions don't matter; the app enforces read-only access itself, not Clerk's built-in permission checks).
+4. Open that organization → **Members**, and set the demo user's role to `org:guest`.
+5. Copy the demo user's ID from **Users → (the demo user)** — it's shown at the top and starts with `user_`.
+6. Set it as a Convex environment variable:
+
+```bash
+cd packages/backend
+npx convex env set CLERK_GUEST_USER_ID user_xxxxxxxxxxxx
+```
+
+**Notes:**
+- If your Clerk plan doesn't support custom organization roles, swap the `org:guest` role check in `packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts` for a check on the user's `publicMetadata` (e.g. `{ isGuest: true }`) exposed via a custom claim in your Clerk JWT template instead.
+- The write guard runs server-side on every mutation, so even a guest calling a mutation directly from devtools gets rejected with a `GUEST_READ_ONLY` error — the button is just the convenient path in.
+- Until `CLERK_GUEST_USER_ID` is set, clicking "Continue as Guest" fails with "Guest sign-in is unavailable right now."
+
+---
+
## Embedding the Widget
Once deployed, add the following snippet to any website:
diff --git a/apps/web/modules/auth/ui/components/GuestSignInButton.tsx b/apps/web/modules/auth/ui/components/GuestSignInButton.tsx
new file mode 100644
index 0000000..791b2d9
--- /dev/null
+++ b/apps/web/modules/auth/ui/components/GuestSignInButton.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import { useAction } from "convex/react";
+import { useSignIn } from "@clerk/nextjs";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { api } from "@workspace/backend/_generated/api";
+import { Button } from "@workspace/ui/components/button";
+import { toast } from "sonner";
+
+const GuestSignInButton = () => {
+ const { signIn, setActive, isLoaded } = useSignIn();
+ const createSignInTicket = useAction(api.public.guest.createSignInTicket);
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleGuestSignIn = async () => {
+ if (!isLoaded || isLoading) return;
+
+ setIsLoading(true);
+
+ try {
+ const { ticket } = await createSignInTicket({});
+ const attempt = await signIn.create({ strategy: "ticket", ticket });
+
+ if (attempt.status === "complete") {
+ await setActive({ session: attempt.createdSessionId });
+ router.push("/");
+ } else {
+ toast.error("Guest sign-in failed. Please try again.");
+ }
+ } catch (error) {
+ console.error(error);
+ toast.error("Guest sign-in is unavailable right now.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default GuestSignInButton;
diff --git a/apps/web/modules/auth/ui/views/SignInView.tsx b/apps/web/modules/auth/ui/views/SignInView.tsx
index d43815a..f214953 100644
--- a/apps/web/modules/auth/ui/views/SignInView.tsx
+++ b/apps/web/modules/auth/ui/views/SignInView.tsx
@@ -1,7 +1,13 @@
import { SignIn } from '@clerk/nextjs'
import React from 'react'
+import GuestSignInButton from '../components/GuestSignInButton'
export default function SignInView() {
- return
+ return (
+
+
+
+
+ )
}
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index f4f86d2..16340e5 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -26,6 +26,7 @@ import type * as private_vapi from "../private/vapi.js";
import type * as private_widgetSettings from "../private/widgetSettings.js";
import type * as public_contactSessions from "../public/contactSessions.js";
import type * as public_conversations from "../public/conversations.js";
+import type * as public_guest from "../public/guest.js";
import type * as public_messages from "../public/messages.js";
import type * as public_organizations from "../public/organizations.js";
import type * as public_secrets from "../public/secrets.js";
@@ -69,6 +70,7 @@ declare const fullApi: ApiFromModules<{
"private/widgetSettings": typeof private_widgetSettings;
"public/contactSessions": typeof public_contactSessions;
"public/conversations": typeof public_conversations;
+ "public/guest": typeof public_guest;
"public/messages": typeof public_messages;
"public/organizations": typeof public_organizations;
"public/secrets": typeof public_secrets;
diff --git a/packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts b/packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts
index 8f3fa8f..800e9c9 100644
--- a/packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts
+++ b/packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts
@@ -8,7 +8,14 @@ type CtxWithAuth =
| Pick
| Pick
-export async function checkUserIdentityAndGetOrgId(ctx: CtxWithAuth): Promise{
+// Org role assigned to the shared recruiter demo account (see convex/public/guest.ts).
+// Kept out of that account's write paths so concurrent guests can't corrupt the demo data.
+export const GUEST_ORG_ROLE = "org:guest";
+
+export async function checkUserIdentityAndGetOrgId(
+ ctx: CtxWithAuth,
+ options?: { requireWrite?: boolean },
+): Promise{
const identity = await ctx.auth.getUserIdentity();
@@ -28,6 +35,13 @@ export async function checkUserIdentityAndGetOrgId(ctx: CtxWithAuth): Promise {
- const orgId = await checkUserIdentityAndGetOrgId(ctx);
- const conversation = await ctx.db.get(args.conversationId);
+ const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true });
+ const conversation = await ctx.db.get(args.conversationId);
if(!conversation) {
throw new ConvexError({
diff --git a/packages/backend/convex/private/files.ts b/packages/backend/convex/private/files.ts
index 4a5a9e1..7d467ab 100644
--- a/packages/backend/convex/private/files.ts
+++ b/packages/backend/convex/private/files.ts
@@ -66,7 +66,7 @@ export const addFile = action({
// Verify identity and get the organization ID
- const orgId = await checkUserIdentityAndGetOrgId(ctx);
+ const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true });
//Only pro customers can add files for knowledge base
const subscription = await ctx.runQuery(internal.system.subscriptions.getByOrganizationId, {
@@ -164,7 +164,7 @@ export const deleteFile = mutation({
handler: async (ctx, args) => {
// Verify user identity and get the organization ID
- const orgId = await checkUserIdentityAndGetOrgId(ctx);
+ const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true });
// Ensure the namespace exists for this organization
const namespace = await rag.getNamespace(ctx, {namespace: orgId});
diff --git a/packages/backend/convex/private/messages.ts b/packages/backend/convex/private/messages.ts
index e703ec0..d0d34b8 100644
--- a/packages/backend/convex/private/messages.ts
+++ b/packages/backend/convex/private/messages.ts
@@ -53,7 +53,7 @@ export const create = mutation({
},
handler: async (ctx, args) => {
- const orgId = await checkUserIdentityAndGetOrgId(ctx);
+ const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true });
const conversation = await ctx.db.get(args.conversationId);
@@ -98,7 +98,7 @@ export const enhanceResponse = action({
},
handler: async (ctx, args) => {
- const orgId = await checkUserIdentityAndGetOrgId(ctx);
+ const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true });
//Only pro customers can ENHANCE RESPONSE
const subscription = await ctx.runQuery(
diff --git a/packages/backend/convex/private/plugins.ts b/packages/backend/convex/private/plugins.ts
index 73fa284..8517dcf 100644
--- a/packages/backend/convex/private/plugins.ts
+++ b/packages/backend/convex/private/plugins.ts
@@ -47,7 +47,7 @@ export const remove = mutation({
service: v.union(v.literal("vapi"))
},
handler: async(ctx, args) => {
- const orgId = await checkUserIdentityAndGetOrgId(ctx);
+ const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true });
const existingPlugin = await ctx.db
.query("plugins")
diff --git a/packages/backend/convex/private/secrets.ts b/packages/backend/convex/private/secrets.ts
index 7ac4bed..63ef919 100644
--- a/packages/backend/convex/private/secrets.ts
+++ b/packages/backend/convex/private/secrets.ts
@@ -78,7 +78,7 @@ export const upsert = mutation({
},
handler: async(ctx, args) =>{
- const orgId = await checkUserIdentityAndGetOrgId(ctx);
+ const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true });
/*
diff --git a/packages/backend/convex/private/widgetSettings.ts b/packages/backend/convex/private/widgetSettings.ts
index df3d47a..4ccdbe8 100644
--- a/packages/backend/convex/private/widgetSettings.ts
+++ b/packages/backend/convex/private/widgetSettings.ts
@@ -45,7 +45,7 @@ export const upsert = mutation({
},
handler: async (ctx, args) => {
- const orgId = await checkUserIdentityAndGetOrgId(ctx);
+ const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true });
const existingWidgetSettings = await ctx.db
.query("widgetSettings")
diff --git a/packages/backend/convex/public/guest.ts b/packages/backend/convex/public/guest.ts
new file mode 100644
index 0000000..c4f6425
--- /dev/null
+++ b/packages/backend/convex/public/guest.ts
@@ -0,0 +1,42 @@
+import { ConvexError } from "convex/values";
+import { action } from "../_generated/server";
+import { createClerkClient } from "@clerk/backend";
+
+if (!process.env.CLERK_SECRET_KEY) {
+ throw new Error(
+ "CLERK_SECRET_KEY environment variable is required"
+ )
+}
+
+const clerkClient = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY });
+
+/**
+ * Mints a single-use, short-lived Clerk sign-in ticket for the shared
+ * recruiter demo account (CLERK_GUEST_USER_ID). The client redeems it via
+ * `signIn.create({ strategy: "ticket", ticket })` to get a real, isolated
+ * Clerk session without ever handling a shared password.
+ *
+ * The guest account's org role (org:guest) is what actually protects the
+ * demo data — see checkUserIdentityAndGetOrgId's `requireWrite` guard.
+ */
+export const createSignInTicket = action({
+ args: {},
+
+ handler: async () => {
+ const guestUserId = process.env.CLERK_GUEST_USER_ID;
+
+ if (!guestUserId) {
+ throw new ConvexError({
+ code: "NOT_CONFIGURED",
+ message: "Guest sign-in is not configured",
+ });
+ }
+
+ const signInToken = await clerkClient.signInTokens.createSignInToken({
+ userId: guestUserId,
+ expiresInSeconds: 60,
+ });
+
+ return { ticket: signInToken.token };
+ },
+});