A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1# Running an ATProto Relay for ATCR Hold Discovery
2
3This document explains what it takes to run an ATProto relay for indexing ATCR hold records, including infrastructure requirements, configuration, and trade-offs.
4
5## Overview
6
7### What is an ATProto Relay?
8
9An ATProto relay is a service that:
10- **Subscribes to multiple PDS hosts** and aggregates their data streams
11- **Outputs a combined "firehose"** event stream for real-time network updates
12- **Validates data integrity** and identity signatures
13- **Provides discovery endpoints** like `com.atproto.sync.listReposByCollection`
14
15The relay acts as a network-wide indexer, making it possible to discover which DIDs have records of specific types (collections).
16
17### Why ATCR Needs a Relay
18
19ATCR uses hold captain records (`io.atcr.hold.captain`) stored in hold PDSs to enable hold discovery. The `listReposByCollection` endpoint allows AppViews to efficiently discover all holds in the network without crawling every PDS individually.
20
21**The problem**: Standard Bluesky relays appear to only index collections from `did:plc` DIDs, not `did:web` DIDs. Since ATCR holds use `did:web` (e.g., `did:web:hold01.atcr.io`), they aren't discoverable via Bluesky's public relays.
22
23## Recommended Approach: Phased Implementation
24
25ATCR's discovery needs evolve as the network grows. Start simple, scale as needed.
26
27## MVP: Minimal Discovery Service
28
29For initial deployment with a small number of holds (dozens, not thousands), build a **lightweight custom discovery service** focused solely on `io.atcr.*` collections.
30
31### Why Minimal Service for MVP?
32
33- **Scope**: Only index `io.atcr.*` collections (manifests, tags, captain/crew, sailor profiles)
34- **Opt-in**: Only crawls PDSs that explicitly call `requestCrawl`
35- **Small scale**: Dozens of holds, not millions of users
36- **Simple storage**: SQLite sufficient for current scale
37- **Cost-effective**: $5-10/month VPS
38
39### Architecture
40
41**Inbound endpoints:**
42```
43POST /xrpc/com.atproto.sync.requestCrawl
44 → Hold registers itself for crawling
45
46GET /xrpc/com.atproto.sync.listReposByCollection?collection=io.atcr.hold.captain
47 → AppView discovers holds
48```
49
50**Outbound (client to PDS):**
51```
521. com.atproto.repo.describeRepo → verify PDS exists
532. com.atproto.sync.getRepo → fetch full CAR file (initial backfill)
543. com.atproto.sync.subscribeRepos → WebSocket for real-time updates
554. Parse events → extract io.atcr.* records → index in SQLite
56```
57
58**Data flow:**
59
60**Initial crawl (on requestCrawl):**
61```
621. Hold POSTs requestCrawl → service queues crawl job
632. Service fetches getRepo (CAR file) from hold's PDS for backfill
643. Service parses CAR using indigo libraries
654. Service extracts io.atcr.* records (captain, crew, manifests, etc.)
665. Service stores: (did, collection, rkey, record_data) in SQLite
676. Service opens WebSocket to subscribeRepos for this DID
687. Service stores cursor for reconnection handling
69```
70
71**Ongoing updates (WebSocket):**
72```
731. Receive commit events via subscribeRepos WebSocket
742. Parse event, filter to io.atcr.* collections only
753. Update indexed_records incrementally (insert/update/delete)
764. Update cursor after processing each event
775. On disconnect: reconnect with stored cursor to resume
78```
79
80**Discovery (AppView query):**
81```
821. AppView GETs listReposByCollection?collection=io.atcr.hold.captain
832. Service queries SQLite WHERE collection='io.atcr.hold.captain'
843. Service returns list of DIDs with that collection
85```
86
87### Implementation Requirements
88
89**Technologies:**
90- Go (reuse indigo libraries for CAR parsing and WebSocket)
91- SQLite (sufficient for dozens/hundreds of holds)
92- Standard HTTP server + WebSocket client
93
94**Core components:**
95
961. **HTTP handlers** (`cmd/atcr-discovery/handlers/`):
97 - `requestCrawl` - queue crawl jobs
98 - `listReposByCollection` - query indexed collections
99
1002. **Crawler** (`pkg/discovery/crawler.go`):
101 - Fetch CAR files from PDSs for initial backfill
102 - Parse with `github.com/bluesky-social/indigo/repo`
103 - Extract records, filter to `io.atcr.*` only
104
1053. **WebSocket subscriber** (`pkg/discovery/subscriber.go`):
106 - WebSocket client for `com.atproto.sync.subscribeRepos`
107 - Event parsing and filtering
108 - Cursor management and persistence
109 - Automatic reconnection with resume
110
1114. **Storage** (`pkg/discovery/storage.go`):
112 - SQLite schema for indexed records
113 - Indexes on (collection, did) for fast queries
114 - Cursor storage for reconnection
115
1165. **Worker** (`pkg/discovery/worker.go`):
117 - Background crawl job processor
118 - WebSocket connection manager
119 - Health monitoring for subscriptions
120
121**Database schema:**
122```sql
123CREATE TABLE indexed_records (
124 did TEXT NOT NULL,
125 collection TEXT NOT NULL,
126 rkey TEXT NOT NULL,
127 record_data TEXT NOT NULL, -- JSON
128 indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
129 PRIMARY KEY (did, collection, rkey)
130);
131
132CREATE INDEX idx_collection ON indexed_records(collection);
133CREATE INDEX idx_did ON indexed_records(did);
134
135CREATE TABLE crawl_queue (
136 id INTEGER PRIMARY KEY AUTOINCREMENT,
137 hostname TEXT NOT NULL UNIQUE,
138 did TEXT,
139 status TEXT DEFAULT 'pending', -- pending, in_progress, subscribed, failed
140 last_crawled_at TIMESTAMP,
141 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
142);
143
144CREATE TABLE subscriptions (
145 did TEXT PRIMARY KEY,
146 hostname TEXT NOT NULL,
147 cursor INTEGER, -- Last processed sequence number
148 status TEXT DEFAULT 'active', -- active, disconnected, failed
149 last_event_at TIMESTAMP,
150 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
151 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
152);
153```
154
155**Leveraging indigo libraries:**
156
157```go
158import (
159 "github.com/bluesky-social/indigo/repo"
160 "github.com/bluesky-social/indigo/atproto/syntax"
161 "github.com/bluesky-social/indigo/events"
162 "github.com/gorilla/websocket"
163 "github.com/ipfs/go-cid"
164)
165
166// Initial backfill: Parse CAR file
167r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(carData))
168if err != nil {
169 return err
170}
171
172// Iterate records
173err = r.ForEach(ctx, "", func(path string, nodeCid cid.Cid) error {
174 // Parse collection from path (e.g., "io.atcr.hold.captain/self")
175 parts := strings.Split(path, "/")
176 if len(parts) != 2 {
177 return nil // skip invalid paths
178 }
179
180 collection := parts[0]
181 rkey := parts[1]
182
183 // Filter to io.atcr.* only
184 if !strings.HasPrefix(collection, "io.atcr.") {
185 return nil
186 }
187
188 // Get record data
189 recordBytes, err := r.GetRecord(ctx, path)
190 if err != nil {
191 return err
192 }
193
194 // Store in database
195 return store.IndexRecord(did, collection, rkey, recordBytes)
196})
197
198// WebSocket subscription: Listen for updates
199wsURL := fmt.Sprintf("wss://%s/xrpc/com.atproto.sync.subscribeRepos", hostname)
200conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
201if err != nil {
202 return err
203}
204
205// Read events
206rsc := &events.RepoStreamCallbacks{
207 RepoCommit: func(evt *events.RepoCommit) error {
208 // Filter to io.atcr.* collections only
209 for _, op := range evt.Ops {
210 if !strings.HasPrefix(op.Collection, "io.atcr.") {
211 continue
212 }
213
214 // Process create/update/delete operations
215 switch op.Action {
216 case "create", "update":
217 store.IndexRecord(evt.Repo, op.Collection, op.Rkey, op.Record)
218 case "delete":
219 store.DeleteRecord(evt.Repo, op.Collection, op.Rkey)
220 }
221 }
222
223 // Update cursor
224 return store.UpdateCursor(evt.Repo, evt.Seq)
225 },
226}
227
228// Process stream
229scheduler := events.NewScheduler("discovery-worker", conn.RemoteAddr().String(), rsc)
230return events.HandleRepoStream(ctx, conn, scheduler)
231```
232
233### Infrastructure Requirements
234
235**Minimum specs:**
236- 1 vCPU
237- 1-2GB RAM
238- 20GB SSD
239- Minimal bandwidth (<1GB/day for dozens of holds)
240
241**Estimated cost:**
242- Hetzner CX11: €4.15/month (~$5/month)
243- DigitalOcean Basic: $6/month
244- Fly.io: ~$5-10/month
245
246**Deployment:**
247```bash
248# Build
249go build -o atcr-discovery ./cmd/atcr-discovery
250
251# Run
252export DATABASE_PATH="/var/lib/atcr-discovery/discovery.db"
253export HTTP_ADDR=":8080"
254./atcr-discovery
255```
256
257### Limitations
258
259**What it does NOT do:**
260- ❌ Serve outbound `subscribeRepos` firehose (AppViews query via listReposByCollection)
261- ❌ Full MST validation (trust PDS validation)
262- ❌ Scale to millions of accounts (SQLite limits)
263- ❌ Multi-instance deployment (single process with SQLite)
264
265**When to migrate to full relay:** When you have 1000+ holds, need PostgreSQL, or multi-instance deployment.
266
267## Future Scale: Full Relay (Sync v1.1)
268
269When ATCR grows beyond dozens of holds and needs real-time indexing, migrate to Bluesky's relay v1.1 implementation.
270
271### When to Upgrade
272
273**Indicators:**
274- 100+ holds requesting frequent crawls
275- Need real-time updates (re-crawl latency too high)
276- Multiple AppView instances need coordinated discovery
277- SQLite performance becomes bottleneck
278
279### Relay v1.1 Characteristics
280
281Released May 2025, this is Bluesky's current reference implementation.
282
283**Key features:**
284- **Non-archival**: Doesn't mirror full repository data, only processes firehose
285- **WebSocket subscriptions**: Real-time updates from PDSs
286- **Scalable**: 2 vCPU, 12GB RAM handles ~100M accounts
287- **PostgreSQL**: Required for production scale
288- **Admin UI**: Web dashboard for management
289
290**Source**: `github.com/bluesky-social/indigo/cmd/relay`
291
292### Migration Path
293
294**Step 1: Deploy relay v1.1**
295```bash
296git clone https://github.com/bluesky-social/indigo.git
297cd indigo
298go build -o relay ./cmd/relay
299
300export DATABASE_URL="postgres://relay:password@localhost:5432/atcr_relay"
301./relay --admin-password="secure-password"
302```
303
304**Step 2: Migrate data**
305- Export indexed records from SQLite
306- Trigger crawls in relay for all known holds
307- Verify relay indexes correctly
308
309**Step 3: Update AppView configuration**
310```bash
311# Point to new relay
312export ATCR_RELAY_ENDPOINT="https://relay.atcr.io"
313```
314
315**Step 4: Decommission minimal service**
316- Monitor relay for stability
317- Shut down old discovery service
318
319### Infrastructure Requirements (Full Relay)
320
321**Minimum specs:**
322- 2 vCPU cores
323- 12GB RAM
324- 100GB SSD
325- 30 Mbps bandwidth
326
327**Estimated cost:**
328- Hetzner: ~$30-40/month
329- DigitalOcean: ~$50/month (with managed PostgreSQL)
330- Fly.io: ~$35-50/month
331
332## Collection Indexing: The `collectiondir` Microservice
333
334The `com.atproto.sync.listReposByCollection` endpoint is **not part of the relay core**. It's provided by a separate microservice called **`collectiondir`**.
335
336### What is collectiondir?
337
338- **Separate service** that indexes collections for efficient discovery
339- **Optional**: Not required by the ATProto spec, but very useful for AppViews
340- **Deployed alongside relay** by Bluesky's public instances
341
342### Current Limitation: did:plc Only?
343
344Based on testing, Bluesky's public relays (with collectiondir) appear to:
345- ✅ Index `io.atcr.*` collections from `did:plc` DIDs
346- ❌ NOT index `io.atcr.*` collections from `did:web` DIDs
347
348This means:
349- ATCR manifests from users (did:plc) are discoverable
350- ATCR hold captain records (did:web) are NOT discoverable
351- The relay still **stores** all data (CAR file includes did:web records)
352- The issue is specifically with **indexing** for `listReposByCollection`
353
354### Configuring collectiondir
355
356Documentation on configuring collectiondir is sparse. Possible approaches:
357
3581. **Fork and modify**: Clone indigo repo, modify collectiondir to index all DIDs
3592. **Configuration file**: Check if collectiondir accepts whitelist/configuration for indexed collections
3603. **No filtering**: Default behavior might be to index everything, but Bluesky's deployment filters
361
362**Action item**: Review `indigo/cmd/collectiondir` source code to understand configuration options.
363
364## Multi-Relay Strategy
365
366Holds can request crawls from **multiple relays** simultaneously. This enables:
367
368### Scenario: Bluesky + ATCR Relays
369
370**Setup:**
3711. Hold deploys with embedded PDS at `did:web:hold01.atcr.io`
3722. Hold creates captain record (`io.atcr.hold.captain/self`)
3733. Hold requests crawl from **both**:
374 - Bluesky relay: `https://bsky.network/xrpc/com.atproto.sync.requestCrawl`
375 - ATCR relay: `https://relay.atcr.io/xrpc/com.atproto.sync.requestCrawl`
376
377**Result:**
378- ✅ Bluesky relay indexes social posts (if hold owner posts)
379- ✅ ATCR relay indexes hold captain records
380- ✅ AppViews query ATCR relay for hold discovery
381- ✅ Independent networks - Bluesky posts work regardless of ATCR relay
382
383### Request Crawl Script
384
385The existing script can be modified to support multiple relays:
386
387```bash
388#!/bin/bash
389# deploy/request-crawl.sh
390
391HOSTNAME=$1
392BLUESKY_RELAY=${2:-"https://bsky.network"}
393ATCR_RELAY=${3:-"https://relay.atcr.io"}
394
395echo "Requesting crawl for $HOSTNAME from Bluesky relay..."
396curl -X POST "$BLUESKY_RELAY/xrpc/com.atproto.sync.requestCrawl" \
397 -H "Content-Type: application/json" \
398 -d "{\"hostname\": \"$HOSTNAME\"}"
399
400echo "Requesting crawl for $HOSTNAME from ATCR relay..."
401curl -X POST "$ATCR_RELAY/xrpc/com.atproto.sync.requestCrawl" \
402 -H "Content-Type: application/json" \
403 -d "{\"hostname\": \"$HOSTNAME\"}"
404```
405
406Usage:
407```bash
408./deploy/request-crawl.sh hold01.atcr.io
409```
410
411## Deployment: Minimal Discovery Service
412
413### 1. Infrastructure Setup
414
415**Provision VPS:**
416- Hetzner CX11, DigitalOcean Basic, or Fly.io
417- Public domain (e.g., `discovery.atcr.io`)
418- TLS certificate (Let's Encrypt)
419
420**Configure reverse proxy (optional - nginx):**
421```nginx
422upstream discovery {
423 server 127.0.0.1:8080;
424}
425
426server {
427 listen 443 ssl http2;
428 server_name discovery.atcr.io;
429
430 ssl_certificate /etc/letsencrypt/live/discovery.atcr.io/fullchain.pem;
431 ssl_certificate_key /etc/letsencrypt/live/discovery.atcr.io/privkey.pem;
432
433 location / {
434 proxy_pass http://discovery;
435 proxy_set_header Host $host;
436 proxy_set_header X-Real-IP $remote_addr;
437 }
438}
439```
440
441### 2. Build and Deploy
442
443```bash
444# Clone ATCR repo
445git clone https://github.com/atcr-io/atcr.git
446cd atcr
447
448# Build discovery service
449go build -o atcr-discovery ./cmd/atcr-discovery
450
451# Run
452export DATABASE_PATH="/var/lib/atcr-discovery/discovery.db"
453export HTTP_ADDR=":8080"
454export CRAWL_INTERVAL="12h"
455./atcr-discovery
456```
457
458### 3. Update Hold Startup
459
460Each hold should request crawl on startup:
461
462```bash
463# In hold startup script or environment
464export ATCR_DISCOVERY_URL="https://discovery.atcr.io"
465
466# Request crawl from both Bluesky and ATCR
467curl -X POST "https://bsky.network/xrpc/com.atproto.sync.requestCrawl" \
468 -H "Content-Type: application/json" \
469 -d "{\"hostname\": \"$HOLD_PUBLIC_URL\"}"
470
471curl -X POST "$ATCR_DISCOVERY_URL/xrpc/com.atproto.sync.requestCrawl" \
472 -H "Content-Type: application/json" \
473 -d "{\"hostname\": \"$HOLD_PUBLIC_URL\"}"
474```
475
476### 4. Update AppView Configuration
477
478Point AppView discovery worker to the discovery service:
479
480```bash
481# In .env.appview or environment
482export ATCR_RELAY_ENDPOINT="https://discovery.atcr.io"
483export ATCR_HOLD_DISCOVERY_ENABLED="true"
484export ATCR_HOLD_DISCOVERY_INTERVAL="6h"
485```
486
487### 5. Monitor and Maintain
488
489**Monitoring:**
490- Check crawl queue status
491- Monitor SQLite database size
492- Track failed crawls
493
494**Maintenance:**
495- Re-crawl on schedule (every 6-24 hours)
496- Prune stale records (>7 days old)
497- Backup SQLite database regularly
498
499## Trade-Offs and Considerations
500
501### Running Your Own Relay
502
503**Pros:**
504- ✅ Full control over indexing (can index `did:web` holds)
505- ✅ No dependency on third-party relay policies
506- ✅ Can customize collection filters for ATCR-specific needs
507- ✅ Relatively lightweight with modern relay implementation
508
509**Cons:**
510- ❌ Infrastructure cost (~$30-50/month minimum)
511- ❌ Operational overhead (monitoring, updates, backups)
512- ❌ Need to maintain as network grows
513- ❌ Single point of failure for discovery (unless multi-relay)
514
515### Alternatives to Running a Relay
516
517#### 1. Direct Registration API
518
519Holds POST to AppView on startup to register themselves:
520
521**Pros:**
522- ✅ Simplest implementation
523- ✅ No relay infrastructure needed
524- ✅ Immediate registration (no crawl delay)
525
526**Cons:**
527- ❌ Ties holds to specific AppView instances
528- ❌ Breaks decentralized discovery model
529- ❌ Each AppView has different hold registry
530
531#### 2. Static Discovery File
532
533Maintain `https://atcr.io/.well-known/holds.json`:
534
535**Pros:**
536- ✅ No infrastructure beyond static hosting
537- ✅ All AppViews share same registry
538- ✅ Simple to implement
539
540**Cons:**
541- ❌ Manual process (PRs/issues to add holds)
542- ❌ Not real-time discovery
543- ❌ Centralized control point
544
545#### 3. Hybrid Approach
546
547Combine multiple discovery mechanisms:
548
549```go
550func (w *HoldDiscoveryWorker) DiscoverHolds(ctx context.Context) error {
551 // 1. Fetch static registry
552 staticHolds := w.fetchStaticRegistry()
553
554 // 2. Query relay (if available)
555 relayHolds := w.queryRelay(ctx)
556
557 // 3. Accept direct registrations
558 registeredHolds := w.getDirectRegistrations()
559
560 // Merge and deduplicate
561 allHolds := mergeHolds(staticHolds, relayHolds, registeredHolds)
562
563 // Cache in database
564 for _, hold := range allHolds {
565 w.cacheHold(hold)
566 }
567}
568```
569
570**Pros:**
571- ✅ Multiple discovery paths (resilient)
572- ✅ Gradual migration to relay-based discovery
573- ✅ Supports both centralized bootstrap and decentralized growth
574
575**Cons:**
576- ❌ More complex implementation
577- ❌ Potential for stale data if sources conflict
578
579## Recommendations for ATCR
580
581### Phase 1: MVP (Now - 1000 holds)
582
583**Build minimal discovery service with WebSocket** (~$5-10/month):
5841. Implement `requestCrawl` + `listReposByCollection` endpoints
5852. Initial backfill via `getRepo` (CAR file parsing)
5863. Real-time updates via WebSocket `subscribeRepos`
5874. SQLite storage with cursor management
5885. Filter to `io.atcr.*` collections only
589
590**Deliverables:**
591- `cmd/atcr-discovery` service
592- SQLite schema with cursor storage
593- CAR file parser (indigo libraries)
594- WebSocket subscriber with reconnection
595- Deployment scripts
596
597**Cost**: ~$5-10/month VPS
598
599**Why**: Minimal infrastructure, real-time updates, full control over indexing, sufficient for hundreds of holds.
600
601### Phase 2: Migrate to Full Relay (1000+ holds)
602
603**Deploy Bluesky relay v1.1** when scaling needed (~$30-50/month):
6041. Set up PostgreSQL database
6052. Deploy indigo relay with admin UI
6063. Migrate indexed data from SQLite
6074. Configure for `io.atcr.*` collection filtering (if possible)
6085. Handle thousands of concurrent WebSocket connections
609
610**Cost**: ~$30-50/month
611
612**Why**: Proven scalability to 100M+ accounts, standardized protocol, community support, production-ready infrastructure.
613
614### Phase 3: Multi-Relay Federation (Future)
615
616**Decentralized relay network:**
6171. Multiple ATCR relays operated independently
6182. AppViews query multiple relays (fallback/redundancy)
6193. Holds request crawls from all known ATCR relays
6204. Cross-relay synchronization (optional)
621
622**Why**: No single point of failure, fully decentralized discovery, geographic distribution.
623
624## Next Steps
625
626### For MVP Implementation
627
6281. **Create `cmd/atcr-discovery` package structure**
629 - HTTP handlers for XRPC endpoints (`requestCrawl`, `listReposByCollection`)
630 - Crawler with indigo CAR parsing for initial backfill
631 - WebSocket subscriber for real-time updates
632 - SQLite storage layer with cursor management
633 - Background worker for managing subscriptions
634
6352. **Database schema**
636 - `indexed_records` table for collection data
637 - `crawl_queue` table for crawl job management
638 - `subscriptions` table for WebSocket cursor tracking
639 - Indexes for efficient queries
640
6413. **WebSocket implementation**
642 - Use `github.com/bluesky-social/indigo/events` for event handling
643 - Implement reconnection logic with cursor resume
644 - Filter events to `io.atcr.*` collections only
645 - Health monitoring for active subscriptions
646
6474. **Testing strategy**
648 - Unit tests for CAR parsing
649 - Unit tests for event filtering
650 - Integration tests with mock PDSs and WebSocket
651 - Connection failure and reconnection testing
652 - Load testing with SQLite
653
6545. **Deployment**
655 - Dockerfile for discovery service
656 - Deployment scripts (systemd, docker-compose)
657 - Monitoring setup (logs, metrics, WebSocket health)
658 - Alert on subscription failures
659
6606. **Documentation**
661 - API documentation for XRPC endpoints
662 - Deployment guide
663 - Troubleshooting guide (WebSocket connection issues)
664
665### Open Questions
666
6671. **CAR parsing edge cases**: How to handle malformed CAR files or invalid records?
6682. **WebSocket reconnection**: What's the optimal backoff strategy for reconnection attempts?
6693. **Subscription management**: How many concurrent WebSocket connections can SQLite handle?
6704. **Rate limiting**: Should discovery service rate-limit requestCrawl to prevent abuse?
6715. **Authentication**: Should requestCrawl require authentication, or remain open?
6726. **Cursor storage**: Should cursors be persisted immediately or batched for performance?
6737. **Monitoring**: What metrics are most important for operational visibility (active subs, event rate, lag)?
6748. **Error handling**: When a WebSocket dies, should we re-backfill via getRepo or trust cursor resume?
675
676## References
677
678### ATProto Specifications
679- [ATProto Sync Specification](https://atproto.com/specs/sync)
680- [Repository Specification](https://atproto.com/specs/repository)
681- [CAR File Format](https://ipld.io/specs/transport/car/)
682
683### Indigo Libraries
684- [Indigo Repository](https://github.com/bluesky-social/indigo)
685- [Indigo Repo Package](https://pkg.go.dev/github.com/bluesky-social/indigo/repo)
686- [Indigo ATProto Package](https://pkg.go.dev/github.com/bluesky-social/indigo/atproto)
687
688### Relay Reference (Future)
689- [Relay v1.1 Updates](https://docs.bsky.app/blog/relay-sync-updates)
690- [Indigo Relay Implementation](https://github.com/bluesky-social/indigo/tree/main/cmd/relay)
691- [Running a Full-Network Relay](https://whtwnd.com/bnewbold.net/3kwzl7tye6u2y)