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.
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
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 package imports under.harn/packages
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 functionsallow_cleartext, when present, must be a boolean and is only valid fora2a://...handlerswhenmust resolve to a function with signaturefn(TriggerEvent) -> boolorfn(TriggerEvent) -> Result<bool, _>when_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
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
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:
cron-daily-digestgithub-new-issuea2a-reviewer-fanoutstream-fan-ingithub-stale-pr-nudgergithub-release-notes-generatorslack-keyword-routerslack-reaction-actionslack-thread-summarizerlinear-sla-breachlinear-cycle-planninglinear-stuck-issue-bumpernotion-content-review-schedulernotion-database-watcherwebhook-generic-hmac