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
kindsuch aswebhook,cron, ora2a-push - a
providerfrom the registered trigger provider catalog - an optional HTTP
path, either as top-levelpath = "/..."ormatch = { path = "/...", ... } - an
autonomy_tier(ortier) 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:
shadowsuggestact_with_approvalact_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"orhandler = "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 = truepermits 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.harnnext to the manifest module::functionresolves 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
providermust exist in the registered trigger provider cataloghandlermust 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 asHarnessFsinto ordinary helpers allow_cleartext, when present, must be a boolean and is only valid fora2a://...handlerswhenis also a runtime entrypoint and must resolve tofn(Harness, TriggerEvent) -> boolorfn(Harness, TriggerEvent) -> Result<bool, _>; pass a narrow handle to any reusable classifier helperwhen_budgetrequireswhen, and itsmax_cost_usd,tokens_max, andtimeoutfields must all be valid when presentdedupe_keyandfiltermust parse as JMESPath expressionsretry.maxmust be<= 100retry.retention_daysdefaults to7and must be>= 1budget.max_cost_usd,budget.hourly_cost_usd, andbudget.daily_cost_usdmust be>= 0budget.max_autonomous_decisions_per_hourandbudget.max_autonomous_decisions_per_daymust be>= 1when presentbudget.max_tokensandbudget.max_concurrentmust be>= 1when present- cron triggers must declare a parseable
schedule - cron
timezonemust 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:
kafkanatspulsarpostgres-cdcemailwebsocket
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_exceededand short-circuit tofalse budget.daily_cost_usdapplies to aggregate predicate spend for the trigger over the current UTC day; once exceeded, the trigger keeps returningfalseuntil the next UTC midnight- replay reuses cached predicate
harness.llm.call(...)responses from the provider request cache plus the event-scopedtrigger.inboxrecord - 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 }orconcurrency = { key = "<expr>", max = N }throttle = { period = "<duration>", max = N }orthrottle = { key = "<expr>", period = "<duration>", max = N }rate_limit = { period = "<duration>", max = N }orrate_limit = { key = "<expr>", period = "<duration>", max = N }debounce = { key = "<expr>", period = "<duration>" }singleton = {}orsingleton = { key = "<expr>" }batch = { size = N, timeout = "<duration>" }orbatch = { 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 fieldpriority = { 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, andbatch.sizemust be positivepriority.ordermust be non-emptypriority = { ... }requiresconcurrency = { ... }batchcannot be combined withdebounce,singleton,concurrency, keyed priority ordering,throttle,rate_limit, or legacybudget.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
7days - 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, orworker) - budget summary
Examples#
See the example manifests under examples/triggers: