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

# Build a durable watch

> Persist source cursors, classify changes, schedule bounded digests, and reconcile uncertain notifications.

`DurableWatch` manages a versioned source-to-notification lifecycle. It uses deterministic filters, persisted cursors, bounded pending events, and an outbox. It makes no model calls. It is separate from `HarnessRuntime`; it requires its own store, scheduler, source, connector, and authorization.

## Define and bind a watch

The following integration factory is complete TypeScript. Your application supplies `WatchServices` with actual host bindings. Calling the factory validates the configuration; `watch.activate()` is the explicit operation that subscribes and schedules work.

```typescript theme={null}
import { defineWatch, describeWatch, DurableWatch, type WatchServices } from "@agentium/harness";

export function importantChanges(services: WatchServices) {
  const definition = defineWatch({
    id: "important-changes",
    version: 1,
    identity: { tenantId: "tenant-1", actorId: "owner-1" },
    sourceId: services.source.id,
    sourceScope: services.source.scope,
    channel: services.notifications.channel,
    destination: services.notifications.destination,
    policyRevision: 1,
    grantRefs: ["read-source", "notify-owner"],
    timeZone: "Asia/Kolkata",
    quietHours: { start: "22:00", end: "07:00" },
    limits: { maxNotificationsPerDay: 5 },
  });
  console.log(describeWatch(definition));
  return new DurableWatch(definition, services);
}
```

Replace the example identity and grant references with your host's authorized values. Watch identity uses **`actorId`**, whereas harness-run identity uses **`userId`**.

`defineWatch()` validates, copies, and freezes JSON configuration. `describeWatch()` returns `{ definition, hash, activation: "explicit", modelCalls: false }`. Neither activates a source. Unknown fields, invalid identifiers, invalid timezones, or out-of-bounds limits fail early.

## Supply host services

| Service | Required behavior |
| - | - |
| `store` | Core `DurableTaskStore` with durable records and outbox support. |
| `source` | `WatchSource` with matching `id`/`scope`, idempotent activation, bounded reads, and polling support. |
| `scheduler` | Durable, idempotent job upsert and cancellation. Must actually dispatch scheduled work. |
| `notifications` | `DurableActionConnector` bound to the definition's fixed channel and destination. |
| `authorize` | Verify identity, operation, source access, destination/channel, policy revision, grant references, and requested notification/event amounts. |
| `filter` | Optional deterministic local event predicate. No model or network calls. |
| `leaseMs` | Optional lease duration passed to the core durable task supervisor. |
| `requireDurability` | Defaults to requiring durable storage, outbox, and scheduler. `false` is for in-memory fixtures only. |

Grant reference strings are not authorization by themselves. Source and notification scope are checked against the definition on operations. Changing connector version or filter behavior requires a new configuration version.

## Dispatch scheduled jobs

Schedules contain `key`, `identity`, `watchId`, `configVersion`, `kind`, and `at`. Persist them with idempotent upserts. Resolve the exact identity/watch/version before dispatching:

```typescript theme={null}
import type { DurableWatch, WatchSchedule } from "@agentium/harness";

export async function dispatchWatchJob(watch: DurableWatch, job: WatchSchedule) {
  const definition = watch.definition;
  if (
    job.watchId !== definition.id || job.configVersion !== definition.version ||
    job.identity.tenantId !== definition.identity.tenantId ||
    job.identity.actorId !== definition.identity.actorId
  ) throw new Error("Watch job scope mismatch");

  switch (job.kind) {
    case "poll": return watch.poll();
    case "renew": return watch.renew();
    case "flush": return watch.flush();
  }
}
```

The library does not start a scheduler worker. Desired schedule times persist if job publication fails; retrying `activate()` repairs publication. Activation persists its baseline cursor before creating the source subscription, so retry does not silently skip ahead.

## Read changes and deliver a digest

`poll()` reads from the persisted cursor. `trigger(raw)` first verifies a source hint, validates principal and scope, then reads from that same persisted cursor. A push-provided cursor cannot advance state. Acknowledge a push only after `trigger()` resolves.

Stable semantic event IDs deduplicate admission. Cursor advancement, decisions, and pending digest events commit together under a fenced lease. Unchanged wakes send nothing. Resync events are recorded as suppressed decisions rather than historical notifications.

`flush()` prepares at most one immutable digest, reserves its local-day notification allowance, and uses the core durable action ledger for dispatch. The action binds identity, destination, connector version, grants, policy revision, and arguments. Authorization runs again immediately before sending.

A lost acknowledgement produces an unknown outcome. Newer digests wait until the host connector supplies durable evidence. `reconcile(outboxId)` checks that evidence and never sends a notification. Leases fence local commits; they cannot retract requests already sent to a provider.

## Lifecycle methods

| Method | Behavior |
| - | - |
| `activate()` / `resume()` | Reauthorize, establish or renew the subscription, and publish schedules. Deleted versions cannot resume. |
| `poll()` | Perform a bounded source read. |
| `trigger(raw)` | Verify a push hint and perform a bounded read. |
| `renew()` | Renew the source subscription and update schedules. |
| `flush()` | Deliver at most one digest, respecting quiet hours, cooldown, limits, and unresolved effects. |
| `pause()` | Stop new admission and cancel schedules. Retain pending/unknown work and the source subscription until expiry. A busy lease can reject the operation. |
| `update(next, services?)` | Require a higher version with the same watch ID, identity, and timezone. Pause the old version before activating the replacement. |
| `delete()` | Tombstone this version, cancel schedules, and stop its owned subscription. Preserve audit and unresolved effects. Retry if source stop fails. |
| `inspect()` | Return an authorized copy of persisted `WatchState`. |
| `reconcile(outboxId)` | Reconcile a previous send, including after pause, update, or deletion. |

An update is a fail-closed rollout, not an atomic transaction across versions. Same-source updates inherit cursor, deduplication IDs, cooldown, and daily reservations. Old pending/unknown sends keep their original aggregate, grants, and destination. Deleting a retired version does not stop its replacement's subscription.

## Timing and limits

| Timing option | Default | Allowed range |
| - | - | - |
| `pollIntervalMs` | `300000` (5 minutes) | Positive safe integer, up to one day |
| `renewalIntervalMs` | `86400000` (1 day) | Positive safe integer, up to one day |
| `cooldownMs` | `60000` (1 minute) | `0` through 30 days |
| `quietHours` | None | `{ start, end }` in `HH:MM`; equal boundaries are rejected—use pause for all-day silence |

| Limit | Default | Maximum where additionally bounded |
| - | - | - |
| `maxOperations` | `4096` | `10000` |
| `maxWakes` | `1000` | Positive safe integer |
| `maxEventsPerWake` | `100` | `1000` |
| `maxPagesPerWake` | `5` | `100` |
| `maxResyncEvents` | `100` | `1000` |
| `maxPendingEvents` | `256` | `1000` |
| `maxDecisions` | `4096` | Positive safe integer |
| `maxOutbox` | `128` | Positive safe integer |
| `maxEventBytes` | `8192` | `65536` |
| `maxStateBytes` | `512000` | `1500000` |
| `maxNotificationsPerDay` | `20` | Positive safe integer |

All limits are positive safe integers. State is bounded per version; arrange explicit version rotation or archiving before exhausting limits. Maintenance operations can still pause, delete, or reconcile after the work budget is exhausted, without admitting new sends.

Daily reservations use the configured IANA timezone and durable store time. Quiet windows account for daylight-saving transitions. Denied or uncertain sends conservatively retain their reservation; delivery delayed to another day reserves that day too. Changing timezone requires a new watch identity.

For a source implementation, implement `baseline`, `activate`, `stop`, bounded `read`, and `compareCursors`; add `verifyTrigger` for push. See [Gmail source](/harness/gmail), [full watch contracts](/harness/api/watches), and [core durable protocols](/durable/protocols).


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