Automation surfaces — what launches a run without a human typing
The four trigger families and the spine they converge on: the dispatcher's tracker poll, inbound forge webhooks, the event-driven trigger spine, and the board capabilities a bot declares to write back.
Full references: ../dispatcher.md, ../webhooks.md, ../adr/046-event-driven-runs-trigger-spine.md.
Surfaces
Dispatcher layer (iterion dispatch)
Iterion ships a long-running dispatcher on top of the runtime engine: iterion dispatch <config.yaml> polls an issue tracker (native kanban, GitHub Issues, or Forgejo/Gitea) and dispatches a workflow run per eligible issue, with retry, stall detection, per-state concurrency, and lifecycle hooks (after_create, before_run, after_run, before_remove).
The dispatcher uses an actor pattern — a single goroutine owns all mutable state; outside callers send typed commands on a channel. The architecture is fully documented in docs/dispatcher.md; the native tracker (the default, locally-owned kanban) is documented in docs/native-tracker.md.
Key files: pkg/dispatcher/dispatcher.go (actor + public API), pkg/dispatcher/loop.go (polling + dispatch), pkg/dispatcher/tracker/tracker.go (the Tracker interface), pkg/dispatcher/native/store.go (the JSON kanban store), pkg/cli/dispatch.go (daemon wiring including the embedded SPA).
The studio's SPA exposes two new routes when the corresponding server flags are set: /board (kanban CRUD with drag-and-drop, gated on server_info.native_tracker_enabled) and /dispatcher (live dashboard with running + retry tables, gated on server_info.dispatcher_enabled).
Inbound webhooks (cloud agent-workflow triggers)
Distinct from the dispatcher (which polls): cloud mode exposes self-authenticating inbound webhook endpoints that launch a bot per external event — GitLab MR open/reopen and /revi note re-review, GitHub PR, Forgejo/Gitea PR, and a generic JSON trigger. Per-org iwh_ tokens (token or HMAC mode), rate limits, monthly quotas, idempotent delivery audit, and the per-org launch gate (run quota / cost cap / concurrency — pkg/orgusage + pkg/server/launch_gate.go) all sit in front of the launch. Key files: pkg/webhooks/ (spine + per-provider parsers), pkg/server/webhooks_common.go (shared admission→idempotency→launch tail). Reference: docs/webhooks.md; platform overview: Iterion Cloud overview.
Event-driven trigger spine (pkg/trigger + pkg/eventbus)
The unifying layer the four trigger families above (schedule, dispatcher poll, forge webhooks, invocations: DSL) are converging onto: one canonical trigger.Event envelope, an internal eventbus.Bus (InProcBus local, NATSBus on the separate Core NATS subject tree iterion.events.> for cloud), and a trigger.Subscription registry binding (event filter) → (bot launch into a repo/workspace) — queryable by repo / by bot (ListByRepo / ListByBot), stored in-memory (local) or Mongo (cloud) like forge.RepoIntegrationStore. The per-bot bundle.Invocation stays the capability ("what can fire me"); Subscription is the binding (where/which repo), generated from invocations — repo/tenant/cron never enter a manifest. Five sources ship on the spine:
- board events — a
kind: boardinvocation with aboard:block (on/to_states/all_labels) fires a bot on a native-card transition. The board source tails the existingnative.Store.Subscribeseam and promotes the card (stamps its bot) so the dispatcher'sClaim— the sole launch authority — picks it up now (Manager.Refresh()) instead of at the 30s poll; the poll stays the reconciliation net, so fast-path + poll cannot double-launch. A board invocation may instead declaremode: direct— the evaluator then launches the bot ON the matching card (card id invars.issue_id) instead of routing the card TO it; withconsume_labels: truethe matcher'sall_labelsset is stripped atomically pre-launch, making the label a one-shot re-armable trigger. This powers issue auto-triage (bots/issue-triage, persona Triagy): forge issues synced to the board are author-classified at ingest (fail-closed trust gate —authorTrustoverforge.PermissionClient, thresholdMinAuthorRole); trusted authors' cards land in inbox withtriage:auto(fires Triagy, who stamps the handler bot + labels viaset_bot), untrusted ones park withneeds:approval+ zero LLM until the operator's studio "Approve & triage" swaps the labels. The same author gate protects the webhookAutoImplementOnOpenzero-touch lane. Cloud parity: the mongo board has its own spine half (pkg/server/trigger_cloud.go) — aboard_eventspoll-tail every replica runs. Board events ride no bus there: the per-tenant CAS cursor advances only AFTER the matched (event, subscription) pairs — plus a projection row when a card move owes the tenant's bound external board a push (ADR-097 §10) — are durable rows in thetrigger_effectsoutbox (ADR-094), whichtrigger.EffectWorkerdrains under a leased claim through the same evaluator, with an ATOMIC label consume (boardmongo.ConsumeLabels), so consume_labels triggers cannot double-launch across replicas; the/api/v1/triggersCRUD is team-scoped in cloud (active-team JWT). - run-completion ("runned by iterion") —
runview.Serviceemitsrun.finished/failed/cancelled/pausedin-process, and cloud runner pods publish the same events onto the NATSBus (Runner.fireOutcomeEvent, sharingtrigger.BuildRunOutcomewith the runview emitter — the NATSBus is wired in cloud since the web-notifications work); a direct-mode subscription chains the next bot (Actor= upstream bot id), and theusernotifydispatcher consumesrun.paused+terminals for browser push notifications (docs/notifications.md). - scheduled —
trigger.Schedulerticks schedule-kind subscriptions on theirCronand keepalive-kind ones oninterval_seconds(sub-minute;Subscription.NextFireowns both cadences) — local tenant "" only. Cloud fires schedules from cloudsched's ownscheduled_botsrows (both cadences); a schedule- or keepalive-kind subscription carrying a team id is fired by neither. - git-forge — the inbound-webhook launch tail emits a
SourceForgeevent with alaunched_run_idmarker (observational; the evaluator never re-launches it, so the mature HMAC/idempotency/quota webhook path stays the sole authority). - custom —
POST /api/v1/triggers/emitpublishes aSourceCustomevent (source forced — it cannot spoof a board/forge/run one); 202, the launch happens in the evaluator.
Direct launches go through serviceLauncher over runview.Service.Launch. Wired in pkg/server/trigger_coordinator.go (both iterion studio and iterion dispatch); REST CRUD at /api/v1/triggers (gated by server_info.triggers_enabled), driven by the studio's Automations view at /triggers. The forge cutover (spine becomes the forge launcher, inline retired), forge-derived provisioning, and dispatcher EngineRunner convergence are staged follow-ons. Reference: docs/adr/046-event-driven-runs-trigger-spine.md.
Bot board access (capabilities)
Agent and judge nodes can write to the native board by declaring a capabilities: list in the .bot DSL (e.g. capabilities: [board.create, board.move, board.read]). The runtime opens the matching tools transparently based on the backend:
- claude_code (default) — registers an internal
__mcp-boardstdio MCP server (subcommand of the iterion binary) and extends the AllowedTools list with the grantedmcp__iterion_board__*FQNs. - claude_code (sandboxed) — falls back to an HTTP transport at
/api/v1/mcp/boardon the iterion server, authenticated via an ephemeralX-Iterion-Runtoken registered by the runtime. - claw — registers the operations as in-process tools under
mcp.iterion_board.*viapkg/backend/tool/claw_board_tools.go.
All three paths route through the same pkg/dispatcher/native/boardops package, so validation and event semantics are identical. Capability diagnostics are C080 (unknown cap, warning) and C081 (malformed, error). The bot catalog Nexie reads (bots/whats-next/skills/iterion-bot-catalog.md) is generated from each bot's manifest.yaml (persona table + per-bot cards with description / triggers / vars / when_to_use, enabled bots only) spliced into a hand-authored iterion-bot-catalog-static.md preamble (the decision tree + distinguishers + rituals you maintain by hand). To change Nexie's routing, edit a bot's manifest (display_name / description / when_to_use / triggers / enabled) or toggle it in the studio Catalog manager — don't hand-edit the generated region. Regeneration runs on every studio bot-metadata save and at run start; which runs refresh which copy, and why, is bot-catalog-regeneration.md. Refresh the committed copy by hand with iterion bots regen-catalog. A workspace overlay (.iterion/bot-overrides.yaml, gitignored) can hide/show a bot per-workspace without editing its manifest. See pkg/botregistry/catalog.go.
