---
title: "Turn Discord support threads into an FAQ for agents"
description: "Build FAQ.md from a Discord support forum with CocoIndex and an LLM: one current answer per question, merged across threads, updated only where threads change."
published: 2026-11-09
last_updated: 2026-11-09
doc_version: "2026-11-09"
canonical: https://cocoindex.io/blogs/discord-support-faq/
---
# Turn Discord support threads into an FAQ for agents

> Build FAQ.md from a Discord support forum with CocoIndex and an LLM: one current answer per question, merged across threads, updated only where threads change.

Published: 2026-11-09 · Canonical: https://cocoindex.io/blogs/discord-support-faq/

A support forum holds the answers to your users' questions, but not in a form that is easy to use. The same question comes up in many posts, worded differently. A correction posted later sits below the answer it corrects. Nothing marks which questions are still open. An agent that reads the raw forum has to read every post and guess which answer is current, for every question it gets.

This tutorial builds a CocoIndex pipeline that does that work once and keeps it up to date. It reads the support threads on a Discord server with the new [Discord connector](https://cocoindex.io/docs/connectors/discord/), released in CocoIndex 1.2.0. An LLM extracts each thread's question and answer, questions asked in other words are merged, and the result is `FAQ.md`: one entry per question, with how many threads asked it and links back to Discord. When the forum changes, a run sends the LLM only the threads whose text changed, plus the merging and answers that those changes affect.

The full example is in the [CocoIndex repo](https://github.com/cocoindex-io/cocoindex/tree/main/examples/discord_support_faq), with a [walkthrough in the docs](https://cocoindex.io/docs/examples/discord-support-faq/).

## What you build

- **A CocoIndex app, `DiscordSupportFaqV1`,** in `main.py`. It reads the threads of 1 or more Discord channels: forum posts, plus threads in text and announcement channels.
- **1 LLM call per thread** through [Instructor](https://github.com/instructor-ai/instructor) and [LiteLLM](https://docs.litellm.ai/). It returns the thread's question, the answer that settled it, whether it is resolved, and a topic.
- **Entity resolution over the questions:** embeddings find questions that may be the same, and an LLM decides which of them really are.
- **`FAQ.md`**, with the answered questions grouped by topic and the unanswered ones last, under Open questions.

**You need** Python 3.11+, `cocoindex>=1.2.0`, a Discord bot in your server ([Set up the bot](#set-up-the-bot) has the steps), and an LLM. Both model settings, `LLM_MODEL` and `RESOLUTION_LLM_MODEL`, default to `openai/gpt-5-mini` with an OpenAI API key; any [LiteLLM model](https://docs.litellm.ai/docs/providers) works, such as `ollama/llama3.2` to run locally without a key.

A few terms used below:

- **Memoization:** CocoIndex caches the result of a [function](https://cocoindex.io/docs/programming_guide/function/) and reuses it while the function's inputs and code stay the same.
- **[Processing component](https://cocoindex.io/docs/programming_guide/processing_component/):** a unit of work that CocoIndex runs, tracks and cleans up on its own. Here, each thread is one.
- **Entity resolution:** deciding which differently written names refer to the same thing. Here, the names are questions.

## From forum threads to one FAQ entry

The example comes with seed data: `seed_server.py` posts 8 made-up support posts into a Discord forum, as if from the help forum of a self-hosted photo library.

<div style={{maxWidth: '560px'}}>

</div>

A person, or an agent, reading this forum has to work out 4 things from it. Here is what `FAQ.md` does with each:

- **3 posts ask the same question** in other words: "Uploads from my phone stall at 99%", "Large videos never finish uploading" and "Phone backup stuck at 99% on long videos". 2 of them got an answer: a reverse proxy caps the request size. The third, also behind nginx, got no answer. `FAQ.md` merges them into 1 entry, asked 3×, with a link to each thread and the unanswered one marked `(Open)`.
- **1 answer was corrected later.** In "How do I move the library to a bigger disk?", the first reply says to set `LIBRARY_PATH`. A later reply says that the server ignores `LIBRARY_PATH` from version 2.0 on. The entry gives the corrected steps, as shown below.
- **1 question has no answer:** "Can downloads keep the original file names?" It is listed last, under Open questions, so you can see what the forum cannot answer yet.
- **1 post asks nothing:** "Show us your setup". It is left out.

Here is the thread with the correction. The seed script posts through a webhook, so Discord marks each message **APP**:

<div style={{maxWidth: '447px'}}>
 Storage. lena: Worked on 2.1, thanks both. Each message carries Discord's APP badge."} title={"An answer, then its correction"} sizes="(min-width: 487px) 447px, calc(100vw - 40px)" />
</div>

The pipeline turns that thread into this entry in `FAQ.md`:

```markdown
## Storage

### How can I move my photo library to a new/bigger disk without re-uploading everything?

Stop the server and copy the library folder to the new disk. For v2.0 and later (the server ignores LIBRARY_PATH) start the server and then set the new library location under Administration > Storage. For older versions, stop the server, copy the folder and update LIBRARY_PATH in your .env to point at the new location.

Asked 1×: [How do I move the library to a bigger disk?](https://discord.com/channels/1100000000000000000/1100000000000000101) (Solved)
```

The entry gives the corrected steps for version 2.0 and later, and keeps the `LIBRARY_PATH` step only for older versions, which is what the correction implies. The question is restated so that someone else with the same problem could search for it, and the link goes back to the thread, so anyone can check the answer against what people wrote.

When answered threads disagree, the entry says what each one says instead of picking one; [step 3](#step-3-write-one-answer-per-question) shows how. Each entry is a short, self-contained section, so the file splits cleanly into chunks for retrieval, or fits whole in an agent's context while it is small.

## How the pipeline fits together

`app_main` lists the threads of each channel, then runs 3 phases:

1. **Per thread,** `process_post` renders the thread as text, and an LLM extracts its question and answer. Each thread is its own processing component, and its result is memoized.
2. **Across threads,** `_resolve_questions` merges questions asked in other words.
3. **Per question,** `write_faq` groups the threads by their merged question, writes one answer for each group, and declares `FAQ.md`.

## Reading Discord: one item per thread

The [Discord connector](https://cocoindex.io/docs/connectors/discord/) reads the messages and threads of text, announcement and forum channels through Discord's HTTP API. It uses the token of a bot that you create and add to your server. It needs the `discord` extra (`pip install "cocoindex[discord]"`); the example's `pyproject.toml` includes it.

### Set up the bot

1. In the [Discord Developer Portal](https://discord.com/developers/applications), create an application. On its **Bot** page, reset and copy the bot token. Store it like a password.
2. On the same page, under **Privileged Gateway Intents**, turn on **Message Content Intent**. Without it, Discord sends messages without their text. The connector checks for the intent and raises an error, instead of indexing empty messages.
3. On the **OAuth2** page, generate an invite URL with the `bot` scope and the **View Channels** and **Read Message History** permissions, and add the bot to your server. The bot only reads.
4. Copy the ID of each channel to read: turn on Developer Mode (User Settings > Advanced), then right-click the channel.

The connector docs' [Setup](https://cocoindex.io/docs/connectors/discord/#setup) section has the full steps, including private threads.

### `DiscordApp`: one per bot token

`DiscordApp` holds the bot token and the HTTP connection. The example opens it in the lifespan, which runs once around the whole pipeline, and provides it to the pipeline through a [context key](https://cocoindex.io/docs/programming_guide/context/):

```python
DISCORD = coco.ContextKey[discord.DiscordApp]("discord")

@coco.lifespan
async def coco_lifespan(
    builder: coco.EnvironmentBuilder,
) -> AsyncIterator[None]:
    async with discord.DiscordApp(token=os.environ["DISCORD_BOT_TOKEN"]) as app:
        builder.provide(DISCORD, app)
        ...
        yield
```

Use one `DiscordApp` per bot token. The connector paces its requests on the rate limits that Discord reports, and it shares that state through the `DiscordApp` object. 2 `DiscordApp` objects with the same token pace separately, so together they can exceed Discord's limits.

`DiscordChannel` is a handle for one channel. Its `items()` method yields a `DiscordMessage` for each message in the channel's main stream, except one that starts a thread, then a `DiscordThread` for each thread the bot can read, active or archived. A forum post is a thread. The example keeps only the threads:

```python
for cid in channel_ids:
    channel = discord.DiscordChannel(
        app=app,
        channel_id=cid,
        message_fields=MESSAGE_FIELDS,
        thread_fields=THREAD_FIELDS,
    )
    async for _key, item in channel.items():
        # Outside a thread, a question and its answer are separate
        # messages, and pairing them up would be guesswork.
        if isinstance(item, discord.DiscordThread):
            threads.append(item)
```

Each run lists every channel in full. The listings carry every field of each item, so a run costs a few requests per channel, plus 1 per 100 archived threads and, in a text or announcement channel, 1 per 100 messages in the main stream. An unchanged thread costs nothing more. The connector fetches a thread's messages only when the thread is processed again.

### `thread_fields`: which changes re-run a thread

Discord gives no content hash, so the connector [fingerprints an item](https://cocoindex.io/docs/connectors/discord/#memoization-and-change-detection) by every field it exposes. By default those include a thread's member count, so someone being added to a thread would re-run it and fetch all its messages again, though its text stays the same. `message_fields` and `thread_fields` limit the fields to the ones the pipeline reads:

```python
MESSAGE_FIELDS = ["author", "content", "embeds", "mentions", "type"]
THREAD_FIELDS = [
    "guild_id",
    "name",
    "tags",
    "message_count",
    "total_message_sent",
    "last_message_id",
    "archived",
    "archive_timestamp",
    "locked",
]
```

The message fields are the ones the thread's text is rendered from. The thread fields are the ones its text and link use (`name`, `tags` and `guild_id`), plus the fields that change when something happens inside the thread. All 3 message fields change with a reply, and `message_count` also drops when a message is deleted. The archive and lock fields change when the thread is archived or locked. Those are the only signs Discord gives that the messages inside a thread changed. The [Choosing fields](https://cocoindex.io/docs/connectors/discord/#choosing-fields) section of the docs lists which fields a thread needs to notice each kind of change.

## Step 1: one question and answer per thread

The LLM returns a `QA` object. Its field descriptions are most of the prompt:

```python
class QA(pydantic.BaseModel):
    question: str = pydantic.Field(
        description=(
            "The question the thread asks, restated as one short standalone "
            "question that someone else with the same problem could search for: "
            "the problem as the asker sees it, without details of their setup "
            "(how they run the server, which device) unless the question is "
            "about them. A cause found in the thread goes in the answer. No names."
        )
    )
    answer: str = pydantic.Field(
        description=(
            "The answer that settled it, as a short standalone paragraph. If a "
            "later message corrects an earlier answer, give the corrected one. "
            "Empty if nobody answered."
        )
    )
    resolved: bool = pydantic.Field(...)
    topic: str = pydantic.Field(...)

class ExtractedThread(pydantic.BaseModel):
    qa: QA | None = pydantic.Field(
        description="The thread's question and answer; null if it asks no question."
    )
```

Corrections are handled by the prompt, not by code: the `answer` description asks the model for the corrected answer when a later message corrects an earlier one, and that is how the library-move entry gets the new steps. A thread that doesn't ask a question returns `qa: null`, which is how the "Show us your setup" post is left out of the FAQ.

`extract_qa(thread_text)` sends the thread's text to the LLM through Instructor and returns the `QA`. It is memoized (`@coco.fn(memo=True)`), so a thread whose text is the same as on its last run is not sent to the LLM again. `process_post` prepares that text:

```python
@coco.fn(memo=True)
async def process_post(thread: discord.DiscordThread) -> Post | None:
    text = await thread.read_text()
    tagged_solved = SOLVED_TAG in (thread.tags or ())
    # Memoized by the text, so a thread rerun by an archive or a lock asks the
    # LLM nothing. The text's "Tags:" line is left out, so a retag doesn't
    # either: the Solved tag is read from the thread itself.
    lines = text.split("\n")
    if thread.tags:
        lines.remove(f"Tags: {', '.join(thread.tags)}")
    qa = await extract_qa("\n".join(lines))
    if qa is None:
        return None
    return Post(
        thread_id=thread.id,
        url=f"https://discord.com/channels/{thread.guild_id}/{thread.id}",
        title=thread.name or "",
        text=text,
        question=qa.question,
        answer=qa.answer,
        resolved=tagged_solved or qa.resolved,
        tagged_solved=tagged_solved,
        topic=qa.topic,
    )
```

- **`thread.read_text()`** renders the thread's title, a `Tags:` line for a tagged forum post, then each message as `name: text`, oldest first. Notices, such as a member being added to the thread, are left out.
- **The `Tags:` line is removed** before the text goes to `extract_qa`. A retag then leaves that text unchanged, so `extract_qa` reuses its cached result. Step 3 reads the full text, `Tags:` line included.
- **The Solved tag decides**: a post tagged Solved counts as resolved, whatever the LLM says. The tag is read from the thread, not from the text.
- **`Post` keeps the full text** of the thread, for step 3.

`app_main` mounts one `process_post` per thread, which runs it as its own processing component, keyed by the thread's ID, and waits for all of them:

```python
extracted = await asyncio.gather(
    *(
        coco.use_mount(coco.component_subpath("post", t.id), process_post, t)
        for t in threads
    )
)
posts = [p for p in extracted if p is not None]
```

A thread deleted in Discord is no longer listed, so its post drops out of the FAQ on the next run, and CocoIndex removes the thread's component and its cached results.

## Step 2: merge questions asked in other words

The 3 upload posts each get their own extracted question, in different words. [Entity resolution](https://cocoindex.io/docs/ops/entity_resolution/) decides which questions are the same:

```python
@coco.fn(memo=True)
async def _resolve_questions(questions: set[str]) -> ResolvedEntities:
    return await resolve_entities(
        entities=questions,
        embedder=coco.use_context(EMBEDDER),
        resolve_pair=LlmPairResolver(
            model=coco.use_context(RESOLUTION_LLM_MODEL),
            entity_type="support question",
            extra_guidance=(
                "Two questions are the same when one answer would answer both, "
                "however differently they're worded: the same symptom is the "
                "same question, even if the askers describe different setups."
            ),
        ),
        # A question reworded sits further from the original than a name
        # written another way does. A wider reach gives the LLM more candidates
        # to check, which costs calls; every merge is still one the LLM chose.
        max_distance=0.55,
    )
```

- **Embeddings find candidates.** `resolve_entities` embeds each question with `sentence-transformers/all-MiniLM-L6-v2`. For each question, the candidates are the groups' representative questions within `max_distance`, a cosine distance, up to 5.
- **The LLM decides each match.** For each question that has candidates, 1 LLM call picks the one it matches, if any. `extra_guidance` defines "the same question" for it: one answer would answer both.
- **`max_distance` is wider than the default.** The default, 0.3, suits names written in different ways, such as "Microsoft Corp." and "Microsoft". A reworded question sits further from the original, so the example uses 0.55. A wider distance gives the LLM more candidates to check; every merge is still one that the LLM chose.

The result maps each question to its group's representative question, through `canonical_of()`. `_resolve_questions` is memoized on the set of questions, so a run in which no extracted question changed makes no resolution calls.

## Step 3: write one answer per question

`write_faq` groups the posts by their representative question, and `faq_entry` writes each group's entry:

```python
async def faq_entry(question: str, group: list[Post]) -> FaqEntry:
    # Discord's IDs grow with time, so this is oldest first.
    group.sort(key=lambda p: int(p.thread_id))
    answered = [p for p in group if p.answered]
    if not answered:
        return FaqEntry(group[-1].topic, question, None, group)
    if len(answered) == 1:
        answer = answered[0].answer
    else:
        # Memoized by the question and the threads' text, so only an entry
        # whose question or threads changed is written again.
        answer = await merge_answers(question, [p.text for p in answered])
    return FaqEntry(answered[-1].topic, question, answer, group)
```

- **No answered thread:** the entry goes under Open questions, with how many threads asked it and links.
- **1 answered thread:** that thread's extracted answer is used as it is, with no LLM call.
- **2 or more answered threads:** `merge_answers` writes one answer. It reads the threads' text, not the answers extracted from them, so the merged answer comes from the messages, not from summaries of them.

The prompt of `merge_answers` handles disagreement:

```text
Answer the question using only what the threads say; add nothing of your own.
If the threads disagree, don't pick one silently: say plainly what each says,
naming the thread by its title, and which one is tagged Solved. Don't name
people.
```

`merge_answers` is memoized on the question and the texts of the threads, so only an entry whose question or answered threads changed is written again. Usually that is just the changed thread's entry; a reworded question can also move a thread to another entry. `write_faq` then renders every entry and declares the file with `localfs.declare_file(FAQ_PATH, ...)`.

## Run it

From `examples/discord_support_faq` in the [CocoIndex repo](https://github.com/cocoindex-io/cocoindex), copy the environment template and install the example:

```sh
cp .env.example .env    # set OPENAI_API_KEY, DISCORD_BOT_TOKEN and DISCORD_CHANNEL_IDS
pip install -e .
```

`DISCORD_CHANNEL_IDS` takes 1 or more channel IDs, separated by commas. `LLM_MODEL` reads each thread and writes the merged answers, and `RESOLUTION_LLM_MODEL` decides whether 2 questions are the same; both default to `openai/gpt-5-mini`.

To fill a forum with the seed posts, give the forum a tag named **Solved**, and make sure the forum does not require tags. Then create a webhook in the forum's settings (Integrations > Webhooks > New Webhook), put its URL in `.env` as `DISCORD_SEED_WEBHOOK_URL`, and run the script once. Each run posts all 8 posts again, so a second run duplicates them:

```sh
python seed_server.py
```

Build the FAQ:

```sh
cocoindex update main
```

On the freshly seeded forum, the first run reads all 8 threads:

```text
CocoIndex 1.2.0
Updating DiscordSupportFaqV1
db default · cocoindex.db

✓ Done in 1m 15s
  ✓ app_main            1 added
  ✓ process_post        8 added
  ✓ _resolve_questions  1 added
  ✓ write_faq           1 added
```

**What you see.** In `FAQ.md`, the 3 upload posts make 1 entry, asked 3×, with the unanswered post's link marked `(Open)`. The library move gives the corrected answer. The question about file names is under Open questions, and "Show us your setup" is not there. On our run with `openai/gpt-5-mini`, the 8 posts made 5 entries, 1 of them under Open questions, from 11 LLM calls: 8 for extraction, 2 for resolution and 1 for writing. Since an LLM does the extraction, merging and writing, results may vary slightly.

## What a re-run sends to the LLM

Each `cocoindex update main` lists every thread again. A thread whose fields did not change reuses the cached result of `process_post`, and the connector does not fetch its messages.

To see it, make these changes in Discord in order, and run `cocoindex update main` after each. Below is what happened on our run with `openai/gpt-5-mini`. The `process_post` line follows from the change in Discord, so yours will match; what the LLM decides (the wording, the topic, whether 2 questions merge) and the number of LLM calls can differ.

1. **Reply to the unanswered post about file names** with an answer: `1 reprocessed`. The question moves from Open questions into the FAQ, with that answer. On our run, this cost 1 LLM call, the extraction of that thread, against 11 for the first run. The new extraction restated the question in other words, so `_resolve_questions` ran again. No other question was within 0.55 of it, and the other questions' decisions were memoized, so it asked the LLM nothing.
2. **Tag that post Solved:** `1 reprocessed`, but no LLM call: extraction reads the text without the tags, and this entry has only 1 answered thread, so no answer is written. The only change to `FAQ.md` is `(Solved)` after the post's link. In an entry merged from 2 or more threads, a retag writes the answer again, since that answer names the thread tagged Solved.
3. **Make a new post that asks an answered question in other words,** such as "Can my parents see an album without signing up?", and answer it: `1 added`. On our run, it joined the sharing entry, which now says asked 2×, with a link to each post and an answer written from both.
4. **In that new post, reply with a correction that contradicts the other post:** `1 reprocessed`. The entry's answer was written again and said that the threads disagree, and no other entry changed. The LLM also happened to give the entry a new topic, Albums instead of Sharing: the topic comes from extraction, so it can change whenever a thread is extracted again.
5. **Close a post:** `1 reprocessed`. Its text is the same, so there is no LLM call, and `FAQ.md` stays the same.
6. **Delete "Phone backup stuck at 99% on long videos":** `1 deleted`. The upload entry now says asked 2×, without the `(Open)` link. On our run, there was no LLM call: the post had no answer, and its question was not the entry's representative question. Had it been, the merging and the answer would run again.

Here is the entry after step 4. The new post, "Album for my parents", says that viewing without an account is not possible yet:

```markdown
### Can someone view a shared album without creating an account?

The forum threads disagree. The thread titled "Sharing an album with family who don't have accounts" (tagged Solved) says: "Yes: open the album and choose Share > Create link. A link can have a password and an expiry date, and you can turn off downloads." The thread titled "Album for my parents" contains two conflicting messages: one says "Yes, open the album and choose Share > Create link," and a later message in the same thread says "That's incorrect, this is not possible yet. Viewing without an account is planned for the next release."

Asked 2×: [Sharing an album with family who don't have accounts](https://discord.com/channels/1100000000000000000/1100000000000000102) (Solved), [Album for my parents](https://discord.com/channels/1100000000000000000/1100000000000000109)
```

:::caution[Edits inside a thread wait for the thread's next change]
Discord reports no change on a thread when a message inside it is edited. So the connector reads an edit only the next time the thread changes in one of the ways above, such as a new reply, or Discord archiving the thread. Until then, the FAQ keeps the answer from before the edit. On a quiet server, a thread may never change again, and a pinned forum post never archives on its own, so an edit may never be picked up. A correction posted as a reply is read on the next run. The connector docs explain this in [The gap inside threads](https://cocoindex.io/docs/connectors/discord/#the-gap-inside-threads).
:::

## Limits and what to watch for

- **Each answer is an LLM's summary of its threads,** so it can leave out a detail that mattered to one asker, or get an answer slightly off. Each entry links to its threads, so anyone can check it against what people wrote.
- **A merged answer shows conflicts instead of resolving them.** Within one thread, extraction keeps the corrected answer. When several threads answer a question, the merge prompt reports what each says instead, because a later message in one thread doesn't settle a disagreement with another. In step 4, the newer reply contradicts the thread tagged Solved, so the entry quotes both and leaves the call to the reader.
- **Merging is the LLM's judgment too.** Questions further apart than `max_distance` never reach the LLM and stay separate entries. Each question is compared only with a group's representative question, so if 2 questions match only through a third, deleting the third can split them.
- **The FAQ carries no Discord permissions.** Anyone who can read `FAQ.md` can read what is in it, including the content of private threads the bot is a member of. Keep channels with different audiences in separate pipelines.

## What to try next

- **Read your own forum.** Set `DISCORD_CHANNEL_IDS` to your support channels. If you'd rather not copy IDs by hand, `list_channels()` lists a server's channels with their types ([docs](https://cocoindex.io/docs/connectors/discord/#list_channels)).
- **Keep it current.** Run `cocoindex update main` on a schedule, or wrap the channel reads in `coco.auto_refresh()` ([periodic refresh](https://cocoindex.io/docs/programming_guide/live_mode/#periodic-refresh-with-cocoauto_refresh)) to re-poll from a long-running process. Each run lists every channel, so choose the interval with Discord's [rate limits](https://cocoindex.io/docs/connectors/discord/#rate-limiting) in mind.
- **Give the FAQ to an agent.** Put `FAQ.md` in the agent's context while it is small, or split it at each `###` entry for retrieval.
- **Store the entries in a database.** One Markdown file suits a small knowledge base. For a large one, the same pipeline can declare each entry as a row in a vector store or a database instead of a section of `FAQ.md`.
- **Search the whole server.** The [Semantic search over Discord](https://cocoindex.io/docs/examples/discord-embedding/) example embeds forum posts, messages and threads into Postgres with pgvector, each row with a link back to Discord.

Further reading:

- [The example on GitHub](https://github.com/cocoindex-io/cocoindex/tree/main/examples/discord_support_faq): `main.py`, `seed_server.py` and the README
- [The example walkthrough in the docs](https://cocoindex.io/docs/examples/discord-support-faq/)
- [Discord connector](https://cocoindex.io/docs/connectors/discord/): setup, fields, rate limits and change detection
- [Entity resolution](https://cocoindex.io/docs/ops/entity_resolution/): `resolve_entities` and its pair resolvers
- [Meeting Notes to Knowledge Graph](https://cocoindex.io/docs/examples/meeting-notes-to-knowledge-graph/): the same two steps on meeting notes: an LLM extracts from each note, and entity resolution merges the names of people, as this example merges questions
- [Functions and memoization](https://cocoindex.io/docs/programming_guide/function/): `memo=True` and change detection

## Sitemap

- [Blog index](https://cocoindex.io/blogs/)
- [Site index (llms.txt)](https://cocoindex.io/llms.txt)
- [Full blog corpus](https://cocoindex.io/llms-full.txt)
- [Markdown sitemap](https://cocoindex.io/sitemap.md)
- [XML sitemap](https://cocoindex.io/sitemap.xml)
- [RSS feed](https://cocoindex.io/blogs/rss.xml)
