August 7, 2026

Giving a Telegram bot's photos a real social preview card.

Kitty Space started as a Telegram bot. People send photos of street cats, the bot saves them, and it sends a link back. The next thing everyone wanted was to share that cat on Twitter or in a chat. Paste the link, though, and no preview showed up: a bare URL, or at best a card with no image. Here is what we found, and the small server built around it.

The reason is clear once you see it. Social platforms do not build a card from an image URL or a t.me link. They send a crawler that fetches the URL and reads <meta> tags from the HTML: og:image, og:title, and the Twitter equivalents. A photo in object storage is a file rather than a web page. It has no HTML and nowhere to put those tags, so there is nothing for the platform to unfurl.

Why a separate page is the fix

Meta tags cannot attach to a JPEG. The links available pointed either at the bot or at the raw image, and neither is an HTML page a crawler can read tags from. At that point there was no website either, only the bot and a storage bucket.

So each photo needs a web page that does not exist yet, and something has to serve it. That something is a small HTTP server with one route. Its whole job is to take a photo id and return an HTML document carrying the right tags. The share link then points at this server instead of at the image.

The flow becomes:

  1. The share link is https://…/p/{photo-id}, pointing at the preview server.
  2. The crawler fetches it and gets an HTML page whose og:image points at the real photo on the CDN.
  3. The platform reads the tags and builds the card.

The image keeps living on the CDN and never moves. The new page exists only to hold the tags that describe it.

Step 1: a server with one route

The server is plain Go with a single handler. Given a photo id, it builds the CDN URL of the image and the public URL of this page, renders the HTML, and returns it:

r.HandleFunc("/p/{id}", func(w http.ResponseWriter, r *http.Request) {
  id := mux.Vars(r)["id"]
  imageURL := fmt.Sprintf("https://%s.%s/photos/%s", bucket, cdn, id)  // the real photo
  pageURL  := publicBaseURL + "/p/" + id                                // this page

  html, _ := gen.GetPreviewHTML(imageURL, pageURL, shouldRedirect)
  w.Header().Set("Content-Type", "text/html; charset=utf-8")
  w.Header().Set("Cache-Control", "public, max-age=31536000")  // a photo's page never changes
  w.Write([]byte(html))
})

There is no database lookup and no image work here. The id alone says where the photo lives, so the page is built and served in one step.

Step 2: the tags that make the card

The HTML is mostly head. The body barely matters to a crawler; the tags are the point:

<meta property="og:title"  content="🐱 Street cat spotted!">
<meta property="og:image"  content="{{.ImageURL}}">   <!-- absolute CDN url -->
<meta property="og:url"    content="{{.PageURL}}">
<meta property="og:type"   content="article">
<meta name="twitter:card"  content="summary_large_image">
<meta name="twitter:image" content="{{.ImageURL}}">

og:image and twitter:image both point at the photo on the CDN. summary_large_image asks Twitter for the full-width layout rather than a small side thumbnail.

Step 3: share the page, not the image

This is the step that is easy to miss. The bot’s share buttons have to link to the preview page rather than the photo, so the share URL is built around /p/{id}:

func GetPreviewURL(baseURL, imageID string) string {
  return fmt.Sprintf("%s/p/%s", baseURL, imageID)
}

func GetTwitterShareURL(baseURL, text, imageID string) string {
  return fmt.Sprintf(
    "https://twitter.com/intent/tweet?text=%s&url=%s",
    url.QueryEscape(text),
    url.QueryEscape(GetPreviewURL(baseURL, imageID)),   // the page, so the card resolves
  )
}

Now a “Share on Twitter” button in the bot opens a tweet whose link is the preview page, and the card fills in on its own.

The parts that took trial and error

The shape is small. Getting every platform to honour it was the slow part.

The og:image has to be an absolute URL on a public host. A relative path, or a link behind auth, and the card comes back blank. Pointing it straight at the CDN keeps the image fast and reachable.

summary_large_image is what turns a Twitter card from a tiny side thumbnail into the full-width photo. Without it the cat is a postage stamp.

The page is cached hard. A photo’s preview never changes, so max-age is a year. Crawlers refetch on their own schedule, and the cache keeps repeat shares cheap.

There is a fallback too: any path that is not a known photo still returns a valid page with default tags, so a mistyped or stale link degrades into “this cat couldn’t be found” rather than a broken card.

The redirect came later

A showcase site appeared much later, with a real profile page per cat. The preview server did not need replacing. It learned to send humans onward while keeping bots in place.

The lever is the user agent. A crawler should stay on the page so it can read the tags; a person should land on the actual profile. So the page redirects only when the visitor is not a known bot:

func isBot(userAgent string) bool {
  bots := []string{
    "facebookexternalhit", "Twitterbot", "Slackbot",
    "TelegramBot", "LinkedInBot", "WhatsApp", "Googlebot",
  }
  ua := strings.ToLower(userAgent)
  for _, b := range bots {
    if strings.Contains(ua, strings.ToLower(b)) { return true }
  }
  return false
}

shouldRedirect := !isBot(r.UserAgent())

When shouldRedirect is set, the page adds a redirect; otherwise it stays put for the crawler:

{{if .ShouldRedirect}}
<meta http-equiv="refresh" content="0; url={{.RedirectURL}}">
<script>setTimeout(() => location.replace("{{.RedirectURL}}"), 1000);</script>
{{end}}

A bot reads the card. A person sees the photo for a second, then arrives at the cat’s profile.

The pattern

If content lives somewhere without its own web page, like a bot or a storage bucket, it cannot be shared with a preview as it stands. What worked here was one small server-rendered page per item, carrying og: and twitter: tags that point at the real image, shared in place of the file, cached hard, with a humans-only redirect added once a fuller destination existed. That single endpoint was enough to make every link unfurl.

Kitty Space is at kitty.tinygods.dev.