Deliver a scheduled brief to a channel
A cron-driven agent run, claimed exactly once across nodes, delivered into the user's own Telegram, WhatsApp or Slack thread.
Background
A background job that runs an agent on a schedule is easy. A brief is harder: it has to land where the person will read it, they have to be able to reply to it, and it has to keep running correctly across nodes, time zones, approvals the agent hits with nobody watching, and owners who leave the company. Latch schedules are rows claimed exactly once by whatever runs runtime.cron(), fired as the principal who created them, and delivered by a host hook into the conversation of your choice.
Create the schedule
await runtime.schedule({
principal,
agent: "reporter",
cron: "0 8 * * 1-5", // null = one-shot, fired on the next tick
timezone: "Europe/Helsinki", // "8am" means the user's 8am
prompt: "Summarise yesterday's signups and anything unusual.",
delivery: { kind: "telegram", botId: "b1" }, // opaque to core — your host reads it
});Make time pass
On a long-lived process createLatchApp runs an in-process cron. On serverless, point a platform cron at one route:
export async function POST(request: Request) {
assertCronSecret(request); // yours — this route is system-wide
return Response.json(await runtime.cron()); // due schedules, then reap + resume crashed runs
}Core needs a cron evaluator and ships none; createLatchApp supplies croner, or pass cron: (expr, afterMs, timezone) => nextRunMs to createRuntime.
Deliver into a conversation
By default a fire runs in a fresh chat. Supply runSchedule to own the fire and run it where the user is:
createRuntime({
// …
runSchedule: async ({ schedule, principal }) => {
const target = parseDelivery(schedule.delivery); // yours; core never reads it
if (!target) return false; // fall through to the default
const { chatId, parked } = await deliver({ schedule, principal, target });
return { handled: true, chatId, parked };
},
});For a Telegram or WhatsApp target, replay the prompt into the user's current conversation with that bot through the channel kernel; for a Slack target, post an anchor message and run the turn threaded under it. Return true only when you ran and delivered — anything else falls back to a fresh chat so a stale target never loses the fire.
Let the agent schedule itself
With defaultTools: true, an agent asked in a Telegram DM can call create_schedule and the brief is delivered back into that DM. The target is derived from the conversation the request came from, never named by the model; list_delivery_channels offers the rest.
Full example
import { Cron } from "croner";
import { createRuntime } from "@intentface/latch-core";
const runtime = createRuntime<Principal, Ctx>({
storage,
context,
agents: { reporter },
durability: { enabled: true },
cron: (expr, afterMs, timezone) =>
new Cron(expr, { timezone: timezone ?? "UTC" }).nextRun(new Date(afterMs))?.getTime() ?? null,
runSchedule: async ({ schedule, principal }) => {
const target = parseDelivery(schedule.delivery);
if (!target) return false;
return deliver({ schedule, principal, target }); // → { handled: true, chatId, parked }
},
});
await runtime.schedule({
principal,
agent: "reporter",
cron: "0 8 * * 1-5",
timezone: "Europe/Helsinki",
prompt: "Summarise yesterday's signups.",
delivery: { kind: "telegram", botId: "b1" },
});
// The cron route — every node may call it; each schedule fires once.
export const POST = async (request: Request) => {
assertCronSecret(request);
return Response.json(await runtime.cron());
};Pitfalls already handled
- Time zones — schedules carry an IANA zone; UTC only when none is set.
- Many nodes, one fire — claiming a due schedule is a single-row transition.
- The fire meets an approval — it parks (
parkedinrunDue's result);onParked: "skip"(the default) stops a recurring schedule minting a new parked chat every tick. - The owner left the org — a schedule's principal is a snapshot; every fire re-checks membership and a departed member's schedule deletes itself.
- Where the DM is — derived from the identity row at fire time, never stored on the schedule.
- The user is mid-conversation — one turn per conversation; the fire is refused, the user is told it is still working, the occurrence is consumed.
- A run that keeps dying —
maxAttemptscaps resume attempts. - "Run now" that drifts from cron —
runScheduleNowfires through the same path.
Run it
pnpm --filter @intentface/latch-example smoke:scheduler # an interval sweep reaps + resumes a stuck runNext
- Run agents on a schedule —
onParked,parked,runSchedule's return shape - Channels — the kernel a DM delivery replays through
- The cron route — guarding the system-wide entry point