Schedules and launch actions

manager.cron is how an agent acts at a time of day, every week, on the first of the month. It has two properties that shape every scheduled agent: it never fires when the agent starts, and a missed occurrence (the agent was restarting) is not replayed. This page gives the patterns that stay correct despite both.

Cron basics

manager.cron('0 9 * * 1', callback, 'Europe/Paris');   // Mondays 09:00 Paris
manager.cron('31 9 * * 1-5', callback, 'America/New_York'); // weekdays 09:31 New York
manager.cron('0 10 1 * *', callback, 'America/New_York');   // the 1st of each month, 10:00
manager.cron('*/15 * * * *', callback);                      // every 15 minutes (zone irrelevant)
  • Five fields: minute, hour, day of month, month, day of week (0 or 7 is Sunday). A sixth, leading, field adds seconds.
  • The zone defaults to UTC. Pass the IANA zone of the wall-clock time you mean; daylight saving is handled.
  • A schedule that trades US stocks must fire inside exchange hours, 09:30 to 16:00 New York: use '31 9 * * 1-5' for "at the open" and '55 15 * * 1-5' for "before the close". An order at 09:00 is pre-market.
  • A schedule firing exactly on a candle boundary runs before that candle closes (see the order of events). Schedule one minute later if you need the closed candle.
  • A callback that trades a symbol needs that symbol subscribed at the root (manager.onPreciseCandle(symbol, () => {}), or any candle series).

Choosing a cadence

Derive the cadence from the strategy and round up: a monthly rebalance runs monthly, a daily signal daily. When the request states no frequency, use daily. A sub-daily cadence has to be earned by the request (an intraday signal, "check every 5 minutes").

Cadence decides what a backtest costs. In a backtest every schedule fire is a simulation step, around the clock, weekends included: a per-minute schedule is about half a million steps a year, and very long runs are refused at launch. Hourly and coarser schedules backtest over many years without trouble.

Acting at launch

Because a schedule never fires at start-up, "buy now, then every Monday" needs two triggers: a launch catch-up and the recurring schedule. The catch-up is a per-minute cron that does nothing once its job is done. Two rules make the pair safe:

  1. Both triggers call the same function, and that function claims a period key in the status bag before doing anything else, so the period runs exactly once whichever trigger fires first. Claim the key before the first await.
  2. The catch-up mirrors the schedule's own boundary (same weekdays, same time of day, same zone) instead of latching on a persisted "initialized" flag. A latch survives restarts while schedules do not, so after a restart that falls past the scheduled time, a latched catch-up would skip that period forever. A boundary check has no such hole.

The key must change exactly as often as the schedule fires: a day key (yyyy-LL-dd) for a daily schedule, a week key for weekly, a month key (yyyy-LL) for monthly. Compute it in the schedule's zone from manager.getCurrentTime().

Release the key when the cycle failed before doing anything visible (no order, notification, comment or capital change), so the next tick retries; once something went out, keep it claimed so a duplicate delivery cannot repeat it. When the cycle calls a paid model, stop retrying after a few failures in the same period.

// Buy $100 of BTC every Monday at 09:00 Paris, with the first buy right away.
const symbol = 'BTC';
const zone = 'Europe/Paris';
const WEEKLY_AMOUNT = 100;

interface DcaState { lastWeek?: string }

manager.onPreciseCandle(symbol, () => {}); // subscribe BTC prices for the schedules

// The period starts at the schedule's boundary, Monday 09:00 Paris, not at midnight: shift the
// clock back 9 hours before taking the ISO week, so Monday 08:59 still belongs to last week.
function weekKey(nowMs: number): string {
  return DateTime.fromMillis(nowMs).setZone(zone).minus({ hours: 9 }).toFormat("kkkk-'W'WW");
}

function runWeek(): void {
  const state = manager.getStatus<DcaState>('default');
  const key = weekKey(manager.getCurrentTime());
  if (state.lastWeek === key) return; // this week already bought
  state.lastWeek = key; // claimed before anything else
  manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: WEEKLY_AMOUNT, amountCurrency: 'USD' });
}

// Launch catch-up: fires within a minute of the start and buys if this week has not been
// bought yet; afterwards it finds the week claimed. Because the week key flips exactly at
// Monday 09:00, it cannot buy before the schedule does.
manager.cron('* * * * *', () => {
  runWeek();
});

manager.cron('0 9 * * 1', () => {
  runWeek();
}, zone);

The weekly buy above needs no separate boundary gate because its key flips at the schedule's own boundary (Monday 09:00 Paris), and "buy now" makes any launch time valid for the first buy. When the action must also happen inside certain hours (a market session), gate the catch-up on them, as in the next example.

// Rebalance SPY/TLT 60/40 on weekdays at 09:31 New York, and at launch once the market is open.
const zone = 'America/New_York';
const TARGETS: Record<string, number> = { SPY: 0.6, TLT: 0.4 };
const symbols = Object.keys(TARGETS);
for (const s of symbols) manager.onPreciseCandle(s, () => {});

interface CycleState { lastDay?: string }

function pastOpen(nowMs: number): boolean {
  const ny = DateTime.fromMillis(nowMs).setZone(zone);
  if (ny.weekday > 5) return false;
  const minutes = ny.hour * 60 + ny.minute;
  return minutes >= 9 * 60 + 31 && minutes < 16 * 60;
}

function runDay(): void {
  const state = manager.getStatus<CycleState>('default');
  const day = DateTime.fromMillis(manager.getCurrentTime()).setZone(zone).toFormat('yyyy-LL-dd');
  if (state.lastDay === day) return;
  const previous = state.lastDay;
  state.lastDay = day;

  const values = new Map<string, number>();
  let held = 0;
  for (const s of symbols) {
    const price = manager.getCurrentPrice(s);
    if (price === undefined || price <= 0) {
      state.lastDay = previous; // nothing done yet: release and retry next minute
      return;
    }
    const value = manager.getPositionQty(s) * price;
    values.set(s, value);
    held += value;
  }

  // Read the free cash once, before selling: paper sells settle at once (reading it after them
  // would count their proceeds twice), live sells do not (the proceeds are not there yet).
  const cash = manager.getAvailableCapital();
  // Sell overweights first and count the proceeds, then buy deficits within one cash budget.
  const total = (held + cash) * 0.98;
  let proceeds = 0;
  for (const s of symbols) {
    const price = manager.getCurrentPrice(s);
    const excess = (values.get(s) ?? 0) - total * (TARGETS[s] ?? 0);
    if (price === undefined || excess <= 1) continue;
    const sellQty = excess / price;
    manager.placeOrder({ symbol: s, type: 'MARKET', position: 'sell', qty: sellQty, inhibitAutoInvest: true });
    proceeds += sellQty * price; // counted next to the sell, as the validator expects
  }
  let remaining = Math.max(0, cash) + proceeds;
  for (const s of symbols) {
    const deficit = total * (TARGETS[s] ?? 0) - (values.get(s) ?? 0);
    const spend = Math.min(deficit, remaining);
    if (spend <= 1) continue;
    manager.placeOrder({ symbol: s, type: 'MARKET', position: 'buy', amount: spend, amountCurrency: 'USD', inhibitAutoInvest: true });
    remaining -= spend;
  }
}

manager.cron('* * * * *', () => {
  if (!pastOpen(manager.getCurrentTime())) return;
  runDay();
});
manager.cron('31 9 * * 1-5', () => {
  runDay();
}, zone);

The per-minute catch-up has a cost: in a backtest every minute becomes a simulation step, so a backtest longer than about a year may be refused at launch. When multi-year backtests matter more than acting in the first minute, drop the catch-up and let the schedule make the first move.

Test your design with one question: if you deleted the per-minute catch-up, would the agent still do the same thing, just later? If not, the two triggers are doing different jobs. In particular, never pass the shared function an "is this the launch?" flag that changes what it does: the catch-up fires first in every period, so the other branch would never run.

One-time actions inside a recurring cycle

A lump sum invested once, a one-off initial transfer: give it its own lifetime flag in the status bag, checked inside the shared cycle, separate from the period key.

interface MyState { lastMonth?: string; seeded?: boolean }
// inside the cycle, after claiming the month:
if (state.seeded !== true) {
  manager.increaseCapital(10000); // the initial lump sum, once in the agent's life
  state.seeded = true;
}

Calendar rules cron cannot express

When a schedule restricts both the day of the month and the day of the week, cron fires when either matches: '0 9 1-7 * 1' runs on days 1 to 7 and on every Monday. For "the first Monday of the month", schedule every Monday and check the date in code:

manager.cron('0 9 * * 1', () => {
  const today = DateTime.fromMillis(manager.getCurrentTime()).setZone('America/New_York');
  if (today.day > 7) return; // not the first Monday
  runMonthlyCycle();
}, 'America/New_York');

The same approach covers "the last trading day of the month" (check that the next weekday is in another month) and "every other week" (check the ISO week number). Cron has no holiday calendar: on an exchange holiday a scheduled order simply finds the market closed, so prefer checking that the symbol traded recently (hasPriceData, a fresh candle) before acting when that matters.

Market hours for scheduled orders

Crons fire around the clock. For stocks and ETFs, gate every order-placing path (catch-ups and retries included) on the exchange session, as pastOpen does above. Crypto trades 24/7 and needs no gate.

Schedules and AI calls

A manager.llm call reached from a schedule or candle callback must be guarded by a period key read from manager.getStatus in the same function, claimed before the call (the validator enforces this: TS9026). A candle or a period can be delivered twice; without the key you pay for the same analysis twice. Event callbacks (onMessage, onNews, onPieuvreEvent) fire once per event and need no key.

const zone = 'America/New_York';
interface BriefState { lastDay?: string; failures?: number }
const briefSchema = z.object({ summary: z.string().describe('Three sentences on today\'s market mood.') });

manager.cron('0 8 * * 1-5', async () => {
  const state = manager.getStatus<BriefState>('default');
  const day = DateTime.fromMillis(manager.getCurrentTime()).setZone(zone).toFormat('yyyy-LL-dd');
  if (state.lastDay === day) return;
  const previous = state.lastDay;
  state.lastDay = day; // claimed before the call
  let summary: string | undefined;
  try {
    const result = await manager.llm('default', 'You write short, factual market briefs.', `Brief for ${day}.`, briefSchema, { news: true });
    summary = result?.summary;
  } catch (error) {
    manager.handleError(error);
  }
  if (summary === undefined) {
    state.failures = (state.failures ?? 0) + 1;
    if (state.failures < 3) state.lastDay = previous; // nothing sent: allow a retry
    return;
  }
  state.failures = 0;
  manager.sendNotification(`**Morning brief, ${day}**\n\n${summary}`);
}, zone);
Browse the documentation