diff --git a/lib/public/js/components/general/agent-defaults.js b/lib/public/js/components/general/agent-defaults.js
new file mode 100644
index 00000000..1c3816b3
--- /dev/null
+++ b/lib/public/js/components/general/agent-defaults.js
@@ -0,0 +1,121 @@
+import { h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import htm from "htm";
+import { fetchAgentDefaults, updateAgentDefaults } from "../../lib/api.js";
+import { setCached } from "../../lib/api-cache.js";
+import { useCachedFetch } from "../../hooks/use-cached-fetch.js";
+import { ChevronDownIcon } from "../icons.js";
+import { ToggleSwitch } from "../toggle-switch.js";
+import { showToast } from "../toast.js";
+
+const html = htm.bind(h);
+
+const kAgentDefaultsCacheKey = "/api/agent-defaults";
+const kThinkingOptions = [
+ { value: "off", label: "Off" },
+ { value: "low", label: "Low" },
+ { value: "medium", label: "Medium" },
+ { value: "high", label: "High" },
+];
+
+export const AgentDefaults = () => {
+ const { data, loading } = useCachedFetch(
+ kAgentDefaultsCacheKey,
+ fetchAgentDefaults,
+ { maxAgeMs: 30000 },
+ );
+ const serverDefaults = data?.agentDefaults || null;
+ const [savingKey, setSavingKey] = useState("");
+ const [thinking, setThinking] = useState("medium");
+ const [dreaming, setDreaming] = useState(false);
+
+ useEffect(() => {
+ if (!serverDefaults) return;
+ if (typeof serverDefaults.thinking === "string") {
+ setThinking(serverDefaults.thinking);
+ }
+ if (typeof serverDefaults.dreaming === "boolean") {
+ setDreaming(serverDefaults.dreaming);
+ }
+ }, [serverDefaults?.thinking, serverDefaults?.dreaming]);
+
+ const save = async (key, payload, optimistic) => {
+ const previous = key === "thinking" ? thinking : dreaming;
+ optimistic();
+ setSavingKey(key);
+ try {
+ const response = await updateAgentDefaults(payload);
+ if (!response.ok) {
+ throw new Error(response.error || "Could not save agent defaults");
+ }
+ const next = response.agentDefaults || {};
+ if (typeof next.thinking === "string") setThinking(next.thinking);
+ if (typeof next.dreaming === "boolean") setDreaming(next.dreaming);
+ setCached(kAgentDefaultsCacheKey, { ...(data || {}), ...response });
+ showToast("Agent defaults saved", "success");
+ } catch (err) {
+ if (key === "thinking") setThinking(previous);
+ else setDreaming(previous);
+ showToast(err.message || "Could not save agent defaults", "error");
+ } finally {
+ setSavingKey("");
+ }
+ };
+
+ const handleThinkingChange = (event) => {
+ const nextThinking = String(event.target.value || "");
+ save("thinking", { thinking: nextThinking }, () => setThinking(nextThinking));
+ };
+
+ const handleDreamingChange = (nextDreaming) => {
+ save("dreaming", { dreaming: nextDreaming }, () => setDreaming(nextDreaming));
+ };
+
+ const disabled = loading && !serverDefaults;
+
+ return html`
+
+
Agent Defaults
+
+
+
+
Thinking default
+
Default reasoning depth for agent calls.
+
+
+
+ <${ChevronDownIcon}
+ className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted"
+ />
+
+
+
+
+
Dreaming
+
Allow the agent to run background ideation between turns.
+
+ <${ToggleSwitch}
+ checked=${dreaming}
+ disabled=${disabled || savingKey === "dreaming"}
+ onChange=${handleDreamingChange}
+ label=${dreaming ? "On" : "Off"}
+ />
+
+
+
+ `;
+};
diff --git a/lib/public/js/components/general/index.js b/lib/public/js/components/general/index.js
index b508c92d..76283e28 100644
--- a/lib/public/js/components/general/index.js
+++ b/lib/public/js/components/general/index.js
@@ -11,6 +11,7 @@ import { Features } from "../features.js";
import { GeneralDoctorWarning } from "../doctor/general-warning.js";
import { ChevronDownIcon } from "../icons.js";
import { UpdateActionButton } from "../update-action-button.js";
+import { AgentDefaults } from "./agent-defaults.js";
import { useGeneralTab } from "./use-general-tab.js";
const html = htm.bind(h);
@@ -131,6 +132,7 @@ export const GeneralTab = ({
`}
/>
<${Features} onSwitchTab=${onSwitchTab} />
+ <${AgentDefaults} />
<${Google}
gatewayStatus=${state.gatewayStatus}
onRestartRequired=${onRestartRequired}
diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js
index 324851b4..a32c7778 100644
--- a/lib/public/js/lib/api.js
+++ b/lib/public/js/lib/api.js
@@ -516,6 +516,40 @@ export async function updateAlphaclaw() {
return res.json();
}
+export async function fetchAgentDefaults() {
+ const res = await authFetch("/api/agent-defaults");
+ const text = await res.text();
+ let data;
+ try {
+ data = text ? JSON.parse(text) : {};
+ } catch {
+ throw new Error(text || "Could not parse agent defaults response");
+ }
+ if (!res.ok) {
+ throw new Error(data.error || text || `HTTP ${res.status}`);
+ }
+ return data;
+}
+
+export async function updateAgentDefaults(payload) {
+ const res = await authFetch("/api/agent-defaults", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ const text = await res.text();
+ let data;
+ try {
+ data = text ? JSON.parse(text) : {};
+ } catch {
+ throw new Error(text || "Could not parse agent defaults response");
+ }
+ if (!res.ok) {
+ throw new Error(data.error || text || `HTTP ${res.status}`);
+ }
+ return data;
+}
+
export async function fetchSyncCron() {
const res = await authFetch("/api/sync-cron");
const text = await res.text();
diff --git a/lib/server/agent-defaults-config.js b/lib/server/agent-defaults-config.js
new file mode 100644
index 00000000..8586837e
--- /dev/null
+++ b/lib/server/agent-defaults-config.js
@@ -0,0 +1,107 @@
+const fs = require("fs");
+const {
+ readOpenclawConfig,
+ resolveOpenclawConfigPath,
+ writeOpenclawConfig,
+} = require("./openclaw-config");
+
+class MalformedOpenclawConfigError extends Error {
+ constructor(configPath) {
+ super(
+ `Refusing to overwrite ${configPath}: file exists but could not be parsed as JSON. Inspect or restore it before changing agent defaults.`,
+ );
+ this.code = "MALFORMED_OPENCLAW_CONFIG";
+ }
+}
+
+const kThinkingLevels = Object.freeze(["off", "low", "medium", "high"]);
+const kDefaultThinkingLevel = "medium";
+const kDefaultDreamingEnabled = false;
+
+const isThinkingLevel = (value) =>
+ typeof value === "string" && kThinkingLevels.includes(value);
+
+const normalizeAgentDefaults = (rawConfig = {}) => {
+ const config =
+ rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)
+ ? rawConfig
+ : {};
+ const raw =
+ config.agentDefaults &&
+ typeof config.agentDefaults === "object" &&
+ !Array.isArray(config.agentDefaults)
+ ? config.agentDefaults
+ : {};
+ return {
+ thinking: isThinkingLevel(raw.thinking) ? raw.thinking : kDefaultThinkingLevel,
+ dreaming: typeof raw.dreaming === "boolean" ? raw.dreaming : kDefaultDreamingEnabled,
+ };
+};
+
+const readAgentDefaults = ({ fsModule = fs, openclawDir } = {}) => {
+ const config = readOpenclawConfig({
+ fsModule,
+ openclawDir,
+ fallback: {},
+ });
+ return normalizeAgentDefaults(config);
+};
+
+const writeAgentDefaults = ({
+ fsModule = fs,
+ openclawDir,
+ thinking,
+ dreaming,
+} = {}) => {
+ const configPath = resolveOpenclawConfigPath({ openclawDir });
+ const exists =
+ typeof fsModule.existsSync === "function"
+ ? fsModule.existsSync(configPath)
+ : null;
+ const config = readOpenclawConfig({
+ fsModule,
+ openclawDir,
+ fallback: exists === true ? null : {},
+ });
+ if (exists === true && config === null) {
+ throw new MalformedOpenclawConfigError(configPath);
+ }
+ const safeConfig =
+ config && typeof config === "object" && !Array.isArray(config) ? config : {};
+ const before = JSON.stringify(safeConfig.agentDefaults || null);
+ const next = normalizeAgentDefaults(safeConfig);
+ if (typeof thinking === "string") {
+ if (!isThinkingLevel(thinking)) {
+ throw new Error(
+ `thinking must be one of: ${kThinkingLevels.join(", ")}`,
+ );
+ }
+ next.thinking = thinking;
+ }
+ if (typeof dreaming === "boolean") {
+ next.dreaming = dreaming;
+ }
+ safeConfig.agentDefaults = next;
+ const after = JSON.stringify(safeConfig.agentDefaults);
+ const changed = before !== after;
+ if (changed) {
+ writeOpenclawConfig({
+ fsModule,
+ openclawDir,
+ config: safeConfig,
+ spacing: 2,
+ });
+ }
+ return { changed, agentDefaults: next };
+};
+
+module.exports = {
+ MalformedOpenclawConfigError,
+ kThinkingLevels,
+ kDefaultThinkingLevel,
+ kDefaultDreamingEnabled,
+ isThinkingLevel,
+ normalizeAgentDefaults,
+ readAgentDefaults,
+ writeAgentDefaults,
+};
diff --git a/lib/server/constants.js b/lib/server/constants.js
index 242dd61b..32802652 100644
--- a/lib/server/constants.js
+++ b/lib/server/constants.js
@@ -408,6 +408,7 @@ const SETUP_API_PREFIXES = [
"/api/channels",
"/api/operations",
"/api/nodes",
+ "/api/agent-defaults",
];
module.exports = {
diff --git a/lib/server/doctor/service.js b/lib/server/doctor/service.js
index 678004e2..2c5915bf 100644
--- a/lib/server/doctor/service.js
+++ b/lib/server/doctor/service.js
@@ -15,6 +15,7 @@ const {
kDoctorRunTimeoutMs,
kDoctorStaleThresholdMs,
} = require("./constants");
+const { readAgentDefaults } = require("../agent-defaults-config");
const kMaxSnippetLines = 20;
@@ -214,12 +215,13 @@ const createDoctorService = ({
promptVersion: kDoctorPromptVersion,
});
const gatewayTimeoutMs = kDoctorRunTimeoutMs + 30000;
+ const { thinking } = readAgentDefaults({ openclawDir: managedRoot });
const gatewayParams = {
agentId: "main",
idempotencyKey: buildDoctorIdempotencyKey(runId),
message: prompt,
sessionKey: buildDoctorSessionKey(runId),
- thinking: "medium",
+ thinking,
timeout: Math.round(kDoctorRunTimeoutMs / 1000),
};
const result = await clawCmd(
diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js
index 28817a91..e16020af 100644
--- a/lib/server/routes/system.js
+++ b/lib/server/routes/system.js
@@ -1,5 +1,12 @@
const { buildManagedPaths } = require("../internal-files-migration");
const { readOpenclawConfig } = require("../openclaw-config");
+const {
+ MalformedOpenclawConfigError,
+ kThinkingLevels,
+ isThinkingLevel,
+ readAgentDefaults,
+ writeAgentDefaults,
+} = require("../agent-defaults-config");
const https = require("https");
const registerSystemRoutes = ({
@@ -576,6 +583,57 @@ const registerSystemRoutes = ({
res.json({ ok: true, syncCron: status });
});
+ app.get("/api/agent-defaults", (req, res) => {
+ try {
+ const agentDefaults = readAgentDefaults({
+ fsModule: fs,
+ openclawDir: OPENCLAW_DIR,
+ });
+ res.json({ ok: true, agentDefaults, thinkingLevels: kThinkingLevels });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: err.message });
+ }
+ });
+
+ app.put("/api/agent-defaults", (req, res) => {
+ const { thinking, dreaming } = req.body || {};
+ if (thinking !== undefined && !isThinkingLevel(thinking)) {
+ return res.status(400).json({
+ ok: false,
+ error: `thinking must be one of: ${kThinkingLevels.join(", ")}`,
+ });
+ }
+ if (dreaming !== undefined && typeof dreaming !== "boolean") {
+ return res
+ .status(400)
+ .json({ ok: false, error: "dreaming must be a boolean" });
+ }
+ if (thinking === undefined && dreaming === undefined) {
+ return res.status(400).json({
+ ok: false,
+ error: "At least one of thinking or dreaming must be provided",
+ });
+ }
+ try {
+ const result = writeAgentDefaults({
+ fsModule: fs,
+ openclawDir: OPENCLAW_DIR,
+ thinking,
+ dreaming,
+ });
+ res.json({
+ ok: true,
+ changed: result.changed,
+ agentDefaults: result.agentDefaults,
+ });
+ } catch (err) {
+ if (err instanceof MalformedOpenclawConfigError) {
+ return res.status(409).json({ ok: false, error: err.message });
+ }
+ res.status(500).json({ ok: false, error: err.message });
+ }
+ });
+
app.get("/api/alphaclaw/version", async (req, res) => {
const refresh = String(req.query.refresh || "") === "1";
const status = await alphaclawVersionService.getVersionStatus(refresh);
diff --git a/tests/server/agent-defaults-config.test.js b/tests/server/agent-defaults-config.test.js
new file mode 100644
index 00000000..4eb70316
--- /dev/null
+++ b/tests/server/agent-defaults-config.test.js
@@ -0,0 +1,138 @@
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+
+const {
+ MalformedOpenclawConfigError,
+ kThinkingLevels,
+ kDefaultThinkingLevel,
+ kDefaultDreamingEnabled,
+ normalizeAgentDefaults,
+ readAgentDefaults,
+ writeAgentDefaults,
+} = require("../../lib/server/agent-defaults-config");
+
+const createTempOpenclawDir = () =>
+ fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-agent-defaults-test-"));
+
+describe("server/agent-defaults-config", () => {
+ it("falls back to safe defaults when openclaw.json is missing or empty", () => {
+ const openclawDir = createTempOpenclawDir();
+
+ expect(readAgentDefaults({ fsModule: fs, openclawDir })).toEqual({
+ thinking: kDefaultThinkingLevel,
+ dreaming: kDefaultDreamingEnabled,
+ });
+ });
+
+ it("normalizes invalid stored values to safe defaults", () => {
+ expect(
+ normalizeAgentDefaults({
+ agentDefaults: { thinking: "ultra", dreaming: "yes" },
+ }),
+ ).toEqual({
+ thinking: kDefaultThinkingLevel,
+ dreaming: kDefaultDreamingEnabled,
+ });
+ });
+
+ it("reads stored values verbatim when valid", () => {
+ const openclawDir = createTempOpenclawDir();
+ fs.writeFileSync(
+ path.join(openclawDir, "openclaw.json"),
+ JSON.stringify({
+ agentDefaults: { thinking: "high", dreaming: true },
+ }),
+ );
+
+ expect(readAgentDefaults({ fsModule: fs, openclawDir })).toEqual({
+ thinking: "high",
+ dreaming: true,
+ });
+ });
+
+ it("writes only the keys provided and preserves existing siblings", () => {
+ const openclawDir = createTempOpenclawDir();
+ const configPath = path.join(openclawDir, "openclaw.json");
+ fs.writeFileSync(
+ configPath,
+ JSON.stringify({
+ tools: { profile: "full" },
+ agentDefaults: { thinking: "low", dreaming: true },
+ }),
+ );
+
+ const result = writeAgentDefaults({
+ fsModule: fs,
+ openclawDir,
+ thinking: "high",
+ });
+
+ expect(result.changed).toBe(true);
+ expect(result.agentDefaults).toEqual({ thinking: "high", dreaming: true });
+ const stored = JSON.parse(fs.readFileSync(configPath, "utf8"));
+ expect(stored).toEqual({
+ tools: { profile: "full" },
+ agentDefaults: { thinking: "high", dreaming: true },
+ });
+ });
+
+ it("reports changed=false when the write is a no-op", () => {
+ const openclawDir = createTempOpenclawDir();
+ fs.writeFileSync(
+ path.join(openclawDir, "openclaw.json"),
+ JSON.stringify({
+ agentDefaults: { thinking: "medium", dreaming: false },
+ }),
+ );
+
+ const result = writeAgentDefaults({
+ fsModule: fs,
+ openclawDir,
+ thinking: "medium",
+ dreaming: false,
+ });
+
+ expect(result.changed).toBe(false);
+ });
+
+ it("rejects invalid thinking values", () => {
+ const openclawDir = createTempOpenclawDir();
+ expect(() =>
+ writeAgentDefaults({ fsModule: fs, openclawDir, thinking: "max" }),
+ ).toThrow(/thinking must be one of/);
+ });
+
+ it("exposes the canonical thinking level list", () => {
+ expect(kThinkingLevels).toEqual(["off", "low", "medium", "high"]);
+ });
+
+ it("refuses to overwrite when openclaw.json exists but is malformed", () => {
+ const openclawDir = createTempOpenclawDir();
+ const configPath = path.join(openclawDir, "openclaw.json");
+ fs.writeFileSync(configPath, "{ malformed:");
+
+ expect(() =>
+ writeAgentDefaults({ fsModule: fs, openclawDir, thinking: "high" }),
+ ).toThrow(MalformedOpenclawConfigError);
+
+ expect(fs.readFileSync(configPath, "utf8")).toBe("{ malformed:");
+ });
+
+ it("creates openclaw.json from scratch when absent", () => {
+ const openclawDir = createTempOpenclawDir();
+ const configPath = path.join(openclawDir, "openclaw.json");
+
+ const result = writeAgentDefaults({
+ fsModule: fs,
+ openclawDir,
+ thinking: "high",
+ dreaming: true,
+ });
+
+ expect(result.changed).toBe(true);
+ expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toEqual({
+ agentDefaults: { thinking: "high", dreaming: true },
+ });
+ });
+});
diff --git a/tests/server/routes-system.test.js b/tests/server/routes-system.test.js
index 2cb044f5..b2cf5d05 100644
--- a/tests/server/routes-system.test.js
+++ b/tests/server/routes-system.test.js
@@ -78,6 +78,7 @@ const createSystemDeps = () => {
restartInProgress: false,
gatewayRunning: true,
})),
+ markRequired: vi.fn(),
markRestartInProgress: vi.fn(),
clearRequired: vi.fn(),
markRestartComplete: vi.fn(),
@@ -666,4 +667,143 @@ describe("server/routes/system", () => {
},
]);
});
+
+ describe("/api/agent-defaults", () => {
+ const buildAgentDefaultsDeps = (storedConfig) => {
+ const deps = createSystemDeps();
+ const stored = { value: storedConfig ? { ...storedConfig } : null };
+ deps.fs.readFileSync = vi.fn((filePath) => {
+ if (
+ typeof filePath === "string" &&
+ filePath.endsWith("openclaw.json") &&
+ stored.value
+ ) {
+ return JSON.stringify(stored.value);
+ }
+ throw new Error("ENOENT");
+ });
+ deps.fs.writeFileSync = vi.fn((filePath, content) => {
+ if (typeof filePath === "string" && filePath.endsWith("openclaw.json")) {
+ stored.value = JSON.parse(content);
+ }
+ });
+ deps.fs.mkdirSync = vi.fn();
+ return { deps, stored };
+ };
+
+ it("returns defaults when openclaw.json is missing", async () => {
+ const { deps } = buildAgentDefaultsDeps(null);
+ const app = createApp(deps);
+
+ const res = await request(app).get("/api/agent-defaults");
+
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ ok: true,
+ agentDefaults: { thinking: "medium", dreaming: false },
+ thinkingLevels: ["off", "low", "medium", "high"],
+ });
+ });
+
+ it("returns stored agent defaults", async () => {
+ const { deps } = buildAgentDefaultsDeps({
+ agentDefaults: { thinking: "high", dreaming: true },
+ });
+ const app = createApp(deps);
+
+ const res = await request(app).get("/api/agent-defaults");
+
+ expect(res.status).toBe(200);
+ expect(res.body.agentDefaults).toEqual({ thinking: "high", dreaming: true });
+ });
+
+ it("rejects invalid thinking values on PUT", async () => {
+ const { deps } = buildAgentDefaultsDeps(null);
+ const app = createApp(deps);
+
+ const res = await request(app)
+ .put("/api/agent-defaults")
+ .send({ thinking: "max" });
+
+ expect(res.status).toBe(400);
+ expect(res.body.ok).toBe(false);
+ expect(res.body.error).toContain("thinking must be one of");
+ });
+
+ it("rejects non-boolean dreaming values on PUT", async () => {
+ const { deps } = buildAgentDefaultsDeps(null);
+ const app = createApp(deps);
+
+ const res = await request(app)
+ .put("/api/agent-defaults")
+ .send({ dreaming: "yes" });
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toContain("dreaming must be a boolean");
+ });
+
+ it("rejects empty payloads on PUT", async () => {
+ const { deps } = buildAgentDefaultsDeps(null);
+ const app = createApp(deps);
+
+ const res = await request(app).put("/api/agent-defaults").send({});
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toContain("At least one of thinking or dreaming");
+ });
+
+ it("persists changes and preserves unrelated config keys", async () => {
+ const { deps, stored } = buildAgentDefaultsDeps({
+ tools: { profile: "full" },
+ });
+ const app = createApp(deps);
+
+ const res = await request(app)
+ .put("/api/agent-defaults")
+ .send({ thinking: "high", dreaming: true });
+
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ ok: true,
+ changed: true,
+ agentDefaults: { thinking: "high", dreaming: true },
+ });
+ expect(stored.value.agentDefaults).toEqual({
+ thinking: "high",
+ dreaming: true,
+ });
+ expect(stored.value.tools).toEqual({ profile: "full" });
+ });
+
+ it("reports no change for no-op writes", async () => {
+ const { deps } = buildAgentDefaultsDeps({
+ agentDefaults: { thinking: "medium", dreaming: false },
+ });
+ const app = createApp(deps);
+
+ const res = await request(app)
+ .put("/api/agent-defaults")
+ .send({ thinking: "medium", dreaming: false });
+
+ expect(res.status).toBe(200);
+ expect(res.body.changed).toBe(false);
+ });
+
+ it("refuses to overwrite when openclaw.json exists but cannot be parsed", async () => {
+ const { deps } = buildAgentDefaultsDeps(null);
+ deps.fs.existsSync = vi.fn(() => true);
+ deps.fs.readFileSync = vi.fn(() => "{ this is not json");
+ const writeSpy = vi.spyOn(deps.fs, "writeFileSync");
+ const app = createApp(deps);
+
+ const res = await request(app)
+ .put("/api/agent-defaults")
+ .send({ thinking: "high" });
+
+ expect(res.status).toBe(409);
+ expect(res.body.ok).toBe(false);
+ expect(res.body.error).toMatch(/could not be parsed/i);
+ expect(writeSpy).not.toHaveBeenCalled();
+ });
+ });
});