Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Trigger manifests

[[triggers]] extends harn.toml with declarative trigger registrations in the same manifest-overlay family as [exports], [llm], and [[hooks]].

Each entry declares:

  • a stable trigger id
  • a trigger kind such as webhook, cron, or a2a-push
  • a provider from the registered trigger provider catalog
  • an optional HTTP path, either as top-level path = "/..." or match = { path = "/...", ... }
  • an autonomy_tier (or tier) that defines the default execution mode
  • a delivery handler
  • optional dedupe, retry, budget, flow-control, secret, and predicate settings

A single handler can also declare sources instead of top-level kind / provider. Each source expands into its own concrete trigger binding with an id of <trigger-id>.<source-id>, while sharing the parent handler and predicate.

Shape#

[[triggers]]
id = "github-new-issue"
kind = "webhook"
provider = "github"
tier = "act_with_approval"
match = { events = ["issues.opened"] }
when = "handlers::should_handle"
when_budget = { max_cost_usd = 0.001, tokens_max = 500, timeout = "5s" }
handler = "handlers::on_new_issue"
dedupe_key = "event.dedupe_key"
retry = { max = 7, backoff = "svix", retention_days = 7 }
priority = "normal"
budget = { max_cost_usd = 0.001, max_tokens = 500, hourly_cost_usd = 1.00, daily_cost_usd = 5.00, max_autonomous_decisions_per_hour = 25, max_autonomous_decisions_per_day = 100, on_budget_exhausted = "false" }
concurrency = { max = 10 }
secrets = { signing_secret = "github/webhook-secret" }
filter = "event.kind"

Run harn routes <root> --json to inspect the manifest's static trigger inventory. The JSON envelope reports route paths, local handler modules, declared budgets, inferred host capabilities, vendor-lock disclosure, and template overhead before the orchestrator starts.

Supported autonomy tiers:

  • shadow
  • suggest
  • act_with_approval
  • act_auto

The manifest tier is the default. At dispatch time, Harn resolves the effective tier from the manifest plus the latest matching trust-graph control record for that agent.

Event selectors#

match.events accepts an event kind with an optional action and provider prefix: pull_request, github.pull_request, pull_request.opened, or github.pull_request.opened. An empty list matches every event from the binding's provider.

Harn compares the kind to TriggerEvent.kind and the optional action to the connector payload's top-level string action, stored under provider_payload.raw. A kind without an action selects every action in that family. A selector with an action doesn't match a missing, null, or non-string action. For a qualified selector, Harn strips only the event's own provider prefix. Matching is exact and case-sensitive; selectors aren't wildcard patterns.

Dotted event kinds such as message.channels still match as whole kinds. An exact match with TriggerEvent.kind takes precedence over prefix stripping, even when that kind's namespace resembles a provider name. Bindings remain scoped to TriggerEvent.provider. For GitHub, github.pull_request.opened is also the connector's event topic. The matcher reads the kind and action, not the topic or the original webhook body.

These selectors apply to event fan-out and to the named binding on HTTP and poll ingress. A delivery that doesn't match the named binding can still reach matching persona subscriptions.

Handler URI schemes#

Harn currently accepts these handler forms:

  • local function: handler = "on_event" or handler = "handlers::on_event"
  • A2A dispatch: handler = "a2a://reviewer.prod/triage"
  • worker queue dispatch: handler = "worker://triage-queue"
  • eval-pack dispatch: handler = "eval_pack://nightly-regression"

Unsupported URI schemes fail fast at load time.

Switching a handler between local and A2A dispatch is intentionally a manifest change, not a handler-source change. Keep the same trigger id and event match, then change handler = "handlers::on_event" to an a2a://... target when the trust boundary moves out of process. See Local and A2A dispatch for the replay and observability contract.

a2a://... handlers accept one extra opt-in field:

  • allow_cleartext = true permits HTTP A2A card discovery / JSON-RPC dispatch for that binding

Leave it unset for normal remote targets. It exists for bounded local-dev cases such as dispatching into harn serve --tls plain. For production harn serve targets, prefer --tls edge behind an HTTPS ingress or --cert/--key for in-process HTTPS.

worker://... handlers reuse the top-level scalar dispatch priority:

  • priority = "high"
  • priority = "normal"
  • priority = "low"

That scalar priority becomes the default queue priority when the dispatcher enqueues the job. An explicit event header priority still overrides it at dispatch time.

eval_pack://... handlers run an eval pack through the same harness.runtime.eval_pack_run(manifest, options?) path as scripts. A bare target resolves by pack id, name, or file stem from root and installed package eval declarations ([package].evals or harn.eval.toml); a path-like target resolves relative to harn.toml. Cron bindings use the normal trigger substrate, so budget, retry, DLQ, replay/cancel, dedupe, and concurrency controls apply before the suite runs. Optional trigger-local ledger = { ... } or eval_options = { ... } fields are passed as eval_pack_run options.

Local handlers and predicates resolve through the same module-export plumbing as the manifest hook loader:

  • bare names resolve against lib.harn next to the manifest
  • module::function resolves either through the current manifest's [exports] table or through imports from the leased current package generation

Validation#

The manifest loader rejects invalid trigger declarations before execution:

  • trigger ids must be unique across the loaded root manifest plus installed package manifests
  • provider must exist in the registered trigger provider catalog
  • handler must be a supported URI, and local handlers must resolve to exported functions
  • local handlers are runtime entrypoints with signature fn(Harness, TriggerEvent) -> _; keep root authority at that boundary and pass narrow handles such as HarnessFs into ordinary helpers
  • allow_cleartext, when present, must be a boolean and is only valid for a2a://... handlers
  • when is also a runtime entrypoint and must resolve to fn(Harness, TriggerEvent) -> bool or fn(Harness, TriggerEvent) -> Result<bool, _>; pass a narrow handle to any reusable classifier helper
  • when_budget requires when, and its max_cost_usd, tokens_max, and timeout fields must all be valid when present
  • dedupe_key and filter must parse as JMESPath expressions
  • retry.max must be <= 100
  • retry.retention_days defaults to 7 and must be >= 1
  • budget.max_cost_usd, budget.hourly_cost_usd, and budget.daily_cost_usd must be >= 0
  • budget.max_autonomous_decisions_per_hour and budget.max_autonomous_decisions_per_day must be >= 1 when present
  • budget.max_tokens and budget.max_concurrent must be >= 1 when present
  • cron triggers must declare a parseable schedule
  • cron timezone must be a valid IANA timezone name
  • secret references must use <namespace>/<name> syntax and the namespace must match the trigger provider

Errors include the manifest path plus the [[triggers]] table index so the bad entry is easy to locate.

Multi-source handlers#

Use sources when one handler should receive events from several trigger transports:

[[triggers]]
id = "market-fan-in"
handler = "handlers::on_market_event"
when = "handlers::should_handle"
debounce = { key = "event.provider + \":\" + event.kind", period = "2s" }

[[triggers.sources]]
id = "open"
kind = "cron"
provider = "cron"
match = { events = ["cron.tick"] }
schedule = "0 14 * * 1-5"
timezone = "America/New_York"

[[triggers.sources]]
id = "quotes"
kind = "stream"
provider = "kafka"
match = { events = ["quote.tick"] }
topic = "quotes"
consumer_group = "harn-market"
window = { mode = "sliding", key = "event.provider_payload.key", size = "5m", every = "1m" }

The loader registers market-fan-in.open and market-fan-in.quotes. Source tables inherit parent when, when_budget, flow-control, retry, dedupe, filter, and secrets unless the source overrides the same field.

For compact manifests, sources = [{ ... }, { ... }] inline arrays are accepted with the same source fields.

Stream triggers#

kind = "stream" registers continuous event sources. The built-in provider catalog currently recognizes these STREAM-01 providers:

  • kafka
  • nats
  • pulsar
  • postgres-cdc
  • email
  • websocket

Stream providers are cataloged with a shared StreamEventPayload typed payload. The built-in stream connector normalizes unsigned HTTP ingress for stream triggers that declare path = "/...". Native long-running broker/email consumer loops are still supplied through Harn connector overrides until provider-specific Rust consumers land.

Windowing is declared with window = { ... }:

  • tumbling: window = { mode = "tumbling", size = "1m" }
  • sliding: window = { mode = "sliding", size = "5m", every = "1m" }
  • session: window = { mode = "session", gap = "30s" }

All window modes accept optional key and max_items. Durations use the same compact suffixes as flow control: s, m, h, d, w. Stream triggers can also use regular debounce, concurrency, throttle, rate_limit, singleton, and keyed priority controls.

LLM-gated predicates#

when runs before handler dispatch, so it is the right place to express typed LLM classification gates such as:

[[triggers]]
id = "slack-outage-triage"
kind = "webhook"
provider = "slack"
match = { events = ["slack.message"] }
when = "handlers::about_outages"
when_budget = { max_cost_usd = 0.001, tokens_max = 500, timeout = "5s" }
handler = "handlers::triage_outage"
budget = { daily_cost_usd = 1.00, max_concurrent = 10 }

Behavior:

  • the predicate may call harness.llm.call(...)
  • per-evaluation overruns emit predicate.budget_exceeded and short-circuit to false
  • budget.daily_cost_usd applies to aggregate predicate spend for the trigger over the current UTC day; once exceeded, the trigger keeps returning false until the next UTC midnight
  • replay reuses cached predicate harness.llm.call(...) responses from the provider request cache plus the event-scoped trigger.inbox record
  • three consecutive predicate failures open a five-minute circuit breaker that fails closed with operator-visible warnings

Flow control#

Trigger manifests can shape dispatch admission with top-level flow-control tables:

[[triggers]]
id = "github-new-issue"
kind = "webhook"
provider = "github"
match = { events = ["issues.opened"] }
handler = "handlers::on_new_issue"

concurrency = { key = "event.headers.tenant", max = 10 }
throttle = { key = "event.headers.user", period = "1m", max = 30 }
rate_limit = { period = "1h", max = 1000 }
debounce = { key = "event.headers.pr_id", period = "30s" }
singleton = { key = "event.headers.repo" }
priority = { key = "event.headers.tier", order = ["gold", "silver", "bronze"] }

Supported tables:

  • concurrency = { max = N } or concurrency = { key = "<expr>", max = N }
  • throttle = { period = "<duration>", max = N } or throttle = { key = "<expr>", period = "<duration>", max = N }
  • rate_limit = { period = "<duration>", max = N } or rate_limit = { key = "<expr>", period = "<duration>", max = N }
  • debounce = { key = "<expr>", period = "<duration>" }
  • singleton = {} or singleton = { key = "<expr>" }
  • batch = { size = N, timeout = "<duration>" } or batch = { key = "<expr>", size = N, timeout = "<duration>" }
  • priority = { key = "<expr>", order = ["...", "..."] }

Durations use compact suffixes: s, m, h, d, w.

key expressions compile into Harn closures over the typed TriggerEvent surface. They use the same event shape as when predicates and local handlers, so expressions like event.headers.tenant, event.kind, or event.provider_payload.raw.repo.full_name all resolve through the normal stdlib trigger types.

When a keyed field omits key, Harn uses a single global gate for that binding. For example, rate_limit = { period = "1h", max = 1000 } applies one shared hourly budget across all matching events for that trigger.

priority is overloaded:

  • priority = "low" | "normal" | "high" keeps the existing dispatch-priority field
  • priority = { key = "...", order = [...] } enables concurrency-waiter ordering for flow control

batch delivers the selected leader event to the handler and attaches the full coalesced group under event.batch.

Legacy budget.max_concurrent still loads, but Harn treats it as deprecated and normalizes it to concurrency = { max = N } with a warning.

Current validation rules:

  • concurrency.max, throttle.max, rate_limit.max, and batch.size must be positive
  • priority.order must be non-empty
  • priority = { ... } requires concurrency = { ... }
  • batch cannot be combined with debounce, singleton, concurrency, keyed priority ordering, throttle, rate_limit, or legacy budget.max_concurrent

Durable dedupe retention#

Trigger dedupe now uses a durable inbox index backed by the shared EventLog topic trigger.inbox.claims. Each successful claim stores the binding id plus the resolved dedupe_key, and duplicate deliveries are rejected until the claim's TTL expires.

  • configure the TTL with retry.retention_days
  • the default is 7 days
  • shorter retention trims durable dedupe history sooner, which lowers storage cost but increases the chance that a late provider retry will be treated as a fresh event

Use a retention window at least as long as the provider's maximum retry window. If a provider can redeliver for longer than your configured TTL, Harn may dispatch that late retry again once the durable claim has expired.

Harn v0.7.23 still soft-reads legacy claim records from the old mixed trigger.inbox topic on startup, but all new claim writes land under trigger.inbox.claims.

Doctor output#

harn doctor now lists loaded triggers with:

  • trigger id
  • trigger kind
  • provider
  • handler kind (local, a2a, or worker)
  • budget summary

Examples#

See the example manifests under examples/triggers: