Managing Operational Context as Code (GitOps)
The Building Operational Context guide covers adding Rules, Commands, Knowledge, and Custom Tasks through Workspace Studio. This guide covers the same outcomes for teams that prefer to manage context as code — definitions live in your Git repository and are applied to a workspace automatically from CI/CD.
This is how you reach Level 4 (Integration) in the maturity model: context stops being something a person maintains in the UI and starts flowing from the systems you already use.
Who this is for Teams that already run GitOps for infrastructure and want operational context (assistant behavior + automation) reviewed, versioned, and deployed the same way.
What you can manage as code
All operational context — Rules, Commands, Knowledge, and Custom Tasks — is created and updated through the RunWhen API. “As code” means the definitions live in your Git repository, and a pipeline applies them to a workspace whenever you merge. Your repository is the source of truth; the API is how you apply it.
| Context type | What you keep in Git | API |
|---|---|---|
| Rules | Rule definitions | …/chat-config/rules |
| Commands | Command definitions | …/chat-config/commands |
| Knowledge (Notes) | Note markdown + frontmatter (paths / tag selectors) | …/notes/sync |
| Custom Tasks | The task script (bash/python) + its config | …/slxs/sync + …/runbooks/sync |
For Rules, Commands, and Custom Tasks the model is the same — list → match by name → create or update — which makes a pipeline idempotent (safe to re-run on every merge). Knowledge has a purpose-built markdown flow with its own tag selectors, PR-time validation, and read-only semantics; it gets its own section below.
Authentication for pipelines
Use a service account (or Personal Access Token) so the pipeline authenticates without a human. Exchange credentials for a short-lived JWT, then send it as a bearer token.
The API is served by PAPI on the papi. host. SaaS uses papi.beta.runwhen.com; self-hosted installs use papi.<your global.domain> — set BASE to your own host. The API spans versions, so paths below include their /api/vN prefix (most are v3; SLX sync is v1).
BASE="https://papi.beta.runwhen.com" # self-hosted: https://papi.<your-domain>WS="my-workspace"
TOKEN=$(curl -s -X POST "$BASE/api/v3/token/" \ -H "Content-Type: application/json" \ -d "{\"client_id\":\"$RW_CLIENT_ID\",\"client_secret\":\"$RW_CLIENT_SECRET\",\"grant_type\":\"client_credentials\"}" \ | jq -r .access_token)Store RW_CLIENT_ID / RW_CLIENT_SECRET as CI secrets. Writes to workspace- and persona-scoped Rules and Commands require the service account to have the workspace admin role.
Use the
papi.host, notapp.Requests to theapp.web-app host return404for every API path. This matters most for self-hosted: the API base ispapi.<domain>, mirroring your ingress (app.<domain>for the UI,papi.<domain>for the API).
Rules and Commands
Keep declarative definitions in your repo and apply them on every merge. The reconcile loop is list → diff by name → create or update, which keeps the operation idempotent (safe to run repeatedly). (Knowledge follows a different, purpose-built flow — see Knowledge as code.)
1. Define context in your repo
Store definitions as data your pipeline can read. For example, context/rules.yaml:
rules: - name: deprioritize-node-pressure scope_type: workspace is_active: true rule_content: > Mention node pressure briefly, but prioritize application-level impact first unless pressure correlates with a user symptom. - name: acknowledge-lab-preempts scope_type: workspace is_active: true rule_content: > Preemptions in lab/dev are expected. Do not treat them as root cause unless clearly correlated with a user-facing symptom.context/commands.yaml:
commands: - name: investigate-namespace scope_type: workspace is_active: true description: Pod status, warning events, and crash logs for a namespace. command_content: > For the namespace the user names, report pod status, recent warning events, and crash-looping container logs. Surface the most likely root cause first.2. Reconcile against the API
A minimal idempotent reconciler in Python. It matches existing objects by name, updates them in place, and creates anything missing.
import os, sys, requests, yaml
BASE = os.environ["RW_BASE"] # e.g. https://papi.beta.runwhen.com (self-hosted: https://papi.<your-domain>)WS = os.environ["RW_WORKSPACE"]H = {"Authorization": f"Bearer {os.environ['RW_TOKEN']}", "Content-Type": "application/json"}
def reconcile_rules(path): desired = yaml.safe_load(open(path))["rules"] existing = requests.get( f"{BASE}/api/v3/workspaces/{WS}/chat-config/rules", headers=H, params={"scope_type": "workspace"}, ).json().get("results", []) by_name = {r["name"]: r for r in existing}
for rule in desired: match = by_name.get(rule["name"]) if match: requests.put( f"{BASE}/api/v3/workspaces/{WS}/chat-config/rules/{match['id']}", headers=H, json=rule, ).raise_for_status() print(f"updated rule {rule['name']}") else: requests.post( f"{BASE}/api/v3/workspaces/{WS}/chat-config/rules", headers=H, json=rule, ).raise_for_status() print(f"created rule {rule['name']}")
if __name__ == "__main__": reconcile_rules("context/rules.yaml") # Commands follow the same pattern against …/chat-config/commands.Field naming gotcha Rules and Commands use snake_case JSON (
rule_content,command_content,scope_type,is_active). Knowledge is authored as markdown-with-frontmatter (also snake_case:resource_paths,resource_selectors) and synced through a different endpoint — see the next section.
Handling deletions
The reconciler above is additive — it never removes context that was deleted from your repo. If you want full convergence (delete-on-removal), list the existing objects, compute the set difference against your desired names, and DELETE the leftovers. Adopt deletion carefully: a stray rename in your repo will otherwise drop and recreate context.
Knowledge as code (Notes)
Store Knowledge notes as markdown files in your repo and sync them to a workspace from CI. The sync API is one upsert-per-file call, keyed by each file’s git provenance, so re-running it is safe and each note updates in place.
Notes synced this way are read-only in Workspace Studio — git is the source of truth, and a Studio edit shows a pointer back to the source file.
1. Author a note
One file per note under .runwhen/knowledgebase/, with a small YAML header. Minimum shape:
---title: payments-5xx-triageresource_paths: - kubernetes/prod/cluster-01/namespace/payments---
When checkout returns 503s, first check connection-pool saturation oncheckout-api before assuming a payments-provider outage…title— hyphen-separated slug; defaults to the file’s name if omitted.resource_paths— resource paths the note applies to. A note on a parent path (e.g. a namespace) applies to the resources beneath it, so you don’t need to list every child.- Identity is the file path. Renaming or moving a file re-creates the note under the new path.
Optional frontmatter fields (add as you need them): resource_selectors (attach by tag — see Attaching by tag below), tags (author-facing labels), status: active | deprecated.
2. Sync from CI
Write endpoints (workspace admin token):
POST /api/v4/workspaces/{ws}/notes/sync— upsert one note. Body: the raw markdown ascontent, plus git provenancesource,source_id, andsource_ref(the commit). Set"dry_run": trueto validate without writing. The response hasok, a resolvedstatus(created/updated/unchanged/rejected), and a list ofdiagnostics.DELETE /api/v4/workspaces/{ws}/notes— remove notes dropped from the repo. Body:sourceplus thesource_idsto delete.
The smallest working sync is one curl — try it from your terminal before wiring it into CI:
curl -sS -X POST "$BASE/api/v4/workspaces/$WS/notes/sync" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d @- <<JSON{ "source": "$(git config --get remote.origin.url)", "source_id": ".runwhen/knowledgebase/payments/5xx-triage.md", "source_ref": "$(git rev-parse HEAD)", "content": $(jq -Rs . < .runwhen/knowledgebase/payments/5xx-triage.md), "dry_run": true}JSONA CI job wraps that call in a loop over the changed files. Validate on PRs; for publish-on-merge, swap the trigger to push: branches: [main] and drop --dry-run:
name: RunWhen KB — validateon: pull_request: paths: ['.runwhen/knowledgebase/**']jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } # need the base ref to diff changed notes - name: Dry-run validate changed notes env: RW_TOKEN: ${{ secrets.RUNWHEN_KB_TOKEN }} RW_API: ${{ vars.RUNWHEN_API_URL }} run: | python3 .runwhen/kb_sync.py \ --workspace "${{ vars.RUNWHEN_WORKSPACE }}" \ --api "$RW_API" --token "$RW_TOKEN" \ --changed "origin/${{ github.base_ref }}...HEAD" \ --dry-runThe dry-run gates the merge: it checks frontmatter, verifies every resource_path exists, and previews what each resource_selector matches. A note that fails these checks fails the check run.
For discovery endpoints (list tags, list resource paths) and the full response schema, see the API Reference.
Reference wrapper
Optional: a ready-to-use .runwhen/kb_sync.py that walks the changed note files, upserts each with its git provenance, handles deletions, and exits non-zero if any note fails validation. Standard library only — adapt freely, or write your own; the API contract above is all you need.
#!/usr/bin/env python3"""Sync .runwhen/knowledgebase/*.md into RunWhen. Adapt freely."""import argparse, glob, json, subprocess, sys, urllib.error, urllib.request
KB_DIR = ".runwhen/knowledgebase"
def git(*args): return subprocess.check_output(["git", *args], text=True).strip()
def call(base, ws, token, method, path, body): req = urllib.request.Request( f"{base}/api/v4/workspaces/{ws}{path}", data=json.dumps(body).encode(), method=method, headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req) as r: return json.load(r) except urllib.error.HTTPError as e: print(f" HTTP {e.code}: {e.read().decode()}") return None
def main(): p = argparse.ArgumentParser() p.add_argument("--workspace", required=True) p.add_argument("--api", required=True) p.add_argument("--token", required=True) p.add_argument("--changed") p.add_argument("--dry-run", action="store_true") a = p.parse_args()
source = git("config", "--get", "remote.origin.url") commit = git("rev-parse", "HEAD")
if a.changed: rows = git("diff", "--name-status", a.changed, "--", KB_DIR).splitlines() else: rows = [f"A\t{f}" for f in glob.glob(f"{KB_DIR}/**/*.md", recursive=True)]
upserts, deletes = [], [] for row in rows: parts = row.split("\t") (upserts if parts[0][0] in "AMR" else deletes).append(parts[-1])
failed = False for path in upserts: print(f"sync {path}") res = call(a.api, a.workspace, a.token, "POST", "/notes/sync", { "source": source, "source_id": path, "source_ref": commit, "content": open(path).read(), "dry_run": a.dry_run, }) if not res or not res.get("ok"): failed = True for d in (res or {}).get("diagnostics", []): print(f" {d['level']}: {d['message']}")
if deletes and not a.dry_run: print(f"delete {len(deletes)} note(s)") call(a.api, a.workspace, a.token, "DELETE", "/notes", {"source": source, "source_ids": deletes})
sys.exit(1 if failed else 0)
if __name__ == "__main__": main()Attaching by tag
resource_paths maps a note to enumerated paths. resource_selectors maps it to every resource carrying a given tag, so the match set stays current as new resources are discovered. Add a selector block alongside (or instead of) resource_paths:
resource_selectors: - match_tags: "[k8s]app.kubernetes.io/name": checkout-apiKeys in a match_tags map are ANDed; multiple selectors in the list are ORed. Tag keys carry provider prefixes ([k8s]…, [aws]…, [azure]…) except for RunWhen’s structural tags (namespace, cluster, resource_type, …), which have no prefix. Discover available keys with GET /api/v4/workspaces/{ws}/tags.
Custom tasks
A custom task is a script you wrote — bash or python — that RunWhen runs on a runner and turns into health checks and issues. You can add one straight from CI: keep the script in your repo and sync it with the API. No agent, no MCP server, and nothing for you to host.
A task is made of two records:
- an SLX — the task’s identity (name, statement, tags), and
- a Runbook — what to run: your script, the interpreter, env vars, and secret mappings.
Upload a script from CI (no agent required)
Keep the script in your repo (for example tasks/check-disk.sh), then make two calls: create the SLX, then create the Runbook with your script base64-encoded into GEN_CMD. A generic wrapper on the runner decodes and executes it — so you never reference a code repository of your own.
BASE="https://papi.beta.runwhen.com" # self-hosted: https://papi.<your-domain>WS="my-workspace"RUNNER="<runner>" # a runner name from: GET $BASE/api/v3/workspaces/$WS/runners
SLX="check-disk"SCRIPT_B64=$(base64 -w0 tasks/check-disk.sh) # macOS: base64 -i tasks/check-disk.sh
# 1) Upsert the SLX (identity + required tags). Returns resource_id.SLX_ID=$(curl -s -X POST "$BASE/api/v1/workspaces/$WS/slxs/sync" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "payload": { "name": "'"$WS--$SLX"'", "alias": "Disk Space Check", "statement": "Disks should have free space", "tags": [{"name": "access", "value": "read-only"}, {"name": "data", "value": "logs-bulk"}] } }' | jq -r .resource_id)
# 2) Upsert the Runbook, carrying the script inline as GEN_CMD.curl -s -X POST "$BASE/api/v1/workspaces/$WS/runbooks/sync" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "payload": { "slx_id": "'"$SLX_ID"'", "runner_uuid": "'"$RUNNER"'", "code_bundle_repo_url": "https://github.com/runwhen-contrib/rw-generic-codecollection.git", "code_bundle_ref": "main", "code_bundle_path": "codebundles/tool-builder/runbook.robot", "config_provided": [ {"name": "TASK_TITLE", "value": "Check disk space"}, {"name": "GEN_CMD", "value": "'"$SCRIPT_B64"'"}, {"name": "INTERPRETER", "value": "bash"}, {"name": "CONFIG_ENV_MAP", "value": "{}"}, {"name": "SECRET_ENV_MAP", "value": "[]"} ], "secrets_provided": [] } }'That’s the whole upload. The task shows up in the workspace right away and runs on the chosen runner. Re-running the same two calls updates it in place (the sync is an idempotent upsert keyed by name), so a pipeline can re-apply on every merge. To remove a task, DELETE $BASE/api/v4/workspaces/$WS/slxs/$SLX.
Set
INTERPRETERtobashorpython. Your script must follow the RunWhen task contract — definemain()and emit issues. See the script contract and Tasks & Runbooks. For an indicator (a 0–1 metric) instead of a task, usecode_bundle_path: codebundles/tool-builder/sli.robotandPOST …/slis/sync.
Passing env vars and secrets
Wire configuration and credentials in through the same config_provided block:
-
Env vars — add each as its own entry and list it in
CONFIG_ENV_MAP:{"name": "NAMESPACE", "value": "prod"},{"name": "CONFIG_ENV_MAP", "value": "{\"NAMESPACE\": \"prod\"}"} -
Secrets — map an env var to a workspace secret key (names come from
GET $BASE/api/v3/workspaces/$WS/secrets-keys) insecrets_provided, and list the env var names inSECRET_ENV_MAP:"secrets_provided": [{"name": "KUBECONFIG", "workspaceKey": "prod-kubeconfig"}],...{"name": "SECRET_ENV_MAP", "value": "[\"KUBECONFIG\"]"}At runtime, each secret env var holds a file path on the runner — not the value. Tools that read paths (e.g.
kubectlviaKUBECONFIG) work as-is; for tokens, read the file in your script (cat "$KUBECONFIG", oropen(os.environ["TOKEN"]).read()).
Reconcile a directory of tasks
To manage many tasks, keep a manifest plus the scripts in your repo and loop the two calls in your reconciler:
tasks: - slx: check-disk alias: Disk Space Check statement: Disks should have free space interpreter: bash script: tasks/check-disk.sh access: read-only data: logs-bulkimport base64, os, requests, yaml
BASE = os.environ["RW_BASE"] # https://papi.<domain>WS = os.environ["RW_WORKSPACE"]RUNNER = os.environ["RW_RUNNER"] # from GET {BASE}/api/v3/workspaces/{WS}/runnersH = {"Authorization": f"Bearer {os.environ['RW_TOKEN']}", "Content-Type": "application/json"}
def sync_task(t): slx = requests.post(f"{BASE}/api/v1/workspaces/{WS}/slxs/sync", headers=H, json={ "payload": { "name": f"{WS}--{t['slx']}", "alias": t["alias"], "statement": t["statement"], "tags": [{"name": "access", "value": t["access"]}, {"name": "data", "value": t["data"]}], }}) slx.raise_for_status() slx_id = slx.json()["resource_id"]
script_b64 = base64.b64encode(open(t["script"], "rb").read()).decode() requests.post(f"{BASE}/api/v1/workspaces/{WS}/runbooks/sync", headers=H, json={ "payload": { "slx_id": slx_id, "runner_uuid": RUNNER, "code_bundle_repo_url": "https://github.com/runwhen-contrib/rw-generic-codecollection.git", "code_bundle_ref": "main", "code_bundle_path": "codebundles/tool-builder/runbook.robot", "config_provided": [ {"name": "TASK_TITLE", "value": t["alias"]}, {"name": "GEN_CMD", "value": script_b64}, {"name": "INTERPRETER", "value": t["interpreter"]}, {"name": "CONFIG_ENV_MAP", "value": "{}"}, {"name": "SECRET_ENV_MAP", "value": "[]"}, ], "secrets_provided": [], }}).raise_for_status() print(f"synced task {t['slx']}")
if __name__ == "__main__": for t in yaml.safe_load(open("tasks/tasks.yaml"))["tasks"]: sync_task(t)Run it from the same CI job as your other context (see Running it in CI).
Other ways to add tasks
- MCP Tool Builder — if your environment can run an agent or IDE, the Tool Builder validates and tests the script on a runner before it is saved. Best for authoring new tasks interactively.
- RunWhen registry — for common stacks (Kubernetes, databases, cloud), deploy ready-made, parameterized automation from the registry instead of writing your own.
Running it in CI
Knowledge runs in its own PR-validate / merge-publish workflow — see Knowledge as code. Rules, Commands, and Custom Tasks apply together in one merge-to-main job: get a token, then run the reconcilers.
jobs: apply-context: runs-on: ubuntu-latest env: RW_BASE: https://papi.beta.runwhen.com # self-hosted: https://papi.<your-domain> RW_WORKSPACE: my-workspace RW_RUNNER: my-runner # from GET $RW_BASE/api/v3/workspaces/$RW_WORKSPACE/runners steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: { python-version: "3.12" } - run: pip install requests pyyaml - name: Get token run: | echo "RW_TOKEN=$(curl -s -X POST "$RW_BASE/api/v3/token/" \ -H 'Content-Type: application/json' \ -d "{\"client_id\":\"${{ secrets.RW_CLIENT_ID }}\",\"client_secret\":\"${{ secrets.RW_CLIENT_SECRET }}\",\"grant_type\":\"client_credentials\"}" \ | jq -r .access_token)" >> "$GITHUB_ENV" - run: python reconcile.py # Rules and Commands - run: python reconcile_tasks.py # Custom tasksStore RW_CLIENT_ID / RW_CLIENT_SECRET as CI secrets. The service account needs the workspace admin role to write Rules, Commands, Assistants, and tasks.
Related
| Topic | Link |
|---|---|
| Building operational context (UI walkthrough) | Building Operational Context |
| Context endpoints | API Reference — Managing Operational Context |
| Interactive API explorer | API Reference |
| MCP server & Tool Builder | MCP Server · Tool Builder |
| Custom task / runbook spec | Tasks & Runbooks |