Python push script for the KB API

A minimal, complete Python script that upserts Markdown articles into the KB via /api/v1/kb — creates by slug, updates existing, upserts categories by name.

5 min read

Python push script for the KB API

This is a complete, minimal Python script that pushes Markdown articles into your Atender Knowledge Base through the /api/v1/kb API: it creates articles that don’t exist yet and updates the ones that do, matched by slug. Save it as scripts/push_kb.py, set ATENDER_API_KEY, and run it locally or from any CI system.

Before you start

  • An API key with both knowledge:read (to list existing articles for slug lookups) and knowledge:write (to create and update) — see Generate a KB API key.
  • Two pip packages: pip install python-frontmatter requests.
  • Articles as Markdown files under an articles/ folder, each with frontmatter.

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

The script

#!/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())

How it works

  • Run it with ATENDER_API_KEY=... python scripts/push_kb.py. Override the endpoint with ATENDER_BASE_URL if you’re not on the default.
  • It lists existing (non-archived) articles once and builds a slug → id map. An article whose slug is already live gets a PATCH; a new slug gets a POST.
  • Categories are upserted by name, case-insensitively. A category named in frontmatter that doesn’t exist yet is created on the fly.

Limitations to know before production

A real production script would add retries, rate-limit backoff, tag handling, and pretty error messages. The above gets you a working pipeline today. Two built-in limits matter as you grow:

  • The list call fetches 500 articles. Once you cross 500, paginate it or the slug lookup misses entries and you get duplicates.
  • Category matching is by name. Rename a category in frontmatter without updating existing articles and the script creates a duplicate category. Keep category names stable.

Never commit the API key — inject it via an environment variable or CI secret, and rotate it if it ever leaks into a commit.

See also

Tags

AdvancedReference