# Harn trigger quick reference (LLM-friendly)

<!-- GENERATED by `harn dump-trigger-quickref` -- do not edit by hand. -->
<!-- Sources of truth: crates/harn-vm/src/triggers/event.rs ProviderCatalog metadata and connector contract v1 docs. -->

<!-- markdownlint-disable MD013 -->

**Canonical URL:** <https://harnlang.com/docs/llm/harn-triggers-quickref.md>

Use this with `docs/llm/harn-quickref.md` when writing trigger, connector, or orchestrator code. It covers manifest shape, provider catalog metadata, the pure-Harn connector contract, and example-library commands.

**Reminders ≠ triggers.** Triggers spawn or schedule tasks from external or timed events; system reminders modify a running agent session by injecting typed ambient context. See `docs/src/system-reminders.md` and the Reminders section in `docs/llm/harn-quickref.md`.

## Trigger manifest

```toml
[package]
name = "review-bot"

[exports]
handlers = "lib.harn"

[[triggers]]
id = "github-prs"
kind = "webhook"
provider = "github"
match = { path = "/hooks/github", events = ["pull_request.opened"] }
handler = "handlers::on_pull_request"
when = "handlers::should_handle"
dedupe_key = "event.dedupe_key"
retry = { max = 7, backoff = "svix" }
budget = { daily_cost_usd = 5.00, max_concurrent = 4 }
secrets = { signing_secret = "github/webhook-secret" }

[[triggers]]
id = "weekday-digest"
kind = "cron"
provider = "cron"
match = { events = ["cron.tick"] }
schedule = "0 9 * * 1-5"
timezone = "America/Los_Angeles"
handler = "handlers::send_digest"

[[triggers]]
id = "nightly-regression"
kind = "cron"
provider = "cron"
match = { events = ["cron.tick"] }
schedule = "0 3 * * *"
timezone = "UTC"
handler = "eval_pack://scheduled-regression"
budget = { daily_cost_usd = 0.25, on_budget_exhausted = "retry_later" }
concurrency = { max = 1 }
```

Key fields: `id`, `kind`, `provider`, `handler`, `path` or `match.path`, `match.events`, `when`, `dedupe_key`, `retry`, `budget`, `secrets`, `schedule`, `timezone`, and provider-specific config tables such as `poll`.

A nominal trigger handler has the boundary signature `fn on_event(harness: Harness, event: TriggerEvent)`. The runtime supplies the root `Harness` and the typed event. Keep `when` predicates pure as `fn should_handle(event: TriggerEvent) -> bool`; they do not receive authority. At the boundary, attenuate authority by passing helpers the smallest coherent nominal handles they need (for example `HarnessFs` or `HarnessLlm`). Root `Harness` parameters are appropriate for entrypoints and orchestration functions that genuinely coordinate several capabilities, but reusable helpers should not inherit process-wide authority by default. The `capability-attenuation` lint reports unnecessarily broad helper signatures and suggests the handles actually used.

Audit a project before deploy with `harn routes <root> --json`; it reports each declarative trigger's route path, handler module, budgets, inferred host capabilities, vendor-lock disclosure, and prompt/template overhead without executing handler code.

## Handler variants

`handler:` accepts a closure (in-process), an `a2a://`, `worker://`, or `eval_pack://` URI string, or a handler-variant dict. `eval_pack://<target>` resolves a bare target from `[package].evals` by id/name/file stem or a path-like target relative to `harn.toml`, then dispatches through `harness.runtime.eval_pack_run(manifest, options?)` under the trigger's normal budget, retry, DLQ, replay/cancel, and flow-control rules. The dict form covers compositions that need more than a single callable; `SpawnToPool` and `ReminderInject` ship today, more variants will plug into the same syntax over time.

```harn
import { SpawnToPool } from "std/triggers"
import { pool_create } from "std/lifecycle/pool"

fn configure_review_trigger(harness: Harness) {
  pool_create(harness.agent, {name: "pr-review-pool", max_concurrent: 4})

  harness.runtime.trigger_register({
    kind: "issue.opened",
    provider: "github",
    handler: SpawnToPool({
      pool: "pr-review-pool",
      priority_from: "headers.priority",   // optional dotted JSON path
      key_from: "tenant_id",               // optional dotted JSON path
      task_factory: { event -> { -> review(event) } },
    }),
    match: {events: ["issue.opened"]},
  })
}
```

The dispatcher invokes `task_factory(event)` per match, extracts priority + fair-queue key from the event payload (missing paths fall back to default priority 0 / null key), and submits the resulting closure to the named pool under its queue strategy + backpressure policy. Pool rejections (`drop_newest`, etc.) reuse the `lifecycle.pool.audit` channel that direct `pool.submit` calls emit on. The dispatch result is shaped as a `pool_task` handle so handlers can call `pool_wait(harness.agent, dispatch.result)` directly.

```harn
import { ReminderInject } from "std/triggers"

fn register_release_reminder(runtime: HarnessRuntime) {
  runtime.trigger_register({
    kind: "channel.emit",
    provider: "channel",
    match: {events: ["channel:pr.merged"]},
    handler: ReminderInject({
      // "current", "parent", a literal session id, or a closure
      target: "current",
      body: "PR {{ event.provider_payload.payload.number }} merged."
        + " Consider cutting a patch release.",
      tags: ["release_reminder"],
      ttl_turns: 1,
      dedupe_key: "release_reminder",
    }),
  })
}
```

`ReminderInject` (#1876) injects a `SystemReminder` (#1815) into the target running session at its next turn boundary — no spawn, no resume, no signal. `target` resolves at dispatch time: `"current"` walks the owning session, `"parent"` walks its parent in the session lineage, any other string is a literal session id, and a closure `event -> string?` lets the trigger pick a target dynamically. `body` is a `.harn.prompt` template rendered against `{{ event }}` (the full trigger event), `{{ match }}` (`matched_at`), and `{{ batch }}` (when flow-control batching is in effect). `tags`, `ttl_turns`, `dedupe_key`, `propagate`, `role_hint`, and `preserve_on_compact` mirror `transcript.inject_reminder`. Missing target sessions are dropped gracefully with a `triggers.reminder_inject.audit` audit entry instead of failing the dispatch.

## Provider catalog

This table is generated from `std/triggers::list_providers()` / `ProviderCatalog` metadata.

| Provider | Kinds | Schema | Runtime | Signature | Secrets | Outbound methods |
|---|---|---|---|---|---|---|
| `a2a-push` | `a2a-push` | `A2aPushPayload` | builtin `a2a-push` | none | - | - |
| `cron` | `cron` | `CronEventPayload` | builtin `cron` | none | - | - |
| `email` | `stream` | `StreamEventPayload` | builtin `stream` | none | - | - |
| `kafka` | `stream` | `StreamEventPayload` | builtin `stream` | none | - | - |
| `nats` | `stream` | `StreamEventPayload` | builtin `stream` | none | - | - |
| `postgres-cdc` | `stream` | `StreamEventPayload` | builtin `stream` | none | - | - |
| `pulsar` | `stream` | `StreamEventPayload` | builtin `stream` | none | - | - |
| `webhook` | `webhook` | `GenericWebhookPayload` | builtin `webhook` / `standard` signatures | HMAC `standard`, header `webhook-signature`, sha256/base64, ts `webhook-timestamp`, id `webhook-id`, 300s tolerance | `webhook/signing_secret` (required) | - |
| `websocket` | `stream` | `StreamEventPayload` | builtin `stream` | none | - | - |

## First-party connector packages

Provider business logic ships as pure-Harn packages. The Rust runtime keeps only core connector primitives such as webhook intake, cron, A2A push, and stream ingress.

| Provider | Package | Install | Package gate |
|---|---|---|---|
| GitHub | <https://github.com/burin-labs/harn-github-connector> | `harn add github.com/burin-labs/harn-github-connector@v0.2.0` | `harn package verify . --provider github` |
| Slack | <https://github.com/burin-labs/harn-slack-connector> | `harn add github.com/burin-labs/harn-slack-connector@v0.1.0` | `harn package verify . --provider slack` |
| Linear | <https://github.com/burin-labs/harn-linear-connector> | `harn add github.com/burin-labs/harn-linear-connector@v0.1.0` | `harn package verify . --provider linear` |
| Notion | <https://github.com/burin-labs/harn-notion-connector> | `harn add github.com/burin-labs/harn-notion-connector@v0.1.0` | `harn package verify . --provider notion --run-poll-tick` |
| GitLab | <https://github.com/burin-labs/harn-gitlab-connector> | `harn add github.com/burin-labs/harn-gitlab-connector@v0.1.0` | `harn package verify . --provider gitlab` |
| Forgejo | <https://github.com/burin-labs/harn-forgejo-connector> | `harn add github.com/burin-labs/harn-forgejo-connector@v0.1.0` | `harn package verify . --provider forgejo` |
| Gitea | <https://github.com/burin-labs/harn-gitea-connector> | `harn add github.com/burin-labs/harn-gitea-connector@v0.1.0` | `harn package verify . --provider gitea` |
| Bitbucket | <https://github.com/burin-labs/harn-bitbucket-connector> | `harn add github.com/burin-labs/harn-bitbucket-connector@v0.1.0` | `harn package verify . --provider bitbucket` |
| CircleCI | <https://github.com/burin-labs/harn-circleci-connector> | `harn add github.com/burin-labs/harn-circleci-connector@v0.1.0` | `harn package verify . --provider circleci` |
| Buildkite | <https://github.com/burin-labs/harn-buildkite-connector> | `harn add github.com/burin-labs/harn-buildkite-connector@v0.1.0` | `harn package verify . --provider buildkite` |
| SourceHut | <https://github.com/burin-labs/harn-sourcehut-connector> | `harn add github.com/burin-labs/harn-sourcehut-connector@v0.1.0` | `harn package verify . --provider sourcehut` |
| Subversion | <https://github.com/burin-labs/harn-svn-connector> | `harn add github.com/burin-labs/harn-svn-connector@v0.1.0` | `harn package verify . --provider svn --run-poll-tick` |

Community connectors are Harn packages that declare `connector_contract = "v1"` and export the connector functions below. Direct GitHub refs are enough for private or pre-registry packages; registry names such as `@burin/notion-connector` are for discoverable package-index entries.

## Connector contract V1

Required exports for a pure-Harn connector package:

| Export | Required | Purpose |
|---|---:|---|
| `provider_id() -> string` | Yes | Provider id, matching `[[providers]].id`. |
| `kinds() -> list<string>` | Yes | Trigger kinds such as `webhook`, `poll`, `cron`, `a2a-push`, or `stream`. |
| `payload_schema() -> dict` | Yes | `{ harn_schema_name, json_schema? }`; the contract check rejects `{ name = ... }` drift. |
| `normalize_inbound(harness, raw) -> dict` | Inbound | Returns `NormalizeResult` v1 for webhook-style input. |
| `poll_tick(harness, ctx) -> dict` | Poll | Required when `kinds()` includes `poll`; returns events plus optional `cursor`/`state`. |
| `call(harness, method, args) -> dict` | Outbound | Provider API escape hatch. Unknown probes may throw `method_not_found:<method>`. |
| `init(harness, ctx)` | No | Receives event log, secrets, metrics, inbox, and rate-limit handles. |
| `activate(harness, bindings)` | No | Runs on manifest activation/reload. |
| `shutdown(harness)` | No | Cleanup on reload or process shutdown. |

`normalize_inbound(harness, raw)` must return one of these tagged shapes: `{ type: "event", event }`, `{ type: "batch", events }`, `{ type: "immediate_response", immediate_response, event?, events? }`, or `{ type: "reject", status, body? }`. Direct legacy event dicts are rejected.

Every runtime export takes the root `Harness` first; the metadata exports (`provider_id`, `kinds`, `payload_schema`) stay pure and take nothing. Reach secrets, the event log, and metrics through that handle: `harness.secrets.read`, `harness.obs.event_log_emit`, and `harness.obs.metrics_inc`. The hot-path `normalize_inbound` effect policy rejects network calls, LLM calls, process execution, host calls, MCP calls, and ambient filesystem/project access.

Runtime scripts can observe EventLog topics directly with `event_log.subscribe({topic, from_cursor, kind_prefix?})`, which returns a `Stream<dict>` of `{id, cursor, topic, kind, payload, headers, occurred_at_ms}` records. Use `event_log.latest(topic)` before subscribing to tail new events only, pass `kind_prefix` to receive only matching event kinds, or persist the `cursor` field to resume after a reconnect.

## Package fixtures

Connector packages should declare deterministic fixtures in `harn.toml` and run them in CI:

```toml
[connector_contract]
version = 1

[[connector_contract.fixtures]]
provider = "slack"
name = "url verification"
kind = "webhook"
headers = { "content-type" = "application/json" }
body_json = { type = "url_verification", challenge = "challenge-token" }
expect_type = "immediate_response"
expect_event_count = 0
```

Run `harn package verify .` locally. Add `--strict` for warning-fatal check/lint gates and strict boundary typing, use `--provider <id>` for a multi-provider package, `--run-poll-tick` to execute the first poll tick, and `--json` for a schema-v3 CI receipt.

## Example library

Ready-to-customize pipelines live under `examples/triggers/`. Each example includes `harn.toml`, `lib.harn`, `README.md`, and `SKILL.md` so it can be copied into a project or installed as a local skill bundle. Validate examples with `make check-trigger-examples`.

## Generic webhook intake substrate

Below the per-provider connectors lives a forge-agnostic intake substrate
that any connector can wire into. It is the lowest-level entry point: a
connector declares a path scope, a signature header + algorithm, a
delivery-id header, and a topic; the substrate handles HMAC verification,
delivery-id deduplication (durable across process restarts), and
republishing onto the chosen topic. Per-forge event normalization lives in
the connector that consumes the topic.

Runtime capability methods, reached through `harness.runtime`:

- `harness.runtime.webhook_intake_register(config) -> dict` — register an
  intake. Returns
  `{ id, path, topic, signature_header, signature_prefix,
  signature_encoding, algorithm, allow_legacy_sha1, delivery_id_header,
  dedupe_ttl_seconds }`. Config keys:
  - `id` (optional) — pin the intake id; one is generated if omitted.
  - `path` (optional) — HTTP path scope. When set,
    `harness.runtime.webhook_intake_feed` rejects deliveries on a
    different path.
  - `secret` (string or bytes, required) — HMAC key.
  - `signature_header` (required) — e.g. `"x-hub-signature-256"`.
  - `signature_prefix` — defaults to `"<algorithm>="`. Pass `""` to opt out.
  - `signature_encoding` — `"hex"` (default) or `"base64"`.
  - `algorithm` — `"sha256"` (default) or legacy `"sha1"` when `allow_legacy_sha1` is true.
  - `allow_legacy_sha1` — explicit opt-in for existing providers that still sign with HMAC-SHA1.
  - `delivery_id_header` (required) — e.g. `"x-github-delivery"`.
  - `topic` (required) — event-log topic accepted deliveries are appended to.
  - `dedupe_ttl_seconds` — defaults to 24h.
- `harness.runtime.webhook_intake_feed(intake_id, request) -> dict` — feed a
  delivery.
  Request keys: `headers` (dict), `body` (string or bytes), optional `path`
  and `received_at` (RFC3339). Returns `{ status, intake_id, topic,
  delivery_id, topic_event_id, reason, received_at }`. `status` is
  `"accepted"`, `"duplicate"`, or `"rejected"`.
- `harness.runtime.webhook_intake_recent(intake_id, limit?) -> list` — a
  bounded replay buffer. Reads the last `limit` accepted deliveries from the
  topic.
- `harness.runtime.webhook_intake_list() -> list` — all currently-registered
  intakes.
- `harness.runtime.webhook_intake_deregister(intake_id) -> bool` — remove an
  intake.

A connector wires this in about thirty lines. `on_delivery` takes the narrow `HarnessRuntime` handle rather than root authority, which is what the `capability-attenuation` lint asks for:

```harn,check
type WebhookRequest = {headers: dict, body: string, path: string}

/** Feed one inbound delivery; the result is the HTTP reply. */
fn on_delivery(
  runtime: HarnessRuntime,
  intake: string,
  req: WebhookRequest,
) -> dict {
  const outcome = runtime.webhook_intake_feed(intake, {
    headers: req.headers,
    body: req.body,
    path: req.path,
  })
  if outcome.status == "rejected" {
    return {status: 401, body: outcome.reason}
  }
  return {status: 202}
}

fn main(harness: Harness) {
  const intake = harness.runtime.webhook_intake_register({
    id: "github",
    path: "/hooks/github",
    secret: harness.secrets.read("github/webhook-secret"),
    signature_header: "x-hub-signature-256",
    delivery_id_header: "x-github-delivery",
    topic: "github.events",
  })
  // Call `on_delivery` for each request your HTTP listener accepts.
  const reply = on_delivery(harness.runtime, intake.id, {
    headers: {
      "x-github-delivery": "8f3c",
      "x-hub-signature-256": "sha256=...",
    },
    body: "{}",
    path: "/hooks/github",
  })
  harness.stdio.println("replied ${reply.status}")
}
```

Rejections are appended to `triggers.webhook_intake.rejections` with the
intake id and reason for audit. The substrate is agnostic to per-forge
event shape — connectors normalize the opaque payload after consuming the
topic.
