diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index fb2bc2f563e..182c1818503 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -7057,6 +7057,7 @@ public struct CronJob: Codable, Sendable {
public let createdatms: Int
public let updatedatms: Int
public let schedule: AnyCodable
+ public let trigger: [String: AnyCodable]?
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
@@ -7088,6 +7089,7 @@ public struct CronJob: Codable, Sendable {
createdatms: Int,
updatedatms: Int,
schedule: AnyCodable,
+ trigger: [String: AnyCodable]?,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
@@ -7118,6 +7120,7 @@ public struct CronJob: Codable, Sendable {
self.createdatms = createdatms
self.updatedatms = updatedatms
self.schedule = schedule
+ self.trigger = trigger
self.sessiontarget = sessiontarget
self.wakemode = wakemode
self.payload = payload
@@ -7150,6 +7153,7 @@ public struct CronJob: Codable, Sendable {
case createdatms = "createdAtMs"
case updatedatms = "updatedAtMs"
case schedule
+ case trigger
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
case payload
@@ -7236,6 +7240,7 @@ public struct CronAddParams: Codable, Sendable {
public let enabled: Bool?
public let deleteafterrun: Bool?
public let schedule: AnyCodable
+ public let trigger: [String: AnyCodable]?
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
@@ -7253,6 +7258,7 @@ public struct CronAddParams: Codable, Sendable {
enabled: Bool?,
deleteafterrun: Bool?,
schedule: AnyCodable,
+ trigger: [String: AnyCodable]?,
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
@@ -7269,6 +7275,7 @@ public struct CronAddParams: Codable, Sendable {
self.enabled = enabled
self.deleteafterrun = deleteafterrun
self.schedule = schedule
+ self.trigger = trigger
self.sessiontarget = sessiontarget
self.wakemode = wakemode
self.payload = payload
@@ -7287,6 +7294,7 @@ public struct CronAddParams: Codable, Sendable {
case enabled
case deleteafterrun = "deleteAfterRun"
case schedule
+ case trigger
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
case payload
@@ -7394,6 +7402,7 @@ public struct CronRunLogEntry: Codable, Sendable {
public let runatms: Int?
public let durationms: Int?
public let nextrunatms: Int?
+ public let triggerfired: Bool?
public let model: String?
public let provider: String?
public let usage: [String: AnyCodable]?
@@ -7418,6 +7427,7 @@ public struct CronRunLogEntry: Codable, Sendable {
runatms: Int?,
durationms: Int?,
nextrunatms: Int?,
+ triggerfired: Bool?,
model: String?,
provider: String?,
usage: [String: AnyCodable]?,
@@ -7441,6 +7451,7 @@ public struct CronRunLogEntry: Codable, Sendable {
self.runatms = runatms
self.durationms = durationms
self.nextrunatms = nextrunatms
+ self.triggerfired = triggerfired
self.model = model
self.provider = provider
self.usage = usage
@@ -7466,6 +7477,7 @@ public struct CronRunLogEntry: Codable, Sendable {
case runatms = "runAtMs"
case durationms = "durationMs"
case nextrunatms = "nextRunAtMs"
+ case triggerfired = "triggerFired"
case model
case provider
case usage
diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index cff3036dce1..daa3fcbd4cf 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -88,6 +88,41 @@ Cron expressions are parsed by [croner](https://github.com/Hexagon/croner). When
This fires roughly 5-6 times a month instead of 0-1 times a month. To require both conditions, use croner's `+` day-of-week modifier (`0 9 15 * +1`), or schedule on one field and guard the other in your job's prompt or command.
+## Event triggers (condition watchers)
+
+An event trigger adds a headless condition script to an `every` or `cron` schedule. Cron evaluates the script when the job is due and runs the normal payload only when the script returns `fire: true`:
+
+```json5
+{
+ schedule: { kind: "every", everyMs: 30000 },
+ trigger: {
+ // Fires only when the observed status differs from the last evaluation.
+ script: "const res = await tools.call('exec', { command: 'gh pr checks 123 --json state -q \\'.[].state\\' | sort -u' }); const status = String(res?.result?.details?.aggregated ?? '').trim(); json({ fire: status !== trigger.state?.status, message: `PR 123 CI: ${trigger.state?.status ?? 'unknown'} -> ${status}`, state: { status } });",
+ once: false,
+ },
+ payload: { kind: "agentTurn", message: "Investigate the CI status change." },
+}
+```
+
+The script must return `{ fire, message?, state? }`. The previous JSON state is available as the deeply frozen `trigger.state`; return a new `state` value to persist it. State is capped at 16 KB. When a firing result includes `message`, cron appends it to the system-event text or agent-turn message before execution. `once: true` disables the job after its first successful fired payload.
+
+`fire: false` persists evaluation state and counters, then reschedules without creating run history. If a fired payload run fails, the returned `state` is **not** persisted — the next evaluation sees the previous state and can fire again, so write scripts as read-only checks and keep actions in the payload. Trigger schedules have a configurable minimum interval (30 seconds by default). Each evaluation has a 30-second wall-clock budget and up to 5 tool calls.
+
+
+Enabling `cron.triggers.enabled` lets agent-authored scripts run headlessly with the owning agent's **full tool policy, including `exec`**. Treat this as unattended code execution with that agent's permissions; leave it disabled unless every agent allowed to create cron jobs is trusted accordingly.
+
+
+Create a watcher from a local script file (`-` reads the script from stdin):
+
+```bash
+openclaw cron add \
+ --name "PR CI watcher" \
+ --every 30s \
+ --trigger-script ./watch-pr-ci.js \
+ --message "Respond to the CI status change" \
+ --session isolated
+```
+
## Payloads
Every job carries exactly one payload kind, chosen by flag:
@@ -497,6 +532,10 @@ When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `
enabled: true,
store: "~/.openclaw/cron/jobs.json",
maxConcurrentRuns: 8,
+ triggers: {
+ enabled: false,
+ minIntervalMs: 30000,
+ },
retry: {
maxAttempts: 3,
backoffMs: [30000, 60000, 300000],
diff --git a/docs/docs_map.md b/docs/docs_map.md
index 5f03c2442e5..3eea26e063a 100644
--- a/docs/docs_map.md
+++ b/docs/docs_map.md
@@ -66,6 +66,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: How cron works
- H2: Schedule types
- H3: Day-of-month and day-of-week use OR logic
+ - H2: Event triggers (condition watchers)
- H2: Payloads
- H3: Agent-turn options
- H3: Command payloads
diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts
index 075096b55f3..00b4f39ecac 100644
--- a/packages/gateway-protocol/src/cron-validators.test.ts
+++ b/packages/gateway-protocol/src/cron-validators.test.ts
@@ -36,6 +36,38 @@ describe("cron protocol validators", () => {
expect(validateCronAddParams(minimalAddParams)).toBe(true);
});
+ it("accepts trigger add, patch, and clear shapes", () => {
+ expect(
+ validateCronAddParams({
+ ...minimalAddParams,
+ trigger: { script: "json({ fire: true })", once: true },
+ }),
+ ).toBe(true);
+ expect(
+ validateCronUpdateParams({
+ id: "job-1",
+ patch: { trigger: { script: "json({ fire: false })" } },
+ }),
+ ).toBe(true);
+ expect(validateCronUpdateParams({ id: "job-1", patch: { trigger: null } })).toBe(true);
+ });
+
+ it("rejects invalid trigger scripts and additional properties", () => {
+ expect(validateCronAddParams({ ...minimalAddParams, trigger: { script: "" } })).toBe(false);
+ expect(
+ validateCronAddParams({
+ ...minimalAddParams,
+ trigger: { script: "json({ fire: true })", unexpected: true },
+ }),
+ ).toBe(false);
+ expect(
+ validateCronUpdateParams({
+ id: "job-1",
+ patch: { trigger: { script: "json({ fire: true })", unexpected: true } },
+ }),
+ ).toBe(false);
+ });
+
it("rejects public caller scope on cron admin params", () => {
expect(validateCronListParams({ callerScope: agentToolCallerScope })).toBe(false);
expect(validateCronGetParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false);
diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts
index d0e56b8989a..f9fcc4ca43e 100644
--- a/packages/gateway-protocol/src/schema/cron.ts
+++ b/packages/gateway-protocol/src/schema/cron.ts
@@ -247,6 +247,15 @@ export const CronScheduleSchema = Type.Union([
),
]);
+/** Headless condition script evaluated before a recurring cron payload runs. */
+export const CronTriggerSchema = Type.Object(
+ {
+ script: Type.String({ minLength: 1, maxLength: 65_536 }),
+ once: Type.Optional(Type.Boolean()),
+ },
+ { additionalProperties: false },
+);
+
/** Full cron payload for new jobs. */
export const CronPayloadSchema = Type.Union([
Type.Object(
@@ -431,6 +440,10 @@ export const CronJobStateSchema = Type.Object(
lastFailureNotificationDeliveryStatus: Type.Optional(CronDeliveryStatusSchema),
lastFailureNotificationDeliveryError: Type.Optional(Type.String()),
lastFailureAlertAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
+ lastTriggerEvalAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
+ triggerEvalCount: Type.Optional(Type.Integer({ minimum: 0 })),
+ lastTriggerFireAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
+ triggerState: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
@@ -454,6 +467,10 @@ const CronJobStatePatchSchema = Type.Object(
lastFailureNotificationDeliveryStatus: Type.Optional(CronDeliveryStatusSchema),
lastFailureNotificationDeliveryError: Type.Optional(Type.String()),
lastFailureAlertAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
+ lastTriggerEvalAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
+ triggerEvalCount: Type.Optional(Type.Integer({ minimum: 0 })),
+ lastTriggerFireAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
+ triggerState: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
@@ -474,6 +491,7 @@ export const CronJobSchema = Type.Object(
createdAtMs: Type.Integer({ minimum: 0 }),
updatedAtMs: Type.Integer({ minimum: 0 }),
schedule: CronScheduleSchema,
+ trigger: Type.Optional(CronTriggerSchema),
sessionTarget: CronSessionTargetSchema,
wakeMode: CronWakeModeSchema,
payload: CronPayloadSchema,
@@ -527,6 +545,7 @@ export const CronAddParamsSchema = Type.Object(
owner: Type.Optional(CronOwnerSchema),
...CronCommonOptionalFields,
schedule: CronScheduleSchema,
+ trigger: Type.Optional(CronTriggerSchema),
sessionTarget: CronSessionTargetSchema,
wakeMode: CronWakeModeSchema,
payload: CronPayloadSchema,
@@ -556,6 +575,7 @@ export const CronJobPatchSchema = Type.Object(
displayName: Type.Optional(Type.Union([CronDisplayNameSchema, Type.Null()])),
...CronCommonOptionalFields,
schedule: Type.Optional(CronScheduleSchema),
+ trigger: Type.Optional(Type.Union([CronTriggerSchema, Type.Null()])),
sessionTarget: Type.Optional(CronSessionTargetSchema),
wakeMode: Type.Optional(CronWakeModeSchema),
payload: Type.Optional(CronPayloadPatchSchema),
@@ -621,6 +641,7 @@ export const CronRunLogEntrySchema = Type.Object(
runAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
durationMs: Type.Optional(Type.Integer({ minimum: 0 })),
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
+ triggerFired: Type.Optional(Type.Boolean()),
model: Type.Optional(Type.String()),
provider: Type.Optional(Type.String()),
usage: Type.Optional(
diff --git a/src/agents/code-mode-headless.test.ts b/src/agents/code-mode-headless.test.ts
new file mode 100644
index 00000000000..e3ecaf246dc
--- /dev/null
+++ b/src/agents/code-mode-headless.test.ts
@@ -0,0 +1,288 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { runCodeModeScriptHeadless, testing, type CodeModeHeadlessResult } from "./code-mode.js";
+import {
+ createToolSearchCatalogRef,
+ registerHeadlessToolSearchCatalog,
+ type ToolSearchToolContext,
+} from "./tool-search.js";
+import { jsonResult, type AnyAgentTool } from "./tools/common.js";
+
+function fakeTool(name: string, execute: AnyAgentTool["execute"]): AnyAgentTool {
+ return {
+ name,
+ label: name,
+ description: `Test tool ${name}`,
+ parameters: { type: "object", properties: {} },
+ execute: vi.fn(execute) as AnyAgentTool["execute"],
+ };
+}
+
+function createHeadlessHarness(tools: AnyAgentTool[] = []): ToolSearchToolContext {
+ const config = {
+ tools: { codeMode: { enabled: false, timeoutMs: 60_000 } },
+ } as never;
+ const catalogRef = createToolSearchCatalogRef();
+ registerHeadlessToolSearchCatalog({ catalogRef, tools });
+ return {
+ config,
+ runtimeConfig: config,
+ agentId: "main",
+ catalogRef,
+ };
+}
+
+function expectCompleted(result: CodeModeHeadlessResult) {
+ expect(result.status).toBe("completed");
+ if (result.status !== "completed") {
+ throw new Error(result.error);
+ }
+ return result;
+}
+
+function expectFailed(result: CodeModeHeadlessResult) {
+ expect(result.status).toBe("failed");
+ if (result.status !== "failed") {
+ throw new Error("expected headless code mode failure");
+ }
+ return result;
+}
+
+describe("headless Code Mode", () => {
+ afterEach(() => {
+ expect(testing.activeRuns.size).toBe(0);
+ testing.activeRuns.clear();
+ testing.resumingRunIds.clear();
+ });
+
+ it("completes multi-round tool calls without publishing active runs", async () => {
+ const first = fakeTool("headless_first", async () => {
+ expect(testing.activeRuns.size).toBe(0);
+ return jsonResult({ value: 2 });
+ });
+ const second = fakeTool("headless_second", async (_toolCallId, input) => {
+ expect(testing.activeRuns.size).toBe(0);
+ return jsonResult({ input });
+ });
+ const ctx = createHeadlessHarness([first, second]);
+
+ const result = expectCompleted(
+ await runCodeModeScriptHeadless({
+ ctx,
+ code: `
+ const first = await tools.call("openclaw:core:headless_first", {});
+ const second = await tools.call("openclaw:core:headless_second", {
+ value: first.result.details.value,
+ });
+ return second.result.details;
+ `,
+ wallClockMs: 120_000,
+ }),
+ );
+
+ expect(result.value).toEqual({ input: { value: 2 } });
+ expect(result.toolCallCount).toBe(2);
+ expect(first.execute).toHaveBeenCalledOnce();
+ expect(second.execute).toHaveBeenCalledOnce();
+ });
+
+ it("injects deeply frozen trigger state and emits replacement state through json", async () => {
+ const result = expectCompleted(
+ await runCodeModeScriptHeadless({
+ ctx: createHeadlessHarness(),
+ code: `
+ json({
+ fire: true,
+ frozen: Object.isFrozen(trigger) &&
+ Object.isFrozen(trigger.state) &&
+ Object.isFrozen(trigger.state.nested),
+ emptyKey: trigger.state[""],
+ state: { count: trigger.state.count + 1 },
+ });
+ return "done";
+ `,
+ extraNamespaces: [
+ {
+ id: "cron:trigger",
+ globalName: "trigger",
+ scope: {
+ kind: "object",
+ entries: [
+ [
+ "state",
+ {
+ kind: "value",
+ value: { "": 7, count: 4, nested: { stable: true } },
+ },
+ ],
+ ],
+ },
+ },
+ ],
+ }),
+ );
+
+ expect(result.output).toEqual([
+ {
+ type: "json",
+ value: { fire: true, frozen: true, emptyKey: 7, state: { count: 5 } },
+ },
+ ]);
+ });
+
+ it("rejects colliding injected namespace globals", async () => {
+ const result = expectFailed(
+ await runCodeModeScriptHeadless({
+ ctx: createHeadlessHarness(),
+ code: "return true;",
+ extraNamespaces: [
+ {
+ id: "cron:trigger",
+ globalName: "trigger",
+ scope: { kind: "object", entries: [] },
+ },
+ {
+ id: "plugin:trigger",
+ globalName: "trigger",
+ scope: { kind: "object", entries: [] },
+ },
+ ],
+ }),
+ );
+
+ expect(result.code).toBe("invalid_input");
+ expect(result.error).toContain("namespace collision");
+ });
+
+ it("fails before settling tool calls beyond the total budget", async () => {
+ const tool = fakeTool("budgeted", async () => jsonResult({ ok: true }));
+ const result = expectFailed(
+ await runCodeModeScriptHeadless({
+ ctx: createHeadlessHarness([tool]),
+ code: `
+ await tools.call("openclaw:core:budgeted", {});
+ await tools.call("openclaw:core:budgeted", {});
+ return true;
+ `,
+ maxToolCalls: 1,
+ wallClockMs: 120_000,
+ }),
+ );
+
+ expect(result.code).toBe("tool_budget_exceeded");
+ expect(result.toolCallCount).toBe(2);
+ expect(tool.execute).toHaveBeenCalledOnce();
+ });
+
+ it("enforces one wall-clock deadline across worker and tool legs", async () => {
+ const slow = fakeTool("slow_leg", async (_toolCallId, _input, signal) => {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(resolve, 30_000);
+ signal?.addEventListener(
+ "abort",
+ () => {
+ clearTimeout(timer);
+ reject(new Error("aborted"));
+ },
+ { once: true },
+ );
+ });
+ return jsonResult({ ok: true });
+ });
+ const result = expectFailed(
+ await runCodeModeScriptHeadless({
+ ctx: createHeadlessHarness([slow]),
+ code: `
+ await tools.call("openclaw:core:slow_leg", {});
+ return true;
+ `,
+ wallClockMs: 15_000,
+ }),
+ );
+
+ expect(result.code).toBe("timeout");
+ expect(result.toolCallCount).toBe(1);
+ });
+
+ it("settles yield_control inline and resumes to completion", async () => {
+ const result = expectCompleted(
+ await runCodeModeScriptHeadless({
+ ctx: createHeadlessHarness(),
+ code: `
+ const yielded = await yield_control("pause");
+ return { yielded, resumed: true };
+ `,
+ }),
+ );
+
+ expect(result.value).toEqual({
+ yielded: { status: "yielded", reason: "pause" },
+ resumed: true,
+ });
+ expect(result.toolCallCount).toBe(0);
+ });
+
+ it("terminates an in-flight worker leg when aborted", async () => {
+ const ctx = createHeadlessHarness();
+ const config = testing.resolveCodeModeHeadlessConfig(ctx);
+ const controller = new AbortController();
+ const resultPromise = testing.runCodeModeWorker(
+ {
+ kind: "exec",
+ source: "while (true) {}",
+ config,
+ catalog: [],
+ apiFiles: [],
+ namespaces: [],
+ },
+ 5000,
+ undefined,
+ controller.signal,
+ );
+ setTimeout(() => controller.abort(), 100);
+
+ await expect(resultPromise).resolves.toMatchObject({ status: "failed", code: "timeout" });
+ });
+
+ it.each([
+ {
+ name: "syntax errors",
+ code: "return (;",
+ expectedCode: "internal_error",
+ overrides: undefined,
+ },
+ {
+ name: "output overages",
+ code: `text("x".repeat(2048)); return true;`,
+ expectedCode: "output_limit_exceeded",
+ overrides: { maxOutputBytes: 1024 },
+ },
+ ])("classifies $name", async ({ code, expectedCode, overrides }) => {
+ const result = expectFailed(
+ await runCodeModeScriptHeadless({
+ ctx: createHeadlessHarness(),
+ code,
+ overrides,
+ }),
+ );
+
+ expect(result.code).toBe(expectedCode);
+ });
+
+ it("clamps headless limit overrides to worker-safe bounds", () => {
+ const config = testing.resolveCodeModeHeadlessConfig(createHeadlessHarness(), {
+ timeoutMs: 1,
+ memoryLimitBytes: 1,
+ maxOutputBytes: 1,
+ maxSnapshotBytes: 1,
+ maxPendingToolCalls: 999,
+ });
+
+ expect(config).toMatchObject({
+ timeoutMs: 100,
+ memoryLimitBytes: 1024 * 1024,
+ maxOutputBytes: 1024,
+ maxSnapshotBytes: 1024,
+ maxPendingToolCalls: 128,
+ });
+ });
+});
diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts
index ba166cae3a4..9261f3f90f2 100644
--- a/src/agents/code-mode.ts
+++ b/src/agents/code-mode.ts
@@ -29,7 +29,9 @@ import {
createCodeModeApiVirtualFiles,
createCodeModeNamespaceRuntime,
describeCodeModeNamespacesForPrompt,
+ type CodeModeNamespaceDescriptor,
type CodeModeNamespaceRuntime,
+ type SerializedCodeModeNamespaceValue,
} from "./code-mode-namespaces.js";
import type { AgentToolUpdateCallback } from "./runtime/index.js";
import { optionalStringEnum } from "./schema/typebox.js";
@@ -123,7 +125,7 @@ type CodeModeRunState = {
type CodeModeToolContext = ToolSearchToolContext;
-type CodeModeFailureCode =
+export type CodeModeFailureCode =
| "invalid_input"
| "runtime_unavailable"
| "timeout"
@@ -131,6 +133,21 @@ type CodeModeFailureCode =
| "snapshot_limit_exceeded"
| "internal_error";
+export type CodeModeHeadlessResult =
+ | {
+ status: "completed";
+ value: unknown;
+ output: unknown[];
+ toolCallCount: number;
+ }
+ | {
+ status: "failed";
+ code: CodeModeFailureCode | "tool_budget_exceeded";
+ error: string;
+ output: unknown[];
+ toolCallCount: number;
+ };
+
type CodeModeWorkerResult =
| {
status: "completed";
@@ -255,6 +272,46 @@ function toToolSearchConfig(config: CodeModeConfig): ToolSearchConfig {
};
}
+function resolveCodeModeHeadlessConfig(
+ ctx: ToolSearchToolContext,
+ overrides?: Partial<
+ Pick<
+ CodeModeConfig,
+ | "timeoutMs"
+ | "memoryLimitBytes"
+ | "maxOutputBytes"
+ | "maxSnapshotBytes"
+ | "maxPendingToolCalls"
+ >
+ >,
+): CodeModeConfig {
+ const base = resolveCodeModeConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId);
+ return {
+ ...base,
+ timeoutMs: clampNumber(readPositiveInteger(overrides?.timeoutMs, base.timeoutMs), 100, 60_000),
+ memoryLimitBytes: clampNumber(
+ readPositiveInteger(overrides?.memoryLimitBytes, base.memoryLimitBytes),
+ 1024 * 1024,
+ 1024 * 1024 * 1024,
+ ),
+ maxOutputBytes: clampNumber(
+ readPositiveInteger(overrides?.maxOutputBytes, base.maxOutputBytes),
+ 1024,
+ 10 * 1024 * 1024,
+ ),
+ maxSnapshotBytes: clampNumber(
+ readPositiveInteger(overrides?.maxSnapshotBytes, base.maxSnapshotBytes),
+ 1024,
+ 256 * 1024 * 1024,
+ ),
+ maxPendingToolCalls: clampNumber(
+ readPositiveInteger(overrides?.maxPendingToolCalls, base.maxPendingToolCalls),
+ 1,
+ 128,
+ ),
+ };
+}
+
function removeExpiredRuns(now = Date.now()): void {
for (const [runId, state] of activeRuns) {
if (!isFutureDateTimestampMs(state.expiresAt, { nowMs: now })) {
@@ -646,6 +703,7 @@ async function runCodeModeWorker(
workerData: unknown,
timeoutMs: number,
workerUrl?: URL,
+ signal?: AbortSignal,
): Promise {
const resolvedWorkerUrl = workerUrl ?? codeModeWorkerUrl();
const sourceWorkerExecArgv = resolvedWorkerUrl.pathname.endsWith(".ts")
@@ -661,6 +719,7 @@ async function runCodeModeWorker(
return failedCodeModeWorkerResult(error, "runtime_unavailable");
}
let timer: ReturnType | undefined;
+ let onAbort: (() => void) | undefined;
try {
return await new Promise((resolve) => {
let settled = false;
@@ -680,6 +739,19 @@ async function runCodeModeWorker(
output: [],
});
}, timeoutMs);
+ onAbort = () => {
+ void worker.terminate();
+ finish({
+ status: "failed",
+ error: "code mode timeout exceeded",
+ code: "timeout",
+ output: [],
+ });
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+ if (signal?.aborted) {
+ onAbort();
+ }
worker.once("message", (message: unknown) => {
void worker.terminate();
const result = isRecord(message)
@@ -710,6 +782,305 @@ async function runCodeModeWorker(
if (timer) {
clearTimeout(timer);
}
+ if (onAbort) {
+ signal?.removeEventListener("abort", onAbort);
+ }
+ }
+}
+
+class CodeModeHeadlessTimeoutError extends Error {
+ constructor(message = "code mode headless wall-clock timeout exceeded") {
+ super(message);
+ this.name = "CodeModeHeadlessTimeoutError";
+ }
+}
+
+function createHeadlessAbortScope(signal: AbortSignal | undefined, wallClockMs: number) {
+ const controller = new AbortController();
+ const onAbort = () => controller.abort(signal?.reason);
+ signal?.addEventListener("abort", onAbort, { once: true });
+ if (signal?.aborted) {
+ onAbort();
+ }
+ const timer = setTimeout(() => controller.abort(new CodeModeHeadlessTimeoutError()), wallClockMs);
+ return {
+ signal: controller.signal,
+ cleanup: () => {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ },
+ };
+}
+
+function headlessAbortError(signal: AbortSignal): CodeModeHeadlessTimeoutError {
+ return signal.reason instanceof CodeModeHeadlessTimeoutError
+ ? signal.reason
+ : new CodeModeHeadlessTimeoutError("code mode headless execution aborted");
+}
+
+function headlessFailure(params: {
+ code: CodeModeFailureCode | "tool_budget_exceeded";
+ error: string;
+ output: unknown[];
+ toolCallCount: number;
+}): CodeModeHeadlessResult {
+ return { status: "failed", ...params };
+}
+
+function remainingHeadlessMs(deadline: number): number {
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) {
+ throw new CodeModeHeadlessTimeoutError();
+ }
+ return remaining;
+}
+
+async function awaitHeadlessDeadline(params: {
+ promise: Promise;
+ deadline: number;
+ signal?: AbortSignal;
+}): Promise {
+ const remainingMs = remainingHeadlessMs(params.deadline);
+ if (params.signal?.aborted) {
+ throw headlessAbortError(params.signal);
+ }
+ let timer: ReturnType | undefined;
+ let onAbort: (() => void) | undefined;
+ try {
+ const timeout = new Promise((_resolve, reject) => {
+ timer = setTimeout(() => reject(new CodeModeHeadlessTimeoutError()), remainingMs);
+ const signal = params.signal;
+ if (signal) {
+ onAbort = () => reject(headlessAbortError(signal));
+ signal.addEventListener("abort", onAbort, { once: true });
+ }
+ });
+ return await Promise.race([params.promise, timeout]);
+ } finally {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ if (params.signal && onAbort) {
+ params.signal.removeEventListener("abort", onAbort);
+ }
+ }
+}
+
+async function runHeadlessWorkerLeg(params: {
+ input: Record;
+ config: CodeModeConfig;
+ deadline: number;
+ signal: AbortSignal;
+}): Promise {
+ const remainingMs = remainingHeadlessMs(params.deadline);
+ const timeoutMs = Math.max(1, Math.min(params.config.timeoutMs, remainingMs));
+ const workerTimeoutMs = Math.max(1, Math.min(remainingMs, timeoutMs + 1000));
+ return await runCodeModeWorker(
+ {
+ ...params.input,
+ config: { ...params.config, timeoutMs },
+ },
+ workerTimeoutMs,
+ undefined,
+ params.signal,
+ );
+}
+
+function normalizeHeadlessNamespaceValue(
+ descriptor: SerializedCodeModeNamespaceValue,
+): SerializedCodeModeNamespaceValue {
+ if (descriptor.kind === "array") {
+ return { kind: "array", items: descriptor.items.map(normalizeHeadlessNamespaceValue) };
+ }
+ if (descriptor.kind === "object") {
+ return {
+ kind: "object",
+ entries: descriptor.entries.map(([key, value]) => {
+ if (!key) {
+ throw new ToolInputError("code mode namespace descriptor keys must not be empty");
+ }
+ return [key, normalizeHeadlessNamespaceValue(value)];
+ }),
+ };
+ }
+ if (descriptor.kind !== "value") {
+ return descriptor;
+ }
+ return { kind: "value", value: toCodeModeJsonSafe(descriptor.value) };
+}
+
+function normalizeHeadlessNamespace(
+ descriptor: CodeModeNamespaceDescriptor,
+): CodeModeNamespaceDescriptor {
+ return { ...descriptor, scope: normalizeHeadlessNamespaceValue(descriptor.scope) };
+}
+
+function mergeHeadlessNamespaces(
+ registered: CodeModeNamespaceDescriptor[],
+ extra: CodeModeNamespaceDescriptor[],
+): CodeModeNamespaceDescriptor[] {
+ const ids = new Set(registered.map((descriptor) => descriptor.id));
+ const globalNames = new Set(registered.map((descriptor) => descriptor.globalName));
+ const merged = [...registered];
+ for (const descriptor of extra) {
+ if (ids.has(descriptor.id) || globalNames.has(descriptor.globalName)) {
+ throw new ToolInputError(
+ `code mode namespace collision for ${descriptor.id} (${descriptor.globalName})`,
+ );
+ }
+ ids.add(descriptor.id);
+ globalNames.add(descriptor.globalName);
+ merged.push(normalizeHeadlessNamespace(descriptor));
+ }
+ return merged;
+}
+
+function headlessNamespaceFreezePrelude(descriptors: CodeModeNamespaceDescriptor[]): string {
+ const globalNames = JSON.stringify(descriptors.map((descriptor) => descriptor.globalName));
+ return `;(() => {
+ const seen = new WeakSet();
+ const freeze = (value) => {
+ if ((value === null || (typeof value !== "object" && typeof value !== "function")) || seen.has(value)) return value;
+ seen.add(value);
+ for (const key of Object.keys(value)) freeze(value[key]);
+ return Object.freeze(value);
+ };
+ for (const name of ${globalNames}) freeze(globalThis[name]);
+ })();\n`;
+}
+
+/** Run Code Mode to completion without publishing resumable snapshot state. */
+export async function runCodeModeScriptHeadless(params: {
+ ctx: ToolSearchToolContext;
+ code: string;
+ language?: "javascript" | "typescript";
+ overrides?: Partial<
+ Pick<
+ CodeModeConfig,
+ | "timeoutMs"
+ | "memoryLimitBytes"
+ | "maxOutputBytes"
+ | "maxSnapshotBytes"
+ | "maxPendingToolCalls"
+ >
+ >;
+ wallClockMs?: number;
+ maxToolCalls?: number;
+ extraNamespaces?: CodeModeNamespaceDescriptor[];
+ signal?: AbortSignal;
+}): Promise {
+ const config = resolveCodeModeHeadlessConfig(params.ctx, params.overrides);
+ const wallClockMs = clampNumber(readPositiveInteger(params.wallClockMs, 30_000), 1, 300_000);
+ const maxToolCalls = clampNumber(readPositiveInteger(params.maxToolCalls, 5), 1, 128);
+ const deadline = Date.now() + wallClockMs;
+ const abortScope = createHeadlessAbortScope(params.signal, wallClockMs);
+ const output: unknown[] = [];
+ let toolCallCount = 0;
+ try {
+ const runtime = new ToolSearchRuntime(params.ctx, toToolSearchConfig(config));
+ const catalog = runtime.all({ includeMcp: false });
+ const namespaceCatalog = runtime.namespaceEntries();
+ const namespaceRuntime = await awaitHeadlessDeadline({
+ promise: createCodeModeNamespaceRuntime(params.ctx, namespaceCatalog),
+ deadline,
+ signal: abortScope.signal,
+ });
+ const preparedSource = await awaitHeadlessDeadline({
+ promise: prepareSource({ code: params.code, language: params.language, config }),
+ deadline,
+ signal: abortScope.signal,
+ });
+ const namespaces = mergeHeadlessNamespaces(
+ namespaceRuntime.descriptors,
+ params.extraNamespaces ?? [],
+ );
+ const source = `${headlessNamespaceFreezePrelude(namespaces)}${preparedSource}`;
+ const parentToolCallId = `headless:${randomUUID()}`;
+ let result = normalizeCodeModeWorkerResult(
+ await runHeadlessWorkerLeg({
+ input: {
+ kind: "exec",
+ source,
+ catalog,
+ apiFiles: createCodeModeApiVirtualFiles(namespaceCatalog),
+ namespaces,
+ },
+ config,
+ deadline,
+ signal: abortScope.signal,
+ }),
+ );
+
+ while (true) {
+ output.push(...result.output);
+ enforceOutputLimit(output, config);
+ if (result.status === "completed") {
+ enforceResultLimit({ output, value: result.value, config });
+ return { status: "completed", value: result.value, output, toolCallCount };
+ }
+ if (result.status === "failed") {
+ return headlessFailure({
+ code: result.code,
+ error: result.error,
+ output,
+ toolCallCount,
+ });
+ }
+
+ enforceSnapshotPayloadLimits({ snapshotBytes: result.snapshotBytes, config, output });
+ const requestedToolCalls = result.pendingRequests.filter(
+ (request) => request.method === "call" || request.method === "namespace",
+ ).length;
+ toolCallCount += requestedToolCalls;
+ if (toolCallCount > maxToolCalls) {
+ return headlessFailure({
+ code: "tool_budget_exceeded",
+ error: `code mode headless tool budget exceeded (${maxToolCalls})`,
+ output,
+ toolCallCount,
+ });
+ }
+
+ const settledRequests = await awaitHeadlessDeadline({
+ promise: Promise.all(
+ result.pendingRequests.map((request) =>
+ runBridgeRequest({
+ runtime,
+ namespaceRuntime,
+ parentToolCallId,
+ request,
+ signal: abortScope.signal,
+ }),
+ ),
+ ),
+ deadline,
+ signal: abortScope.signal,
+ });
+ result = normalizeCodeModeWorkerResult(
+ await runHeadlessWorkerLeg({
+ input: {
+ kind: "resume",
+ snapshotBytes: result.snapshotBytes,
+ settledRequests,
+ },
+ config,
+ deadline,
+ signal: abortScope.signal,
+ }),
+ );
+ }
+ } catch (error) {
+ return headlessFailure({
+ code: error instanceof CodeModeHeadlessTimeoutError ? "timeout" : codeModeFailureCode(error),
+ error:
+ error instanceof CodeModeHeadlessTimeoutError
+ ? error.message
+ : codeModeFailureMessage(error),
+ output,
+ toolCallCount,
+ });
+ } finally {
+ abortScope.cleanup();
}
}
@@ -1272,6 +1643,7 @@ export const testing = {
codeModeWorkerUrl,
normalizeCodeModeWorkerResult,
runCodeModeWorker,
+ resolveCodeModeHeadlessConfig,
resolveCodeModeWorkerUrl,
resolveCodeModeConfig,
getTypescriptRuntimePromise: (): Promise | null =>
diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts
index 24dd5527e2a..ea94bd027a2 100644
--- a/src/agents/tool-search.ts
+++ b/src/agents/tool-search.ts
@@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getPluginToolMeta, type PluginToolMcpMeta } from "../plugins/tools.js";
import {
isToolWrappedWithBeforeToolCallHook,
+ rewrapToolWithBeforeToolCallHook,
type HookContext,
wrapToolWithBeforeToolCallHook,
} from "./agent-tools.before-tool-call.js";
@@ -712,6 +713,29 @@ function shouldCatalogTool(tool: AnyAgentTool): boolean {
return true;
}
+/**
+ * Register a catalog owned only by an explicit ref (no session keys), for
+ * headless callers like cron trigger evaluation. Registration internals stay
+ * module-private; this is the single public seam for ref-only catalogs.
+ */
+export function registerHeadlessToolSearchCatalog(params: {
+ catalogRef: ToolSearchCatalogRef;
+ tools: readonly AnyAgentTool[];
+ hookContext?: HookContext;
+}): void {
+ const { catalogRef, tools, hookContext } = params;
+ const entries = tools
+ .filter((tool) => shouldCatalogTool(tool))
+ .map((tool) => {
+ const scopedTool =
+ hookContext && isToolWrappedWithBeforeToolCallHook(tool)
+ ? rewrapToolWithBeforeToolCallHook(tool, hookContext)
+ : tool;
+ return toCatalogEntry(scopedTool, undefined, hookContext);
+ });
+ registerToolSearchCatalog({ catalogRef, entries });
+}
+
export function collectUniqueCatalogToolNames(tools: readonly AnyAgentTool[]): Set {
const nameCounts = new Map();
for (const tool of tools) {
diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts
index 3ba2cbfbb85..11dd4c16e7d 100644
--- a/src/agents/tools/cron-tool.schema.test.ts
+++ b/src/agents/tools/cron-tool.schema.test.ts
@@ -58,6 +58,7 @@ describe("createCronToolSchema", () => {
"schedule",
"sessionKey",
"sessionTarget",
+ "trigger",
"wakeMode",
].toSorted(),
);
@@ -78,6 +79,7 @@ describe("createCronToolSchema", () => {
"schedule",
"sessionKey",
"sessionTarget",
+ "trigger",
"wakeMode",
].toSorted(),
);
diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts
index 80d94ac4c2c..5af1f9f8583 100644
--- a/src/agents/tools/cron-tool.ts
+++ b/src/agents/tools/cron-tool.ts
@@ -170,6 +170,17 @@ function createCronPayloadSchema(): TSchema {
);
}
+function createCronTriggerSchema(params: { nullableClears: boolean }): TSchema {
+ const trigger = Type.Object(
+ {
+ script: Type.String({ minLength: 1, maxLength: 65_536 }),
+ once: Type.Optional(Type.Boolean()),
+ },
+ { additionalProperties: false },
+ );
+ return Type.Optional(params.nullableClears ? Type.Union([trigger, Type.Null()]) : trigger);
+}
+
function cronDeliverySchema(params: { nullableClears: boolean }) {
const failureDestinationObject = Type.Object(
{
@@ -281,6 +292,7 @@ function createCronJobObjectSchema(): TSchema {
),
),
schedule: createCronScheduleSchema(),
+ trigger: createCronTriggerSchema({ nullableClears: false }),
sessionTarget: Type.Optional(
Type.String({
description: "main | isolated | current | session:",
@@ -312,6 +324,7 @@ function createCronPatchObjectSchema(): TSchema {
}),
),
schedule: createCronScheduleSchema(),
+ trigger: createCronTriggerSchema({ nullableClears: true }),
sessionTarget: Type.Optional(Type.String({ description: "Session target" })),
wakeMode: optionalStringEnum(CRON_WAKE_MODES),
payload: Type.Optional(
@@ -901,6 +914,7 @@ JOB SCHEMA (for add action):
{
"name": "string",
"schedule": { ... }, // required
+ "trigger": { "script": "...", "once": false }, // optional condition gate for every/cron
"payload": { ... }, // required
"delivery": { ... }, // optional announce for isolated/current/session, webhook for any target
"sessionTarget": "main" | "isolated" | "current" | "session:",
@@ -929,6 +943,8 @@ SCHEDULE TYPES (schedule.kind):
tz omitted => Gateway host local timezone, not UTC.
Example 6pm Shanghai daily: { "kind": "cron", "expr": "0 18 * * *", "tz": "Asia/Shanghai" }
+Optional trigger scripts poll headlessly on every/cron schedules and run the payload only when they return { fire: true }.
+
For "at", ISO timestamps without timezone are UTC.
PAYLOAD TYPES (payload.kind):
diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts
index 5dac973dfa0..4b41c6b32fc 100644
--- a/src/cli/cron-cli/register.cron-add.ts
+++ b/src/cli/cron-cli/register.cron-add.ts
@@ -26,6 +26,7 @@ import {
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
+import { readCronTriggerScript } from "./trigger-options.js";
export function registerCronStatusCommand(cron: Command) {
addGatewayClientOptions(
@@ -113,6 +114,8 @@ export function registerCronAddCommand(cron: Command) {
)
.option("--stagger ", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)", false)
+ .option("--trigger-script ", "Condition script file, or - for stdin")
+ .option("--trigger-once", "Disable after the first successful triggered run", false)
.option("--system-event ", "System event payload (main session)")
.option("--message ", "Agent message payload")
.option("--command ", "Command payload run as sh -lc on the Gateway")
@@ -382,6 +385,16 @@ export function registerCronAddCommand(cron: Command) {
}
const sessionKey = normalizeOptionalString(opts.sessionKey);
+ const triggerScriptPath = normalizeOptionalString(opts.triggerScript);
+ if (opts.triggerOnce && !triggerScriptPath) {
+ throw new Error("--trigger-once requires --trigger-script");
+ }
+ const trigger = triggerScriptPath
+ ? {
+ script: await readCronTriggerScript(triggerScriptPath),
+ ...(opts.triggerOnce ? { once: true } : {}),
+ }
+ : undefined;
if ((payload.kind === "agentTurn" || payload.kind === "command") && !agentId) {
defaultRuntime.error(
@@ -404,6 +417,7 @@ export function registerCronAddCommand(cron: Command) {
agentId,
sessionKey,
schedule,
+ trigger,
sessionTarget,
wakeMode,
payload,
diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts
index ff68723fb48..46196437c09 100644
--- a/src/cli/cron-cli/register.cron-edit.ts
+++ b/src/cli/cron-cli/register.cron-edit.ts
@@ -24,6 +24,7 @@ import {
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
+import { readCronTriggerScript } from "./trigger-options.js";
const CRON_EDIT_LOOKUP_PAGE_SIZE = 200;
const CRON_EDIT_LOOKUP_MAX_PAGES = 50;
@@ -118,6 +119,9 @@ export function registerCronEditCommand(cron: Command) {
)
.option("--stagger ", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)")
+ .option("--trigger-script ", "Set condition script from file, or - for stdin")
+ .option("--trigger-once", "Disable after the first successful triggered run", false)
+ .option("--clear-trigger", "Remove the condition trigger", false)
.option("--system-event ", "Set systemEvent payload")
.option("--message ", "Set agentTurn payload message")
.option("--command ", "Set command payload run as sh -lc on the Gateway")
@@ -276,6 +280,25 @@ export function registerCronEditCommand(cron: Command) {
patch.sessionKey = null;
}
+ const triggerScriptPath = normalizeOptionalString(opts.triggerScript);
+ if (opts.clearTrigger && (triggerScriptPath || opts.triggerOnce)) {
+ throw new Error("Use --clear-trigger or trigger options, not both");
+ }
+ if (opts.clearTrigger) {
+ patch.trigger = null;
+ } else if (triggerScriptPath) {
+ patch.trigger = {
+ script: await readCronTriggerScript(triggerScriptPath),
+ ...(opts.triggerOnce ? { once: true } : {}),
+ };
+ } else if (opts.triggerOnce) {
+ const existing = await readCronJobForEdit(opts, String(id));
+ if (!existing.trigger) {
+ throw new Error("--trigger-once requires an existing trigger or --trigger-script");
+ }
+ patch.trigger = { ...existing.trigger, once: true };
+ }
+
const scheduleRequest = resolveCronEditScheduleRequest({
at: opts.at,
cron: opts.cron,
diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts
index 4bc7a84a9d9..7e426a1695d 100644
--- a/src/cli/cron-cli/shared.test.ts
+++ b/src/cli/cron-cli/shared.test.ts
@@ -155,6 +155,26 @@ describe("printCronList", () => {
expectLogsToInclude(logs, "(stagger 5m)");
});
+ it("marks trigger schedules and shows evaluation details", () => {
+ const job = createBaseJob({
+ schedule: { kind: "every", everyMs: 30_000 },
+ trigger: { script: "json({ fire: true })", once: true },
+ state: {
+ triggerEvalCount: 4,
+ lastTriggerEvalAtMs: Date.now() - 30_000,
+ lastTriggerFireAtMs: Date.now() - 60_000,
+ },
+ });
+
+ const list = createRuntimeLogCapture();
+ printCronList([job], list.runtime);
+ expectLogsToInclude(list.logs, "every 30s+trigger");
+
+ const show = createRuntimeLogCapture();
+ printCronShow(job, show.runtime);
+ expectLogsToInclude(show.logs, "trigger: once=yes; evals=4;");
+ });
+
it("shows on-exit schedules in list and show output", () => {
const job = createBaseJob({
id: "on-exit-job",
diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts
index 120978f38ba..200afe570b3 100644
--- a/src/cli/cron-cli/shared.ts
+++ b/src/cli/cron-cli/shared.ts
@@ -397,12 +397,13 @@ const formatRelative = (ms: number | null | undefined, nowMs: number) => {
return delta >= 0 ? `in ${label}` : `${label} ago`;
};
-const formatSchedule = (schedule: CronSchedule | undefined) => {
+const formatSchedule = (schedule: CronSchedule | undefined, hasTrigger = false) => {
+ const suffix = hasTrigger ? "+trigger" : "";
if (schedule?.kind === "at") {
- return `at ${formatIsoMinute(schedule.at)}`;
+ return `at ${formatIsoMinute(schedule.at)}${suffix}`;
}
if (schedule?.kind === "every") {
- return `every ${formatDurationHuman(schedule.everyMs)}`;
+ return `every ${formatDurationHuman(schedule.everyMs)}${suffix}`;
}
if (schedule?.kind === "on-exit") {
const cwd = schedule.cwd ? ` @ ${schedule.cwd}` : "";
@@ -411,7 +412,9 @@ const formatSchedule = (schedule: CronSchedule | undefined) => {
if (schedule?.kind !== "cron") {
return "-";
}
- const base = schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
+ const base = schedule.tz
+ ? `cron ${schedule.expr} @ ${schedule.tz}${suffix}`
+ : `cron ${schedule.expr}${suffix}`;
const staggerMs = resolveCronStaggerMs(schedule);
if (staggerMs <= 0) {
return `${base} (exact)`;
@@ -482,7 +485,7 @@ export function printCronList(
CRON_NAME_PAD,
);
const scheduleLabel = pad(
- truncate(formatSchedule(job.schedule), CRON_SCHEDULE_PAD),
+ truncate(formatSchedule(job.schedule, job.trigger !== undefined), CRON_SCHEDULE_PAD),
CRON_SCHEDULE_PAD,
);
const nextLabel = pad(
@@ -571,7 +574,10 @@ export function printCronShow(
runtime.log(`owner agent: ${job.owner?.agentId ?? "-"}`);
runtime.log(`owner session: ${job.owner?.sessionKey ?? "-"}`);
runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`);
- runtime.log(`schedule: ${formatSchedule(job.schedule)}`);
+ runtime.log(`schedule: ${formatSchedule(job.schedule, job.trigger !== undefined)}`);
+ runtime.log(
+ `trigger: ${job.trigger ? `once=${job.trigger.once === true ? "yes" : "no"}; evals=${job.state.triggerEvalCount ?? 0}; last eval=${formatRelative(job.state.lastTriggerEvalAtMs, Date.now())}; last fire=${formatRelative(job.state.lastTriggerFireAtMs, Date.now())}` : "-"}`,
+ );
runtime.log(`session: ${job.sessionTarget ?? "-"}`);
runtime.log(`agent: ${job.agentId ?? "-"}`);
runtime.log(`model: ${job.payload.kind === "agentTurn" ? (job.payload.model ?? "-") : "-"}`);
diff --git a/src/cli/cron-cli/trigger-options.test.ts b/src/cli/cron-cli/trigger-options.test.ts
new file mode 100644
index 00000000000..676c151b226
--- /dev/null
+++ b/src/cli/cron-cli/trigger-options.test.ts
@@ -0,0 +1,79 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { Command } from "commander";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const callGatewayFromCli = vi.fn();
+
+vi.mock("../gateway-rpc.js", async () => {
+ const actual = await vi.importActual("../gateway-rpc.js");
+ return {
+ ...actual,
+ callGatewayFromCli: (...args: Parameters) =>
+ callGatewayFromCli(...args),
+ };
+});
+
+const { registerCronAddCommand } = await import("./register.cron-add.js");
+const { registerCronEditCommand } = await import("./register.cron-edit.js");
+
+describe("cron trigger CLI options", () => {
+ let fixtureRoot = "";
+
+ beforeEach(async () => {
+ fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cron-trigger-cli-"));
+ callGatewayFromCli.mockReset();
+ callGatewayFromCli.mockResolvedValue({ ok: true });
+ });
+
+ afterEach(async () => {
+ await fs.rm(fixtureRoot, { recursive: true, force: true });
+ });
+
+ it("reads --trigger-script client-side and sends trigger metadata on add", async () => {
+ const scriptPath = path.join(fixtureRoot, "watch.js");
+ await fs.writeFile(scriptPath, " json({ fire: true }) \n", "utf8");
+ const program = new Command().exitOverride();
+ registerCronAddCommand(program);
+
+ await program.parseAsync(
+ [
+ "add",
+ "--name",
+ "watcher",
+ "--every",
+ "30s",
+ "--trigger-script",
+ scriptPath,
+ "--trigger-once",
+ "--system-event",
+ "changed",
+ "--session",
+ "main",
+ ],
+ { from: "user" },
+ );
+
+ expect(callGatewayFromCli).toHaveBeenCalledWith(
+ "cron.add",
+ expect.objectContaining({ triggerScript: scriptPath, triggerOnce: true }),
+ expect.objectContaining({
+ trigger: { script: "json({ fire: true })", once: true },
+ }),
+ );
+ });
+
+ it("maps --clear-trigger to a nullable edit patch", async () => {
+ const program = new Command().exitOverride();
+ registerCronEditCommand(program);
+
+ await program.parseAsync(["edit", "job-1", "--clear-trigger"], { from: "user" });
+
+ expect(callGatewayFromCli).toHaveBeenCalledWith(
+ "cron.update",
+ expect.objectContaining({ clearTrigger: true }),
+ { id: "job-1", patch: { trigger: null } },
+ );
+ });
+});
diff --git a/src/cli/cron-cli/trigger-options.ts b/src/cli/cron-cli/trigger-options.ts
new file mode 100644
index 00000000000..98a29f62ec9
--- /dev/null
+++ b/src/cli/cron-cli/trigger-options.ts
@@ -0,0 +1,40 @@
+// Client-side trigger script loading for cron create/edit commands.
+import fs from "node:fs/promises";
+
+const MAX_CRON_TRIGGER_SCRIPT_BYTES = 65_536;
+
+async function readStdin(stream: NodeJS.ReadableStream): Promise {
+ const chunks: Buffer[] = [];
+ let total = 0;
+ for await (const chunk of stream) {
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ total += buffer.byteLength;
+ if (total > MAX_CRON_TRIGGER_SCRIPT_BYTES) {
+ throw new Error("Trigger script exceeds 65536 bytes");
+ }
+ chunks.push(buffer);
+ }
+ return Buffer.concat(chunks, total).toString("utf8");
+}
+
+/** Reads a trigger script locally before sending the cron RPC. */
+export async function readCronTriggerScript(
+ source: string,
+ deps?: {
+ readFile?: (path: string) => Promise;
+ stdin?: NodeJS.ReadableStream;
+ },
+): Promise {
+ const raw =
+ source === "-"
+ ? await readStdin(deps?.stdin ?? process.stdin)
+ : await (deps?.readFile ?? ((path) => fs.readFile(path, "utf8")))(source);
+ if (Buffer.byteLength(raw, "utf8") > MAX_CRON_TRIGGER_SCRIPT_BYTES) {
+ throw new Error("Trigger script exceeds 65536 bytes");
+ }
+ const script = raw.trim();
+ if (!script) {
+ throw new Error("Trigger script must not be empty");
+ }
+ return script;
+}
diff --git a/src/config/cron-limits.ts b/src/config/cron-limits.ts
index 2cb2c78d5a0..e7442caa7a7 100644
--- a/src/config/cron-limits.ts
+++ b/src/config/cron-limits.ts
@@ -3,6 +3,7 @@ import type { CronConfig } from "./types.cron.js";
/** Default maximum number of cron jobs allowed to run at once. */
export const DEFAULT_CRON_MAX_CONCURRENT_RUNS = 8;
+export const DEFAULT_CRON_TRIGGER_MIN_INTERVAL_MS = 30_000;
/** Resolves cron concurrency config, flooring finite values and clamping to at least one. */
export function resolveCronMaxConcurrentRuns(
@@ -14,3 +15,12 @@ export function resolveCronMaxConcurrentRuns(
}
return DEFAULT_CRON_MAX_CONCURRENT_RUNS;
}
+
+/** Resolves the minimum cadence for trigger-bearing cron jobs. */
+export function resolveCronTriggerMinIntervalMs(cronConfig?: Pick): number {
+ const raw = cronConfig?.triggers?.minIntervalMs;
+ if (typeof raw === "number" && Number.isFinite(raw)) {
+ return Math.max(1, Math.floor(raw));
+ }
+ return DEFAULT_CRON_TRIGGER_MIN_INTERVAL_MS;
+}
diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts
index 10bf68594e8..0923d7a20f8 100644
--- a/src/config/types.cron.ts
+++ b/src/config/types.cron.ts
@@ -33,6 +33,10 @@ export type CronConfig = {
enabled?: boolean;
store?: string;
maxConcurrentRuns?: number;
+ triggers?: {
+ enabled?: boolean;
+ minIntervalMs?: number;
+ };
/** Override default retry policy for one-shot jobs on transient errors. */
retry?: CronRetryConfig;
/**
diff --git a/src/config/zod-schema.cron-triggers.test.ts b/src/config/zod-schema.cron-triggers.test.ts
new file mode 100644
index 00000000000..3fd5df98b65
--- /dev/null
+++ b/src/config/zod-schema.cron-triggers.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { OpenClawSchema } from "./zod-schema.js";
+
+describe("OpenClawSchema cron triggers", () => {
+ it("accepts the strict trigger gate and interval floor", () => {
+ expect(
+ OpenClawSchema.parse({ cron: { triggers: { enabled: true, minIntervalMs: 45_000 } } }).cron
+ ?.triggers,
+ ).toEqual({ enabled: true, minIntervalMs: 45_000 });
+ });
+
+ it("rejects invalid and unknown trigger settings", () => {
+ expect(OpenClawSchema.safeParse({ cron: { triggers: { minIntervalMs: 0 } } }).success).toBe(
+ false,
+ );
+ expect(
+ OpenClawSchema.safeParse({ cron: { triggers: { enabled: true, extra: true } } }).success,
+ ).toBe(false);
+ });
+});
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 71c552da962..6e2cbb13979 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -921,6 +921,13 @@ export const OpenClawSchema = z
enabled: z.boolean().optional(),
store: z.string().optional(),
maxConcurrentRuns: z.number().int().positive().optional(),
+ triggers: z
+ .object({
+ enabled: z.boolean().optional(),
+ minIntervalMs: z.number().int().positive().optional(),
+ })
+ .strict()
+ .optional(),
retry: z
.object({
maxAttempts: z.number().int().min(0).max(10).optional(),
diff --git a/src/cron/isolated-agent/run-config.ts b/src/cron/isolated-agent/run-config.ts
index 66d2c66547b..1be9f1a66fc 100644
--- a/src/cron/isolated-agent/run-config.ts
+++ b/src/cron/isolated-agent/run-config.ts
@@ -1,9 +1,27 @@
/** Builds isolated cron runner config from global defaults plus agent overrides. */
import type { resolveAgentConfig } from "../../agents/agent-scope.js";
+import {
+ getRuntimeConfigSnapshot,
+ getRuntimeConfigSourceSnapshot,
+ selectApplicableRuntimeConfig,
+} from "../../config/config.js";
import type { AgentDefaultsConfig } from "../../config/types.js";
+import type { OpenClawConfig } from "../../config/types.openclaw.js";
type ResolvedAgentConfig = NonNullable>;
+/** Selects the active reloadable config when it descends from the cron caller's snapshot. */
+export function resolveCronActiveRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
+ const runtimeConfig = getRuntimeConfigSnapshot();
+ const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
+ if (!runtimeConfig || !runtimeSourceConfig) {
+ return cfg;
+ }
+ return (
+ selectApplicableRuntimeConfig({ inputConfig: cfg, runtimeConfig, runtimeSourceConfig }) ?? cfg
+ );
+}
+
function extractCronAgentDefaultsOverride(agentConfigOverride?: ResolvedAgentConfig) {
const {
model: overrideModel,
diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts
index 48d30337c7c..e2a191b2163 100644
--- a/src/cron/isolated-agent/run.ts
+++ b/src/cron/isolated-agent/run.ts
@@ -11,11 +11,6 @@ import { expandToolGroups, normalizeToolName } from "../../agents/tool-policy.js
import { deriveContextPromptTokens } from "../../agents/usage.js";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { CliDeps } from "../../cli/outbound-send-deps.js";
-import {
- getRuntimeConfigSnapshot,
- getRuntimeConfigSourceSnapshot,
- selectApplicableRuntimeConfig,
-} from "../../config/config.js";
import { resolveAgentModelPrimaryValue } from "../../config/model-input.js";
import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js";
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
@@ -80,7 +75,7 @@ import {
resolveHeartbeatAckMaxChars,
} from "./helpers.js";
import { resolveCronModelSelection } from "./model-selection.js";
-import { buildCronAgentDefaultsConfig } from "./run-config.js";
+import { buildCronAgentDefaultsConfig, resolveCronActiveRuntimeConfig } from "./run-config.js";
import { resolveCronPreflightCandidates } from "./run-fallback-policy.js";
import {
adoptCronRunSessionMetadata,
@@ -570,21 +565,6 @@ type CronPreparationResult =
| { ok: true; context: PreparedCronRunContext }
| { ok: false; result: RunCronAgentTurnResult };
-function resolveCronActiveRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
- const runtimeConfig = getRuntimeConfigSnapshot();
- const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
- if (!runtimeConfig || !runtimeSourceConfig) {
- return cfg;
- }
- return (
- selectApplicableRuntimeConfig({
- inputConfig: cfg,
- runtimeConfig,
- runtimeSourceConfig,
- }) ?? cfg
- );
-}
-
async function prepareCronRunContext(params: {
input: RunCronAgentTurnParams;
isFastTestEnv: boolean;
diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts
index 199c491a6c8..20e826c59a3 100644
--- a/src/cron/normalize.test.ts
+++ b/src/cron/normalize.test.ts
@@ -73,6 +73,21 @@ function normalizeMainSystemEventCreateJob(params: {
}
describe("normalizeCronJobCreate", () => {
+ it("normalizes trigger scripts and preserves patch clears", () => {
+ const normalized = normalizeCronJobCreate({
+ name: "watcher",
+ enabled: true,
+ schedule: { kind: "every", everyMs: 30_000 },
+ sessionTarget: "main",
+ wakeMode: "now",
+ payload: { kind: "systemEvent", text: "changed" },
+ trigger: { script: " json({ fire: true }) ", once: "true", ignored: true },
+ }) as unknown as Record;
+
+ expect(normalized.trigger).toEqual({ script: "json({ fire: true })", once: true });
+ expect(normalizeCronJobPatch({ trigger: null })).toEqual({ trigger: null });
+ });
+
it("trims agentId and drops null", () => {
const normalized = normalizeCronJobCreate({
name: "agent-set",
diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts
index bd658b431ed..f60a2213e2a 100644
--- a/src/cron/normalize.ts
+++ b/src/cron/normalize.ts
@@ -353,6 +353,15 @@ function coercePayload(payload: UnknownRecord) {
return next;
}
+function coerceTrigger(trigger: UnknownRecord): UnknownRecord {
+ const script = typeof trigger.script === "string" ? trigger.script.trim() : "";
+ const once = parseBoolean(trigger.once);
+ return {
+ script,
+ ...(once !== undefined ? { once } : {}),
+ };
+}
+
function coerceDelivery(delivery: UnknownRecord) {
const next: UnknownRecord = { ...delivery };
const parsed = parseDeliveryInput(delivery);
@@ -615,6 +624,16 @@ export function normalizeCronJobInput(
next.payload = coercePayload(base.payload);
}
+ if ("trigger" in base) {
+ if (base.trigger === null) {
+ next.trigger = null;
+ } else if (isRecord(base.trigger)) {
+ next.trigger = coerceTrigger(base.trigger);
+ } else {
+ delete next.trigger;
+ }
+ }
+
if (isRecord(base.delivery)) {
next.delivery = coerceDelivery(base.delivery);
}
diff --git a/src/cron/persisted-shape.trigger.test.ts b/src/cron/persisted-shape.trigger.test.ts
new file mode 100644
index 00000000000..970a878ac92
--- /dev/null
+++ b/src/cron/persisted-shape.trigger.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { getInvalidPersistedCronJobReason } from "./persisted-shape.js";
+
+function candidate(overrides: Record = {}) {
+ return {
+ id: "job-1",
+ schedule: { kind: "every", everyMs: 30_000 },
+ trigger: { script: "json({ fire: true })" },
+ payload: { kind: "systemEvent", text: "changed" },
+ sessionTarget: "main",
+ ...overrides,
+ };
+}
+
+describe("persisted cron trigger shape", () => {
+ it("accepts a non-empty trigger on recurring schedules", () => {
+ expect(getInvalidPersistedCronJobReason(candidate())).toBeNull();
+ expect(
+ getInvalidPersistedCronJobReason(
+ candidate({ schedule: { kind: "cron", expr: "* * * * *" } }),
+ ),
+ ).toBeNull();
+ });
+
+ it("rejects empty scripts and non-object triggers", () => {
+ expect(getInvalidPersistedCronJobReason(candidate({ trigger: { script: " " } }))).toBe(
+ "invalid-trigger",
+ );
+ expect(getInvalidPersistedCronJobReason(candidate({ trigger: "script" }))).toBe(
+ "invalid-trigger",
+ );
+ });
+
+ it("rejects triggers on at and on-exit schedules", () => {
+ expect(
+ getInvalidPersistedCronJobReason(
+ candidate({ schedule: { kind: "at", at: "2026-08-01T00:00:00.000Z" } }),
+ ),
+ ).toBe("invalid-trigger");
+ expect(
+ getInvalidPersistedCronJobReason(
+ candidate({ schedule: { kind: "on-exit", command: "make build" } }),
+ ),
+ ).toBe("invalid-trigger");
+ });
+});
diff --git a/src/cron/persisted-shape.ts b/src/cron/persisted-shape.ts
index 37ed6dc4bc7..65704e0e3e7 100644
--- a/src/cron/persisted-shape.ts
+++ b/src/cron/persisted-shape.ts
@@ -6,6 +6,7 @@ export type InvalidPersistedCronJobReason =
| "missing-id"
| "missing-schedule"
| "invalid-schedule"
+ | "invalid-trigger"
| "missing-payload"
| "invalid-payload";
@@ -63,6 +64,21 @@ export function getInvalidPersistedCronJobReason(
return "invalid-schedule";
}
}
+ if ("trigger" in candidate) {
+ const trigger = candidate.trigger;
+ if (!trigger || typeof trigger !== "object" || Array.isArray(trigger)) {
+ return "invalid-trigger";
+ }
+ const script = (trigger as Record).script;
+ if (
+ typeof script !== "string" ||
+ script.trim().length === 0 ||
+ scheduleKind === "at" ||
+ scheduleKind === "on-exit"
+ ) {
+ return "invalid-trigger";
+ }
+ }
const payload = candidate.payload;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return "missing-payload";
diff --git a/src/cron/run-log-types.ts b/src/cron/run-log-types.ts
index 05697fd44ea..46e6ef65752 100644
--- a/src/cron/run-log-types.ts
+++ b/src/cron/run-log-types.ts
@@ -30,4 +30,5 @@ export type CronRunLogEntry = {
runAtMs?: number;
durationMs?: number;
nextRunAtMs?: number;
+ triggerFired?: boolean;
} & CronRunTelemetry;
diff --git a/src/cron/run-log/entry-codec.ts b/src/cron/run-log/entry-codec.ts
index f5cf8fcb69b..6446e9b0b1f 100644
--- a/src/cron/run-log/entry-codec.ts
+++ b/src/cron/run-log/entry-codec.ts
@@ -81,6 +81,7 @@ export function parseCronRunLogEntryObject(
runAtMs: entryObj.runAtMs,
durationMs: entryObj.durationMs,
nextRunAtMs: entryObj.nextRunAtMs,
+ triggerFired: entryObj.triggerFired === true ? true : undefined,
model: typeof entryObj.model === "string" && entryObj.model.trim() ? entryObj.model : undefined,
provider: normalizedProvider,
usage: usage
diff --git a/src/cron/service.trigger-eval.test.ts b/src/cron/service.trigger-eval.test.ts
new file mode 100644
index 00000000000..7d4994ad0ba
--- /dev/null
+++ b/src/cron/service.trigger-eval.test.ts
@@ -0,0 +1,338 @@
+import { describe, expect, it, vi } from "vitest";
+import { appendCronRunLog, readCronRunLogEntriesSync } from "./run-log.js";
+import type { CronEvent, CronServiceDeps } from "./service.js";
+import { CronService } from "./service.js";
+import { setupCronServiceSuite } from "./service.test-harness.js";
+import { computeJobNextRunAtMs } from "./service/jobs.js";
+import type { CronJobCreate } from "./types.js";
+
+const { logger, makeStorePath } = setupCronServiceSuite({ prefix: "cron-trigger-eval-" });
+
+type Evaluator = NonNullable;
+type IsolatedRunner = CronServiceDeps["runIsolatedAgentJob"];
+
+function watcher(overrides: Partial = {}): CronJobCreate {
+ return {
+ name: "watcher",
+ enabled: true,
+ schedule: { kind: "cron", expr: "* * * * * *", staggerMs: 0 },
+ trigger: { script: "json({ fire: false })" },
+ sessionTarget: "isolated",
+ wakeMode: "now",
+ payload: { kind: "agentTurn", message: "base message" },
+ ...overrides,
+ };
+}
+
+async function createHarness(params: {
+ evaluateCronTrigger?: Evaluator;
+ runIsolatedAgentJob?: IsolatedRunner;
+}) {
+ const { storePath } = await makeStorePath();
+ const events: CronEvent[] = [];
+ const enqueueSystemEvent = vi.fn();
+ const runIsolatedAgentJob =
+ params.runIsolatedAgentJob ?? vi.fn(async () => ({ status: "ok" as const }));
+ const cron = new CronService({
+ storePath,
+ cronEnabled: true,
+ cronConfig: { triggers: { enabled: true, minIntervalMs: 30_000 } },
+ log: logger,
+ enqueueSystemEvent,
+ requestHeartbeat: vi.fn(),
+ runIsolatedAgentJob,
+ ...(params.evaluateCronTrigger ? { evaluateCronTrigger: params.evaluateCronTrigger } : {}),
+ onEvent: (event) => events.push(event),
+ });
+ await cron.start();
+ return { cron, enqueueSystemEvent, events, runIsolatedAgentJob, storePath };
+}
+
+async function runWhenDue(cron: CronService, jobId: string) {
+ const nextRunAtMs = cron.getJob(jobId)?.state.nextRunAtMs;
+ if (nextRunAtMs === undefined) {
+ throw new Error("test job has no next run");
+ }
+ vi.setSystemTime(nextRunAtMs);
+ return cron.run(jobId, "due");
+}
+
+describe("cron trigger evaluation", () => {
+ it("persists quiet evaluations without payload execution or run history", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({
+ kind: "evaluated" as const,
+ fire: false,
+ state: { status: "green" },
+ }));
+ const harness = await createHarness({ evaluateCronTrigger });
+ try {
+ const job = await harness.cron.add(watcher());
+ const dueAt = job.state.nextRunAtMs ?? 0;
+
+ expect(await runWhenDue(harness.cron, job.id)).toEqual({ ok: true, ran: true });
+
+ const stored = harness.cron.getJob(job.id);
+ expect(stored?.state).toMatchObject({
+ lastTriggerEvalAtMs: dueAt,
+ triggerEvalCount: 1,
+ triggerState: { status: "green" },
+ consecutiveErrors: 0,
+ scheduleErrorCount: 0,
+ });
+ expect(stored?.state.lastRunAtMs).toBeUndefined();
+ expect((stored?.state.nextRunAtMs ?? 0) - dueAt).toBeGreaterThanOrEqual(30_000);
+ expect(harness.runIsolatedAgentJob).not.toHaveBeenCalled();
+ expect(harness.events.filter((event) => event.action === "finished")).toHaveLength(0);
+ expect(readCronRunLogEntriesSync({ storePath: harness.storePath, jobId: job.id })).toEqual(
+ [],
+ );
+ } finally {
+ harness.cron.stop();
+ }
+ });
+
+ it("appends the trigger message and marks fired run history", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({
+ kind: "evaluated" as const,
+ fire: true,
+ message: "CI became red",
+ state: { status: "red" },
+ }));
+ const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" }));
+ const harness = await createHarness({ evaluateCronTrigger, runIsolatedAgentJob });
+ try {
+ const job = await harness.cron.add(watcher());
+ await runWhenDue(harness.cron, job.id);
+
+ expect(runIsolatedAgentJob).toHaveBeenCalledWith(
+ expect.objectContaining({ message: "base message\n\nCI became red" }),
+ );
+ const finished = harness.events.find((event) => event.action === "finished");
+ expect(finished).toMatchObject({ status: "ok", triggerFired: true });
+ if (!finished) {
+ throw new Error("missing finished event");
+ }
+ await appendCronRunLog({
+ storePath: harness.storePath,
+ entry: {
+ ts: Date.now(),
+ jobId: job.id,
+ action: "finished",
+ status: finished.status,
+ triggerFired: finished.triggerFired,
+ },
+ });
+ expect(readCronRunLogEntriesSync({ storePath: harness.storePath, jobId: job.id })).toEqual([
+ expect.objectContaining({ triggerFired: true }),
+ ]);
+ expect(harness.cron.getJob(job.id)?.state).toMatchObject({
+ triggerEvalCount: 1,
+ lastTriggerFireAtMs: expect.any(Number),
+ triggerState: { status: "red" },
+ });
+ } finally {
+ harness.cron.stop();
+ }
+ });
+
+ it("appends the trigger message to main-session system events", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({
+ kind: "evaluated" as const,
+ fire: true,
+ message: "deploy completed",
+ }));
+ const harness = await createHarness({ evaluateCronTrigger });
+ try {
+ const job = await harness.cron.add(
+ watcher({
+ sessionTarget: "main",
+ payload: { kind: "systemEvent", text: "base event" },
+ }),
+ );
+ await runWhenDue(harness.cron, job.id);
+
+ expect(harness.events.find((event) => event.action === "finished")).toMatchObject({
+ status: "ok",
+ triggerFired: true,
+ });
+ expect(harness.enqueueSystemEvent).toHaveBeenCalledWith(
+ "base event\n\ndeploy completed",
+ expect.any(Object),
+ );
+ } finally {
+ harness.cron.stop();
+ }
+ });
+
+ it("routes evaluator errors through execution backoff", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({
+ kind: "error" as const,
+ code: "timeout" as const,
+ error: "deadline exceeded",
+ }));
+ const harness = await createHarness({ evaluateCronTrigger });
+ try {
+ const job = await harness.cron.add(watcher());
+ const dueAt = job.state.nextRunAtMs ?? 0;
+ await runWhenDue(harness.cron, job.id);
+
+ expect(harness.cron.getJob(job.id)?.state).toMatchObject({
+ consecutiveErrors: 1,
+ triggerEvalCount: 1,
+ lastRunStatus: "error",
+ });
+ expect(harness.cron.getJob(job.id)?.state.nextRunAtMs).toBeGreaterThan(dueAt);
+ expect(harness.events.find((event) => event.action === "finished")).toMatchObject({
+ status: "error",
+ error: expect.stringContaining("deadline exceeded"),
+ });
+ } finally {
+ harness.cron.stop();
+ }
+ });
+
+ it("treats evaluator saturation as a quiet skip with no trigger state update", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({ kind: "busy" as const }));
+ const harness = await createHarness({ evaluateCronTrigger });
+ try {
+ const job = await harness.cron.add(watcher());
+ await runWhenDue(harness.cron, job.id);
+
+ const state = harness.cron.getJob(job.id)?.state;
+ expect(state?.triggerEvalCount).toBeUndefined();
+ expect(state?.lastTriggerEvalAtMs).toBeUndefined();
+ expect(state?.triggerState).toBeUndefined();
+ expect(harness.events.filter((event) => event.action === "finished")).toHaveLength(0);
+ expect(logger.debug).toHaveBeenCalledWith(
+ { jobId: job.id },
+ "cron: trigger evaluation skipped while busy",
+ );
+ } finally {
+ harness.cron.stop();
+ }
+ });
+
+ it("disables once triggers only after a successful fired payload", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({
+ kind: "evaluated" as const,
+ fire: true,
+ }));
+ const success = await createHarness({ evaluateCronTrigger });
+ try {
+ const job = await success.cron.add(watcher({ trigger: { script: "fire", once: true } }));
+ await runWhenDue(success.cron, job.id);
+ expect(success.cron.getJob(job.id)).toMatchObject({ enabled: false });
+ expect(success.cron.getJob(job.id)?.state.nextRunAtMs).toBeUndefined();
+ } finally {
+ success.cron.stop();
+ }
+
+ const failed = await createHarness({
+ evaluateCronTrigger,
+ runIsolatedAgentJob: vi.fn(async () => ({
+ status: "error" as const,
+ error: "payload failed",
+ })),
+ });
+ try {
+ const job = await failed.cron.add(watcher({ trigger: { script: "fire", once: true } }));
+ await runWhenDue(failed.cron, job.id);
+ expect(failed.cron.getJob(job.id)).toMatchObject({ enabled: true });
+ expect(failed.cron.getJob(job.id)?.state.nextRunAtMs).toEqual(expect.any(Number));
+ } finally {
+ failed.cron.stop();
+ }
+ });
+
+ it("keeps per-job cron staggering when rescheduling quiet ticks", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({
+ kind: "evaluated" as const,
+ fire: false,
+ }));
+ const harness = await createHarness({ evaluateCronTrigger });
+ try {
+ const job = await harness.cron.add(
+ watcher({ schedule: { kind: "cron", expr: "0 * * * *", staggerMs: 300_000 } }),
+ );
+ const dueAt = job.state.nextRunAtMs ?? 0;
+ await runWhenDue(harness.cron, job.id);
+
+ const stored = harness.cron.getJob(job.id);
+ if (!stored) {
+ throw new Error("missing job");
+ }
+ // Must match the job-level (stagger-aware) computation, not the raw boundary.
+ expect(stored.state.nextRunAtMs).toBe(computeJobNextRunAtMs(stored, dueAt));
+ } finally {
+ harness.cron.stop();
+ }
+ });
+
+ it("keeps prior trigger state when the fired payload run fails", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({
+ kind: "evaluated" as const,
+ fire: true,
+ message: "CI became red",
+ state: { status: "red" },
+ }));
+ const harness = await createHarness({
+ evaluateCronTrigger,
+ runIsolatedAgentJob: vi.fn(async () => ({
+ status: "error" as const,
+ error: "payload failed",
+ })),
+ });
+ try {
+ const job = await harness.cron.add(watcher());
+ await runWhenDue(harness.cron, job.id);
+
+ const state = harness.cron.getJob(job.id)?.state;
+ expect(state).toMatchObject({
+ triggerEvalCount: 1,
+ lastTriggerFireAtMs: expect.any(Number),
+ lastRunStatus: "error",
+ });
+ // Old state survives so the next evaluation re-detects the change.
+ expect(state?.triggerState).toBeUndefined();
+ } finally {
+ harness.cron.stop();
+ }
+ });
+
+ it("reports a missing evaluator as an execution error", async () => {
+ const harness = await createHarness({});
+ try {
+ const job = await harness.cron.add(watcher());
+ await runWhenDue(harness.cron, job.id);
+ expect(harness.cron.getJob(job.id)?.state).toMatchObject({
+ consecutiveErrors: 1,
+ lastRunStatus: "error",
+ lastError: "cron trigger evaluator is unavailable",
+ });
+ } finally {
+ harness.cron.stop();
+ }
+ });
+
+ it("bypasses trigger evaluation for force runs", async () => {
+ const evaluateCronTrigger = vi.fn(async () => ({
+ kind: "evaluated" as const,
+ fire: false,
+ }));
+ const harness = await createHarness({ evaluateCronTrigger });
+ try {
+ const job = await harness.cron.add(watcher());
+ expect(await harness.cron.run(job.id, "force")).toEqual({ ok: true, ran: true });
+ expect(evaluateCronTrigger).not.toHaveBeenCalled();
+ expect(harness.runIsolatedAgentJob).toHaveBeenCalledOnce();
+ expect(harness.events.find((event) => event.action === "finished")).toMatchObject({
+ status: "ok",
+ });
+ expect(
+ harness.events.find((event) => event.action === "finished")?.triggerFired,
+ ).toBeUndefined();
+ } finally {
+ harness.cron.stop();
+ }
+ });
+});
diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts
index e43165897a7..24bdf3d0363 100644
--- a/src/cron/service/jobs.ts
+++ b/src/cron/service/jobs.ts
@@ -4,6 +4,8 @@ import {
normalizeOptionalString,
normalizeOptionalThreadValue,
} from "@openclaw/normalization-core/string-coerce";
+import { resolveCronTriggerMinIntervalMs } from "../../config/cron-limits.js";
+import type { CronConfig } from "../../config/types.cron.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { resolveCronDeliveryPlan } from "../delivery-plan.js";
import { parseAbsoluteTimeMs } from "../parse.js";
@@ -304,6 +306,25 @@ export function assertSupportedJobSpec(job: Pick,
+ opts?: { cronConfig?: CronConfig; requireEnabled?: boolean },
+) {
+ if (!job.trigger) {
+ return;
+ }
+ if (opts?.requireEnabled && opts.cronConfig?.triggers?.enabled !== true) {
+ throw new Error("cron triggers are disabled; set cron.triggers.enabled=true");
+ }
+ if (job.schedule.kind !== "every" && job.schedule.kind !== "cron") {
+ throw new Error("cron triggers require an every or cron schedule");
+ }
+ const minIntervalMs = resolveCronTriggerMinIntervalMs(opts?.cronConfig);
+ if (job.schedule.kind === "every" && job.schedule.everyMs < minIntervalMs) {
+ throw new Error(`cron trigger every interval must be at least ${minIntervalMs}ms`);
+ }
+}
+
function assertCronExpressionSatisfiable(job: CronJob, nowMs: number) {
if (job.schedule.kind !== "cron") {
return;
@@ -883,11 +904,16 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
payload: input.payload,
delivery: resolveInitialCronDelivery(input),
failureAlert: input.failureAlert,
+ ...(input.trigger ? { trigger: structuredClone(input.trigger) } : {}),
state: {
...input.state,
},
};
assertSupportedJobSpec(job);
+ assertTriggerSupport(job, {
+ cronConfig: state.deps.cronConfig,
+ requireEnabled: job.trigger !== undefined,
+ });
assertMainSessionAgentId(job, state.deps.defaultAgentId);
assertDeliverySupport(job);
assertFailureDestinationSupport(job);
@@ -900,7 +926,11 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
export function applyJobPatch(
job: CronJob,
patch: CronJobPatch,
- opts?: { defaultAgentId?: string; scheduleValidationNowMs?: number },
+ opts?: {
+ defaultAgentId?: string;
+ scheduleValidationNowMs?: number;
+ cronConfig?: CronConfig;
+ },
) {
if ("name" in patch) {
job.name = normalizeRequiredName(patch.name);
@@ -950,6 +980,13 @@ export function applyJobPatch(
job.schedule = patch.schedule;
}
}
+ if ("trigger" in patch) {
+ if (patch.trigger === null || patch.trigger === undefined) {
+ delete job.trigger;
+ } else {
+ job.trigger = structuredClone(patch.trigger);
+ }
+ }
if (patch.sessionTarget) {
job.sessionTarget = patch.sessionTarget;
}
@@ -994,6 +1031,10 @@ export function applyJobPatch(
job.sessionKey = normalizeOptionalString((patch as { sessionKey?: unknown }).sessionKey);
}
assertSupportedJobSpec(job);
+ assertTriggerSupport(job, {
+ cronConfig: opts?.cronConfig,
+ requireEnabled: patch.trigger !== null && patch.trigger !== undefined,
+ });
assertMainSessionAgentId(job, opts?.defaultAgentId);
assertDeliverySupport(job);
assertFailureDestinationSupport(job);
@@ -1009,7 +1050,12 @@ export function applyJobPatch(
export function applyDeclarativeJobSpec(
job: CronJob,
input: CronJobCreate,
- opts: { defaultAgentId?: string; enabledExplicit: boolean; nowMs: number },
+ opts: {
+ defaultAgentId?: string;
+ enabledExplicit: boolean;
+ nowMs: number;
+ cronConfig?: CronConfig;
+ },
) {
// Name, target, routing, owner, and run policy remain outside declaration
// convergence; changing those uses cron.update and cannot retarget an identity.
@@ -1052,6 +1098,11 @@ export function applyDeclarativeJobSpec(
job.schedule = structuredClone(input.schedule);
}
job.payload = structuredClone(input.payload);
+ if (input.trigger) {
+ job.trigger = structuredClone(input.trigger);
+ } else {
+ delete job.trigger;
+ }
const delivery = resolveInitialCronDelivery(input);
if (delivery) {
job.delivery = structuredClone(delivery);
@@ -1061,6 +1112,10 @@ export function applyDeclarativeJobSpec(
if (opts.enabledExplicit) {
job.enabled = input.enabled;
}
+ assertTriggerSupport(job, {
+ cronConfig: opts.cronConfig,
+ requireEnabled: input.trigger !== undefined,
+ });
assertSupportedJobSpec(job);
assertMainSessionAgentId(job, opts.defaultAgentId);
diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts
index b8c41359d93..0e8b8a97046 100644
--- a/src/cron/service/ops.ts
+++ b/src/cron/service/ops.ts
@@ -61,6 +61,8 @@ import { ensureLoaded, persist, warnIfDisabled } from "./store.js";
import { CRON_TASK_RUNNING_PROGRESS_SUMMARY } from "./task-ledger.js";
import {
applyJobResult,
+ applyTriggerNoFireResult,
+ applyTriggerRunResult,
armTimer,
emit,
executeJobCoreWithTimeout,
@@ -606,6 +608,7 @@ async function persistUpdatedJob(params: {
function declarativeFields(job: CronJob, includeEnabled: boolean) {
return {
schedule: job.schedule,
+ trigger: job.trigger,
payload: job.payload,
delivery: job.delivery,
displayName: job.displayName,
@@ -644,6 +647,7 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?:
defaultAgentId: state.deps.defaultAgentId,
enabledExplicit: opts?.enabledExplicit === true,
nowMs: now,
+ cronConfig: state.deps.cronConfig,
});
const includeEnabled = opts?.enabledExplicit === true;
if (
@@ -722,12 +726,14 @@ async function updateLoadedJob(params: {
applyJobPatch(nextJob, patch, {
defaultAgentId: state.deps.defaultAgentId,
scheduleValidationNowMs: now,
+ cronConfig: state.deps.cronConfig,
});
finalizeUpdatedJob({
job,
nextJob,
now,
- schedulingInputsRequested: patch.schedule !== undefined || patch.enabled !== undefined,
+ schedulingInputsRequested:
+ patch.schedule !== undefined || patch.enabled !== undefined || patch.trigger !== undefined,
scheduleChanged: patch.schedule !== undefined,
});
await persistUpdatedJob({ state, snapshot, nextJob });
@@ -1060,6 +1066,11 @@ async function prepareManualRun(
// Execute against a snapshot so later reload/merge can preserve delivery
// target writeback from disk without mutating the running object.
const executionJob = structuredClone(job);
+ if (mode === "force" && executionJob.trigger) {
+ // Force means run the payload now; strip the gate only from this snapshot
+ // so persisted trigger state and future due evaluations stay intact.
+ delete executionJob.trigger;
+ }
if (opts?.payload) {
executionJob.payload = structuredClone(opts.payload);
}
@@ -1120,44 +1131,61 @@ async function finishPreparedManualRun(
return;
}
- const shouldDelete = applyJobResult(
- state,
- job,
- {
- status: coreResult.status,
- error: coreResult.error,
- diagnostics: coreResult.diagnostics,
- delivered: coreResult.delivered,
- provider: coreResult.provider,
+ let shouldDelete = false;
+ if (coreResult.status === "ok" && coreResult.triggerEval?.fired === false) {
+ // Manual due checks share scheduled quiet-tick semantics: persist the
+ // evaluation but create no finished event or run-history entry.
+ applyTriggerNoFireResult(state, job, {
startedAt,
endedAt,
- },
- { preserveSchedule: mode === "force" },
- );
+ triggerEval: coreResult.triggerEval,
+ });
+ } else {
+ shouldDelete = applyJobResult(
+ state,
+ job,
+ {
+ status: coreResult.status,
+ error: coreResult.error,
+ diagnostics: coreResult.diagnostics,
+ delivered: coreResult.delivered,
+ provider: coreResult.provider,
+ startedAt,
+ endedAt,
+ },
+ { preserveSchedule: mode === "force" },
+ );
+ applyTriggerRunResult(job, {
+ status: coreResult.status,
+ endedAt,
+ triggerEval: coreResult.triggerEval,
+ });
- emit(state, {
- jobId: job.id,
- action: "finished",
- job,
- status: coreResult.status,
- error: coreResult.error,
- summary: coreResult.summary,
- diagnostics: coreResult.diagnostics,
- delivered: job.state.lastDelivered,
- deliveryStatus: job.state.lastDeliveryStatus,
- deliveryError: job.state.lastDeliveryError,
- failureNotificationDelivery: failureNotificationDeliveryFromJobState(job),
- delivery: coreResult.delivery,
- sessionId: coreResult.sessionId,
- sessionKey: coreResult.sessionKey,
- runId,
- runAtMs: startedAt,
- durationMs: job.state.lastDurationMs,
- nextRunAtMs: job.state.nextRunAtMs,
- model: coreResult.model,
- provider: coreResult.provider,
- usage: coreResult.usage,
- });
+ emit(state, {
+ jobId: job.id,
+ action: "finished",
+ job,
+ status: coreResult.status,
+ error: coreResult.error,
+ summary: coreResult.summary,
+ diagnostics: coreResult.diagnostics,
+ delivered: job.state.lastDelivered,
+ deliveryStatus: job.state.lastDeliveryStatus,
+ deliveryError: job.state.lastDeliveryError,
+ failureNotificationDelivery: failureNotificationDeliveryFromJobState(job),
+ delivery: coreResult.delivery,
+ sessionId: coreResult.sessionId,
+ sessionKey: coreResult.sessionKey,
+ runId,
+ runAtMs: startedAt,
+ durationMs: job.state.lastDurationMs,
+ nextRunAtMs: job.state.nextRunAtMs,
+ ...(coreResult.triggerEval?.fired ? { triggerFired: true } : {}),
+ model: coreResult.model,
+ provider: coreResult.provider,
+ usage: coreResult.usage,
+ });
+ }
if (shouldDelete && state.store) {
state.store.jobs = state.store.jobs.filter((entry) => entry.id !== job.id);
diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts
index b735e4cadfa..d6b26569c05 100644
--- a/src/cron/service/state.ts
+++ b/src/cron/service/state.ts
@@ -4,6 +4,7 @@ import type { HeartbeatRunResult, HeartbeatWakeRequest } from "../../infra/heart
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { QuarantinedCronConfigJob } from "../store.js";
import type {
+ CronTriggerEvaluationResult,
CronAgentExecutionPhaseUpdate,
CronAgentExecutionStarted,
CronFailureNotificationDelivery,
@@ -41,6 +42,7 @@ export type CronEvent = {
sessionKey?: string;
runId?: string;
nextRunAtMs?: number;
+ triggerFired?: boolean;
} & CronRunTelemetry;
/** Logger contract consumed by cron service internals. */
@@ -67,6 +69,12 @@ export type CronServiceDeps = {
cronEnabled: boolean;
/** CronConfig for session retention settings. */
cronConfig?: CronConfig;
+ evaluateCronTrigger?: (params: {
+ job: CronJob;
+ script: string;
+ state: unknown;
+ abortSignal?: AbortSignal;
+ }) => Promise;
/** Default agent id for jobs without an agent id. */
defaultAgentId?: string;
/** Resolve session store path for a given agent id. */
diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts
index 3fb214819d4..855112993d4 100644
--- a/src/cron/service/timer.ts
+++ b/src/cron/service/timer.ts
@@ -1,6 +1,7 @@
/** Cron timer loop, execution, catch-up, and run-result state transitions. */
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
import { resolveFailoverReasonFromError } from "../../agents/failover-error.js";
+import { resolveCronTriggerMinIntervalMs } from "../../config/cron-limits.js";
import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
import type { CronConfig, CronRetryOn } from "../../config/types.cron.js";
import { formatErrorMessage } from "../../infra/errors.js";
@@ -119,8 +120,16 @@ type TimedCronRunOutcome = CronRunOutcome &
activeJobMarker?: CronActiveJobMarker;
startedAt: number;
endedAt: number;
+ triggerEval?: CronTriggerEvalOutcome;
};
+export type CronTriggerEvalOutcome = {
+ fired: boolean;
+ stateChanged: boolean;
+ state?: unknown;
+ busy?: true;
+};
+
export type IsolatedAgentSetupTimeoutSignal = {
error: string;
timeoutMs: number;
@@ -977,7 +986,14 @@ export function applyJobResult(
// after the current run ended. Prevents spin-loops when the
// schedule computation lands in the same second due to
// timezone/croner edge cases (see #17821).
- const minNext = result.endedAt + MIN_REFIRE_GAP_MS;
+ // Trigger schedules obey the operator floor even when a cron expression
+ // would otherwise refire sooner after a successful payload run.
+ const minNext =
+ result.endedAt +
+ Math.max(
+ MIN_REFIRE_GAP_MS,
+ job.trigger ? resolveCronTriggerMinIntervalMs(state.deps.cronConfig) : 0,
+ );
job.state.nextRunAtMs = resolveCronNextRunWithLowerBound({
state,
job,
@@ -986,7 +1002,13 @@ export function applyJobResult(
context: "completion",
});
} else {
- job.state.nextRunAtMs = naturalNext;
+ job.state.nextRunAtMs =
+ naturalNext !== undefined && job.trigger
+ ? Math.max(
+ naturalNext,
+ result.endedAt + resolveCronTriggerMinIntervalMs(state.deps.cronConfig),
+ )
+ : naturalNext;
}
} else {
job.state.nextRunAtMs = undefined;
@@ -996,6 +1018,81 @@ export function applyJobResult(
return shouldDelete;
}
+function applyTriggerEvaluationState(
+ job: CronJob,
+ triggerEval: CronTriggerEvalOutcome,
+ evaluatedAtMs: number,
+): void {
+ if (triggerEval.busy) {
+ return;
+ }
+ job.state.lastTriggerEvalAtMs = evaluatedAtMs;
+ job.state.triggerEvalCount = (job.state.triggerEvalCount ?? 0) + 1;
+ if (triggerEval.stateChanged) {
+ job.state.triggerState = triggerEval.state;
+ }
+ if (triggerEval.fired) {
+ job.state.lastTriggerFireAtMs = evaluatedAtMs;
+ }
+}
+
+/** Persists fired/error evaluation metadata and applies successful once-disarm policy. */
+export function applyTriggerRunResult(
+ job: CronJob,
+ result: { status: CronRunStatus; endedAt: number; triggerEval?: CronTriggerEvalOutcome },
+): void {
+ if (!result.triggerEval) {
+ return;
+ }
+ // Fired-run trigger state persists only on payload success: a failed or
+ // skipped run keeps the previous state so the next evaluation re-detects
+ // the change and fires again instead of silently losing the event.
+ const persistedEval =
+ result.status === "ok"
+ ? result.triggerEval
+ : { ...result.triggerEval, stateChanged: false, state: undefined };
+ applyTriggerEvaluationState(job, persistedEval, result.endedAt);
+ // A once trigger disarms only after the fired payload succeeds. Errors keep
+ // it armed so the normal backoff path can evaluate and retry later.
+ if (result.triggerEval.fired && job.trigger?.once === true && result.status === "ok") {
+ job.enabled = false;
+ job.state.nextRunAtMs = undefined;
+ }
+}
+
+/** Applies a quiet trigger tick without mutating normal run-history state. */
+export function applyTriggerNoFireResult(
+ state: CronServiceState,
+ job: CronJob,
+ result: { startedAt: number; endedAt: number; triggerEval: CronTriggerEvalOutcome },
+): void {
+ job.state.runningAtMs = undefined;
+ job.updatedAtMs = result.endedAt;
+ if (!result.triggerEval.busy) {
+ // A non-firing evaluation is successful scheduler work, not a payload run;
+ // reset error machinery while leaving lastRun/delivery history untouched.
+ job.state.consecutiveErrors = 0;
+ job.state.scheduleErrorCount = 0;
+ job.state.lastFailureAlertAtMs = undefined;
+ applyTriggerEvaluationState(job, result.triggerEval, result.endedAt);
+ }
+ try {
+ // Job-level computation keeps per-job cron staggering intact on quiet
+ // ticks; raw schedule math would collapse watchers onto exact boundaries.
+ const naturalNext = computeJobNextRunAtMs(job, result.endedAt);
+ const floorMs = Math.max(
+ MIN_REFIRE_GAP_MS,
+ resolveCronTriggerMinIntervalMs(state.deps.cronConfig),
+ );
+ // Quiet ticks still advance the schedule; the floor prevents scripts from
+ // becoming a headless hot loop even when cron resolves inside the window.
+ job.state.nextRunAtMs =
+ naturalNext === undefined ? undefined : Math.max(naturalNext, result.endedAt + floorMs);
+ } catch (err) {
+ recordScheduleComputeError({ state, job, err });
+ }
+}
+
function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOutcome): void {
tryFinishCronTaskRun(state, result);
const store = state.store;
@@ -1005,6 +1102,9 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu
const jobs = store.jobs;
const job = jobs.find((entry) => entry.id === result.jobId);
if (!job) {
+ if (result.status === "ok" && result.triggerEval?.fired === false) {
+ return;
+ }
if (result.status === "ok") {
// A manual/queued run may finish after the job was removed. Preserve the
// successful run log state without resurrecting the job in the store.
@@ -1031,6 +1131,18 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu
return;
}
+ if (result.status === "ok" && result.triggerEval && !result.triggerEval.fired) {
+ // Quiet trigger ticks intentionally emit no finished event: run history,
+ // plugin hooks, and completion notifications represent payload runs only.
+ applyTriggerNoFireResult(state, job, {
+ startedAt: result.startedAt,
+ endedAt: result.endedAt,
+ triggerEval: result.triggerEval,
+ });
+ state.pendingCatchupDeferralJobIds.delete(job.id);
+ return;
+ }
+
const shouldDelete = applyJobResult(state, job, {
status: result.status,
error: result.error,
@@ -1040,6 +1152,7 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu
startedAt: result.startedAt,
endedAt: result.endedAt,
});
+ applyTriggerRunResult(job, result);
state.pendingCatchupDeferralJobIds.delete(job.id);
emitJobFinished(state, job, result, result.startedAt);
@@ -1801,6 +1914,9 @@ async function runStartupCatchupCandidate(
provider: result.provider,
usage: result.usage,
isolatedAgentSetupTimeout: result.isolatedAgentSetupTimeout,
+ // Quiet trigger ticks during startup catch-up must keep their eval
+ // outcome; dropping it would record them as successful payload runs.
+ triggerEval: result.triggerEval,
startedAt,
endedAt: state.deps.nowMs(),
};
@@ -1916,6 +2032,7 @@ export async function executeJobCore(
delivered?: boolean;
deliveryAttempted?: boolean;
delivery?: CronDeliveryTrace;
+ triggerEval?: CronTriggerEvalOutcome;
}
> {
const resolveAbortError = () => ({
@@ -1949,11 +2066,65 @@ export async function executeJobCore(
if (abortSignal?.aborted) {
return resolveAbortError();
}
- if (job.sessionTarget === "main") {
- return await executeMainSessionCronJob(state, job, abortSignal, waitWithAbort);
+ let effectiveJob = job;
+ let triggerEval: CronTriggerEvalOutcome | undefined;
+ if (job.trigger) {
+ const evaluator = state.deps.evaluateCronTrigger;
+ if (!evaluator) {
+ return { status: "error", error: "cron trigger evaluator is unavailable" };
+ }
+ const evaluation = await evaluator({
+ job,
+ script: job.trigger.script,
+ state: job.state.triggerState,
+ abortSignal,
+ });
+ if (evaluation.kind === "busy") {
+ state.deps.log.debug({ jobId: job.id }, "cron: trigger evaluation skipped while busy");
+ return {
+ status: "ok",
+ triggerEval: { fired: false, stateChanged: false, busy: true },
+ };
+ }
+ if (evaluation.kind === "error") {
+ return {
+ status: "error",
+ error: `cron trigger evaluation failed (${evaluation.code}): ${evaluation.error}`,
+ triggerEval: { fired: false, stateChanged: false },
+ };
+ }
+ const stateChanged = Object.hasOwn(evaluation, "state");
+ triggerEval = {
+ fired: evaluation.fire,
+ stateChanged,
+ ...(stateChanged ? { state: evaluation.state } : {}),
+ };
+ if (!evaluation.fire) {
+ return { status: "ok", triggerEval };
+ }
+ if (evaluation.message !== undefined) {
+ const payload =
+ job.payload.kind === "systemEvent"
+ ? { ...job.payload, text: `${job.payload.text}\n\n${evaluation.message}` }
+ : job.payload.kind === "agentTurn"
+ ? { ...job.payload, message: `${job.payload.message}\n\n${evaluation.message}` }
+ : job.payload;
+ effectiveJob = { ...job, payload };
+ }
+ }
+ if (effectiveJob.sessionTarget === "main") {
+ const result = await executeMainSessionCronJob(state, effectiveJob, abortSignal, waitWithAbort);
+ return triggerEval ? { ...result, triggerEval } : result;
}
- return await executeDetachedCronJob(state, job, abortSignal, resolveAbortError, options);
+ const result = await executeDetachedCronJob(
+ state,
+ effectiveJob,
+ abortSignal,
+ resolveAbortError,
+ options,
+ );
+ return triggerEval ? { ...result, triggerEval } : result;
}
async function executeMainSessionCronJob(
@@ -2209,6 +2380,7 @@ function emitJobFinished(
status: CronRunStatus;
delivered?: boolean;
delivery?: CronDeliveryTrace;
+ triggerEval?: CronTriggerEvalOutcome;
} & CronRunOutcome &
CronRunTelemetry,
runAtMs: number,
@@ -2231,6 +2403,7 @@ function emitJobFinished(
runAtMs,
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
+ ...(result.triggerEval?.fired ? { triggerFired: true } : {}),
model: result.model,
provider: result.provider,
usage: result.usage,
diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts
index 14b74e049d6..d67203a63db 100644
--- a/src/cron/store/row-codec.ts
+++ b/src/cron/store/row-codec.ts
@@ -19,6 +19,7 @@ import {
import type { CronJobInsert, CronJobRow } from "./schema.js";
import { getCronStoreKysely } from "./schema.js";
import { bindStateColumns, stateFromRow } from "./state-codec.js";
+import { bindTriggerColumns, triggerFromRow } from "./trigger-codec.js";
import type { LoadedCronStore } from "./types.js";
export function bindScheduleColumns(
@@ -154,6 +155,7 @@ function bindCronJobRow(storeKey: string, job: CronJob, sortOrder: number): Cron
session_key: job.sessionKey ?? null,
session_target: job.sessionTarget,
wake_mode: job.wakeMode,
+ ...bindTriggerColumns(job.trigger),
...bindScheduleColumns(job.schedule),
...bindPayloadColumns(job.payload),
...bindDeliveryColumns(job.delivery),
@@ -242,6 +244,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
const payload = payloadFromRow(row);
const delivery = deliveryFromRow(row);
const failureAlert = failureAlertFromRow(row);
+ const trigger = triggerFromRow(row);
if (!schedule || !payload) {
return null;
}
@@ -272,6 +275,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null {
schedule,
sessionTarget: row.session_target as CronJob["sessionTarget"],
wakeMode: row.wake_mode as CronJob["wakeMode"],
+ ...(trigger ? { trigger } : {}),
payload,
...(delivery ? { delivery } : {}),
...(failureAlert !== undefined ? { failureAlert } : {}),
diff --git a/src/cron/store/trigger-codec.test.ts b/src/cron/store/trigger-codec.test.ts
new file mode 100644
index 00000000000..d3c9635bdc2
--- /dev/null
+++ b/src/cron/store/trigger-codec.test.ts
@@ -0,0 +1,61 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js";
+import type { CronJob } from "../types.js";
+import {
+ loadedCronStoreFromRows,
+ loadCronRows,
+ replaceCronRows,
+ updateCronRuntimeRows,
+} from "./row-codec.js";
+import type { CronJobRow } from "./schema.js";
+import { bindTriggerColumns, triggerFromRow } from "./trigger-codec.js";
+
+describe("cron trigger SQLite codec", () => {
+ it("round-trips trigger columns", () => {
+ const columns = bindTriggerColumns({ script: "json({ fire: true })", once: true });
+ expect(columns).toEqual({ trigger_script: "json({ fire: true })", trigger_once: 1 });
+ expect(triggerFromRow(columns as CronJobRow)).toEqual({
+ script: "json({ fire: true })",
+ once: true,
+ });
+ expect(triggerFromRow(bindTriggerColumns(undefined) as CronJobRow)).toBeUndefined();
+ });
+
+ it("round-trips trigger state through updateCronRuntimeRows", async () => {
+ const job = {
+ id: "job-1",
+ name: "watcher",
+ enabled: true,
+ createdAtMs: 1,
+ updatedAtMs: 2,
+ schedule: { kind: "every", everyMs: 30_000 },
+ trigger: { script: "json({ fire: false })", once: false },
+ sessionTarget: "main",
+ wakeMode: "now",
+ payload: { kind: "systemEvent", text: "changed" },
+ state: {
+ lastTriggerEvalAtMs: 10,
+ triggerEvalCount: 3,
+ lastTriggerFireAtMs: 8,
+ triggerState: { status: "green" },
+ },
+ } satisfies CronJob;
+
+ const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cron-trigger-codec-"));
+ const handle = openOpenClawStateDatabase({ path: path.join(fixtureRoot, "state.sqlite") });
+ try {
+ replaceCronRows(handle.db, "test", { version: 1, jobs: [{ ...job, state: {} }] });
+ updateCronRuntimeRows(handle.db, "test", { version: 1, jobs: [job] });
+ const [decoded] = loadedCronStoreFromRows(loadCronRows(handle.db, "test")).store.jobs;
+ expect(decoded?.trigger).toEqual(job.trigger);
+ expect(decoded?.state).toEqual(job.state);
+ } finally {
+ handle.walMaintenance.close();
+ handle.db.close();
+ await fs.rm(fixtureRoot, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/cron/store/trigger-codec.ts b/src/cron/store/trigger-codec.ts
new file mode 100644
index 00000000000..06b80cab66a
--- /dev/null
+++ b/src/cron/store/trigger-codec.ts
@@ -0,0 +1,25 @@
+/** SQLite column codec for cron trigger configuration. */
+import type { CronTrigger } from "../types.js";
+import { booleanToInteger, integerToBoolean } from "./scalar-codec.js";
+import type { CronJobInsert, CronJobRow } from "./schema.js";
+
+/** Maps cron trigger config into normalized SQLite columns. */
+export function bindTriggerColumns(
+ trigger: CronTrigger | undefined,
+): Pick {
+ return {
+ trigger_script: trigger?.script ?? null,
+ trigger_once: booleanToInteger(trigger?.once),
+ };
+}
+
+/** Reconstructs trigger config from normalized SQLite columns. */
+export function triggerFromRow(row: CronJobRow): CronTrigger | undefined {
+ if (!row.trigger_script) {
+ return undefined;
+ }
+ return {
+ script: row.trigger_script,
+ ...(row.trigger_once != null ? { once: integerToBoolean(row.trigger_once) } : {}),
+ };
+}
diff --git a/src/cron/trigger-script.test.ts b/src/cron/trigger-script.test.ts
new file mode 100644
index 00000000000..e67c2ac40da
--- /dev/null
+++ b/src/cron/trigger-script.test.ts
@@ -0,0 +1,280 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ testing as beforeToolCallTesting,
+ wrapToolWithBeforeToolCallHook,
+} from "../agents/agent-tools.before-tool-call.js";
+import type { CodeModeHeadlessResult } from "../agents/code-mode.js";
+import type { AnyAgentTool } from "../agents/tools/common.js";
+import type { OpenClawConfig } from "../config/types.openclaw.js";
+import { createCronTriggerEvaluator } from "./trigger-script.js";
+
+type EvaluatorDeps = Parameters[0];
+type HeadlessParams = Parameters>[0];
+type PrepareParams = Parameters>[0];
+
+function completed(params: { value: unknown; output?: unknown[] }): CodeModeHeadlessResult {
+ return {
+ status: "completed",
+ value: params.value,
+ output: params.output ?? [],
+ toolCallCount: 0,
+ };
+}
+
+function createPreparedRuntime(config: OpenClawConfig) {
+ const tool = wrapToolWithBeforeToolCallHook(
+ {
+ name: "probe",
+ label: "Probe",
+ description: "Probe tool",
+ parameters: { type: "object", properties: {} },
+ execute: vi.fn(),
+ } satisfies AnyAgentTool,
+ { config, agentId: "main", sessionKey: "cron:test:trigger" },
+ );
+ return {
+ tools: [tool],
+ ctx: {
+ config,
+ runtimeConfig: config,
+ agentId: "main",
+ sessionKey: "cron:test:trigger",
+ },
+ hookContext: { config, agentId: "main", sessionKey: "cron:test:trigger" },
+ };
+}
+
+function createEvaluator(
+ runHeadless: (
+ params: Parameters<
+ NonNullable[0]["runHeadless"]>
+ >[0],
+ ) => Promise,
+) {
+ const config = {} as OpenClawConfig;
+ const prepareRuntime = vi.fn(async () => createPreparedRuntime(config));
+ return {
+ evaluate: createCronTriggerEvaluator({ config, runHeadless, prepareRuntime }),
+ prepareRuntime,
+ };
+}
+
+describe("cron trigger script evaluator", () => {
+ it("prefers a valid returned value and injects trigger state", async () => {
+ const runHeadless = vi.fn(async (_params: HeadlessParams) =>
+ completed({
+ value: { fire: true, message: "changed", state: { revision: 2 } },
+ output: [{ type: "json", value: { fire: false, state: { revision: 1 } } }],
+ }),
+ );
+ const { evaluate } = createEvaluator(runHeadless);
+
+ await expect(
+ evaluate({
+ jobId: "job-value",
+ script: "return result",
+ state: { revision: 1 },
+ }),
+ ).resolves.toEqual({
+ kind: "evaluated",
+ fire: true,
+ message: "changed",
+ state: { revision: 2 },
+ });
+ expect(runHeadless).toHaveBeenCalledOnce();
+ expect(runHeadless).toHaveBeenCalledWith(
+ expect.objectContaining({
+ extraNamespaces: [
+ {
+ id: "cron:trigger",
+ globalName: "trigger",
+ scope: {
+ kind: "object",
+ entries: [["state", { kind: "value", value: { revision: 1 } }]],
+ },
+ },
+ ],
+ }),
+ );
+ });
+
+ it("falls back to the last json output entry", async () => {
+ const { evaluate } = createEvaluator(
+ vi.fn(async () =>
+ completed({
+ value: null,
+ output: [
+ { type: "json", value: { fire: false, state: { old: true } } },
+ { type: "text", text: "ignored" },
+ { type: "json", value: { fire: true, state: { current: true } } },
+ ],
+ }),
+ ),
+ );
+
+ await expect(
+ evaluate({ jobId: "job-json", script: "json(result)", state: null }),
+ ).resolves.toEqual({
+ kind: "evaluated",
+ fire: true,
+ state: { current: true },
+ });
+ });
+
+ it("uses a fresh hook run scope for each evaluation", async () => {
+ const contexts: Array> = [];
+ const { evaluate, prepareRuntime } = createEvaluator(
+ vi.fn(async (params) => {
+ const wrapped = params.ctx.catalogRef?.current?.entries[0]?.tool;
+ contexts.push((wrapped ?? {}) as Record);
+ return completed({ value: { fire: false } });
+ }),
+ );
+
+ await evaluate({ jobId: "job-loop-scope", script: "return result", state: null });
+ await evaluate({ jobId: "job-loop-scope", script: "return result", state: null });
+
+ expect(prepareRuntime).toHaveBeenCalledOnce();
+ const runIds = contexts.map((tool) => {
+ const context = tool[beforeToolCallTesting.BEFORE_TOOL_CALL_HOOK_CONTEXT];
+ return (context as { runId?: string } | undefined)?.runId;
+ });
+ expect(runIds[0]).toMatch(/^cron-trigger:job-loop-scope:/);
+ expect(runIds[1]).toMatch(/^cron-trigger:job-loop-scope:/);
+ expect(runIds[1]).not.toBe(runIds[0]);
+ });
+
+ it("single-flights concurrent runtime preparation for the same job", async () => {
+ const config = {} as OpenClawConfig;
+ let release: ((runtime: ReturnType) => void) | undefined;
+ const pending = new Promise>((resolve) => {
+ release = resolve;
+ });
+ const prepareRuntime = vi.fn(async () => await pending);
+ const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
+ const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless });
+
+ const first = evaluate({ jobId: "job-single-flight", script: "return result", state: null });
+ const second = evaluate({ jobId: "job-single-flight", script: "return result", state: null });
+ await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce());
+ release?.(createPreparedRuntime(config));
+
+ await expect(Promise.all([first, second])).resolves.toEqual([
+ { kind: "evaluated", fire: false },
+ { kind: "evaluated", fire: false },
+ ]);
+ expect(runHeadless).toHaveBeenCalledTimes(2);
+ });
+
+ it("invalidates a cached runtime when toolsAllow changes", async () => {
+ const config = {} as OpenClawConfig;
+ const prepareRuntime = vi.fn(async (_params: PrepareParams) => createPreparedRuntime(config));
+ const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
+ const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless });
+
+ await evaluate({
+ jobId: "job-tools-allow",
+ script: "return result",
+ state: null,
+ toolsAllow: ["probe"],
+ });
+ await evaluate({
+ jobId: "job-tools-allow",
+ script: "return result",
+ state: null,
+ toolsAllow: ["exec"],
+ });
+
+ expect(prepareRuntime).toHaveBeenCalledTimes(2);
+ expect(prepareRuntime.mock.calls.map(([params]) => params.toolsAllow)).toEqual([
+ ["probe"],
+ ["exec"],
+ ]);
+ });
+
+ it.each([
+ completed({ value: null }),
+ completed({ value: { fire: "yes" } }),
+ completed({ value: { fire: true, message: 42 } }),
+ ])("rejects invalid result shapes", async (headlessResult) => {
+ const { evaluate } = createEvaluator(vi.fn(async () => headlessResult));
+
+ const result = await evaluate({ jobId: "job-invalid", script: "return bad", state: null });
+
+ expect(result).toMatchObject({ kind: "error", code: "internal_error" });
+ });
+
+ it("rejects returned state larger than 16KB", async () => {
+ const { evaluate } = createEvaluator(
+ vi.fn(async () =>
+ completed({ value: { fire: false, state: { value: "x".repeat(16 * 1024) } } }),
+ ),
+ );
+
+ await expect(
+ evaluate({ jobId: "job-large-state", script: "return result", state: null }),
+ ).resolves.toEqual({
+ kind: "error",
+ code: "output_limit_exceeded",
+ error: "cron trigger state exceeds the 16KB limit",
+ });
+ });
+
+ it("returns busy instead of queueing a fourth concurrent evaluation", async () => {
+ let release: ((result: CodeModeHeadlessResult) => void) | undefined;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ const runHeadless = vi.fn(async () => await pending);
+ const { evaluate } = createEvaluator(runHeadless);
+ const running = ["one", "two", "three"].map((jobId) =>
+ evaluate({ jobId, script: "return result", state: null }),
+ );
+ await vi.waitFor(() => expect(runHeadless).toHaveBeenCalledTimes(3));
+
+ const saturated = await evaluate({
+ jobId: "four",
+ script: "return result",
+ state: null,
+ });
+ release?.(completed({ value: { fire: false } }));
+ await Promise.all(running);
+
+ expect(saturated).toEqual({ kind: "busy" });
+ expect(runHeadless).toHaveBeenCalledTimes(3);
+ });
+
+ it("cancels runtime preparation when its only evaluator aborts", async () => {
+ const config = {} as OpenClawConfig;
+ let preparationSignal: AbortSignal | undefined;
+ const prepareRuntime = vi.fn(async (params: { signal?: AbortSignal }): Promise => {
+ preparationSignal = params.signal;
+ return await new Promise((_resolve, reject) => {
+ params.signal?.addEventListener(
+ "abort",
+ () => {
+ const reason = params.signal?.reason;
+ reject(reason instanceof Error ? reason : new Error("aborted"));
+ },
+ { once: true },
+ );
+ });
+ });
+ const runHeadless = vi.fn(async () => completed({ value: { fire: false } }));
+ const evaluate = createCronTriggerEvaluator({ config, prepareRuntime, runHeadless });
+ const controller = new AbortController();
+ const evaluation = evaluate({
+ jobId: "job-abort-preparation",
+ script: "return result",
+ state: null,
+ abortSignal: controller.signal,
+ });
+ await vi.waitFor(() => expect(prepareRuntime).toHaveBeenCalledOnce());
+
+ controller.abort();
+
+ await expect(evaluation).resolves.toMatchObject({ kind: "error", code: "timeout" });
+ await vi.waitFor(() => expect(preparationSignal?.aborted).toBe(true));
+ expect(runHeadless).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/cron/trigger-script.ts b/src/cron/trigger-script.ts
new file mode 100644
index 00000000000..c4db1ee0de3
--- /dev/null
+++ b/src/cron/trigger-script.ts
@@ -0,0 +1,438 @@
+import crypto from "node:crypto";
+import { isRecord } from "@openclaw/normalization-core/record-coerce";
+import {
+ resolveAgentConfig,
+ resolveAgentDir,
+ resolveAgentWorkspaceDir,
+ resolveDefaultAgentId,
+} from "../agents/agent-scope.js";
+import type { HookContext } from "../agents/agent-tools.before-tool-call.js";
+import {
+ createOpenClawCodingTools,
+ resolveToolLoopDetectionConfig,
+} from "../agents/agent-tools.js";
+import type { CodeModeNamespaceDescriptor } from "../agents/code-mode-namespaces.js";
+import {
+ runCodeModeScriptHeadless,
+ type CodeModeFailureCode,
+ type CodeModeHeadlessResult,
+} from "../agents/code-mode.js";
+import {
+ applyEmbeddedAttemptToolsAllow,
+ resolveEmbeddedAttemptToolConstructionPlan,
+} from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js";
+import { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js";
+import { resolveSandboxContext } from "../agents/sandbox.js";
+import {
+ createToolSearchCatalogRef,
+ registerHeadlessToolSearchCatalog,
+ type ToolSearchToolContext,
+} from "../agents/tool-search.js";
+import type { AnyAgentTool } from "../agents/tools/common.js";
+import { ensureAgentWorkspace } from "../agents/workspace.js";
+import type { OpenClawConfig } from "../config/types.openclaw.js";
+import { getPluginToolMeta } from "../plugins/tools.js";
+import { normalizeAgentId } from "../routing/session-key.js";
+import {
+ buildCronAgentDefaultsConfig,
+ resolveCronActiveRuntimeConfig,
+} from "./isolated-agent/run-config.js";
+import { resolveCronAgentSessionKey } from "./isolated-agent/session-key.js";
+import type { CronTriggerEvaluationResult, CronTriggerFailureCode } from "./types.js";
+
+const MAX_CONCURRENT_TRIGGER_EVALS = 3;
+const MAX_TRIGGER_STATE_BYTES = 16 * 1024;
+const MAX_CACHED_TRIGGER_RUNTIMES = 128;
+const HEADLESS_TRIGGER_WALL_CLOCK_MS = 30_000;
+const HEADLESS_TRIGGER_TOOL_BUDGET = 5;
+
+let activeTriggerEvaluations = 0;
+
+// Compile-time sync with the leaf contract in ./types.ts: a new code-mode
+// failure code must be added to CronTriggerFailureCode or this line errors.
+type AssertTriggerCodesCoverHeadless = [CodeModeFailureCode | "tool_budget_exceeded"] extends [
+ CronTriggerFailureCode,
+]
+ ? true
+ : never;
+const assertTriggerCodesCoverHeadless: AssertTriggerCodesCoverHeadless = true;
+void assertTriggerCodesCoverHeadless;
+
+type PreparedTriggerRuntime = {
+ tools: AnyAgentTool[];
+ ctx: Omit;
+ hookContext: Omit;
+};
+
+type PrepareTriggerRuntime = (params: {
+ runtimeConfig: OpenClawConfig;
+ jobId: string;
+ agentId?: string;
+ toolsAllow?: string[];
+ signal?: AbortSignal;
+}) => Promise;
+
+type CronTriggerEvaluatorDeps = {
+ config: OpenClawConfig;
+ runHeadless?: typeof runCodeModeScriptHeadless;
+ prepareRuntime?: PrepareTriggerRuntime;
+};
+
+type TriggerRuntimeCacheEntry = {
+ promise: Promise;
+ configEpoch: OpenClawConfig;
+ agentId: string;
+ toolsAllowKey: string;
+};
+
+function resolveTriggerAgentId(config: OpenClawConfig, agentId?: string): string {
+ return agentId?.trim() ? normalizeAgentId(agentId) : resolveDefaultAgentId(config);
+}
+
+async function prepareTriggerRuntime(params: {
+ runtimeConfig: OpenClawConfig;
+ jobId: string;
+ agentId?: string;
+ toolsAllow?: string[];
+ signal?: AbortSignal;
+}): Promise {
+ params.signal?.throwIfAborted();
+ const agentId = resolveTriggerAgentId(params.runtimeConfig, params.agentId);
+ const selectedAgentConfig = resolveAgentConfig(params.runtimeConfig, agentId);
+ const agentConfigOverride = params.agentId?.trim() ? selectedAgentConfig : undefined;
+ const agentDefaults = buildCronAgentDefaultsConfig({
+ defaults: params.runtimeConfig.agents?.defaults,
+ agentConfigOverride,
+ });
+ const config: OpenClawConfig = {
+ ...params.runtimeConfig,
+ agents: Object.assign({}, params.runtimeConfig.agents, { defaults: agentDefaults }),
+ };
+ const workspaceDirRaw = resolveAgentWorkspaceDir(config, agentId);
+ const agentDir = resolveAgentDir(config, agentId);
+ const workspace = await ensureAgentWorkspace({
+ dir: workspaceDirRaw,
+ ensureBootstrapFiles: !agentDefaults.skipBootstrap,
+ skipOptionalBootstrapFiles: agentDefaults.skipOptionalBootstrapFiles,
+ });
+ params.signal?.throwIfAborted();
+ const workspaceDir = workspace.dir;
+ ensureRuntimePluginsLoaded({
+ config,
+ workspaceDir,
+ allowGatewaySubagentBinding: true,
+ });
+
+ const rawSessionKey = `cron:${params.jobId}:trigger`;
+ const sessionKey = resolveCronAgentSessionKey({
+ sessionKey: rawSessionKey,
+ agentId,
+ mainKey: config.session?.mainKey,
+ cfg: config,
+ });
+ const sandbox = await resolveSandboxContext({
+ config,
+ sessionKey,
+ workspaceDir,
+ });
+ params.signal?.throwIfAborted();
+ const effectiveWorkspace =
+ sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? sandbox.workspaceDir : workspaceDir;
+ const toolPlan = resolveEmbeddedAttemptToolConstructionPlan({
+ toolsEnabled: true,
+ toolsAllow: params.toolsAllow,
+ });
+ // Bundle MCP tools are source:"mcp", which the headless bridge excludes.
+ // LSP runtimes are session-scoped and intentionally outside trigger v1.
+ const allTools = toolPlan.constructTools
+ ? createOpenClawCodingTools({
+ agentId,
+ exec: { config },
+ sandbox,
+ sessionKey,
+ trigger: "cron",
+ jobId: params.jobId,
+ agentDir,
+ cwd: effectiveWorkspace,
+ workspaceDir: effectiveWorkspace,
+ spawnWorkspaceDir: workspaceDir,
+ config,
+ allowGatewaySubagentBinding: true,
+ includeCoreTools: toolPlan.includeCoreTools,
+ runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
+ toolConstructionPlan: toolPlan.codingToolConstructionPlan,
+ })
+ : [];
+ const tools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow, {
+ toolMeta: (tool) => getPluginToolMeta(tool),
+ });
+ const hookContext: HookContext = {
+ agentId,
+ config,
+ cwd: effectiveWorkspace,
+ workspaceDir: effectiveWorkspace,
+ sessionKey,
+ loopDetection: resolveToolLoopDetectionConfig({ cfg: config, agentId }),
+ };
+ return {
+ tools,
+ hookContext,
+ ctx: {
+ config,
+ runtimeConfig: config,
+ agentId,
+ sessionKey,
+ },
+ };
+}
+
+function triggerStateNamespace(state: unknown): CodeModeNamespaceDescriptor {
+ return {
+ id: "cron:trigger",
+ globalName: "trigger",
+ scope: {
+ kind: "object",
+ entries: [["state", { kind: "value", value: state }]],
+ },
+ };
+}
+
+function triggerResultCandidate(result: Extract) {
+ if (isRecord(result.value) && typeof result.value.fire === "boolean") {
+ return result.value;
+ }
+ for (let index = result.output.length - 1; index >= 0; index -= 1) {
+ const entry = result.output[index];
+ if (isRecord(entry) && entry.type === "json") {
+ return entry.value;
+ }
+ }
+ return undefined;
+}
+
+function parseTriggerResult(
+ result: Extract,
+): CronTriggerEvaluationResult {
+ const candidate = triggerResultCandidate(result);
+ if (!isRecord(candidate) || typeof candidate.fire !== "boolean") {
+ return {
+ kind: "error",
+ code: "internal_error",
+ error: "cron trigger script must return an object with boolean fire",
+ };
+ }
+ if (candidate.message !== undefined && typeof candidate.message !== "string") {
+ return {
+ kind: "error",
+ code: "internal_error",
+ error: "cron trigger script message must be a string",
+ };
+ }
+ const hasState = Object.hasOwn(candidate, "state");
+ if (hasState) {
+ let serialized: string | undefined;
+ try {
+ serialized = JSON.stringify(candidate.state);
+ } catch (error) {
+ return {
+ kind: "error",
+ code: "internal_error",
+ error: `cron trigger state is not JSON-serializable: ${String(error)}`,
+ };
+ }
+ if (serialized === undefined) {
+ return {
+ kind: "error",
+ code: "internal_error",
+ error: "cron trigger state is not JSON-serializable",
+ };
+ }
+ if (Buffer.byteLength(serialized, "utf8") > MAX_TRIGGER_STATE_BYTES) {
+ return {
+ kind: "error",
+ code: "output_limit_exceeded",
+ error: "cron trigger state exceeds the 16KB limit",
+ };
+ }
+ }
+ return {
+ kind: "evaluated",
+ fire: candidate.fire,
+ ...(typeof candidate.message === "string" ? { message: candidate.message } : {}),
+ ...(hasState ? { state: candidate.state } : {}),
+ };
+}
+
+class TriggerEvaluationTimeoutError extends Error {
+ constructor(message = "cron trigger evaluation timed out") {
+ super(message);
+ this.name = "TriggerEvaluationTimeoutError";
+ }
+}
+
+function createTriggerDeadlineScope(externalSignal?: AbortSignal) {
+ const controller = new AbortController();
+ const onExternalAbort = () =>
+ controller.abort(new TriggerEvaluationTimeoutError("cron trigger evaluation aborted"));
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
+ if (externalSignal?.aborted) {
+ onExternalAbort();
+ }
+ const timer = setTimeout(
+ () => controller.abort(new TriggerEvaluationTimeoutError()),
+ HEADLESS_TRIGGER_WALL_CLOCK_MS,
+ );
+ return {
+ deadline: Date.now() + HEADLESS_TRIGGER_WALL_CLOCK_MS,
+ signal: controller.signal,
+ cleanup: () => {
+ clearTimeout(timer);
+ externalSignal?.removeEventListener("abort", onExternalAbort);
+ },
+ };
+}
+
+async function awaitTriggerSignal(promise: Promise, signal: AbortSignal): Promise {
+ if (signal.aborted) {
+ throw signal.reason instanceof Error ? signal.reason : new TriggerEvaluationTimeoutError();
+ }
+ let onAbort: (() => void) | undefined;
+ try {
+ const aborted = new Promise((_resolve, reject) => {
+ onAbort = () =>
+ reject(
+ signal.reason instanceof Error ? signal.reason : new TriggerEvaluationTimeoutError(),
+ );
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+ return await Promise.race([promise, aborted]);
+ } finally {
+ if (onAbort) {
+ signal.removeEventListener("abort", onAbort);
+ }
+ }
+}
+
+export function createCronTriggerEvaluator(deps: CronTriggerEvaluatorDeps) {
+ const runHeadless = deps.runHeadless ?? runCodeModeScriptHeadless;
+ const prepareRuntime = deps.prepareRuntime ?? prepareTriggerRuntime;
+ // Config identity is the reload epoch; caching the preparation promise makes
+ // concurrent cold evaluations for one job single-flight.
+ const runtimeCache = new Map();
+
+ const trimRuntimeCache = () => {
+ while (runtimeCache.size > MAX_CACHED_TRIGGER_RUNTIMES) {
+ const oldestJobId = runtimeCache.keys().next().value;
+ if (oldestJobId === undefined) {
+ return;
+ }
+ runtimeCache.delete(oldestJobId);
+ }
+ };
+ const resolveCachedRuntime = async (request: {
+ runtimeConfig: OpenClawConfig;
+ jobId: string;
+ requestedAgentId?: string;
+ agentId: string;
+ toolsAllow?: string[];
+ toolsAllowKey: string;
+ signal: AbortSignal;
+ }): Promise => {
+ const cached = runtimeCache.get(request.jobId);
+ if (
+ cached &&
+ cached.configEpoch === request.runtimeConfig &&
+ cached.agentId === request.agentId &&
+ cached.toolsAllowKey === request.toolsAllowKey
+ ) {
+ runtimeCache.delete(request.jobId);
+ runtimeCache.set(request.jobId, cached);
+ return await awaitTriggerSignal(cached.promise, request.signal);
+ }
+ const promise = prepareRuntime({
+ runtimeConfig: request.runtimeConfig,
+ jobId: request.jobId,
+ agentId: request.requestedAgentId,
+ toolsAllow: request.toolsAllow,
+ signal: request.signal,
+ });
+ const entry: TriggerRuntimeCacheEntry = {
+ promise,
+ configEpoch: request.runtimeConfig,
+ agentId: request.agentId,
+ toolsAllowKey: request.toolsAllowKey,
+ };
+ runtimeCache.delete(request.jobId);
+ runtimeCache.set(request.jobId, entry);
+ trimRuntimeCache();
+ // Failed preparations evict themselves so the next tick retries cold.
+ void promise.catch(() => {
+ if (runtimeCache.get(request.jobId) === entry) {
+ runtimeCache.delete(request.jobId);
+ }
+ });
+ return await awaitTriggerSignal(entry.promise, request.signal);
+ };
+
+ return async function evaluateCronTrigger(params: {
+ jobId: string;
+ agentId?: string;
+ script: string;
+ state: unknown;
+ toolsAllow?: string[];
+ abortSignal?: AbortSignal;
+ }): Promise {
+ if (activeTriggerEvaluations >= MAX_CONCURRENT_TRIGGER_EVALS) {
+ return { kind: "busy" };
+ }
+ activeTriggerEvaluations += 1;
+ const evaluationScope = createTriggerDeadlineScope(params.abortSignal);
+ try {
+ const runtimeConfig = resolveCronActiveRuntimeConfig(deps.config);
+ const agentId = resolveTriggerAgentId(runtimeConfig, params.agentId);
+ const toolsAllowKey = JSON.stringify(params.toolsAllow ?? null);
+ const runtime = await resolveCachedRuntime({
+ runtimeConfig,
+ jobId: params.jobId,
+ requestedAgentId: params.agentId,
+ agentId,
+ toolsAllow: params.toolsAllow,
+ toolsAllowKey,
+ signal: evaluationScope.signal,
+ });
+
+ const catalogRef = createToolSearchCatalogRef();
+ const runId = `cron-trigger:${params.jobId}:${crypto.randomUUID()}`;
+ registerHeadlessToolSearchCatalog({
+ catalogRef,
+ tools: runtime.tools,
+ hookContext: { ...runtime.hookContext, runId },
+ });
+ const remainingWallClockMs = evaluationScope.deadline - Date.now();
+ if (remainingWallClockMs <= 0) {
+ throw new TriggerEvaluationTimeoutError();
+ }
+ const result = await runHeadless({
+ ctx: { ...runtime.ctx, catalogRef, abortSignal: evaluationScope.signal },
+ code: params.script,
+ wallClockMs: remainingWallClockMs,
+ maxToolCalls: HEADLESS_TRIGGER_TOOL_BUDGET,
+ extraNamespaces: [triggerStateNamespace(params.state)],
+ signal: evaluationScope.signal,
+ });
+ if (result.status === "failed") {
+ return { kind: "error", code: result.code, error: result.error };
+ }
+ return parseTriggerResult(result);
+ } catch (error) {
+ return {
+ kind: "error",
+ code: error instanceof TriggerEvaluationTimeoutError ? "timeout" : "internal_error",
+ error: error instanceof Error ? error.message : String(error),
+ };
+ } finally {
+ evaluationScope.cleanup();
+ activeTriggerEvaluations -= 1;
+ }
+ };
+}
diff --git a/src/cron/types.ts b/src/cron/types.ts
index 8b15b3c99c5..18fb80e39c7 100644
--- a/src/cron/types.ts
+++ b/src/cron/types.ts
@@ -320,6 +320,14 @@ export type CronJobState = {
lastFailureAlertAtMs?: number;
/** Number of consecutive schedule computation errors. Auto-disables job after threshold. */
scheduleErrorCount?: number;
+ /** Timestamp of the last trigger script evaluation. */
+ lastTriggerEvalAtMs?: number;
+ /** Number of completed trigger script evaluations. */
+ triggerEvalCount?: number;
+ /** Timestamp of the last trigger evaluation that fired. */
+ lastTriggerFireAtMs?: number;
+ /** JSON state returned by the last trigger script evaluation. */
+ triggerState?: unknown;
/** Explicit delivery outcome, separate from execution outcome. */
lastDeliveryStatus?: CronDeliveryStatus;
/** Delivery-specific error text when available. */
@@ -334,6 +342,32 @@ export type CronJobState = {
lastFailureNotificationDeliveryError?: string;
};
+export type CronTrigger = {
+ script: string;
+ once?: boolean;
+};
+
+/**
+ * Closed failure taxonomy for trigger-script evaluation. Mirrors the code-mode
+ * failure codes plus the trigger tool budget; trigger-script.ts asserts the
+ * union stays in sync at compile time. Lives here (leaf module) so the cron
+ * service contract never imports the agents runtime.
+ */
+export type CronTriggerFailureCode =
+ | "invalid_input"
+ | "runtime_unavailable"
+ | "timeout"
+ | "output_limit_exceeded"
+ | "snapshot_limit_exceeded"
+ | "internal_error"
+ | "tool_budget_exceeded";
+
+/** Result union returned by the cron trigger-script evaluator. */
+export type CronTriggerEvaluationResult =
+ | { kind: "evaluated"; fire: boolean; message?: string; state?: unknown }
+ | { kind: "busy" }
+ | { kind: "error"; code: CronTriggerFailureCode; error: string };
+
/** Fully persisted cron job with spec fields and mutable run state. */
export type CronJob = CronJobBase<
CronSchedule,
@@ -349,6 +383,7 @@ export type CronJob = CronJobBase<
agentId?: string;
sessionKey?: string;
};
+ trigger?: CronTrigger;
state: CronJobState;
};
@@ -380,6 +415,7 @@ export type CronJobPatch = Partial<
>
> & {
displayName?: string | null;
+ trigger?: CronTrigger | null;
payload?: CronPayloadPatch;
delivery?: CronDeliveryPatch;
state?: Partial;
diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts
index 824ea808ec4..c7b7d5a1509 100644
--- a/src/gateway/server-cron.ts
+++ b/src/gateway/server-cron.ts
@@ -31,6 +31,7 @@ import {
resolveCronSessionTargetSessionKey,
} from "../cron/session-target.js";
import { resolveCronJobsStorePath } from "../cron/store.js";
+import { createCronTriggerEvaluator } from "../cron/trigger-script.js";
import type { CronJob, CronPayload } from "../cron/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveMainScopedEventSessionKey } from "../infra/event-session-routing.js";
@@ -356,6 +357,10 @@ export function buildGatewayCronService(params: {
agentId: agentId ?? defaultAgentId,
});
const sessionStorePath = resolveSessionStorePath(defaultAgentId);
+ const triggerEvaluator =
+ params.cfg.cron?.triggers?.enabled === true
+ ? createCronTriggerEvaluator({ config: params.cfg })
+ : undefined;
const runCronChangedHook = (evt: PluginHookCronChangedEvent) => {
const hookRunner = getGlobalHookRunner();
@@ -399,6 +404,19 @@ export function buildGatewayCronService(params: {
storePath,
cronEnabled,
cronConfig: params.cfg.cron,
+ ...(triggerEvaluator
+ ? {
+ evaluateCronTrigger: ({ job, script, state, abortSignal }) =>
+ triggerEvaluator({
+ jobId: job.id,
+ agentId: job.agentId,
+ script,
+ state,
+ toolsAllow: job.payload.kind === "agentTurn" ? job.payload.toolsAllow : undefined,
+ abortSignal,
+ }),
+ }
+ : {}),
defaultAgentId,
resolveSessionStorePath,
sessionStorePath,
@@ -731,6 +749,7 @@ export function buildGatewayCronService(params: {
runAtMs: evt.runAtMs,
durationMs: evt.durationMs,
nextRunAtMs: evt.nextRunAtMs,
+ triggerFired: evt.triggerFired,
model: evt.model,
provider: evt.provider,
usage: evt.usage,
diff --git a/src/gateway/server-methods/cron-error-classification.ts b/src/gateway/server-methods/cron-error-classification.ts
index cb7952af797..7208d1376de 100644
--- a/src/gateway/server-methods/cron-error-classification.ts
+++ b/src/gateway/server-methods/cron-error-classification.ts
@@ -8,6 +8,9 @@ export function isCronInvalidRequestError(err: unknown): boolean {
message.includes("cron job id must not be blank") ||
message.includes("cron declarationKey") ||
message.includes("cron displayName") ||
+ message.includes("cron triggers are disabled") ||
+ message.includes("cron triggers require") ||
+ message.includes("cron trigger every interval") ||
message.includes("cron job is missing sessionTarget") ||
message.includes("invalid cron sessionTarget session id") ||
message.includes('main cron jobs require payload.kind="systemEvent"') ||
diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts
index 99b0b21b414..068d7f0a2e6 100644
--- a/src/gateway/server-methods/cron.ts
+++ b/src/gateway/server-methods/cron.ts
@@ -101,6 +101,7 @@ function compactCronListJob(job: CronJob) {
enabled: job.enabled,
nextRunAtMs: job.state.nextRunAtMs ?? null,
scheduleKind: job.schedule.kind,
+ ...(job.trigger ? { trigger: true } : {}),
lastRunAtMs: job.state.lastRunAtMs ?? null,
lastRunStatus: job.state.lastRunStatus ?? job.state.lastStatus ?? null,
lastRunError: job.state.lastError ?? null,
@@ -189,6 +190,7 @@ async function assertValidCronUpdatePatch(params: {
const nextJob = structuredClone(params.currentJob);
applyJobPatch(nextJob, params.patch, {
defaultAgentId: params.defaultAgentId,
+ cronConfig: params.cfg.cron,
});
if ("delivery" in params.patch) {
const delivery =
diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts
index 78334b1b2e8..bcf4d988bb4 100644
--- a/src/gateway/server.cron.test.ts
+++ b/src/gateway/server.cron.test.ts
@@ -575,6 +575,32 @@ describe("gateway server cron", () => {
}
});
+ test("returns INVALID_REQUEST when cron trigger authoring is disabled", async () => {
+ const { prevSkipCron } = await setupCronTestRun({
+ tempPrefix: "openclaw-gw-cron-trigger-gate-",
+ cronEnabled: false,
+ });
+ const cronState = await createDirectCronState();
+
+ try {
+ const response = await directCronReq(cronState, "cron.add", {
+ name: "disabled watcher",
+ enabled: true,
+ schedule: { kind: "every", everyMs: 30_000 },
+ trigger: { script: "json({ fire: true })" },
+ sessionTarget: "main",
+ wakeMode: "now",
+ payload: { kind: "systemEvent", text: "changed" },
+ });
+
+ expect(response.ok).toBe(false);
+ expect(response.error?.code).toBe("INVALID_REQUEST");
+ expect(response.error?.message).toContain("cron triggers are disabled");
+ } finally {
+ await cleanupCronTestRun({ cronState, prevSkipCron });
+ }
+ });
+
test("cron.add leaves legacy top-level array stores for doctor migration", async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-legacy-array-",
diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts
index 5e2b36aebd3..8f76da0ad86 100644
--- a/src/state/openclaw-state-db.generated.d.ts
+++ b/src/state/openclaw-state-db.generated.d.ts
@@ -346,6 +346,8 @@ export interface CronJobs {
stagger_ms: number | null;
state_json: Generated;
store_key: string;
+ trigger_once: number | null;
+ trigger_script: string | null;
updated_at: number;
wake_mode: string;
}
diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts
index 1a2d65bc5bb..666bc03b655 100644
--- a/src/state/openclaw-state-db.ts
+++ b/src/state/openclaw-state-db.ts
@@ -746,6 +746,8 @@ function ensureAdditiveStateColumns(db: DatabaseSync): void {
ensureColumn(db, "cron_jobs", "stagger_ms INTEGER");
ensureColumn(db, "cron_jobs", "session_target TEXT NOT NULL DEFAULT 'main'");
ensureColumn(db, "cron_jobs", "wake_mode TEXT NOT NULL DEFAULT 'auto'");
+ ensureColumn(db, "cron_jobs", "trigger_script TEXT");
+ ensureColumn(db, "cron_jobs", "trigger_once INTEGER");
ensureColumn(db, "cron_jobs", "payload_kind TEXT NOT NULL DEFAULT 'message'");
ensureColumn(db, "cron_jobs", "payload_message TEXT");
ensureColumn(db, "cron_jobs", "payload_model TEXT");
diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts
index 52e15cccc8c..8bbc9acdcce 100644
--- a/src/state/openclaw-state-schema.generated.ts
+++ b/src/state/openclaw-state-schema.generated.ts
@@ -1000,6 +1000,8 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
stagger_ms INTEGER,
session_target TEXT NOT NULL,
wake_mode TEXT NOT NULL,
+ trigger_script TEXT,
+ trigger_once INTEGER,
payload_kind TEXT NOT NULL,
payload_message TEXT,
payload_model TEXT,
diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql
index 311dd62115a..31c6aaca975 100644
--- a/src/state/openclaw-state-schema.sql
+++ b/src/state/openclaw-state-schema.sql
@@ -995,6 +995,8 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
stagger_ms INTEGER,
session_target TEXT NOT NULL,
wake_mode TEXT NOT NULL,
+ trigger_script TEXT,
+ trigger_once INTEGER,
payload_kind TEXT NOT NULL,
payload_message TEXT,
payload_model TEXT,
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json
index 78fbc8c11a4..484070a9dba 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json
@@ -395,7 +395,7 @@
},
{
"deferLoading": true,
- "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
+ "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"trigger\": { \"script\": \"...\", \"once\": false }, // optional condition gate for every/cron\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nOptional trigger scripts poll headlessly on every/cron schedules and run the payload only when they return { fire: true }.\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -684,6 +684,21 @@
"description": "main | isolated | current | session:",
"type": "string"
},
+ "trigger": {
+ "additionalProperties": false,
+ "properties": {
+ "once": {
+ "type": "boolean"
+ },
+ "script": {
+ "maxLength": 65536,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": ["script"],
+ "type": "object"
+ },
"wakeMode": {
"description": "Wake timing",
"enum": ["now", "next-heartbeat"],
@@ -1022,6 +1037,28 @@
"description": "Session target",
"type": "string"
},
+ "trigger": {
+ "anyOf": [
+ {
+ "additionalProperties": false,
+ "properties": {
+ "once": {
+ "type": "boolean"
+ },
+ "script": {
+ "maxLength": 65536,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": ["script"],
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"wakeMode": {
"enum": ["now", "next-heartbeat"],
"type": "string"
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json
index 66fe9aecac5..e74fdee1a05 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json
@@ -391,7 +391,7 @@
},
{
"deferLoading": true,
- "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
+ "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"trigger\": { \"script\": \"...\", \"once\": false }, // optional condition gate for every/cron\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nOptional trigger scripts poll headlessly on every/cron schedules and run the payload only when they return { fire: true }.\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -680,6 +680,21 @@
"description": "main | isolated | current | session:",
"type": "string"
},
+ "trigger": {
+ "additionalProperties": false,
+ "properties": {
+ "once": {
+ "type": "boolean"
+ },
+ "script": {
+ "maxLength": 65536,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": ["script"],
+ "type": "object"
+ },
"wakeMode": {
"description": "Wake timing",
"enum": ["now", "next-heartbeat"],
@@ -1018,6 +1033,28 @@
"description": "Session target",
"type": "string"
},
+ "trigger": {
+ "anyOf": [
+ {
+ "additionalProperties": false,
+ "properties": {
+ "once": {
+ "type": "boolean"
+ },
+ "script": {
+ "maxLength": 65536,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": ["script"],
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"wakeMode": {
"enum": ["now", "next-heartbeat"],
"type": "string"
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json
index 3fabe33e452..fd531c5a6f9 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json
@@ -391,7 +391,7 @@
},
{
"deferLoading": true,
- "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
+ "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: compact job summaries; includeDisabled true includes disabled; use get for full job details; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: run only if due by default; needs jobId; pass runMode=\"force\" to trigger now\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane owned by the calling agent.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"trigger\": { \"script\": \"...\", \"once\": false }, // optional condition gate for every/cron\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nOptional trigger scripts poll headlessly on every/cron schedules and run the payload only when they return { fire: true }.\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -680,6 +680,21 @@
"description": "main | isolated | current | session:",
"type": "string"
},
+ "trigger": {
+ "additionalProperties": false,
+ "properties": {
+ "once": {
+ "type": "boolean"
+ },
+ "script": {
+ "maxLength": 65536,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": ["script"],
+ "type": "object"
+ },
"wakeMode": {
"description": "Wake timing",
"enum": ["now", "next-heartbeat"],
@@ -1018,6 +1033,28 @@
"description": "Session target",
"type": "string"
},
+ "trigger": {
+ "anyOf": [
+ {
+ "additionalProperties": false,
+ "properties": {
+ "once": {
+ "type": "boolean"
+ },
+ "script": {
+ "maxLength": 65536,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": ["script"],
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"wakeMode": {
"enum": ["now", "next-heartbeat"],
"type": "string"
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md
index 7c9ffee1812..d936e33352a 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md
@@ -225,8 +225,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
- "chars": 51940,
- "roughTokens": 12985
+ "chars": 53471,
+ "roughTokens": 13368
},
"openClawDeveloperInstructions": {
"chars": 3245,
@@ -237,8 +237,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6943
},
"totalWithDynamicToolsJson": {
- "chars": 79712,
- "roughTokens": 19928
+ "chars": 81243,
+ "roughTokens": 20311
},
"userInputText": {
"chars": 1442,
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
index 8bd2c417396..9bb7fddcbca 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
@@ -225,8 +225,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
- "chars": 51629,
- "roughTokens": 12908
+ "chars": 53160,
+ "roughTokens": 13290
},
"openClawDeveloperInstructions": {
"chars": 2136,
@@ -237,8 +237,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6563
},
"totalWithDynamicToolsJson": {
- "chars": 77883,
- "roughTokens": 19471
+ "chars": 79414,
+ "roughTokens": 19854
},
"userInputText": {
"chars": 1033,
diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md
index 642a0542d9c..f004bd6819b 100644
--- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md
+++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md
@@ -226,8 +226,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
- "chars": 52919,
- "roughTokens": 13230
+ "chars": 54450,
+ "roughTokens": 13613
},
"openClawDeveloperInstructions": {
"chars": 2155,
@@ -238,8 +238,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6799
},
"totalWithDynamicToolsJson": {
- "chars": 80116,
- "roughTokens": 20029
+ "chars": 81647,
+ "roughTokens": 20412
},
"userInputText": {
"chars": 1271,