Home | Send Feedback | Share on Bluesky |

Writing a Go program that posts to Bluesky

Published: 23. February 2025  •  go, bluesky

In this blog post, I will show you how to write programs in Go that post to Bluesky. Bluesky is a micro-blogging platform. Users can share short posts containing text, images, and videos. Bluesky implements the AT Protocol, an open communication protocol for distributed social networks.

The developers of the AT Protocol provide official libraries for Python, TypeScript, and Go.

Setup

After you set up a new Go program with go mod init, you can add the Bluesky library to your project with the following command:

go get github.com/bluesky-social/indigo

Authentication

To post a message to Bluesky, we need to authenticate with the Bluesky server. For this, we need the DID (Decentralized Identifier) of the user that we use for posting and the password.

The DID is a unique identifier that is used to identify users on the Bluesky network. It's different from the handle, which is a human-readable username that can be changed by the user. The DID is permanent and cannot be changed. The handle you see in the Bluesky app is just a friendly alias for the DID. To retrieve a Bluesky DID from a handle, you can use the AT Protocol's resolveHandle method. You can use the following curl command to fetch the DID of a handle:

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

The command above only works when you have a bsky.social handle. If you run your own PDS server, you need to send the request to your PDS server.

https://<my.domain.com>/xrpc/com.atproto.identity.resolveHandle?handle=<my-handle>

You can also resolve a handle with the SDKs. Here is an example in Go:

    client := &xrpc.Client{Host: "https://bsky.social"}
    handle, err := atproto.IdentityResolveHandle(context.Background(), client, "<my-handle>")
    if err != nil {
        return fmt.Errorf("failed to resolve handle: %w", err)
    }
    fmt.Println("Resolved handle:", handle.Did)

As with the curl command, if you use a self-hosted PDS server, you need to set the host in the client configuration to your PDS server.


For the password, you could use the password that you set up the account with. But it's better to use an app password. An app password has most of the same abilities as the user's account password. However, they're restricted from destructive actions such as account deletion or account migration. They are also restricted from creating additional app passwords. Additionally, you can specify if the app password should be able to access direct messages.

To create an app password, log in to the Bluesky app and go to Settings, then Privacy and Security, and then App Passwords.


For the following programs, I put the DID and the app password in a .env file.

BLUESKY_IDENTIFIER=did:...
BLUESKY_PASSWORD=w...

Hello World

With everything in place, we can start writing our first program. The following program posts a simple text message to Bluesky.

To read the .env file, I added the godotenv library to the project with this command: go get github.com/joho/godotenv. The following code reads the .env file and sets up the client. Replace bsky.social with the address of your self-hosted PDS server if you use one.

  client := &xrpc.Client{
    Host: "https://bsky.social",
  }

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

  did := os.Getenv("BLUESKY_IDENTIFIER")
  appPassword := os.Getenv("BLUESKY_PASSWORD")

main.go

Next, the code sends a request with the DID and app password to the Bluesky server to create a session. If the request is successful, the response contains a JWT token that can be used to authenticate future requests. For that, a new client authClient is created with the JWT token. Further requests will use this client.

  auth, err := atproto.ServerCreateSession(
    context.Background(),
    client,
    &atproto.ServerCreateSession_Input{
      Identifier: did,
      Password:   appPassword,
    },
  )
  if err != nil {
    log.Fatal("Failed to authenticate:", err)
  }

  authClient := xrpc.Client{
    Host: client.Host,
    Auth: &xrpc.AuthInfo{AccessJwt: auth.AccessJwt},
  }

main.go

The last part of the program creates a new post by creating a FeedPost struct with the text of the message and the creation time. The program then sends a request to the Bluesky server to post the message.

  currentTime := time.Now()
  post := &bsky.FeedPost{
    Text:      fmt.Sprintf("👋 Hello World! Posted from my Go program at %s", currentTime.Format("15:04:05")),
    CreatedAt: currentTime.Format(time.RFC3339),
  }

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

main.go

If you run the program and everything is set up correctly, you should see a new post in the user's feed.

Earthquake

In the next example, I show you how to create a program that posts a message to Bluesky every time an earthquake happens. The program will run periodically, fetch the latest earthquake data from the USGS Earthquake Hazards Program as a CSV file, filter the data, and post a message.

To prevent any duplicate posts, the program stores the IDs of the posted earthquakes in an embedded database. I use Pebble for this. I added the database library with the command go get github.com/cockroachdb/pebble to the project.

The first part of the program downloads the CSV file and reads the earthquake data into a slice of Earthquake structs. Thanks to the support for CSV files in the Go standard library (encoding/csv), this is quite easy to do.

type Earthquake struct {
  Time   string
  Mag    float64
  Status string
  ID     string
  Place  string
  Type   string
}

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

  resp, err := http.Get("https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_hour.csv")
  if err != nil {
    log.Fatal("Failed to download CSV:", err)
  }
  defer resp.Body.Close()

  if resp.StatusCode != http.StatusOK {
    log.Fatalf("Unexpected status code: %d", resp.StatusCode)
  }

  reader := csv.NewReader(resp.Body)
  headers, err := reader.Read()
  if err != nil {
    log.Fatal("Failed to read CSV header:", err)
  }

  var earthquakes []Earthquake
  for {
    record, err := reader.Read()
    if err == io.EOF {
      break
    }
    if err != nil {
      log.Fatal("Failed to read CSV record:", err)
    }

    quakeMap := make(map[string]string)
    for i, h := range headers {
      quakeMap[h] = record[i]
    }

    mag, err := strconv.ParseFloat(quakeMap["mag"], 64)
    if err != nil {
      log.Printf("Skipping invalid magnitude '%s' for ID %s", quakeMap["mag"], quakeMap["id"])
      continue
    }

    earthquakes = append(earthquakes, Earthquake{
      Time:   quakeMap["time"],
      Mag:    mag,
      Status: quakeMap["status"],
      ID:     quakeMap["id"],
      Place:  quakeMap["place"],
      Type:   quakeMap["type"],
    })
  }

main.go

After reading the data, the program filters the earthquakes by magnitude and status. We only want to post earthquakes with a magnitude of 5 or higher that have been reviewed. After that, the code opens the embedded database for storing the IDs of the posted earthquakes.

  var filtered []Earthquake
  for _, q := range earthquakes {
    if q.Mag >= 5 && q.Status == "reviewed" {
      filtered = append(filtered, q)
    }
  }

  db, err := pebble.Open("quake-db", &pebble.Options{})
  if err != nil {
    log.Fatal("Failed to open database:", err)
  }
  defer db.Close()

main.go

The program now loops over the filtered earthquakes. For each earthquake, it checks if the ID is already in the database. If it's not, the program posts a message to Bluesky and stores the ID in the database.

  for _, q := range filtered {
    key := []byte(q.ID)
    _, closer, err := db.Get(key)
    if err == nil {
      closer.Close()
      continue
    }
    if !errors.Is(err, pebble.ErrNotFound) {
      log.Printf("Database error for ID %s: %v", q.ID, err)
      continue
    }

    t, err := time.Parse("2006-01-02T15:04:05Z", q.Time)
    if err != nil {
      log.Printf("Failed to parse time for ID %s: %v", q.ID, err)
      continue
    }

    isoTimestamp := t.Format("2006-01-02 15:04:05 UTC")

    msg := fmt.Sprintf("%.1f magnitude %s\n%s\n%s\nhttps://earthquake.usgs.gov/earthquakes/eventpage/%s/executive",
      q.Mag, q.Type, isoTimestamp, q.Place, q.ID)

    if err := postToBluesky(msg); err != nil {
      log.Printf("Failed to post for ID %s: %v", q.ID, err)
      continue
    }

    if err := db.Set(key, []byte("posted"), &pebble.WriteOptions{}); err != nil {
      log.Printf("Failed to store ID %s: %v", q.ID, err)
    }
  }
  _ = db.Flush()

main.go

For posting the message to Bluesky, the code calls the postToBluesky method.

The first part of this method is similar to the one in the Hello World program. It creates a session with the Bluesky server and instantiates an authenticated client with the JWT token.

  authClient := xrpc.Client{
    Host: client.Host,
    Auth: &xrpc.AuthInfo{AccessJwt: auth.AccessJwt},
  }

main.go

This message contains a link to the USGS website with more information about the earthquake. Creating a link is done with a facet. Bluesky currently supports three kinds of facets:

To create a facet, you need to know the range of the string you want to decorate. The range has an inclusive start and an exclusive end. Note that the positions are byte positions, not character positions. Bluesky uses UTF-8 code units to index facets. UTF-8 is a variable-length encoding that uses 1 to 4 bytes per character. In this example, the message contains only ASCII characters, which are each encoded as a single byte in UTF-8. Therefore, in this code, we can use strings.Index and len to determine the start and end positions of the text that should be decorated with a link.

For more information on facets, see the Bluesky documentation.

  linkStartPos := strings.Index(text, "https://")
  linkEndPos := len(text)
  link := text[linkStartPos:linkEndPos]

  linkFacet := &bsky.RichtextFacet{
    Features: []*bsky.RichtextFacet_Features_Elem{
      {
        RichtextFacet_Link: &bsky.RichtextFacet_Link{
          Uri: link,
        },
      },
    },
    Index: &bsky.RichtextFacet_ByteSlice{
      ByteEnd:   int64(linkEndPos),
      ByteStart: int64(linkStartPos),
    },
  }

main.go

After creating the facet, the rest of the code looks similar to the Hello World program. The only difference is that the language of the post is set to English and the facet is added to the post. Setting the language of a post is recommended, as it helps users that filter their feed by language.

  post := &bsky.FeedPost{
    Text:      text,
    Langs:     []string{"en"},
    CreatedAt: time.Now().Format(time.RFC3339),
    Facets:    []*bsky.RichtextFacet{linkFacet},
  }

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

main.go

Conclusion

In this blog post, I showed you how to write a Go program that posts to Bluesky. The Go SDK makes it easy to interact with the Bluesky server. You can use the SDK for much more than just posting messages. It supports all the features of the AT Protocol, such as creating and updating records, following users, liking posts, and sending direct messages.

I hope this blog post was helpful to you. If you have any questions or feedback, feel free to reach out to me on Bluesky. The earthquake bot I showed you in this blog post is live on this Bluesky account: @earthquake.rasc.ch.