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 (vianode-cron), durable deferred-lane cleanup, lane lifecycle dispatch, file-change watching (viachokidar), 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-localautomation_schedule_occurrencestable, 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_jsonis always writtennull), and on every insert it prunesautomation_ingress_eventsfor that project at write time — dropping rows older than 7 days and, for non-dispatchedrows, everything beyond the newest 2,000 (insert + prune run in oneBEGIN IMMEDIATE). At construction it runs a one-time, chunked reclaim that nulls any legacyraw_payload_jsonstill 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 fromstate/dbMaintenanceApiso 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 anAutomationRuleDraft. The Codex planner config carries optionalmodelandreasoningEffort:modelis a provider-native id, and omitting it means "whatever~/.codex/config.tomldefaults to" rather than a hardcoded fallback, whilereasoningEffortis 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.AutomationIngressEventRecordis the normalized event shape. AcceptsautomationService: nullfor the PR-freshness-only mode described under Runtime ownership: the relay still feedsprService.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 injectedingressCursorStore—createKvIngressCursorStore(db), which reads/writesautomations.ingress.cursor.<source>in the kv table — instead ofautomationService's cursor storage. Linked PR ids from relay deliveries are accumulated only after their page cursor commits, then flushed as one targetedprPollingService.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 byprPollingService; config removal, poll failure, and shutdown clear it so direct GitHub polling resumes. Page/transport failures honorRetry-Afterand 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 statusdisabled, a singleautomations.github_relay_auth_pendinginfo log). A signed-in machine keeps a broken App credential from disabling the subscription, because the account token can still carry the poll —noteHostedAuthFailurestarts the cooldown withoutenterHostedAuthPending'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 plusextraRepos. Each tick first asksautomationService.hasEnabledGithubRules()(or the injected equivalent) and does no GitHub work unless at least one enabled rule has a canonicalgithub.*trigger. Active ticks diff per-poll snapshots of issues/PRs/comments to emitgithub.issue_*andgithub.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; seereadCursor/writeCursorfor 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 thatsetup()creates through the shared Linear client (createWebhook/listWebhooks/deleteWebhook, resource typesIssue/Comment/IssueLabel), or the ADE Linear OAuth app's auto-provisioned webhook (sentinel idade-linear-app, surfaced asappManagedin status — never created or deleted here). Polls the relay'sseq:<n>cursor for new Linear deliveries and exposesgetStatus/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 WorkerDEFAULT_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), andcreateLinearAccessTokenGetter(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/statusand/eventsreads (monotonicseq:<n>cursors;order=asc&limitadds forward pagination with ahasMoreflag, while the default descending shape stays unchanged for old clients), and exposes/github/repos/:owner/:repo/subscribefor debounced WebSocket wake-up frames. The socket is only a hint; D1 plus the cursor remains the durable stream. Signed-in account requests to/eventsand/subscribeare 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. TheRepoEventsDurableObject(src/repoEventsDurableObject.ts, one hibernating instance per lowercasedowner/repo, bound asREPO_EVENTSinwrangler.jsoncwith 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 code4401to force credential revalidation, and answers an app-levelpingwithpongat the edge without waking. A webhook write'snotifyRepoEventsfailure 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 viaEVENT_RETENTION_DAYS);slimGitHubPayloadForStoragestrips the avatar-heavy top-levelsender/organization/enterpriseduplicates andcheck_run.outputfrom storedcheck_run/check_suite/workflow_run/statuspayloads (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 ownGITHUB_WEBHOOK_SECRETviaPATCH /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 itsrepository_idmatches the authorized repo; app-level ping/meta deliveries with no repository are kept). The sharedassertGitHubRepoAuthorizedgate takes awrite | adminaccess level and returns therepositoryIdused for that filter. Legacy/projects/:projectId/github/...project-token routes remain for self-hosted deployments. Seeapps/webhook-relay/README.mdfor 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) withusesHostedDefault;fetchGitHubAppInstallationStatusauthenticates the hosted repo status route with a GitHub App user access token viaresolveHostedGitHubRelayAuthToken(never the user's general GitHub token), falling back to the legacy project-token route only whenshouldUseLegacyGitHubRelayProjectRoute(non-default base URL + project id + access token).fetchAppInstallationStatusForRepois 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 reportsappUserAuthFailure(a typedGitHubAppUserAuthUnavailable) plus the account-shaped copy fromappUserAuthUnavailableCopy; any other status was answered with a credential the relay accepted, so it keeps the relay's own message. Also exposescreateGitHubRelayAuthAuditLog, a dedup wrapper that emits onegithub.hosted_relay_auth_token_usedaudit line per (event, route, repo, token source).apps/desktop/src/main/services/github/githubAppUserAuth.ts— raw GitHub device-flow HTTP helpers:startGitHubAppDeviceFlow,pollGitHubAppDeviceFlow, andrefreshGitHubAppUserTokenagainst GitHub's OAuth device endpoints, plus theADE_GITHUB_APP_CLIENT_IDconstant and theGitHubAppUserTokenRecordshape. Failures raise a typedGitHubOAuthErrorcarrying the endpoint, the HTTP status, the OAutherrorcode, the description, andretry-afterin seconds. The type matters because GitHub answers a rejected refresh token with HTTP 200 plus anerrorfield, so status alone cannot tell a dead credential from a healthy response, and it answers a throttled client with 429 plusretry-after, the only honest source for how long to wait. Use theisGitHubOAuthErrorpredicate rather thaninstanceof: the desktop service and the headless twin load this module through different paths, and a cross-realminstanceofanswers 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.RefreshLedgerholdsnotBeforeAt(no refresh before this instant),consecutiveFailures,dead,leaseUntil/leaseHolder, agenerationcounter for cross-process log correlation, andlastFailure. It lives inside the credential record so every process that reads the credential also reads the backoff that applies to it.readIsoActiveWithinis 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.ts—createGitHubAppUserAuthService, the shared factory that owns the App user token store (github.appUserToken.v1), the refresh lease, andgetValidTokenForRelay(which renews inside a 2-minute skew).judgeStoredAuthis the single ladder every gate reads —missing→fresh→needs_reauth→blocked→refreshable, in that order — andcredentialStateOfturns it into thecredentialStatethe status DTO reports. Four callers used to run their own copy of that ladder in three different orders, and the orders disagreed.acquireRefreshLeasejudges every gate and takes the lease as one atomicupdateKeySync, 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 desktopgithubServiceand 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.classifyRefreshFailureturns a failed refresh POST into one ofdead_token/rate_limited/outage/network/unknown, and above all into the verdictdead. 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.GitHubAppUserAuthErrorcarries thecredentialState, theretryAt, and the failure;classifyAppUserAuthFailuremaps it onto theGitHubAuthFailurekinds the status surfaces already speak, including therenewingkind for ADE waiting on its own lease.resolveAppUserTokenForRelayandresolveStoredAppUserTokenForRelayare 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.ts—createGitHubAppUserDeviceFlow, the pending browser sessions and thestartDeviceAuth/pollDeviceAuth/clearSessionscalls 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 asGITHUB_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.tspassesonAppUserAuthChangedintogithubService, which callsautomationIngressService.pollNow()), but the ADE brain owns neither service and the credential is written by whichever process ran the device flow — sobootstrap.tsinstalls 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 ofgithub.appUserToken.v1rather 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) andapps/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).githubServicetakes an optionalonAppUserAuthChangedcallback and fires it whenever the stored App credential is replaced or removed;main.tswires 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 theade-actionaction 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.listAllowedAdeActionNamesandisAllowedAdeActiongate runtime dispatch.
Renderer
apps/desktop/src/renderer/components/automations/— the/automationssurface, rebuilt into a Linear-grade master/detail builder on the app's semantic theme tokens. Seeui-design.mdfor the design brief.- Page shells.
AutomationsPage.tsx— shell +AutomationsProductionGate(readsAppInfo.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/templatesroute hostingtemplates/TemplateGallery.AutomationsComingSoon.tsx— the disabled-build screen. - Shared data + copy.
designTokens.ts(semantic token class strings),automationCopy.ts+automationCopy.test.ts(buildRuleSentencegrammar 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 brandaccenthex, withsourceAccent/accentTinthelpers for tints),actionCatalog.ts(step kinds + add-menu, incl.delete-lane; each step kind carries anaccenthex),variableCatalog.ts({{trigger.*}}variables per source),localAutomationConfig.ts(string consts),shared.ts(extractError/parseList),permissionControls.ts,linearIngressApi.ts(defensivewindow.ade.automations.linearIngressprobe shared byTriggerCard,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 whenconfigTrustRequiredand at least one rule has a non-localsource, with aTrust configbutton that callsprojectConfig.confirmTrust),RuleRow.tsx(sentence row with toggle/status/next-run/hover actions; shows a per-source amber warning glyph — titled with the deliverysetupError— 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-accentedSourceIconBadgeandTemplateSourceChip).builder/—RuleBuilder.tsx(header actions + vertical step stack; threadsonIngressChangeddown so a successful in-card Linear connect re-fetches ingress status),TriggerCard.tsx(source picker with per-source brand icons/accents; rendersTriggerDeliveryCallout— an amber callout with the deliverysetupErrorplus anOpen GitHub settings/Connect Linear/Open Linear settingsaction — only when the selected source'sdelivery[key].readyis false),ScheduleEditor.tsx,StepStack.tsx/StepCard.tsx(stacked steps + inserters + terminal cleanup zone,alwaysRunbadge; step icons tinted by the step kind'saccent),AgentStepEditor.tsx(prompt + model/effort/permission + lane targeting),LaneTargeting.tsx(new lane / existing lane / no lane),VariableMenu.tsx(insert-at-cursor{{trigger.*}}picker), anddraftBridge.ts+ test (theAutomationRuleDraft⇆ 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.tsdrives typed forms (string / string-array / number / boolean / enum / json) with{{trigger.*}}hints; the runtime allowlist still lives inapps/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(exportsSourceIconBadge, a source-accented icon badge, andTemplateSourceChip, a "Source · Event" chip with the source's brand icon — both derive their color fromtriggerCatalog's per-source accent and are shared byTemplateCardandAutomationsEmptyState),templateData.ts(grouped flagship + reworked templates),templateIcons.ts, anddraftHandoff.ts— a module-scoped mailbox that carries a seeded draft from the templates route toAutomationsPagebecause the project tab host renders routes from a stored route string and stripslocation.state.settings/IngressStatusStrip.tsx— the left-rail ingress status strip: GitHub path (App / relay / polling) plus Linear connect/status. Uses the sharedlinearIngressApiprobe and theLinearMarkbrand glyph (components/lanes/linearBrand) for the Linear row.
- Page shells.
apps/desktop/src/renderer/components/usage/— header Usage popup (HeaderUsageControl,UsageLimitsBand) that hosts live provider quotas + the collapsible automation guardrails.BudgetCapEditor,UsageMeter,UsagePacingBadge, andCostSummaryCardcontinue to live undercomponents/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.ts—window.ade.automationssurface. BeyondpollGithubNowit now exposeslistScheduledCleanups()/cancelScheduledCleanup(id)and alinearIngresssub-object (getStatus/setup/teardown/pollNow).apps/desktop/src/main/services/ipc/registerIpc.ts— registersautomations:*channels including the ADE Actions registry read, GitHub polling trigger, the registry-backedrunAdeActiondispatch, the deferred-cleanup reads (automationsListScheduledCleanups/automationsCancelScheduledCleanup), and the Linear ingress channels (automationsLinearIngressGetStatus/Setup/Teardown/PollNow, which resolve the runtime'slinearIngressServiceand 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-actionsteps:ADE_ACTION_ALLOWLIST.automationsaddslistScheduledCleanups,cancelScheduledCleanup,linearIngressGetStatus/Setup/Teardown/PollNow, butADE_ACTION_CTO_ONLY.automationsrestrictslinearIngressSetupandlinearIngressTeardownto 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 (seetriggers-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}}.sessioncarries optionaltitle,reasoningEffort, andcodexFastMode(boolean);codexFastModeis 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).reviewProfile—quick|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.guardrails—confidenceThreshold,maxDurationMin,requireHuman, path/lane allowlists (seeguardrails.md).outputs.disposition—comment-only|open-task|open-lane|prepare-patch|open-pr-draft.verification—verifyBeforePublish+mode(e.g.interventionfor human approval).billingCode— tracks spend per rule (defaultauto:<id>).
Trigger classes
Automations support two broad trigger classes:
- Time-based —
schedulewith a 5-field cron expression.computeNextScheduleAtwalks forward in 1-minute steps (bounded at ~1 year) to find the next match usingparseCronPartfor*,*/N, ranges, and lists. - Action-based —
manual,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, variouslinear.*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.createSessionwith 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
AutomationActionsteps with typed input/output. AutomationActionTypevalues: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
targetLaneIdfor that step alone;agent-sessionactions additionally acceptmodelConfigandpermissionConfigoverrides that layer on top of the rule's defaults (allowed-tool lists are merged, not replaced).alwaysRun: truegives a trailing action finally semantics after an earlier non-continuable failure; the original failure remains the run's overall status. Seetriggers-and-actions.mdfor the override resolution order. - No separate worker thread.
- Low overhead; sandboxed to the target lane's worktree via
validateAutomationCwdandresolvePathWithinRoot.
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 = 0orsunday = 7both match;parseCronParthandles 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-webhook—automationIngressServiceopens an HTTP endpoint.github-webhookevents verify HMAC-SHA256 viasafeCompareSignature(timing-safe). Secret read fromautomations.githubWebhook.secret.webhookevents 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-scopedGET /github/repos/:owner/:repo/subscribeWebSocket and drainsGET /github/repos/:owner/:repo/events?after=<cursor>&order=asc&limit=100on connect and eachgithub_deliveryframe. Returned pages are processed oldest-first without reversal, andhasMoreimmediately 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 anextCursorthat 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, respectRetry-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 authtoken. 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 exposesGET .../statusplus two repo-scoped webhook-maintenance routes for drift recovery and diagnostics —POST .../webhook/heal(admin-gated re-sync of the App's webhook secret) andGET .../webhook/deliveries(push-gated, repo-filtered proxy of the App delivery log); see the source file map above. The relay base URL defaults toDEFAULT_GITHUB_RELAY_API_BASE_URL. The legacyautomations.githubRelay.apiBaseUrl+remoteProjectId+accessTokenproject-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 bylinearIngressService.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,adminscope), whose webhook Linear auto-provisions on authorization and signs with the app-levelLINEAR_APP_WEBHOOK_SECRETthe Worker holds. App-connected projects self-configure on the first poll (isAdeAppConnectiondep) — no manual connect step; teardown never deletes the app's webhook.github-polling—githubPollingServicepolls the GitHub REST API directly for the origin repo and anyextraRepos, diffing per-poll snapshots to synthesizegithub.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 viaautomationService.setIngressCursor({ source: "github-polling" }); default interval is 30s, but each tick returns before network access when no enabled rule has agithub.*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-webhook → githubWebhook, webhook → webhook, 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, labellow|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.maxDurationMinprevent runaway runs. - Usage telemetry respects
billingCodeso 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-sessionfor AI-driven logic.
Gotchas
- Legacy
triggervstriggers. Rules can carry either; the service normalizes vianormalizedRuleTriggersandprimaryTrigger. When writing new code read fromrule.triggers. commitis aliased togit.commitbynormalizeTriggerType. Rules persisted withcommitstill work but the dispatcher treats them asgit.commit.- Legacy
git.pr_*triggers alias togithub.pr_*.LEGACY_GITHUB_PR_TRIGGER_ALIASESis the authoritative mapping; the canonical names aregithub.pr_opened,github.pr_updated,github.pr_merged,github.pr_closed. Prefer the canonical names in new code and UI. lane.mergedis distinct fromgithub.pr_merged. It is lane-scoped, supports the samenamePatternglob as other lane lifecycle triggers, and uses a persistent per-PR marker. Do not bypassnotifyLaneMergedwith 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_totalincreasing after the original chain ends. - Polling cursor format is sticky.
githubPollingService.readCursormust 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; otherwisenode-cronthrows. - Webhook secret verification is timing-safe. Don't refactor
safeCompareSignatureinto a plain string compare. - Legacy relay polling must respect the access token ref.
automations.githubRelay.accessTokenis an env ref for self-hosted/project-token relays; resolve viaautomationSecretService, 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
nextCursorafter 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.65baseline. 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.
runRuleNowthrows whenprojectConfig.trust.requiresSharedTrustis true and the rule'sidappears inprojectConfig.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 itsTrust configCTA (which callsprojectConfig.confirmTrust) likewise appear only when the rule list actually contains a non-localrule, so a project with no shared automations never sees a trust prompt.
Cross-links
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.