a tool to help your Letta AI agents navigate bluesky
1import os
2from typing import Optional, List, Dict, Any
3from atproto import Client, models
4
5
6def _normalize_datetime(date_str: Optional[str]) -> Optional[str]:
7 """
8 Convert date string to ISO 8601 datetime format.
9 Accepts: "2024-01-15" or "2024-01-15T10:30:00Z"
10 Returns: "2024-01-15T00:00:00Z" or the original if already full datetime
11 """
12 if not date_str:
13 return None
14
15 # If it's already a full datetime (contains 'T'), return as-is
16 if 'T' in date_str:
17 return date_str
18
19 # If it's just a date (YYYY-MM-DD), add time component
20 if len(date_str) == 10 and date_str.count('-') == 2:
21 return f"{date_str}T00:00:00Z"
22
23 # Return as-is if we can't parse it (let the API handle the error)
24 return date_str
25
26
27def search_bluesky(
28 query: str,
29 search_type: str = "posts",
30 author: Optional[str] = None,
31 mentions: Optional[str] = None,
32 hashtags: Optional[List[str]] = None,
33 domain: Optional[str] = None,
34 language: Optional[str] = None,
35 sort: str = "latest",
36 since: Optional[str] = None,
37 until: Optional[str] = None,
38 limit: int = 25
39) -> Dict[str, Any]:
40 """
41 Search Bluesky for posts or users with comprehensive filtering and sorting options.
42
43 This tool allows you to search across Bluesky's content using keywords, phrases, and
44 multiple filter criteria. You can search for posts with specific hashtags, by particular
45 authors, mentioning certain users, linking to domains, or within date ranges. You can
46 also search for user accounts. The tool supports Lucene query syntax for advanced
47 searches with AND, OR, and NOT operators.
48
49 Use this tool when you need to find specific content on Bluesky, discover posts about
50 topics, find posts by or mentioning users, or search for user accounts.
51
52 Args:
53 query (str): The search keywords or phrases to find. This parameter is required and
54 cannot be empty.
55
56 - For simple searches: Use plain keywords like "artificial intelligence"
57 - For exact phrases: Use quotes like "climate change policy"
58 - For advanced searches: Use Lucene syntax with AND, OR, NOT operators
59 Example: "python AND (tutorial OR guide) NOT beginner"
60
61 The query is matched against post text content or user names/handles depending
62 on the search_type parameter.
63
64 search_type (str, optional): The type of content to search for. Defaults to "posts".
65
66 - "posts": Search through all Bluesky posts (status updates, replies, etc.)
67 - "users": Search for user accounts by handle or display name
68
69 This parameter determines which filters are available. Most filters only work
70 with "posts" search type.
71
72 author (Optional[str]): Filter posts to only those created by a specific user.
73 Only applies when search_type="posts". Defaults to None (search all users).
74
75 Provide the user's handle (e.g., "user.bsky.social") or their DID
76 (e.g., "did:plc:..."). This is useful for searching within a specific user's
77 posts.
78
79 mentions (Optional[str]): Filter posts to only those mentioning a specific user.
80 Only applies when search_type="posts". Defaults to None (no mention filter).
81
82 Provide the mentioned user's handle or DID. This finds posts that @mention
83 the specified user.
84
85 hashtags (Optional[List[str]]): Filter posts to only those containing specific hashtags.
86 Only applies when search_type="posts". Defaults to None (no hashtag filter).
87
88 IMPORTANT: Provide hashtag text WITHOUT the # symbol.
89 - Correct: ["python", "programming"]
90 - Incorrect: ["#python", "#programming"]
91
92 When multiple hashtags are provided, posts must contain ALL of them (AND logic).
93
94 domain (Optional[str]): Filter posts to only those containing links to a specific domain.
95 Only applies when search_type="posts". Defaults to None (no domain filter).
96
97 Provide just the domain name without protocol or path.
98 Examples: "nytimes.com", "github.com", "example.org"
99
100 language (Optional[str]): Filter posts to only those in a specific language.
101 Only applies when search_type="posts". Defaults to None (all languages).
102
103 Use ISO 639-1 language codes:
104 - "en" - English
105 - "es" - Spanish
106 - "ja" - Japanese
107 - "fr" - French
108 - "de" - German
109 - "pt" - Portuguese
110
111 sort (str, optional): Sort order for post results. Only applies when search_type="posts".
112 Defaults to "latest".
113
114 - "latest": Newest posts first (chronological order, most recent first)
115 - "top": Most popular posts first (by engagement metrics)
116
117 User searches are always sorted by relevance and ignore this parameter.
118
119 since (Optional[str]): Filter posts to only those created after this date/time.
120 Only applies when search_type="posts". Defaults to None (no start date).
121
122 Accepts two formats:
123 - Simple date: "2024-01-15" (automatically converts to midnight UTC)
124 - Full datetime: "2024-01-15T10:30:00Z" (ISO 8601 format)
125
126 until (Optional[str]): Filter posts to only those created before this date/time.
127 Only applies when search_type="posts". Defaults to None (no end date).
128
129 Accepts two formats:
130 - Simple date: "2024-12-31" (automatically converts to midnight UTC)
131 - Full datetime: "2024-12-31T23:59:59Z" (ISO 8601 format)
132
133 limit (int, optional): Maximum number of results to return. Defaults to 25.
134
135 Must be between 1 and 100. Higher limits take longer but provide more results.
136 Be mindful that very high limits may take significant time to process.
137
138 Returns:
139 Dict[str, Any]: A dictionary containing the search results with the following keys:
140
141 Common keys (all searches):
142 - status (str): Either "success" or "error"
143 - search_type (str): Either "posts" or "users"
144 - query (str): The search query that was executed
145 - result_count (int): Number of results found
146
147 For successful post searches:
148 - posts (List[Dict]): List of post objects, each containing:
149 - author (str): The handle of the post author
150 - authorDID (str): The DID of the post author
151 - uri (str): The AT Protocol URI of the post
152 - message (str): The text content of the post
153 - posted-datetime (str): When the post was created
154 - replies (int): Number of replies
155 - reposts (int): Number of reposts
156 - likes (int): Number of likes
157 - quotes (int): Number of quote posts
158
159 For successful user searches:
160 - users (List[Dict]): List of user objects, each containing:
161 - handle (str): The user's handle
162 - did (str): The user's DID
163 - display_name (str): The user's display name
164 - bio (str): The user's bio/description
165 - followers (int): Follower count
166 - following (int): Following count
167 - posts (int): Post count
168 - avatar_url (str): URL to avatar image (if present)
169 - profile_url (str): Link to view profile on bsky.app
170
171 For errors:
172 - message (str): Human-readable error description with guidance
173
174 Examples:
175 # Simple search for posts about AI
176 search_bluesky(query="artificial intelligence")
177
178 # Search for recent posts by a specific user
179 search_bluesky(
180 query="announcement",
181 author="bsky.app"
182 )
183
184 # Find posts with specific hashtags (multiple tags = AND logic)
185 search_bluesky(
186 query="tutorial",
187 hashtags=["python", "programming"]
188 )
189
190 # Search for popular posts mentioning someone
191 search_bluesky(
192 query="great work",
193 mentions="alice.bsky.social",
194 sort="top"
195 )
196
197 # Find posts with links to a specific domain
198 search_bluesky(
199 query="article",
200 domain="nytimes.com"
201 )
202
203 # Search posts in a specific language
204 search_bluesky(
205 query="news",
206 language="es"
207 )
208
209 # Search posts within a date range
210 search_bluesky(
211 query="conference",
212 since="2024-01-01",
213 until="2024-12-31"
214 )
215
216 # Search for user accounts
217 search_bluesky(
218 query="software engineer",
219 search_type="users"
220 )
221
222 # Get top posts with more results
223 search_bluesky(
224 query="bluesky",
225 sort="top",
226 since="2024-10-28",
227 limit=50
228 )
229
230 # Advanced search with Lucene syntax
231 search_bluesky(
232 query="python AND (tutorial OR guide) NOT beginner"
233 )
234 """
235 try:
236 # Validate query
237 if not query or len(query.strip()) == 0:
238 return {
239 "status": "error",
240 "message": "Error: The search query parameter is empty. To resolve this, provide keywords or "
241 "phrases to search for, such as 'artificial intelligence' or 'climate change'. "
242 "This is a common mistake and can be fixed by calling the tool again with a valid search query."
243 }
244
245 # Validate search_type
246 valid_search_types = ["posts", "users"]
247 if search_type not in valid_search_types:
248 return {
249 "status": "error",
250 "message": f"Error: The search_type '{search_type}' is not valid. To resolve this, use either 'posts' "
251 f"to search for posts or 'users' to search for user accounts. Feed search is not supported. "
252 f"This is a common parameter error that can be fixed by using one of the supported search types."
253 }
254
255 # Validate limit
256 if limit < 1 or limit > 100:
257 return {
258 "status": "error",
259 "message": f"Error: The limit parameter {limit} is out of range. To resolve this, choose a value between "
260 f"1 and 100. Lower limits return results faster, while higher limits provide more comprehensive "
261 f"results. This is a validation error that can be fixed by adjusting the limit parameter."
262 }
263
264 # Validate sort for posts
265 if search_type == "posts" and sort not in ["latest", "top"]:
266 return {
267 "status": "error",
268 "message": f"Error: The sort parameter '{sort}' is not valid for post searches. To resolve this, use "
269 f"'latest' for newest posts first (chronological) or 'top' for most popular posts first "
270 f"(by engagement). This is a parameter validation error that can be fixed by using a supported "
271 f"sort option."
272 }
273
274 # Validate hashtags format
275 if hashtags:
276 for tag in hashtags:
277 if tag.startswith('#'):
278 corrected = [t.lstrip('#') for t in hashtags]
279 return {
280 "status": "error",
281 "message": f"Error: The hashtag '{tag}' includes the # symbol, but hashtags should be provided "
282 f"without the # prefix. To resolve this, use {corrected} instead. This is a common "
283 f"formatting mistake that can be fixed by removing the # symbol from hashtag values."
284 }
285
286 # Get credentials
287 username = os.environ.get("BSKY_USERNAME")
288 password = os.environ.get("BSKY_APP_PASSWORD")
289 if not username or not password:
290 return {
291 "status": "error",
292 "message": "Error: Missing Bluesky authentication credentials. The BSKY_USERNAME and BSKY_APP_PASSWORD "
293 "environment variables are not set. To resolve this, ask the user to configure these environment "
294 "variables with valid Bluesky credentials. The user can generate an app password in Bluesky "
295 "Settings → App Passwords. This is a configuration issue that the user needs to address before "
296 "you can search Bluesky."
297 }
298
299 # Login to Bluesky
300 client = Client()
301 client.login(username, password)
302
303 if search_type == "posts":
304 # Normalize date formats to full ISO 8601 datetime
305 normalized_since = _normalize_datetime(since)
306 normalized_until = _normalize_datetime(until)
307
308 # Build search parameters
309 params = models.AppBskyFeedSearchPosts.Params(
310 q=query,
311 limit=min(limit, 100)
312 )
313
314 # Add optional filters
315 if author:
316 params.author = author
317 if mentions:
318 params.mentions = mentions
319 if hashtags:
320 params.tag = hashtags
321 if domain:
322 params.domain = domain
323 if language:
324 params.lang = language
325 if sort:
326 params.sort = sort
327 if normalized_since:
328 params.since = normalized_since
329 if normalized_until:
330 params.until = normalized_until
331
332 # Execute search
333 try:
334 response = client.app.bsky.feed.search_posts(params)
335 except Exception as e:
336 return {
337 "status": "error",
338 "message": f"Error: Failed to search posts. The Bluesky API returned: {str(e)}. To resolve this, "
339 f"verify your search parameters are correct and try again. This type of error can occur due "
340 f"to invalid filter combinations, malformed date strings, temporary API issues, or network "
341 f"problems, and usually succeeds on retry."
342 }
343
344 # Transform results to compact format
345 posts = []
346 for item in response.posts:
347 record = item.record
348 posts.append({
349 "author": item.author.handle,
350 "authorDID": item.author.did,
351 "uri": item.uri,
352 "message": getattr(record, 'text', ''),
353 "posted-datetime": getattr(record, 'created_at', ''),
354 "replies": getattr(item, 'reply_count', 0),
355 "reposts": getattr(item, 'repost_count', 0),
356 "likes": getattr(item, 'like_count', 0),
357 "quotes": getattr(item, 'quote_count', 0)
358 })
359
360 return {
361 "status": "success",
362 "search_type": "posts",
363 "query": query,
364 "result_count": len(posts),
365 "posts": posts
366 }
367
368 else: # search_type == "users"
369 # Build search parameters for users
370 params = models.AppBskyActorSearchActors.Params(
371 q=query,
372 limit=min(limit, 100)
373 )
374
375 # Execute search
376 try:
377 response = client.app.bsky.actor.search_actors(params)
378 except Exception as e:
379 return {
380 "status": "error",
381 "message": f"Error: Failed to search users. The Bluesky API returned: {str(e)}. To resolve this, "
382 f"verify your search query is correct and try again. This type of error can occur due to "
383 f"temporary API issues or network problems, and usually succeeds on retry."
384 }
385
386 # Transform results to compact format
387 users = []
388 for actor in response.actors:
389 users.append({
390 "handle": actor.handle,
391 "did": actor.did,
392 "display_name": getattr(actor, 'display_name', '') or actor.handle,
393 "bio": getattr(actor, 'description', '') or '',
394 "followers": getattr(actor, 'followers_count', 0),
395 "following": getattr(actor, 'follows_count', 0),
396 "posts": getattr(actor, 'posts_count', 0),
397 "avatar_url": getattr(actor, 'avatar', None),
398 "profile_url": f"https://bsky.app/profile/{actor.handle}"
399 })
400
401 return {
402 "status": "success",
403 "search_type": "users",
404 "query": query,
405 "result_count": len(users),
406 "users": users
407 }
408
409 except ImportError:
410 return {
411 "status": "error",
412 "message": "Error: The atproto Python package is not installed in the execution environment. "
413 "To resolve this, the system administrator needs to install it using 'pip install atproto'. "
414 "This is a dependency issue that prevents the tool from connecting to Bluesky. Once the "
415 "package is installed, this tool will work normally."
416 }
417 except Exception as e:
418 return {
419 "status": "error",
420 "message": f"Error: An unexpected issue occurred while searching Bluesky: {str(e)}. To resolve this, "
421 f"verify your credentials are correct, check your network connection, and ensure your search "
422 f"parameters are valid. This type of error is uncommon but can usually be resolved by retrying "
423 f"or adjusting the search parameters."
424 }