AwaitResponseAsync — Declarative Follow-Up for Courier Workflows
A blocking, declarative API on ClaimWorkflowBase that lets any courier say "wait for a reply, and if none comes, do this."
The Problem
Today, reminder timing is tied to an implicit reference time chain (LastReminderSentAt → LastEmailProcessedAt → WorkflowStartedAt). When a courier sends an outbound email — a submission, a follow-up, anything — there's no clean way to say "start waiting for a response, and escalate if one doesn't come."
The existing ReminderHandler handles the main post-submission chase loop well, but it's not reusable for ad-hoc "send and wait" scenarios elsewhere in the workflow. A developer adding a new outbound email has to understand the full reminder internals to get follow-up behavior.
The API
Two overloads on ClaimWorkflowBase — one simple, one with escalating intervals:
Simple: Single Wait
// Wait up to 7 days. If no response, run the callback.
var responded = await AwaitResponseAsync(TimeSpan.FromDays(7), async () =>
{
await SendFollowUpEmail(new OutboundEmail
{
To = "customerservice.uk@asendia.com",
Subject = $"Follow-up: Claim {State.TrackingNumber}",
Body = "We haven't received a response..."
});
TransitionTo(ClaimStatuses.DatabaseIds.Chasing);
});Returns true if an email arrived before timeout, false if the callback fired.
Multi-Interval: Escalating Follow-Ups
// Try 3 times with increasing urgency
var responded = await AwaitResponseAsync(
intervals: [TimeSpan.FromDays(7), TimeSpan.FromDays(14), TimeSpan.FromDays(21)],
onElapsed: async (reminder) =>
{
var to = reminder.IsLast
? AsendiaWorkflowConstants.EmailAddresses.UK_ESCALATION
: AsendiaWorkflowConstants.EmailAddresses.UK;
var urgency = reminder.Number switch
{
1 => "Friendly reminder",
2 => "Second reminder",
_ => "URGENT: Final reminder"
};
await SendFollowUpEmail(new OutboundEmail
{
To = to,
Subject = $"{urgency}: Claim {State.TrackingNumber}",
Body = $"{urgency} — we submitted this claim and haven't heard back."
});
if (reminder.IsLast)
TransitionTo(ClaimStatuses.DatabaseIds.Chasing);
});The reminder.Number (1, 2, 3...) and reminder.IsLast let the callback adapt its behavior per interval — different recipients, subjects, urgency levels — all in one readable block.
How It Works
graph TD
Call["AwaitResponseAsync(intervals, onElapsed)"] --> Loop
Loop["for each interval"] --> Wait["Workflow.WaitConditionAsync<br/>(email arrives OR timeout)"]
Wait -->|Email arrives| Return["return true ✓<br/>email stays in queue<br/>for normal processing"]
Wait -->|ShouldComplete| Return2["return true ✓<br/>workflow ending"]
Wait -->|Timeout| Fire["onElapsed(reminder)"]
Fire --> Next{More intervals?}
Next -->|Yes| Loop
Next -->|No| ReturnFalse["return false ✗<br/>all intervals exhausted"]
style Call fill:#fff3e0
style Fire fill:#fce4ec
style Return fill:#e8f5e9
style Return2 fill:#e8f5e9
style ReturnFalse fill:#fce4ecKey behaviors:
- Blocking — the workflow suspends at the call site until either an email arrives or all intervals are exhausted
- Email arrives → returns immediately — the email stays in the queue, the caller (or the base template email loop) processes it normally
- ShouldComplete → returns immediately — respects workflow completion signals
- Callback has full access — it's a
Func<Task>orFunc<ReminderContext, Task>, so the courier can do anything: send emails, change status, log audits, escalate
Base Implementation
// In ClaimWorkflowBase
protected async Task<bool> AwaitResponseAsync(
TimeSpan[] intervals,
Func<ReminderContext, Task> onElapsed)
{
for (var i = 0; i < intervals.Length; i++)
{
var gotResponse = await Workflow.WaitConditionAsync(
() => EmailQueue.Count > 0 || ShouldComplete,
intervals[i]);
if (gotResponse || ShouldComplete)
return true;
await onElapsed(new ReminderContext
{
Number = i + 1,
IsLast = i == intervals.Length - 1
});
}
return false;
}
// Single-interval overload
protected Task<bool> AwaitResponseAsync(TimeSpan timeout, Func<Task> onElapsed)
=> AwaitResponseAsync([timeout], _ => onElapsed());public record ReminderContext
{
public int Number { get; init; }
public bool IsLast { get; init; }
}That's it. No new state fields, no reference time chain changes, no config. The intervals are in the code, the behavior is in the callback.
Concrete Example: Asendia Submission
protected override async Task OnSubmissionAsync(ClaimWorkflowRequest request)
{
if (request.IsBackfill || State.ClaimSubmissionAttempted)
return;
// ... delay + send submission email (existing code) ...
if (response is { Success: true, WasSubmitted: true })
{
State.SubmittedAt = Workflow.UtcNow;
TransitionTo(ClaimStatuses.DatabaseIds.SubmittedSent);
// Wait for Asendia to respond, escalate if they don't
await AwaitResponseAsync(
intervals: [TimeSpan.FromDays(7), TimeSpan.FromDays(14), TimeSpan.FromDays(21)],
onElapsed: async (reminder) =>
{
var to = reminder.IsLast
? AsendiaWorkflowConstants.EmailAddresses.UK_ESCALATION
: AsendiaWorkflowConstants.EmailAddresses.UK;
await SendFollowUpEmail(new OutboundEmail
{
To = to,
Subject = $"Reminder #{reminder.Number}: Claim {State.TrackingNumber}",
Body = BuildReminderBody(reminder.Number)
});
AddAuditLog($"Sent follow-up #{reminder.Number} to {to}");
});
}
}Reading this code, a developer immediately knows:
| Question | Answer (visible in code) |
|---|---|
| How long before first follow-up? | 7 days |
| How many follow-ups? | 3 (length of intervals array) |
| Who gets emailed? | UK address for #1-2, escalation for #3 |
| What does it say? | Subject and body right there in the callback |
| What if they reply? | AwaitResponseAsync returns true, email stays in queue for the normal pipeline |
Timeline: What Happens When
sequenceDiagram
participant A as Asendia Workflow
participant Q as Email Queue
participant C as Courier
Note over A: OnSubmissionAsync
A->>C: Submit claim email
A->>A: AwaitResponseAsync([7d, 14d, 21d])
rect rgb(232, 245, 233)
Note over A: Blocking — waiting for response
Note over Q: If email arrives → return true
end
Note over A: Day 7 — no response
A->>C: onElapsed(#1) → Friendly reminder to UK
rect rgb(232, 245, 233)
Note over A: Blocking — waiting again
end
Note over A: Day 14 — still no response
A->>C: onElapsed(#2) → Second reminder to UK
rect rgb(232, 245, 233)
Note over A: Blocking — last chance
end
Note over A: Day 21 — no response
A->>C: onElapsed(#3, IsLast) → URGENT to escalation
Note over A: returns false — all exhausted
Note over A: OnSubmissionAsync completes
Note over A: Base template enters email loopRelationship to Existing ReminderHandler
AwaitResponseAsync doesn't replace the existing ReminderHandler — they serve different purposes:
ReminderHandler |
AwaitResponseAsync |
|
|---|---|---|
| Where | Main email loop (post-submission) | Anywhere in a lifecycle hook |
| Driven by | Config (ReminderIntervalsDays) + ReminderAnalyzer email thread analysis |
Intervals + callback passed at the call site |
| Approval | Human-in-the-loop intervention system | Up to the callback (can include approval or not) |
| State tracking | LastReminderSentAt, RemindersSentCount |
None — purely blocking |
For the main post-submission chase loop, ReminderHandler remains the right tool — it has email thread analysis, human approval, config-driven intervals, and status management.
AwaitResponseAsync is for ad-hoc waits — places where a courier needs to send something and wait for a reply, with full control over what happens if nobody responds. It's a building block, not a replacement.
Scope of Changes
| File | Change |
|---|---|
ClaimWorkflowBase.cs |
Add AwaitResponseAsync (2 overloads, ~20 lines) |
ClaimWorkflowModels.cs |
Add ReminderContext record |
That's it. No state field changes, no config changes, no changes to existing couriers. Couriers opt in by calling it.