···1111### For end users
121213131. Sign in to Airglow using [AT Protocol OAuth](https://atproto.com/specs/oauth).
1414-2. Create an automation by choosing a lexicon to listen to (e.g. `sh.tangled.feed.star`, `site.standard.document`).
1515-3. Add conditions to filter events — simple equality checks on record fields like the record owner or specific values in the data. The schema is known from the lexicon, so Airglow can present the available fields.
1616-4. Add actions — deliver a webhook to a callback URL, or create a record on your PDS using a template.
1414+2. Create an automation by choosing a lexicon to listen to (e.g. `sh.tangled.feed.star`, `site.standard.document`) and which operations to watch (`create`, `update`, `delete`).
1515+3. Add conditions to filter events — match record fields using operators like `eq`, `startsWith`, `endsWith`, or `contains`. The `{{self}}` placeholder resolves to the automation owner's DID. The schema is known from the lexicon, so Airglow can present the available fields.
1616+4. Optionally add fetch steps to retrieve records from a PDS at event time. Fetched data can then be referenced in action templates using `{{fetchName.record.field}}` placeholders.
1717+5. Add actions — deliver a webhook to a callback URL, or create a record on your PDS using a template.
1818+1919+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.
17201821Airglow verifies that webhook callback URLs actually support the selected lexicon before activating the automation (see [Callback endpoints](#callback-endpoints) below).
2222+2323+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.
19242025#### Data ownership
2126···107112108113## Self-hosting
109114110110-Airglow is designed to be easy to self-host. Instance operators can configure allowlists and blocklists on NSID domains 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.
115115+Airglow is designed to be easy to self-host. Configuration is done via environment variables (see `.env.example`):
116116+117117+| Variable | Purpose |
118118+| --- | --- |
119119+| `PUBLIC_URL` | Public-facing base URL of the instance |
120120+| `DATABASE_PATH` | Path to the SQLite database file |
121121+| `JETSTREAM_URL` | Jetstream WebSocket endpoint |
122122+| `COOKIE_SECRET` | Secret for session cookies (min 32 chars) |
123123+| `NSID_ALLOWLIST` | Comma-separated NSIDs to allow (empty = allow all) |
124124+| `NSID_BLOCKLIST` | Comma-separated NSIDs to block (empty = block none) |
125125+126126+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.
111127112128## References
113129
+9-9
docs/performance.md
···2233## Current Architecture
4455-Airglow maintains a **single WebSocket connection** to Jetstream, regardless of how many user subscriptions exist.
55+Airglow maintains a **single WebSocket connection** to Jetstream, regardless of how many user automations exist.
6677### How it works
8899-1. **One WebSocket, deduplicated collections** — `JetstreamConsumer` is a singleton. On startup (and whenever subscriptions change), it loads all active subscriptions from the database and groups them by lexicon (collection). Only the **unique collection names** are sent as `wantedCollections` params to Jetstream. If 100 users subscribe to `app.bsky.feed.post`, Jetstream sends events for that collection once.
99+1. **One WebSocket, deduplicated collections** — `JetstreamConsumer` is a singleton. On startup (and whenever automations change), it loads all active automations from the database and groups them by collection + operation (e.g. `app.bsky.feed.post` + `create`). Only the **unique collection names** are sent as `wantedCollections` params to Jetstream. If 100 users watch `app.bsky.feed.post`, Jetstream sends events for that collection once.
10101111-2. **In-memory fan-out** — When an event arrives, the consumer looks up all subscriptions for that collection in a `Map<string, Subscription[]>` and iterates through them, evaluating each subscription's conditions. Only matching subscriptions trigger their actions (webhook delivery or record creation).
1111+2. **In-memory fan-out** — When an event arrives, the consumer looks up all automations for that collection + operation in a `Map<string, Automation[]>` and iterates through them, evaluating each automation's conditions. Only matching automations trigger their actions (webhook delivery or record creation).
12121313-3. **WebSocket reconnection on collection changes** — When a subscription is created or deleted, if the set of watched collections changes, the consumer closes and reopens the WebSocket with updated `wantedCollections` params. If only the subscriptions within an existing collection change, no reconnection is needed — the in-memory map is simply updated.
1313+3. **WebSocket reconnection on collection changes** — When an automation is created or deleted, if the set of watched collections changes, the consumer closes and reopens the WebSocket with updated `wantedCollections` params. If only the automations within an existing collection change, no reconnection is needed — the in-memory map is simply updated.
14141515### Why this works well at current scale
16161717-- The `Map` lookup by collection is O(1).
1818-- Condition matching is a simple linear scan per subscription — fast for dozens or even hundreds of subscriptions per collection.
1717+- The `Map` lookup by collection + operation is O(1).
1818+- Condition matching is a simple linear scan per automation — fast for dozens or even hundreds of automations per collection.
1919- All fan-out happens in-process with no network overhead.
2020- A single WebSocket minimizes Jetstream resource usage.
21212222## Potential Future Improvements
23232424-As the number of subscriptions per collection grows (thousands+), the linear scan through conditions on every event could become a bottleneck. Some options:
2424+As the number of automations per collection grows (thousands+), the linear scan through conditions on every event could become a bottleneck. Some options:
25252626-- **Condition indexing** — Build inverted indexes on condition fields/values so only potentially matching subscriptions are evaluated, rather than scanning all of them.
2727-- **Batch/parallel condition evaluation** — Evaluate conditions for multiple subscriptions concurrently rather than sequentially.
2626+- **Condition indexing** — Build inverted indexes on condition fields/values so only potentially matching automations are evaluated, rather than scanning all of them.
2727+- **Batch/parallel condition evaluation** — Evaluate conditions for multiple automations concurrently rather than sequentially.
2828- **Sharded consumers** — Run multiple consumer instances, each responsible for a subset of collections or users, to distribute the fan-out load.
2929- **Pre-filtering with Jetstream features** — If Jetstream adds more granular filtering (e.g. by DID or record fields), leverage that to reduce the volume of events the consumer needs to process.
3030
+1-1
docs/sqlite-bun-compat.md
···7979The `.env` file is auto-loaded by Bun but not by Node (Vite SSR). `lib/config.ts` includes a minimal `.env` loader for the Node context:
80808181```ts
8282-if (typeof globalThis.Bun === "undefined" && existsSync(".env")) {
8282+if (!("Bun" in globalThis) && existsSync(".env")) {
8383 for (const line of readFileSync(".env", "utf-8").split("\n")) {
8484 const m = line.match(/^([^#=\s]+)=(.*)$/);
8585 if (m?.[1] && !(m[1] in process.env)) process.env[m[1]] = m[2];
+1-1
lib/jetstream/matcher.ts
···7272/**
7373 * Check if all conditions match the event.
7474 * Empty conditions = match all events for that collection.
7575- * `ownerDid` is the subscription owner's DID, used to resolve {{self}} in condition values.
7575+ * `ownerDid` is the automation owner's DID, used to resolve {{self}} in condition values.
7676 */
7777export function matchConditions(
7878 event: JetstreamEvent,