Push articles from a GitHub repo

A working doc-as-code setup. Markdown articles in a GitHub repo, a Python push script, a GitHub Actions workflow that syncs to Atender's v1 API on every push to main. Copy-paste skeleton inside.

8 min read

Push articles from a GitHub repo

A working end-to-end setup that pushes Markdown articles from a GitHub repo to your Atender Knowledge Base on every commit to main. Adapt the pieces — the shape is portable to any CI system.

What you’ll end up with

  • A /articles/ folder in a GitHub repo with one Markdown file per article.
  • A Python push script under /scripts/push_kb.py that reads articles and upserts them.
  • A GitHub Actions workflow that runs the script when articles change.
  • A repository secret holding your Atender API key.

Total setup time: about 30 minutes if you’re comfortable with GitHub Actions; an hour from cold.

Step 1 — Generate the API key

Follow Generate a KB API key. You want a key with both knowledge:read (to list existing articles for slug lookups) and knowledge:write (to create and update).

Name it something obvious: github-actions-kb-sync. Copy the key once when shown — you can’t retrieve it again.

Step 2 — Add the key as a GitHub secret

In your GitHub repo:

  1. Go to Settings → Secrets and variables → Actions.
  2. Click New repository secret.
  3. Name: ATENDER_API_KEY.
  4. Value: the sa_live_... key you copied.
  5. Save.

Step 3 — Lay out the repo

.
├── articles/
│   ├── billing/
│   │   ├── how-to-update-payment.md
│   │   └── concept-invoices.md
│   ├── shipping/
│   │   ├── how-to-track-an-order.md
│   │   └── faq-shipping-times.md
│   └── ...
├── scripts/
│   └── push_kb.py
└── .github/
    └── workflows/
        └── push-kb.yml

Each Markdown file carries frontmatter at the top:

---
title: "How to update your payment method"
slug: "update-payment-method"
category: "Billing"
type: "how-to"
summary: "Open Account → Billing → Change."
keywords: [payment, credit card, billing]
ux_path: "Account → Billing → Payment methods"
roles: [admin]
status: "published"
---

Frontmatter fields the script reads: title, slug, category, summary, keywords, status, difficulty, estimated_minutes, ux_path. Anything extra is ignored.

Step 4 — The push script

A minimal scripts/push_kb.py that does what you need:

#!/usr/bin/env python3
"""Push articles from /articles/ to Atender's KB via /api/v1/kb."""
import os
import sys
from pathlib import Path

import frontmatter
import requests

BASE_URL = os.environ.get("ATENDER_BASE_URL", "https://prod.atender.dev/api/v1/kb")
API_KEY = os.environ["ATENDER_API_KEY"]
HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

session = requests.Session()
session.headers.update(HEADERS)


def list_articles():
    resp = session.get(f"{BASE_URL}/articles", params={"limit": 500})
    resp.raise_for_status()
    return resp.json().get("data", resp.json())


def list_categories():
    resp = session.get(f"{BASE_URL}/categories")
    resp.raise_for_status()
    return resp.json().get("data", resp.json())


def ensure_category(name: str, existing: list) -> str:
    for c in existing:
        if c["name"].strip().lower() == name.strip().lower():
            return c["id"]
    resp = session.post(f"{BASE_URL}/categories", json={"name": name})
    resp.raise_for_status()
    new = resp.json().get("data", resp.json())
    existing.append(new)
    return new["id"]


def upsert_article(path: Path, slug_to_id: dict, categories: list):
    post = frontmatter.load(path)
    fm = post.metadata
    category_id = ensure_category(fm["category"], categories)
    payload = {
        "title": fm["title"],
        "summary": fm.get("summary", ""),
        "content": post.content,
        "categoryId": category_id,
        "status": fm.get("status", "draft"),
        "keywords": fm.get("keywords", []),
        "difficulty": fm.get("difficulty"),
        "estimatedMinutes": fm.get("estimated_minutes"),
        "customMetadata": {
            "uxPath": fm.get("ux_path"),
            "type": fm.get("type"),
        },
    }
    slug = fm["slug"]
    existing_id = slug_to_id.get(slug)
    if existing_id:
        resp = session.patch(f"{BASE_URL}/articles/{existing_id}", json=payload)
        action = "updated"
    else:
        resp = session.post(f"{BASE_URL}/articles", json=payload)
        action = "created"
    resp.raise_for_status()
    return action, slug


def main():
    articles = list_articles()
    slug_to_id = {a["slug"]: a["id"] for a in articles if not a.get("isArchived")}
    categories = list_categories()
    counts = {"created": 0, "updated": 0}
    for md in sorted(Path("articles").rglob("*.md")):
        action, slug = upsert_article(md, slug_to_id, categories)
        counts[action] += 1
        print(f"  {action[0]} {slug}")
    print(f"\nDone. Created: {counts['created']}  Updated: {counts['updated']}")


if __name__ == "__main__":
    sys.exit(main())

A real production script would add retries, rate-limit backoff, tag handling, and pretty error messages. The above gets you a working pipeline today.

Step 5 — The GitHub Actions workflow

.github/workflows/push-kb.yml:

name: Push KB to Atender

on:
  push:
    branches: [main]
    paths:
      - "articles/**"
      - "scripts/push_kb.py"
  workflow_dispatch:

jobs:
  push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: pip install python-frontmatter requests

      - name: Push to Atender
        env:
          ATENDER_API_KEY: ${{ secrets.ATENDER_API_KEY }}
        run: python scripts/push_kb.py

Commit it, push to main, and the workflow runs on every change to articles/ or the script. Use workflow_dispatch to run it manually from the Actions tab.

Step 6 — Try it

  1. Add an article under articles/billing/how-to-update-payment.md with the frontmatter shape above.
  2. Commit and push to main.
  3. Watch the Actions tab. The job should print c update-payment (created) on the first run.
  4. Open your Atender help center. The article should appear in the Billing category within a minute (after embeddings catch up).
  5. Edit the article, commit, push. The job prints u update-payment (updated) and the change goes live within a minute.

Step 7 — Add translation sync, if you have languages enabled

The doc-as-code path doesn’t auto-trigger translation sync. After a push, click Sync Translations in Settings → Knowledge → Markets and Languages. Or wire a step in the workflow to call the sync endpoint — but be cautious: triggering sync on every push during a bulk update can pile up jobs. Most teams run sync manually after a content batch.

Trim it down

The minimum viable version of this recipe:

  • A single Markdown file in a repo.
  • One CLI call: ATENDER_API_KEY=... python scripts/push_kb.py.
  • No CI yet — run locally until the loop feels right.

Add the GitHub Actions wrapper once you trust the script.

What this gives you

  • Version history with author attribution. Git, not Atender, holds the trail.
  • PR review on doc changes. A teammate can comment on a diff before it goes live.
  • CI checks. Spell-check, link-check, frontmatter validators — anything you’d want to run on code.
  • Reproducibility. A clean checkout + the API key always reproduces the live KB. No “is this the latest version?” guessing.

Common gotchas

  • The script lists 500 articles by default. Once you cross 500, paginate the list call or the slug lookup misses entries.
  • Categories upsert by name, not slug. Rename a category in the frontmatter without updating existing articles, and the script creates a duplicate category. Keep category names stable.
  • Don’t commit the API key. Use the repository secret. Rotate the key if it ever leaks into a commit.
  • Embeddings catch up in the background. Articles appear in the UI within seconds; they appear in AI retrieval within a minute or two.
  • Translation isn’t automatic via API. Click Sync Translations after a push, or schedule it.

Tags

AdvancedRecipe