- }
- title="Android phones"
- description="Phones connected to this computer over USB or Wi-Fi. Agents see the screen, tap, type, open apps and run commands — you watch live and can take over any time."
- actions={
- <>
- refresh.mutate()} disabled={refresh.isPending || !adb?.installed}>
- {refresh.isPending ? : }
-
- setWifiOpen(true)} disabled={!adb?.installed || off}>
- Connect over Wi-Fi
-
- >
- }
- />
-
- {off ? (
- }
- title="Android phones are turned off"
- description="Turn them on in Settings → Android phones to let Godmode look for phones and agents control them."
- />
- ) : status.isLoading || list.isLoading ? (
-
+
+
+
{devices.length ? `${connected} of ${devices.length} connected` : "Phones"}
{[0, 1].map((i) => (
))}
@@ -126,7 +114,7 @@ export default function AndroidPage() {
description={
status.data?.canInstall
? "adb is Android's free debugging bridge from Google. Godmode installs its own copy (about 16 MB) — nothing else on your computer changes."
- : "Install Android's platform-tools (adb) and make sure it's on your PATH, or set its path in Settings → Android phones."
+ : "Install Android's platform-tools (adb) and make sure it's on your PATH, or set its path below."
}
action={
status.data?.canInstall && (
@@ -196,10 +184,6 @@ export default function AndroidPage() {
/>
) : (
<>
-
diff --git a/apps/desktop/src/components/artifacts/artifact-view.tsx b/apps/desktop/src/components/artifacts/artifact-view.tsx
index 2bf1d8c6..60c542fa 100644
--- a/apps/desktop/src/components/artifacts/artifact-view.tsx
+++ b/apps/desktop/src/components/artifacts/artifact-view.tsx
@@ -383,7 +383,7 @@ export function ArtifactView({
onOpenChange={setDeleting}
onDeleted={() => {
onClose?.();
- if (variant === "page") actions.navigate("/artifacts");
+ if (variant === "page") actions.navigate("/settings/artifacts");
}}
/>
diff --git a/apps/desktop/src/pages/artifacts/artifacts-page.tsx b/apps/desktop/src/components/artifacts/artifacts-overview.tsx
similarity index 80%
rename from apps/desktop/src/pages/artifacts/artifacts-page.tsx
rename to apps/desktop/src/components/artifacts/artifacts-overview.tsx
index 9bd534b4..8ebdc284 100644
--- a/apps/desktop/src/pages/artifacts/artifacts-page.tsx
+++ b/apps/desktop/src/components/artifacts/artifacts-overview.tsx
@@ -6,7 +6,7 @@ import { toast } from "sonner";
import type { ArtifactSummary } from "@godmode/shared";
import { ARTIFACT_STARTERS, MASCOT_CHARACTER, MASCOT_COLOR } from "@godmode/shared";
import { AppWindow, ArrowUp, MoreHorizontal, Pin, PinOff, Search, Trash2 } from "lucide-react";
-import { AgentAvatar, EmptyState, PageBody, PageHeader } from "@/components/common";
+import { AgentAvatar, EmptyState } from "@/components/common";
import { Character } from "@/components/character";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -22,7 +22,8 @@ import { useAgents, useArtifact, useArtifacts } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import { useUi } from "@/stores/ui";
-export default function ArtifactsPage() {
+/** Dashboards and small apps the agents built: a line to ask for a new one, then the gallery. */
+export function ArtifactsOverview() {
const { data: artifacts, isLoading } = useArtifacts();
const [query, setQuery] = useState("");
const list = useMemo(() => {
@@ -33,52 +34,45 @@ export default function ArtifactsPage() {
const empty = !isLoading && (artifacts?.length ?? 0) === 0;
return (
-
- }
- title="Artifacts"
- description="Dashboards and small apps your agents build for you. Open one, use it, and ask for changes in plain words."
- />
-
-
+
- ) : empty ? (
- }
- title="Nothing built yet"
- description="Describe a dashboard or a tool above, or pick a starting point. Agents also build artifacts when you ask for one in any chat."
- />
- ) : (
-
-
+ ) : empty ? (
+ }
+ title="Nothing built yet"
+ description="Describe a dashboard or a tool above, or pick a starting point. Agents also build artifacts when you ask for one in any chat."
+ />
+ ) : (
+
+
- }
- title="Messaging"
- description="Talk to your agents from Slack, Telegram and Microsoft Teams — they answer right where you already chat."
- />
-
+
-
-
- {s?.error && (
-
- {s.error}
-
- )}
-
- }
- actions={
-
-
- Manage phones
-
-
- }
- >
-
- set({ enabled })} />
-
-
-
- }
- description="Let agents sign in to apps and websites on a phone with the logins and 2FA codes from your vault."
- >
-
- void setVaultFill(on)} />
-
-
- }>Only agents that may see passwords
- }>Passwords only into password fields
- }>Every fill audited
- } caution>
- Not bound to an app
-
- } caution>
- Readable through the shell
-
-
-
-
- }
- description="Android Debug Bridge, from Google's platform-tools. Godmode reaches the phones through it."
- actions={
- s && !s.adb.installed && s.canInstall ? (
- install.mutate()} disabled={install.isPending}>
- {install.isPending && } {install.isPending ? "Installing…" : "Install"}
-
- ) : undefined
- }
- >
-
- {s ? (
-
- {s.adb.installed
- ? [s.adb.version ? `adb ${s.adb.version}` : "Installed", s.adb.managed ? "Godmode's own copy" : "found on this computer"].join(" · ")
- : s.canInstall
- ? "Not installed — Godmode can download it from Google"
- : "Not installed — install Android's platform-tools"}
-
- ) : (
- Checking…
+ }
+ points={[
+ { icon: , text: "Phones and emulators over USB or Wi-Fi" },
+ { icon: , text: "Watch live and take over any time" },
+ { icon: , text: "Only the agents and chats you give a phone" },
+ ]}
+ >
+
+ }
+ description="Let agents sign in to apps and websites on a phone with the logins and 2FA codes from your vault."
+ >
+
+ void setVaultFill(on)} />
+
+
+ }>Only agents that may see passwords
+ }>Passwords only into password fields
+ }>Every fill audited
+ } caution>
+ Not bound to an app
+
+ } caution>
+ Readable through the shell
+
+
+ );
+}
diff --git a/apps/desktop/src/components/settings/artifacts-section.tsx b/apps/desktop/src/components/settings/artifacts-section.tsx
new file mode 100644
index 00000000..19532a5c
--- /dev/null
+++ b/apps/desktop/src/components/settings/artifacts-section.tsx
@@ -0,0 +1,25 @@
+import { AppWindow, History, LayoutDashboard, PanelRight } from "lucide-react";
+import type { Settings } from "@godmode/shared";
+import { ArtifactsOverview } from "@/components/artifacts/artifacts-overview";
+import { useEasyMode } from "@/lib/mode";
+import { FeatureSection } from "./feature-section";
+
+export function ArtifactsSection({ settings }: { settings: Settings }) {
+ const easy = useEasyMode();
+ return (
+ }
+ points={[
+ { icon: , text: "Dashboards, trackers, planners and calculators" },
+ { icon: , text: "Opens next to the chat it was built in" },
+ { icon: , text: "Every change is a version you can go back to" },
+ ]}
+ >
+
+
+ );
+}
diff --git a/apps/desktop/src/components/settings/calls-section.tsx b/apps/desktop/src/components/settings/calls-section.tsx
index c4ad8228..8fe7c0f4 100644
--- a/apps/desktop/src/components/settings/calls-section.tsx
+++ b/apps/desktop/src/components/settings/calls-section.tsx
@@ -192,7 +192,7 @@ function SetupStatus({ setup }: { setup: ReturnType }) {
) : (
<>
No ElevenLabs key yet. Add one under{" "}
-
+
Integrations → API keys
.
diff --git a/apps/desktop/src/components/settings/feature-section.tsx b/apps/desktop/src/components/settings/feature-section.tsx
new file mode 100644
index 00000000..d60b7fd5
--- /dev/null
+++ b/apps/desktop/src/components/settings/feature-section.tsx
@@ -0,0 +1,88 @@
+import { useId, type ReactNode } from "react";
+import { motion } from "motion/react";
+import type { Settings } from "@godmode/shared";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
+import { useSettingsPatch } from "./settings-kit";
+
+export const FEATURES = ["artifacts", "integrations", "messaging", "android"] as const;
+export type Feature = (typeof FEATURES)[number];
+
+export const featureOn = (settings: Pick | undefined, feature: Feature) => !!settings?.[feature].enabled;
+
+/**
+ * An optional feature's home in Settings: its switch and what it does at the top, then the feature itself — or, while
+ * it is off, what it would bring and a way to turn it on.
+ */
+export function FeatureSection({
+ feature,
+ settings,
+ title,
+ summary,
+ icon,
+ points,
+ children,
+}: {
+ feature: Feature;
+ settings: Settings;
+ title: string;
+ summary: string;
+ icon: ReactNode;
+ points: { icon: ReactNode; text: string }[];
+ children: ReactNode;
+}) {
+ const { patch } = useSettingsPatch();
+ const on = featureOn(settings, feature);
+ const id = useId();
+ const set = (enabled: boolean) => patch({ [feature]: { enabled } });
+ const off = `${title} ${title.endsWith("s") ? "are" : "is"} off`;
+
+ return (
+
+
+
+
{title}
+
{summary}
+
+
+
+
+ {on ? (
+
+ {children}
+
+ ) : (
+
+
{icon}
+
{off}
+
Agents don't get its tools while it's off. Turn it on whenever you need it.
+
+ {points.map((p) => (
+
+ {p.icon}
+ {p.text}
+
+ ))}
+
+ set(true)}>
+ Turn on {title}
+
+
+ )}
+
+ );
+}
diff --git a/apps/desktop/src/components/settings/integrations-section.tsx b/apps/desktop/src/components/settings/integrations-section.tsx
new file mode 100644
index 00000000..4ca83cec
--- /dev/null
+++ b/apps/desktop/src/components/settings/integrations-section.tsx
@@ -0,0 +1,25 @@
+import { Boxes, Plug, Server, Wrench } from "lucide-react";
+import type { Settings } from "@godmode/shared";
+import { IntegrationsOverview } from "@/components/integrations/integrations-overview";
+import { useEasyMode } from "@/lib/mode";
+import { FeatureSection } from "./feature-section";
+
+export function IntegrationsSection({ settings }: { settings: Settings }) {
+ const easy = useEasyMode();
+ return (
+ }
+ points={[
+ { icon: , text: "Gmail, Slack, Notion and more through Composio" },
+ { icon: , text: "Any API with a key, without agents seeing it" },
+ { icon: , text: "Your own MCP servers" },
+ ]}
+ >
+
+
+ );
+}
diff --git a/apps/desktop/src/components/settings/messaging-section.tsx b/apps/desktop/src/components/settings/messaging-section.tsx
new file mode 100644
index 00000000..cc4d97b7
--- /dev/null
+++ b/apps/desktop/src/components/settings/messaging-section.tsx
@@ -0,0 +1,25 @@
+import { KeyRound, MessageCircle, MessagesSquare, UserCheck } from "lucide-react";
+import type { Settings } from "@godmode/shared";
+import { MessagingOverview } from "@/components/messaging/messaging-overview";
+import { useEasyMode } from "@/lib/mode";
+import { FeatureSection } from "./feature-section";
+
+export function MessagingSection({ settings }: { settings: Settings }) {
+ const easy = useEasyMode();
+ return (
+ }
+ points={[
+ { icon: , text: "Slack, Telegram and Microsoft Teams" },
+ { icon: , text: "Private: you approve who may write" },
+ { icon: , text: "Bot tokens sealed in your vault" },
+ ]}
+ >
+
+
+ );
+}
diff --git a/apps/desktop/src/components/settings/voice-section.tsx b/apps/desktop/src/components/settings/voice-section.tsx
index b5b41d1e..c5c13f46 100644
--- a/apps/desktop/src/components/settings/voice-section.tsx
+++ b/apps/desktop/src/components/settings/voice-section.tsx
@@ -294,7 +294,7 @@ export function VoiceSection({ settings }: { settings: Settings }) {
{(usesOpenAi || v.ttsProvider === "elevenlabs") && (
} title="API keys live in the vault">
Add your {[usesOpenAi && "OpenAI", v.ttsProvider === "elevenlabs" && "ElevenLabs"].filter(Boolean).join(" and ")} key under{" "}
-
+
Integrations → API keys
. Keys are encrypted and never shown again.
diff --git a/apps/desktop/src/components/vault/passwords-header.tsx b/apps/desktop/src/components/vault/passwords-header.tsx
new file mode 100644
index 00000000..37871b7f
--- /dev/null
+++ b/apps/desktop/src/components/vault/passwords-header.tsx
@@ -0,0 +1,57 @@
+import type { ReactNode } from "react";
+import { NavLink } from "react-router";
+import { useQuery } from "@tanstack/react-query";
+import { Fingerprint, KeyRound, ShieldCheck } from "lucide-react";
+import { PageHeader } from "@/components/common";
+import { api } from "@/lib/api";
+import { qk } from "@/lib/queryKeys";
+import { cn } from "@/lib/utils";
+
+export const PASSWORD_TABS = ["/vault/logins", "/vault/2fa", "/vault/passkeys"] as const;
+
+/** One screen for what agents sign in with: the title, a tab each for logins, 2FA codes and passkeys, and the tab's actions. */
+export function PasswordsHeader({ actions }: { actions?: ReactNode }) {
+ const logins = useQuery({ queryKey: qk.credentialList("all", ""), queryFn: () => api.credentials.list({ workspaceId: "all" }) });
+ const codes = useQuery({ queryKey: qk.totpList("all"), queryFn: () => api.totp.list({ workspaceId: "all" }) });
+ const passkeys = useQuery({ queryKey: qk.passkeyList("all"), queryFn: () => api.passkeys.list({ workspaceId: "all" }) });
+ const tabs = [
+ { to: PASSWORD_TABS[0], label: "Logins", icon: , count: logins.data?.length },
+ { to: PASSWORD_TABS[1], label: "2FA codes", icon: , count: codes.data?.length },
+ { to: PASSWORD_TABS[2], label: "Passkeys", icon: , count: passkeys.data?.length },
+ ];
+ return (
+ <>
+ }
+ title="Passwords"
+ description="The logins, 2FA codes and passkeys your agents sign in with. Encrypted on this computer; agents never see the secrets."
+ actions={actions}
+ className="pb-4 @2xl:pb-5"
+ />
+
+
+
+ >
+ );
+}
diff --git a/apps/desktop/src/lib/mode.ts b/apps/desktop/src/lib/mode.ts
index 0acfc57a..0adfef2e 100644
--- a/apps/desktop/src/lib/mode.ts
+++ b/apps/desktop/src/lib/mode.ts
@@ -20,18 +20,13 @@ export function useInterfaceMode(): InterfaceMode {
/** Easy mode titles and descriptions for the top-level pages, by path (the sidebar uses the same names). */
export const EASY_PAGES: Record = {
- "/artifacts": { title: "Dashboards & apps" },
"/tasks": { title: "Task board" },
"/automations": { description: "Let your agents start work by themselves: on a schedule, or when something happens in your apps." },
"/files": { description: "What your agents keep: their files, notes and memory. Look through it, tidy it up, and put back what you deleted." },
"/activity": { title: "History", description: "Everything your agents did." },
"/calls": { title: "Phone calls" },
"/plugins": { title: "Add-ons", description: "Extra skills for your agents. Add one, switch it off or remove it any time." },
- "/vault/logins": { title: "Passwords", description: "The logins your agents may use. Encrypted on this computer; agents never see the passwords." },
- "/vault/2fa": { title: "2FA codes" },
"/vault/cards": { title: "Payment cards" },
- "/integrations": { title: "Connected apps", description: "Optional: your agents already sign in to your websites with your saved logins and 2FA codes. Connect an app only so they react the moment something arrives." },
- "/messaging": { title: "Chat apps", description: "Talk to your agents from Slack, Telegram or Microsoft Teams." },
"/computer": { title: "Computer control" },
"/ssh": { title: "Servers" },
"/runners": { title: "Other computers" },
diff --git a/apps/desktop/src/pages/android/android-device-page.tsx b/apps/desktop/src/pages/android/android-device-page.tsx
index 7d3895ad..5f44584c 100644
--- a/apps/desktop/src/pages/android/android-device-page.tsx
+++ b/apps/desktop/src/pages/android/android-device-page.tsx
@@ -208,7 +208,7 @@ export default function AndroidDevicePage() {
description={list.isError ? errorMessage(list.error) : "It may have been forgotten. Plug it in again and it shows up as a new phone."}
action={
-
+
All phones
@@ -223,7 +223,7 @@ export default function AndroidDevicePage() {
);
diff --git a/apps/desktop/src/pages/vault/logins-page.tsx b/apps/desktop/src/pages/vault/logins-page.tsx
index 1d70fbdf..29af12d2 100644
--- a/apps/desktop/src/pages/vault/logins-page.tsx
+++ b/apps/desktop/src/pages/vault/logins-page.tsx
@@ -7,12 +7,13 @@ import type { Credential, TotpEntry } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
-import { EmptyState, Kbd, PageBody, PageHeader } from "@/components/common";
+import { EmptyState, Kbd, PageBody } from "@/components/common";
import { usePageScroll } from "@/components/layout/page-scroll";
import { CredentialDialog, type CredentialPrefill } from "@/components/vault/credential-dialog";
import { CREDENTIAL_GRID, CredentialRow } from "@/components/vault/credential-row";
import { ConfirmDeleteDialog } from "@/components/vault/confirm-dialog";
import { PasswordImportDialog } from "@/components/vault/password-import-dialog";
+import { PasswordsHeader } from "@/components/vault/passwords-header";
import { ScopeFilterSelect } from "@/components/vault/workspace-select";
import { useDebouncedValue } from "@/components/vault/use-debounced-value";
import { isVaultLocked, toastApiError } from "@/components/vault/vault-utils";
@@ -118,10 +119,7 @@ export default function LoginsPage() {
return (
- }
- title="Logins"
- description="Website logins your agents can use — encrypted in your local vault."
+ setImportOpen(true)}>
diff --git a/apps/desktop/src/pages/vault/passkeys-page.tsx b/apps/desktop/src/pages/vault/passkeys-page.tsx
index d4fa34e2..23a2a005 100644
--- a/apps/desktop/src/pages/vault/passkeys-page.tsx
+++ b/apps/desktop/src/pages/vault/passkeys-page.tsx
@@ -8,7 +8,8 @@ import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { EmptyState, PageBody, PageHeader, ScopeBadge, StatusDot } from "@/components/common";
+import { EmptyState, PageBody, ScopeBadge, StatusDot } from "@/components/common";
+import { PasswordsHeader } from "@/components/vault/passwords-header";
import { ConfirmDeleteDialog } from "@/components/vault/confirm-dialog";
import { Favicon } from "@/components/vault/favicon";
import { ScopeFilterSelect } from "@/components/vault/workspace-select";
@@ -59,10 +60,7 @@ export default function PasskeysPage() {
return (
- }
- title="Passkeys"
- description="Passkeys sites created in Godmode's browser. Each belongs to the browser profile it was made in, like its cookies, and agents sign in with it on their own — no phone, fingerprint or security key."
+
diff --git a/apps/desktop/src/pages/vault/totp-page.tsx b/apps/desktop/src/pages/vault/totp-page.tsx
index c304737c..aa160d94 100644
--- a/apps/desktop/src/pages/vault/totp-page.tsx
+++ b/apps/desktop/src/pages/vault/totp-page.tsx
@@ -7,7 +7,8 @@ import type { TotpEntry } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { EmptyState, PageBody, PageHeader } from "@/components/common";
+import { EmptyState, PageBody } from "@/components/common";
+import { PasswordsHeader } from "@/components/vault/passwords-header";
import { TotpCard, TotpCardSkeleton } from "@/components/vault/totp-card";
import { TotpEditDialog } from "@/components/vault/totp-edit-dialog";
import { TotpImportDialog } from "@/components/vault/totp-import-dialog";
@@ -104,10 +105,7 @@ export default function TotpPage() {
return (
- }
- title="2FA Codes"
- description="Authenticator codes your agents type in during sign-in — so two-factor never needs you."
+
diff --git a/docs/screenshots/simpler-sidebar/passwords-2fa-dark.png b/docs/screenshots/simpler-sidebar/passwords-2fa-dark.png
new file mode 100644
index 00000000..064b9130
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/passwords-2fa-dark.png differ
diff --git a/docs/screenshots/simpler-sidebar/passwords-logins.png b/docs/screenshots/simpler-sidebar/passwords-logins.png
new file mode 100644
index 00000000..f7611925
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/passwords-logins.png differ
diff --git a/docs/screenshots/simpler-sidebar/passwords-passkeys.png b/docs/screenshots/simpler-sidebar/passwords-passkeys.png
new file mode 100644
index 00000000..b9f1542c
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/passwords-passkeys.png differ
diff --git a/docs/screenshots/simpler-sidebar/settings-android-off-dark.png b/docs/screenshots/simpler-sidebar/settings-android-off-dark.png
new file mode 100644
index 00000000..b1af43e9
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/settings-android-off-dark.png differ
diff --git a/docs/screenshots/simpler-sidebar/settings-android-on.png b/docs/screenshots/simpler-sidebar/settings-android-on.png
new file mode 100644
index 00000000..321e1f64
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/settings-android-on.png differ
diff --git a/docs/screenshots/simpler-sidebar/settings-artifacts-off.png b/docs/screenshots/simpler-sidebar/settings-artifacts-off.png
new file mode 100644
index 00000000..8a469c1f
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/settings-artifacts-off.png differ
diff --git a/docs/screenshots/simpler-sidebar/settings-artifacts-on.png b/docs/screenshots/simpler-sidebar/settings-artifacts-on.png
new file mode 100644
index 00000000..f67f1e29
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/settings-artifacts-on.png differ
diff --git a/docs/screenshots/simpler-sidebar/settings-easy.png b/docs/screenshots/simpler-sidebar/settings-easy.png
new file mode 100644
index 00000000..e4bdbe79
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/settings-easy.png differ
diff --git a/docs/screenshots/simpler-sidebar/settings-integrations-on.png b/docs/screenshots/simpler-sidebar/settings-integrations-on.png
new file mode 100644
index 00000000..de315e72
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/settings-integrations-on.png differ
diff --git a/docs/screenshots/simpler-sidebar/settings-messaging-on.png b/docs/screenshots/simpler-sidebar/settings-messaging-on.png
new file mode 100644
index 00000000..d4131df8
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/settings-messaging-on.png differ
diff --git a/docs/screenshots/simpler-sidebar/sidebar-easy.png b/docs/screenshots/simpler-sidebar/sidebar-easy.png
new file mode 100644
index 00000000..0f92aa4c
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/sidebar-easy.png differ
diff --git a/docs/screenshots/simpler-sidebar/sidebar-pro.png b/docs/screenshots/simpler-sidebar/sidebar-pro.png
new file mode 100644
index 00000000..674e9b71
Binary files /dev/null and b/docs/screenshots/simpler-sidebar/sidebar-pro.png differ
diff --git a/packages/core/src/backup/backup.ts b/packages/core/src/backup/backup.ts
index 2127233b..2db11868 100644
--- a/packages/core/src/backup/backup.ts
+++ b/packages/core/src/backup/backup.ts
@@ -25,7 +25,7 @@ import { config, VERSION } from "../config";
import { all, get, getDb, run as exec } from "../db";
import { recoverInterruptedRuns } from "../runner/runner";
import { leaveInterruptedWork } from "../services/resume";
-import { SPEND_BACKFILL_SQL, SPEND_TRACKING_SQL, TEAM_BACKFILL_SQL, TICKET_FACTS_SQL } from "../db/migrations";
+import { FEATURE_SWITCHES_SQL, SPEND_BACKFILL_SQL, SPEND_TRACKING_SQL, TEAM_BACKFILL_SQL, TICKET_FACTS_SQL } from "../db/migrations";
import { startBudgets, stopBudgets } from "../services/budgets";
import { repairReportingLines } from "../agents/service";
import { bus } from "../events/bus";
@@ -789,6 +789,8 @@ export function importBackup(file: Uint8Array, passphrase: string, actor = "user
getDb().run(SPEND_BACKFILL_SQL);
// A backup from before spend tracking: on where it had budgets.
getDb().run(SPEND_TRACKING_SQL);
+ // A backup from before the feature switches: on for the features it uses.
+ getDb().run(FEATURE_SWITCHES_SQL);
exec("UPDATE automation_events SET status = 'skipped', note = 'Restored from a backup' WHERE status = 'pending'");
exec("DELETE FROM followups WHERE due_at <= ?", new Date().toISOString());
// Paused runs come back paused; none continues by itself after a restore.
diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts
index 6469b9ed..e1f15ea0 100644
--- a/packages/core/src/cloud/scope.ts
+++ b/packages/core/src/cloud/scope.ts
@@ -32,7 +32,7 @@ const S: Rule = "secrets";
const refused = (message: string): Rule => ({ refused: message });
/** Settings groups a cloud user may change. Machine, access and security settings stay on the computer; a new group is refused until it is added here. */
-const CLOUD_SETTINGS_GROUPS = new Set(["general", "runner", "browser", "computer", "vm", "voice", "memory", "diagnostics", "onboardingComplete"]);
+const CLOUD_SETTINGS_GROUPS = new Set(["general", "runner", "browser", "computer", "vm", "artifacts", "integrations", "messaging", "voice", "memory", "diagnostics", "onboardingComplete"]);
/** The voice provider's address is where the vault's OpenAI key is sent: pointing it elsewhere needs allowSecrets. */
const settingsGroups: Rule = (body) => {
diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts
index d6582cd5..442a1369 100644
--- a/packages/core/src/db/migrations.ts
+++ b/packages/core/src/db/migrations.ts
@@ -57,6 +57,23 @@ SELECT id, agent_id, agent_name, trigger, at, MAX(0, own), duration_ms, failed F
) WHERE id NOT IN (SELECT run_id FROM spend) AND (own > 0 OR duration_ms > 0);
`;
+/**
+ * Artifacts, integrations, messaging and Android phones are off for new installs. An install that already uses one
+ * keeps it on: artifacts exist; API tools, MCP servers, Composio apps or a Composio key are set up; a bot is connected;
+ * a phone was seen (Android was on by default). A section saved before keeps its switch. Idempotent (only settings
+ * without the switch); also run after a restore, because a backup from before migration 105 has no switches.
+ * Migration 105 embeds it.
+ */
+export const FEATURE_SWITCHES_SQL = /* sql */ `
+INSERT OR IGNORE INTO settings (key, value) SELECT 'artifacts', '{"enabled":true}' WHERE EXISTS (SELECT 1 FROM artifacts);
+INSERT OR IGNORE INTO settings (key, value) SELECT 'integrations', '{"enabled":true}' WHERE EXISTS (SELECT 1 FROM api_tools)
+ OR EXISTS (SELECT 1 FROM mcp_servers) OR EXISTS (SELECT 1 FROM composio_connections) OR EXISTS (SELECT 1 FROM secrets WHERE key = 'composio_api_key');
+INSERT OR IGNORE INTO settings (key, value) SELECT 'messaging', '{"enabled":true}' WHERE EXISTS (SELECT 1 FROM messaging_connections);
+INSERT OR IGNORE INTO settings (key, value) SELECT 'android', '{"enabled":true}' WHERE EXISTS (SELECT 1 FROM android_devices);
+UPDATE settings SET value = json_set(value, '$.enabled', json(CASE WHEN EXISTS (SELECT 1 FROM android_devices) THEN 'true' ELSE 'false' END))
+WHERE key = 'android' AND json_valid(value) AND json_type(value, '$.enabled') IS NULL;
+`;
+
/**
* Ordered, append-only SQL migrations. Never edit a shipped migration — add a new one.
* JSON columns are stored as TEXT. Encrypted columns end with `_enc` and hold vault ciphertext.
@@ -1714,4 +1731,9 @@ CREATE TABLE IF NOT EXISTS self_repair_tickets (
);
`,
},
+ {
+ id: 105,
+ name: "feature_switches",
+ sql: FEATURE_SWITCHES_SQL,
+ },
];
diff --git a/packages/core/src/integrations/apiTools.ts b/packages/core/src/integrations/apiTools.ts
index d3569182..e9be58a7 100644
--- a/packages/core/src/integrations/apiTools.ts
+++ b/packages/core/src/integrations/apiTools.ts
@@ -11,6 +11,7 @@ import { all, bool, get, insert, run, update } from "../db";
import { bus } from "../events/bus";
import { logger } from "../log";
import { audit } from "../services/audit";
+import { getSettings } from "../services/settings";
import * as vault from "../vault/vault";
import { badRequest, HttpError, newId, notFound, now, parseJson, slugify } from "../util";
@@ -370,10 +371,11 @@ export function apiToolInAgentScope(tool: { workspaceId: string | null; agentId:
const specificity = (t: ApiTool) => (t.agentId ? 2 : t.workspaceId ? 1 : 0);
/**
- * Enabled tools `agent` may use, most general first. Shared (global / workspace) ones only when the agent inherits
+ * Enabled tools `agent` may use, most general first (none while integrations are off). Shared (global / workspace) ones only when the agent inherits
* shared integrations (`inheritMcp`); tools pinned to the agent always.
*/
export function apiToolsForAgent(agent: Pick): ApiTool[] {
+ if (!getSettings().integrations.enabled) return [];
return all("SELECT * FROM api_tools WHERE enabled = 1 ORDER BY name COLLATE NOCASE, created_at")
.map(toModel)
.filter((t) => apiToolInAgentScope(t, agent) && (t.agentId === agent.id || agent.inheritMcp))
diff --git a/packages/core/src/integrations/mcpServers.ts b/packages/core/src/integrations/mcpServers.ts
index 801c2f4a..4c3d0096 100644
--- a/packages/core/src/integrations/mcpServers.ts
+++ b/packages/core/src/integrations/mcpServers.ts
@@ -13,6 +13,7 @@ import { all, bool, get, insert, run, update } from "../db";
import { bus } from "../events/bus";
import { logger } from "../log";
import { audit } from "../services/audit";
+import { getSettings } from "../services/settings";
import * as vault from "../vault/vault";
import { badRequest, HttpError, newId, notFound, now, parseJson, slugify } from "../util";
import { COMPOSIO_SERVER_NAMES, composioMcpServersForAgent } from "./composio";
@@ -377,13 +378,14 @@ export function mcpServerJson(server: McpServer, secrets: { env: Record> {
const out: Record = {};
+ if (!getSettings().integrations.enabled) return out;
const taken = new Set(RESERVED_NAMES);
for (const server of mcpServerRowsForAgent(agent)) {
let secrets: { env: Record; headers: Record };
diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts
index 37a899da..6672ab19 100644
--- a/packages/core/src/mcp/tools.ts
+++ b/packages/core/src/mcp/tools.ts
@@ -920,8 +920,14 @@ function isDreamRun(ctx: RunContext): boolean {
}
}
-/** Artifacts belong to a chat: not in condition checks, dreams or connected apps, and not on a runner (its chats show on the controller). */
-const canArtifact = (_agent: Agent, ctx: RunContext) => config().role !== "runner" && !!ctx.conversationId && !isCheckRun(ctx) && !isDreamRun(ctx);
+/**
+ * Artifacts belong to a chat: not in condition checks, dreams or connected apps, and not on a runner (its chats show on
+ * the controller). Only while the human has artifacts on.
+ */
+const canArtifact = (_agent: Agent, ctx: RunContext) =>
+ getSettings().artifacts.enabled && config().role !== "runner" && !!ctx.conversationId && !isCheckRun(ctx) && !isDreamRun(ctx);
+
+const androidOn = () => getSettings().android.enabled;
/** Agents change their own artifacts; managers anyone's. */
function artifactRefusal(agent: Agent, id: string): string | null {
@@ -3489,7 +3495,7 @@ Make it look like a polished 2026 product: clean layout, real hierarchy, readabl
description:
"Android phones connected to this computer over adb (and ones seen before): id, name, model, Android version, connected or not, battery, and which agents and chats control them. Also phones waiting for the human to allow USB debugging.",
schema: z.object({}),
- when: isManager,
+ when: (agent) => isManager(agent) && androidOn(),
run: async () => {
const [status, devices] = await Promise.all([androidStatus(), freshDevices()]);
if (!status.adb.installed) return "adb isn't installed on this computer; the human can install it on the Android phones page.";
@@ -3517,7 +3523,7 @@ Make it look like a polished 2026 product: clean layout, real hierarchy, readabl
deviceId: z.string(),
agentId: z.string().describe("The agent that gets the phone"),
}),
- when: managesSetup,
+ when: (agent) => managesSetup(agent) && androidOn(),
run: async ({ deviceId, agentId }, { agent, ctx }) => {
if (lockedVm(ctx)) return fail("This task runs in a virtual machine and is kept off the human's computer, where the phones are plugged in.");
if (agentId === agent.id) return fail("You can't give yourself a phone. Ask the human: they pick phones for a chat in its message box, or for an agent in its settings.");
diff --git a/packages/core/src/messaging/service.ts b/packages/core/src/messaging/service.ts
index 618aa98e..f094bd60 100644
--- a/packages/core/src/messaging/service.ts
+++ b/packages/core/src/messaging/service.ts
@@ -26,6 +26,7 @@ import { all, bool, get, insert, run as exec, update } from "../db";
import { bus } from "../events/bus";
import { logger } from "../log";
import { audit } from "../services/audit";
+import { getSettings } from "../services/settings";
import { notify } from "../services/notifications";
import * as vault from "../vault/vault";
import { sha256 } from "../vault/crypto";
@@ -111,6 +112,8 @@ const notified = new Map();
let unsubscribe: (() => void) | null = null;
let syncing: Promise | null = null;
let syncAgain = false;
+/** The messaging switch (Settings → Messaging) as the last sync saw it: off, no bot runs. */
+let switchedOn: boolean | null = null;
const secretsContext = (id: string) => `messaging_connections.secrets:${id}`;
@@ -598,17 +601,18 @@ async function stopRuntime(id: string) {
}
async function syncOnce() {
+ const on = (switchedOn = getSettings().messaging.enabled);
const rows = all("SELECT * FROM messaging_connections");
const unlocked = vault.isUnlocked();
- const wanted = new Map(rows.filter((r) => bool(r.enabled) && unlocked).map((r) => [r.id, r]));
+ const wanted = new Map(rows.filter((r) => on && bool(r.enabled) && unlocked).map((r) => [r.id, r]));
// Keep running adapters when the vault locks: their tokens are already in memory, and people keep talking.
for (const id of [...runtimes.keys()]) {
const row = rows.find((r) => r.id === id);
- if (!row || !bool(row.enabled) || (unlocked && runtimes.get(id)!.fingerprint !== fingerprintOf(row))) await stopRuntime(id);
+ if (!on || !row || !bool(row.enabled) || (unlocked && runtimes.get(id)!.fingerprint !== fingerprintOf(row))) await stopRuntime(id);
}
for (const id of wanted.keys()) {
const row = connectionRow(id);
- if (runtimes.has(id) || !row || !bool(row.enabled)) continue;
+ if (!getSettings().messaging.enabled || runtimes.has(id) || !row || !bool(row.enabled)) continue;
try {
await startRuntime(row);
startErrors.delete(id);
@@ -644,6 +648,7 @@ function requestSync() {
function onBusEvent(event: ServerEvent) {
if (event.type === "vault.status") requestSync();
+ if (event.type === "entity.changed" && event.entity === "settings" && getSettings().messaging.enabled !== switchedOn) requestSync();
if (event.type === "question.updated" && event.question.answer) {
void followAnsweredRun(event.question).catch((err) => log.warn("could not follow an answered run", err));
}
diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts
index 70c4b6dd..eca9e27b 100644
--- a/packages/core/src/runner/runner.ts
+++ b/packages/core/src/runner/runner.ts
@@ -2215,7 +2215,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise = {
browser: ["headless", "keepAliveMinutes", "stealth", "muteAudio"],
computer: ["agentCursor", "liveViewFps", "screenshotMaxSize"],
vm: ["onQuit", "idleStopMinutes"],
+ // A dashboard the human asks for needs artifacts; integrations, messaging and phones reach outside: the human's.
+ artifacts: ["enabled"],
voice: ["enabled", "autoSpeak", "language", "sttModel", "ttsModel", "ttsVoice", "elevenlabsVoiceId", "browserVoice", "rate"],
// Agents may set calls up (switch, number, the owner's phone); quiet hours, model, length and what the voice tells
// callers stay the human's.
diff --git a/packages/core/src/services/artifacts.ts b/packages/core/src/services/artifacts.ts
index 75be0d23..599d2dd4 100644
--- a/packages/core/src/services/artifacts.ts
+++ b/packages/core/src/services/artifacts.ts
@@ -19,9 +19,10 @@ import type {
import { MAX_ARTIFACT_DATA, MAX_ARTIFACT_HTML, MAX_ARTIFACT_VERSIONS } from "@godmode/shared";
import { all, get, insert, run as sql, tx } from "../db";
import { bus } from "../events/bus";
-import { badRequest, newId, notFound, now, parseJson } from "../util";
+import { badRequest, conflict, newId, notFound, now, parseJson } from "../util";
import { getDefaultAgentId } from "../agents/service";
import { audit } from "./audit";
+import { getSettings } from "./settings";
import { conversationExists, startChat } from "./conversations";
import { hasQueued, submitMessage } from "./messageQueue";
import { activeRunForConversation } from "../runner/runner";
@@ -377,6 +378,7 @@ export async function refreshArtifact(id: string): Promise {
+ if (!getSettings().artifacts.enabled) throw conflict("Artifacts are turned off. Turn them on in Settings → Artifacts.");
const prompt = input.prompt.trim();
if (!prompt) throw badRequest("Describe what to build");
const started = await startChat({
diff --git a/packages/core/src/services/settings.ts b/packages/core/src/services/settings.ts
index dbed9af2..49102cb5 100644
--- a/packages/core/src/services/settings.ts
+++ b/packages/core/src/services/settings.ts
@@ -68,10 +68,13 @@ export const DEFAULT_SETTINGS: Settings = {
tartPath: "",
},
android: {
- enabled: true,
+ enabled: false,
vaultFill: false,
adbPath: "",
},
+ artifacts: { enabled: false },
+ integrations: { enabled: false },
+ messaging: { enabled: false },
voice: {
enabled: true,
sttProvider: "browser",
diff --git a/packages/core/src/services/settingsPatch.ts b/packages/core/src/services/settingsPatch.ts
index 6df74475..231c0725 100644
--- a/packages/core/src/services/settingsPatch.ts
+++ b/packages/core/src/services/settingsPatch.ts
@@ -114,6 +114,10 @@ export function checkSettingsPatch(patch: Record, needsGrant: (
throw badRequest("The adb binary must be an absolute path (or empty)");
}
}
+ for (const feature of ["artifacts", "integrations", "messaging"] as const) {
+ const value = patch[feature] as Record | undefined;
+ if (isObject(value) && value.enabled !== undefined && typeof value.enabled !== "boolean") throw badRequest(`${feature}.enabled must be true or false`);
+ }
const maintenance = patch.maintenance as Record | undefined;
if (maintenance !== undefined) {
if (typeof maintenance !== "object" || maintenance === null || Array.isArray(maintenance)) throw badRequest("Invalid upkeep settings");
diff --git a/packages/core/test/android.test.ts b/packages/core/test/android.test.ts
index b34b69c6..150b9f0f 100644
--- a/packages/core/test/android.test.ts
+++ b/packages/core/test/android.test.ts
@@ -73,7 +73,7 @@ beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), "godmode-fake-adb-"));
process.env.FAKE_ADB_DIR = dir;
writeState("", {});
- updateSettings({ android: { adbPath: FAKE_ADB } });
+ updateSettings({ android: { adbPath: FAKE_ADB, enabled: true } });
agent = await makeAgent({ name: "Phone Operator" });
});
diff --git a/packages/core/test/api-tools.test.ts b/packages/core/test/api-tools.test.ts
index 3199ad74..0c57efca 100644
--- a/packages/core/test/api-tools.test.ts
+++ b/packages/core/test/api-tools.test.ts
@@ -13,7 +13,7 @@ import { createWorkspace, deleteWorkspace } from "../src/services/workspaces";
import { updateAgent } from "../src/agents/service";
import { buildEnv } from "../src/runner/runner";
import { buildSystemPrompt, resumeContextPrefix } from "../src/runner/prompt";
-import { getSettings } from "../src/services/settings";
+import { getSettings, updateSettings } from "../src/services/settings";
import {
apiToolEnv,
apiToolKey,
@@ -78,6 +78,7 @@ async function gateway(agent: Agent, name: string, args: unknown = {}) {
beforeAll(async () => {
env = await setupEnv("godmode-api-tools-");
await vault.setup(PASSPHRASE, false);
+ updateSettings({ integrations: { enabled: true } });
api = Bun.serve({
hostname: "127.0.0.1",
port: 0,
diff --git a/packages/core/test/artifacts.test.ts b/packages/core/test/artifacts.test.ts
index c77e8bbe..d2b256e1 100644
--- a/packages/core/test/artifacts.test.ts
+++ b/packages/core/test/artifacts.test.ts
@@ -6,6 +6,7 @@ import { deleteConversation, getConversation, startChat } from "../src/services/
import { applyEdits, getArtifact, listArtifacts, listArtifactVersions } from "../src/services/artifacts";
import { listActiveRuns, listRuns, waitForRun } from "../src/runner/runner";
import { getAccessToken } from "../src/server/auth";
+import { updateSettings } from "../src/services/settings";
let env: TestEnv;
let agent: Agent;
@@ -13,6 +14,7 @@ let other: Agent;
beforeAll(async () => {
env = await setupEnv("godmode-artifacts-");
+ updateSettings({ artifacts: { enabled: true } });
agent = await makeAgent({ name: "Builder" });
other = await makeAgent({ name: "Other" });
});
diff --git a/packages/core/test/composio.test.ts b/packages/core/test/composio.test.ts
index 91149b9b..f05604a6 100644
--- a/packages/core/test/composio.test.ts
+++ b/packages/core/test/composio.test.ts
@@ -7,7 +7,7 @@ import { Hono } from "hono";
import { loadConfig } from "../src/config";
import { closeDb, get, getMeta, insert, openDb, run } from "../src/db";
import { setLogLevel } from "../src/log";
-import { resetSettingsCache } from "../src/services/settings";
+import { resetSettingsCache, updateSettings } from "../src/services/settings";
import * as vault from "../src/vault/vault";
import * as composio from "../src/integrations/composio";
import { createMcpServer, mcpServersForAgent } from "../src/integrations/mcpServers";
@@ -122,6 +122,7 @@ beforeAll(async () => {
composio.resetComposioState();
vault.lock();
await vault.setup(PASSPHRASE, false);
+ updateSettings({ integrations: { enabled: true } });
const ts = new Date().toISOString();
insert("workspaces", { id: "ws_sales", name: "Sales", slug: "sales", created_at: ts, updated_at: ts });
insert("agents", { id: "agt_x", workspace_id: "ws_sales", name: "X", slug: "x", repo_path: join(dataDir, "agents", "x"), created_at: ts, updated_at: ts });
diff --git a/packages/core/test/feature-switches.test.ts b/packages/core/test/feature-switches.test.ts
new file mode 100644
index 00000000..5b72629d
--- /dev/null
+++ b/packages/core/test/feature-switches.test.ts
@@ -0,0 +1,184 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import type { Agent } from "@godmode/shared";
+import { makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness";
+import { getDb, insert, run as sql } from "../src/db";
+import { FEATURE_SWITCHES_SQL, MIGRATIONS } from "../src/db/migrations";
+import { getAccessToken } from "../src/server/auth";
+import { getSettings, resetSettingsCache, updateSettings } from "../src/services/settings";
+import { callTool, listToolsFor } from "../src/mcp/tools";
+import { createApiTool, deleteApiTool } from "../src/integrations/apiTools";
+import { mcpServersForAgent } from "../src/integrations/mcpServers";
+import * as vault from "../src/vault/vault";
+import { newId, now } from "../src/util";
+
+let env: TestEnv;
+let manager: Agent;
+
+const FEATURES = ["artifacts", "integrations", "messaging", "android"] as const;
+type Feature = (typeof FEATURES)[number];
+
+const api = (method: string, path: string, body?: unknown) =>
+ fetch(`${env.baseUrl}${path}`, {
+ method,
+ headers: { Authorization: `Bearer ${getAccessToken()}`, "Content-Type": "application/json" },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+
+const switches = () => Object.fromEntries(FEATURES.map((f) => [f, getSettings()[f].enabled])) as Record;
+const allOff = { artifacts: false, integrations: false, messaging: false, android: false };
+
+/** Runs the migration again on whatever the tables hold now, the way an upgrade or a restore does. */
+function migrate(): Record {
+ getDb().run(FEATURE_SWITCHES_SQL);
+ resetSettingsCache();
+ return switches();
+}
+
+function clear() {
+ for (const table of ["artifacts", "api_tools", "mcp_servers", "composio_connections", "messaging_connections", "android_devices"]) sql(`DELETE FROM ${table}`);
+ sql("DELETE FROM secrets WHERE key = 'composio_api_key'");
+ sql("DELETE FROM settings WHERE key IN ('artifacts', 'integrations', 'messaging', 'android')");
+ resetSettingsCache();
+}
+
+function chatRun(agent: Agent) {
+ const conversationId = newId("cnv");
+ insert("conversations", { id: conversationId, agent_id: agent.id, title: "x", origin: "chat", created_at: now(), updated_at: now() });
+ const runId = newId("run");
+ insert("runs", { id: runId, agent_id: agent.id, conversation_id: conversationId, trigger: "chat", status: "running", prompt: "x", created_at: now() });
+ return { runId, agentId: agent.id, conversationId, workspaceId: null, depth: 0 };
+}
+
+const toolNames = (agent: Agent) => listToolsFor(agent, chatRun(agent)).map((t) => t.name);
+
+beforeAll(async () => {
+ env = await setupEnv("godmode-features-");
+ await vault.setup("correct horse battery staple", false);
+ manager = await makeAgent({ name: "Boss", permissions: { canManageAgents: true, allowDelegation: true } });
+});
+
+afterAll(async () => {
+ await env.close();
+});
+
+describe("new installs", () => {
+ test("start with every optional feature off", () => {
+ clear();
+ expect(switches()).toEqual(allOff);
+ expect(migrate()).toEqual(allOff);
+ });
+});
+
+describe("existing installs", () => {
+ test("migration 105 is the backfill", () => {
+ expect(MIGRATIONS.find((m) => m.name === "feature_switches")!.sql).toBe(FEATURE_SWITCHES_SQL);
+ expect(MIGRATIONS.find((m) => m.name === "feature_switches")!.id).toBe(105);
+ });
+
+ test("keep artifacts on when there are artifacts", () => {
+ clear();
+ insert("artifacts", { id: "art_1", title: "Sales", html: "