Files
itinova-budget-si/references/periodic-updates.md

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, it curls 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)

  1. Callback path MUST start with /api/scheduled/. Forge rejects everything else.
  2. 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 by task_uid, never by name or by anything from req.body.
  3. 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.
  4. Wrap handler logic in try/catch and JSON-encode the error on 500 — the platform's Investigate flow surfaces it verbatim.
  5. Cron is 6-field (with seconds): sec min hour dom mon dow, UTC, min interval 60s. Use 0 for the seconds field — e.g. 0 0 9 * * * is daily 09:00 UTC.
  6. Handlers must be idempotent. The platform retries 5xx and 429 up to 3 times (3s → 1m backoff). Other 4xx are treated as business failures and not retried.
  7. 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):

  1. tRPC mutation that calls createHeartbeatJob(...) and persists the returned taskUid to that column.
  2. Express handler at /api/scheduled/<name> that authenticates via sdk.authenticateRequest, looks up the business row by taskUid, runs the work.
  3. Explicit app.post("/api/scheduled/<name>", handler) in server/_core/index.ts before 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:

  1. Add /api/scheduled/<name> as in §3 Step 2 (same auth, same handler shape).
  2. Save a checkpoint and ask the user to Deploy.
  3. 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 raw app_session_id JWT 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.