diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts
index de7080103..a8f1eb6a8 100644
--- a/src/cli/program/register.onboard.ts
+++ b/src/cli/program/register.onboard.ts
@@ -41,6 +41,10 @@ export function registerOnboardCommand(program: Command) {
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/onboard", "docs.molt.bot/cli/onboard")}\n`,
)
.option("--workspace
", "Agent workspace directory (default: ~/clawd)")
+ .option(
+ "--user-timezone ",
+ "User timezone for cron schedules (e.g., America/New_York, Africa/Lagos)",
+ )
.option("--reset", "Reset config + credentials + sessions + workspace before running wizard")
.option("--non-interactive", "Run without prompts", false)
.option(
@@ -106,6 +110,7 @@ export function registerOnboardCommand(program: Command) {
await onboardCommand(
{
workspace: opts.workspace as string | undefined,
+ userTimezone: opts.userTimezone as string | undefined,
nonInteractive: Boolean(opts.nonInteractive),
acceptRisk: Boolean(opts.acceptRisk),
flow: opts.flow as "quickstart" | "advanced" | "manual" | undefined,
diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts
index 53c33a670..358faaa6e 100644
--- a/src/cli/program/register.setup.ts
+++ b/src/cli/program/register.setup.ts
@@ -20,6 +20,10 @@ export function registerSetupCommand(program: Command) {
"--workspace ",
"Agent workspace directory (default: ~/clawd; stored as agents.defaults.workspace)",
)
+ .option(
+ "--user-timezone ",
+ "User timezone for cron schedules (e.g., America/New_York, Africa/Lagos)",
+ )
.option("--wizard", "Run the interactive onboarding wizard", false)
.option("--non-interactive", "Run the wizard without prompts", false)
.option("--mode ", "Wizard mode: local|remote")
@@ -38,6 +42,7 @@ export function registerSetupCommand(program: Command) {
await onboardCommand(
{
workspace: opts.workspace as string | undefined,
+ userTimezone: opts.userTimezone as string | undefined,
nonInteractive: Boolean(opts.nonInteractive),
mode: opts.mode as "local" | "remote" | undefined,
remoteUrl: opts.remoteUrl as string | undefined,
diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts
index 376555a39..12e905cc1 100644
--- a/src/commands/onboard-helpers.ts
+++ b/src/commands/onboard-helpers.ts
@@ -425,3 +425,44 @@ function isValidIPv4(host: string): boolean {
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
});
}
+
+/**
+ * Validate an IANA timezone string.
+ * Uses Intl.supportedValuesOf('timeZone') when available (Node 18.6+),
+ * falls back to testing via DateTimeFormat.
+ */
+export function isValidTimezone(tz: string): boolean {
+ if (!tz || typeof tz !== "string") return false;
+ const trimmed = tz.trim();
+ if (!trimmed) return false;
+
+ // Try Intl.supportedValuesOf if available (Node 18.6+)
+ if (typeof Intl.supportedValuesOf === "function") {
+ try {
+ const supported = Intl.supportedValuesOf("timeZone");
+ return supported.includes(trimmed);
+ } catch {
+ // Fall through to DateTimeFormat check
+ }
+ }
+
+ // Fallback: try to create a DateTimeFormat with the timezone
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: trimmed });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Detect the system's local timezone.
+ * Returns the IANA timezone string (e.g., "America/New_York").
+ */
+export function detectSystemTimezone(): string {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
+ } catch {
+ return "UTC";
+ }
+}
diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts
index d642cc1da..206cc6c4d 100644
--- a/src/commands/onboard-non-interactive/local.ts
+++ b/src/commands/onboard-non-interactive/local.ts
@@ -8,7 +8,9 @@ import { healthCommand } from "../health.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
+ detectSystemTimezone,
ensureWorkspaceAndSessions,
+ isValidTimezone,
resolveControlUiLinks,
waitForGatewayReachable,
} from "../onboard-helpers.js";
@@ -35,6 +37,23 @@ export async function runNonInteractiveOnboardingLocal(params: {
defaultWorkspaceDir: DEFAULT_WORKSPACE,
});
+ // Resolve user timezone
+ const systemTimezone = detectSystemTimezone();
+ const existingTimezone = baseConfig.agents?.defaults?.userTimezone;
+ let userTimezone: string | undefined;
+ if (opts.userTimezone !== undefined) {
+ const trimmed = opts.userTimezone.trim();
+ if (trimmed && isValidTimezone(trimmed)) {
+ userTimezone = trimmed;
+ } else if (trimmed) {
+ runtime.log(`Invalid timezone "${trimmed}". Using system default: ${systemTimezone}`);
+ userTimezone = systemTimezone;
+ }
+ } else {
+ // Use existing or system timezone
+ userTimezone = existingTimezone ?? systemTimezone;
+ }
+
let nextConfig: MoltbotConfig = {
...baseConfig,
agents: {
@@ -42,6 +61,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
defaults: {
...baseConfig.agents?.defaults,
workspace: workspaceDir,
+ ...(userTimezone ? { userTimezone } : {}),
},
},
gateway: {
diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts
index f4154bc6d..6189dd604 100644
--- a/src/commands/onboard-types.ts
+++ b/src/commands/onboard-types.ts
@@ -47,6 +47,8 @@ export type OnboardOptions = {
/** "manual" is an alias for "advanced". */
flow?: "quickstart" | "advanced" | "manual";
workspace?: string;
+ /** User timezone for cron schedules (e.g., "America/New_York", "Europe/London"). */
+ userTimezone?: string;
nonInteractive?: boolean;
/** Required for non-interactive onboarding; skips the interactive risk prompt when true. */
acceptRisk?: boolean;
diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts
index 75543ca19..bc6ae5f47 100644
--- a/src/wizard/onboarding.ts
+++ b/src/wizard/onboarding.ts
@@ -11,8 +11,10 @@ import { setupChannels } from "../commands/onboard-channels.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
+ detectSystemTimezone,
ensureWorkspaceAndSessions,
handleReset,
+ isValidTimezone,
printWizardHeader,
probeGatewayReachable,
summarizeExistingConfig,
@@ -335,6 +337,42 @@ export async function runOnboardingWizard(
const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_WORKSPACE);
+ // Timezone prompt
+ const systemTimezone = detectSystemTimezone();
+ const existingTimezone = baseConfig.agents?.defaults?.userTimezone;
+ const timezoneDefault = existingTimezone ?? systemTimezone;
+
+ let userTimezone: string | undefined;
+ if (opts.userTimezone !== undefined) {
+ // CLI option provided
+ userTimezone = opts.userTimezone.trim() || undefined;
+ if (userTimezone && !isValidTimezone(userTimezone)) {
+ await prompter.note(
+ `Invalid timezone "${userTimezone}". Using system default: ${systemTimezone}`,
+ "Timezone",
+ );
+ userTimezone = systemTimezone;
+ }
+ } else if (flow === "quickstart") {
+ // QuickStart: use existing or system timezone silently
+ userTimezone = existingTimezone ?? systemTimezone;
+ } else {
+ // Manual flow: prompt for timezone
+ const timezoneInput = await prompter.text({
+ message: "Timezone (for cron schedules)",
+ initialValue: timezoneDefault,
+ placeholder: "e.g., America/New_York, Europe/London, Africa/Lagos",
+ validate: (value) => {
+ const trimmed = value.trim();
+ if (!trimmed) return; // Allow empty (will use system default)
+ if (!isValidTimezone(trimmed)) {
+ return `Invalid timezone. Examples: America/New_York, Europe/London, Africa/Lagos`;
+ }
+ },
+ });
+ userTimezone = timezoneInput.trim() || systemTimezone;
+ }
+
let nextConfig: MoltbotConfig = {
...baseConfig,
agents: {
@@ -342,6 +380,7 @@ export async function runOnboardingWizard(
defaults: {
...baseConfig.agents?.defaults,
workspace: workspaceDir,
+ ...(userTimezone ? { userTimezone } : {}),
},
},
gateway: {