diff --git a/.env.example b/.env.example
index cfe2b3302d..5ed2b57c3e 100644
--- a/.env.example
+++ b/.env.example
@@ -37,6 +37,9 @@ TENANT_PACKAGE_DIR=../examples/fintech
# closes it. A deployment on a paid Intelligence plan never shows it. A fork running OpenBot for its
# own organization can turn it off for everybody.
# OPENBOT_SELF_HOST_BANNER=false
+# The signed-in app asks each person once, after their fifth message, to star OpenBot on GitHub, and
+# Settings → General links to it. A fork running OpenBot for its own organization can turn both off.
+# OPENBOT_STAR_PROMPT=false
# What this deployment calls itself, when more than one shares an Intelligence project. A copy of a
# deployment made for development uses the same project key, and threads are listed per Bot with
# nothing to say which deployment a conversation came from. The name goes into every thread id this
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2791bf8756..8f21be9fe4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -366,6 +366,15 @@ every deployment:
### Conversations
+- **OpenBot asks once to be starred on GitHub.** After your fifth message, a card above the message
+ box asks you to star OpenBot on GitHub, with Not now beside the link. Either answer is saved to
+ your account, so it is never asked again on any device, and it waits until you have answered the
+ notifications offer, so the two never show together. Settings → General keeps the link in a new
+ OpenBot section. Set `OPENBOT_STAR_PROMPT=false` to turn both off for everybody, which suits a
+ deployment running OpenBot for its own organization. With product telemetry on, OpenBot counts
+ how many people were shown the card, opened the link or dismissed it, through three new events
+ (`star_prompt_shown`, `star_prompt_clicked`, `star_prompt_dismissed`); see
+ [TELEMETRY.md](docs/TELEMETRY.md).
- **A Bot no longer does an app change twice after you answer an approval.** When a built-in Bot
asked for several app changes at once and one of them waited for your Allow, the others that
needed no approval still went ahead, but the Bot was told they had not run. It then did them
diff --git a/README.md b/README.md
index 2089d7b5c9..a49e46d4b9 100644
--- a/README.md
+++ b/README.md
@@ -473,6 +473,7 @@ Settings worth knowing:
| `TENANT_PACKAGE_DIR` | Directory containing tenant YAML. Defaults to `../examples/fintech`. |
| `DEPLOYMENT_ID` | Names this deployment when two share one Intelligence project. |
| `OPENBOT_SELF_HOST_BANNER` | Set `false` to hide the bar offering CopilotKit's help self-hosting OpenBot. It never shows on a paid Intelligence plan. |
+| `OPENBOT_STAR_PROMPT` | Set `false` to stop asking people to star OpenBot on GitHub, and drop the link from Settings. |
Full reference: [docs/configuration.md](docs/configuration.md).
diff --git a/app/src/components/channels/conversation-view.tsx b/app/src/components/channels/conversation-view.tsx
index 0651f336bb..410d0994a7 100644
--- a/app/src/components/channels/conversation-view.tsx
+++ b/app/src/components/channels/conversation-view.tsx
@@ -22,8 +22,14 @@ import {
type QueuedMessage,
reduceQueue,
} from "@/components/channels/composer";
-import { NotifyPrompt } from "@/components/notifications/notify-prompt";
+import {
+ NotifyPrompt,
+ notifyPromptAnswered,
+} from "@/components/notifications/notify-prompt";
+import { StarPrompt } from "@/components/star-prompt/star-prompt";
import { attachmentUrl } from "@/lib/channels/attachments";
+import { useNotificationPermission } from "@/lib/notifications/permission";
+import { notePromptSent } from "@/lib/star-prompt";
import { newId } from "../../lib/new-id";
export function ConversationView({
@@ -194,6 +200,7 @@ export function ConversationView({
* for the same fact.
*/
const [running, setRunning] = useState(false);
+ const notificationPermission = useNotificationPermission();
const inFlight = pending || running;
/**
@@ -321,6 +328,14 @@ export function ConversationView({
setRunning(true);
try {
await onSubmit(draft);
+ /*
+ * Once `onSubmit` has resolved, which in a conversation is when the turn it started is over
+ * (a stopped turn included), so the card can appear as the answer lands. A send that throws,
+ * whose words go back in the box, is not counted. A first message that opens a new
+ * conversation resolves once the conversation is made, so it counts even if its run then
+ * fails, which is fine for a nudge counted to five.
+ */
+ notePromptSent();
} finally {
setRunning(false);
}
@@ -506,6 +521,22 @@ export function ConversationView({
{messages.some((message) => message.role === "user") ? (
) : null}
+ {/*
+ * The ask to star OpenBot on GitHub, once this browser has earned it, and never beside the
+ * notifications offer: one card above the composer at a time, and the offer that is useful
+ * to this person goes first. Read on each render, which a finished turn always causes.
+ *
+ * It also waits while OpenBot Desktop has not yet said whether notifications are on
+ * (`checking`), or the card could draw, be counted as shown, and vanish when the answer is
+ * "not asked yet". And never in a conversation that cannot take a message: the brief
+ * "Picking a Bot" screen, or one whose Bot was deleted.
+ */}
+ {disabled ||
+ notificationPermission === "checking" ||
+ (notificationPermission === "default" &&
+ !notifyPromptAnswered()) ? null : (
+
+ )}
{/*
* THE DRAWER IS THE LAST THING ABOVE THE COMPOSER, AND THE STARTER TASKS STEP ASIDE FOR IT.
*
diff --git a/app/src/components/notifications/notify-prompt.tsx b/app/src/components/notifications/notify-prompt.tsx
index f9373adfbe..6800fdb7ff 100644
--- a/app/src/components/notifications/notify-prompt.tsx
+++ b/app/src/components/notifications/notify-prompt.tsx
@@ -16,7 +16,8 @@ import {
/** Set once somebody answers the prompt either way, so it is offered once per browser. */
export const NOTIFY_PROMPT_KEY = "openbot-notify-prompt-answered";
-function answered(): boolean {
+/** Whether this browser has answered the offer, either way. The star prompt waits until it has. */
+export function notifyPromptAnswered(): boolean {
try {
return localStorage.getItem(NOTIFY_PROMPT_KEY) !== null;
} catch {
@@ -42,7 +43,7 @@ function remember() {
*/
export function NotifyPrompt() {
const permission = useNotificationPermission();
- const [dismissed, setDismissed] = useState(answered);
+ const [dismissed, setDismissed] = useState(notifyPromptAnswered);
if (permission !== "default" || dismissed) return null;
return (
diff --git a/app/src/components/settings/star-on-github.tsx b/app/src/components/settings/star-on-github.tsx
new file mode 100644
index 0000000000..c25426ea89
--- /dev/null
+++ b/app/src/components/settings/star-on-github.tsx
@@ -0,0 +1,69 @@
+import { IconStar } from "@tabler/icons-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { PageRows, PageSection } from "@/components/layout/page-shell";
+import { buttonVariants } from "@/components/ui/button";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item";
+import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries";
+import {
+ answerStarPromptMutationOptions,
+ useUserPreferences,
+} from "@/lib/settings/message-list";
+import { OPENBOT_REPO_URL, recordStarPrompt } from "@/lib/star-prompt";
+
+/**
+ * Settings → General's permanent way to star OpenBot on GitHub: the quiet half of the ask whose
+ * active half is the card above the composer (`components/star-prompt/star-prompt.tsx`).
+ *
+ * Here whenever the deployment allows the ask at all, and gone with it when the operator set
+ * OPENBOT_STAR_PROMPT=false. Unlike the card it stays after an answer, because somebody who said
+ * Not now may want it later and this is where they would look. Pressing it is counted under
+ * `settings`, and answers the card, so somebody who starred from here is not asked again.
+ */
+export function StarOnGitHubSection() {
+ const queryClient = useQueryClient();
+ const { data: capabilities } = useQuery(deploymentCapabilitiesQueryOptions());
+ const { data: preferences, userId } = useUserPreferences();
+ const answer = useMutation(
+ answerStarPromptMutationOptions(queryClient, userId),
+ );
+ if (capabilities?.starPrompt !== true) return null;
+
+ return (
+
+
+
+
+
+
+
+ Star OpenBot on GitHub
+
+ OpenBot is open source. A star helps other people find it.
+
+
+
+ {
+ void recordStarPrompt("star_prompt_clicked", "settings");
+ if (preferences?.starPromptAnswered !== true) answer.mutate();
+ }}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Star on GitHub
+
+
+
+
+
+ );
+}
diff --git a/app/src/components/star-prompt/star-prompt.tsx b/app/src/components/star-prompt/star-prompt.tsx
new file mode 100644
index 0000000000..01ccd41a4a
--- /dev/null
+++ b/app/src/components/star-prompt/star-prompt.tsx
@@ -0,0 +1,120 @@
+import { IconStar } from "@tabler/icons-react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { Button, buttonVariants } from "@/components/ui/button";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item";
+import { currentUserQueryOptions } from "@/lib/auth/queries";
+import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries";
+import {
+ answerStarPromptMutationOptions,
+ userPreferencesQueryOptions,
+} from "@/lib/settings/message-list";
+import {
+ firstStarPromptShow,
+ OPENBOT_REPO_URL,
+ recordStarPrompt,
+ STAR_PROMPT_TRIGGER,
+ type StarPromptEvent,
+ type StarPromptTrigger,
+ starPromptEarned,
+} from "@/lib/star-prompt";
+import { queryClient } from "@/query-client";
+
+/**
+ * The ask to star OpenBot on GitHub, once this browser has reached the moment it asks at (see
+ * `lib/star-prompt.ts`). The same card as the notifications offer above it, so it reads as part of
+ * the composer rather than as an interruption, and never a dialog.
+ *
+ * Hidden until every answer is in, so it never flashes on and then off: the deployment has to allow
+ * it, and this person's preferences have to have loaded and say they have not answered it. Either
+ * button answers it, on every device they sign in from, and is counted with the trigger that asked.
+ *
+ * THE APP'S QUERY CLIENT, NAMED. This is drawn inside the conversation, whose components read no
+ * query context of their own (`voice-call-widget.tsx` names it the same way), so it does not depend
+ * on a provider being above it.
+ */
+export function StarPrompt({
+ trigger = STAR_PROMPT_TRIGGER,
+}: {
+ trigger?: StarPromptTrigger;
+}) {
+ const { data: capabilities } = useQuery(
+ deploymentCapabilitiesQueryOptions(),
+ queryClient,
+ );
+ const userId = useQuery(currentUserQueryOptions(), queryClient).data?.id;
+ const { data: preferences } = useQuery(
+ userPreferencesQueryOptions(userId),
+ queryClient,
+ );
+ const answer = useMutation(
+ answerStarPromptMutationOptions(queryClient, userId),
+ queryClient,
+ );
+ const shown =
+ capabilities?.starPrompt === true &&
+ preferences !== undefined &&
+ preferences.starPromptAnswered !== true &&
+ starPromptEarned(trigger);
+
+ // Once per browser and trigger; the server keys the count on the person, so this is people.
+ useEffect(() => {
+ if (shown && firstStarPromptShow(trigger))
+ void recordStarPrompt("star_prompt_shown", trigger);
+ }, [shown, trigger]);
+
+ if (!shown) return null;
+
+ const respond = (kind: StarPromptEvent) => {
+ void recordStarPrompt(kind, trigger);
+ answer.mutate();
+ };
+
+ return (
+
+
+
+
+
+
+ Enjoying OpenBot? Star it on GitHub so more people find it.
+
+
+
+
+ {/*
+ * A link, not a button that opens one: it goes somewhere, and a person can see where before
+ * they go. A new tab in a browser; OpenBot Desktop opens it in the system browser, where
+ * they are signed in to GitHub (`desktop/src-tauri/src/outside-links.js`).
+ */}
+ respond("star_prompt_clicked")}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Star on GitHub
+
+
+
+ );
+}
diff --git a/app/src/lib/agents/mutations.ts b/app/src/lib/agents/mutations.ts
index a22d8c2615..8d577526fa 100644
--- a/app/src/lib/agents/mutations.ts
+++ b/app/src/lib/agents/mutations.ts
@@ -4,6 +4,7 @@ import type { CreateBotRequest } from "@/lib/api/generated/model";
import { idempotencyKey, ORGANIZATION_ID } from "@/lib/api/request";
import { channelKeys } from "@/lib/channels/queries";
import { client } from "@/lib/client";
+import { noteBotCreated } from "@/lib/star-prompt";
import { type AgentProfile, agentApiPath, agentKeys } from "./queries";
/**
@@ -34,7 +35,11 @@ export function createAgentMutationOptions(queryClient: QueryClient) {
fallback: FALLBACK,
})
).bot,
- onSuccess: () => invalidateAgents(queryClient),
+ onSuccess: () => {
+ // Here rather than in the New Bot sheet, so a Bot made from a conversation counts too.
+ noteBotCreated();
+ return invalidateAgents(queryClient);
+ },
});
}
diff --git a/app/src/lib/deployment/queries.ts b/app/src/lib/deployment/queries.ts
index 7e42fac76b..e5fb944db2 100644
--- a/app/src/lib/deployment/queries.ts
+++ b/app/src/lib/deployment/queries.ts
@@ -28,6 +28,11 @@ export type DeploymentCapabilities = {
* for a deployment on a paid Intelligence plan.
*/
selfHostBanner: boolean;
+ /**
+ * Whether to ask people to star OpenBot on GitHub, and list the link in Settings. On unless the
+ * deployment set OPENBOT_STAR_PROMPT=false. Absent, from a server too old to say, reads as off.
+ */
+ starPrompt?: boolean;
/**
* Whether this is somebody trying OpenBot on their own machine rather than an organization's
* deployment: single-user mode on the server, which only a local, unreachable install can be in.
@@ -76,6 +81,7 @@ export function deploymentCapabilitiesQueryOptions() {
).json()) as {
generativeUi?: boolean;
selfHostBanner?: boolean;
+ starPrompt?: boolean;
localEvaluation?: boolean;
edition?: string;
transcription?: boolean;
@@ -85,6 +91,7 @@ export function deploymentCapabilitiesQueryOptions() {
return {
generativeUi: body.generativeUi === true,
selfHostBanner: body.selfHostBanner === true,
+ starPrompt: body.starPrompt === true,
localEvaluation: body.localEvaluation === true,
edition: body.edition === "personal" ? "personal" : "team",
transcription: body.transcription === true,
diff --git a/app/src/lib/settings/message-list.ts b/app/src/lib/settings/message-list.ts
index 9d80df87b4..7bcab1eb60 100644
--- a/app/src/lib/settings/message-list.ts
+++ b/app/src/lib/settings/message-list.ts
@@ -75,6 +75,45 @@ export function dismissSelfHostBannerMutationOptions(
});
}
+/**
+ * Answer the ask to star OpenBot on GitHub for this person, on every device they sign in from.
+ * Either answer, Star on GitHub or Not now, is the same answer: do not ask again.
+ *
+ * Patched in onMutate like the self-host banner's, so the card is gone the moment it is pressed,
+ * and put back if the save fails rather than vanishing now and returning on the next load.
+ */
+export function answerStarPromptMutationOptions(
+ queryClient: QueryClient,
+ userId: string | undefined,
+) {
+ const queryKey = userPreferencesQueryOptions(userId).queryKey;
+ return mutationOptions({
+ mutationFn: (): Promise => {
+ if (!userId) throw new Error("Sign in to save your preferences.");
+ return client("/api/settings/preferences", "preferences", {
+ method: "PATCH",
+ body: { starPromptAnswered: true },
+ fallback: "Could not save your preferences",
+ });
+ },
+ onMutate: () => {
+ const previous = queryClient.getQueryData(queryKey);
+ if (previous) {
+ queryClient.setQueryData(queryKey, {
+ ...previous,
+ starPromptAnswered: true,
+ });
+ }
+ return { previous };
+ },
+ onError: (_error, _variables, context) => {
+ if (context?.previous)
+ queryClient.setQueryData(queryKey, context.previous);
+ },
+ onSuccess: (preferences) => queryClient.setQueryData(queryKey, preferences),
+ });
+}
+
export function saveUserPreferencesMutationOptions(
queryClient: QueryClient,
userId: string | undefined,
diff --git a/app/src/lib/star-prompt.ts b/app/src/lib/star-prompt.ts
new file mode 100644
index 0000000000..bdc8942469
--- /dev/null
+++ b/app/src/lib/star-prompt.ts
@@ -0,0 +1,139 @@
+import { tryClient } from "@/lib/client";
+import { desktopShell } from "@/lib/desktop/shell";
+
+/**
+ * THE ASK TO STAR OPENBOT ON GITHUB, AND THE MOMENT THAT EARNS IT.
+ *
+ * A star is how other people find an open-source project, and somebody already getting work done in
+ * OpenBot is the person most likely to give one. So OpenBot asks once, at one moment, in a card above
+ * the composer (`components/star-prompt/star-prompt.tsx`), and keeps a permanent link in Settings →
+ * General. The deployment can turn both off (`OPENBOT_STAR_PROMPT`), and either answer is saved to
+ * the person's preferences, so it is asked once on every device they sign in from.
+ *
+ * WHICH MOMENT IS ONE CONSTANT, ON PURPOSE. There is no experiment framework here, so the moments are
+ * compared one release at a time: ship one, read the counts, switch. Each answer is counted with the
+ * trigger that asked (docs/TELEMETRY.md, "The star prompt"), which is what lets two releases be
+ * compared when the project's own star count also moves with every launch and post.
+ *
+ * - `prompts`: once five of this person's messages from this browser have been answered. A fresh
+ * install already has Bots to talk to, so this reaches nearly everybody who uses OpenBot. (In a
+ * browser that has not answered the notifications offer, the card waits behind it; see
+ * `conversation-view.tsx`.)
+ * - `bot_created`: once they create a Bot from this browser, which fewer people ever do.
+ *
+ * What earned it is kept in this browser rather than on the account: it is a nudge, not a record, and
+ * counting every message on the server would be a write per message for nothing else.
+ */
+export type StarPromptTrigger = "prompts" | "bot_created";
+
+/** Where an answer came from: the card, under the trigger that showed it, or Settings' link. */
+export type StarPromptSource = StarPromptTrigger | "settings";
+
+/** What is counted. The server accepts exactly these (server/src/telemetry/product.ts). */
+export type StarPromptEvent =
+ | "star_prompt_shown"
+ | "star_prompt_clicked"
+ | "star_prompt_dismissed";
+
+/** The moment this build asks at. Change it here to compare another one. */
+export const STAR_PROMPT_TRIGGER: StarPromptTrigger = "prompts";
+
+/** How many messages earn `prompts`. */
+export const STAR_PROMPT_AFTER_PROMPTS = 5;
+
+/** Where Star on GitHub goes. The repository's own page, which has the Star button. */
+export const OPENBOT_REPO_URL = "https://github.com/CopilotKit/OpenBot";
+
+/** Messages sent from this browser, counted up to the threshold and no further. */
+export const STAR_PROMPT_PROMPTS_KEY = "openbot-star-prompt-prompts";
+
+/** Set once a Bot is created from this browser. */
+export const STAR_PROMPT_BOT_CREATED_KEY = "openbot-star-prompt-bot-created";
+
+/** Set once this browser has reported the card shown for a trigger, so it reports it once. */
+const shownKey = (trigger: StarPromptTrigger) =>
+ `openbot-star-prompt-shown-${trigger}`;
+
+function read(key: string): string | null {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ // Storage refused (a private window, blocked site data): nothing is earned, so nothing is asked.
+ return null;
+ }
+}
+
+function write(key: string, value: string) {
+ try {
+ localStorage.setItem(key, value);
+ } catch {
+ // Nothing to keep it in. The ask simply never comes, which is the quiet way to fail.
+ }
+}
+
+function promptsSent(): number {
+ const count = Number.parseInt(read(STAR_PROMPT_PROMPTS_KEY) ?? "", 10);
+ return Number.isFinite(count) && count > 0 ? count : 0;
+}
+
+/**
+ * One message this person sent from a conversation. Called once the conversation's `onSubmit` has
+ * resolved: in a conversation, when the turn it started is over; for the first message of a new
+ * one, when the conversation is made.
+ */
+export function notePromptSent(): void {
+ const sent = promptsSent();
+ // Stops at the threshold: past it, the count changes nothing, so there is nothing to write.
+ if (sent >= STAR_PROMPT_AFTER_PROMPTS) return;
+ write(STAR_PROMPT_PROMPTS_KEY, String(sent + 1));
+}
+
+/** A Bot this person created. Called from the create mutation, so every way of creating one counts. */
+export function noteBotCreated(): void {
+ write(STAR_PROMPT_BOT_CREATED_KEY, new Date().toISOString());
+}
+
+/** Whether this browser has reached the moment `trigger` asks at. */
+export function starPromptEarned(
+ trigger: StarPromptTrigger = STAR_PROMPT_TRIGGER,
+): boolean {
+ return trigger === "prompts"
+ ? promptsSent() >= STAR_PROMPT_AFTER_PROMPTS
+ : read(STAR_PROMPT_BOT_CREATED_KEY) !== null;
+}
+
+/**
+ * Whether this is the first time this browser draws the card for `trigger`, marking it drawn. The
+ * server counts people either way; this keeps a card somebody ignores from sending a show, and a
+ * billed event, for every conversation they open. Storage that refuses answers yes every time, which
+ * the server still counts once.
+ */
+export function firstStarPromptShow(trigger: StarPromptTrigger): boolean {
+ if (read(shownKey(trigger)) !== null) return false;
+ write(shownKey(trigger), new Date().toISOString());
+ return true;
+}
+
+/**
+ * Count a show or an answer. Best effort: the server derives who and where from the session, and a
+ * count that does not arrive changes nothing on screen. Never throws, so a caller can fire and forget.
+ */
+export async function recordStarPrompt(
+ kind: StarPromptEvent,
+ trigger: StarPromptSource,
+): Promise {
+ try {
+ const response = await tryClient("/api/telemetry/product-events", {
+ method: "POST",
+ body: {
+ kind,
+ trigger,
+ surface: desktopShell() === null ? "web" : "desktop",
+ },
+ });
+ if (!response.ok)
+ console.warn("Star prompt telemetry was not accepted.", response.status);
+ } catch {
+ console.warn("Star prompt telemetry could not be delivered.");
+ }
+}
diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx
index e8ffca4a61..dbbd4432e9 100644
--- a/app/src/routes/_authed/settings/index.tsx
+++ b/app/src/routes/_authed/settings/index.tsx
@@ -10,6 +10,7 @@ import { AiModelSection } from "@/components/settings/ai-connection";
import { MessageListPreference } from "@/components/settings/message-list-preference";
import { NameRow } from "@/components/settings/profile";
import { StandingInstructionsRow } from "@/components/settings/standing-instructions";
+import { StarOnGitHubSection } from "@/components/settings/star-on-github";
import { UsageDataSection } from "@/components/settings/usage-data";
import { useTheme } from "@/components/theme-provider";
import {
@@ -117,6 +118,8 @@ function RouteComponent() {
))}
+ {/* Last: OpenBot itself rather than this person's OpenBot. Gone when the deployment says so. */}
+
);
}
diff --git a/app/tests/star-on-github-setting.test.tsx b/app/tests/star-on-github-setting.test.tsx
new file mode 100644
index 0000000000..1a7870b510
--- /dev/null
+++ b/app/tests/star-on-github-setting.test.tsx
@@ -0,0 +1,159 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ expect,
+ test,
+} from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import type { UserPreferences } from "@openbot/shared/user-preferences";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import { StarOnGitHubSection } from "@/components/settings/star-on-github";
+import { authKeys } from "@/lib/auth/queries";
+import { deploymentKeys } from "@/lib/deployment/queries";
+import { userPreferencesQueryOptions } from "@/lib/settings/message-list";
+import { OPENBOT_REPO_URL } from "@/lib/star-prompt";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * Settings → General's permanent link to star OpenBot on GitHub. There whenever the deployment
+ * allows the ask at all, whether or not this person has answered the card; pressing it is counted
+ * as a Settings click and answers the card, so somebody who starred here is not asked again.
+ */
+
+const originalFetch = globalThis.fetch;
+const clients: QueryClient[] = [];
+let requests: { path: string; method: string; body: unknown }[];
+
+beforeAll(() =>
+ // The link is real: pressing it must not open github.com from a test.
+ GlobalRegistrator.register({
+ url: "http://localhost:3010",
+ settings: {
+ navigation: {
+ disableChildPageNavigation: true,
+ disableMainFrameNavigation: true,
+ },
+ },
+ }),
+);
+beforeEach(() => {
+ requests = [];
+ globalThis.fetch = Object.assign(
+ async (input: RequestInfo | URL, init?: RequestInit) => {
+ const path = String(input);
+ const method = init?.method ?? "GET";
+ const body =
+ typeof init?.body === "string" ? JSON.parse(init.body) : init?.body;
+ requests.push({ path, method, body });
+ if (path.endsWith("/api/settings/preferences")) {
+ return Response.json({
+ preferences: { ...preferences, ...(body as object) },
+ });
+ }
+ return new Response(null, { status: 204 });
+ },
+ { preconnect: originalFetch.preconnect },
+ );
+});
+afterEach(() => {
+ cleanup();
+ for (const client of clients) client.clear();
+ clients.length = 0;
+ globalThis.fetch = originalFetch;
+});
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const preferences: UserPreferences = {
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: true,
+ narration: null,
+ skin: "openbot",
+ proactive: "on",
+};
+
+function setup(starPrompt: boolean) {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime: Infinity },
+ mutations: { retry: false },
+ },
+ });
+ clients.push(client);
+ client.setQueryData(authKeys.currentUser(), {
+ id: "alice",
+ email: "alice@example.com",
+ role: "user",
+ });
+ client.setQueryData(deploymentKeys.capabilities(), {
+ generativeUi: true,
+ selfHostBanner: false,
+ localEvaluation: false,
+ edition: "team",
+ starPrompt,
+ });
+ client.setQueryData(
+ userPreferencesQueryOptions("alice").queryKey,
+ preferences,
+ );
+ return {
+ client,
+ view: render(
+
+
+ ,
+ ),
+ };
+}
+
+test("Settings lists Star OpenBot on GitHub, linking to the repository in a new tab", () => {
+ const { view } = setup(true);
+ expect(view.getByText("Star OpenBot on GitHub")).toBeTruthy();
+ const link = view.getByRole("link", { name: "Star on GitHub" });
+ expect(link.getAttribute("href")).toBe(OPENBOT_REPO_URL);
+ expect(link.getAttribute("target")).toBe("_blank");
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
+});
+
+test("Settings lists nothing when the deployment turned the ask off", () => {
+ const { view } = setup(false);
+ expect(view.queryByText("Star OpenBot on GitHub")).toBeNull();
+ expect(view.queryByRole("link", { name: "Star on GitHub" })).toBeNull();
+});
+
+test("pressing it counts a Settings click and answers the card", async () => {
+ const { client, view } = setup(true);
+ fireEvent.click(view.getByRole("link", { name: "Star on GitHub" }));
+
+ await waitFor(() =>
+ expect(
+ requests.find((request) =>
+ request.path.endsWith("/api/telemetry/product-events"),
+ )?.body,
+ ).toEqual({
+ kind: "star_prompt_clicked",
+ trigger: "settings",
+ surface: "web",
+ }),
+ );
+ await waitFor(() =>
+ expect(
+ requests.find(
+ (request) =>
+ request.path.endsWith("/api/settings/preferences") &&
+ request.method === "PATCH",
+ )?.body,
+ ).toEqual({ starPromptAnswered: true }),
+ );
+ expect(
+ client.getQueryData(userPreferencesQueryOptions("alice").queryKey)
+ ?.starPromptAnswered,
+ ).toBe(true);
+ // Still there: the link in Settings is permanent, whatever the card's answer.
+ expect(view.getByRole("link", { name: "Star on GitHub" })).toBeTruthy();
+});
diff --git a/app/tests/star-prompt.test.tsx b/app/tests/star-prompt.test.tsx
new file mode 100644
index 0000000000..52f7ead7e8
--- /dev/null
+++ b/app/tests/star-prompt.test.tsx
@@ -0,0 +1,455 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ expect,
+ test,
+} from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import type { UserPreferences } from "@openbot/shared/user-preferences";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ waitFor,
+} from "@testing-library/react";
+import { ConversationView } from "@/components/channels/conversation-view";
+import { NOTIFY_PROMPT_KEY } from "@/components/notifications/notify-prompt";
+import { StarPrompt } from "@/components/star-prompt/star-prompt";
+import { createAgentMutationOptions } from "@/lib/agents/mutations";
+import { authKeys } from "@/lib/auth/queries";
+import { deploymentKeys } from "@/lib/deployment/queries";
+import { refreshNotificationPermission } from "@/lib/notifications/permission";
+import { userPreferencesQueryOptions } from "@/lib/settings/message-list";
+import {
+ noteBotCreated,
+ notePromptSent,
+ OPENBOT_REPO_URL,
+ STAR_PROMPT_AFTER_PROMPTS,
+ starPromptEarned,
+} from "@/lib/star-prompt";
+import { queryClient } from "@/query-client";
+import { closeDesktop, launchDesktop } from "./desktop-notifications.fixture";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The ask to star OpenBot on GitHub: a card above the composer once somebody has sent their fifth
+ * message from this browser. Only while the deployment allows it and the person has not answered
+ * it; either answer is saved to their preferences, so it is asked once on every device. The card
+ * waits while the notifications offer is still open, so the two never stack.
+ */
+
+class OfflineWebSocket extends EventTarget {
+ readyState = 0;
+ onclose = null;
+ onerror = null;
+ onmessage = null;
+ onopen = null;
+ close() {}
+ send() {}
+}
+
+const originalFetch = globalThis.fetch;
+const originalWebSocket = globalThis.WebSocket;
+const originalNotification = (globalThis as { Notification?: unknown })
+ .Notification;
+let requests: { path: string; method: string; body: unknown }[];
+let telemetryStatus: number;
+
+beforeAll(() => {
+ // Star on GitHub is a real link: pressing it must not open github.com from a test.
+ GlobalRegistrator.register({
+ url: "http://localhost:3010",
+ settings: {
+ navigation: {
+ disableChildPageNavigation: true,
+ disableMainFrameNavigation: true,
+ },
+ },
+ });
+ globalThis.WebSocket = OfflineWebSocket as unknown as typeof WebSocket;
+});
+beforeEach(() => {
+ window.localStorage.clear();
+ requests = [];
+ telemetryStatus = 204;
+ globalThis.fetch = Object.assign(
+ async (input: RequestInfo | URL, init?: RequestInit) => {
+ const path = String(input);
+ const method = init?.method ?? "GET";
+ const body =
+ typeof init?.body === "string" ? JSON.parse(init.body) : init?.body;
+ requests.push({ path, method, body });
+ if (path.endsWith("/api/telemetry/product-events")) {
+ return new Response(null, { status: telemetryStatus });
+ }
+ if (path.endsWith("/api/settings/preferences") && method === "PATCH") {
+ return Response.json({
+ preferences: { ...preferences(), ...(body as object) },
+ });
+ }
+ return Response.json({ error: "not served" }, { status: 500 });
+ },
+ { preconnect: originalFetch.preconnect },
+ );
+ // A browser already answered about notifications, so that offer is not what is being tested.
+ (globalThis as { Notification?: unknown }).Notification = {
+ permission: "granted",
+ requestPermission: async () => "granted",
+ };
+ refreshNotificationPermission();
+});
+afterEach(() => {
+ cleanup();
+ closeDesktop();
+ queryClient.clear();
+ window.localStorage.clear();
+ globalThis.fetch = originalFetch;
+});
+afterAll(async () => {
+ await settleReactWork();
+ globalThis.WebSocket = originalWebSocket;
+ (globalThis as { Notification?: unknown }).Notification =
+ originalNotification;
+ refreshNotificationPermission();
+ GlobalRegistrator.unregister();
+});
+
+const ASK = "Enjoying OpenBot? Star it on GitHub so more people find it.";
+
+function preferences(answered?: boolean): UserPreferences {
+ return {
+ messageListEmphasis: "thread",
+ selfHostBannerDismissed: true,
+ narration: null,
+ skin: "openbot",
+ proactive: "on",
+ ...(answered === undefined ? {} : { starPromptAnswered: answered }),
+ };
+}
+
+function seed({
+ starPrompt = true as boolean | "absent",
+ answered = undefined as boolean | undefined,
+ loaded = true,
+} = {}) {
+ queryClient.setQueryData(authKeys.currentUser(), {
+ id: "alice",
+ email: "alice@example.com",
+ role: "user",
+ });
+ queryClient.setQueryData(deploymentKeys.capabilities(), {
+ generativeUi: true,
+ selfHostBanner: false,
+ localEvaluation: false,
+ edition: "team",
+ ...(starPrompt === "absent" ? {} : { starPrompt }),
+ });
+ if (loaded) {
+ queryClient.setQueryData(
+ userPreferencesQueryOptions("alice").queryKey,
+ preferences(answered),
+ );
+ }
+}
+
+function sendPrompts(count: number) {
+ for (let sent = 0; sent < count; sent += 1) notePromptSent();
+}
+
+const posted = (kind: string) =>
+ requests.filter(
+ (request) =>
+ request.path.endsWith("/api/telemetry/product-events") &&
+ (request.body as { kind?: string }).kind === kind,
+ );
+
+test("is not asked after four messages, and is asked after the fifth", () => {
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS - 1);
+ const before = render();
+ expect(before.queryByText(ASK)).toBeNull();
+ before.unmount();
+
+ notePromptSent();
+ const after = render();
+ expect(after.getByText(ASK)).toBeTruthy();
+ expect(after.getByRole("button", { name: "Not now" })).toBeTruthy();
+});
+
+test("is never asked where the deployment turned it off, or by a server too old to say", () => {
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ seed({ starPrompt: false });
+ expect(render().queryByText(ASK)).toBeNull();
+ cleanup();
+ queryClient.clear();
+ seed({ starPrompt: "absent" });
+ expect(render().queryByText(ASK)).toBeNull();
+});
+
+test("is never asked before the person's preferences load, when they fail, or once answered", async () => {
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ seed({ loaded: false });
+ const loading = render();
+ expect(loading.queryByText(ASK)).toBeNull();
+ // The preferences request is answered 500 by the stub; the card still never appears.
+ await waitFor(() =>
+ expect(
+ requests.some((request) =>
+ request.path.endsWith("/api/settings/preferences"),
+ ),
+ ).toBe(true),
+ );
+ expect(loading.queryByText(ASK)).toBeNull();
+ cleanup();
+ queryClient.clear();
+
+ seed({ answered: true });
+ expect(render().queryByText(ASK)).toBeNull();
+});
+
+test("Star on GitHub is a link to the repository, opened in a new tab", () => {
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ const link = render().getByRole("link", {
+ name: "Star on GitHub",
+ });
+ expect(link.getAttribute("href")).toBe(OPENBOT_REPO_URL);
+ expect(OPENBOT_REPO_URL).toBe("https://github.com/CopilotKit/OpenBot");
+ expect(link.getAttribute("target")).toBe("_blank");
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
+});
+
+test("drawing it counts one show for the prompts trigger", async () => {
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ render();
+ await waitFor(() => expect(posted("star_prompt_shown")).toHaveLength(1));
+ expect(posted("star_prompt_shown")[0]?.body).toEqual({
+ kind: "star_prompt_shown",
+ trigger: "prompts",
+ surface: "web",
+ });
+});
+
+for (const [button, kind] of [
+ ["Star on GitHub", "star_prompt_clicked"],
+ ["Not now", "star_prompt_dismissed"],
+] as const) {
+ test(`${button} hides it, saves the answer, and counts it`, async () => {
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ const view = render();
+ fireEvent.click(
+ button === "Not now"
+ ? view.getByRole("button", { name: button })
+ : view.getByRole("link", { name: button }),
+ );
+
+ await waitFor(() => expect(view.queryByText(ASK)).toBeNull());
+ await waitFor(() => expect(posted(kind)).toHaveLength(1));
+ expect(posted(kind)[0]?.body).toEqual({
+ kind,
+ trigger: "prompts",
+ surface: "web",
+ });
+ const saved = requests.find(
+ (request) =>
+ request.path.endsWith("/api/settings/preferences") &&
+ request.method === "PATCH",
+ );
+ expect(saved?.body).toEqual({ starPromptAnswered: true });
+ });
+}
+
+test("a count the server refuses still hides it and still saves the answer", async () => {
+ telemetryStatus = 500;
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ const view = render();
+ fireEvent.click(view.getByRole("button", { name: "Not now" }));
+
+ await waitFor(() => expect(view.queryByText(ASK)).toBeNull());
+ await waitFor(() =>
+ expect(
+ requests.some(
+ (request) =>
+ request.path.endsWith("/api/settings/preferences") &&
+ request.method === "PATCH",
+ ),
+ ).toBe(true),
+ );
+});
+
+test("storage that refuses earns nothing and breaks nothing", () => {
+ // As with site data blocked: reaching `localStorage` at all throws.
+ const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ get() {
+ throw new Error("storage refused");
+ },
+ });
+ try {
+ expect(() => sendPrompts(STAR_PROMPT_AFTER_PROMPTS)).not.toThrow();
+ expect(() => noteBotCreated()).not.toThrow();
+ expect(starPromptEarned("prompts")).toBe(false);
+ expect(starPromptEarned("bot_created")).toBe(false);
+ } finally {
+ if (original) Object.defineProperty(globalThis, "localStorage", original);
+ }
+});
+
+test("creating a Bot earns the bot_created trigger, and only that one", async () => {
+ expect(starPromptEarned("bot_created")).toBe(false);
+ const options = createAgentMutationOptions(queryClient);
+ await options.onSuccess?.(
+ // The created Bot itself is not read; only that a create succeeded.
+ {} as never,
+ {} as never,
+ undefined as never,
+ {} as never,
+ );
+ expect(starPromptEarned("bot_created")).toBe(true);
+ expect(starPromptEarned("prompts")).toBe(false);
+});
+
+test("a conversation counts each message sent, and asks above the composer at the fifth", async () => {
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS - 1);
+ let resolveSend = () => {};
+ const view = render(
+
+ new Promise((resolve) => {
+ resolveSend = resolve;
+ })
+ }
+ />,
+ );
+ expect(view.container.querySelector("[data-star-prompt]")).toBeNull();
+
+ const box = view.getByRole("textbox");
+ fireEvent.input(box, { target: { textContent: "And a taxi" } });
+ fireEvent.keyDown(box, { key: "Enter" });
+ resolveSend();
+
+ await waitFor(() => expect(starPromptEarned("prompts")).toBe(true));
+ await waitFor(() =>
+ expect(view.container.querySelector("[data-star-prompt]")).not.toBeNull(),
+ );
+});
+
+test("a send the conversation refuses is not counted", async () => {
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS - 1);
+ let refuse = (_reason: Error) => {};
+ const view = render(
+
+ new Promise((_resolve, reject) => {
+ refuse = reject;
+ })
+ }
+ />,
+ );
+ const box = view.getByRole("textbox");
+ fireEvent.input(box, { target: { textContent: "And a taxi" } });
+ fireEvent.keyDown(box, { key: "Enter" });
+ refuse(new Error("Agent unavailable"));
+
+ // The words go back in the box when a send is refused; wait for that, then check nothing counted.
+ await waitFor(() => expect(box.textContent).toContain("And a taxi"));
+ expect(starPromptEarned("prompts")).toBe(false);
+ expect(view.container.querySelector("[data-star-prompt]")).toBeNull();
+});
+
+test("a conversation that cannot take messages never asks", () => {
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ const view = render(
+ {}}
+ />,
+ );
+ expect(view.container.querySelector("[data-star-prompt]")).toBeNull();
+});
+
+test("in OpenBot Desktop the card waits until the app has said whether notifications are on", async () => {
+ const desktop = launchDesktop({ plugin: true, answersLate: true });
+ refreshNotificationPermission();
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ const view = render(
+ {}}
+ />,
+ );
+ expect(view.container.querySelector("[data-star-prompt]")).toBeNull();
+ expect(posted("star_prompt_shown")).toHaveLength(0);
+
+ await act(async () => desktop.answer());
+
+ await waitFor(() =>
+ expect(view.container.querySelector("[data-star-prompt]")).not.toBeNull(),
+ );
+});
+
+test("a card drawn again in the same browser is counted as shown once", async () => {
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ render();
+ await waitFor(() => expect(posted("star_prompt_shown")).toHaveLength(1));
+ cleanup();
+
+ render();
+ expect(render().getAllByText(ASK).length).toBeGreaterThan(0);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(posted("star_prompt_shown")).toHaveLength(1);
+});
+
+test("waits while the notifications offer is still open, so the two never stack", () => {
+ (globalThis as { Notification?: unknown }).Notification = {
+ permission: "default",
+ requestPermission: async () => "default",
+ };
+ refreshNotificationPermission();
+ seed();
+ sendPrompts(STAR_PROMPT_AFTER_PROMPTS);
+ const messages = [
+ { id: "u1", role: "user" as const, content: "Book a table" },
+ ];
+ const open = render(
+ {}}
+ />,
+ );
+ expect(open.container.querySelector("[data-notify-prompt]")).not.toBeNull();
+ expect(open.container.querySelector("[data-star-prompt]")).toBeNull();
+ cleanup();
+
+ window.localStorage.setItem(NOTIFY_PROMPT_KEY, new Date().toISOString());
+ const answered = render(
+ {}}
+ />,
+ );
+ const card = answered.container.querySelector("[data-star-prompt]");
+ expect(card).not.toBeNull();
+ expect(card?.closest('[data-slot="composer-column"]')).not.toBeNull();
+});
diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md
index 215c30d101..16e6bc4b4f 100644
--- a/docs/TELEMETRY.md
+++ b/docs/TELEMETRY.md
@@ -18,7 +18,7 @@ Browser runs are `web`, or `desktop` in the desktop distribution; the native app
## Events and interpretation
-The event names are `oss.runtime.openbot.run_accepted`, `.run_terminal`, `.run_stop_requested`, `.capability`, `.result_viewed` and `.result_copied`. Common properties include `schema_version`, `app_id`, `event_id`, `occurred_at`, `deployment_id`, `user_id` and `environment`. Run events carry known thread/run/root/parent IDs, `attempt_id=run_id`, a numeric `attempt` only when available, `trigger`, `surface` and `purpose`. Unknown linkage stays unknown.
+The event names are `oss.runtime.openbot.run_accepted`, `.run_terminal`, `.run_stop_requested`, `.capability`, `.result_viewed`, `.result_copied`, `.star_prompt_shown`, `.star_prompt_clicked` and `.star_prompt_dismissed`. Common properties include `schema_version`, `app_id`, `event_id`, `occurred_at`, `deployment_id`, `user_id` and `environment`. Run events carry known thread/run/root/parent IDs, `attempt_id=run_id`, a numeric `attempt` only when available, `trigger`, `surface` and `purpose`. Unknown linkage stays unknown.
`run_accepted` records entry into agent execution, rather than every HTTP run/connect request. Only runs OpenBot started as work are counted: a person's, a routine's, a subagent's or a hop's. The runtime's own runs on a Bot, naming a new conversation or offering suggestions, carry no OpenBot context and emit no run events. `run_terminal` records the observed `outcome`, bounded `failure_category`, duration, `is_root` and up to 256 `result_message_ids`. An actual protocol finish without an explicit failure is normal completion; source errors, cancellation and timeout remain distinct. The host may emit `run_stop_requested` with a bounded stop reason; this records a request, not a terminal outcome. A run OpenBot knows it stopped ends `cancelled` with `failure_category` `none`: a person's Stop (on a browser run or on a thread a headless turn runs in), a stop that reaches a subagent, a headless cancel or pause, and a paused hop. A headless turn stopped at its deadline ends `timeout`. A lost lock is not a stop. Missing terminal evidence is unknown. A child run never determines its parent's outcome. Human initiation, scheduled routines, followups and subagents remain separate triggers.
@@ -42,6 +42,16 @@ PostHog joins consumption to terminal execution through scoped user, deployment,
Tool/activity-only outputs, nested code/table copy controls, native mobile and external-channel consumption remain outside this UI coverage. The notification sheet is an approval/question inbox with no delivered-result open action, so a bell click emits no result-open event. Existing feedback behavior is unchanged and has no new product analytics event.
+## The star prompt
+
+The same endpoint accepts the one other body shape, for the ask to star OpenBot on GitHub (`OPENBOT_STAR_PROMPT`, see [configuration.md](configuration.md)):
+
+```json
+{"kind":"star_prompt_clicked","trigger":"prompts","surface":"desktop"}
+```
+
+`kind` is `star_prompt_shown|star_prompt_clicked|star_prompt_dismissed`: the card was drawn, its Star on GitHub link was pressed, or Not now was. `trigger` is the moment that asked: `prompts` (the card after a person's fifth message) or `bot_created` (the card after they create a Bot), and `settings` for the permanent link in Settings → General, which has no shown event. `surface` is `web` or `desktop`. Nothing else is accepted. Beyond the common properties the server forwards them as `prompt_trigger` and `prompt_surface`, named apart from the run events' own `trigger` and `surface`, and adds nothing else. The stable event ID is the person, the kind and the trigger, so a card drawn on every visit, or a link pressed twice, counts once: deduplicated, the counts are people. The browser also reports a card as shown only the first time it draws it for each trigger. Clicked means the repository opened, not that a star was given; GitHub's own star count is the outcome. [Query F](telemetry-queries.sql) counts people shown, clicking and dismissing per trigger.
+
## Delivery and reporting limits
Sending is best effort through the existing transport. There is no local event journal, application-level telemetry retry, replay or exactly-once producer guarantee. Stable event IDs support deduplication in PostHog queries; physical sink/destination deduplication remains an external transport concern. Shared sender completion does not acknowledge destination receipt, and opt-out or unavailable transport can leave gaps.
diff --git a/docs/configuration.md b/docs/configuration.md
index 4d29d65c4a..5ffd1791de 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -93,6 +93,7 @@ at `agent-langgraph` on a laptop.
| `WORKER_SHARED_SECRET` | unset; `start.sh` uses a fixed local default; the Helm chart generates one | The secret the routines worker presents to fire a due routine. Without it the server refuses every handoff, whether or not a worker exists to send one. |
| `OPENBOT_GENERATIVE_UI` | unset (capability on) | Set `false` or `0` to stop Bots from answering with generated interfaces. |
| `OPENBOT_SELF_HOST_BANNER` | unset (banner on) | Set `false` or `0` to hide the bar offering help self-hosting OpenBot, for everybody. It never shows on a paid Intelligence plan, and each person can also close it for themselves. |
+| `OPENBOT_STAR_PROMPT` | unset (on) | Set `false` or `0` to stop asking people to star OpenBot on GitHub and to drop the link from Settings → General, for everybody. Each person who answers the ask stops seeing it. |
| `OPENBOT_ACCESSIBILITY_DISABLED` | unset | `true` or `1` stops naming OpenBot on the analytics the runtime already sends. |
| `OPENBOT_PRODUCT_TELEMETRY` | unset (off; on for OpenBot Desktop) | `1` or `true` sends OpenBot's product usage events; `0` turns them off. `DO_NOT_TRACK` or `COPILOTKIT_TELEMETRY_DISABLED` set to `1` or `true` turns them off whatever this says. See [TELEMETRY.md](TELEMETRY.md). |
| `OPENBOT_ALLOW_LOOPBACK_MCP` | unset | `1` lets an MCP server on this machine's loopback address (`http://127.0.0.1:` or `[::1]`) be added by its address, for developing an MCP server against OpenBot and for the end-to-end tests. Leave it unset everywhere people use OpenBot: every other private, local or plain-http address stays refused either way. |
@@ -125,6 +126,15 @@ kept for ten minutes, so a plan bought today hides the bar within ten minutes, a
most a second for the first answer after the server starts. In Helm, set the variable through
`config.extraEnv`.
+**`OPENBOT_STAR_PROMPT`** asks each person once, in a card above the message box, to star OpenBot
+on GitHub, and lists the same link in Settings → General. The card appears after the fifth message
+sent from that browser. Either of its buttons, Not now or Star on GitHub, is saved to that person's
+preferences, so it is never asked again on any device they sign in from. It is on in every
+edition. Set `false` or `0` to turn off both the card and the Settings link for everybody, which
+suits a fork running OpenBot for its own organization. Any other value, or none, leaves it on. With
+product telemetry on, OpenBot counts how many people were shown the card, starred from it, or
+dismissed it; see [TELEMETRY.md](TELEMETRY.md).
+
The component catalogue has separate per-Bot grants. Its sortable data table (`showTable`),
interactive form (`askForm`), and other compiled or playground-authored components remain governed
by those grants. In Admin → Playground, edit a draft and its sample arguments, preview it, then
diff --git a/docs/telemetry-queries.sql b/docs/telemetry-queries.sql
index 2f68a42591..93be7b158e 100644
--- a/docs/telemetry-queries.sql
+++ b/docs/telemetry-queries.sql
@@ -124,3 +124,24 @@ FROM consumption c LEFT JOIN result_messages t
-- No matching execution is unknown coverage, not evidence of success/failure.
-- No view/copy is unobserved consumption, not dissatisfaction. Notification
-- open and usefulness have no event here; do not infer them from delivery.
+
+-- F. The ask to star OpenBot on GitHub: people shown, starring and dismissing, per
+-- trigger. Each event_id is one person, answer and trigger, so counts are people.
+-- Surface is not in the key: somebody on web and Desktop is one person, under the
+-- surface of the copy kept. Clicked is opening the repository, not a GitHub star;
+-- GitHub's own count is the outcome. 'settings' clicks have no shown event.
+SELECT properties.prompt_trigger AS trigger,
+ count(DISTINCT if(event='oss.runtime.openbot.star_prompt_shown',
+ properties.event_id, NULL)) AS people_shown,
+ count(DISTINCT if(event='oss.runtime.openbot.star_prompt_clicked',
+ properties.event_id, NULL)) AS people_clicked,
+ count(DISTINCT if(event='oss.runtime.openbot.star_prompt_dismissed',
+ properties.event_id, NULL)) AS people_dismissed
+FROM events
+WHERE event IN ('oss.runtime.openbot.star_prompt_shown',
+ 'oss.runtime.openbot.star_prompt_clicked',
+ 'oss.runtime.openbot.star_prompt_dismissed')
+ AND properties.app_id='openbot' AND properties.environment='production'
+ AND properties.event_id IS NOT NULL
+ AND timestamp >= toDateTime('2026-11-03 00:00:00', 'UTC')
+GROUP BY trigger;
diff --git a/server/src/app.ts b/server/src/app.ts
index 54b7ebc01e..c2b2d7a50a 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -613,6 +613,9 @@ export function createApp(
: selfHostBanner
? await selfHostBanner.shown()
: config.selfHostBanner,
+ // Whether to ask people to star OpenBot on GitHub. In every edition: anybody using the open
+ // project can help others find it. Only the operator's switch turns it off.
+ starPrompt: config.starPrompt,
/*
* Whether this is somebody trying OpenBot on their own machine, not an organization's
* deployment. Onboarding reads it to explain where OpenBot can run, which only the person
diff --git a/server/src/config.ts b/server/src/config.ts
index 4263ae4024..737f0a97df 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -453,6 +453,17 @@ export type DeploymentConfig = {
* switch only; a deployment on a paid Intelligence plan hides the bar too (self-host-banner.ts).
*/
selfHostBanner: boolean;
+ /**
+ * Whether the signed-in app asks people, once, to star OpenBot on GitHub, and lists the link in
+ * Settings → General.
+ *
+ * On by default, for the same reason as the self-host banner: a fresh install is somebody trying
+ * the open-source project, and a star is how other people find it. A deployment running OpenBot
+ * for its own organization turns it off with OPENBOT_STAR_PROMPT=false or OPENBOT_STAR_PROMPT=0,
+ * since asking its people to star a repository is not its business. Each person answering it
+ * hides it for them on every device (`starPromptAnswered` in their preferences).
+ */
+ starPrompt: boolean;
/**
* Where the built app is, when this process serves it.
*
@@ -1543,6 +1554,12 @@ function selfHostBannerEnabled(environment: Environment): boolean {
return value !== "false" && value !== "0";
}
+/** Same rule again: on unless explicitly "false" or "0". */
+function starPromptEnabled(environment: Environment): boolean {
+ const value = optional(environment, "OPENBOT_STAR_PROMPT");
+ return value !== "false" && value !== "0";
+}
+
/**
* How long the audit trail is kept.
*
@@ -1707,6 +1724,7 @@ export function loadConfig(
accessibility: accessibilityEnabled(environment),
generativeUi: generativeUiEnabled(environment),
selfHostBanner: selfHostBannerEnabled(environment),
+ starPrompt: starPromptEnabled(environment),
...(optional(environment, "APP_DIST_DIR")
? { appDistDir: optional(environment, "APP_DIST_DIR") as string }
: {}),
diff --git a/server/src/telemetry/product-routes.ts b/server/src/telemetry/product-routes.ts
index 40b3732b02..ca3f11a40a 100644
--- a/server/src/telemetry/product-routes.ts
+++ b/server/src/telemetry/product-routes.ts
@@ -1,11 +1,15 @@
import { Hono, type MiddlewareHandler } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { AppVariables } from "../auth/guards";
-import { type ProductTelemetry, parseConsumption } from "./product";
+import {
+ type ProductTelemetry,
+ parseConsumption,
+ parseStarPrompt,
+} from "./product";
export function createProductTelemetryRoutes(
requireUser: MiddlewareHandler<{ Variables: AppVariables }>,
- telemetry: Pick,
+ telemetry: Pick,
) {
const routes = new Hono<{ Variables: AppVariables }>();
routes.use("*", requireUser);
@@ -22,9 +26,15 @@ export function createProductTelemetryRoutes(
} catch {
return c.json({ error: "Invalid event" }, 400);
}
+ // One of two strict shapes: a result viewed or copied, or an answer to the star prompt.
const event = parseConsumption(raw);
- if (!event) return c.json({ error: "Invalid event" }, 400);
- await telemetry.consume(c.var.actor.id, event);
+ if (event) {
+ await telemetry.consume(c.var.actor.id, event);
+ return c.body(null, 204);
+ }
+ const star = parseStarPrompt(raw);
+ if (!star) return c.json({ error: "Invalid event" }, 400);
+ await telemetry.starPrompt(c.var.actor.id, star);
return c.body(null, 204);
},
);
diff --git a/server/src/telemetry/product.ts b/server/src/telemetry/product.ts
index e812a9fe00..53e79d381a 100644
--- a/server/src/telemetry/product.ts
+++ b/server/src/telemetry/product.ts
@@ -103,6 +103,25 @@ export function parseConsumption(value: unknown): ProductConsumption | null {
const parsed = consumptionSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
+/*
+ * The ask to star OpenBot on GitHub: shown, answered with the link, or answered with Not now. The
+ * trigger says which moment asked (`settings` is the permanent link there), so the moments can be
+ * compared without an experiment. Nothing about the person or what they were doing goes with it.
+ */
+const starPromptSchema = z.strictObject({
+ kind: z.enum([
+ "star_prompt_shown",
+ "star_prompt_clicked",
+ "star_prompt_dismissed",
+ ]),
+ trigger: z.enum(["prompts", "bot_created", "settings"]),
+ surface: z.enum(["web", "desktop"]),
+});
+export type ProductStarPrompt = z.infer;
+export function parseStarPrompt(value: unknown): ProductStarPrompt | null {
+ const parsed = starPromptSchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+}
export function productExportEnabled(
env: Record,
): boolean {
@@ -646,6 +665,25 @@ export async function createProductTelemetry(options: {
},
);
},
+ /*
+ * Keyed on the person, the answer and the trigger, so a card shown on every visit, or a link
+ * clicked twice, is one event: PostHog counts people who were asked and people who starred.
+ *
+ * `prompt_trigger` and `prompt_surface`, not `trigger` and `surface`: run events already carry
+ * those names with other values (`human`, `routine`), and a breakdown across every OpenBot event
+ * would mix the two, as `consumption_surface` already avoids.
+ */
+ starPrompt: async (actor: string, event: ProductStarPrompt) => {
+ await emit(
+ event.kind,
+ `star:${user(actor)}:${event.kind}:${event.trigger}`,
+ {
+ user_id: user(actor),
+ prompt_trigger: event.trigger,
+ prompt_surface: event.surface,
+ },
+ );
+ },
markStopped,
stopKnown: async (
actor: string,
diff --git a/server/src/user-preferences-routes.ts b/server/src/user-preferences-routes.ts
index af7579ff3e..c0bc9a335a 100644
--- a/server/src/user-preferences-routes.ts
+++ b/server/src/user-preferences-routes.ts
@@ -14,6 +14,7 @@ const preferencesPatch = z
.strictObject({
messageListEmphasis: z.enum(["agent", "thread"]).optional(),
selfHostBannerDismissed: z.boolean().optional(),
+ starPromptAnswered: z.boolean().optional(),
narration: z.enum(["on", "off"]).optional(),
skin: z.string().transform(currentSkinId).pipe(z.enum(SKIN_IDS)).optional(),
proactive: z.enum(["on", "off"]).optional(),
@@ -48,7 +49,7 @@ export function userPreferencesRoutes(
if (!parsed.success) {
return context.json(
{
- error: `Choose agent or thread for message list emphasis, true or false for the self-host banner, on or off for narration or check-ins, or one of these skins: ${SKIN_IDS.join(", ")}.`,
+ error: `Choose agent or thread for message list emphasis, true or false for the self-host banner or the star prompt, on or off for narration or check-ins, or one of these skins: ${SKIN_IDS.join(", ")}.`,
},
400,
);
diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts
index b10133f772..375b1ad72e 100644
--- a/server/tests/config.test.ts
+++ b/server/tests/config.test.ts
@@ -1135,6 +1135,28 @@ describe("generated interfaces", () => {
});
});
+describe("the star prompt", () => {
+ test("is on when nothing is set", () => {
+ expect(loadConfig(baseEnvironment).starPrompt).toBe(true);
+ });
+
+ test.each(["false", "0"])("is off for OPENBOT_STAR_PROMPT=%p", (value) => {
+ expect(
+ loadConfig({ ...baseEnvironment, OPENBOT_STAR_PROMPT: value }).starPrompt,
+ ).toBe(false);
+ });
+
+ test.each(["true", "1", "", "no"])(
+ "stays on for OPENBOT_STAR_PROMPT=%p",
+ (value) => {
+ expect(
+ loadConfig({ ...baseEnvironment, OPENBOT_STAR_PROMPT: value })
+ .starPrompt,
+ ).toBe(true);
+ },
+ );
+});
+
describe("the self-host banner", () => {
test("is on when nothing is set", () => {
expect(loadConfig(baseEnvironment).selfHostBanner).toBe(true);
diff --git a/server/tests/health.test.ts b/server/tests/health.test.ts
index 77737b37cd..cc5a469e74 100644
--- a/server/tests/health.test.ts
+++ b/server/tests/health.test.ts
@@ -47,6 +47,8 @@ describe("runtime capabilities", () => {
generativeUi: true,
// Default-on: a fresh clone is somebody evaluating the template.
selfHostBanner: true,
+ // Default-on in every edition: the ask to star OpenBot on GitHub, until somebody answers it.
+ starPrompt: true,
// Off: this deployment has an identity provider, so it is not somebody's local trial.
localEvaluation: false,
// Which product this is. The app trims its screens for the personal edition.
@@ -77,6 +79,7 @@ describe("runtime capabilities", () => {
"durableHistory",
"generativeUi",
"selfHostBanner",
+ "starPrompt",
"localEvaluation",
"edition",
"transcription",
@@ -124,6 +127,23 @@ describe("runtime capabilities", () => {
expect((await response.json()).selfHostBanner).toBe(false);
});
+ // The ask to star OpenBot on GitHub is the operator's to turn off, and the browser has to hear it.
+ test("reports the star prompt as on unless the deployment opts out", async () => {
+ const on = await (
+ await createApp(loadConfig(testEnvironment())).request(
+ "http://openbot.local/api/capabilities",
+ )
+ ).json();
+ const off = await (
+ await createApp(
+ loadConfig(testEnvironment({ OPENBOT_STAR_PROMPT: "false" })),
+ ).request("http://openbot.local/api/capabilities")
+ ).json();
+
+ expect(on.starPrompt).toBe(true);
+ expect(off.starPrompt).toBe(false);
+ });
+
// One person running it for themselves is not somebody to offer help running it for a company.
test("reports the personal edition, with no self-host banner", async () => {
const personal = createApp(
diff --git a/server/tests/product-telemetry-routes.test.ts b/server/tests/product-telemetry-routes.test.ts
index f04af64df3..f213a1be83 100644
--- a/server/tests/product-telemetry-routes.test.ts
+++ b/server/tests/product-telemetry-routes.test.ts
@@ -88,3 +88,94 @@ test("bounded historical references are forwarded without fabricated success and
).status,
).toBe(404);
});
+
+/*
+ * The ask to star OpenBot on GitHub, counted per trigger so the triggers can be compared. A person,
+ * not a click: the same answer twice is one event id, and nothing names a thread or a message.
+ */
+const star = {
+ kind: "star_prompt_clicked",
+ trigger: "prompts",
+ surface: "desktop",
+};
+test("a star prompt event is forwarded under the person's pseudonym, with its trigger and surface", async () => {
+ const f = await fixture();
+ expect((await f.request(star)).status).toBe(204);
+ expect(f.sent).toHaveLength(1);
+ expect(f.sent[0]?.event).toBe("oss.runtime.openbot.star_prompt_clicked");
+ // Named for the prompt: run events already use `trigger` and `surface` for something else.
+ expect(f.sent[0]?.properties).toMatchObject({
+ prompt_trigger: "prompts",
+ prompt_surface: "desktop",
+ user_id: expect.stringMatching(/^ob_/),
+ });
+ expect(f.sent[0]?.properties).not.toHaveProperty("trigger");
+ expect(f.sent[0]?.properties).not.toHaveProperty("surface");
+ expect(String(f.sent[0]?.properties?.user_id)).not.toContain(
+ "private-user-one",
+ );
+ expect(f.sent[0]?.properties).not.toHaveProperty("thread_id");
+ expect(f.sent[0]?.properties).not.toHaveProperty("message_id");
+});
+test("the same person and answer is one event id, and another trigger is another", async () => {
+ const f = await fixture();
+ await f.request(star);
+ await f.request(star);
+ await f.request({ ...star, trigger: "settings" });
+ for (const kind of ["star_prompt_shown", "star_prompt_dismissed"]) {
+ expect((await f.request({ ...star, kind })).status).toBe(204);
+ }
+ expect(f.sent[0]?.properties?.event_id).toBe(f.sent[1]?.properties?.event_id);
+ expect(f.sent[2]?.properties?.event_id).not.toBe(
+ f.sent[0]?.properties?.event_id,
+ );
+ expect(f.sent.map((sent) => sent.event)).toEqual([
+ "oss.runtime.openbot.star_prompt_clicked",
+ "oss.runtime.openbot.star_prompt_clicked",
+ "oss.runtime.openbot.star_prompt_clicked",
+ "oss.runtime.openbot.star_prompt_shown",
+ "oss.runtime.openbot.star_prompt_dismissed",
+ ]);
+});
+test("a star prompt event refuses an unknown trigger, surface or kind, and any extra field", async () => {
+ const f = await fixture();
+ for (const body of [
+ { ...star, trigger: "onboarding" },
+ { ...star, surface: "mobile" },
+ { ...star, kind: "star_prompt_starred" },
+ { ...star, threadId: "thread-one" },
+ { kind: "star_prompt_clicked", trigger: "prompts" },
+ ]) {
+ expect((await f.request(body)).status).toBe(400);
+ }
+ expect(f.sent).toHaveLength(0);
+});
+test("with product telemetry off, a star prompt event is accepted and nothing is sent", async () => {
+ const sent: LambdaSendOptions[] = [];
+ const telemetry = await createProductTelemetry({
+ privacyKey: "synthetic-key",
+ deploymentId: "deployment",
+ env: {},
+ send: async (event) => {
+ sent.push(event);
+ },
+ });
+ const app = new Hono().route(
+ "/api/telemetry",
+ createProductTelemetryRoutes(async (c, next) => {
+ c.set("actor", {
+ id: "private-user-one",
+ email: "synthetic@example.test",
+ role: "user",
+ });
+ await next();
+ }, telemetry),
+ );
+ const response = await app.request("/api/telemetry/product-events", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(star),
+ });
+ expect(response.status).toBe(204);
+ expect(sent).toHaveLength(0);
+});
diff --git a/server/tests/user-preferences-routes.test.ts b/server/tests/user-preferences-routes.test.ts
index 0fd1fd8171..43f8088141 100644
--- a/server/tests/user-preferences-routes.test.ts
+++ b/server/tests/user-preferences-routes.test.ts
@@ -88,6 +88,8 @@ test.each([
{},
null,
{ selfHostBannerDismissed: "yes" },
+ { starPromptAnswered: "yes" },
+ { starPromptAnswered: null },
{ messageListEmphasis: "invalid" },
{ narration: "always" },
{ narration: null },
@@ -272,3 +274,18 @@ test("check-ins start on, and a person's off is kept without touching the others
(await (await appFor("alice").request(path)).json()).preferences.proactive,
).toBe("on");
});
+
+// Either answer to the ask to star OpenBot hides it on every device; nothing else moves.
+test("saves the star prompt's answer without touching other preferences", async () => {
+ const { appFor } = fixture();
+ const alice = appFor("alice");
+ await alice.request(path, patch({ messageListEmphasis: "agent" }));
+ const saved = await alice.request(path, patch({ starPromptAnswered: true }));
+ expect(saved.status).toBe(200);
+ expect(
+ (await (await appFor("alice").request(path)).json()).preferences,
+ ).toMatchObject({ messageListEmphasis: "agent", starPromptAnswered: true });
+ expect(
+ (await (await appFor("bob").request(path)).json()).preferences,
+ ).not.toHaveProperty("starPromptAnswered");
+});
diff --git a/shared/user-preferences.ts b/shared/user-preferences.ts
index 0a871e1f42..9cda75fd8e 100644
--- a/shared/user-preferences.ts
+++ b/shared/user-preferences.ts
@@ -48,6 +48,11 @@ export type UserPreferences = {
messageListEmphasis: MessageListEmphasis;
/** Whether this person closed the banner offering help self-hosting OpenBot. */
selfHostBannerDismissed: boolean;
+ /**
+ * Set once this person answers the ask to star OpenBot on GitHub, either way, so it is asked once
+ * on every device they sign in from. Absent until then, which reads as not answered.
+ */
+ starPromptAnswered?: boolean;
/**
* Whether this person narrates their recordings in OpenBot's Browser: the page listens to their microphone (hosted
* OpenBot), or the Recorder in their browser does (OpenBot Desktop), only when it is `on`. Null until they chose, which