> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Schedules

> Cron-driven handlers that run on a schedule with proper idempotency and skip handling.

Schedules are cron-driven background jobs. Same handler shape, same `ctx` as webhooks and tools.

## Basic schedule

```ts theme={null}
import type { PartnerScheduleDeclaration } from '@ekanos/sdk/integration';
import { ACME_API_KEY } from './config';

export const dailyReconcile: PartnerScheduleDeclaration<MyStorage> = {
  id: 'daily-reconcile',
  description: 'Pull the latest payments each morning and store a summary.',
  cron: '0 6 * * *', // 06:00 daily

  async handler(ctx, invocation) {
    const apiKey = await ctx.secrets.get(ACME_API_KEY);

    if (!apiKey) {
      // Not configured is expected — skip, don't fail
      return { status: 'skipped', detail: 'No API key configured.' };
    }

    const response = await ctx.fetch('https://api.acme.example/v1/payments', {
      headers: { authorization: `Bearer ${apiKey}` },
    });

    if (!response.ok) {
      // Upstream failure IS a failed run — throw so the transport records it
      throw new Error(`Acme returned ${response.status} during reconcile.`);
    }

    const payments = await response.json();
    const summary = {
      count: payments.length,
      total: payments.reduce((s, p) => s + p.amount, 0),
      succeeded: payments.filter((p) => p.status === 'succeeded').length,
    };

    await ctx.storage.account.set('cache/daily-summary', summary);

    return {
      status: 'completed',
      detail: `Reconciled ${payments.length} payments.`,
    };
  },
};

// In defineIntegration:
export const integration = defineIntegration({
  schedules: [dailyReconcile],
});
```

## Declare the schedule

```ts theme={null}
schedules: [
  {
    id: 'daily-reconcile', // kebab-case
    description: 'Pull latest payments…',
    cron: '0 6 * * *', // 5 fields, standard syntax
    async handler(ctx, invocation) {
      /* … */
    },
  },
];
```

## Invocation object

```ts theme={null}
{
  scheduledFor: string; // ISO 8601 — the tick this invocation stands for
  invokedAt: string; // ISO 8601 — when the handler actually started
  trigger: 'schedule' | 'manual'; // Cron or human button press
}
```

Use `trigger` to distinguish scheduled runs from a human pressing "Run now" (in the harness, or an admin in the host). Handlers may branch on it, but must be safe under both, and under redelivery.

## Result contract

| Outcome | How to signal |
| - | - |
| Ran successfully | `return { status: 'completed' }` |
| Correctly did nothing | `return { status: 'skipped' }` |
| Could not complete | **throw** |

`completed` means work was done. `skipped` means the handler correctly decided nothing needed to happen (no credentials, nothing to reconcile). Only throw for genuine faults.

## Cron syntax

Standard 5 fields: `minute hour day-of-month month day-of-week`

```
0 6 * * *     — 6 AM every day
0 9 * * 1-5   — 9 AM weekdays
*/30 * * * *  — Every 30 minutes
0 0 1 * *     — 1st of month at midnight
*/15 * * * *  — Every 15 minutes
```

| Field | Range | Allowed |
| - | - | - |
| Minute | 0-59 | `*`, lists `1,15`, ranges `1-5`, steps `*/15` `0-30/5` |
| Hour | 0-23 | same |
| Day of month | 1-31 | same |
| Month | 1-12 | same (no names like `JAN`) |
| Day of week | 0-7 | same (0 and 7 both Sunday) |

**No seconds field, no `@daily` macros, no named months/days.** A step needs `*` or a range as its base — `5/15` is rejected; write `*/15` or `0-59/15`.

Validation happens at `defineIntegration()` import time.

## Common patterns

### Reconcile from upstream

Fetch and cache once a day:

```ts theme={null}
async handler(ctx, invocation) {
  const apiKey = await ctx.secrets.get('api_key');
  if (!apiKey) {
    return { status: 'skipped', detail: 'Not configured.' };
  }

  const response = await ctx.fetch('https://api.example/data');
  if (!response.ok) throw new Error(`API: ${response.status}`);

  await ctx.storage.account.set('cache/data', await response.json());
  return { status: 'completed' };
}
```

### Bypass an idempotency window for manual runs

```ts theme={null}
async handler(ctx, invocation) {
  const last = await ctx.storage.account.get('sync_state/last-run');
  const ranRecently =
    last !== null && Date.now() - Date.parse(last.updatedAt) < 3_600_000;

  // A scheduled tick respects the window; a human pressing "Run now" does not
  if (ranRecently && invocation.trigger === 'schedule') {
    return { status: 'skipped', detail: 'Ran within the last hour.' };
  }

  await doWork();
  await ctx.storage.account.set('sync_state/last-run', { at: invocation.invokedAt });
  return { status: 'completed' };
}
```

## Testing

Use `invokeSchedule()`:

```ts theme={null}
import { invokeSchedule } from '@ekanos/sdk/testing';

const { result, ctx } = await invokeSchedule(integration, 'daily-reconcile', {
  contextOptions: {
    secrets: { account: { api_key: 'test_key' } },
    fetchHandlers: [
      { match: 'https://api.acme.example', respond: () => Response.json([]) },
    ],
  },
});

expect(result.status).toBe('completed');
expect(ctx.dumpStorage().account['cache/daily-summary']).toBeDefined();
```

Reuse one context across multiple invocations so state accumulates:

```ts theme={null}
const { ctx } = await invokeSchedule(integration, 'hourly-check', {
  contextOptions: {/* … */},
});

const second = await invokeSchedule(integration, 'hourly-check', {
  context: ctx, // Reuse storage and logs
});
```

## Troubleshooting

### Cron validation error

`defineIntegration()` reports `schedules["<id>"]: Cron expression "<expr>" is invalid: <detail>.` followed by the expected grammar. Check:

* 5 fields exactly
* No seconds field
* No names like `JAN` or `MON`
* Valid ranges per field
* No `@daily` macros
* No step on a single value (`5/15`)

### Schedule id not declared

`invokeSchedule()` throws `Integration "<slug>" declares no schedule "<id>".` and lists the declared ids. Check the id spelling.

## Next steps

* [On-activate](/sdk/surfaces/on-activate) — seed data at connect time
* [Testing](/sdk/testing) — test schedules with invokeSchedule()


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.