Skip to content

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 typeWhat you keep in GitAPI
RulesRule definitions…/chat-config/rules
CommandsCommand definitions…/chat-config/commands
Knowledge (Notes)Note markdown + frontmatter (paths / tag selectors)…/notes/sync
Custom TasksThe 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).

Terminal window
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, not app. Requests to the app. web-app host return 404 for every API path. This matters most for self-hosted: the API base is papi.<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-triage
resource_paths:
- kubernetes/prod/cluster-01/namespace/payments
---
When checkout returns 503s, first check connection-pool saturation on
checkout-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 as content, plus git provenance source, source_id, and source_ref (the commit). Set "dry_run": true to validate without writing. The response has ok, a resolved status (created / updated / unchanged / rejected), and a list of diagnostics.
  • DELETE /api/v4/workspaces/{ws}/notes — remove notes dropped from the repo. Body: source plus the source_ids to delete.

The smallest working sync is one curl — try it from your terminal before wiring it into CI:

Terminal window
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
}
JSON

A 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:

.github/workflows/runwhen-kb-validate.yml
name: RunWhen KB — validate
on:
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-run

The 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-api

Keys 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.

Terminal window
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 INTERPRETER to bash or python. Your script must follow the RunWhen task contract — define main() and emit issues. See the script contract and Tasks & Runbooks. For an indicator (a 0–1 metric) instead of a task, use code_bundle_path: codebundles/tool-builder/sli.robot and POST …/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) in secrets_provided, and list the env var names in SECRET_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. kubectl via KUBECONFIG) work as-is; for tokens, read the file in your script (cat "$KUBECONFIG", or open(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/tasks.yaml
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-bulk
import 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}/runners
H = {"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 tasks

Store RW_CLIENT_ID / RW_CLIENT_SECRET as CI secrets. The service account needs the workspace admin role to write Rules, Commands, Assistants, and tasks.


TopicLink
Building operational context (UI walkthrough)Building Operational Context
Context endpointsAPI Reference — Managing Operational Context
Interactive API explorerAPI Reference
MCP server & Tool BuilderMCP Server · Tool Builder
Custom task / runbook specTasks & Runbooks