Automations

August 20, 2026 · View on GitHub

Automations are rule-based background workflows. Each rule has a trigger, a target execution surface, a prompt template or action chain, an optional tool palette, an optional output contract, and guardrails. Automations sit between the CTO (heavy, stateful, chat-driven) and raw cron (deterministic, no AI). The execution surface choice is the key control point.

There is no autonomous Linear intake pipeline (the CTO's Linear workflow engine was removed). Automations can react to Linear events as context or write to Linear as an action through the shared Linear client, but no rule "owns" issue dispatch — the CTO is a chat thread that reads and lightly updates issues, not a router.

Runtime ownership

The automation rule engine, cron scheduler, deferred-cleanup sweeper, file watcher, ingress endpoints (webhook listener, GitHub relay/polling, Linear relay), and built-in action runner all execute inside the ADE runtime (ade serve) that owns the project. For local project bindings the local runtime hosts them; for remote project bindings the remote runtime hosts them. The desktop renderer is a view: it edits rules, watches run history, and triggers manual fires through window.ade.automations, but it does not own scheduling, ingress, or dispatch state.

Availability

Automations ship enabled in every build — packaged desktop apps and installed daemons included. areAutomationsEnabledForPackagedState (shared/automationAvailability.ts) returns true regardless of the packaged flag; ADE_DISABLE_AUTOMATIONS=1 is the kill switch and ADE_ENABLE_AUTOMATIONS=1 still forces the feature on. The runtime reports the resolved state as AppInfo.automationsEnabled, and the /automations gate (AutomationsProductionGate in AutomationsPage.tsx) reads it, falling back to the old "off when packaged" rule only against a runtime too old to send the flag. When the kill switch is set the tab renders the disabled screen (AutomationsComingSoon.tsx, "Automations are disabled on this build.").

The ingress service keeps a reduced PR-freshness-only mode for exactly that kill-switch case. When automations are unavailable, the GitHub relay poll still runs so webhook-driven PR state updates reach prService.ingestGithubWebhook, while automation rule dispatch, the local webhook HTTP server, and ingress status/event reporting stay gated. See automationIngressService.ts in the source file map below.

Caveat: GitHub-polling and webhook ingress only work on a runtime that can reach the public internet (or your relay). A remote runtime behind a firewall may need the relay path even if the local desktop is internet-reachable.

Source file map

Services (apps/desktop/src/main/services/automations/)

These services are loaded by the ADE runtime's project scope (and by the desktop main process when it hosts a local project) — the path reflects the source tree, not where the code "runs".

  • automationService.ts — main service. Rule CRUD, execution dispatch (agent-session, built-in), cron scheduling (via node-cron), durable deferred-lane cleanup, lane lifecycle dispatch, file-change watching (via chokidar), queue management, run history, confidence scoring, billing codes, ingress cursor storage. Scheduled callbacks first atomically claim a deterministic (project, automation, trigger index, cron expression, minute slot) occurrence in the machine-local automation_schedule_occurrences table, so concurrent runtimes sharing a project database elect exactly one executor before any chat or lane is created; claims are pruned after 35 days. Deleting an automation-owned chat tears down its provider runtime and cancels any still-open associated run. Ingress retention: it no longer persists raw webhook payloads (raw_payload_json is always written null), and on every insert it prunes automation_ingress_events for that project at write time — dropping rows older than 7 days and, for non-dispatched rows, everything beyond the newest 2,000 (insert + prune run in one BEGIN IMMEDIATE). At construction it runs a one-time, chunked reclaim that nulls any legacy raw_payload_json still on disk and prunes the review-artifact / PR-snapshot tables to their retention windows; a reclaim failure is logged and retried next boot, never blocking service startup. The retention/count bounds are imported from state/dbMaintenanceApi so the writer, the storage-doctor DB hooks, and the storage ledger enforce one policy.
  • automationPlannerService.ts — natural-language rule authoring. parseNaturalLanguage, validateDraft, saveDraft, simulate. Runs a planner subprocess (Claude or Codex) to turn a free-text brief into an AutomationRuleDraft. The Codex planner config carries optional model and reasoningEffort: model is a provider-native id, and omitting it means "whatever ~/.codex/config.toml defaults to" rather than a hardcoded fallback, while reasoningEffort is forwarded as -c model_reasoning_effort.
  • automationIngressService.ts — HTTP webhook ingress (GitHub, custom webhooks) plus GitHub relay cursor drains and a repo-scoped WebSocket wake-up subscription. Signature verification for webhooks. AutomationIngressEventRecord is the normalized event shape. Accepts automationService: null for the PR-freshness-only mode described under Runtime ownership: the relay still feeds prService.ingestGithubWebhook, but rule dispatch, the local webhook server, and ingress status/event reads are skipped. In that mode the relay cursor is persisted through an injected ingressCursorStorecreateKvIngressCursorStore(db), which reads/writes automations.ingress.cursor.<source> in the kv table — instead of automationService's cursor storage. Linked PR ids from relay deliveries are accumulated only after their page cursor commits, then flushed as one targeted prPollingService.reconcilePrs(prIds) call per successful drain; if a later page fails, ids from earlier committed pages are still reconciled. Local GitHub webhook deliveries request the same targeted reconciliation immediately. A successful drain sets the relay-health signal consumed by prPollingService; config removal, poll failure, and shutdown clear it so direct GitHub polling resumes. Page/transport failures honor Retry-After and use an exponential 30 s–15 min poll cooldown. A missing GitHub App user or ADE account token instead puts polling and socket connection attempts into a quiet 5-minute auth-pending cooldown (relay status disabled, a single automations.github_relay_auth_pending info log). A signed-in machine keeps a broken App credential from disabling the subscription, because the account token can still carry the poll — noteHostedAuthFailure starts the cooldown without enterHostedAuthPending's teardown. That cooldown gates the App-token lookup as well as the subscription: asking for a credential ADE just found broken is what turned one dead token into a refresh request every thirty seconds. While the lookup is paused the deadline is not re-stamped, so the credential is tried again when the cooldown ends rather than being pushed out forever. pollNow() clears either cooldown for an explicit retry, and releases the "logged once" latch with it: a repair that did not take is a new fact, and the log line saying so must not be suppressed by the failure it replaced. stop()/dispose() abort active polling and close the socket plus polling/connect/reconnect timers.
  • githubPollingService.ts — direct GitHub REST polling for the origin repo plus extraRepos. Each tick first asks automationService.hasEnabledGithubRules() (or the injected equivalent) and does no GitHub work unless at least one enabled rule has a canonical github.* trigger. Active ticks diff per-poll snapshots of issues/PRs/comments to emit github.issue_* and github.pr_* events without requiring a webhook or relay. Cursor format is <slug>=<iso>|<slug>=<iso> to support multi-repo state in a single stored string; see readCursor/writeCursor for the legacy-compat parser.
  • automationSecretService.ts — secret resolution for automation actions (env-ref style). Referenced as ${env:VAR} in action config; resolved at execution time.
  • linearIngressService.ts — Linear event ingress over the hosted relay. Two delivery modes: a per-workspace Linear webhook that setup() creates through the shared Linear client (createWebhook / listWebhooks / deleteWebhook, resource types Issue/Comment/IssueLabel), or the ADE Linear OAuth app's auto-provisioned webhook (sentinel id ade-linear-app, surfaced as appManaged in status — never created or deleted here). Polls the relay's seq:<n> cursor for new Linear deliveries and exposes getStatus / setup / teardown / pollNow. AutomationLinearIngressStatus (shared/types/automations.ts) is the status shape; app-connected workspaces self-configure on the first poll and their teardown leaves the app webhook alone.
  • linearRelayConfig.ts — kv + credential helpers for the Linear relay path: base-URL resolution (ADE_LINEAR_RELAY_API_BASE_URL, defaulting to the shared GitHub relay Worker DEFAULT_GITHUB_RELAY_API_BASE_URL), webhook-id/organization/secret persistence in kv (automations.linearRelay.*) plus the webhook secret in the credential store (linear.webhookSecret.v1), and createLinearAccessTokenGetter (Bearer-prefixes OAuth tokens, passes API keys raw) shared by desktop and headless wiring.

GitHub relay and App

  • apps/webhook-relay/ — the hosted GitHub relay: a Cloudflare Worker (src/index.ts / src/relay.ts) plus D1 migrations. Receives ADE-GitHub-App webhooks, verifies the HMAC signature, stores deliveries idempotently by delivery id, serves repo-scoped /github/repos/:owner/:repo/status and /events reads (monotonic seq:<n> cursors; order=asc&limit adds forward pagination with a hasMore flag, while the default descending shape stays unchanged for old clients), and exposes /github/repos/:owner/:repo/subscribe for debounced WebSocket wake-up frames. The socket is only a hint; D1 plus the cursor remains the durable stream. Signed-in account requests to /events and /subscribe are authorized first from the account-owned, still-installed repository binding in D1, avoiding one GitHub REST access check per poll/reconnect; legacy requests without a matching ADE account binding fall back to the GitHub-token authorization path. The RepoEventsDurableObject (src/repoEventsDurableObject.ts, one hibernating instance per lowercased owner/repo, bound as REPO_EVENTS in wrangler.jsonc with a SQLite-class migration) coalesces a repo's webhook burst into at most one {"t":"github_delivery","repo":"owner/repo"} frame per ~1s debounce, carries no payload or cursor, closes each socket after ~4h with code 4401 to force credential revalidation, and answers an app-level ping with pong at the edge without waking. A webhook write's notifyRepoEvents failure is swallowed (the safety poll recovers) so a committed delivery never becomes a GitHub retry. Storage is bounded three ways: default event retention dropped from 30 to 7 days (DEFAULT_RETENTION_DAYS, overridable via EVENT_RETENTION_DAYS); slimGitHubPayloadForStorage strips the avatar-heavy top-level sender/organization/enterprise duplicates and check_run.output from stored check_run/check_suite/workflow_run/status payloads (idempotency still hashes the raw body); and a 5-minute isolate-scoped cache of write-level repo-access verdicts (keyed by token digest, never the token) cuts repeat GitHub authorization round-trips on the legacy token path — admin-level checks are never cached. Two repo-scoped webhook-maintenance routes back drift recovery and diagnostics: POST /github/repos/:owner/:repo/webhook/heal (repo-admin gated) re-syncs the GitHub App's webhook secret to the Worker's own GITHUB_WEBHOOK_SECRET via PATCH /app/hook/config — the recovery path when a rotated secret causes signature-mismatch drift, and idempotent because it can only converge on the Worker's current secret; GET /github/repos/:owner/:repo/webhook/deliveries (push/write gated) proxies the GitHub App delivery log filtered to the caller's repository, failing closed (a repo-scoped delivery is dropped unless its repository_id matches the authorized repo; app-level ping/meta deliveries with no repository are kept). The shared assertGitHubRepoAuthorized gate takes a write | admin access level and returns the repositoryId used for that filter. Legacy /projects/:projectId/github/... project-token routes remain for self-hosted deployments. See apps/webhook-relay/README.md for deploy/setup.
  • apps/desktop/src/main/services/github/githubRelayConfig.ts — resolves the relay base URL and auth mode. Defaults to the hosted Worker (DEFAULT_GITHUB_RELAY_API_BASE_URL) with usesHostedDefault; fetchGitHubAppInstallationStatus authenticates the hosted repo status route with a GitHub App user access token via resolveHostedGitHubRelayAuthToken (never the user's general GitHub token), falling back to the legacy project-token route only when shouldUseLegacyGitHubRelayProjectRoute (non-default base URL + project id + access token). fetchAppInstallationStatusForRepo is the whole check in one call — resolve the App user token, keep the failure when there is none, then fetch — and the desktop service and the headless twin both call it. The middle step is why it is one function: the relay answers a request with no App token with its own 401 ("GitHub auth token is required"), and repeating that wording blames the repository for a problem with ADE's authorization. A 401 therefore reports appUserAuthFailure (a typed GitHubAppUserAuthUnavailable) plus the account-shaped copy from appUserAuthUnavailableCopy; any other status was answered with a credential the relay accepted, so it keeps the relay's own message. Also exposes createGitHubRelayAuthAuditLog, a dedup wrapper that emits one github.hosted_relay_auth_token_used audit line per (event, route, repo, token source).
  • apps/desktop/src/main/services/github/githubAppUserAuth.ts — raw GitHub device-flow HTTP helpers: startGitHubAppDeviceFlow, pollGitHubAppDeviceFlow, and refreshGitHubAppUserToken against GitHub's OAuth device endpoints, plus the ADE_GITHUB_APP_CLIENT_ID constant and the GitHubAppUserTokenRecord shape. Failures raise a typed GitHubOAuthError carrying the endpoint, the HTTP status, the OAuth error code, the description, and retry-after in seconds. The type matters because GitHub answers a rejected refresh token with HTTP 200 plus an error field, so status alone cannot tell a dead credential from a healthy response, and it answers a throttled client with 429 plus retry-after, the only honest source for how long to wait. Use the isGitHubOAuthError predicate rather than instanceof: the desktop service and the headless twin load this module through different paths, and a cross-realm instanceof answers wrong. No storage or lifecycle logic — pure request/response mapping.
  • apps/desktop/src/main/services/github/githubAppUserAuthLedger.ts — the stored credential record and the refresh ledger beside it, plus the pure parse/serialize/default functions over both. RefreshLedger holds notBeforeAt (no refresh before this instant), consecutiveFailures, dead, leaseUntil / leaseHolder, a generation counter for cross-process log correlation, and lastFailure. It lives inside the credential record so every process that reads the credential also reads the backoff that applies to it. readIsoActiveWithin is the guard against a poisoned deadline: every instant in this ledger is written by a peer process, and a peer with a wrong clock can stamp a lease or a backoff a year ahead that no amount of waiting reaches. A deadline further ahead than the longest one ADE ever writes reads as expired, so the next writer replaces it. See How ADE stops a GitHub App refresh storm.
  • apps/desktop/src/main/services/github/githubAppUserAuthService.tscreateGitHubAppUserAuthService, the shared factory that owns the App user token store (github.appUserToken.v1), the refresh lease, and getValidTokenForRelay (which renews inside a 2-minute skew). judgeStoredAuth is the single ladder every gate reads — missingfreshneeds_reauthblockedrefreshable, in that order — and credentialStateOf turns it into the credentialState the status DTO reports. Four callers used to run their own copy of that ladder in three different orders, and the orders disagreed. acquireRefreshLease judges every gate and takes the lease as one atomic updateKeySync, capturing the record it is about to POST inside the same step: capturing it from an earlier read leaves a window where a peer rotates the token first and the stale refresh token gets POSTed anyway, which GitHub answers by revoking the credential. Writes are compare-and-swap against the refresh token that was POSTed, so a sign-out or a device flow that finished mid-flight is never overwritten, and a failure that belongs to a replaced credential never marks the fresh one dead. Consumed by both desktop githubService and the ade-cli headless services. See How ADE stops a GitHub App refresh storm.
  • apps/desktop/src/main/services/github/githubAppUserAuthFailure.ts — why ADE cannot hand out an App user token, in terms every caller can act on. classifyRefreshFailure turns a failed refresh POST into one of dead_token / rate_limited / outage / network / unknown, and above all into the verdict dead. A credential is declared dead only on an explicit 401 or an OAuth error code that names the grant as rejected, because the cost is asymmetric: a wasted retry costs one request, while writing off a live credential costs the user their connection until they notice. GitHub answers a secondary rate limit with a bare 403 and often no body, which is the exact shape that used to read as a dead grant. GitHubAppUserAuthError carries the credentialState, the retryAt, and the failure; classifyAppUserAuthFailure maps it onto the GitHubAuthFailure kinds the status surfaces already speak, including the renewing kind for ADE waiting on its own lease. resolveAppUserTokenForRelay and resolveStoredAppUserTokenForRelay are the two shared lookups — the second skips the read entirely when no token is stored, because building a credential inventory is the hot path.
  • apps/desktop/src/main/services/github/githubAppUserAuthDeviceFlow.tscreateGitHubAppUserDeviceFlow, the pending browser sessions and the startDeviceAuth / pollDeviceAuth / clearSessions calls against GitHub's device endpoints. Split from the service because it shares nothing with the refresh ledger. It caps pending sessions at 5 (evicting oldest first), prunes expired ones, and returns a transport failure as a poll result rather than a thrown IPC error, so the polling UI can pace itself. A 429 from either endpoint is GitHub throttling ADE's sign-in, not a denied user, and is reported as GITHUB_SIGN_IN_RATE_LIMITED_COPY.
  • apps/ade-cli/src/services/credentials/credentialChangeRelayRepair.ts — ends the relay's auth-pending cooldown as soon as the credential it is waiting on changes on disk. The desktop app gets this for free (main.ts passes onAppUserAuthChanged into githubService, which calls automationIngressService.pollNow()), but the ADE brain owns neither service and the credential is written by whichever process ran the device flow — so bootstrap.ts installs this watcher over the shared machine credential file instead. Without it the brain sits out the full five-minute cooldown after a repair the user already finished. It watches the decrypted value of github.appUserToken.v1 rather than the file bytes, because whole-file re-encryption changes every byte on every write and account sessions rotate far more often than this credential does; an unreadable read fails open and polls anyway. One underlying watcher is shared per credential file per process, so ten open projects cost one stat cycle rather than ten, at a 2-second interval, and forced polls coalesce on the trailing edge of a burst (CREDENTIAL_CHANGE_POLL_COALESCE_MS, 5 s). Best-effort throughout: a store with no watcher leaves the behaviour exactly as it was.
  • apps/desktop/src/main/services/github/githubService.ts (getAppInstallationStatus, getAppUserAuthStatus, startAppUserDeviceAuth, pollAppUserDeviceAuth, clearAppUserAuth) and apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx — desktop surface for installing / checking "ADE for GitHub" per repo, authorizing the App via device flow, and disconnecting it again (Settings and onboarding). githubService takes an optional onAppUserAuthChanged callback and fires it whenever the stored App credential is replaced or removed; main.ts wires it to the ingress poll. The wiring lives with the owner that holds both services rather than inside either one, so the ingress loop stays free of GitHub internals.

ADE Actions registry

  • apps/desktop/src/main/services/adeActions/registry.ts — curated allowlist of (domain, action) pairs exposed to automation rules as the ade-action action type. Each domain maps to a main-process service (lane, git, pr, issue, chat, linear_*, file, pty, etc.); the allowlist keeps the surface deterministic and audit-able. listAllowedAdeActionNames and isAllowedAdeAction gate runtime dispatch.

Renderer

  • apps/desktop/src/renderer/components/automations/ — the /automations surface, rebuilt into a Linear-grade master/detail builder on the app's semantic theme tokens. See ui-design.md for the design brief.
    • Page shells. AutomationsPage.tsx — shell + AutomationsProductionGate (reads AppInfo.automationsEnabled) + the template-draft mailbox read. AutomationsWorkspace.tsx — master/detail: left rule list, right pane switching between Builder and History via a segmented control. AutomationsTemplatesPage.tsx — the /automations/templates route hosting templates/TemplateGallery. AutomationsComingSoon.tsx — the disabled-build screen.
    • Shared data + copy. designTokens.ts (semantic token class strings), automationCopy.ts + automationCopy.test.ts (buildRuleSentence grammar and trigger/action/disposition labels), cronDescribe.ts + test (cron → human gloss), triggerCatalog.ts (trigger sources → events → filter kinds, incl. lane.merged; each source carries a brand accent hex, with sourceAccent/accentTint helpers for tints), actionCatalog.ts (step kinds + add-menu, incl. delete-lane; each step kind carries an accent hex), variableCatalog.ts ({{trigger.*}} variables per source), localAutomationConfig.ts (string consts), shared.ts (extractError/parseList), permissionControls.ts, linearIngressApi.ts (defensive window.ade.automations.linearIngress probe shared by TriggerCard, IngressStatusStrip, and any caller that must degrade to a settings link when a remote runtime is too old to expose the Linear ingress IPC).
    • list/ — left rail: RuleList.tsx (header, search, ingress strip, rows, empty state, and the shared-config trust banner — rendered only when configTrustRequired and at least one rule has a non-local source, with a Trust config button that calls projectConfig.confirmTrust), RuleRow.tsx (sentence row with toggle/status/next-run/hover actions; shows a per-source amber warning glyph — titled with the delivery setupError — only when an enabled rule's trigger source has no ready delivery path, and stamps the schedule hint with the trigger source's brand icon/accent), RuleSentence.tsx (trigger→steps clauses), AutomationsEmptyState.tsx (first-visit flagship template cards, each with a source-accented SourceIconBadge and TemplateSourceChip).
    • builder/RuleBuilder.tsx (header actions + vertical step stack; threads onIngressChanged down so a successful in-card Linear connect re-fetches ingress status), TriggerCard.tsx (source picker with per-source brand icons/accents; renders TriggerDeliveryCallout — an amber callout with the delivery setupError plus an Open GitHub settings / Connect Linear / Open Linear settings action — only when the selected source's delivery[key].ready is false), ScheduleEditor.tsx, StepStack.tsx / StepCard.tsx (stacked steps + inserters + terminal cleanup zone, alwaysRun badge; step icons tinted by the step kind's accent), AgentStepEditor.tsx (prompt + model/effort/permission + lane targeting), LaneTargeting.tsx (new lane / existing lane / no lane), VariableMenu.tsx (insert-at-cursor {{trigger.*}} picker), and draftBridge.ts + test (the AutomationRuleDraft ⇆ built-in-actions ⇆ agent-session normalization ported verbatim from the old editor).
    • AdeActionEditor.tsx + adeActionSchemas.ts — the ADE Actions step editor and its curated parameter schema. adeActionSchemas.ts drives typed forms (string / string-array / number / boolean / enum / json) with {{trigger.*}} hints; the runtime allowlist still lives in apps/desktop/src/main/services/adeActions/registry.ts — this file adds presentation metadata only, not dispatch surface.
    • GitHubTriggerFilters.tsx / LinearTriggerFilters.tsx — per-trigger filter editors (labels, authors, target branch, title/body regex, repo, team, project, assignee).
    • history/RuleHistory.tsx (per-rule runs list + detail), RunRow.tsx, RunDetail.tsx (per-step results with lane/chat/PR deep links, queue/verify state).
    • templates/TemplateGallery.tsx, TemplateCard.tsx, TemplateSourceChip.tsx (exports SourceIconBadge, a source-accented icon badge, and TemplateSourceChip, a "Source · Event" chip with the source's brand icon — both derive their color from triggerCatalog's per-source accent and are shared by TemplateCard and AutomationsEmptyState), templateData.ts (grouped flagship + reworked templates), templateIcons.ts, and draftHandoff.ts — a module-scoped mailbox that carries a seeded draft from the templates route to AutomationsPage because the project tab host renders routes from a stored route string and strips location.state.
    • settings/IngressStatusStrip.tsx — the left-rail ingress status strip: GitHub path (App / relay / polling) plus Linear connect/status. Uses the shared linearIngressApi probe and the LinearMark brand glyph (components/lanes/linearBrand) for the Linear row.
  • apps/desktop/src/renderer/components/usage/ — header Usage popup (HeaderUsageControl, UsageLimitsBand) that hosts live provider quotas + the collapsible automation guardrails. BudgetCapEditor, UsageMeter, UsagePacingBadge, and CostSummaryCard continue to live under components/settings/ but are rendered from the popup. Settings > Usage is a separate retrospective cross-client ADE activity dashboard and does not host automation guardrails.
  • apps/desktop/src/renderer/components/chat/AgentChatPane.tsx — agent-session execution surfaces as a chat thread filtered by automation owner.

IPC and runtime RPC

  • apps/desktop/src/preload/global.d.tswindow.ade.automations surface. Beyond pollGithubNow it now exposes listScheduledCleanups() / cancelScheduledCleanup(id) and a linearIngress sub-object (getStatus / setup / teardown / pollNow).
  • apps/desktop/src/main/services/ipc/registerIpc.ts — registers automations:* channels including the ADE Actions registry read, GitHub polling trigger, the registry-backed runAdeAction dispatch, the deferred-cleanup reads (automationsListScheduledCleanups / automationsCancelScheduledCleanup), and the Linear ingress channels (automationsLinearIngressGetStatus / Setup / Teardown / PollNow, which resolve the runtime's linearIngressService and throw when it is absent). Each call routes through the active project binding's runtime connection (local runtime for local projects, SSH-tunneled JSON-RPC for remote projects) so the same automation rule edits or run-history reads apply to whichever runtime owns the project.
  • ADE Actions gating. The same automations surface is reachable as ade-action steps: ADE_ACTION_ALLOWLIST.automations adds listScheduledCleanups, cancelScheduledCleanup, linearIngressGetStatus/Setup/Teardown/PollNow, but ADE_ACTION_CTO_ONLY.automations restricts linearIngressSetup and linearIngressTeardown to CTO-authored dispatch (they create/delete a real Linear webhook against the user's workspace). Status, poll, and cleanup reads stay open to ordinary automation agents.
  • apps/ade-cli/src/multiProjectRpcServer.ts — exposes the same automation surface as JSON-RPC actions so the headless ADE CLI can manage rules, fire manual runs, and read run history without the desktop UI.

Core model

Each AutomationRule carries:

  • id, name, description, enabled.
  • triggers — one or more trigger descriptors (see triggers-and-actions.md). Normalized to a single primary trigger for legacy compatibility.
  • execution — which surface launches. AutomationExecution:
    • { kind: "agent-session", targetLaneId?, laneMode?, laneNamePreset?, laneNameTemplate?, session? } — launches a scoped AI chat thread, recorded as an automation-only chat. laneMode: "create" creates one lane for the run; its custom name template supports {{trigger.*}} plus {{date}} (YYYY-MM-DD), {{time}} (HH:mm), and {{rule.name}}. session carries optional title, reasoningEffort, and codexFastMode (boolean); codexFastMode is forwarded to the chat service only when the resolved provider is Codex and the model supports fast mode, so it is safe to set on a rule that may later switch models.
    • { kind: "built-in", targetLaneId?, builtIn: { actions: [...] } } — runs ADE-native deterministic actions (AutomationAction[]).
  • executor — always { mode: "automation-bot" } (the automation system identifies itself that way in logs).
  • reviewProfilequick | incremental | full | security | release-risk | cross-repo-contract. Drives confidence base and output expectations.
  • toolPalette — explicit tool family list (repo, git, tests, github, linear, browser).
  • contextSources — e.g. recent PRs or configured project context sources.
  • guardrailsconfidenceThreshold, maxDurationMin, requireHuman, path/lane allowlists (see guardrails.md).
  • outputs.dispositioncomment-only | open-task | open-lane | prepare-patch | open-pr-draft.
  • verificationverifyBeforePublish + mode (e.g. intervention for human approval).
  • billingCode — tracks spend per rule (default auto:<id>).

Trigger classes

Automations support two broad trigger classes:

  1. Time-basedschedule with a 5-field cron expression. computeNextScheduleAt walks forward in 1-minute steps (bounded at ~1 year) to find the next match using parseCronPart for *, */N, ranges, and lists.
  2. Action-basedmanual, git.commit, git.push, git.pr_opened, git.pr_updated, git.pr_closed, git.pr_merged, lane.created, lane.archived, lane.merged, file.change, session-end, webhook, github-webhook, various linear.* events.

The commit trigger is an alias for git.commit (normalized by normalizeTriggerType).

Current action coverage is intentionally focused — the runtime semantics stay predictable and easy to debug. See triggers-and-actions.md for the full trigger and action surface.

Execution surfaces

agent-session

Best for lightweight autonomous text-work: reviews, audits, short summaries, status checks.

  • Launches through agentChatService.createSession with the rule's prompt template and allowed tools.
  • Records the session as an automation-scoped chat.
  • Appears in Automations > History as a thread.
  • Minimal orchestration overhead — no planner, no run-graph, no worker pool.

built-in

Best for deterministic ADE operations.

  • Runs a sequence of AutomationAction steps with typed input/output.
  • AutomationActionType values: create-lane (spawns a new lane and threads it into the rest of the chain), delete-lane (immediate or deferred cleanup), run-command (shell), run-tests, predict-conflicts, agent-session (embedded agent step), ade-action (see below).
  • Each action may override targetLaneId for that step alone; agent-session actions additionally accept modelConfig and permissionConfig overrides that layer on top of the rule's defaults (allowed-tool lists are merged, not replaced). alwaysRun: true gives a trailing action finally semantics after an earlier non-continuable failure; the original failure remains the run's overall status. See triggers-and-actions.md for the override resolution order.
  • No separate worker thread.
  • Low overhead; sandboxed to the target lane's worktree via validateAutomationCwd and resolvePathWithinRoot.

The ade-action action type dispatches directly into a main-process domain service through the ADE Actions registry (apps/desktop/src/main/services/adeActions/registry.ts). RunAdeActionConfig points at a domain + action on the allowlist (e.g. pr.addComment, linear_sync.runSyncNow, issue.close), with args that may embed {{trigger.*}} placeholders resolved from the trigger context at dispatch time, or an explicit resolvers map for the same. This gives built-in rules typed access to ADE services without writing a shell command or a bespoke tool.

Lane lifecycle and cleanup

lane.merged is emitted when onPullRequestChanged observes a PR transition into merged. The trigger carries the lane id/name/branch and structured PR number, URL, title, repo, head/base branch, and merged state, so action templates can use both lane and PR context. notifyLaneMerged is also public for callers that already have a complete merge notification. A persistent kv marker keyed by project, PR identity, and PR number prevents the same merge from dispatching twice across restarts.

delete-lane resolves only an explicit action/rule target, a lane created for the current run, or the trigger lane. It fails instead of guessing when none exists. With afterMinutes > 0, the action writes an automation_scheduled_cleanups row and succeeds with a scheduled result; otherwise it calls laneService.delete immediately with deleteBranch, deleteRemoteBranch, and force options.

The service sweeps due cleanup rows at startup and every 60 seconds. Completed or failed cleanup is appended as another delete-lane action result on the originating run, so it remains visible in existing run history; a cleanup failure also leaves that run failed. A lane that is already gone is recorded as a successful no-op. listScheduledCleanups() exposes all statuses, and cancelScheduledCleanup(id) changes only a still-scheduled row to cancelled.

Cron scheduling

automationService uses node-cron for in-process cron tasks. Each enabled schedule rule installs a CronTask that fires triggerRun on match. computeNextScheduleAt lets the UI preview the next fire time.

Stability rules:

  • Cron tasks are stopped on rule disable or delete.
  • Tasks are re-installed on restart by re-reading enabled rules.
  • Seconds are not supported (the field parser expects 5 fields).
  • sunday = 0 or sunday = 7 both match; parseCronPart handles the aliasing.

File-change triggers

file.change triggers use chokidar to watch paths under the target lane's worktree (or project root if no lane). WatchedFileRoot scopes the watcher per lane. Changes are debounced and posted to triggerRun with the matched paths.

globToRegExp and matchesGlob are the primitives for path matching. escapeRegExp is used by the legacy path-list matcher.

Webhook, relay, and polling ingress

Automations accept inbound events from four sources (AutomationIngressSource):

  • local-webhookautomationIngressService opens an HTTP endpoint.
    • github-webhook events verify HMAC-SHA256 via safeCompareSignature (timing-safe). Secret read from automations.githubWebhook.secret.
    • webhook events are custom inbound webhooks with optional shared-secret verification.
  • github-relay — the default hosted path. A Cloudflare Worker (apps/webhook-relay/) receives GitHub App webhooks, verifies the GitHub HMAC signature, and writes each delivery into D1. ADE connects to the repo-scoped GET /github/repos/:owner/:repo/subscribe WebSocket and drains GET /github/repos/:owner/:repo/events?after=<cursor>&order=asc&limit=100 on connect and each github_delivery frame. Returned pages are processed oldest-first without reversal, and hasMore immediately continues the drain. The durable cursor is persisted once per page, after every event in it has been attempted. Linked PR ids are attached to that commit boundary: a successful drain batches them into one targeted PR refresh, while a later page/transport failure still flushes ids from pages whose cursors were already committed. A per-event ingest or dispatch failure does not stall the drain: it is caught, logged (automations.github_relay_pr_ingest_failed / automations.github_relay_dispatch_failed), and the cursor still advances past that event — the delivery is already durably recorded relay-side and the background PR poller corrects PR state independently, so a single poison event can no longer replay from the same cursor forever and freeze all ingest for the repo. Only a page/transport failure (non-OK response, fetch abort, or a nextCursor that fails to advance) throws out of the loop, leaving the durable cursor at the prior page so that page replays on the next drain. Such failures mark relay health false, respect Retry-After, and enter an exponential 30-second-to-15-minute poll cooldown; a successful drain clears the cooldown and marks the relay healthy again. Socket reconnects use their own jittered exponential backoff with fresh auth and a catch-up drain. The interval poll remains a safety net: the configured 30-second cadence applies while the socket is down, stretching to five minutes while connected, subject to the failure cooldown. Hosted relay reads and subscriptions use either an expiring GitHub App user access token created through GitHub device flow or the existing ADE account-token path, never the user's general ADE GitHub PAT/OAuth/gh auth token. Account-authenticated event/subscription requests use the account's installed repository binding without a GitHub API round-trip; the App user token path remains the legacy fallback and requires push/write, maintain, or admin access. Read-only public-repo callers are rejected with 403. The same Worker also exposes GET .../status plus two repo-scoped webhook-maintenance routes for drift recovery and diagnostics — POST .../webhook/heal (admin-gated re-sync of the App's webhook secret) and GET .../webhook/deliveries (push-gated, repo-filtered proxy of the App delivery log); see the source file map above. The relay base URL defaults to DEFAULT_GITHUB_RELAY_API_BASE_URL. The legacy automations.githubRelay.apiBaseUrl + remoteProjectId + accessToken project-token routes (/projects/:projectId/github/...) remain poll-only for self-hosted relays — chosen only when a non-default base URL plus project id and access token are all set (shouldUseLegacyGitHubRelayProjectRoute) and do not require GitHub App user authorization.
  • linear-relay — Linear event relay for automation triggers; Linear triggers here are context-only. Two delivery modes share the relay: a per-workspace webhook created by linearIngressService.setup() (requires a workspace-admin credential; per-org signing secret registered in the Worker's D1), or the ADE Linear OAuth app (linearAppClient.ts — client id bundled, PKCE, read,write,admin scope), whose webhook Linear auto-provisions on authorization and signs with the app-level LINEAR_APP_WEBHOOK_SECRET the Worker holds. App-connected projects self-configure on the first poll (isAdeAppConnection dep) — no manual connect step; teardown never deletes the app's webhook.
  • github-pollinggithubPollingService polls the GitHub REST API directly for the origin repo and any extraRepos, diffing per-poll snapshots to synthesize github.issue_* / github.pr_* events (opened / edited / labeled / closed / commented, and PR merged). No relay or webhook infra required. Cursor is a <slug>=<iso>|<slug>=<iso> string stored via automationService.setIngressCursor({ source: "github-polling" }); default interval is 30s, but each tick returns before network access when no enabled rule has a github.* trigger.

Ingress events normalize to AutomationIngressEventRecord with source, eventKey, triggerType, summary, plus cursor for relay/polling replay. Matching rules are resolved by eventKey-to-rule-id mapping. An optional repo filter on a rule's trigger (e.g. github.issue_opened with repo: "owner/name") restricts dispatch when multiple repos are polled. The record no longer carries the raw payload — ADE keeps only the normalized fields — and the automation_ingress_events row is bounded at write time (7 days, and the newest 2,000 non-dispatched rows per project), with the storage doctor sweeping the same table on its schedule. See Storage and recovery.

Enabling an externally triggered rule is capability-gated by the paths that can actually fire it, computed once per status read by computeDeliveryStatuses. It resolves the live availability of each path (relay configured, GitHub polling capable, local webhook listening/healthy, public gateway ready, Linear ingress capable) and returns an AutomationIngressDelivery — one AutomationTriggerDeliveryStatus (ready, the winning via path, and a human setupError) per source class: github, githubWebhook, webhook, and linear. Canonical github.* rules are ready if any of relay, direct polling, local webhook, or public gateway is available (in that preference order); github-webhook needs relay, local webhook, or public gateway; custom webhook needs local webhook or public gateway; linear.* needs the injected Linear-ingress capability. triggerDeliveryKeyForType(type) (shared/types/automations.ts) maps a trigger type to its delivery key (github.* and legacy git.pr_*github, linear.*linear, github-webhookgithubWebhook, webhookwebhook, everything else → null), and getIngressSetupError(rule) returns the first not-ready source's setupError — so the message points to the corresponding Automations setup rather than requiring ADE Webhook Gateway for every external trigger. getIngressStatus() returns the whole AutomationIngressDelivery as an optional delivery field (optional for compatibility with remote runtimes built before per-trigger delivery reporting); the renderer reads it to gate the TriggerCard/RuleRow delivery callouts. There is no separate "does this rule need external ingress" allowlist to keep in sync — the reconcile loop calls getIngressSetupError for every enabled rule and lets a null result mean "no external ingress required".

How ADE stops a GitHub App refresh storm

The GitHub App user credential is one record per machine, and its refresh token rotates: each successful refresh invalidates the token that was POSTed. GitHub treats a reuse of a rotated refresh token as theft and revokes the whole grant. So the number of processes allowed to POST that refresh is one.

A machine runs many more than one. The desktop app builds a service per project scope, the brain builds another per project scope, and the ade CLI builds its own. Each of them used to coordinate through a per-instance in-flight promise, which coordinates nothing across processes: they all POSTed the same refresh token, GitHub's rotation-reuse detection revoked the credential, and every process then retried the dead token until GitHub rate-limited the OAuth host. Four mechanisms close that, and all four are load-bearing.

One file. github.appUserToken.v1 is a file-backed credential key (FILE_BACKED_CREDENTIAL_KEYS), so it lives in the shared machine credential file that the app, the brain, and the CLI all read. Two copies of the record means two independent rotations. See ARCHITECTURE §8.1.

One ledger, inside the credential. The refresh ledger is stored in the same record as the token, so a process cannot read the credential without also reading the backoff and the lease that apply to it. A sidecar file would let a process read one and miss the other.

One lease. acquireRefreshLease judges every gate and takes a 60-second lease as a single atomic key update. A process that finds the lease held waits for the holder in 250 ms steps, bounded at roughly three seconds, then serves whatever the store now holds. A peer still holding the lease at the end of a wait is remembered process-locally, so one slow refresh does not make every project scope in the app repeat the same three-second poll.

One backoff, shared. A failed refresh writes notBeforeAt into the ledger: exponential from 60 s, capped at one hour, and never shorter than GitHub's own retry-after. Every process reads that deadline, so a failure costs the machine one request rather than one request per process.

Only an explicit 401 or an OAuth error code naming the grant as rejected marks the credential dead. Everything else keeps it and backs off. The asymmetry is the reason: a wasted retry costs one request, while writing off a live credential costs the user their connection until they notice and re-authorize by hand — against the same OAuth endpoint that was already refusing them.

On top of the ledger, both request paths hold a resolved credential for GITHUB_CREDENTIAL_CACHE_TTL_MS (30 s) through shared/expiringPromiseCache.ts, which caches the in-flight promise rather than the value so concurrent callers join one read. Every status read, PR call, and relay poll asks for a credential, and on a lapsed access token that ask is a refresh POST. The headless twin had no such window at all, which is why the brain — not the desktop app — drove most of the refresh traffic.

Queue and confidence

Automation runs that require review (confidence below threshold, verifyBeforePublish, or explicit requireHuman) land in a queue:

  • AutomationRunQueueStatus: pending-review, actionable-findings, verification-required, completed-clean, ignored, archived.
  • AutomationConfidenceScore: value 0..1, label low | medium | high, reason string.
  • computeConfidence(rule, procedureCount) blends the review profile's base value with context-source and procedure boosts minus a threshold penalty.

The queue dashboard renders severity summaries and suggested actions so operators can triage without opening each run.

Output disposition

Automations route outputs based on outputs.disposition:

  • comment-only — write a comment to the automation log or PR.
  • open-pr — open a draft PR from the target lane.
  • linear-comment — post a Linear comment (uses the project's shared Linear client).
  • in-app-notification — push a desktop notification.
  • evidence-only — leave the run record; no external output.

createArtifact: true records proof evidence for indexing. notificationChannel lets a rule override the default channel.

Budget policy

  • Budget caps come from the header Usage popup → Automation guardrails. Rule-level caps via guardrails.maxDurationMin prevent runaway runs.
  • Usage telemetry respects billingCode so operators can slice spend per rule.

Boundaries

  • No autonomous Linear dispatch. There is no CTO workflow engine to compete with; Linear triggers in automations are context-only. If a rule needs to act on an issue it does so with an explicit action (comment, state update) through the shared Linear client — nothing auto-routes issues to agents.
  • Built-in actions are deterministic. They should not wrap an AI call. Use agent-session for AI-driven logic.

Gotchas

  • Legacy trigger vs triggers. Rules can carry either; the service normalizes via normalizedRuleTriggers and primaryTrigger. When writing new code read from rule.triggers.
  • commit is aliased to git.commit by normalizeTriggerType. Rules persisted with commit still work but the dispatcher treats them as git.commit.
  • Legacy git.pr_* triggers alias to github.pr_*. LEGACY_GITHUB_PR_TRIGGER_ALIASES is the authoritative mapping; the canonical names are github.pr_opened, github.pr_updated, github.pr_merged, github.pr_closed. Prefer the canonical names in new code and UI.
  • lane.merged is distinct from github.pr_merged. It is lane-scoped, supports the same namePattern glob as other lane lifecycle triggers, and uses a persistent per-PR marker. Do not bypass notifyLaneMerged with a raw dispatch or the restart-safe dedupe is lost.
  • Deferred cleanup is attached to the original run. The sweeper appends an action result instead of creating a second run; history readers must tolerate actions_total increasing after the original chain ends.
  • Polling cursor format is sticky. githubPollingService.readCursor must handle three historical shapes: bare <iso> (first-ever poll, legacy), single <slug>=<iso> (new single-repo), and multi-repo <slug>=<iso>|<slug>=<iso>. Don't simplify the parser without a migration path.
  • Cron sanity-check before installing. cron.validate(expr) plus the 5-field split is the safety net; otherwise node-cron throws.
  • Webhook secret verification is timing-safe. Don't refactor safeCompareSignature into a plain string compare.
  • Legacy relay polling must respect the access token ref. automations.githubRelay.accessToken is an env ref for self-hosted/project-token relays; resolve via automationSecretService, never hard-coded.
  • Relay wake-ups are not deliveries. The repo WebSocket only requests a cursor drain. Coalesce concurrent drain requests with one dirty rerun, process ascending pages in returned order, and persist each nextCursor after that page's events have all been attempted. Collect linked PR ids at the same page-commit boundary and reconcile them once per successful drain; on a later page failure, flush only ids from already committed pages. A per-event ingest/dispatch throw is caught and the cursor advances past it (a poison event must not freeze the repo's ingest); only a page fetch/transport failure or a non-advancing cursor aborts the drain and leaves the durable cursor pointing at the prior page. Those failures must also clear relay health and respect the poll cooldown so the PR poller returns to direct GitHub fallback without hammering the relay. Config/repo changes and service shutdown must close the old socket, clear health, and clear every reconnect/connect/poll timer.
  • Confidence threshold is 0.65 baseline. Rules that explicitly raise the threshold penalize confidence proportionally — document this in rule descriptions so operators understand scoring.
  • Shared-config trust only blocks shared rules. runRuleNow throws when projectConfig.trust.requiresSharedTrust is true and the rule's id appears in projectConfig.shared.automations (i.e. it is defined in .ade/ade.yaml). A rule authored in local config still runs when the shared config is untrusted. The Automations-tab banner and its Trust config CTA (which calls projectConfig.confirmTrust) likewise appear only when the rule list actually contains a non-local rule, so a project with no shared automations never sees a trust prompt.
  • triggers-and-actions.md — full trigger and action surface.
  • guardrails.md — approval gates, safety boundaries, verification modes.
  • ../linear-integration/README.md — the Linear read/write surface automations use for context and actions.
  • ../computer-use/README.md — automations can request computer-use proof.