12 KiB
Periodic Updates — Reference
Scope: any recurring or scheduled work for this site (digests, refreshes, cleanups, end-user-defined schedules, periodic notifications).
Forbidden: setInterval, node-cron, or any in-process timer. Cloud Run terminates idle instances; in-process timers will not survive.
1. Pick the right cron type
Two flavors. The difference is what runs at trigger time:
- Heartbeat (HTTP cron). Platform POSTs directly to
/api/scheduled/*on this site. Your handler runs and returns. No agent spawned — if the handler needs an LLM call, do it inline via the site's own LLM SDK (server/_core/llm.ts). - AGENT cron. Platform spawns a fresh, isolated Manus session that runs the prompt you wrote at create time. The agent has the full Manus toolchain — browser, file system, shell, image gen, deep research — but no session history, DB, source code, or credentials beyond the two env vars
$SCHEDULED_TASK_ENDPOINT_BASE/$SCHEDULED_TASK_COOKIE. If your prompt tells it to, itcurls back to/api/scheduled/*on this site at the end.
Decision: AGENT cron only if the trigger genuinely needs agentic capabilities — i.e. tool use beyond a single LLM call: web browsing, file manipulation, shell, deep research, multi-step planning, etc. A one-shot LLM completion belongs inline in a Heartbeat handler. Do not attempt to replicate complex agentic flows using website-inbuilt capabilities, if it could be outsourced to AGENT cron. End-user-defined schedules (UI on this site lets a user pick when X runs) are always Heartbeat — see §3.
Both flavors hit the same /api/scheduled/* endpoint with the same auth shape: sdk.authenticateRequest(req) returns user.isCron === true with user.taskUid set.
2. Facts (apply to BOTH flavors)
- Callback path MUST start with
/api/scheduled/. Forge rejects everything else. - Add a
schedule_cron_task_uid varchar(65)column (indexed, nullable) to whatever business row owns the job. Update / delete / look up the business row bytask_uid, never bynameor by anything fromreq.body. - The site must be deployed before scheduling — bizserver POSTs the production URL, dev sandboxes are unreachable. For any changes to the callback handler, or creation of callback handler, ALWAYS follow this WORKFLOW: save a checkpoint, ask the user to Deploy, before any schedule actions take place.
- Wrap handler logic in try/catch and JSON-encode the error on 500 — the platform's Investigate flow surfaces it verbatim.
- Cron is 6-field (with seconds):
sec min hour dom mon dow, UTC, min interval 60s. Use0for the seconds field — e.g.0 0 9 * * *is daily 09:00 UTC. - Handlers must be idempotent. The platform retries
5xxand429up to 3 times (3s → 1m backoff). Other4xxare treated as business failures and not retried. - Handler timeout is 2 minutes per call.
3. End-user-driven Heartbeat (tRPC create + /api/scheduled/* callback)
Required pieces (assumes the schedule_cron_task_uid column from Facts #2 is already on the business row):
- tRPC mutation that calls
createHeartbeatJob(...)and persists the returnedtaskUidto that column. - Express handler at
/api/scheduled/<name>that authenticates viasdk.authenticateRequest, looks up the business row bytaskUid, runs the work. - Explicit
app.post("/api/scheduled/<name>", handler)inserver/_core/index.tsbefore the Vite/static fallthrough —/api/scheduled/*is not auto-registered.
A one-time setup walkthrough — do all three steps in one pass; they're a single workflow, not independent options.
Step 1 — tRPC mutation calls the SDK to create the cron and persists task_uid on the business row. For update / delete / pause / resume, look up scheduleCronTaskUid first then call updateHeartbeatJob(taskUid, patch, sessionToken) / deleteHeartbeatJob(taskUid, sessionToken) — patch.enable=false pauses, true resumes, omit to leave unchanged. All SDK functions throw TRPCError; let trpc bubble.
import { parse as parseCookie } from "cookie";
import { COOKIE_NAME } from "@shared/const";
import { createHeartbeatJob } from "../_core/heartbeat";
const sessionToken = parseCookie(ctx.req.headers.cookie ?? "")[COOKIE_NAME] ?? "";
const job = await createHeartbeatJob({
name: `marketing-${campaign.id}`, // unique within (project, owner)
cron: input.cron, // 6-field "sec min hour dom mon dow"
path: "/api/scheduled/sendMarketing",
payload: { campaignId: campaign.id },
description: `Daily 9am send for ${campaign.name}`,
}, sessionToken);
await db.update(campaigns)
.set({ scheduleCronTaskUid: job.taskUid })
.where(eq(campaigns.id, campaign.id));
sessionToken MUST be the decoded app_session_id cookie value, not the raw Cookie header — forge attributes the cron to the requesting end-user via this token. Apply your normal ownership check on the business row before scheduling.
Step 2 — Express handler at /api/scheduled/<name> runs on each trigger. Look up the business row by user.taskUid (never by req.body fields — the body is attacker-controllable; taskUid is set by the cron system). Wrap with try/catch and on 500 return { error, stack, context: { url, taskUid }, timestamp } so the platform Investigate flow can surface it.
const user = await sdk.authenticateRequest(req);
if (!user.isCron || !user.taskUid) return res.status(403).json({ error: "cron-only" });
const campaign = (await db.select().from(campaigns)
.where(eq(campaigns.scheduleCronTaskUid, user.taskUid)).limit(1))[0];
if (!campaign) return res.json({ ok: true, skipped: "orphan" }); // 2xx so forge stops retrying
await sendCampaignEmails(campaign);
res.json({ ok: true });
Step 3 — Mount the handler in server/_core/index.ts before the Vite / static fallthrough — /api/scheduled/* is not auto-registered.
app.post("/api/scheduled/sendMarketing", sendMarketingHandler);
After deploy, end-users create their own crons via your tRPC mutation. As the project owner, you can also inspect / pause / resume / view logs for any end-user's cron from the sandbox terminal — manus-heartbeat list --user-id u_xxx and friends; see §5b. End-users can never see another user's cron through your tRPC SDK.
4. Variants — when the trigger isn't an end-user
Same callback handler, same /api/scheduled/* URL, same user.isCron check, same Manus-platform-managed cron lifecycle (cron persistence and triggering live entirely on the platform, independent of any sandbox session). Only the creator changes.
4a. Project-level Heartbeat (no end-user)
For crons your end-users never see (nightly DB cleanup, daily digest to admins, hourly external-API ping) — the cron is owned by the project owner identity. Create via the sandbox CLI (see §5b for the full subcommand list):
manus-heartbeat create \
--name nightly-cleanup \
--cron "0 0 3 * * *" \
--path /api/scheduled/cleanup \
--description "Nightly expired-row cleanup"
The created cron lives on the Manus platform, not in this sandbox: it survives sandbox hibernation/teardown and keeps firing as long as the deployed site is reachable. Any future Manus session can manus-heartbeat list / update / pause / delete it — the cron is bound to the project owner, not to the build session that created it.
Persist the returned task_uid somewhere durable (admin DB row / config) if you'll need to update/delete it later — manus-heartbeat list can also recover it.
4b. AGENT cron — when the trigger needs agentic capabilities
Created via the schedule tool inside this Manus session (not from site code). Each trigger spawns a fresh, isolated Manus agent — no session history, no source/DB/credentials, no skills. The only knowledge transfer is whatever you write into the cron prompt.
Use it when the work genuinely needs agent intelligence (deep research, content composition, image gen). Tell it WHAT to do, not HOW — it's a real agent, not a workflow runner.
If the AGENT cron needs to write back to this site, the only path in is the site's HTTP API:
- Add
/api/scheduled/<name>as in §3 Step 2 (same auth, same handler shape). - Save a checkpoint and ask the user to Deploy.
- In the cron prompt, instruct the agent to POST via
curl(not python libs) using two auto-injected envs:$SCHEDULED_TASK_ENDPOINT_BASE— base URL of this site$SCHEDULED_TASK_COOKIE— the rawapp_session_idJWT value
curl -X POST "$SCHEDULED_TASK_ENDPOINT_BASE/api/scheduled/news" \ -H "Content-Type: application/json" \ -H "Cookie: app_session_id=$SCHEDULED_TASK_COOKIE" \ -d '{"title":"…","body":"…"}'
The endpoint receives user.isCron === true exactly like Heartbeat — no special-casing needed.
4c. Owner UI on manus.im (NOT something you build)
The Manus dashboard surfaces ALL crons in the project (both end-user-driven and agent-driven), with execution history, pause/resume, edit, Run Now, and Investigate. Owners can't create crons there — only via §3 / §4a / §4b. Mention this to the user when they ask "how do I see/manage all my crons".
5. References
5a. Site SDK — server/_core/heartbeat.ts
type HeartbeatJob = {
name: string; // unique within (project, owner)
cron: string; // 6-field UTC "sec min hour dom mon dow"
path: string; // must start with /api/scheduled/
method?: "POST" | "PUT"; // default POST
payload?: unknown; // JSON body sent on every trigger
description?: string;
};
type HeartbeatJobUpdate = Partial<Omit<HeartbeatJob, "name">> & {
enable?: boolean; // true=resume, false=pause, omit=unchanged
};
type HeartbeatJobInfo = {
taskUid: string; name: string; userId: string; description: string;
cronExpression: string; callbackPath: string; callbackMethod: string;
callbackPayload: string; isEnable: boolean;
createdAt?: string | null;
lastExecutedAt?: string | null;
nextExecutionAt?: string | null;
};
createHeartbeatJob(job: HeartbeatJob, userSession: string)
: Promise<{ taskUid: string; nextExecutionAt?: string | null }>;
updateHeartbeatJob(taskUid: string, patch: HeartbeatJobUpdate, userSession: string)
: Promise<{ nextExecutionAt?: string | null }>;
deleteHeartbeatJob(taskUid: string, userSession: string)
: Promise<void>;
listHeartbeatJobs(userSession: string, pagination?: { page?: number; pageSize?: number })
: Promise<{ total: number; actorUserId: string; jobs: HeartbeatJobInfo[] }>;
userSession is the decoded app_session_id cookie value, NOT the raw Cookie header. All four functions throw TRPCError (UNAUTHORIZED / NOT_FOUND / TOO_MANY_REQUESTS / FORBIDDEN / BAD_REQUEST) — let trpc bubble them.
5b. Sandbox CLI — manus-heartbeat
In-sandbox tool that hits the same backend as the SDK in §5a but with project owner identity (no end-user cookie). BUILT_IN_FORGE_API_* envs are pre-set on PATH; run from a sandbox terminal during this session.
The table below is just an index — manus-heartbeat <cmd> --help is canonical for full flag lists and examples.
| Command | What |
|---|---|
create |
Create a cron under the project owner identity (§4a). Returns task_uid — persist it. |
update |
Mutate a cron located by --task-uid. --enable=false pauses, --enable=true resumes; can also change cron expression / path / payload. |
delete |
Remove a cron located by --task-uid. |
list |
List crons. Default = owner's own; --user-id u_xxx inspects an end-user's (debugging §3 crons). |
logs |
Recent execution history for one task (--task-uid). Default last 20 runs, no body — --with-body for full responses, --run-uid for one specific run, --status failed to filter. |
bootstrap-legacy-project |
Legacy projects only. Drops references/periodic-updates.md and server/_core/heartbeat.ts into the project so the SDK in §5a becomes available. No-op when those files already exist. |