Airglow #
Automations for the AT Protocol — listen to events, filter them, and trigger actions like webhook deliveries or PDS record creation.
Airglow connects to Jetstream (AT Protocol's event streaming service), matches incoming records against user-defined automations, and executes actions automatically. Think IFTTT or Zapier, but the trigger side is always "something happened on the AT Protocol."
Website: airglow.run
How it works #
For end users #
- Sign in to Airglow using AT Protocol OAuth.
- Create an automation by choosing a trigger: a lexicon to listen to (e.g.
sh.tangled.feed.star,site.standard.document) and which operations to watch (create,update,delete). - Compose the automation as an ordered list of steps, in any order:
- Conditions check assertions against event data or earlier step outputs, with typed operators (string, numeric, boolean, presence) offered per the field's schema. A condition can act as a gate (no match ends the run) or branch into then/else step lists that rejoin the flow. Assertions combine with all/any matching. The
{{self}}placeholder resolves to the automation owner's DID. - Fetches and searches retrieve records from a PDS at event time and expose them under a name for later steps (
{{name.record.field}}). - Variables compute a value once at their position in the run and expose it under a name.
- Loops run their nested steps once per item resolved from an array path (e.g. link facets on a post).
- Actions do the work — deliver a webhook, create or patch a record, post to Bluesky, follow, save, notify, and more. Named action outputs are referenceable by later steps.
- Conditions check assertions against event data or earlier step outputs, with typed operators (string, numeric, boolean, presence) offered per the field's schema. A condition can act as a gate (no match ends the run) or branch into then/else step lists that rejoin the flow. Assertions combine with all/any matching. The
- Each step's editor shows exactly the placeholders available at that point in the flow, computed from the steps above it.
Automations can be created in dry-run mode — all logic (condition matching, fetches, template rendering) runs, but no side effects occur. Results are logged so you can verify behavior before going live. Dry-run fires count toward the same per-automation rate limits as live ones, and an automation that breaches a limit is automatically deactivated in either mode.
Airglow verifies that webhook callback URLs actually support the selected lexicon before activating the automation (see Callback endpoints below).
Each user has a public profile at /u/<handle> showing their automations and maintained lexicons. Individual automations can be viewed and duplicated by other users.
Social graph sync #
Besides automations, Airglow offers social graph sync: pick the apps whose follow graphs should stay identical (Bluesky, Tangled, Sifa, Grain, Semble) and turn it on. Existing follows are merged, follows and unfollows are mirrored live, and people who join one of the apps later are followed there. Only an observed unfollow ever deletes a follow. It lives on your profile, at /u/{handle}/social-graph-sync. See docs/social-graph-sync.md.
Data ownership #
Automations are stored on the user's PDS as run.airglow.automation records, visible to any AT Protocol client. Operationally, the Airglow instance's local index is authoritative for execution: the PDS copy is a projection the instance keeps converged on every save (and best-effort after schema migrations — a copy can lag while an owner's OAuth session is unavailable, and converges on their next sign-in or save). The record shape is documented by the lexicon, so the data remains portable and inspectable; importing records into another Airglow instance is not yet automated.
For developers #
Developers build HTTP endpoints that receive webhook payloads from Airglow.
Callback endpoints #
A callback server can optionally expose a metadata route so Airglow can discover its endpoints and verify which lexicons each one accepts:
GET <server-base-url>/.well-known/airglow
This returns a JSON manifest mapping callback paths to the lexicons they handle:
{
"callbacks": [
{ "path": "/hooks/stars", "lexicons": ["sh.tangled.feed.star"] },
{ "path": "/hooks/posts", "lexicons": ["app.bsky.feed.post"] }
]
}
When a user registers a callback URL (e.g. https://example.com/hooks/stars), Airglow fetches the manifest from https://example.com/.well-known/airglow. If the manifest is present and the path is listed with the requested lexicon, the webhook is marked as verified. If the manifest is missing or doesn't match, the webhook is still created but shown as unverified. Verification is re-checked when an automation is reactivated.
Webhook payload #
When a matching event occurs, Airglow sends a POST request to the callback URL. The payload contains the Jetstream event (commit operation, record data, repo DID, timestamp) wrapped in an Airglow envelope with metadata such as the automation ID and its trigger conditions.
The envelope's conditions field is a compatibility projection of the automation's leading trigger gate in the pre-steps shape: assertions expressible with the legacy operators (eq, startsWith, endsWith, contains, exists, not-exists spelling) are included; assertions using newer operators (negations, numeric comparisons) and any-mode gates are omitted rather than mis-signaled. A future payload version will expose the full step tree.
Custom request body #
The envelope is the default, not the only option. An automation can instead supply its own body template, so it can POST straight to a service that expects its own shape (a chat webhook, a notification service, a generic API) with no relay in between. Two formats:
- JSON — validated as JSON when the automation is saved, and guaranteed to be valid JSON on the wire. Substituted values are escaped, so a record field containing quotes or newlines cannot break the document.
- Plain text — sent verbatim.
Both accept the same {{placeholder}} expressions as the rest of the product: event fields, named step outputs, loop items, variables, and {{automation.*}}.
Content-Type follows the format (application/json or text/plain; charset=utf-8) and can be overridden with a custom header when a body template is set. Automations sending the default envelope always send application/json, so an endpoint verified through the manifest can rely on it.
Secrets in a webhook #
Stored secrets ({{secret:name}}) can be referenced from custom headers and from the callback URL, never from the body. A failed delivery persists the rendered body into the delivery log, so a secret placed there would end up in a row the user reads back; headers and the callback URL never persist their resolved form.
In the callback URL, references are allowed anywhere after the host, which covers services that carry credentials in the path:
https://discord.com/api/webhooks/{{secret:discord_id}}/{{secret:discord_token}}
The scheme and hostname must stay literal, so the automation's public profile can keep showing which host it posts to. Substituted values are URL-encoded like every other callback-URL placeholder, so a stored value cannot inject path, query, or fragment structure into the request. That means one path segment per secret: store the Discord id and token separately rather than as a single id/token string.
The reference name travels on the public automation record; the value never leaves the server. If a referenced secret is missing when an event fires, the delivery is logged as a failure naming the secret and no request is sent.
Request signing #
Airglow signs every outgoing request so that callback endpoints can verify it actually came from a legitimate Airglow instance (similar to how Stripe or GitHub sign webhook deliveries). The signature always covers the bytes actually sent, so it verifies the same way whether the body is the default envelope or a custom template.
Response handling #
- 2xx — Success. The event was delivered.
- 4xx — Logged as a delivery failure. Users can review these errors in Airglow.
- 5xx — Airglow retries delivery (up to 2 retries with backoff).
Future: protocol-native discovery #
Today, users provide callback URLs manually. In the future, developers will be able to publish a run.airglow.callback record on their PDS, declaring their endpoint URL and supported lexicons. Airglow instances could then subscribe to this collection and index available callbacks, letting users browse and pick from discovered endpoints instead of entering URLs by hand.
Development #
Prerequisites #
Getting started #
# Install dependencies
vp install
# Set up the database
cp .env.example .env
bun run db:migrate
# Start the dev server
vp dev
The app will be available at http://localhost:5173.
Useful commands #
vp check # lint, format, type-check
vp test # run tests
vp build # build client assets for production
bun run start # build, then run the production server
bun run serve # run the production server from an existing dist/ build
Lexicons #
Lexicon schemas live in lexicons/ and are managed with goat:
goat lex lint lexicons/ # validate schemas
goat lex new record run.airglow.<name> # create a new lexicon
Self-hosting #
Airglow is designed to be easy to self-host. Configuration is done via environment variables (see .env.example):
| Variable | Purpose |
|---|---|
PUBLIC_URL |
Public-facing base URL of the instance |
DATABASE_PATH |
Path to the SQLite database file |
JETSTREAM_URL |
Jetstream WebSocket endpoint (defaults to a v2 instance) |
JETSTREAM_MAX_LOOKBACK_US |
Max age of a stored cursor still used to resume (default 1h) |
SYNC_SHARD_COUNT |
Jetstream subscriptions social graph sync hashes users into (default 1) |
SYNC_JETSTREAM_MAX_LOOKBACK_US |
Resume window for social graph sync subscriptions (default 24h) |
SYNC_HOURLY_POINTS |
PDS write points sync may spend per account per hour (default 1500) |
SYNC_DAILY_POINTS |
PDS write points sync may spend per account per day (default 15000) |
COOKIE_SECRET |
Secret for session cookies (min 32 chars) |
NSID_ALLOWLIST |
Comma-separated NSIDs to allow (empty = allow all) |
NSID_BLOCKLIST |
Comma-separated NSIDs to block (empty = block none) |
Instance operators can configure NSID_ALLOWLIST and NSID_BLOCKLIST to control which lexicons their instance handles. For example, a typical instance may want to block app.bsky.* or app.bsky.feed.* since those collections are very active and could overwhelm a small instance.