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

Signals reference

The harn signals command, its hook adapter, the inbox file, and its configuration. For the design, see Ambient signals. For setup, see Deliver signals to Claude Code and Codex.

Commands#

Every command accepts --dir <DIR> to use an inbox other than the default. Commands that print data accept --json and emit the standard JSON envelope with schemaVersion: 1.

CommandEffect
harn signals listEvery signal with its key, state and generation, severity, delivery (held (<reason>), routed to, leased to ... (attempt N), lease lapsed for ... (attempt N), or acked by), and summary. Control characters, line and paragraph separators, bidirectional marks, zero-width characters, and tag characters print as spaces.
harn signals pending [--session ID]Signals whose current generation has not been acked.
harn signals sessionsRegistered sessions: id, harness, busy or idle, repository, role, working directory.
harn signals fire ...Fire a signal. See Firing.
harn signals resolve KEY [--summary TEXT] [--signature local|verified|unverified]Resolve a fired signal. --summary replaces what the recovery reminder says happened. --signature (default local) says how the recovery report was checked; one weaker than the signal's own is refused.
harn signals ack KEY [--generation N] [--session ID]Ack a leased reminder so it is not redelivered. The generation defaults to the current one. Fails if that generation was never leased or is already acked.
harn signals hook ...Run as a harness hook. See Hook adapter.

Firing#

FlagDefaultMeaning
--key KEY<provider>:<source-url>, or the event's dedupe_keyThe condition's identity.
--summary TEXTrequired without --eventWhat happened.
--source-url URLrequired without --eventWhere the condition can be checked.
--why TEXTemptyWhy it deserves attention.
--action TEXTa generic check-the-source stepThe proposed next step.
--provider NAME, --kind KINDmanualEnvelope provider and source_kind.
--severity info|warning|urgentwarningOnly urgent keeps a stopping session going or wakes an idle one.
--producer NAMEharn signals fireWho fired the signal; shown in the reminder.
--signature local|verified|unverifiedlocalWhether the alert's origin was verified; shown in the reminder.
--repo OWNER/NAMEnoneRouting hint: sessions in this repository.
--cwd DIRnoneRouting hint: sessions working inside this directory.
--topic TOPICnoneRouting hint for custom routers.
--expires-in DURATIONfired_ttl_secondsExpire the signal if it is still fired after this long, for example 4h.
--event FILEnoneRead a harn.triage_event.v1 document from a file, or - for stdin, instead of the text flags.

fire and resolve print the transition: fired (generation N) for a state change, or unchanged when the signal was already in that state. A change owes one reminder; an unchanged result owes none.

Hook adapter#

harn signals hook --format claude-code|codex --event EVENT [--role ROLE] [--rewake] [--max-wait DURATION]

The hook reads the harness's JSON payload on stdin and uses its session_id, cwd, and, from Claude Code, session_title. The repository is the origin remote of the Git repository containing cwd. The role comes from --role, else HARN_EXT_SIGNALS_ROLE.

Every invocation except SessionEnd and Stop --rewake leases at most three reminders, prints them, and after flushing its output acks exactly those leases by token. A hook killed before that leaves its leases to lapse and be leased again; a hook running beside it never acks them.

--eventRegisters the session asLeasesOutput
SessionStartbusyany severityhookSpecificOutput.additionalContext
UserPromptSubmitbusyany severityhookSpecificOutput.additionalContext
PostToolUsebusyany severityhookSpecificOutput.additionalContext
Stopidle, or busy if it deliversClaude Code: urgent only. Codex: nothingClaude Code: hookSpecificOutput.additionalContext, which continues the turn. Codex: {}, because a Codex Stop reason becomes a user prompt.
Stop --rewakeidlenothing; watches for urgent reminders while idleClaude Code only. Exits 2 to wake the session under asyncRewake, printing only a fixed notice on stderr: Claude Code shows that stderr as a user-role Stop hook feedback turn, so it carries no signal text. The woken turn's Stop hook then leases the reminder and delivers it as additionalContext. It wakes for an urgent reminder that appears after its first poll, or one pending at its first poll that still has no lease out two polls later (polls are two seconds apart). Exits 0 after --max-wait (default 9m, under the hook's default 600-second timeout), when the session becomes busy, when it ends, or at once when another watcher for the session is running.
SessionEndremovednothingnone

When more reminders are routed to the session than one hook leases, the output ends with how many follow. With nothing to deliver, the hook prints nothing (Codex Stop: {}) and exits 0. A hook that fails prints harn signals hook: <reason> on stderr and exits 1, which both harnesses treat as a non-blocking hook error. It never exits 2 on failure.

Files#

The inbox directory is $HARN_EXT_SIGNALS_DIR when set, else ~/.harn/signals. It is created with mode 0700, and its files with 0600.

FileContents
inbox.jsonSignals and sessions, schema harn.signals.inbox.v1. Replaced atomically on every change.
inbox.lockAdvisory lock file every transaction holds exclusively.
watchers/<hex session id>.lockHeld by the running --rewake watcher for a session. The watcher removes it as it exits; SessionEnd, and the next write to the inbox, remove one a killed watcher left.
.inbox.json.*.tmpA write in progress. The next write removes one a killed process left.
config.tomlOptional configuration and delivery policy.

inbox.json#

{
  "schema": "harn.signals.inbox.v1",
  "signals": {
    "<key>": {
      "key": "<key>",
      "state": "fired | resolved | expired",
      "generation": 1,
      "severity": "info | warning | urgent",
      "event": {"schema": "harn.triage_event.v1", "...": "..."},
      "routing": {"repo": "owner/name", "cwd": "/path", "topic": "..."},
      "provenance": {"producer": "std/signals.monitor_github_ci", "signature": "verified | unverified | local"},
      "fired_at": "RFC 3339",
      "changed_at": "RFC 3339",
      "expires_at": "RFC 3339 or null",
      "delivery": {
        "generation": 1,
        "session_id": "...",
        "assigned_at": "RFC 3339",
        "lease": {"token": "...", "leased_at": "RFC 3339", "expires_at": "RFC 3339"},
        "acked_at": "RFC 3339 or null"
      },
      "attempts": 1,
      "fired_recipient": "session id leased the latest fired generation"
    }
  },
  "sessions": {
    "<id>": {
      "id": "<id>",
      "harness": "claude-code | codex | <other>",
      "cwd": "/path",
      "repo": "owner/name",
      "title": "...",
      "role": "control",
      "activity": "busy | idle",
      "registered_at": "RFC 3339",
      "last_seen": "RFC 3339"
    }
  }
}

The inbox stores the event's normalized fields only. It replaces raw_payload with {} and sets privacy.raw_payload_retained to false.

A document with any other schema is refused rather than migrated.

config.toml#

KeyDefaultMeaning
stale_after_seconds7200A session not seen for this long is removed, and its unacked reminders are routed again.
retain_for_seconds604800Resolved and expired signals are pruned this long after their last change.
lease_seconds600How long a lease runs before an unacked reminder is leased again, to its holder while that session is responsive, otherwise to a responsive session. A session silent this long loses its unleased reminders to a responsive session.
fired_ttl_seconds86400A fired signal that is not fired again within this long expires, unless the producer set its own expiry.
max_signals1000The most signals the inbox keeps. The oldest settled signal is pruned to make room; with every signal still fired, firing a new key fails.
fallback_role"control"The role that receives signals no session matches.
min_severity"info"Signals below this severity are held.
muted[]Signal keys to hold. An entry ending in * matches every key with that prefix, for example "github.open_prs:*".
[quiet_hours]noneA daily window in which only signals at or above its min_severity route.
quiet_hours.start, quiet_hours.endrequiredHH:MM. A window whose end is before its start spans midnight.
quiet_hours.utc_offset"+00:00"The offset the window is read in, +HH:MM or -HH:MM. It does not follow daylight saving time.
quiet_hours.min_severity"urgent"What still routes during quiet hours.

The inbox is the only owner of this policy. A held signal stays in the inbox unrouted and routes once the policy allows it. Unknown keys are refused.

Environment#

VariableMeaning
HARN_EXT_SIGNALS_DIRInbox directory.
HARN_EXT_SIGNALS_ROLEDefault --role for harn signals hook.

Rust API#

Hosts embed the inbox in-process through the harn-signals crate: Inbox::open, Inbox::register_session, Inbox::check_in, Inbox::ack_leases, Inbox::ack, Inbox::end_session, Inbox::fire, Inbox::resolve, Inbox::poll_idle, Inbox::claim_watcher, and Inbox::snapshot. A consumer acks each leased reminder with Inbox::ack_leases and its lease_token once the reminder reached the model. harn_signals::display_safe is the character filter for showing signal text anywhere. Replace the routing rules with Inbox::with_router and any Router implementation; the policy in config.toml applies before the router runs. harn_vm::stdlib::signals::render_reminders renders reminders into the text the hook adapter prints. Deliver it as a system reminder, never as a user message.

Harn API#

std/signals is the producer surface for Harn scripts and trigger handlers.

FunctionEffect
signal_apply(runtime, decision, options?)Fire the decision's signal when decision.firing, otherwise resolve it. Returns {outcome, state?, generation?}.
signal_deliver(harness, agent_session_id, session, options?)Check in and inject each leased reminder into a running agent session as an advisory system reminder, deduplicated by signal key, then ack each injected lease. Returns {check_in, reminder_ids, acked}.
ci_branch_decision(repo, branch, runs, severity?)Firing while the newest decisive run of any workflow on branch failed. runs holds GitHub workflow runs, newest first; the first completed run per workflow that concluded success, failure, timed_out, startup_failure, or action_required counts, and cancelled, skipped, stale, and neutral runs are passed over. Key github.ci:<repo>@<branch>.
open_prs_decision(repo, open_count, threshold, severity?)Firing while open_count >= threshold. Key github.open_prs:<repo>.
runner_pool_decision(pool, online, severity?)Firing while online < pool.minimum. pool is {scope, minimum, group?, required_labels?}. Key github.runners:<scope>[/<group>][:<sorted labels>].
runner_pool_online(runners, required_labels?)Online runners in a runner list that carry every required label.
rate_limit_decision(subject, remaining, limit, min_percent, source_url?, severity?)Firing while remaining is under min_percent of limit. Key github.rate_limit:<subject>.
alert_webhook_decisions(provider, payload, signature, routing?)Sentry, Grafana, or Honeycomb webhook body to decisions; other providers return []. signature is "verified" or "unverified", from the trigger's signature_status. Only the summary and link come from the payload. Keys sentry:<issue>, grafana:<fingerprint>, honeycomb:<trigger>.
monitor_github_ci(harness, repo, branch?, options?), monitor_github_open_prs(harness, repo, threshold, options?), monitor_github_runners(harness, pool, options?), monitor_github_rate_limit(harness, subject, min_percent, options?)Fetch from the GitHub REST API, evaluate, and apply. A failed or incomplete read returns {outcome: "unmeasured", error} and changes no signal.

GitHub monitor options:

OptionMeaning
app{app_id, installation_id, private_key_secret?, private_key_pem?, installation_scope?}. Each poll mints a fresh installation token through the GitHub connector, because a cached token can read a different rate-limit counter.
tokenA token the caller already resolved.
token_envAn environment variable holding a token. Default GITHUB_TOKEN.
api_urlAPI base URL. Default https://api.github.com.
severity, dirSignal severity and inbox directory.

monitor_github_runners lists the organization runner group in pool.group when set, or else the repository's or organization's runners, reading every page. A listing stops at total_count, or at a short page when the response has none. A page without the runners field, a listing that ends short of its total_count, or one past 5,000 runners is unmeasured rather than counted short.

monitor_github_ci lists the repository's active workflows and reads each one's 20 most recent completed push runs on the branch, one request per workflow, so a busy workflow cannot push another's runs out of a shared page. The newest decisive run of each workflow is its reading. A branch where no workflow has a decisive recent run, or a response without workflow_runs, is unmeasured rather than recovered. monitor_github_open_prs is unmeasured when the search reports incomplete_results.

The runtime builtins are the session lifecycle and the producer calls underneath signal_apply:

BuiltinEffect
harness.runtime.signal_fire(request)Fire a signal. Returns the transition.
harness.runtime.signal_resolve(key, options?)Resolve a fired signal. options takes event, signature (default "local"; one weaker than the signal's own is refused), and dir.
harness.runtime.signal_register(session, options?)Record a session without leasing or acking. session is {id, harness, cwd, repo?, title?, role?, activity: "busy" | "idle"}.
harness.runtime.signal_check_in(session, options?)Lease pending reminders. options takes scope ("all" or "urgent_only"), limit (default 3), and dir. Returns {reminders, waiting}; each reminder carries a lease_token.
harness.runtime.signal_ack(session_id, lease_tokens, options?)Ack the leases these tokens name once their reminders reached the model. A token whose lease lapsed and was leased again acks nothing. Returns the {key, generation} ids acked.
harness.runtime.signal_end_session(session_id, options?)Remove the session; its unacked reminders route elsewhere.
harness.runtime.signal_render(reminders)The notification text for leased reminders.

request takes event (a normalized harn.triage_event.v1), key, severity, routing, provenance ({producer, signature}, default a local script), expires_in_seconds, and dir; unknown fields are refused. Timestamps come from the VM clock, so a mocked clock moves signal lifecycles too.

examples/triggers/ambient-signals wires the monitors to a five-minute cron trigger and the alert normalizers to webhook triggers.