Help center

Guide: keep a knowledge base in sync

A complete, restartable sync job that copies every SOP out of SOPCompass, then picks up only what changed, including archives and deletions.

Business plan Updated
On this page
  1. How it works
  2. Where to store things
  3. The job
  4. Why it’s correct
  5. Rendering what you stored
  6. Cost in requests

This guide builds a job that keeps a copy of every SOP somewhere else: an intranet, a wiki, a search index or a warehouse table. It needs a read only key.

How it works

  1. GET /sops is ordered by updated_at, oldest first, and updated_since returns everything that changed at or after a time. So a job that remembers the largest updated_at it has seen can ask for only what changed since.
  2. The list doesn’t include content, so the job fetches GET /sops/{id} only for SOPs that are new or changed.
  3. include_archived=true makes archived SOPs come back with archived_at set, so the job can hide them.
  4. A periodic full pass (no updated_since) catches the things a change feed can’t: deleted SOPs and libraries that stopped being shared.

Where to store things

The job needs two things to persist between runs:

  • The checkpoint: the largest updated_at string seen. Store the API’s string exactly.
  • Your copy of each SOP, keyed by id, including its updated_at.

The example stores both in a SQLite file; swap in your own database or target system.

The job

Python
"""Sync SOPCompass SOPs into a local SQLite database.

Run it on a schedule (every 15 minutes is plenty). Pass --full once a week
to also remove SOPs that were deleted or stopped being shared.
"""
import json
import os
import sqlite3
import sys
import time

import requests

BASE = "https://app.sopcompass.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SOPCOMPASS_API_KEY']}"

db = sqlite3.connect("sopcompass.db")
db.execute("create table if not exists sops (id text primary key, updated_at text, archived integer, doc text)")
db.execute("create table if not exists state (k text primary key, v text)")


def call(path, params=None):
    for attempt in range(6):
        res = session.get(f"{BASE}{path}", params=params, timeout=30)
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", "5")))
            continue
        if res.status_code >= 500:
            time.sleep(min(30, 2 ** attempt))
            continue
        body = res.json()
        if not res.ok:
            raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")
        return body
    raise RuntimeError(f"gave up on {path}")


def list_sops(updated_since):
    params = {"include_archived": "true", "limit": 100}
    if updated_since:
        params["updated_since"] = updated_since
    while True:
        page = call("/sops", params)
        yield from page["data"]
        if not page["has_more"]:
            return
        params["cursor"] = page["next_cursor"]


def run(full):
    row = db.execute("select v from state where k = 'checkpoint'").fetchone()
    checkpoint = None if full or not row else row[0]
    newest = checkpoint
    seen = set()

    for item in list_sops(checkpoint):
        seen.add(item["id"])
        mine = db.execute("select updated_at from sops where id = ?", (item["id"],)).fetchone()
        if mine is None or mine[0] < item["updated_at"]:
            sop = call(f"/sops/{item['id']}")["data"]
            db.execute(
                "insert into sops (id, updated_at, archived, doc) values (?, ?, ?, ?) "
                "on conflict(id) do update set updated_at = excluded.updated_at, "
                "archived = excluded.archived, doc = excluded.doc",
                (sop["id"], sop["updated_at"], sop["archived_at"] is not None, json.dumps(sop)),
            )
        if newest is None or item["updated_at"] > newest:
            newest = item["updated_at"]

    if full:
        # Anything we hold that a full pass didn't return is gone: deleted,
        # unshared, or archived in a library shared in from another workspace.
        for (sop_id,) in db.execute("select id from sops").fetchall():
            if sop_id not in seen:
                db.execute("delete from sops where id = ?", (sop_id,))

    if newest:
        db.execute("insert into state (k, v) values ('checkpoint', ?) "
                   "on conflict(k) do update set v = excluded.v", (newest,))
    db.commit()


if __name__ == "__main__":
    run(full="--full" in sys.argv)

Why it’s correct

  • Nothing is missed. updated_since is inclusive and the list is ordered by updated_at, so every change at or after the checkpoint is returned. An SOP edited while the job is paging moves to the end of the list and is picked up on a later page, or on the next run.
  • Nothing is fetched twice for no reason. The updated_at comparison skips SOPs you already have at that version, including the one sitting exactly on the checkpoint.
  • It’s restartable. The checkpoint is saved only after a run finishes. If a run dies halfway, the next one starts from the old checkpoint and the updated_at comparison skips what was already stored.
  • Archives are handled. Archived SOPs come back with archived_at set and the job marks them; restoring clears it.

Rendering what you stored

  • cards[].body_html and cards[].steps[].html are stored HTML. Sanitize them before showing them on a page of yours.
  • cards[].image_urls lists every image a card shows. Images are hosted by SOPCompass; if your mirror must work without SOPCompass, download and re-host them.
  • app_url links back to the SOP in the app, for people who can sign in.

Cost in requests

A run costs one request per 100 SOPs listed, plus one per changed SOP. At 60 requests a minute per key, the first full copy of 1,000 SOPs takes about 17 minutes, which the retry logic handles on its own. Incremental runs usually cost a handful of requests.