···1+# Activity Overview
2+3+## What We Built
4+5+### 1. Basic Posting (01a-hello-world.js)
6+- Connects to Bluesky using app password
7+- Posts a simple message with #ATProtoChicago
8+- Shows the basics of authentication and posting
9+10+### 2. Creative Posts (01b-emoji-art.js)
11+- Generates random emoji art grids
12+- Demonstrates creative uses of the posting API
13+- Fun visual output that's shareable
14+15+### 3. Rich Text Features (02-rich-text.js)
16+- Auto-detects links and converts them to clickable links
17+- Auto-detects hashtags for discoverability
18+- Shows how Bluesky enhances plain text
19+20+### 4. Real-time Firehose (03-feed-generator.js)
21+- Connects to Bluesky's Jetstream service
22+- Filters the entire network for #ATProtoChicago posts
23+- Shows posts in real-time with clean JSON format
24+- Demonstrates how feed generators work
25+26+### 5. Timeline Filtering (05-post-filter.js)
27+- Fetches your home timeline
28+- Filters for Chicago/ATProto mentions
29+- Shows engagement metrics
30+- Local processing example
31+32+### 6. Profile Updates (04-profile-updater.js)
33+- Programmatically updates your profile
34+- Adds timestamp to description
35+- Shows how to modify account data
36+37+## Key Concepts Demonstrated
38+39+- **Authentication**: Using app passwords safely
40+- **CRUD Operations**: Create posts, read timeline, update profile
41+- **Real-time Data**: Consuming the firehose
42+- **Data Processing**: Filtering and analyzing posts
43+- **Rich Content**: Links, mentions, hashtags
44+- **API Patterns**: Consistent error handling and async/await
45+46+## Architecture Notes
47+48+- All examples use the official `@atproto/api` SDK
49+- Jetstream provides JSON instead of binary CAR/CBOR
50+- Credentials stored in `.env` for security
51+- Bun runtime for fast execution and built-in TypeScript support
+26-5
README.md
···71. **Create a Bluesky account** at https://bsky.app
82. **Generate an app password**: Settings → App Passwords → Add
93. **Install Bun** (if needed): `curl -fsSL https://bun.sh/install | bash`
10-4. **Clone this repo** and create `.env` file:
00000011 ```
12 BSKY_USERNAME=your-handle.bsky.social
13 BSKY_PASSWORD=xxxx-xxxx-xxxx-xxxx
···33```
34Demonstrates auto-detection of links and hashtags in posts.
3536-### Activity 3: Feed Generator Metadata (15 minutes)
37```bash
38bun activities/03-feed-generator.js
39```
40-Creates metadata for a custom feed (note: actual feed requires a server).
0000004142-### Activity 4: Profile Updater (10 minutes)
43```bash
44bun activities/04-profile-updater.js
45```
···60- Use `#ATProtoChicago` to find other meetup participants!
6162## Common Issues
0063- **Rate limits**: The API has rate limits, wait a minute if you hit them
64-- **Module not found**: Run the scripts from the repo root directory0000000
···71. **Create a Bluesky account** at https://bsky.app
82. **Generate an app password**: Settings → App Passwords → Add
93. **Install Bun** (if needed): `curl -fsSL https://bun.sh/install | bash`
10+4. **Clone this repo** and install dependencies:
11+ ```bash
12+ git clone <this-repo>
13+ cd atproto-meetup
14+ bun install
15+ ```
16+5. **Create `.env` file** with your credentials:
17 ```
18 BSKY_USERNAME=your-handle.bsky.social
19 BSKY_PASSWORD=xxxx-xxxx-xxxx-xxxx
···39```
40Demonstrates auto-detection of links and hashtags in posts.
4142+### Activity 3: Real-time Firehose with Jetstream (15 minutes)
43```bash
44bun activities/03-feed-generator.js
45```
46+Connect to Bluesky's Jetstream service to see real-time posts with #ATProtoChicago. Uses simple JSON instead of complex binary formats!
47+48+### Activity 4: Local Post Filter (15 minutes)
49+```bash
50+bun activities/05-post-filter.js
51+```
52+Build a local feed algorithm that filters your timeline for Chicago/ATProto posts.
5354+### Activity 5: Profile Updater (10 minutes)
55```bash
56bun activities/04-profile-updater.js
57```
···72- Use `#ATProtoChicago` to find other meetup participants!
7374## Common Issues
75+76+- **"Invalid identifier or password"**: Make sure you're using an app password, not your main password
77- **Rate limits**: The API has rate limits, wait a minute if you hit them
78+- **Module not found**: Run the scripts from the repo root directory
79+- **WebSocket errors**: The firehose can be flaky, just restart if it disconnects
80+81+## What's Next?
82+83+- Build a full feed generator: https://github.com/bluesky-social/feed-generator
84+- Explore the AT Protocol specs: https://atproto.com/specs
85+- Join the Bluesky developer community: https://github.com/bluesky-social/atproto/discussions
+3-3
activities/01a-hello-world.js
···1#!/usr/bin/env bun
2-import { BskyAgent } from '@atproto/api'
34// Read credentials from .env file
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213-const agent = new BskyAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({
···21console.log('Connected! Your DID:', agent.session?.did)
2223const post = await agent.post({
24- text: 'Hello from the Chicago ATProto meetup! 🚀'
25})
2627console.log('Posted successfully!')
···1#!/usr/bin/env bun
2+import { AtpAgent } from '@atproto/api'
34// Read credentials from .env file
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213+const agent = new AtpAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({
···21console.log('Connected! Your DID:', agent.session?.did)
2223const post = await agent.post({
24+ text: 'Hello from the #ATProtoChicago meetup! 🚀'
25})
2627console.log('Posted successfully!')
+2-2
activities/01b-emoji-art.js
···1#!/usr/bin/env bun
2-import { BskyAgent } from '@atproto/api'
34// Read credentials from .env
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213-const agent = new BskyAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({
···1#!/usr/bin/env bun
2+import { AtpAgent } from '@atproto/api'
34// Read credentials from .env
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213+const agent = new AtpAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({
+2-2
activities/02-rich-text.js
···1#!/usr/bin/env bun
2-import { BskyAgent, RichText } from '@atproto/api'
34// Read credentials from .env
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213-const agent = new BskyAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({
···1#!/usr/bin/env bun
2+import { AtpAgent, RichText } from '@atproto/api'
34// Read credentials from .env
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213+const agent = new AtpAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({
+110-45
activities/03-feed-generator.js
···1#!/usr/bin/env bun
2-import { BskyAgent } from '@atproto/api'
034// Read credentials from .env
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213-const agent = new BskyAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({
···2021console.log('Connected! Your DID:', agent.session?.did)
2223-// Create a simple feed generator record
24-// Note: This creates the metadata for a feed, but you'd need a server to actually generate the feed content
25-const feedRecord = {
26- did: `did:plc:${agent.session.did.split(':')[2]}`, // Use your actual DID
27- displayName: 'Chicago ATProto Meetup',
28- description: 'Posts from our Chicago ATProto meetup! Tag your posts with #ATProtoChicago',
29- avatar: undefined,
30- createdAt: new Date().toISOString()
31-}
0000003233-const rkey = 'chicago-meetup-feed'
00003435-console.log('\nCreating feed generator record...')
36-console.log('Feed details:', JSON.stringify(feedRecord, null, 2))
0000000000000000000000000000000000000000000000000000000000000000003738-try {
39- // First check if it already exists
40- const existing = await agent.com.atproto.repo.getRecord({
41- repo: agent.session.did,
42- collection: 'app.bsky.feed.generator',
43- rkey: rkey
44- }).catch(() => null)
4546- if (existing) {
47- console.log('\nFeed already exists! Updating...')
48- await agent.com.atproto.repo.putRecord({
49- repo: agent.session.did,
50- collection: 'app.bsky.feed.generator',
51- rkey: rkey,
52- record: feedRecord,
53- swapRecord: existing.data.cid
54- })
55- } else {
56- await agent.com.atproto.repo.putRecord({
57- repo: agent.session.did,
58- collection: 'app.bsky.feed.generator',
59- rkey: rkey,
60- record: feedRecord
61 })
62 }
06364- console.log('\nFeed generator record created successfully!')
65- console.log(`\nNote: This creates the feed metadata. To make it functional, you would need:`)
66- console.log('1. A server running the feed generation logic')
67- console.log('2. The feed to be published and indexed by Bluesky')
68- console.log(`\nYour feed URI: at://${agent.session.did}/app.bsky.feed.generator/${rkey}`)
69-70-} catch (error) {
71- console.error('Error creating feed:', error.message)
72-}
···1#!/usr/bin/env bun
2+import { AtpAgent } from '@atproto/api'
3+import { WebSocket } from 'ws'
45// Read credentials from .env
6const username = process.env.BSKY_USERNAME
···11 process.exit(1)
12}
1314+const agent = new AtpAgent({ service: 'https://bsky.social' })
1516console.log('Logging in...')
17await agent.login({
···2122console.log('Connected! Your DID:', agent.session?.did)
2324+// Store found posts
25+let postCount = 0
26+const foundPosts = []
27+28+console.log('\n🚀 Starting Feed Generator with Jetstream')
29+console.log('==========================================')
30+console.log('This connects to Bluesky\'s Jetstream service which provides:')
31+console.log('- Simple JSON format (not binary CAR/CBOR)')
32+console.log('- Filtered streams (just posts, not all events)')
33+console.log('- Clean, readable data')
34+console.log('\nLooking for #ATProtoChicago posts...')
35+console.log('Press Ctrl+C to stop\n')
36+37+// Connect to Jetstream - only get posts
38+const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post')
3940+ws.on('open', () => {
41+ console.log('✅ Connected to Jetstream!')
42+ console.log('Listening for posts...\n')
43+ console.log('═'.repeat(60))
44+})
4546+ws.on('message', async (data) => {
47+ try {
48+ const event = JSON.parse(data.toString())
49+50+ // Check if this is a post event
51+ if (event.commit && event.commit.record && event.commit.record.text) {
52+ const text = event.commit.record.text.toLowerCase()
53+54+ // Check for our hashtag
55+ if (text.includes('#atprotochicago') || (text.includes('atproto') && text.includes('chicago'))) {
56+ postCount++
57+ const timestamp = new Date().toLocaleTimeString()
58+59+ console.log(`\n🎯 [${timestamp}] Found post #${postCount} with #ATProtoChicago!`)
60+ console.log('─'.repeat(60))
61+62+ // Get author info from the event
63+ const did = event.did
64+ let authorHandle = did
65+66+ try {
67+ const profile = await agent.getProfile({ actor: did })
68+ authorHandle = profile.data.handle
69+ if (profile.data.displayName) {
70+ authorHandle = `${profile.data.displayName} (@${profile.data.handle})`
71+ }
72+ } catch {
73+ // If profile lookup fails, just show DID
74+ authorHandle = did.substring(0, 20) + '...'
75+ }
76+77+ console.log(`👤 Author: ${authorHandle}`)
78+ console.log(`📝 Post:`)
79+ console.log('')
80+81+ // Display the post text
82+ const lines = event.commit.record.text.split('\n')
83+ lines.forEach(line => {
84+ console.log(` ${line}`)
85+ })
86+87+ // Show metadata
88+ console.log('')
89+ console.log(`🔗 URI: at://${did}/app.bsky.feed.post/${event.commit.rkey}`)
90+ console.log(`🕐 Created: ${new Date(event.commit.record.createdAt).toLocaleString()}`)
91+92+ // Show engagement if available
93+ if (event.commit.record.reply) {
94+ console.log(`💬 This is a reply`)
95+ }
96+97+ console.log('─'.repeat(60))
98+ console.log(`\n📊 Total posts found: ${postCount}`)
99+ console.log('═'.repeat(60))
100+101+ foundPosts.push({
102+ timestamp: new Date(),
103+ author: authorHandle,
104+ text: event.commit.record.text,
105+ uri: `at://${did}/app.bsky.feed.post/${event.commit.rkey}`,
106+ did: did
107+ })
108+ }
109+ }
110+ } catch (error) {
111+ // Ignore parsing errors
112+ }
113+})
114115+ws.on('error', (error) => {
116+ console.error('\n❌ WebSocket error:', error.message)
117+})
0000118119+ws.on('close', () => {
120+ console.log('\n\n👋 Disconnected from Jetstream')
121+ console.log(`Found ${postCount} posts with #ATProtoChicago`)
122+123+ if (foundPosts.length > 0) {
124+ console.log('\n📋 Summary of posts found:')
125+ console.log('─'.repeat(60))
126+ foundPosts.forEach((post, i) => {
127+ console.log(`${i + 1}. ${post.author}: "${post.text.substring(0, 50)}${post.text.length > 50 ? '...' : ''}"`)
000000128 })
129 }
130+})
131132+// Graceful shutdown
133+process.on('SIGINT', () => {
134+ console.log('\n\nShutting down gracefully...')
135+ ws.close()
136+ process.exit(0)
137+})000
+2-2
activities/04-profile-updater.js
···1#!/usr/bin/env bun
2-import { BskyAgent } from '@atproto/api'
34// Read credentials from .env
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213-const agent = new BskyAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({
···1#!/usr/bin/env bun
2+import { AtpAgent } from '@atproto/api'
34// Read credentials from .env
5const username = process.env.BSKY_USERNAME
···10 process.exit(1)
11}
1213+const agent = new AtpAgent({ service: 'https://bsky.social' })
1415console.log('Logging in...')
16await agent.login({