diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index ac8e19791e7..3c09c5b1174 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -27,6 +27,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
```bash
openclaw cron list
+ openclaw cron get
openclaw cron show
```
@@ -359,6 +360,9 @@ When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `
# List all jobs
openclaw cron list
+# Get one stored job as JSON
+openclaw cron get
+
# Show one job, including resolved delivery route
openclaw cron show
diff --git a/docs/cli/cron.md b/docs/cli/cron.md
index 0485205769e..5f52c928997 100644
--- a/docs/cli/cron.md
+++ b/docs/cli/cron.md
@@ -220,6 +220,7 @@ Manual run and inspection:
```bash
openclaw cron list
openclaw cron list --agent ops
+openclaw cron get
openclaw cron show
openclaw cron run
openclaw cron run --due
@@ -228,6 +229,8 @@ openclaw cron runs --id --limit 50
`openclaw cron list` shows all matching jobs by default. Pass `--agent ` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent.
+`openclaw cron get ` returns the stored job JSON directly. Use `cron show ` when you want the human-readable view with delivery-route preview.
+
`cron list --json` and `cron show --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. This mirrors the human-readable status column so external tooling can read job state without re-deriving it.
`cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state.
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 14cf25a6156..57dc671a0f2 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -256,6 +256,7 @@ openclaw [--dev] [--profile ]
cron
status
list
+ get
add
edit
rm
diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md
index 24a8411d95a..896a1783cd9 100644
--- a/docs/gateway/protocol.md
+++ b/docs/gateway/protocol.md
@@ -467,7 +467,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
- - Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
+ - Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.get`, `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
- Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`.
diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts
index 2898b8ad90f..b134d7d7f7c 100644
--- a/src/agents/tools/cron-tool.test.ts
+++ b/src/agents/tools/cron-tool.test.ts
@@ -193,7 +193,7 @@ describe("cron tool", () => {
action: "remove",
jobId: "job-other",
}),
- ).rejects.toThrow("Cron tool is restricted to removing the current cron job.");
+ ).rejects.toThrow("Cron tool is restricted to the current cron job.");
expect(callGatewayMock).not.toHaveBeenCalled();
});
@@ -233,7 +233,7 @@ describe("cron tool", () => {
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
await expect(tool.execute("call-runs-denied", args)).rejects.toThrow(
- "Cron tool is restricted to removing the current cron job.",
+ "Cron tool is restricted to the current cron job.",
);
expect(callGatewayMock).not.toHaveBeenCalled();
@@ -258,6 +258,33 @@ describe("cron tool", () => {
expect(result.details).toEqual({ enabled: true });
});
+ it("allows scoped isolated cron runs to get the current job", async () => {
+ callGatewayMock.mockResolvedValueOnce({ id: "job-current", name: "current" });
+ const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
+
+ const result = await tool.execute("call-get", {
+ action: "get",
+ jobId: "job-current",
+ });
+
+ const params = expectSingleGatewayCallMethod("cron.get");
+ expect(params).toStrictEqual({ id: "job-current" });
+ expect(result.details).toEqual({ id: "job-current", name: "current" });
+ });
+
+ it.each([
+ ["another job", { action: "get", jobId: "job-other" }],
+ ["missing job id", { action: "get" }],
+ ])("denies scoped isolated cron runs from getting %s", async (_label, args) => {
+ const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
+
+ await expect(tool.execute("call-get-denied", args)).rejects.toThrow(
+ "Cron tool is restricted to the current cron job.",
+ );
+
+ expect(callGatewayMock).not.toHaveBeenCalled();
+ });
+
it("allows scoped isolated cron runs to list only the current job", async () => {
callGatewayMock.mockResolvedValueOnce({
jobs: [
@@ -366,7 +393,7 @@ describe("cron tool", () => {
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
await expect(tool.execute("call-denied", args)).rejects.toThrow(
- "Cron tool is restricted to removing the current cron job.",
+ "Cron tool is restricted to the current cron job.",
);
expect(callGatewayMock).not.toHaveBeenCalled();
@@ -437,6 +464,8 @@ describe("cron tool", () => {
["remove", { action: "remove", id: "job-2" }, { id: "job-2" }],
["run", { action: "run", jobId: "job-1" }, { id: "job-1", mode: "force" }],
["run", { action: "run", id: "job-2" }, { id: "job-2", mode: "force" }],
+ ["get", { action: "get", jobId: "job-1" }, { id: "job-1" }],
+ ["get", { action: "get", id: "job-2" }, { id: "job-2" }],
["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }],
["runs", { action: "runs", id: "job-2" }, { id: "job-2" }],
])("%s sends id to gateway", async (action, args, expectedParams) => {
diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts
index b531b593ce3..726b83f1574 100644
--- a/src/agents/tools/cron-tool.ts
+++ b/src/agents/tools/cron-tool.ts
@@ -29,7 +29,17 @@ import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-h
// We spell out job/patch properties so that LLMs know what fields to send.
// Nested unions are avoided; runtime validation happens in normalizeCronJob*.
-const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs", "wake"] as const;
+const CRON_ACTIONS = [
+ "status",
+ "list",
+ "get",
+ "add",
+ "update",
+ "remove",
+ "run",
+ "runs",
+ "wake",
+] as const;
const CRON_SCHEDULE_KINDS = ["at", "every", "cron"] as const;
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
@@ -315,7 +325,6 @@ export const CronToolSchema = Type.Object(
type CronToolOptions = {
agentSessionKey?: string;
currentDeliveryContext?: DeliveryContext;
- /** Restrict this cron tool instance to removing only this active cron job. */
selfRemoveOnlyJobId?: string;
};
@@ -350,7 +359,7 @@ function readCronJobIdParam(params: Record) {
return readStringParam(params, "jobId") ?? readStringParam(params, "id");
}
-const CRON_SELF_REMOVE_SCOPE_ERROR = "Cron tool is restricted to removing the current cron job.";
+const CRON_SELF_REMOVE_SCOPE_ERROR = "Cron tool is restricted to the current cron job.";
function readCronSelfRemoveOnlyJobId(opts: CronToolOptions | undefined) {
return opts?.selfRemoveOnlyJobId?.trim() || undefined;
@@ -369,7 +378,7 @@ function assertCronSelfRemoveScope(
if (!selfRemoveOnlyJobId || isCronSelfIntrospectionAction(action)) {
return;
}
- if (action === "remove" || action === "runs") {
+ if (action === "get" || action === "remove" || action === "runs") {
const id = readCronJobIdParam(params);
if (id && id === selfRemoveOnlyJobId) {
return;
@@ -623,13 +632,14 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any
name: "cron",
ownerOnly: isOpenClawOwnerOnlyCoreToolName("cron"),
displaySummary: CRON_TOOL_DISPLAY_SUMMARY,
- description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.
+ description: `Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.
Main-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in \`openclaw tasks\`.
ACTIONS:
- status: Check cron scheduler status
- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)
+- get: Get one job by id (requires jobId)
- add: Create job (requires job object, see schema below)
- update: Modify job (requires jobId + patch object)
- remove: Delete job (requires jobId)
@@ -692,7 +702,7 @@ CRITICAL CONSTRAINTS:
Default: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.
RESTRICTED CRON RUNS:
-- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.
+- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.
WAKE MODES (for wake action):
- "next-heartbeat" (default): Wake on next heartbeat
@@ -756,6 +766,13 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
selfRemoveOnlyJobId ? filterCronListResultToJobId(result, selfRemoveOnlyJobId) : result,
);
}
+ case "get": {
+ const id = readCronJobIdParam(params);
+ if (!id) {
+ throw new Error("jobId required (id accepted for backward compatibility)");
+ }
+ return jsonResult(await callGateway("cron.get", gatewayOpts, { id }));
+ }
case "add": {
// Flat-params recovery: non-frontier models (e.g. Grok) sometimes flatten
// job properties to the top level alongside `action` instead of nesting
diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts
index dd50ce7d031..7018a26b193 100644
--- a/src/cli/cron-cli.test.ts
+++ b/src/cli/cron-cli.test.ts
@@ -513,6 +513,14 @@ describe("cron cli", () => {
expect(listCall?.[2]).toEqual({ includeDisabled: false, agentId: "ops" });
});
+ it("routes cron get to cron.get with the provided id", async () => {
+ await runCronCommand(["cron", "get", "job-1"]);
+
+ const getCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.get");
+ expect(getCall?.[2]).toEqual({ id: "job-1" });
+ expect(stdoutText()).toContain('"id": "job-1"');
+ });
+
it("paginates cron show lookups", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts
index 0c1dbe59a62..de1fe554ccb 100644
--- a/src/cli/cron-cli/register.cron-simple.ts
+++ b/src/cli/cron-cli/register.cron-simple.ts
@@ -110,6 +110,21 @@ export function registerCronSimpleCommands(cron: Command) {
enabled: false,
});
+ addGatewayClientOptions(
+ cron
+ .command("get")
+ .description("Get a cron job as JSON")
+ .argument("", "Job id")
+ .action(async (id, opts) => {
+ try {
+ const res = await callGatewayFromCli("cron.get", opts, { id: String(id) });
+ printCronJson(res);
+ } catch (err) {
+ handleCronCliError(err);
+ }
+ }),
+ );
+
addGatewayClientOptions(
cron
.command("show")
diff --git a/src/cron/service-contract.ts b/src/cron/service-contract.ts
index 3ae1246cb87..3d579f73f50 100644
--- a/src/cron/service-contract.ts
+++ b/src/cron/service-contract.ts
@@ -29,6 +29,7 @@ export interface CronServiceContract {
run(id: string, mode?: CronRunMode): Promise;
enqueueRun(id: string, mode?: CronRunMode): Promise;
getJob(id: string): CronJob | undefined;
+ readJob(id: string): Promise;
getDefaultAgentId(): string | undefined;
wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }): CronWakeResult;
}
diff --git a/src/cron/service.get-job.test.ts b/src/cron/service.get-job.test.ts
index 93fb0d3a561..a238f84dc95 100644
--- a/src/cron/service.get-job.test.ts
+++ b/src/cron/service.get-job.test.ts
@@ -10,10 +10,10 @@ const logger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-get-job-" });
installCronTestHooks({ logger });
-function createCronService(storePath: string) {
+function createCronService(storePath: string, cronEnabled = true) {
return new CronService({
storePath,
- cronEnabled: true,
+ cronEnabled,
log: logger,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
@@ -38,7 +38,8 @@ describe("CronService.getJob", () => {
});
expect(cron.getJob(added.id)?.id).toBe(added.id);
- expect(cron.getJob("missing-job-id")).toBeUndefined();
+ await expect(cron.readJob(added.id)).resolves.toEqual(added);
+ await expect(cron.readJob("missing-job-id")).resolves.toBeUndefined();
} finally {
cron.stop();
}
@@ -59,6 +60,12 @@ describe("CronService.getJob", () => {
payload: { kind: "systemEvent", text: "ping" },
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
});
+ await expect(cron.readJob(webhookJob.id)).resolves.toMatchObject({
+ delivery: {
+ mode: "webhook",
+ to: "https://example.invalid/cron",
+ },
+ });
expect(cron.getJob(webhookJob.id)?.delivery).toEqual({
mode: "webhook",
to: "https://example.invalid/cron",
@@ -67,4 +74,27 @@ describe("CronService.getJob", () => {
cron.stop();
}
});
+
+ it("loads persisted jobs for direct reads without starting the scheduler", async () => {
+ const { storePath } = await makeStorePath();
+ const writer = createCronService(storePath);
+ await writer.start();
+ const persisted = await writer.add({
+ name: "persisted-job",
+ enabled: true,
+ schedule: { kind: "every", everyMs: 60_000 },
+ sessionTarget: "main",
+ wakeMode: "next-heartbeat",
+ payload: { kind: "systemEvent", text: "ping" },
+ });
+ writer.stop();
+
+ const reader = createCronService(storePath, false);
+
+ await expect(reader.readJob(persisted.id)).resolves.toMatchObject({
+ id: persisted.id,
+ name: "persisted-job",
+ });
+ expect(reader.getJob(persisted.id)).toBeDefined();
+ });
});
diff --git a/src/cron/service.ts b/src/cron/service.ts
index 6770e8264e1..539ab81426c 100644
--- a/src/cron/service.ts
+++ b/src/cron/service.ts
@@ -64,6 +64,10 @@ export class CronService implements CronServiceContract {
return this.state.store?.jobs.find((job) => job.id === id);
}
+ async readJob(id: string): Promise {
+ return await ops.readJob(this.state, id);
+ }
+
getDefaultAgentId(): string | undefined {
return this.state.deps.defaultAgentId;
}
diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts
index 6002a271f1a..9394ddc9c09 100644
--- a/src/cron/service/ops.ts
+++ b/src/cron/service/ops.ts
@@ -236,6 +236,13 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b
});
}
+export async function readJob(state: CronServiceState, id: string) {
+ return await locked(state, async () => {
+ await ensureLoadedForRead(state);
+ return state.store?.jobs.find((job) => job.id === id);
+ });
+}
+
function resolveEnabledFilter(opts?: CronListPageOptions): CronJobsEnabledFilter {
if (opts?.enabled === "all" || opts?.enabled === "enabled" || opts?.enabled === "disabled") {
return opts.enabled;
diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts
index f942973d459..0de03db4838 100644
--- a/src/gateway/method-scopes.ts
+++ b/src/gateway/method-scopes.ts
@@ -113,6 +113,7 @@ const METHOD_SCOPE_GROUPS: Record = {
"sessions.usage",
"sessions.usage.timeseries",
"sessions.usage.logs",
+ "cron.get",
"cron.list",
"cron.status",
"cron.runs",
diff --git a/src/gateway/protocol/cron-validators.test.ts b/src/gateway/protocol/cron-validators.test.ts
index 69073b93ef4..e7eb2b4947b 100644
--- a/src/gateway/protocol/cron-validators.test.ts
+++ b/src/gateway/protocol/cron-validators.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
validateCronAddParams,
+ validateCronGetParams,
validateCronListParams,
validateCronRemoveParams,
validateCronRunParams,
@@ -54,6 +55,13 @@ describe("cron protocol validators", () => {
expect(validateCronUpdateParams({ jobId: "job-2", patch: { enabled: true } })).toBe(true);
});
+ it("accepts get params for id and jobId selectors", () => {
+ expect(validateCronGetParams({ id: "job-1" })).toBe(true);
+ expect(validateCronGetParams({ jobId: "job-2" })).toBe(true);
+ expect(validateCronGetParams({})).toBe(false);
+ expect(validateCronGetParams({ id: "" })).toBe(false);
+ });
+
it("accepts delivery threadId on add and update params", () => {
expect(
validateCronAddParams({
diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts
index 46f8dc17d14..ce6d23a9daa 100644
--- a/src/gateway/protocol/index.ts
+++ b/src/gateway/protocol/index.ts
@@ -146,6 +146,8 @@ import {
ConnectParamsSchema,
type CronAddParams,
CronAddParamsSchema,
+ type CronGetParams,
+ CronGetParamsSchema,
type CronJob,
CronJobSchema,
type CronListParams,
@@ -671,6 +673,7 @@ export const validateSkillsSearchParams = ajv.compile(Skills
export const validateSkillsDetailParams = ajv.compile(SkillsDetailParamsSchema);
export const validateCronListParams = ajv.compile(CronListParamsSchema);
export const validateCronStatusParams = ajv.compile(CronStatusParamsSchema);
+export const validateCronGetParams = ajv.compile(CronGetParamsSchema);
export const validateCronAddParams = ajv.compile(CronAddParamsSchema);
export const validateCronUpdateParams = ajv.compile(CronUpdateParamsSchema);
export const validateCronRemoveParams = ajv.compile(CronRemoveParamsSchema);
@@ -940,6 +943,7 @@ export {
CronJobSchema,
CronListParamsSchema,
CronStatusParamsSchema,
+ CronGetParamsSchema,
CronAddParamsSchema,
CronUpdateParamsSchema,
CronRemoveParamsSchema,
@@ -1122,6 +1126,7 @@ export type {
CronJob,
CronListParams,
CronStatusParams,
+ CronGetParams,
CronAddParams,
CronUpdateParams,
CronRemoveParams,
diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts
index b3ffabcae46..deff5cc5b84 100644
--- a/src/gateway/protocol/schema/cron.ts
+++ b/src/gateway/protocol/schema/cron.ts
@@ -351,6 +351,8 @@ export const CronListParamsSchema = Type.Object(
export const CronStatusParamsSchema = Type.Object({}, { additionalProperties: false });
+export const CronGetParamsSchema = cronIdOrJobIdParams({});
+
export const CronAddParamsSchema = Type.Object(
{
name: NonEmptyString,
diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts
index 18034b94ba4..3a22869362b 100644
--- a/src/gateway/protocol/schema/protocol-schemas.ts
+++ b/src/gateway/protocol/schema/protocol-schemas.ts
@@ -116,6 +116,7 @@ import {
} from "./config.js";
import {
CronAddParamsSchema,
+ CronGetParamsSchema,
CronJobSchema,
CronListParamsSchema,
CronRemoveParamsSchema,
@@ -454,6 +455,7 @@ export const ProtocolSchemas = {
CronJob: CronJobSchema,
CronListParams: CronListParamsSchema,
CronStatusParams: CronStatusParamsSchema,
+ CronGetParams: CronGetParamsSchema,
CronAddParams: CronAddParamsSchema,
CronUpdateParams: CronUpdateParamsSchema,
CronRemoveParams: CronRemoveParamsSchema,
diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts
index 6b149c19721..0f5df850f13 100644
--- a/src/gateway/protocol/schema/types.ts
+++ b/src/gateway/protocol/schema/types.ts
@@ -190,6 +190,7 @@ export type SkillsUpdateParams = SchemaType<"SkillsUpdateParams">;
export type CronJob = SchemaType<"CronJob">;
export type CronListParams = SchemaType<"CronListParams">;
export type CronStatusParams = SchemaType<"CronStatusParams">;
+export type CronGetParams = SchemaType<"CronGetParams">;
export type CronAddParams = SchemaType<"CronAddParams">;
export type CronUpdateParams = SchemaType<"CronUpdateParams">;
export type CronRemoveParams = SchemaType<"CronRemoveParams">;
diff --git a/src/gateway/server-cron-lazy.test.ts b/src/gateway/server-cron-lazy.test.ts
index b86bced8da5..74ec16ff6d8 100644
--- a/src/gateway/server-cron-lazy.test.ts
+++ b/src/gateway/server-cron-lazy.test.ts
@@ -43,6 +43,17 @@ describe("createLazyGatewayCronState", () => {
expect(cron.status).toHaveBeenCalledTimes(1);
});
+ it("loads the cron service for direct job reads", async () => {
+ const cron = createCronService();
+ hoisted.setState(createCronState(cron));
+
+ const lazy = createLazyGatewayCronState(createParams());
+ await lazy.cron.readJob("demo");
+
+ expect(hoisted.buildGatewayCronService).toHaveBeenCalledTimes(1);
+ expect(cron.readJob).toHaveBeenCalledWith("demo");
+ });
+
it("starts the loaded cron service once", async () => {
const cron = createCronService();
hoisted.setState(createCronState(cron));
@@ -140,6 +151,7 @@ function createCronService(): CronServiceContract {
run: vi.fn(async () => ({ ok: true, ran: false, reason: "invalid-spec" }) as never),
enqueueRun: vi.fn(async () => ({ ok: true, ran: false, reason: "invalid-spec" }) as never),
getJob: vi.fn(() => undefined),
+ readJob: vi.fn(async () => undefined),
getDefaultAgentId: vi.fn(() => "default"),
wake: vi.fn(() => ({ ok: true })),
};
diff --git a/src/gateway/server-cron-lazy.ts b/src/gateway/server-cron-lazy.ts
index 08c3808c4ad..e3775a4c96e 100644
--- a/src/gateway/server-cron-lazy.ts
+++ b/src/gateway/server-cron-lazy.ts
@@ -102,6 +102,9 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew
}
return loaded.state.cron.getJob(id);
},
+ async readJob(id) {
+ return await (await load()).state.cron.readJob(id);
+ },
getDefaultAgentId() {
if (!loaded) {
return undefined;
diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts
index 18ae9426b16..450ffb369f4 100644
--- a/src/gateway/server-methods-list.ts
+++ b/src/gateway/server-methods-list.ts
@@ -156,6 +156,7 @@ const BASE_METHODS = [
"node.pending.ack",
"node.invoke.result",
"node.event",
+ "cron.get",
"cron.list",
"cron.status",
"cron.add",
diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts
index 1ae45be2f00..b1cf2341a20 100644
--- a/src/gateway/server-methods/cron.ts
+++ b/src/gateway/server-methods/cron.ts
@@ -23,6 +23,7 @@ import {
errorShape,
formatValidationErrors,
validateCronAddParams,
+ validateCronGetParams,
validateCronListParams,
validateCronRemoveParams,
validateCronRunParams,
@@ -248,6 +249,39 @@ export const cronHandlers: GatewayRequestHandlers = {
const status = await context.cron.status();
respond(true, status, undefined);
},
+ "cron.get": async ({ params, respond, context }) => {
+ if (!validateCronGetParams(params)) {
+ respond(
+ false,
+ undefined,
+ errorShape(
+ ErrorCodes.INVALID_REQUEST,
+ `invalid cron.get params: ${formatValidationErrors(validateCronGetParams.errors)}`,
+ ),
+ );
+ return;
+ }
+ const p = params as { id?: string; jobId?: string };
+ const jobId = p.id ?? p.jobId;
+ if (!jobId) {
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.get params: missing id"),
+ );
+ return;
+ }
+ const job = await context.cron.readJob(jobId);
+ if (!job) {
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.INVALID_REQUEST, `cron job not found: ${jobId}`),
+ );
+ return;
+ }
+ respond(true, job, undefined);
+ },
"cron.add": async ({ params, respond, context }) => {
const sessionKey =
typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string"
diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts
index 01b965404d7..a4f83c43097 100644
--- a/src/gateway/server-methods/cron.validation.test.ts
+++ b/src/gateway/server-methods/cron.validation.test.ts
@@ -78,6 +78,7 @@ function createCronContext(currentJob?: CronJob) {
getDefaultAgentId: vi.fn(() => "main"),
getJob: vi.fn(() => currentJob),
wake: vi.fn(() => ({ ok: true }) as const),
+ readJob: vi.fn(async (id: string) => (id === currentJob?.id ? currentJob : undefined)),
},
logGateway: {
info: vi.fn(),
@@ -100,6 +101,20 @@ async function invokeCronAdd(params: Record) {
return { context, respond };
}
+async function invokeCronGet(params: Record, currentJob?: CronJob) {
+ const context = createCronContext(currentJob);
+ const respond = vi.fn();
+ await cronHandlers["cron.get"]({
+ req: {} as never,
+ params: params as never,
+ respond: respond as never,
+ context: context as never,
+ client: null,
+ isWebchatConnect: () => false,
+ });
+ return { context, respond };
+}
+
async function invokeCronUpdate(params: Record, currentJob: CronJob) {
const context = createCronContext(currentJob);
const respond = vi.fn();
@@ -231,6 +246,24 @@ describe("cron method validation", () => {
expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined);
});
+ it("returns a single cron job for cron.get", async () => {
+ const job = createCronJob({ id: "cron-42", name: "single job" });
+
+ const { context, respond } = await invokeCronGet({ id: "cron-42" }, job);
+
+ expect(context.cron.readJob).toHaveBeenCalledWith("cron-42");
+ expect(respond).toHaveBeenCalledWith(true, job, undefined);
+ });
+
+ it("returns INVALID_REQUEST when cron.get cannot find the job", async () => {
+ const { respond } = await invokeCronGet({ jobId: "missing" });
+
+ expectResponseError(respond, {
+ code: "INVALID_REQUEST",
+ messageIncludes: "cron job not found: missing",
+ });
+ });
+
it("accepts threadId on announce delivery update params", async () => {
getRuntimeConfig.mockReturnValue({
channels: {
diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts
index 744f6f0a1b8..4ee65cf6a41 100644
--- a/src/gateway/server.cron.test.ts
+++ b/src/gateway/server.cron.test.ts
@@ -469,6 +469,16 @@ describe("gateway server cron", () => {
detail: "webhook",
});
+ const getRes = await directCronReq(cronState, "cron.get", { id: String(dailyJobId) });
+ expect(getRes.ok).toBe(true);
+ expect((getRes.payload as { id?: unknown } | null)?.id).toBe(dailyJobId);
+ expect((getRes.payload as { name?: unknown } | null)?.name).toBe("daily");
+
+ const missingGetRes = await directCronReq(cronState, "cron.get", { id: "missing-job-id" });
+ expect(missingGetRes.ok).toBe(false);
+ expect(missingGetRes.error?.code).toBe("INVALID_REQUEST");
+ expect(missingGetRes.error?.message).toContain("cron job not found: missing-job-id");
+
const routeAtMs = Date.now() - 1;
const routeRes = await directCronReq(cronState, "cron.add", {
name: "route test",
diff --git a/src/plugins/contracts/scheduled-turns.contract.test.ts b/src/plugins/contracts/scheduled-turns.contract.test.ts
index 0f42f0c2f4f..5adca5ac6ff 100644
--- a/src/plugins/contracts/scheduled-turns.contract.test.ts
+++ b/src/plugins/contracts/scheduled-turns.contract.test.ts
@@ -101,6 +101,7 @@ function createMockCronService(): CronServiceContract {
run: vi.fn(async () => ({ ok: true, ran: false, reason: "not-due" })),
enqueueRun: vi.fn(async () => ({ ok: true, ran: false, reason: "not-due" })),
getJob: vi.fn(() => undefined),
+ readJob: vi.fn(async () => undefined),
getDefaultAgentId: vi.fn(() => undefined),
wake: vi.fn(() => ({ ok: true })),
} as CronServiceContract;
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 1a4d01761f9..a12a8c35724 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
@@ -137,12 +137,12 @@
},
{
"deferLoading": true,
- "description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
+ "description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
"inputSchema": {
"additionalProperties": true,
"properties": {
"action": {
- "enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"],
+ "enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"],
"type": "string"
},
"agentId": {
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 fcbb76caccb..5e98adbed56 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
@@ -137,12 +137,12 @@
},
{
"deferLoading": true,
- "description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
+ "description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
"inputSchema": {
"additionalProperties": true,
"properties": {
"action": {
- "enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"],
+ "enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"],
"type": "string"
},
"agentId": {
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 ce1eb91fe5b..4310d0b21d2 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
@@ -137,12 +137,12 @@
},
{
"deferLoading": true,
- "description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
+ "description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
"inputSchema": {
"additionalProperties": true,
"properties": {
"action": {
- "enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"],
+ "enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"],
"type": "string"
},
"agentId": {
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 5555c0e4832..8384a201977 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
@@ -217,8 +217,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 140
},
"dynamicToolsJson": {
- "chars": 43000,
- "roughTokens": 10750
+ "chars": 43053,
+ "roughTokens": 10764
},
"openClawDeveloperInstructions": {
"chars": 5436,
@@ -229,8 +229,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 7129
},
"totalWithDynamicToolsJson": {
- "chars": 71518,
- "roughTokens": 17880
+ "chars": 71571,
+ "roughTokens": 17893
},
"userInputText": {
"chars": 870,
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 121efcb1d4a..ea82ffb4797 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
@@ -217,8 +217,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 140
},
"dynamicToolsJson": {
- "chars": 42691,
- "roughTokens": 10673
+ "chars": 42744,
+ "roughTokens": 10686
},
"openClawDeveloperInstructions": {
"chars": 4412,
@@ -229,8 +229,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6748
},
"totalWithDynamicToolsJson": {
- "chars": 69685,
- "roughTokens": 17422
+ "chars": 69738,
+ "roughTokens": 17435
},
"userInputText": {
"chars": 370,
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 56824b85fd0..99a23a977e1 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
@@ -218,8 +218,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 140
},
"dynamicToolsJson": {
- "chars": 43869,
- "roughTokens": 10968
+ "chars": 43922,
+ "roughTokens": 10981
},
"openClawDeveloperInstructions": {
"chars": 4412,
@@ -230,8 +230,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 7155
},
"totalWithDynamicToolsJson": {
- "chars": 72490,
- "roughTokens": 18123
+ "chars": 72543,
+ "roughTokens": 18136
},
"userInputText": {
"chars": 608,