createScheduler
Schedule prepared announcements with bounded queues and an injected clock.
Signature and cleanup
Most apps should use createRuntime. Use the scheduler directly only if your app already decides which events should produce announcements.
import { createScheduler, systemClock } from "@generative-a11y/core";
const scheduler = createScheduler({
clock: systemClock,
minimumGapMs: 750,
dedupeWindowMs: 4_000,
maxQueueSize: 64,
onAnnouncement: (intent) => console.log(intent.text),
});
scheduler.schedule({
channel: "polite",
text: "The report is ready.",
sourceType: "response.completed",
scope: "response:report-1",
capacityPriority: "content",
});Use scope to cancel related pending output when a response, tool, run, or step
ends. Under capacity pressure, response content can replace a status update.
Call scheduler.dispose() when its owner ends.
SchedulerOptions
The scheduler requires explicit timing and capacity values; it does not apply a runtime preset.
Prop
Type
ScheduleAnnouncement
A candidate describes prepared user-facing output. It must not contain raw backend data.
Prop
Type
Scheduler methods
Dispose cancels the owned timer and pending queue.
Prop
Type
Delivery pacing
The scheduler keeps a single timer aimed at the next eligible candidate. An
announcement becomes eligible at its dueAt time, which is the scheduling
moment plus delayMs on the injected clock. Polite announcements additionally
wait for minimumGapMs to elapse since the last successful delivery, so a
burst of status updates cannot machine-gun a screen reader. Assertive
announcements bypass the minimum gap: when an eligible assertive and an
eligible polite candidate compete, the assertive one delivers first. The gap is
only updated by successful deliveries; a suppressed duplicate does not reset
pacing.
import { createScheduler, systemClock } from "@generative-a11y/core";
const scheduler = createScheduler({
clock: systemClock,
minimumGapMs: 1000,
dedupeWindowMs: 5_000,
maxQueueSize: 32,
onAnnouncement: (intent) => liveRegion.textContent = intent.text,
});
// Deliver soon, but no sooner than one second after the previous announcement.
const statusId = scheduler.schedule({
channel: "polite",
text: "Still working on your request.",
sourceType: "response.started",
responseId: "response:r1",
capacityPriority: "status",
});
// Urgent content jumps ahead of the polite queue.
scheduler.schedule({
channel: "assertive",
text: "Your approval is required.",
sourceType: "approval.requested",
interactionId: "interaction:a1",
capacityPriority: "content",
});Deduplication
Each delivered announcement registers a dedupe key for dedupeWindowMs. When
a queued candidate later becomes eligible with the same key, delivery is
suppressed and a suppressed / duplicate diagnostic is recorded. The key
combines the locale with either your explicit dedupeKey or a default built
from the source type, an entity identity
(responseId ?? toolId ?? interactionId ?? scope ?? "global"), the channel,
and the text. Passing an explicit dedupeKey lets you collapse equivalent
output that differs in incidental text, while the default keeps announcements
from two different responses separate even when the wording matches.
Coalescing is the queue-time counterpart. If a candidate carries a
coalesceKey that matches a pending item, the pending item is replaced in
place: it keeps its ID and queue position but adopts the newest candidate's
text and a fresh dueAt. A merged / coalesced diagnostic records the
replacement. Use coalescing for progress updates where only the latest value
matters.
Capacity and eviction
The queue is bounded by maxQueueSize, a positive integer validated at
construction. When a new candidate arrives at a full queue, the scheduler
chooses a victim rather than growing: if any polite-channel candidate is
involved, only polite candidates are eligible for eviction, which shields
assertive output. Among the pool, the scheduler drops the lowest
capacityPriority (status before content) and then the oldest sequence.
If the new candidate itself is the victim, schedule returns undefined and
records a cancelled / queue-capacity diagnostic.
cancelScope(scope) removes every pending candidate tagged with that scope,
for example when a response, tool, run, or step ends and its follow-up
output is no longer relevant. cancelPurposes(purposes, reason?) removes only
queued candidates whose purpose (defaulting to "notice") appears in the
list, with the reason defaulting to "scope-cancelled". Both emit one
diagnostic per cancelled candidate. Already delivered output cannot be
retracted by either method.
Diagnostics
Attach onDiagnostic to observe scheduler decisions without affecting them.
Every decision carries a disposition (queued, announced, merged,
suppressed, cancelled), a reason (such as scheduled, coalesced,
duplicate, queue-capacity, scope-cancelled, delivery-error, or
runtime-disposed), a timestamp from the injected clock, and, when relevant, a
content-free announcement projection plus the correlation IDs and timing
fields of the item involved.
getDiagnosticSnapshot() returns a frozen projection of the current pending
queue in due-time order. It is deliberately content-free: no text,
dedupe keys, or coalesce keys are exposed, so it is safe to log or render.
Diagnostic delivery is best-effort and budgeted. The scheduler drains
queued diagnostics with a budget of at least 16 entries (scaling with
maxQueueSize); if diagnostics are produced faster than they drain, the excess
is aggregated into a single suppressed / queue-capacity diagnostic with a
count. An onDiagnostic listener that throws cannot alter scheduling: the
error is swallowed.
Error semantics
Construction validates its timing and capacity values up front:
minimumGapMs and dedupeWindowMs must be finite non-negative numbers,
and maxQueueSize must be a positive integer; violations throw a
RangeError. schedule throws a RangeError when delayMs is negative or
non-finite, and silently ignores candidates with blank text.
Delivery failures are contained. If onAnnouncement throws, the failed
announcement is suppressed with a delivery-error diagnostic and your
onDeliveryError observer is notified; an observer that throws is swallowed so
it cannot strand the remaining queue. A scheduler whose onAnnouncement
always throws keeps its queue moving: every queued item is attempted once, in
due order, regardless of earlier failures.
Testing with a manual clock
Pair the scheduler with ManualClock from @generative-a11y/core to test
pacing deterministically. Advance the clock instead of waiting, and assert on
the announcements your onAnnouncement listener collects.
import { createScheduler, ManualClock } from "@generative-a11y/core";
const clock = new ManualClock(0);
const announced: string[] = [];
const scheduler = createScheduler({
clock,
minimumGapMs: 1000,
dedupeWindowMs: 5_000,
maxQueueSize: 8,
onAnnouncement: (intent) => announced.push(intent.text),
});
scheduler.schedule({
channel: "polite",
text: "First update.",
sourceType: "response.started",
});
scheduler.schedule({
channel: "polite",
text: "Second update.",
sourceType: "response.text.delta",
});
// Nothing delivered yet: the timer waits on the manual clock.
clock.advanceBy(1);
console.log(announced); // ["First update."]
clock.advanceBy(1000);
console.log(announced); // ["First update.", "Second update."]
scheduler.dispose();