Automations and webhooks for the AT Protocol airglow.run
automation webhook atproto atprotocol
airglow CONCEPTS.md
21 kB
Markdown
at main

Concepts #

Shared domain vocabulary for this project. Entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.

Identity & sessions #

The signed, HTTP-only cookie holding the viewer's DID. Authenticates the browser to Airglow's UI, separate from the OAuth Session and the Grant. Clearing it signs the user out of the UI but does not stop background work that depends on the OAuth Session.

OAuth session #

Airglow's locally-stored representation of a user's AT Protocol OAuth authorization: the DPoP keypair, the access token, and the refresh token. Distinct from the Airglow Session Cookie (a UI authentication marker) and from the Grant (the authorization at the PDS). The OAuth Session is what background workers carry when they act on a user's behalf.

Grant #

Short for OAuth grant. The authorization held by the PDS that lets Airglow act on a user's behalf via the OAuth Session's tokens. The Grant lives at the PDS, not in Airglow. It is created on first sign-in, revoked when the user explicitly revokes from /settings/account, and may be revoked independently at the PDS by the user. A live Grant with no local OAuth Session is possible; a local OAuth Session with a dead Grant is also possible.

Session-missing #

The state where a Viewer holds a valid Airglow Session Cookie but has no OAuth Session in the local store. Indicates the PDS-side Grant was revoked or expired and Airglow's background workers can no longer act on their behalf. Surfaces a re-authorization banner across signed-in surfaces.

Audit event #

A row in the auth-events log capturing security-relevant state changes (sign_in, sign_out, oauth_session_created, oauth_session_refreshed, oauth_session_revoked, session_loss_detected, session_reactivated). Each event is a state claim: recording oauth_session_revoked asserts that the Grant is no longer usable from this DID via Airglow. Audit events are surfaced by /settings/security.

Roles #

Owner #

For a given resource (an Automation, a profile), the AT Protocol identity whose DID matches the resource's did field. Owner-vs-Viewer is a server-side predicate that controls which UI affordances render and which fields of the underlying record are exposed.

Viewer #

The currently-authenticated identity making a request, if any. May or may not be the Owner of the resource being viewed. Anonymous viewers (no cookie) are also a valid state — most surfaces render adaptively for the three cases (Owner, signed-in non-Owner, anonymous).

Automation domain #

Automation #

The central user-authored entity. An AT Protocol record (NSID run.airglow.automation) holding a Trigger and an ordered tree of Steps to run when the trigger matches. Lives on the user's PDS, indexed by Airglow; the local index is authoritative for execution and the PDS copy is a converged projection. Automations fire from the background worker, using the Owner's OAuth Session to authenticate any outgoing repo writes.

Trigger #

What starts a Run. Currently one kind: a PDS event trigger (a lexicon NSID, the commit operations to watch, and optional wanted DIDs). An open union in the record so future kinds (inbound webhook, periodic pull) can be added without a shape break.

Step #

One node of an Automation's tree. Kinds: condition (Gate or Branch), fetch, search, variable, Loop, and the Action types. Every Step carries a stable opaque id (log and scheduled-row identity, excluded from the Behavior hash); output-producing Steps carry a user-visible name that later templates reference ({{name.record.field}}). A step may reference outputs of any step lexically before it at its own or an ancestor level.

Gate #

A condition Step with no then-branch. When its assertions fail, the Run ends (or, inside a Loop body, the current item is skipped). Gate misses write no log row, but every miss that ends a Run, Trigger gate misses included, is counted per Automation per day (automation_filter_counts) so the owner can tell a rejected event from one that never arrived. The leading run of event-only Gates is the Trigger gate: it is evaluated on the Jetstream hot path as part of matching, in live and dry-run mode alike, before any handler work.

Branch #

A condition Step with a then list (and optionally an else list). The matching side's child steps run, then flow continues after the step (the branches rejoin). Names defined inside a branch are visible after the rejoin but may be unset on the path that did not run; an Action referencing an unset name Skips instead of firing with blanks.

Loop #

A Step that runs its child steps once per item resolved from an array path (e.g. event.commit.record.facets[].features[]). Inside the body the current item is addressable via its item alias (default item); body-defined names are loop-scoped and reset per iteration. The Loop's own name exposes the last successful terminal-action result's scalars plus a results[] array to later steps. Item count, per-run side effects, network calls, and total step executions are all capped.

Action #

An executable Step with an external side effect (or a processor that only transforms data). Project-specific types include webhook (POST a signed payload), create-record (write to the Owner's PDS), and post-to-bluesky. Some Action types require the broader transition:generic OAuth scope; others can run on the basic repo:run.airglow.automation scope — an Action anywhere in the tree requires its scope, branch placement included.

Record template #

The body an Action writes as a record, authored as JSON with Placeholders standing in for values resolved at Run time. Stored as text on the Step rather than as structured data.

A record template is not required to be strict JSON: a Placeholder may stand in for a whole value, which is how an array or object gets injected at Run time. Validation therefore checks the shape with Placeholders stripped, and an editor that parses a template must hand back one it cannot parse rather than rewriting it.

Placeholder #

A {{path}} reference resolved at Run time against the triggering event and the named outputs of earlier Steps. Position decides meaning: inside a quoted string it interpolates as text, and outside one it stands in for an entire value.

Run #

One evaluation of an Automation's tree for one matched event, identified by a run id that groups its Delivery rows. A failed Action or exhausted budget breaks the Run (later steps do not execute); a Gate stop ends it as a non-event; a Skip is not a break. Replaces the pre-steps "Action chain" concept.

Suspended run #

A Run parked at a Step that cannot answer inline, today only the AI agent step. The walker stops at that Step, saves the cursor and the values it has produced so far, and a background lane resumes the remaining Steps once the answer arrives. The event is not recorded as handled at the suspension, so the Run's tail writes the handled record on the same terms an uninterrupted Run would. A Run that is still parked blocks a replay of the same event from opening a second Run; one abandoned past its deadline is recorded as a failure.

In progress #

The Delivery state of the Step a Suspended run is parked at: written the instant the Run suspends, carrying no status code, and cleared when the answer resolves it into an ordinary Delivery. An in-progress Delivery is neither a success nor a failure, so it is excluded from the status filters, from the send budget, and from the Owner's last-outcome summary until it resolves.

Scheduled action #

An Action whose execution is deferred to a later time instead of running inline when the event matches: it is persisted and a background runner later claims and executes it, retrying with backoff on failure up to a fixed number of attempts.

Orphan recovery: if a claimed Scheduled action stays in progress past a recovery timeout (chosen to exceed any legitimate run), the runner treats the worker as dead, returns the work to pending, and lets it be re-claimed. An Action that can hang longer than that window may therefore execute twice, so each must be bounded to fail before the window elapses.

Delivery #

A single execution attempt of one Action for one matched event, persisted with its status code, error, attempt number, dry-run flag, and steps-model identity (step id, run id, loop item index; null on pre-migration rows, which render by their legacy positional index). The Automation's logs view browses Deliveries; the Owner-only activity summary on the Automation page counts them.

Skip #

A Delivery recording that a Step deliberately did nothing: a built-in safety pre-flight declined (already following, already on the list, no profile), a Loop matched no items, an in-tree Gate stopped a dry run, or an Action referenced a branch name unset on this path. A Skip counts as success for Run continuation, is never retried, and is excluded from rate-limit counting, in live and dry-run mode alike.

Source dedupe #

An Automation setting (dedupeBySource) that fires the Run at most once per source record: after a Run completes with at least one real side effect, that source is recorded as handled and later matching events for the same record are skipped.

The handled record is written only when the Run reaches its end without a break AND at least one Action produced a real side effect. A Run that breaks partway — or ends at a Gate — leaves the source un-recorded, so the next matching event re-runs it and the failed or filtered step gets another attempt; partial execution is never mistaken for done. Dry Run bypasses Source dedupe so a record can be re-tested.

Operational status #

The Owner-visible run state of an Automation: whether it is currently active, whether it is in Dry Run, and whether it was Auto-disabled and for what reason. A concept in its own right because it is a visibility class, not just a set of fields: it is withheld from every Viewer who is not the Owner.

Operational status never appears on a public surface. A Viewer browsing the gallery sees an Automation's public identity (its name, description, trigger, and actions) and the Blueprint badge, but cannot tell whether that Automation is switched off, under test, or rate limited. Only the Owner sees it, on their own profile and the Automation's own page.

Dry Run #

A per-Automation test mode: matched events flow through the full pipeline and are logged as Deliveries, but Actions do not execute. Dry-run Deliveries are marked as such, bypass Source dedupe so the same record can be re-tested, and count toward the Automation's rate limits like live fires (Skips excluded).

Dry Run is time-boxed — an Automation left in dry run beyond the allowed period is Auto-disabled with a dry-run-expired reason, and toggling Dry Run resets that countdown. Disabling Dry Run on an Automation whose Actions write beyond the basic scope requires re-authorizing with the broader OAuth scope.

Blueprint #

An Automation published by a partner account as an official template for visitors to duplicate. Only Owners flagged as partner apps may mark an Automation as a Blueprint. The Blueprint badge is publicly visible — unlike operational status (active, dry run, rate limited), which only the Owner sees.

Showcase #

The curated front door for discovering an Automation to duplicate: a small, admin-picked, admin-ordered set, drawn primarily from Blueprints but allowing any active Automation. Distinct from a Blueprint, which is a single partner-published template, and from the public gallery, which lists the whole corpus uncurated: the Showcase is a hand-maintained editorial selection.

Duplication lineage #

The ancestry chain linking an Automation to the one it was duplicated from. Contagious and flattened: duplicating a copy points the new Automation at the original root, never at the intermediate copy, so every descendant of a root shares one lineage identity. An Automation built from scratch, and any Blueprint, has no lineage and is its own root.

The rule that the public gallery renders one card per Duplication lineage and name, rather than one card per Automation. Same-name copies of a root fold into it, whatever their configuration: the name is the only divergence signal a visitor can see on a card, so renaming is how a copy keeps its own card and a rewritten copy that kept its name is still collapsed. Collapse never crosses lineage boundaries, is the same for every visitor, and carries no copy count. It applies only to the public gallery: an Owner still sees every Automation they own on their profile, and the Showcase is curated independently.

Auto-disable #

The platform's protective shutdown of an Automation: flipping it inactive with a recorded reason and timestamp. Two triggers exist — exceeding any rate-limit window, and Dry Run expiry. Rate-limit windows (per second, minute, and hour) count live and dry-run Deliveries alike; Skips are excluded. Re-activating the Automation clears the reason and restarts the relevant rolling window; the Owner-facing page surfaces the reason as an alert until then.

One exception: a rate-limit breach on an Ingestion catch-up event throttles instead of disabling. The Delivery is still skipped, so the budget is enforced, but the Automation stays active.

Ingestion catch-up #

Processing of Jetstream events that are materially older than wall clock, i.e. a replay of a gap after the worker was disconnected. Classified per-event from the event's own age, not from connection state, so the replay-to-live handover needs no cutover signal.

An Automation enters a catch-up episode on its first catch-up event and leaves once no replay-aged event has arrived for a drain grace period. While in an episode, a rate-limit breach throttles the event and leaves the Automation active, because a replay arriving at wire speed would otherwise exhaust the rolling windows within seconds of reconnecting and disable healthy Automations. Throttled events are not retried, so gap recovery is best-effort.

The episode ends on a quiet wire rather than on the first steady-state event. The tail of a backlog is young enough to classify as live while still arriving at wire speed, so ending on age hands that tail to steady-state rules and disables the Automation at the very moment the recovery completes.

Leaving the episode restarts the rolling rate-limit windows. Those windows are wall-clock over Delivery write time, so a replay fills them in seconds and those Deliveries are still inside the window at handover; without the reset the first live event would Auto-disable the Automation, which is the outcome the throttle exists to prevent, merely deferred to the end of the recovery. Only Automations that actually replayed are reset.

Distinct from PDS catch-up, which rewrites Automation records to a user's PDS after their OAuth session is restored. Unrelated mechanisms that share a word.

Lookback window #

The bound on how far back an ingestion cursor may reach before a worker discards it and begins from live instead of replaying the gap. It is consulted on every connect, but measures different things: at process start it bounds how long ago the persisted cursor's writer was last known connected, and on a reconnect that still holds a cursor in memory it bounds the time spent disconnected. Time disconnected, not cursor age, is the right measure there - a quiet subscription legitimately holds an old cursor on a healthy connection (and periodically re-stamps its persisted one to say so), while a connection that keeps failing has a real and growing gap.

Sized to the gap a worker restart actually leaves, not to how far back the upstream service is willing to serve. It is deliberately not a backfill mechanism: because rate-limit windows are wall-clock, a window wider than what an Automation's budget can execute advertises recovery it does not deliver, and, since replay is chronological, spends that budget on the oldest events while never reaching the recent ones. Widening it is a product question about how stale an action may be and still be worth firing.

Social graph sync shards have their own, longer window (24 hours by default). Their handler only writes to the database and their queue drops nothing, so a deep replay costs nothing; the window instead bounds how long an outage can be before unfollows in the gap are lost.

Social graph sync #

Sync set #

The apps, chosen among the registered follow targets, whose follow graphs a user has asked Airglow to keep identical. One set per user today; a user may add or remove apps or disable the set at any time, and none of those changes deletes a follow. Stored as a record on the user's PDS with the local index authoritative, like an Automation.

Social graph sync #

The first-party feature that keeps a Sync set's graphs identical: existing follows are merged at enable, every follow and unfollow event on one app in the set is mirrored to the others, and a subject who later joins an app is followed when their profile appears. Distinct from a follow Action, which mirrors one direction from inside a user-authored Automation. Deletes come only from an observed unfollow event; backfill, the Waiting list and the sweep only add.

Follow mirror #

Airglow's local index of a synced user's follow records, one row per record per app in their Sync set, keyed by the record's rkey and carrying the subject DID. Seeded by listing the user's follows at enable, kept current by observed creates and deletes, and refreshed by the sweep. It exists because a Jetstream delete commit carries only the rkey, so an unfollow's subject is only knowable from the mirror; it also answers "already follows" locally and identifies sync's own writes (a create event whose rkey is already mirrored). Dropping a mirror row never deletes anything on a PDS.

Waiting list #

Per user and per app in their Sync set, the subjects who could not be followed there because they had no profile on that app yet. Entries are cleared when the follow is written or when the user unfollows the subject everywhere. Profile creates on low-volume graphs are matched against Waiting lists so a late joiner is followed within seconds; a periodic sweep re-checks the rest.

Notifications #

Notification grant #

The atmo.pub-side permission a user gives Airglow to send them notifications. Distinct from the Grant (the PDS-side OAuth authorization). Tracked locally as a fact-only state machine (none, pending, granted, revoked) driven by relay signals: there is no sender-side status query, so granted is learned from an alreadyGranted response or a successful send, and revoked from a send failing NotAuthorized.

Airglow notification #

A system alert Airglow sends to a user about automation lifecycle events: Auto-disable, session loss requiring re-authorization, an Automation turning failing, or an upcoming Dry Run expiry. Delivered through atmo.pub under Airglow's sender identity, one category per event type so users can toggle them in settings (a disabled category means Airglow does not send). Error alerts fire once per healthy-to-failing transition: the first failed Delivery after a success notifies, then nothing until the Automation recovers and fails again.

Notification action #

An Action type that sends an atmo.pub notification to the Automation's Owner, with title and body rendered through the standard template placeholders. Requires the Owner's one-time atmo.pub permission grant; sends without a grant fail as Delivery errors. Self-only: it cannot target other recipients.

Send budget #

The per-user daily cap Airglow imposes on Notification action sends, set below atmo.pub's 100/day per-recipient limit. Airglow notifications bypass the budget, so the reserved headroom keeps system alerts deliverable even when an Automation exhausts it. Budget-exhausted sends log as Delivery failures, not Skips, so the condition stays visible and trips the failing-Automation alert.

AT Protocol #

AppView #

The AT Protocol application-layer service that aggregates and indexes social graph data — profile metadata, feeds, and social relationships — and exposes it via the XRPC protocol. A distinct tier from the PDS (the user's personal data store), which Airglow accesses directly via OAuth. Responses from the AppView are treated as untrusted network input because the service is a third-party dependency outside Airglow's trust boundary.

UI architecture #

Island #

A hydrated interactive UI unit: server-rendered first, then hydrated in the browser where its event handlers and effects attach. Everything outside Islands is static server output. Before hydration completes an Island's controls are inert, so interactive elements are expected to render in a safe disabled state until then. The framework wraps each Island in an inline wrapper element, which constrains layout unless the surrounding container accounts for it.