Install
$ agentstack add skill-resonatehq-resonate-skills-resonate-durable-sleep-scheduled-work-typescript ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Resonate Durable Sleep & Scheduled Work (TypeScript)
Overview
Resonate's ctx.sleep() creates suspension points where workflows pause without consuming resources. Unlike regular setTimeout() or sleep(), durable sleep survives crashes, restarts, and redeployments. The workflow suspends its state, and Resonate resumes it exactly where it left off after the delay expires.
Core principle: yield* ctx.sleep(milliseconds) suspends execution durably. Workflows can sleep for seconds, hours, days, or weeks without holding threads, connections, or memory.
Mental Model
Regular Sleep (Ephemeral):
Process → sleep(10h) → [BLOCKS THREAD] → Resume
Crash during sleep? → LOST, never resumes
Durable Sleep (Resonate):
Workflow → yield* ctx.sleep(10h) → [SUSPENDS STATE] → Resume
Crash during sleep? → Automatically resumes after delay
Restart process? → Resumes from checkpoint after delay
No resources held during sleep - workflow exists only as durable state.
Basic Pattern: Simple Countdown
import { Context } from "@resonatehq/sdk";
function* countdown(
ctx: Context,
count: number,
delayMinutes: number,
notificationUrl: string
) {
for (let i = count; i > 0; i--) {
// Send notification
yield* ctx.run(sendNotification, notificationUrl, `Countdown: ${i}`);
// Durable sleep - workflow suspends here
yield* ctx.sleep(delayMinutes * 60 * 1000);
}
// Send final notification
yield* ctx.run(sendNotification, notificationUrl, "Done!");
}
async function sendNotification(
_ctx: Context,
url: string,
message: string
) {
await fetch(url, {
method: "POST",
body: message,
headers: { "Content-Type": "text/plain" }
});
}
Usage:
// Count down from 5, waiting 1 minute between each count
await resonate.run(
"countdown-1",
countdown,
5, // count
1, // delay in minutes
"https://ntfy.sh/mychannel"
);
What happens:
- Sends "Countdown: 5"
- Sleeps 1 minute (workflow suspends, no resources held)
- Resumes, sends "Countdown: 4"
- Sleeps 1 minute
- Repeats until "Done!"
Pattern: Scheduled Reminder
function* scheduleReminder(
ctx: Context,
userId: string,
message: string,
delayMs: number
) {
// Sleep until reminder time
yield* ctx.sleep(delayMs);
// Send reminder
yield* ctx.run(sendEmail, userId, "Reminder", message);
return { sent: true, timestamp: Date.now() };
}
// Schedule reminder 24 hours from now
await resonate.run(
`reminder/${userId}/${Date.now()}`,
scheduleReminder,
"user-123",
"Don't forget to review the document!",
24 * 60 * 60 * 1000 // 24 hours
);
Pattern: Recurring Notification
function* recurringNotification(
ctx: Context,
userId: string,
intervalHours: number,
totalOccurrences: number
) {
for (let i = 1; i {
await emailService.send({
to: userId,
subject: `Daily Update #${i}`,
body: `This is occurrence ${i} of ${totalOccurrences}`
});
});
// Sleep until next occurrence (unless this was the last one)
if (i {
// Delete records older than 90 days
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
const result = await db.delete({
table: "logs",
where: { created_at: { lt: cutoffDate } }
});
return result.deletedCount;
}
// Run cleanup every 7 days
await resonate.run(
"cleanup-job",
scheduledCleanup,
7 // every 7 days
);
Pattern: Timeout with Sleep
function* operationWithTimeout(
ctx: Context,
taskId: string,
timeoutMinutes: number
) {
// Start the operation
const handle = yield* ctx.beginRpc(longRunningOperation, taskId);
// Start a timeout timer
const timeoutHandle = yield* ctx.beginRpc(
timeoutTimer,
timeoutMinutes
);
// Race between operation and timeout
// Note: Resonate doesn't have built-in race(), so we check completion
try {
const result = yield* handle;
return { success: true, result };
} catch (error) {
return { success: false, timedOut: true };
}
}
function* timeoutTimer(ctx: Context, minutes: number) {
yield* ctx.sleep(minutes * 60 * 1000);
throw new Error("Operation timed out");
}
Pattern: Scheduled Batch Processing
function* batchProcessingScheduler(
ctx: Context,
batchSize: number,
intervalHours: number
) {
while (true) {
// Fetch batch of items to process
const items = yield* ctx.run(fetchPendingItems, batchSize);
if (items.length === 0) {
console.log("No items to process");
} else {
// Process each item
for (const item of items) {
yield* ctx.run(processItem, item);
}
console.log(`Processed ${items.length} items`);
}
// Sleep until next batch
yield* ctx.sleep(intervalHours * 60 * 60 * 1000);
}
}
// Process batches of 100 items every 6 hours
await resonate.run(
"batch-processor",
batchProcessingScheduler,
100, // batch size
6 // every 6 hours
);
Real-World Example: Trial Expiration Workflow
function* trialExpirationWorkflow(
ctx: Context,
userId: string,
trialDays: number
) {
const trialEndDate = new Date();
trialEndDate.setDate(trialEndDate.getDate() + trialDays);
// Send welcome email immediately
yield* ctx.run(sendEmail, userId, "Trial Started", welcomeEmail);
// Day 7: Reminder
yield* ctx.sleep(7 * 24 * 60 * 60 * 1000);
yield* ctx.run(sendEmail, userId, "Trial Reminder", reminderEmail);
// Day 13: Upgrade prompt
yield* ctx.sleep(6 * 24 * 60 * 60 * 1000);
yield* ctx.run(sendEmail, userId, "Upgrade Now", upgradeEmail);
// Day 14: Trial expires
yield* ctx.sleep(1 * 24 * 60 * 60 * 1000);
// Check if user upgraded
const user = yield* ctx.run(getUser, userId);
if (!user.isPaid) {
// Downgrade to free tier
yield* ctx.run(downgradeAccount, userId);
yield* ctx.run(sendEmail, userId, "Trial Ended", trialEndedEmail);
}
return { userId, upgraded: user.isPaid };
}
Crash Recovery Behavior
Scenario: Countdown crashes during sleep
Initial execution:
✓ Send "Countdown: 5"
✓ ctx.sleep(60000) - Creates durable timer
✗ CRASH
Resume (after worker restarts):
→ Resonate replays from beginning
→ Send "Countdown: 5" - Idempotent (or checkpointed)
→ ctx.sleep(60000) - Resonate knows this already completed, skips
→ After sleep expires, workflow automatically resumes
→ Continues with "Countdown: 4"
Key insight: Crashes during sleep don't lose the timer. Resonate tracks the sleep's expiration time durably.
Sleep Duration Limits
Practical limits:
- Milliseconds: Minimum sleep duration
- Days/Weeks: Common for scheduled workflows
- Months: Possible but consider alternatives for very long delays
- Maximum: No hard limit, but long sleeps may require server configuration
Recommendation: For delays > 90 days, consider using a cron-style scheduler or external job queue.
Common Pitfalls
1. Using setTimeout Instead of ctx.sleep
// ❌ WRONG - Not durable, lost on crash
function* badCountdown(ctx: Context, count: number) {
for (let i = count; i > 0; i--) {
yield* ctx.run(notify, `Count: ${i}`);
await new Promise(resolve => setTimeout(resolve, 60000)); // LOST ON CRASH
}
}
// ✅ CORRECT - Durable sleep
function* goodCountdown(ctx: Context, count: number) {
for (let i = count; i > 0; i--) {
yield* ctx.run(notify, `Count: ${i}`);
yield* ctx.sleep(60000); // SURVIVES CRASHES
}
}
2. Sleeping Outside Generator Context
// ❌ WRONG - Can't sleep outside ctx
async function badDelay(ctx: Context) {
await ctx.run(async () => {
await new Promise(resolve => setTimeout(resolve, 60000)); // NOT DURABLE
});
}
// ✅ CORRECT - Sleep in generator
function* goodDelay(ctx: Context) {
yield* ctx.sleep(60000); // DURABLE
}
3. Not Handling Idempotency
// ❌ WRONG - Sends duplicate notifications on retry
function* badReminder(ctx: Context, userId: string) {
await sendEmail(userId, "Reminder"); // NOT IDEMPOTENT
yield* ctx.sleep(24 * 60 * 60 * 1000);
}
// ✅ CORRECT - Wrapped in ctx.run for idempotency
function* goodReminder(ctx: Context, userId: string) {
yield* ctx.run(sendEmail, userId, "Reminder"); // IDEMPOTENT
yield* ctx.sleep(24 * 60 * 60 * 1000);
}
Time Precision
Resonate's sleep precision:
- Sleep duration specified in milliseconds
- Actual resume time may vary by seconds (depends on server load, polling intervals)
- Not suitable for sub-second precision requirements
- Suitable for minutes, hours, days
Example:
yield* ctx.sleep(60000); // Sleeps ~60 seconds (±few seconds variance)
yield* ctx.sleep(100); // Not reliable for 100ms precision
Decision Tree
Use ctx.sleep() when:
- Delays span minutes, hours, or days
- Workflow must survive crashes during delay
- Scheduled notifications, reminders, timeouts
- Rate limiting, backoff, periodic tasks
Don't use ctx.sleep() when:
- Sub-second precision required (use regular timers)
- Delay is ephemeral (use setTimeout in ctx.run)
- Need to cancel/update sleep dynamically (use promises with timeout)
Summary
Durable sleep enables workflows to:
- Pause execution for hours, days, or weeks without consuming resources
- Survive crashes, restarts, and redeployments during sleep
- Implement countdowns, scheduled reminders, recurring tasks
- Handle timeouts, rate limiting, exponential backoff
- Create multi-stage delayed workflows
Core recipe: yield* ctx.sleep(milliseconds) → Workflow suspends → Resonate resumes after delay → Continue execution
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: resonatehq
- Source: resonatehq/resonate-skills
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.