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.pythat 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:
- Go to Settings → Secrets and variables → Actions.
- Click New repository secret.
- Name:
ATENDER_API_KEY. - Value: the
sa_live_...key you copied. - 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
- Add an article under
articles/billing/how-to-update-payment.mdwith the frontmatter shape above. - Commit and push to
main. - Watch the Actions tab. The job should print
c update-payment(created) on the first run. - Open your Atender help center. The article should appear in the Billing category within a minute (after embeddings catch up).
- 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.