Skip to content
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,16 @@ WORKER_HOST=127.0.0.1
# AGENT_TOKEN=
# OpenBot's Intelligence runtime is not this raw AG-UI URL. See its adapter docs.

# CopilotKit Intelligence is required in every mode. Create or select a project
# with the commands below, then keep the generated key server-only.
# CopilotKit Intelligence is the default threads backend and requires a project
# key in every mode. Create or select a project with the commands below, then
# keep the generated key server-only.
# npx copilotkit@latest login
# npx copilotkit@latest project select
CPK_INTELLIGENCE_API_KEY=
# THREADS_BACKEND=local keeps chat threads on the server's own database with
# no Intelligence key and no Intelligence requests. Rename, archive and
# realtime thread metadata remain Intelligence-only. See docs/PLUGGABLE-THREADS.md.
# THREADS_BACKEND=intelligence

# CopilotKit SDK telemetry goes through the shared sink to PostHog, tagged OpenMuse.
# project select also writes CPK_TELEMETRY_ID: preserve that generated value and
Expand Down
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ The computer combines **persistent Chromium and an optional Linux workspace**. T
| **Finance** | Import transaction CSV to create a spending summary with categories, transactions, and a savings-goal action. |
| **Gmail & Calendar** | Google OAuth adapters, complete mail threads, drafts/attachments, calendar discovery, and reviewed event creation/update/deletion. Live credentials required. |
| **Personal context** | Editable name, tone, avatar, and memories. Background-update preferences and durable in-app notifications. |
| **Rich Threads** | CopilotKit Intelligence persistence in every mode, with a stable main conversation, side chats, renaming, archiving, restoring, and replay. A server-only project key is required. |
| **Rich Threads** | CopilotKit Intelligence persistence in every mode, with a stable main conversation, side chats, renaming, archiving, restoring, and replay. A server-only project key is required — or set `THREADS_BACKEND=local` to persist threads on your own database with no key. |

The [feature inventory](docs/FEATURES.md) describes implemented capabilities and planned extensions. Health/bank/social connectors, device push, voice, generated executable tools, and automatic reservations/payments are on the [roadmap](ROADMAP.md).

## Quick start

**Requirements:** Node 24 LTS, pnpm 11.19.0, and a CopilotKit Intelligence project key. The local sample app needs no model, Google account, or Docker.
**Requirements:** Node 24 LTS, pnpm 11.19.0, and either a CopilotKit Intelligence project key (default) or `THREADS_BACKEND=local` for fully self-hosted thread persistence. The local sample app needs no model, Google account, or Docker.

```sh
git clone https://github.com/CopilotKit/OpenMuse.git openmuse
Expand All @@ -73,6 +73,7 @@ cp .env.example .env
npx copilotkit@latest login
npx copilotkit@latest project select
# Set CPK_INTELLIGENCE_API_KEY in .env to the generated server-only project key.
# Or set THREADS_BACKEND=local in .env to skip Intelligence entirely.
# Keep the CLI-generated CPK_TELEMETRY_ID too, including in deployed server environments.
pnpm dev
```
Expand Down Expand Up @@ -111,7 +112,7 @@ The deploy form asks for three values you provide. Render generates the other tw

| Variable | Set by | If it is missing |
|---|---|---|
| `CPK_INTELLIGENCE_API_KEY` | You. Run `npx copilotkit@latest login`, then `npx copilotkit@latest project select`. Keep it on the server. | Chat cannot open a thread. |
| `CPK_INTELLIGENCE_API_KEY` | You. Run `npx copilotkit@latest login`, then `npx copilotkit@latest project select`. Keep it on the server. Required unless `THREADS_BACKEND=local`. | Chat cannot open a thread (the API refuses to start when the backend is `intelligence`). |
| `CPK_TELEMETRY_ID` | You. Keep the identity generated by the same project selection. | Runtime usage cannot be attributed to the Intelligence signup account. |
| `OPENAI_API_KEY` | You. Used by the default `openai/gpt-5`. Change `MODEL` and supply the matching provider key for Anthropic or Google. | The model call fails. |
| `OPENMUSE_ACCESS_KEY` | Render | You cannot sign in. |
Expand All @@ -129,7 +130,7 @@ Health check: `https://<openmuse-api>/api/health`.

**Standard** is the smallest plan that stays up. At 512 MB the process runs out of memory before it binds a port, because PGlite loads an embedded Postgres build.

**The disk** holds the database, PDFs, and the signing key. A redeploy without it wipes that data. Chat threads are stored by CopilotKit Intelligence, so a thread can still load after you sign back in even when the disk was never attached.
**The disk** holds the database, PDFs, and the signing key. A redeploy without it wipes that data. With the default `THREADS_BACKEND=intelligence`, chat threads are stored by CopilotKit Intelligence, so a thread can still load after you sign back in even when the disk was never attached. With `THREADS_BACKEND=local`, chat threads live on that same disk and are lost with it.

**Live mode** is required. Render binds `0.0.0.0`, and sample mode rejects any host that is not loopback. The Blueprint sets `WORKSPACE_MODE=live`.

Expand All @@ -142,8 +143,8 @@ The Docker computer and Google mail or calendar need the setup in the sections b
Copy the commented settings in [.env.example](.env.example) into your private `.env`:

1. Set `AGENT_BACKEND=model`, `MODEL=provider/model-id`, and the matching provider key. CopilotKit supports the configured OpenAI, Anthropic or Google provider. Fictional data can still be used with a real model. Provider keys stay on the server.
2. Create or select a CopilotKit Intelligence project with `npx copilotkit@latest login` and `npx copilotkit@latest project select`. Keep the generated `CPK_INTELLIGENCE_API_KEY` on the server.
3. For personal mail/calendar, set `WORKSPACE_MODE=live`, the generated `CPK_INTELLIGENCE_API_KEY`, a random `OPENMUSE_ACCESS_KEY` of at least 24 characters, and `TOKEN_ENCRYPTION_KEY` containing 32 random bytes encoded as base64. Restart the API.
2. Create or select a CopilotKit Intelligence project with `npx copilotkit@latest login` and `npx copilotkit@latest project select`. Keep the generated `CPK_INTELLIGENCE_API_KEY` on the server. (Skip this and set `THREADS_BACKEND=local` to keep threads on your own database instead.)
3. For personal mail/calendar, set `WORKSPACE_MODE=live`, the generated `CPK_INTELLIGENCE_API_KEY` (unless threads are local), a random `OPENMUSE_ACCESS_KEY` of at least 24 characters, and `TOKEN_ENCRYPTION_KEY` containing 32 random bytes encoded as base64. Restart the API.
4. Configure a Google OAuth web client with Gmail and Calendar APIs enabled. Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`; register `${PUBLIC_API_URL}/api/google/callback` as its redirect URI. Configure consent/test-user access in your Google project.
5. Open **Apps → Gmail** (or **Google Calendar**), connect read access, and grant write access when needed. Every send or calendar change still requires its own stored review. Changing/disconnecting the account invalidates pending connection-bound work.

Expand Down Expand Up @@ -189,7 +190,9 @@ No hidden retry occurs after an uncertain external write. Review its provider ou

## CopilotKit Rich Threads

Every deployment requires `CPK_INTELLIGENCE_API_KEY` on the API server for CopilotKit Intelligence conversation persistence and replay. Create or select a project with `npx copilotkit@latest login` and `npx copilotkit@latest project select`, set the generated server-only key, and restart the API. The native menu uses `useThreads`; rich tool results link back to saved tasks, documents, and browser sessions.
By default every deployment requires `CPK_INTELLIGENCE_API_KEY` on the API server for CopilotKit Intelligence conversation persistence and replay. Create or select a project with `npx copilotkit@latest login` and `npx copilotkit@latest project select`, set the generated server-only key, and restart the API. The native menu uses `useThreads`; rich tool results link back to saved tasks, documents, and browser sessions.

Set `THREADS_BACKEND=local` to run fully self-hosted instead: threads persist in the server's own database with no Intelligence key and no Intelligence requests. Local mode supports the main conversation, side chats, listing, replay, and clear; rename, archive, and realtime metadata remain Intelligence-only and are hidden in the native UI. [Backend selection, durability boundary and capability matrix](docs/PLUGGABLE-THREADS.md).

Intelligence is a separate service and is not included in this repository's MIT license. No project key is shipped. [Configuration and validation boundaries](docs/RICH-THREADS.md).

Expand All @@ -203,7 +206,7 @@ The CopilotKit SDK sends runtime telemetry through the shared CopilotKit sink to
flowchart TD
Client[Expo / React Native / Web] -->|AG-UI and authenticated API| API[Hono + CopilotKit runtime]
API --> Tasks[Durable task worker]
API --> Threads[CopilotKit Intelligence required in every mode]
API --> Threads[CopilotKit Intelligence (default) or THREADS_BACKEND=local]
API --> Store[(PGlite or PostgreSQL)]
Tasks --> Store
Tasks --> Review[Stored action review]
Expand Down
171 changes: 115 additions & 56 deletions apps/mobile/src/threads.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useThreads } from "@copilotkit/react-native/headless";
import {
Archive,
ArchiveRestore,
CalendarDays,
FileText,
MessageCircle,
Monitor,
Plus,
RefreshCw,
Settings2,
Trash2,
} from "lucide-react-native";
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
Expand All @@ -32,6 +34,7 @@ const ThreadContext = createContext<{
retry: () => void;
select: (selection: Selection) => void;
start: () => void;
removeVisited: (id: string) => void;
claimPrompt: (id: number) => boolean;
} | null>(null);
export function ThreadsProvider({ children }: { children: ReactNode }) {
Expand Down Expand Up @@ -88,6 +91,10 @@ export function ThreadsProvider({ children }: { children: ReactNode }) {
selection,
select,
start: () => select({ id: newThreadId(), existing: false }),
removeVisited: (id) => {
setVisited((items) => items.filter((item) => item.id !== id));
if (selection.id === id) select({ id: mainId, existing: true });
},
}}
>
{children}
Expand All @@ -99,6 +106,81 @@ export function useMuseThread() {
if (!context) throw new Error("Threads provider is unavailable");
return context;
}

type ThreadSummary = { id: string; name: string | null; archived: boolean };

/**
* One saved conversation row. The layout matches the Intelligence backend:
* the title sits on its own line, and a row of small labeled buttons
* (Rename / Archive|Restore / Delete) sits beneath it.
*/
function ThreadRow({
thread,
selected,
editing,
busy,
draft,
onDraft,
onOpen,
onSave,
onArchive,
onDelete,
}: {
thread: ThreadSummary;
selected: boolean;
editing: boolean;
busy: boolean;
draft: string;
onDraft: (value: string) => void;
onOpen: () => void;
onSave: () => void;
onArchive: () => void;
onDelete: () => void;
}) {
const title = thread.name || "Untitled conversation";
return (
<View
style={{
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.line,
gap: 10,
}}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Open conversation: ${title}`}
accessibilityState={{ selected }}
onPress={onOpen}
style={[s.row, { gap: 10 }]}
>
<MessageCircle size={19} color={colors.text} />
<Text style={[s.text, { flex: 1 }]} numberOfLines={1} ellipsizeMode="tail">
{title}
</Text>
</Pressable>
{editing && (
<Field label="Conversation name" value={draft} onChangeText={onDraft} autoFocus />
)}
<View style={[s.row, { gap: 8 }]}>
<Button small disabled={busy || (editing && !draft.trim())} onPress={onSave}>
{editing ? "Save name" : "Rename"}
</Button>
<Button
small
icon={thread.archived ? ArchiveRestore : Archive}
disabled={busy}
onPress={onArchive}
>
{thread.archived ? "Restore" : "Archive"}
</Button>
<Button small icon={Trash2} danger disabled={busy} onPress={onDelete}>
Delete
</Button>
</View>
</View>
);
}
export function ThreadsSheet({ onClose }: { onClose: () => void }) {
const {
enabled,
Expand All @@ -110,6 +192,7 @@ export function ThreadsSheet({ onClose }: { onClose: () => void }) {
retry,
select,
start,
removeVisited,
} = useMuseThread();
const { workspace, open, navigate, refresh } = useWorkspace();
const threads = useThreads({ agentId: "default", enabled, includeArchived: true, limit: 20 });
Expand Down Expand Up @@ -201,64 +284,40 @@ export function ThreadsSheet({ onClose }: { onClose: () => void }) {
{threads.threads
.filter((thread) => thread.id !== mainId && thread.archived === archived)
.map((thread) => (
<View
<ThreadRow
key={thread.id}
style={{
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.line,
gap: 10,
thread={thread}
selected={selection.id === thread.id}
editing={editing === thread.id}
busy={threads.isMutating}
draft={name}
onDraft={setName}
onOpen={() => {
select({ id: thread.id, existing: true });
onClose();
}}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Open conversation: ${thread.name || "Untitled conversation"}`}
accessibilityState={{ selected: selection.id === thread.id }}
onPress={() => {
select({ id: thread.id, existing: true });
onClose();
}}
style={[s.row, { gap: 10 }]}
>
<MessageCircle size={19} color={colors.text} />
<Text style={[s.text, { flex: 1 }]}>
{thread.name || "Untitled conversation"}
</Text>
</Pressable>
{editing === thread.id && (
<Field label="Conversation name" value={name} onChangeText={setName} />
)}
<View style={[s.row, { gap: 8 }]}>
<Button
small
disabled={threads.isMutating || (editing === thread.id && !name.trim())}
onPress={() => {
if (editing === thread.id)
void mutate(() => threads.renameThread(thread.id, name.trim()));
else {
setEditing(thread.id);
setName(thread.name || "");
}
}}
>
{editing === thread.id ? "Save name" : "Rename"}
</Button>
<Button
small
icon={Archive}
disabled={threads.isMutating}
onPress={() =>
void mutate(() =>
thread.archived
? threads.unarchiveThread(thread.id)
: threads.archiveThread(thread.id),
)
}
>
{thread.archived ? "Restore" : "Archive"}
</Button>
</View>
</View>
onSave={() => {
if (editing === thread.id)
void mutate(() => threads.renameThread(thread.id, name.trim()));
else {
setEditing(thread.id);
setName(thread.name || "");
}
}}
onArchive={() =>
void mutate(() =>
thread.archived
? threads.unarchiveThread(thread.id)
: threads.archiveThread(thread.id),
)
}
onDelete={() =>
void mutate(async () => {
await threads.deleteThread(thread.id);
removeVisited(thread.id);
})
}
/>
))}
{!threads.isLoading &&
!threads.error &&
Expand Down
19 changes: 15 additions & 4 deletions apps/mobile/src/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,28 +162,39 @@ export function IconButton({
icon: Icon,
label,
onPress,
disabled,
small,
danger,
}: {
icon: LucideIcon;
label: string;
onPress: () => void;
disabled?: boolean;
small?: boolean;
danger?: boolean;
}) {
const color = danger ? colors.danger : colors.text;
const size = small ? 32 : 44;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled: !!disabled }}
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
{
width: 44,
height: 44,
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
borderRadius: 22,
borderRadius: small ? 16 : 22,
backgroundColor: pressed ? colors.line : "#FFFFFF",
},
disabled && { opacity: 0.4 },
]}
>
<Icon size={20} strokeWidth={1.8} color={colors.text} />
<Icon size={small ? 16 : 20} strokeWidth={1.8} color={color} />
</Pressable>
);
}
Expand Down
Loading
Loading