feat: wire multi-agent config and routing

Co-authored-by: Mark Pors <1078320+pors@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-09 12:44:23 +00:00
parent 81beda0772
commit 7b81d97ec2
189 changed files with 4340 additions and 2903 deletions

View File

@@ -31,18 +31,18 @@ function canonicalizeAgentDir(agentDir: string): string {
function collectReferencedAgentIds(cfg: ClawdbotConfig): string[] {
const ids = new Set<string>();
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
const defaultAgentId =
cfg.routing?.defaultAgentId?.trim() || DEFAULT_AGENT_ID;
agents.find((agent) => agent?.default)?.id ??
agents[0]?.id ??
DEFAULT_AGENT_ID;
ids.add(normalizeAgentId(defaultAgentId));
const agents = cfg.routing?.agents;
if (agents && typeof agents === "object") {
for (const id of Object.keys(agents)) {
ids.add(normalizeAgentId(id));
}
for (const entry of agents) {
if (entry?.id) ids.add(normalizeAgentId(entry.id));
}
const bindings = cfg.routing?.bindings;
const bindings = cfg.bindings;
if (Array.isArray(bindings)) {
for (const binding of bindings) {
const id = binding?.agentId;
@@ -61,8 +61,12 @@ function resolveEffectiveAgentDir(
deps?: { env?: NodeJS.ProcessEnv; homedir?: () => string },
): string {
const id = normalizeAgentId(agentId);
const configured = cfg.routing?.agents?.[id]?.agentDir?.trim();
if (configured) return resolveUserPath(configured);
const configured = Array.isArray(cfg.agents?.list)
? cfg.agents?.list.find((agent) => normalizeAgentId(agent.id) === id)
?.agentDir
: undefined;
const trimmed = configured?.trim();
if (trimmed) return resolveUserPath(trimmed);
const root = resolveStateDir(
deps?.env ?? process.env,
deps?.homedir ?? os.homedir,
@@ -102,7 +106,7 @@ export function formatDuplicateAgentDirError(
(d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`,
),
"",
"Fix: remove the shared routing.agents.*.agentDir override (or give each agent its own directory).",
"Fix: remove the shared agents.list[].agentDir override (or give each agent its own directory).",
"If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.",
];
return lines.join("\n");

View File

@@ -80,7 +80,7 @@ describe("config identity defaults", () => {
process.env.HOME = previousHome;
});
it("derives mentionPatterns when identity is set", async () => {
it("does not derive mentionPatterns when identity is set", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -88,9 +88,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
routing: {},
},
null,
2,
@@ -103,13 +113,11 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
});
});
it("defaults ackReaction to identity emoji", async () => {
it("defaults ackReactionScope without setting ackReaction", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -117,7 +125,18 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
},
null,
@@ -130,12 +149,12 @@ describe("config identity defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBe("🦥");
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
it("defaults ackReaction to 👀 when identity is missing", async () => {
it("keeps ackReaction unset when identity is missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -155,7 +174,7 @@ describe("config identity defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBe("👀");
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
@@ -168,17 +187,22 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
},
groupChat: { mentionPatterns: ["@clawd"] },
},
],
},
messages: {
responsePrefix: "✅",
},
routing: {
groupChat: { mentionPatterns: ["@clawd"] },
},
},
null,
2,
@@ -191,7 +215,9 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBe("✅");
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual(["@clawd"]);
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
});
});
@@ -209,7 +235,6 @@ describe("config identity defaults", () => {
// legacy field should be ignored (moved to providers)
textChunkLimit: 9999,
},
routing: {},
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
discord: {
@@ -251,9 +276,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: { responsePrefix: "" },
routing: {},
},
null,
2,
@@ -277,9 +312,7 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" },
messages: {},
routing: {},
},
null,
2,
@@ -292,10 +325,8 @@ describe("config identity defaults", () => {
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
expect(cfg.agent).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents).toBeUndefined();
expect(cfg.session).toBeUndefined();
});
});
@@ -308,9 +339,19 @@ describe("config identity defaults", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
identity: { name: "Clawd", theme: "space lobster", emoji: "🦞" },
agents: {
list: [
{
id: "main",
identity: {
name: "Clawd",
theme: "space lobster",
emoji: "🦞",
},
},
],
},
messages: {},
routing: {},
},
null,
2,
@@ -411,7 +452,7 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({ agent: {} }, null, 2),
JSON.stringify({ agents: { defaults: {} } }, null, 2),
"utf-8",
);
@@ -419,7 +460,7 @@ describe("config pruning defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agent?.contextPruning?.mode).toBe("adaptive");
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive");
});
});
@@ -429,7 +470,11 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({ agent: { contextPruning: { mode: "off" } } }, null, 2),
JSON.stringify(
{ agents: { defaults: { contextPruning: { mode: "off" } } } },
null,
2,
),
"utf-8",
);
@@ -437,7 +482,7 @@ describe("config pruning defaults", () => {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agent?.contextPruning?.mode).toBe("off");
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off");
});
});
});
@@ -850,6 +895,97 @@ describe("legacy config detection", () => {
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { mentionPatterns: ["@clawd"] } },
});
expect(res.changes).toContain(
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
);
expect(res.config?.messages?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
expect(res.config?.routing?.groupChat?.mentionPatterns).toBeUndefined();
});
it("migrates routing agentToAgent/queue/transcribeAudio to tools/messages/audio", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: {
agentToAgent: { enabled: true, allow: ["main"] },
queue: { mode: "queue", cap: 3 },
transcribeAudio: { command: ["echo", "hi"], timeoutSeconds: 2 },
},
});
expect(res.changes).toContain(
"Moved routing.agentToAgent → tools.agentToAgent.",
);
expect(res.changes).toContain("Moved routing.queue → messages.queue.");
expect(res.changes).toContain(
"Moved routing.transcribeAudio → audio.transcription.",
);
expect(res.config?.tools?.agentToAgent).toEqual({
enabled: true,
allow: ["main"],
});
expect(res.config?.messages?.queue).toEqual({
mode: "queue",
cap: 3,
});
expect(res.config?.audio?.transcription).toEqual({
command: ["echo", "hi"],
timeoutSeconds: 2,
});
expect(res.config?.routing).toBeUndefined();
});
it("migrates agent config into agents.defaults and tools", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
agent: {
model: "openai/gpt-5.2",
tools: { allow: ["sessions.list"], deny: ["danger"] },
elevated: { enabled: true, allowFrom: { discord: ["user:1"] } },
bash: { timeoutSec: 12 },
sandbox: { tools: { allow: ["browser.open"] } },
subagents: { tools: { deny: ["sandbox"] } },
},
});
expect(res.changes).toContain("Moved agent.tools.allow → tools.allow.");
expect(res.changes).toContain("Moved agent.tools.deny → tools.deny.");
expect(res.changes).toContain("Moved agent.elevated → tools.elevated.");
expect(res.changes).toContain("Moved agent.bash → tools.bash.");
expect(res.changes).toContain(
"Moved agent.sandbox.tools → tools.sandbox.tools.",
);
expect(res.changes).toContain(
"Moved agent.subagents.tools → tools.subagents.tools.",
);
expect(res.changes).toContain("Moved agent → agents.defaults.");
expect(res.config?.agents?.defaults?.model).toEqual({
primary: "openai/gpt-5.2",
fallbacks: [],
});
expect(res.config?.tools?.allow).toEqual(["sessions.list"]);
expect(res.config?.tools?.deny).toEqual(["danger"]);
expect(res.config?.tools?.elevated).toEqual({
enabled: true,
allowFrom: { discord: ["user:1"] },
});
expect(res.config?.tools?.bash).toEqual({ timeoutSec: 12 });
expect(res.config?.tools?.sandbox?.tools).toEqual({
allow: ["browser.open"],
});
expect(res.config?.tools?.subagents?.tools).toEqual({
deny: ["sandbox"],
});
expect((res.config as { agent?: unknown }).agent).toBeUndefined();
});
it("rejects telegram.requireMention", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
@@ -1064,7 +1200,7 @@ describe("legacy config detection", () => {
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("agent.model");
expect(res.issues.some((i) => i.path === "agent.model")).toBe(true);
}
});
@@ -1095,22 +1231,25 @@ describe("legacy config detection", () => {
},
});
expect(res.config?.agent?.model?.primary).toBe("anthropic/claude-opus-4-5");
expect(res.config?.agent?.model?.fallbacks).toEqual([
expect(res.config?.agents?.defaults?.model?.primary).toBe(
"anthropic/claude-opus-4-5",
);
expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([
"openai/gpt-4.1-mini",
]);
expect(res.config?.agent?.imageModel?.primary).toBe("openai/gpt-4.1-mini");
expect(res.config?.agent?.imageModel?.fallbacks).toEqual([
expect(res.config?.agents?.defaults?.imageModel?.primary).toBe(
"openai/gpt-4.1-mini",
);
expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([
"anthropic/claude-opus-4-5",
]);
expect(
res.config?.agent?.models?.["anthropic/claude-opus-4-5"],
res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"],
).toMatchObject({ alias: "Opus" });
expect(res.config?.agent?.models?.["openai/gpt-4.1-mini"]).toBeTruthy();
expect(res.config?.agent?.allowedModels).toBeUndefined();
expect(res.config?.agent?.modelAliases).toBeUndefined();
expect(res.config?.agent?.modelFallbacks).toBeUndefined();
expect(res.config?.agent?.imageModelFallbacks).toBeUndefined();
expect(
res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"],
).toBeTruthy();
expect(res.config?.agent).toBeUndefined();
});
it("surfaces legacy issues in snapshot", async () => {
@@ -1135,21 +1274,21 @@ describe("legacy config detection", () => {
});
describe("multi-agent agentDir validation", () => {
it("rejects shared routing.agents.*.agentDir", async () => {
it("rejects shared agents.list agentDir", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const shared = path.join(os.tmpdir(), "clawdbot-shared-agentdir");
const res = validateConfigObject({
routing: {
agents: {
a: { agentDir: shared },
b: { agentDir: shared },
},
agents: {
list: [
{ id: "a", agentDir: shared },
{ id: "b", agentDir: shared },
],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path === "routing.agents")).toBe(true);
expect(res.issues.some((i) => i.path === "agents.list")).toBe(true);
expect(res.issues[0]?.message).toContain("Duplicate agentDir");
}
});
@@ -1162,13 +1301,13 @@ describe("multi-agent agentDir validation", () => {
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
routing: {
agents: {
a: { agentDir: "~/.clawdbot/agents/shared/agent" },
b: { agentDir: "~/.clawdbot/agents/shared/agent" },
},
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
agents: {
list: [
{ id: "a", agentDir: "~/.clawdbot/agents/shared/agent" },
{ id: "b", agentDir: "~/.clawdbot/agents/shared/agent" },
],
},
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
},
null,
2,

View File

@@ -24,56 +24,13 @@ export type SessionDefaultsOptions = {
warnState?: WarnState;
};
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const identity = cfg.identity;
if (!identity) return cfg;
const name = identity.name?.trim();
const routing = cfg.routing ?? {};
const groupChat = routing.groupChat ?? {};
let mutated = false;
const next: ClawdbotConfig = { ...cfg };
if (name && !groupChat.mentionPatterns) {
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
const re = parts.length ? parts.join("\\s+") : escapeRegExp(name);
const pattern = `\\b@?${re}\\b`;
next.routing = {
...(next.routing ?? routing),
groupChat: { ...groupChat, mentionPatterns: [pattern] },
};
mutated = true;
}
return mutated ? next : cfg;
}
export function applyMessageDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const messages = cfg.messages;
const hasAckReaction = messages?.ackReaction !== undefined;
const hasAckScope = messages?.ackReactionScope !== undefined;
if (hasAckReaction && hasAckScope) return cfg;
if (hasAckScope) return cfg;
const fallbackEmoji = cfg.identity?.emoji?.trim() || "👀";
const nextMessages = messages ? { ...messages } : {};
let mutated = false;
if (!hasAckReaction) {
nextMessages.ackReaction = fallbackEmoji;
mutated = true;
}
if (!hasAckScope) {
nextMessages.ackReactionScope = "group-mentions";
mutated = true;
}
if (!mutated) return cfg;
nextMessages.ackReactionScope = "group-mentions";
return {
...cfg,
messages: nextMessages,
@@ -119,7 +76,7 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig {
}
export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const existingAgent = cfg.agent;
const existingAgent = cfg.agents?.defaults;
if (!existingAgent) return cfg;
const existingModels = existingAgent.models ?? {};
if (Object.keys(existingModels).length === 0) return cfg;
@@ -141,9 +98,9 @@ export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
return {
...cfg,
agent: {
...existingAgent,
models: nextModels,
agents: {
...cfg.agents,
defaults: { ...existingAgent, models: nextModels },
},
};
}
@@ -164,18 +121,21 @@ export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
export function applyContextPruningDefaults(
cfg: ClawdbotConfig,
): ClawdbotConfig {
const agent = cfg.agent;
if (!agent) return cfg;
const contextPruning = agent?.contextPruning;
const defaults = cfg.agents?.defaults;
if (!defaults) return cfg;
const contextPruning = defaults?.contextPruning;
if (contextPruning?.mode) return cfg;
return {
...cfg,
agent: {
...agent,
contextPruning: {
...contextPruning,
mode: "adaptive",
agents: {
...cfg.agents,
defaults: {
...defaults,
contextPruning: {
...contextPruning,
mode: "adaptive",
},
},
},
};

View File

@@ -14,7 +14,6 @@ import {
} from "./agent-dirs.js";
import {
applyContextPruningDefaults,
applyIdentityDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
@@ -165,9 +164,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
applyContextPruningDefaults(
applySessionDefaults(
applyLoggingDefaults(
applyMessageDefaults(
applyIdentityDefaults(validated.data as ClawdbotConfig),
),
applyMessageDefaults(validated.data as ClawdbotConfig),
),
),
),

View File

@@ -12,53 +12,179 @@ type LegacyConfigMigration = {
apply: (raw: Record<string, unknown>, changes: string[]) => void;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const getRecord = (value: unknown): Record<string, unknown> | null =>
isRecord(value) ? value : null;
const ensureRecord = (
root: Record<string, unknown>,
key: string,
): Record<string, unknown> => {
const existing = root[key];
if (isRecord(existing)) return existing;
const next: Record<string, unknown> = {};
root[key] = next;
return next;
};
const mergeMissing = (
target: Record<string, unknown>,
source: Record<string, unknown>,
) => {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) continue;
const existing = target[key];
if (existing === undefined) {
target[key] = value;
continue;
}
if (isRecord(existing) && isRecord(value)) {
mergeMissing(existing, value);
}
}
};
const getAgentsList = (agents: Record<string, unknown> | null) => {
const list = agents?.list;
return Array.isArray(list) ? list : [];
};
const resolveDefaultAgentIdFromRaw = (raw: Record<string, unknown>) => {
const agents = getRecord(raw.agents);
const list = getAgentsList(agents);
const defaultEntry = list.find(
(entry): entry is { id: string } =>
isRecord(entry) &&
entry.default === true &&
typeof entry.id === "string" &&
entry.id.trim() !== "",
);
if (defaultEntry) return defaultEntry.id.trim();
const routing = getRecord(raw.routing);
const routingDefault =
typeof routing?.defaultAgentId === "string"
? routing.defaultAgentId.trim()
: "";
if (routingDefault) return routingDefault;
const firstEntry = list.find(
(entry): entry is { id: string } =>
isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "",
);
if (firstEntry) return firstEntry.id.trim();
return "main";
};
const ensureAgentEntry = (
list: unknown[],
id: string,
): Record<string, unknown> => {
const normalized = id.trim();
const existing = list.find(
(entry): entry is Record<string, unknown> =>
isRecord(entry) &&
typeof entry.id === "string" &&
entry.id.trim() === normalized,
);
if (existing) return existing;
const created: Record<string, unknown> = { id: normalized };
list.push(created);
return created;
};
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
{
path: ["routing", "allowFrom"],
message:
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "bindings"],
message:
"routing.bindings was moved; use top-level bindings instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "agents"],
message:
"routing.agents was moved; use agents.list instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "defaultAgentId"],
message:
"routing.defaultAgentId was moved; use agents.list[].default instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "agentToAgent"],
message:
"routing.agentToAgent was moved; use tools.agentToAgent instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "groupChat", "requireMention"],
message:
'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdbot doctor` to migrate).',
},
{
path: ["routing", "groupChat", "mentionPatterns"],
message:
"routing.groupChat.mentionPatterns was moved; use agents.list[].groupChat.mentionPatterns or messages.groupChat.mentionPatterns instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "queue"],
message:
"routing.queue was moved; use messages.queue instead (run `clawdbot doctor` to migrate).",
},
{
path: ["routing", "transcribeAudio"],
message:
"routing.transcribeAudio was moved; use audio.transcription instead (run `clawdbot doctor` to migrate).",
},
{
path: ["telegram", "requireMention"],
message:
'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).',
},
{
path: ["identity"],
message:
"identity was moved; use agents.list[].identity instead (run `clawdbot doctor` to migrate).",
},
{
path: ["agent"],
message:
"agent.* was moved; use agents.defaults (and tools.* for tool/elevated/bash settings) instead (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "model"],
message:
"agent.model string was replaced by agent.model.primary/fallbacks and agent.models (run `clawdbot doctor` to migrate).",
"agent.model string was replaced by agents.defaults.model.primary/fallbacks and agents.defaults.models (run `clawdbot doctor` to migrate).",
match: (value) => typeof value === "string",
},
{
path: ["agent", "imageModel"],
message:
"agent.imageModel string was replaced by agent.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
"agent.imageModel string was replaced by agents.defaults.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
match: (value) => typeof value === "string",
},
{
path: ["agent", "allowedModels"],
message:
"agent.allowedModels was replaced by agent.models (run `clawdbot doctor` to migrate).",
"agent.allowedModels was replaced by agents.defaults.models (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "modelAliases"],
message:
"agent.modelAliases was replaced by agent.models.*.alias (run `clawdbot doctor` to migrate).",
"agent.modelAliases was replaced by agents.defaults.models.*.alias (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "modelFallbacks"],
message:
"agent.modelFallbacks was replaced by agent.model.fallbacks (run `clawdbot doctor` to migrate).",
"agent.modelFallbacks was replaced by agents.defaults.model.fallbacks (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "imageModelFallbacks"],
message:
"agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
},
{
path: ["gateway", "token"],
@@ -236,11 +362,11 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
describe:
"Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
apply: (raw, changes) => {
const agent =
raw.agent && typeof raw.agent === "object"
? (raw.agent as Record<string, unknown>)
: null;
const agentRoot = getRecord(raw.agent);
const defaults = getRecord(getRecord(raw.agents)?.defaults);
const agent = agentRoot ?? defaults;
if (!agent) return;
const label = agentRoot ? "agent" : "agents.defaults";
const legacyModel =
typeof agent.model === "string" ? String(agent.model) : undefined;
@@ -358,26 +484,32 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
agent.models = models;
if (legacyModel !== undefined) {
changes.push("Migrated agent.model string → agent.model.primary.");
changes.push(
`Migrated ${label}.model string → ${label}.model.primary.`,
);
}
if (legacyModelFallbacks.length > 0) {
changes.push("Migrated agent.modelFallbacks → agent.model.fallbacks.");
changes.push(
`Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`,
);
}
if (legacyImageModel !== undefined) {
changes.push(
"Migrated agent.imageModel string → agent.imageModel.primary.",
`Migrated ${label}.imageModel string → ${label}.imageModel.primary.`,
);
}
if (legacyImageModelFallbacks.length > 0) {
changes.push(
"Migrated agent.imageModelFallbacks → agent.imageModel.fallbacks.",
`Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`,
);
}
if (legacyAllowed.length > 0) {
changes.push("Migrated agent.allowedModels → agent.models.");
changes.push(`Migrated ${label}.allowedModels → ${label}.models.`);
}
if (Object.keys(legacyAliases).length > 0) {
changes.push("Migrated agent.modelAliases → agent.models.*.alias.");
changes.push(
`Migrated ${label}.modelAliases → ${label}.models.*.alias.`,
);
}
delete agent.allowedModels;
@@ -386,6 +518,311 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
delete agent.imageModelFallbacks;
},
},
{
id: "routing.agents-v2",
describe: "Move routing.agents/defaultAgentId to agents.list",
apply: (raw, changes) => {
const routing = getRecord(raw.routing);
if (!routing) return;
const routingAgents = getRecord(routing.agents);
const agents = ensureRecord(raw, "agents");
const list = getAgentsList(agents);
if (routingAgents) {
for (const [rawId, entryRaw] of Object.entries(routingAgents)) {
const agentId = String(rawId ?? "").trim();
const entry = getRecord(entryRaw);
if (!agentId || !entry) continue;
const target = ensureAgentEntry(list, agentId);
const entryCopy: Record<string, unknown> = { ...entry };
if ("mentionPatterns" in entryCopy) {
const mentionPatterns = entryCopy.mentionPatterns;
const groupChat = ensureRecord(target, "groupChat");
if (groupChat.mentionPatterns === undefined) {
groupChat.mentionPatterns = mentionPatterns;
changes.push(
`Moved routing.agents.${agentId}.mentionPatterns → agents.list (id "${agentId}").groupChat.mentionPatterns.`,
);
} else {
changes.push(
`Removed routing.agents.${agentId}.mentionPatterns (agents.list groupChat mentionPatterns already set).`,
);
}
delete entryCopy.mentionPatterns;
}
const legacyGroupChat = getRecord(entryCopy.groupChat);
if (legacyGroupChat) {
const groupChat = ensureRecord(target, "groupChat");
mergeMissing(groupChat, legacyGroupChat);
delete entryCopy.groupChat;
}
const legacySandbox = getRecord(entryCopy.sandbox);
if (legacySandbox) {
const sandboxTools = getRecord(legacySandbox.tools);
if (sandboxTools) {
const tools = ensureRecord(target, "tools");
const sandbox = ensureRecord(tools, "sandbox");
const toolPolicy = ensureRecord(sandbox, "tools");
mergeMissing(toolPolicy, sandboxTools);
delete legacySandbox.tools;
changes.push(
`Moved routing.agents.${agentId}.sandbox.tools → agents.list (id "${agentId}").tools.sandbox.tools.`,
);
}
entryCopy.sandbox = legacySandbox;
}
mergeMissing(target, entryCopy);
}
delete routing.agents;
changes.push("Moved routing.agents → agents.list.");
}
const defaultAgentId =
typeof routing.defaultAgentId === "string"
? routing.defaultAgentId.trim()
: "";
if (defaultAgentId) {
const hasDefault = list.some(
(entry): entry is Record<string, unknown> =>
isRecord(entry) && entry.default === true,
);
if (!hasDefault) {
const entry = ensureAgentEntry(list, defaultAgentId);
entry.default = true;
changes.push(
`Moved routing.defaultAgentId → agents.list (id "${defaultAgentId}").default.`,
);
} else {
changes.push(
"Removed routing.defaultAgentId (agents.list default already set).",
);
}
delete routing.defaultAgentId;
}
if (list.length > 0) {
agents.list = list;
}
if (Object.keys(routing).length === 0) {
delete raw.routing;
}
},
},
{
id: "routing.config-v2",
describe:
"Move routing bindings/groupChat/queue/agentToAgent/transcribeAudio",
apply: (raw, changes) => {
const routing = getRecord(raw.routing);
if (!routing) return;
if (routing.bindings !== undefined) {
if (raw.bindings === undefined) {
raw.bindings = routing.bindings;
changes.push("Moved routing.bindings → bindings.");
} else {
changes.push("Removed routing.bindings (bindings already set).");
}
delete routing.bindings;
}
if (routing.agentToAgent !== undefined) {
const tools = ensureRecord(raw, "tools");
if (tools.agentToAgent === undefined) {
tools.agentToAgent = routing.agentToAgent;
changes.push("Moved routing.agentToAgent → tools.agentToAgent.");
} else {
changes.push(
"Removed routing.agentToAgent (tools.agentToAgent already set).",
);
}
delete routing.agentToAgent;
}
if (routing.queue !== undefined) {
const messages = ensureRecord(raw, "messages");
if (messages.queue === undefined) {
messages.queue = routing.queue;
changes.push("Moved routing.queue → messages.queue.");
} else {
changes.push("Removed routing.queue (messages.queue already set).");
}
delete routing.queue;
}
const groupChat = getRecord(routing.groupChat);
if (groupChat) {
const historyLimit = groupChat.historyLimit;
if (historyLimit !== undefined) {
const messages = ensureRecord(raw, "messages");
const messagesGroup = ensureRecord(messages, "groupChat");
if (messagesGroup.historyLimit === undefined) {
messagesGroup.historyLimit = historyLimit;
changes.push(
"Moved routing.groupChat.historyLimit → messages.groupChat.historyLimit.",
);
} else {
changes.push(
"Removed routing.groupChat.historyLimit (messages.groupChat.historyLimit already set).",
);
}
delete groupChat.historyLimit;
}
const mentionPatterns = groupChat.mentionPatterns;
if (mentionPatterns !== undefined) {
const messages = ensureRecord(raw, "messages");
const messagesGroup = ensureRecord(messages, "groupChat");
if (messagesGroup.mentionPatterns === undefined) {
messagesGroup.mentionPatterns = mentionPatterns;
changes.push(
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
);
} else {
changes.push(
"Removed routing.groupChat.mentionPatterns (messages.groupChat.mentionPatterns already set).",
);
}
delete groupChat.mentionPatterns;
}
if (Object.keys(groupChat).length === 0) {
delete routing.groupChat;
} else {
routing.groupChat = groupChat;
}
}
if (routing.transcribeAudio !== undefined) {
const audio = ensureRecord(raw, "audio");
if (audio.transcription === undefined) {
audio.transcription = routing.transcribeAudio;
changes.push("Moved routing.transcribeAudio → audio.transcription.");
} else {
changes.push(
"Removed routing.transcribeAudio (audio.transcription already set).",
);
}
delete routing.transcribeAudio;
}
if (Object.keys(routing).length === 0) {
delete raw.routing;
}
},
},
{
id: "agent.defaults-v2",
describe: "Move agent config to agents.defaults and tools",
apply: (raw, changes) => {
const agent = getRecord(raw.agent);
if (!agent) return;
const agents = ensureRecord(raw, "agents");
const defaults = getRecord(agents.defaults) ?? {};
const tools = ensureRecord(raw, "tools");
const agentTools = getRecord(agent.tools);
if (agentTools) {
if (tools.allow === undefined && agentTools.allow !== undefined) {
tools.allow = agentTools.allow;
changes.push("Moved agent.tools.allow → tools.allow.");
}
if (tools.deny === undefined && agentTools.deny !== undefined) {
tools.deny = agentTools.deny;
changes.push("Moved agent.tools.deny → tools.deny.");
}
}
const elevated = getRecord(agent.elevated);
if (elevated) {
if (tools.elevated === undefined) {
tools.elevated = elevated;
changes.push("Moved agent.elevated → tools.elevated.");
} else {
changes.push("Removed agent.elevated (tools.elevated already set).");
}
}
const bash = getRecord(agent.bash);
if (bash) {
if (tools.bash === undefined) {
tools.bash = bash;
changes.push("Moved agent.bash → tools.bash.");
} else {
changes.push("Removed agent.bash (tools.bash already set).");
}
}
const sandbox = getRecord(agent.sandbox);
if (sandbox) {
const sandboxTools = getRecord(sandbox.tools);
if (sandboxTools) {
const toolsSandbox = ensureRecord(tools, "sandbox");
const toolPolicy = ensureRecord(toolsSandbox, "tools");
mergeMissing(toolPolicy, sandboxTools);
delete sandbox.tools;
changes.push("Moved agent.sandbox.tools → tools.sandbox.tools.");
}
}
const subagents = getRecord(agent.subagents);
if (subagents) {
const subagentTools = getRecord(subagents.tools);
if (subagentTools) {
const toolsSubagents = ensureRecord(tools, "subagents");
const toolPolicy = ensureRecord(toolsSubagents, "tools");
mergeMissing(toolPolicy, subagentTools);
delete subagents.tools;
changes.push("Moved agent.subagents.tools → tools.subagents.tools.");
}
}
const agentCopy: Record<string, unknown> = structuredClone(agent);
delete agentCopy.tools;
delete agentCopy.elevated;
delete agentCopy.bash;
if (isRecord(agentCopy.sandbox)) delete agentCopy.sandbox.tools;
if (isRecord(agentCopy.subagents)) delete agentCopy.subagents.tools;
mergeMissing(defaults, agentCopy);
agents.defaults = defaults;
raw.agents = agents;
delete raw.agent;
changes.push("Moved agent → agents.defaults.");
},
},
{
id: "identity->agents.list",
describe: "Move identity to agents.list[].identity",
apply: (raw, changes) => {
const identity = getRecord(raw.identity);
if (!identity) return;
const agents = ensureRecord(raw, "agents");
const list = getAgentsList(agents);
const defaultId = resolveDefaultAgentIdFromRaw(raw);
const entry = ensureAgentEntry(list, defaultId);
if (entry.identity === undefined) {
entry.identity = identity;
changes.push(
`Moved identity → agents.list (id "${defaultId}").identity.`,
);
} else {
changes.push("Removed identity (agents.list identity already set).");
}
agents.list = list;
raw.agents = agents;
delete raw.identity;
},
},
];
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {

View File

@@ -5,52 +5,62 @@ import type { ClawdbotConfig } from "./types.js";
describe("applyModelDefaults", () => {
it("adds default aliases when models are present", () => {
const cfg = {
agent: {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-5.2": {},
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-5.2": {},
},
},
},
} satisfies ClawdbotConfig;
const next = applyModelDefaults(cfg);
expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
"opus",
expect(
next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias,
).toBe("opus");
expect(next.agents?.defaults?.models?.["openai/gpt-5.2"]?.alias).toBe(
"gpt",
);
expect(next.agent?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt");
});
it("does not override existing aliases", () => {
const cfg = {
agent: {
models: {
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
} satisfies ClawdbotConfig;
const next = applyModelDefaults(cfg);
expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
"Opus",
);
expect(
next.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.alias,
).toBe("Opus");
});
it("respects explicit empty alias disables", () => {
const cfg = {
agent: {
models: {
"google/gemini-3-pro-preview": { alias: "" },
"google/gemini-3-flash-preview": {},
agents: {
defaults: {
models: {
"google/gemini-3-pro-preview": { alias: "" },
"google/gemini-3-flash-preview": {},
},
},
},
} satisfies ClawdbotConfig;
const next = applyModelDefaults(cfg);
expect(next.agent?.models?.["google/gemini-3-pro-preview"]?.alias).toBe("");
expect(next.agent?.models?.["google/gemini-3-flash-preview"]?.alias).toBe(
"gemini-flash",
);
expect(
next.agents?.defaults?.models?.["google/gemini-3-pro-preview"]?.alias,
).toBe("");
expect(
next.agents?.defaults?.models?.["google/gemini-3-flash-preview"]?.alias,
).toBe("gemini-flash");
});
});

View File

@@ -7,7 +7,7 @@ describe("config schema", () => {
const res = buildConfigSchema();
const schema = res.schema as { properties?: Record<string, unknown> };
expect(schema.properties?.gateway).toBeTruthy();
expect(schema.properties?.agent).toBeTruthy();
expect(schema.properties?.agents).toBeTruthy();
expect(res.uiHints.gateway?.label).toBe("Gateway");
expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true);
expect(res.version).toBeTruthy();

View File

@@ -24,13 +24,14 @@ export type ConfigSchemaResponse = {
};
const GROUP_LABELS: Record<string, string> = {
identity: "Identity",
wizard: "Wizard",
logging: "Logging",
gateway: "Gateway",
agent: "Agent",
agents: "Agents",
tools: "Tools",
bindings: "Bindings",
audio: "Audio",
models: "Models",
routing: "Routing",
messages: "Messages",
commands: "Commands",
session: "Session",
@@ -52,30 +53,31 @@ const GROUP_LABELS: Record<string, string> = {
};
const GROUP_ORDER: Record<string, number> = {
identity: 10,
wizard: 20,
gateway: 30,
agent: 40,
models: 50,
routing: 60,
messages: 70,
commands: 75,
session: 80,
cron: 90,
hooks: 100,
ui: 110,
browser: 120,
talk: 130,
telegram: 140,
discord: 150,
slack: 155,
signal: 160,
imessage: 170,
whatsapp: 180,
skills: 190,
discovery: 200,
presence: 210,
voicewake: 220,
agents: 40,
tools: 50,
bindings: 55,
audio: 60,
models: 70,
messages: 80,
commands: 85,
session: 90,
cron: 100,
hooks: 110,
ui: 120,
browser: 130,
talk: 140,
telegram: 150,
discord: 160,
slack: 165,
signal: 170,
imessage: 180,
whatsapp: 190,
skills: 200,
discovery: 210,
presence: 220,
voicewake: 230,
logging: 900,
};
@@ -90,14 +92,14 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"agent.workspace": "Workspace",
"agents.defaults.workspace": "Workspace",
"auth.profiles": "Auth Profiles",
"auth.order": "Auth Profile Order",
"agent.models": "Models",
"agent.model.primary": "Primary Model",
"agent.model.fallbacks": "Model Fallbacks",
"agent.imageModel.primary": "Image Model",
"agent.imageModel.fallbacks": "Image Model Fallbacks",
"agents.defaults.models": "Models",
"agents.defaults.model.primary": "Primary Model",
"agents.defaults.model.fallbacks": "Model Fallbacks",
"agents.defaults.imageModel.primary": "Image Model",
"agents.defaults.imageModel.fallbacks": "Image Model Fallbacks",
"commands.native": "Native Commands",
"commands.text": "Text Commands",
"commands.restart": "Allow Restart",
@@ -154,14 +156,14 @@ const FIELD_HELP: Record<string, string> = {
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order":
"Ordered auth profile IDs per provider (used for automatic failover).",
"agent.models":
"agents.defaults.models":
"Configured model catalog (keys are full provider/model IDs).",
"agent.model.primary": "Primary model (provider/model).",
"agent.model.fallbacks":
"agents.defaults.model.primary": "Primary model (provider/model).",
"agents.defaults.model.fallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.",
"agent.imageModel.primary":
"agents.defaults.imageModel.primary":
"Optional image model (provider/model) used when the primary model lacks image input.",
"agent.imageModel.fallbacks":
"agents.defaults.imageModel.fallbacks":
"Ordered fallback image models (provider/model).",
"commands.native":
"Register native commands with connectors that support it (Discord/Slack/Telegram).",

View File

@@ -217,12 +217,15 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
export function resolveMainSessionKey(cfg?: {
session?: { scope?: SessionScope; mainKey?: string };
routing?: { defaultAgentId?: string };
agents?: { list?: Array<{ id?: string; default?: boolean }> };
}): string {
if (cfg?.session?.scope === "global") return "global";
const agentId = normalizeAgentId(
cfg?.routing?.defaultAgentId ?? DEFAULT_AGENT_ID,
);
const agents = cfg?.agents?.list ?? [];
const defaultAgentId =
agents.find((agent) => agent?.default)?.id ??
agents[0]?.id ??
DEFAULT_AGENT_ID;
const agentId = normalizeAgentId(defaultAgentId);
const mainKey =
(cfg?.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
return buildAgentMainSessionKey({ agentId, mainKey });

View File

@@ -91,6 +91,12 @@ export type AgentElevatedAllowFromConfig = {
webchat?: Array<string | number>;
};
export type IdentityConfig = {
name?: string;
theme?: string;
emoji?: string;
};
export type WhatsAppActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
@@ -762,83 +768,133 @@ export type GroupChatConfig = {
historyLimit?: number;
};
export type RoutingConfig = {
transcribeAudio?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[];
timeoutSeconds?: number;
export type QueueConfig = {
mode?: QueueMode;
byProvider?: QueueModeByProvider;
debounceMs?: number;
cap?: number;
drop?: QueueDropPolicy;
};
export type AgentToolsConfig = {
allow?: string[];
deny?: string[];
sandbox?: {
tools?: {
allow?: string[];
deny?: string[];
};
};
groupChat?: GroupChatConfig;
/** Default agent id when no binding matches. Default: "main". */
defaultAgentId?: string;
};
export type ToolsConfig = {
allow?: string[];
deny?: string[];
agentToAgent?: {
/** Enable agent-to-agent messaging tools. Default: false. */
enabled?: boolean;
/** Allowlist of agent ids or patterns (implementation-defined). */
allow?: string[];
};
agents?: Record<
string,
{
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
/** Per-agent override for group mention patterns. */
mentionPatterns?: string[];
subagents?: {
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
/** Agent workspace access inside the sandbox. */
workspaceAccess?: "none" | "ro" | "rw";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
workspaceRoot?: string;
/** Docker-specific sandbox overrides for this agent. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser overrides for this agent. */
browser?: SandboxBrowserSettings;
/** Tool allow/deny policy for sandboxed sessions (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
/** Auto-prune overrides for this agent. */
prune?: SandboxPruneSettings;
};
tools?: {
allow?: string[];
deny?: string[];
};
}
>;
bindings?: Array<{
agentId: string;
match: {
provider: string;
accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
teamId?: string;
/** Elevated bash permissions for the host machine. */
elevated?: {
/** Enable or disable elevated mode (default: true). */
enabled?: boolean;
/** Approved senders for /elevated (per-provider allowlists). */
allowFrom?: AgentElevatedAllowFromConfig;
};
/** Bash tool defaults. */
bash?: {
/** Default time (ms) before a bash command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing bash commands. */
timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
};
/** Sub-agent tool policy defaults (deny wins). */
subagents?: {
tools?: {
allow?: string[];
deny?: string[];
};
}>;
queue?: {
mode?: QueueMode;
byProvider?: QueueModeByProvider;
debounceMs?: number;
cap?: number;
drop?: QueueDropPolicy;
};
/** Sandbox tool policy defaults (deny wins). */
sandbox?: {
tools?: {
allow?: string[];
deny?: string[];
};
};
};
export type AgentConfig = {
id: string;
default?: boolean;
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: IdentityConfig;
groupChat?: GroupChatConfig;
subagents?: {
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
/** Agent workspace access inside the sandbox. */
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
workspaceRoot?: string;
/** Docker-specific sandbox overrides for this agent. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser overrides for this agent. */
browser?: SandboxBrowserSettings;
/** Auto-prune overrides for this agent. */
prune?: SandboxPruneSettings;
};
tools?: AgentToolsConfig;
};
export type AgentsConfig = {
defaults?: AgentDefaultsConfig;
list?: AgentConfig[];
};
export type AgentBinding = {
agentId: string;
match: {
provider: string;
accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
teamId?: string;
};
};
export type AudioConfig = {
transcription?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[];
timeoutSeconds?: number;
};
};
export type MessagesConfig = {
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "")
responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
groupChat?: GroupChatConfig;
queue?: QueueConfig;
/** Emoji reaction used to acknowledge inbound messages (empty disables). */
ackReaction?: string;
/** When to send ack reactions. Default: "group-mentions". */
@@ -1097,6 +1153,113 @@ export type AgentContextPruningConfig = {
};
};
export type AgentDefaultsConfig = {
/** Primary model and fallbacks (provider/model). */
model?: AgentModelListConfig;
/** Optional image-capable model and fallbacks (provider/model). */
imageModel?: AgentModelListConfig;
/** Model catalog with optional aliases (full provider/model keys). */
models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string;
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
skipBootstrap?: boolean;
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
userTimezone?: string;
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
contextPruning?: AgentContextPruningConfig;
/** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on";
/** Default elevated level when no /elevated directive is present. */
elevatedDefault?: "off" | "on";
/** Default block streaming level when no override is present. */
blockStreamingDefault?: "off" | "on";
/**
* Block streaming boundary:
* - "text_end": end of each assistant text content block (before tool calls)
* - "message_end": end of the whole assistant message (may include tool blocks)
*/
blockStreamingBreak?: "text_end" | "message_end";
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
blockStreamingChunk?: {
minChars?: number;
maxChars?: number;
breakPreference?: "paragraph" | "newline" | "sentence";
};
timeoutSeconds?: number;
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
mediaMaxMb?: number;
typingIntervalSeconds?: number;
/** Typing indicator start mode (never|instant|thinking|message). */
typingMode?: TypingMode;
/** Periodic background heartbeat runs. */
heartbeat?: {
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
/** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
prompt?: string;
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
ackMaxChars?: number;
};
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
maxConcurrent?: number;
/** Sub-agent defaults (spawned via sessions_spawn). */
subagents?: {
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
maxConcurrent?: number;
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
archiveAfterMinutes?: number;
};
/** Optional sandbox settings for non-main sessions. */
sandbox?: {
/** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all";
/**
* Agent workspace access inside the sandbox.
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
* - "ro": mount the agent workspace read-only; disables write/edit tools
* - "rw": mount the agent workspace read/write; enables write/edit tools
*/
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
/** Root directory for sandbox workspaces. */
workspaceRoot?: string;
/** Docker-specific sandbox settings. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser settings. */
browser?: SandboxBrowserSettings;
/** Auto-prune sandbox containers. */
prune?: SandboxPruneSettings;
};
};
export type ClawdbotConfig = {
auth?: AuthConfig;
env?: {
@@ -1115,11 +1278,6 @@ export type ClawdbotConfig = {
| { enabled?: boolean; timeoutMs?: number }
| undefined;
};
identity?: {
name?: string;
theme?: string;
emoji?: string;
};
wizard?: {
lastRunAt?: string;
lastRunVersion?: string;
@@ -1135,145 +1293,10 @@ export type ClawdbotConfig = {
};
skills?: SkillsConfig;
models?: ModelsConfig;
agent?: {
/** Primary model and fallbacks (provider/model). */
model?: AgentModelListConfig;
/** Optional image-capable model and fallbacks (provider/model). */
imageModel?: AgentModelListConfig;
/** Model catalog with optional aliases (full provider/model keys). */
models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string;
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
skipBootstrap?: boolean;
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
userTimezone?: string;
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
contextPruning?: AgentContextPruningConfig;
/** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on";
/** Default elevated level when no /elevated directive is present. */
elevatedDefault?: "off" | "on";
/** Default block streaming level when no override is present. */
blockStreamingDefault?: "off" | "on";
/**
* Block streaming boundary:
* - "text_end": end of each assistant text content block (before tool calls)
* - "message_end": end of the whole assistant message (may include tool blocks)
*/
blockStreamingBreak?: "text_end" | "message_end";
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
blockStreamingChunk?: {
minChars?: number;
maxChars?: number;
breakPreference?: "paragraph" | "newline" | "sentence";
};
timeoutSeconds?: number;
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
mediaMaxMb?: number;
typingIntervalSeconds?: number;
/** Typing indicator start mode (never|instant|thinking|message). */
typingMode?: TypingMode;
/** Periodic background heartbeat runs. */
heartbeat?: {
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
/** Delivery target (last|whatsapp|telegram|discord|signal|imessage|msteams|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams"
| "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
prompt?: string;
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
ackMaxChars?: number;
};
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
maxConcurrent?: number;
/** Sub-agent defaults (spawned via sessions_spawn). */
subagents?: {
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
maxConcurrent?: number;
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
archiveAfterMinutes?: number;
/** Tool allow/deny policy for sub-agent sessions (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
};
/** Bash tool defaults. */
bash?: {
/** Default time (ms) before a bash command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing bash commands. */
timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
};
/** Elevated bash permissions for the host machine. */
elevated?: {
/** Enable or disable elevated mode (default: true). */
enabled?: boolean;
/** Approved senders for /elevated (per-provider allowlists). */
allowFrom?: AgentElevatedAllowFromConfig;
};
/** Optional sandbox settings for non-main sessions. */
sandbox?: {
/** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all";
/**
* Agent workspace access inside the sandbox.
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
* - "ro": mount the agent workspace read-only; disables write/edit tools
* - "rw": mount the agent workspace read/write; enables write/edit tools
*/
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
/** Root directory for sandbox workspaces. */
workspaceRoot?: string;
/** Docker-specific sandbox settings. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser settings. */
browser?: SandboxBrowserSettings;
/** Tool allow/deny policy (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
/** Auto-prune sandbox containers. */
prune?: SandboxPruneSettings;
};
/** Global tool allow/deny policy for all providers (deny wins). */
tools?: {
allow?: string[];
deny?: string[];
};
};
routing?: RoutingConfig;
agents?: AgentsConfig;
tools?: ToolsConfig;
bindings?: AgentBinding[];
audio?: AudioConfig;
messages?: MessagesConfig;
commands?: CommandsConfig;
session?: SessionConfig;

View File

@@ -2,11 +2,7 @@ import {
findDuplicateAgentDirs,
formatDuplicateAgentDirError,
} from "./agent-dirs.js";
import {
applyIdentityDefaults,
applyModelDefaults,
applySessionDefaults,
} from "./defaults.js";
import { applyModelDefaults, applySessionDefaults } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.js";
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
import { ClawdbotSchema } from "./zod-schema.js";
@@ -42,7 +38,7 @@ export function validateConfigObject(
ok: false,
issues: [
{
path: "routing.agents",
path: "agents.list",
message: formatDuplicateAgentDirError(duplicates),
},
],
@@ -51,9 +47,7 @@ export function validateConfigObject(
return {
ok: true,
config: applyModelDefaults(
applySessionDefaults(
applyIdentityDefaults(validated.data as ClawdbotConfig),
),
applySessionDefaults(validated.data as ClawdbotConfig),
),
};
}

View File

@@ -61,6 +61,14 @@ const GroupChatSchema = z
})
.optional();
const IdentitySchema = z
.object({
name: z.string().optional(),
theme: z.string().optional(),
emoji: z.string().optional(),
})
.optional();
const QueueModeSchema = z.union([
z.literal("steer"),
z.literal("followup"),
@@ -133,6 +141,16 @@ const QueueModeBySurfaceSchema = z
})
.optional();
const QueueSchema = z
.object({
mode: QueueModeSchema.optional(),
byProvider: QueueModeBySurfaceSchema,
debounceMs: z.number().int().nonnegative().optional(),
cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(),
})
.optional();
const TranscribeAudioSchema = z
.object({
command: z.array(z.string()),
@@ -554,6 +572,8 @@ const MessagesSchema = z
.object({
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
groupChat: GroupChatSchema,
queue: QueueSchema,
ackReaction: z.string().optional(),
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all"])
@@ -667,96 +687,140 @@ const ToolPolicySchema = z
})
.optional();
const RoutingSchema = z
const ElevatedAllowFromSchema = z
.object({
groupChat: GroupChatSchema,
transcribeAudio: TranscribeAudioSchema,
defaultAgentId: z.string().optional(),
whatsapp: z.array(z.string()).optional(),
telegram: z.array(z.union([z.string(), z.number()])).optional(),
discord: z.array(z.union([z.string(), z.number()])).optional(),
slack: z.array(z.union([z.string(), z.number()])).optional(),
signal: z.array(z.union([z.string(), z.number()])).optional(),
imessage: z.array(z.union([z.string(), z.number()])).optional(),
webchat: z.array(z.union([z.string(), z.number()])).optional(),
})
.optional();
const AgentSandboxSchema = z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([z.literal("session"), z.literal("agent"), z.literal("shared")])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
prune: SandboxPruneSchema,
})
.optional();
const AgentToolsSchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
sandbox: z
.object({
tools: ToolPolicySchema,
})
.optional(),
})
.optional();
const AgentEntrySchema = z.object({
id: z.string(),
default: z.boolean().optional(),
name: z.string().optional(),
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
identity: IdentitySchema,
groupChat: GroupChatSchema,
subagents: z
.object({
allowAgents: z.array(z.string()).optional(),
})
.optional(),
sandbox: AgentSandboxSchema,
tools: AgentToolsSchema,
});
const ToolsSchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
agentToAgent: z
.object({
enabled: z.boolean().optional(),
allow: z.array(z.string()).optional(),
})
.optional(),
agents: z
.record(
z.string(),
z
.object({
name: z.string().optional(),
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
mentionPatterns: z.array(z.string()).optional(),
subagents: z
.object({
allowAgents: z.array(z.string()).optional(),
})
.optional(),
sandbox: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("non-main"),
z.literal("all"),
])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
tools: ToolPolicySchema,
prune: SandboxPruneSchema,
})
.optional(),
tools: ToolPolicySchema,
})
.optional(),
)
.optional(),
bindings: z
.array(
z.object({
agentId: z.string(),
match: z.object({
provider: z.string(),
accountId: z.string().optional(),
peer: z
.object({
kind: z.union([
z.literal("dm"),
z.literal("group"),
z.literal("channel"),
]),
id: z.string(),
})
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
}),
}),
)
.optional(),
queue: z
elevated: z
.object({
mode: QueueModeSchema.optional(),
byProvider: QueueModeBySurfaceSchema,
debounceMs: z.number().int().nonnegative().optional(),
cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(),
enabled: z.boolean().optional(),
allowFrom: ElevatedAllowFromSchema,
})
.optional(),
bash: z
.object({
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
})
.optional(),
subagents: z
.object({
tools: ToolPolicySchema,
})
.optional(),
sandbox: z
.object({
tools: ToolPolicySchema,
})
.optional(),
})
.optional();
const AgentsSchema = z
.object({
defaults: z.lazy(() => AgentDefaultsSchema).optional(),
list: z.array(AgentEntrySchema).optional(),
})
.optional();
const BindingsSchema = z
.array(
z.object({
agentId: z.string(),
match: z.object({
provider: z.string(),
accountId: z.string().optional(),
peer: z
.object({
kind: z.union([
z.literal("dm"),
z.literal("group"),
z.literal("channel"),
]),
id: z.string(),
})
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
}),
}),
)
.optional();
const AudioSchema = z
.object({
transcription: TranscribeAudioSchema,
})
.optional();
@@ -832,6 +896,145 @@ const HooksGmailSchema = z
})
.optional();
const AgentDefaultsSchema = z
.object({
model: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
imageModel: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
models: z
.record(
z.string(),
z.object({
alias: z.string().optional(),
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
params: z.record(z.string(), z.unknown()).optional(),
}),
)
.optional(),
workspace: z.string().optional(),
skipBootstrap: z.boolean().optional(),
userTimezone: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
contextPruning: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("adaptive"),
z.literal("aggressive"),
])
.optional(),
keepLastAssistants: z.number().int().nonnegative().optional(),
softTrimRatio: z.number().min(0).max(1).optional(),
hardClearRatio: z.number().min(0).max(1).optional(),
minPrunableToolChars: z.number().int().nonnegative().optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
softTrim: z
.object({
maxChars: z.number().int().nonnegative().optional(),
headChars: z.number().int().nonnegative().optional(),
tailChars: z.number().int().nonnegative().optional(),
})
.optional(),
hardClear: z
.object({
enabled: z.boolean().optional(),
placeholder: z.string().optional(),
})
.optional(),
})
.optional(),
thinkingDefault: z
.union([
z.literal("off"),
z.literal("minimal"),
z.literal("low"),
z.literal("medium"),
z.literal("high"),
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingDefault: z
.union([z.literal("off"), z.literal("on")])
.optional(),
blockStreamingBreak: z
.union([z.literal("text_end"), z.literal("message_end")])
.optional(),
blockStreamingChunk: z
.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
breakPreference: z
.union([
z.literal("paragraph"),
z.literal("newline"),
z.literal("sentence"),
])
.optional(),
})
.optional(),
timeoutSeconds: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z
.union([
z.literal("never"),
z.literal("instant"),
z.literal("thinking"),
z.literal("message"),
])
.optional(),
heartbeat: HeartbeatSchema,
maxConcurrent: z.number().int().positive().optional(),
subagents: z
.object({
maxConcurrent: z.number().int().positive().optional(),
archiveAfterMinutes: z.number().int().positive().optional(),
})
.optional(),
sandbox: z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
prune: SandboxPruneSchema,
})
.optional(),
})
.optional();
export const ClawdbotSchema = z.object({
env: z
.object({
@@ -845,13 +1048,6 @@ export const ClawdbotSchema = z.object({
})
.catchall(z.string())
.optional(),
identity: z
.object({
name: z.string().optional(),
theme: z.string().optional(),
emoji: z.string().optional(),
})
.optional(),
wizard: z
.object({
lastRunAt: z.string().optional(),
@@ -954,182 +1150,10 @@ export const ClawdbotSchema = z.object({
})
.optional(),
models: ModelsConfigSchema,
agent: z
.object({
model: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
imageModel: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
models: z
.record(
z.string(),
z.object({
alias: z.string().optional(),
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
params: z.record(z.string(), z.unknown()).optional(),
}),
)
.optional(),
workspace: z.string().optional(),
skipBootstrap: z.boolean().optional(),
userTimezone: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
contextPruning: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("adaptive"),
z.literal("aggressive"),
])
.optional(),
keepLastAssistants: z.number().int().nonnegative().optional(),
softTrimRatio: z.number().min(0).max(1).optional(),
hardClearRatio: z.number().min(0).max(1).optional(),
minPrunableToolChars: z.number().int().nonnegative().optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
softTrim: z
.object({
maxChars: z.number().int().nonnegative().optional(),
headChars: z.number().int().nonnegative().optional(),
tailChars: z.number().int().nonnegative().optional(),
})
.optional(),
hardClear: z
.object({
enabled: z.boolean().optional(),
placeholder: z.string().optional(),
})
.optional(),
})
.optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
thinkingDefault: z
.union([
z.literal("off"),
z.literal("minimal"),
z.literal("low"),
z.literal("medium"),
z.literal("high"),
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingDefault: z
.union([z.literal("off"), z.literal("on")])
.optional(),
blockStreamingBreak: z
.union([z.literal("text_end"), z.literal("message_end")])
.optional(),
blockStreamingChunk: z
.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
breakPreference: z
.union([
z.literal("paragraph"),
z.literal("newline"),
z.literal("sentence"),
])
.optional(),
})
.optional(),
timeoutSeconds: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z
.union([
z.literal("never"),
z.literal("instant"),
z.literal("thinking"),
z.literal("message"),
])
.optional(),
heartbeat: HeartbeatSchema,
maxConcurrent: z.number().int().positive().optional(),
subagents: z
.object({
maxConcurrent: z.number().int().positive().optional(),
archiveAfterMinutes: z.number().int().positive().optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
})
.optional(),
bash: z
.object({
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
})
.optional(),
elevated: z
.object({
enabled: z.boolean().optional(),
allowFrom: z
.object({
whatsapp: z.array(z.string()).optional(),
telegram: z.array(z.union([z.string(), z.number()])).optional(),
discord: z.array(z.union([z.string(), z.number()])).optional(),
slack: z.array(z.union([z.string(), z.number()])).optional(),
signal: z.array(z.union([z.string(), z.number()])).optional(),
imessage: z.array(z.union([z.string(), z.number()])).optional(),
msteams: z.array(z.union([z.string(), z.number()])).optional(),
webchat: z.array(z.union([z.string(), z.number()])).optional(),
})
.optional(),
})
.optional(),
sandbox: z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
tools: ToolPolicySchema,
prune: SandboxPruneSchema,
})
.optional(),
})
.optional(),
routing: RoutingSchema,
agents: AgentsSchema,
tools: ToolsSchema,
bindings: BindingsSchema,
audio: AudioSchema,
messages: MessagesSchema,
commands: CommandsSchema,
session: SessionSchema,