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) andknowledge: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 withATENDER_BASE_URLif 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 aPOST. - 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
- Push articles from a GitHub repo — the full CI recipe this script belongs to
- Why is my GitHub KB sync not working?
- Push articles via API
- Generate a KB API key