← All posts

How to Build a Slack Bot That Searches and Reads the Live Web

Someone in your team channel asks a question that nobody can answer from memory. What changed in the latest release of a dependency, what a competitor just announced, what a status page says right now. You could alt-tab to a browser, or you could type /web what changed in the latest kubernetes release and get a grounded answer posted back into the thread.

That is a small bot, and you can build it in an afternoon. The two hard parts are not what you would guess. Reading the live web is one line once you have the right API. Beating Slack's three-second response deadline is the part that trips people up. This post walks through both.

What we are building

A Slack slash command called /web. A user types a question, the bot searches the web, reads the most relevant page, and posts a short answer with a source link back into the same channel.

The web-access piece runs on link.sc, which exposes two endpoints: /v1/search returns ranked results with page content already extracted as markdown, and /v1/fetch returns a single URL as clean markdown. Both are plain POST calls that return JSON, and both handle the JavaScript rendering, proxy rotation, and retries that make DIY scraping a maintenance sink. If you want the reasoning behind that, give your AI agent internet access covers it.

The three-second rule that shapes everything

Slack sends your slash command a POST request when the user hits enter. It expects an HTTP 200 back within three seconds. Miss that window and the user sees operation_timeout, even if your bot eventually does the right thing.

A web search plus a page fetch plus an LLM summary takes longer than three seconds. Every time. So you cannot do the work and then reply. You have to acknowledge immediately, do the slow work in the background, and post the result afterward.

Slack gives you the hook for exactly this. Every slash command payload includes a response_url. You can POST to that URL for up to 30 minutes after the command fires, up to five times. So the pattern is:

  1. Receive the command, return a quick "working on it" message within three seconds.
  2. Kick off the real work on a background thread.
  3. When the work finishes, POST the answer to response_url.

Get that shape right and the rest is wiring.

Setting up the Slack app

Create an app at api.slack.com/apps, pick "From scratch," and give it a workspace. Under "Slash Commands," create a new command:

Field Value
Command /web
Request URL https://your-host/slack/web
Short description Search and read the live web
Usage hint [your question]

Install the app to your workspace. You do not need any OAuth scopes for a slash command that only replies through response_url, which keeps the setup minimal.

Grab your link.sc API key from the dashboard and set both secrets as environment variables. Never hardcode them.

The server

Here is a complete Flask handler. It verifies the request came from Slack, acks immediately, and does the search-and-fetch on a background thread.

import os, hmac, hashlib, time, threading, requests
from flask import Flask, request, jsonify

app = Flask(__name__)
SLACK_SECRET = os.environ["SLACK_SIGNING_SECRET"]
LINKSC_KEY = os.environ["LINKSC_API_KEY"]
LINKSC_HEADERS = {"x-api-key": LINKSC_KEY, "Content-Type": "application/json"}

def verify_slack(req) -> bool:
    ts = req.headers.get("X-Slack-Request-Timestamp", "0")
    if abs(time.time() - int(ts)) > 60 * 5:
        return False
    base = f"v0:{ts}:{req.get_data(as_text=True)}"
    digest = hmac.new(SLACK_SECRET.encode(), base.encode(), hashlib.sha256).hexdigest()
    expected = f"v0={digest}"
    return hmac.compare_digest(expected, req.headers.get("X-Slack-Signature", ""))

@app.route("/slack/web", methods=["POST"])
def slack_web():
    if not verify_slack(request):
        return "bad signature", 403
    query = request.form.get("text", "").strip()
    response_url = request.form.get("response_url")
    if not query:
        return jsonify(text="Usage: /web your question here")
    threading.Thread(target=do_work, args=(query, response_url)).start()
    return jsonify(response_type="ephemeral", text=f"Searching the web for: {query}")

The signature check matters. Your request URL is public, so without it anyone who finds the endpoint can trigger your bot and burn your credits. Slack signs every request with your signing secret, and the five-minute timestamp window blocks replay attacks.

Doing the actual web work

Now the background function. It searches, reads the top result, and posts a formatted answer to response_url.

def do_work(query, response_url):
    try:
        s = requests.post("https://api.link.sc/v1/search",
                          headers=LINKSC_HEADERS, json={"q": query}, timeout=60)
        s.raise_for_status()
        results = s.json().get("results", [])
        if not results:
            post(response_url, "No results found for that query.")
            return

        top = results[0]
        f = requests.post("https://api.link.sc/v1/fetch",
                          headers=LINKSC_HEADERS,
                          json={"url": top["url"], "format": "markdown"}, timeout=90)
        f.raise_for_status()
        content = f.json().get("content", "")[:8000]

        answer = summarize(query, content)  # your LLM call
        blocks = [
            {"type": "section",
             "text": {"type": "mrkdwn", "text": answer}},
            {"type": "context",
             "elements": [{"type": "mrkdwn",
                           "text": f"Source: <{top['url']}|{top.get('title', top['url'])}>"}]},
        ]
        post(response_url, answer, blocks)
    except Exception as e:
        post(response_url, f"Something went wrong: {e}")

def post(response_url, text, blocks=None):
    payload = {"response_type": "in_channel", "text": text}
    if blocks:
        payload["blocks"] = blocks
    requests.post(response_url, json=payload, timeout=10)

Two design choices are worth calling out. response_type: "in_channel" posts the answer visibly to everyone in the channel, which is what you want for a shared research bot. The initial ack was ephemeral, so only the person who ran the command sees the "searching" message and the channel does not fill with noise.

The [:8000] slice on the fetched content is a cost guard. You are feeding this page into an LLM to summarize, and markdown from link.sc is already far cheaper in tokens than raw HTML. Trimming to the first several thousand characters keeps a single answer from ballooning if someone points the bot at a huge page. For more on that lever, see token optimization for feeding web data to LLMs.

The summarize step, and grounding

The summarize function is a normal LLM call: pass the user's query and the fetched markdown, ask for a short answer that uses only the provided content and admits when the answer is not there. That last instruction is the whole game. Handing the model a live page does not stop it from answering from memory unless you tell it to stay inside the text you gave it.

If you would rather skip the LLM entirely, post the search snippet and the source link directly. That gives you a "here is the top result and a clean excerpt" bot with no model cost at all, and it is genuinely useful for quick lookups.

Where to run it

Any host that gives you a public HTTPS URL works: a small VM, a container, or a serverless function. The one caveat with serverless is the background thread. On a platform that freezes execution the moment you return a response, spawn the slow work through a proper queue or a second function invocation rather than a raw thread, or the fetch never completes. On a long-lived server the thread approach in the code above is fine.

If your team already lives in Claude Desktop or Cursor instead of building a custom bot, the same search and fetch backend is available over MCP, described in connect Claude to the web.

Keeping it cheap and quiet

Two meters run each time someone uses the bot: the web-data calls and any LLM tokens. A search plus one fetch is roughly two credits on link.sc, and the free tier includes 500 credits a month, enough to build and dogfood the bot before you pay anything. Fetch only the top result rather than every link. Cache repeated queries if your channel tends to ask the same thing. And keep answers short, because a Slack bot that dumps three paragraphs into a channel gets muted fast.

The finished bot is small, but it changes a habit. Questions that used to sit unanswered, or send someone into a browser rabbit hole, get a grounded reply with a source in the place the question was already asked.


Give your Slack workspace live web access today. Grab a free key at link.sc/register.