Home | Send Feedback | Share on Bluesky |

Writing a Reply Bot for Bluesky in Go

Published: 17. July 2025  •  go, bluesky

In a previous blog post I showed you how to write a Go program that posts to Bluesky. In this post, I will show you how to write a Reply Bot that replies to posts on Bluesky. Like the previous post, this bot is also written in Go.

Workflow

To mention someone on Bluesky, you insert the @ symbol followed by their handle into the post. The mentioned user will receive a notification that they have been mentioned.

The bot will periodically poll Bluesky for these mentions, extract the body text and sender from the post, send the text to the Gemini 2.5 Flash model, and then send the generated text from the LLM to the sender.

Yes
No
Poll Bluesky for Mentions
New mentions?
Extract Body Text and Sender
Send Text to gemini-2.5-flash
Reply to sender on Bluesky

Setup

After initializing the program with go mod init blueskyreplybot, we will need to install the following packages:

go get github.com/bluesky-social/indigo
go get github.com/firebase/genkit/go
go get github.com/joho/godotenv

The indigo package allows us to interact with the Bluesky API, genkit is used for accessing the Gemini API, and godotenv is used for loading environment variables from a .env file. For this application I put the following environment variables in the .env file:

BLUESKY_IDENTIFIER=did:...
BLUESKY_PASSWORD=n...
GEMINI_API_KEY=A....

Note that the Bluesky identifier is a DID (Decentralized Identifier), not the handle you see in the Bluesky app, like @example.bsky.social. To get the DID from a handle, you can call this endpoint:

curl -X GET "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=<handle>"

For the password, it is recommended to create an app password instead of using your main password. To create an app password, log in to the Bluesky app and go to Settings, then Privacy and Security, and then App Passwords.

The Gemini API key can be obtained from the Google AI Studio.

In the Go program, we will load these environment variables using the godotenv library:

  err := godotenv.Load()
  if err != nil {
    log.Fatal("Error loading .env file")
  }

main.go

And then we can access them using os.Getenv:

    blueskyIdentifier := os.Getenv("BLUESKY_IDENTIFIER")
    blueskyPassword := os.Getenv("BLUESKY_PASSWORD")
    geminiApiKey := os.Getenv("GEMINI_API_KEY")

Instead of using godotenv, you can also set these environment variables directly in your shell or use a different method to load them.

Main loop

The main loop of the bot will run indefinitely, checking for new notifications every minute. In each iteration, it will create an authenticated Bluesky client, check for notifications, and process any messages found.

  ticker := time.NewTicker(1 * time.Minute)
  defer ticker.Stop()

  for range ticker.C {
    authClient, auth, err := createAuthenticatedBlueskyClient()
    if err != nil {
      log.Fatal("Error creating authenticated Bluesky client:", err)
    }

    notifications, err := checkNotifications(authClient)
    if err != nil {
      log.Fatal("Error checking notifications:", err)
    }

    for _, notif := range notifications {
      processNotification(authClient, auth, genkitInstance, notif)
    }
  }

main.go

Retrieving notifications

The bot uses the following code to check for mentioned posts. If somebody mentions a user, they will see these posts in their notifications. The Bluesky API provides the NotificationListNotifications method to list notifications. The type of notification is determined by the reason field. Bluesky supports several notification types like: like, repost, follow, mention, reply, quote, starterpack-joined, verified, unverified.

Because our bot is only interested in mentions, it will filter the notifications by the mention reason. The code also sets a limit of 5 notifications per request to avoid fetching too many at once. If there are more than 5 notifications, the NotificationListNotifications method will return a cursor that can be used to fetch the next batch of notifications.

Each notification can be either read or unread. The IsRead field indicates whether the notification has been read or not. The code loops through the notifications and collects all unread notifications until it finds a read notification or there are no more notifications to fetch.

After collecting all unread notifications, the bot marks them as seen by calling the NotificationUpdateSeen method. So the next time this method runs, all notifications that have been previously retrieved will not be fetched again.

func checkNotifications(authClient *xrpc.Client) ([]*bsky.NotificationListNotifications_Notification, error) {
  limit := int64(5)
  reasons := []string{"mention"}
  cursor := ""
  var allUnreadNotifications []*bsky.NotificationListNotifications_Notification

  for {
    notificationsList, err := bsky.NotificationListNotifications(context.Background(), authClient, cursor, limit, false, reasons, "")
    if err != nil {
      return nil, fmt.Errorf("failed to list notifications: %w", err)
    }

    if len(notificationsList.Notifications) == 0 {
      break
    }

    hasReadMessages := false
    var unreadInBatch []*bsky.NotificationListNotifications_Notification

    for _, notif := range notificationsList.Notifications {
      if notif.IsRead {
        hasReadMessages = true
      } else {
        unreadInBatch = append(unreadInBatch, notif)
      }
    }

    allUnreadNotifications = append(allUnreadNotifications, unreadInBatch...)

    if hasReadMessages {
      break
    }

    if notificationsList.Cursor == nil {
      break
    }

    cursor = *notificationsList.Cursor
  }

  if len(allUnreadNotifications) > 0 {
    seenInput := &bsky.NotificationUpdateSeen_Input{
      SeenAt: time.Now().UTC().Format(time.RFC3339),
    }

    err := bsky.NotificationUpdateSeen(context.Background(), authClient, seenInput)
    if err != nil {
      return nil, fmt.Errorf("failed to mark notifications as seen: %w", err)
    }
  }

  return allUnreadNotifications, nil
}

main.go

Generate reply with Gemini

For each retrieved notification the bot calls the following method. This method extracts the text from the post and cleans it up. The mentioned handle (@handle) is always part of the post text. Because this is not relevant, the code removes it from the text.

func processNotification(authClient *xrpc.Client, auth *atproto.ServerCreateSession_Output, genkitInstance *genkit.Genkit, notif *bsky.NotificationListNotifications_Notification) {
  var postText string
  feedPost, ok := notif.Record.Val.(*bsky.FeedPost)
  if !ok {
    log.Printf("Notification record is not a FeedPost: %v", notif.Record.Val)
    return
  }

  postText = feedPost.Text

  if postText == "" {
    return
  }

  cleanedText := strings.ReplaceAll(postText, "@llm.rasc.ch", "")
  cleanedText = strings.TrimSpace(cleanedText)

  if cleanedText == "" {
    return
  }

main.go

After cleaning the text, the bot sends the post text to Gemini to generate a reply.

  aiResponse, err := generateGeminiResponse(genkitInstance, cleanedText)
  if err != nil {
    log.Printf("Failed to generate AI response: %v", err)
    return
  }

main.go

Calling the Gemini API is done using the genkit library, which simplifies the interaction with the Gemini model. All you need is to initialize a genkit instance with the Google AI plugin and the desired model.

  genkitInstance, err := genkit.Init(context.Background(),
    genkit.WithPlugins(&googlegenai.GoogleAI{}),
    genkit.WithDefaultModel("googleai/gemini-2.5-flash"),
  )

main.go

To send a request to the Gemini model, call genkit.Generate and pass the client instance and prompt. The response can be extracted from the response object using resp.Text().

func generateGeminiResponse(genkitInstance *genkit.Genkit, userMessage string) (string, error) {
  prompt := fmt.Sprintf(`You are a helpful AI assistant responding to a message on Bluesky (a social media platform similar to Twitter).
Please provide a thoughtful, engaging, and helpful response to the following message.
Keep your response concise and appropriate for social media (under 280 characters when possible).

User message: %s

Response:`, userMessage)

  resp, err := genkit.Generate(context.Background(), genkitInstance, ai.WithPrompt(prompt))
  if err != nil {
    return "", fmt.Errorf("failed to generate response from Gemini: %w", err)
  }

  response := resp.Text()
  if response == "" {
    return "", fmt.Errorf("received empty response from Gemini")
  }

  return response, nil
}

main.go

To learn more about Genkit check out the documentation.

Sending reply

After the LLM generated a response, the bot sends the reply back to the original sender on Bluesky. This can be done using the RepoCreateRecord method from the Bluesky API. Very similar to posting a normal message on Bluesky, the program creates a bsky.FeedPost object. But here it also fills in the Reply field to indicate that this is a reply to an existing post. The Root and Parent fields in the ReplyRef struct point to the original post that this new post is replying to.

func sendReply(authClient *xrpc.Client, auth *atproto.ServerCreateSession_Output, originalNotif *bsky.NotificationListNotifications_Notification, replyText string) error {

  replyRecord := bsky.FeedPost{
    Text:      replyText,
    CreatedAt: time.Now().UTC().Format(time.RFC3339),
    Reply: &bsky.FeedPost_ReplyRef{
      Root: &atproto.RepoStrongRef{
        Uri: originalNotif.Uri,
        Cid: originalNotif.Cid,
      },
      Parent: &atproto.RepoStrongRef{
        Uri: originalNotif.Uri,
        Cid: originalNotif.Cid,
      },
    },
  }

  encodedRecord := &util.LexiconTypeDecoder{Val: &replyRecord}

  _, err := atproto.RepoCreateRecord(
    context.Background(),
    authClient,
    &atproto.RepoCreateRecord_Input{
      Repo:       auth.Did,
      Collection: "app.bsky.feed.post",
      Record:     encodedRecord,
    },
  )

  return err
}

main.go


With everything in place and the bot running, when a user mentions the bot account in a post, the bot will reply with a generated response from the Gemini model. In the Bluesky app this looks like this:

reply bot example

Conclusion

In this blog post, I showed you how to create a simple Reply Bot for Bluesky using Go. The bot polls for mentions, generates replies using the Gemini 2.5 Flash model, and sends the replies back to the original sender.

Be careful when writing a reply bot that generates responses with an LLM, because it can easily generate replies that are not appropriate for the context. The bot can also easily be overwhelmed if it suddenly goes viral, especially if the bot calls a 3rd party API that has a rate limit and costs money. So make sure to add some rate limiting to your bot to avoid unexpected costs.