Skip to content
Docs menu

Config as code

Each tenant keeps its Tempa configuration in its own git repo, for Devleaps Devleaps/tempa-config-devleaps. tempa-api pulls it on every merge, validates it and publishes it as a new config version (TEMPA-007), kept in Tempa's own config store with every earlier version (below). The same validator runs on every pull request in the config repo, so a broken change never reaches developers. A tenant without a Git repo keeps its config in Tempa instead and changes it through drafts (below).

Layout

tenant.yaml                         tenant settings and the top of the tree
nodes/<opco>/node.yaml              one folder per node; nesting is the tree
nodes/<opco>/<dept>/<team>/node.yaml
standards/<name>.md                 coding standards, rendered as rules
skills/<name>/SKILL.md              Agent Skills format; other files in the folder ship with it
commands/<name>.md                  harness commands
agents/<name>.md                    subagents (frontmatter needs a description)
policies/<bundle>/*.rego            Rego, agent-policies style; package name = folder name
policies/<bundle>/*_test.rego       its Rego unit tests (opa test), never evaluated or shipped
policies/<bundle>/samples.yaml      its sample events with expected decisions (docs/policies.md)
mcp/<name>.yaml                     MCP endpoint registrations
steps.yaml                          optional: the SDLC step model for the work board (see below)
packs/<pack>/...                    library packs the tenant subscribed to, with their pack.lock (Tempa writes these)
CODEOWNERS                          who approves which level

Standards, skills, commands, agents, policy bundles and MCP endpoints can also come from Tempa's starter library: versioned packs with the same layout (library.md, TEMPA-117). A tenant that subscribes to a pack (TEMPA-118) gets it under packs/<pack id>/ through a draft: the pack's files verbatim and a pack.lock, plus the subscribing node's settings adding its assets like any other. The validator loads those assets with the config's own (one namespace: a name both have is an error) and refuses any file under packs/ that differs from its lock: pack files change only through a pack update, which comes as a draft (library.md). In a Git tenant's repo, commit the files a subscription's draft made and leave packs/ alone otherwise; the CI validator catches an edit there.

YAML files are plain YAML without aliases: an anchor (&name) is allowed, a reference to it (*name, also in a <<: merge key) is an error. Write the value out instead (TEMPA-219: nested aliases could expand a few hundred bytes into gigabytes). An issue quotes at most 500 characters of what it complains about.

A folder under nodes/ without a node.yaml only groups its children. Node kinds must go down the tree: tenant → opco → dept → team → project (a level may be skipped, never reversed).

tenant.yaml

id: devleaps
name: Devleaps
default_node: team-platform     # profile for users and repos that match nothing else
profile_ttl: 7d                 # how long a signed profile stays valid offline
retention_days: { events: 90, aggregates: 396 }   # telemetry retention; unset keys take the installation default, see telemetry.md
groups:                         # Entra groups bound to a role on the whole tenant
  - { id: <entra-group-object-id>, role: platform_owner }
settings: { ... }               # see Settings

node.yaml

kind: team                      # opco, dept, team or project
id: team-platform               # unique in the tenant
name: Platform
repos: [github.com/Devleaps/bridge, github.com/Devleaps/dl-agents]
groups:
  - { id: <entra-group-object-id>, role: developer, default: true }   # default: members' default team
  - { id: <entra-group-object-id>, role: team_lead }
settings: { ... }

Roles: platform_owner, node_admin, team_lead, standards_owner, auditor, developer. A role on a node applies to its whole subtree. A repo belongs to at most one node.

Settings and modes

Every setting is set at some level with a mode:

ModeLower levels may
lockednot change or remove it
extendableadd to it, not remove from it
openadd, remove or override

List settings name assets from this repo:

settings:
  standards: { mode: extendable, add: [python-fastapi, nextjs] }
  skills:    { mode: open, add: [tdd-workflow], remove: [old-skill] }
  commands:  { mode: open, add: [review] }
  agents:    { mode: open, add: [reviewer] }
  mcp:       { mode: extendable, add: [second-brain] }
  policies:  { mode: locked, bundles: [universal, python_uv] }    # bundles: is the same as add:

An item can only be removed below the level that added it when that level used open.

Guardrails combine strictest-wins, so two settings stay tighten-only even when locked: below a locked policies a level may still add bundles (never remove them), and below a locked permissions it may still add ask and deny rules (never allow).

  permissions: { mode: extendable, deny: ["Bash(terraform apply:*)"], ask: ["Bash(git push:*)"] }

Single values use value, with locked or open:

SettingValue
sync_intervalduration, for example 15m
default_decisionallow, ask or deny when no policy matches (default allow)
allow_external_mcptrue / false; true lets MCP servers other than the tempa proxy through to the harness's own permissions (default true; set false to allow only Tempa's proxy)
local_evaluationtrue / false; whether the CLI decides shell commands on the laptop from the compiled policy module (default true; false sends every one to /v1/decide, policies.md)
telemetrytrue / false
log_prompts, log_tool_detailstrue / false; off unless a level opts in
work_referenceslist of {tracker, pattern, url}: how the CLI recognises an issue or story in a prompt or branch name (default [])
work_board{visibility: team | named, approved_ref}: whether the work board shows people by name (default team, see below)
account_policy{allowed_kinds, on_violation, require_training_attestation}: which kinds of Claude account a session may use (default all, inform, no attestation; see below)

Every setting, with its shape, modes, value schema and default, is also described for forms and tools in the settings catalogue.

work_references adds to what the CLI recognises on its own (GitHub, GitLab, Jira, Linear and Azure DevOps issue URLs, and keys like ABC-123 in branch names). pattern is a JavaScript-compatible regex with at most one group, the key (the whole match when there is none); url is an optional https template with {key}:

settings:
  work_references:
    mode: open
    value:
      - { tracker: github, pattern: "TEMPA-(\\d+)", url: "https://github.com/Devleaps/tempa/issues?q=TEMPA-{key}" }
      - { tracker: atlas, pattern: "https://atlas\\.devleaps\\.nl/doc/[\\w-]+" }

work_board: names on the work board

The live work board (TEMPA-E2) shows per node either

  • team (default): work items, steps and deliveries with the team, never a person; or
  • named: a row per developer with their name.

named is employee monitoring. Before a node switches to it:

  1. do a DPIA for the work board (what is shown, to whom, how long it is kept: the telemetry retention);
  2. tell the team what will be visible and why;
  3. where there is a works council (OR), get its consent: a system that can monitor employees needs it (WOR art. 27).

named then needs approved_ref, a link to the DPIA or the decision; the validator refuses it without one:

settings:
  work_board:
    mode: open
    value: { visibility: named, approved_ref: "https://atlas.devleaps.nl/doc/dpia-work-board-..." }

How it works: telemetry stays pseudonymous in ClickHouse. Every row is stamped with the visibility its node had when the row was recorded (tempa.work_board), and tempa-api only looks up names for named rows, at query time, from its own user table. Switching a node to named does not name its history; switching it back to team hides names at once, also for older rows. A developer always sees their own work by name. Every publish lists the named nodes and their approved_ref in the audit log. To forbid named everywhere, lock work_board at the tenant with visibility: team.

account_policy: which Claude accounts

Personal Claude accounts fall under Anthropic's consumer terms (a model-training setting, five years of retention when it is on); Team and Enterprise seats, API keys and cloud providers do not (TEMPA-132, accounts.md). At every session start the CLI classifies the session's account as api_key, cloud, team_or_enterprise, personal or unknown and applies this setting:

KeyValuesDefault
allowed_kindslist of the kinds aboveall of them
on_violationinform: a message at session start; ask: also the first tool call of the session asks; block: every tool call is denied, read-only ones tooinform
require_training_attestationtrue: a session whose account may be personal (personal, unknown) needs tempa attest --no-training on the device, under the same on_violationfalse
settings:
  account_policy:
    mode: locked
    value: { allowed_kinds: [team_or_enterprise, api_key, cloud], on_violation: block }

A claude.ai login is team_or_enterprise only when Claude Code's managed settings file pins logins to the company's organisation (forceLoginOrgUUID); without that it is unknown, because nothing documented tells a Pro or Max plan from a Team seat. So a policy that leaves unknown out needs the managed settings deployed first. With the defaults nothing changes for anyone.

policy_exceptions: exceptions to the policies

A policy that blocks legitimate work gets an explicit, reviewed exception instead of a workaround. Each entry names one shell command and the policies it waives; nothing else changes:

settings:
  policy_exceptions:
    mode: extendable
    add:
      - { tool: Bash, command: "terraform plan:*", waives: [tf_guardrails], reason: "plans are read-only", request: "<request id>" }
KeyWhat
toolBash (shell commands, in every harness), the only tool for now
commandthe exact command, or <prefix>:* for the prefix's words followed by anything; no shell metacharacters (; & | < > $ ( ) \ ' " { } * ? [ ] ! # ~`, backslash, newline)
waivesthe policies it overrides: policy bundle names, or tempa/default-decision (required, at least one)
reason, requestwhy, and the exception request it came from (optional, for traceability)

The decision (policies.md) turns a deny or ask into allow only when the command has no metacharacters, matches the entry, and every policy that decided the call is in waives; a call another policy decides stays decided. The MCP allowlist, the account policy, Tempa's own files and the offline rules are never waived. Entries add up down the tree. A locked level ends the list: nothing below it adds one, so a tenant that allows no exceptions anywhere locks it empty (policy_exceptions: { mode: locked }). An approver makes these entries from a developer's exception request in tempa-web (Exception requests); they then go through the normal approval and publish.

permissions.allow is a different thing: it is not rendered (TEMPA-213) and never changes a Tempa decision.

steps.yaml: the SDLC step model

The work board shows each piece of work in a step of our development process, with a sub-step inside it (TEMPA-034). Without a steps.yaml the default applies:

StepSub-steps
refineunderstand, plan
designdesign
developbuild, test, security, docs
reviewpr-open, ci, changes-requested, approved
delivermerged, deployed

A step is known in two ways:

  • Skills report it. The tempa MCP proxy offers work_step (develop.test, review.pr-open, ...); a skill says when to call it, for example "when the failing test is written, call work_step with develop.test".
  • Rules infer it from tool calls, first match wins: tool is a regex on the tool name (whole match), command a regex searched in a Bash command, path one searched in the edited file's path relative to the repo. The default rules map gh pr merge → deliver.merged, gh pr create → review.pr-open, gh pr checks → review.ci, security scanners → develop.security, test runners and edits of test files → develop.test, edits under docs/ and of Markdown → develop.docs, other edits → develop.build (DEFAULT_STEPS in api/tempa_config/model.py).

A steps.yaml replaces the whole default:

steps:
  - { id: refine, sub_steps: [understand, plan] }
  - { id: develop, label: Develop, sub_steps: [build, test, security, docs] }
  - { id: review, sub_steps: [pr-open, ci] }
  - { id: deliver, sub_steps: [merged] }
rules:
  - { step: deliver.merged, tool: Bash, command: "\\bgh pr merge\\b" }
  - { step: develop.test, tool: Bash, command: "\\b(pytest|vitest|pnpm test)\\b" }
  - { step: develop.build, tool: "Edit|Write|MultiEdit" }

The validator checks that ids are unique lowercase names, that every rule names a step (and sub-step) that exists, and that every regex compiles and is JavaScript-compatible (the CLI runs them). The model is tenant-wide for now; teams cannot extend it yet.

Validate locally

cd api
uv run python -m tempa_config validate ../path/to/tempa-config-devleaps
uv run python -m tempa_config validate ../path/to/repo --dump    # print the loaded model

Besides compiling the Rego with regopy (what tempa-api evaluates with), the validator builds the policy bundles to WebAssembly with opa build -t wasm, which the CLI evaluates locally (TEMPA-123, policies.md). OPA is stricter than regopy: an unused variable or a call to helpers.… without import data.helpers in the same file fails that build. This is a warning at its file and line, not an error: the config is still valid, publishes and goes live (the commit status is success with the warning, the Action passes with a warning annotation); only local evaluation is off for that version, so developers' CLIs keep asking /v1/decide. The check needs opa 1.22 on the PATH (brew install opa) or in TEMPA_OPA_PATH; without it the check is skipped and the validator says so (note: WebAssembly build check skipped). tempa-api and the Action below always have it.

In the config repo's CI

# .github/workflows/validate.yml in the config repo
on: pull_request
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Devleaps/tempa/.github/actions/validate-config@main

Devleaps/tempa is private, so its Actions settings must allow access from repositories in the Devleaps organisation (Settings → Actions → General → Access).

Publishing: webhook, versions and rollback

On every push to the config repo's main branch GitHub calls tempa-api, which downloads that commit, validates it and publishes it as the new live config version. A commit that fails validation is stored as invalid, the previous version stays live, and the commit gets a failing tempa/config status with the first error (the full list is at GET /v1/config/versions/<sha>). Every publish, rejection and rollback is in the audit log.

Setup in the config repo (Settings → Webhooks):

FieldValue
Payload URLhttps://tempa.devleaps.nl/v1/config/webhook
Content typeapplication/json
Secretthe value of TEMPA_GITHUB_WEBHOOK_SECRET
Eventsjust the push event

tempa-api settings: TEMPA_CONFIG_REPO (Devleaps/tempa-config-devleaps), TEMPA_CONFIG_BRANCH (main), TEMPA_GITHUB_WEBHOOK_SECRET, and TEMPA_GITHUB_TOKEN: a fine-grained token (or GitHub App installation token) on the config repo with Contents: read and Commit statuses: read and write.

One config source per tenant. Where a tenant's config comes from is its config_source tenant setting (Tenant settings): kind: github (this section: repo, branch, optional overlay_repo and overlay_branch, sync_interval_seconds, and credentials) or kind: git, any Git URL (Git sources). The home tenant (TEMPA_TENANT_ID, see auth.md) falls back to the variables above (TEMPA_CONFIG_REPO, TEMPA_CONFIG_BRANCH, TEMPA_CONFIG_OVERLAY_*, TEMPA_CONFIG_SYNC_INTERVAL_SECONDS) while it has none stored. A source's versions are published into its tenant, and its tenant.yaml must have that tenant's id, or it is rejected.

Credentials per tenant (TEMPA-053, TEMPA-079). A tenant may sync its config source when it is the home tenant or has Git credentials of its own. A github source is read with the tenant's git_token secret (or the git_token:<label> its credentials names); only the home tenant falls back to the installation's TEMPA_GITHUB_TOKEN, which reads whatever the operator granted it. So PATCH /v1/tenant/settings refuses a github source for another tenant that has no such secret with 422 ("a github config source for this tenant needs Git credentials of its own: store a git_token secret first ..."; clearing it with null is allowed), and a source whose secret is missing (written with SQL, or the secret was deleted later) is skipped by the webhooks and the catch-up with a warning in the log. A git source always uses the tenant's own credentials (or none, for a public repo). The token needs Contents: read; with Commit statuses: read and write the commits also get the tempa/config status (without it the publish goes on and the log says so).

GitHub's push webhook is one URL for all tenants (POST /v1/config/webhook): tempa-api first routes the push to the tenant whose config repo or overlay the pushed repo is (case-insensitive), then verifies its signature with that tenant's webhook_secret (the home tenant without one: TEMPA_GITHUB_WEBHOOK_SECRET), and ingests it bound to that tenant (row-level security). A tenant without a webhook secret gets no push through (its catch-up still runs). A push from a repo no tenant has is verified with the installation's secret, answered ignored and logged; a repo is the source of at most one tenant (setting one another tenant uses is refused with 409, without naming that tenant). A github source is set only once Tempa can read its repo (and overlay) with the tenant's own credentials (TEMPA-235; 422 otherwise), so naming a repo a tenant cannot read never blocks the tenant that owns it. Keep TEMPA_GITHUB_TOKEN scoped to the home tenant's config repos.

Catch-up. A webhook can be missed: tempa-api was down, or the commit was merged before the webhook existed. So tempa-api also reads every tenant's branch heads at startup and then every sync_interval_seconds of that tenant's source (home tenant default TEMPA_CONFIG_SYNC_INTERVAL_SECONDS, 300; 0 turns that tenant's catch-up off), and ingests a head the same way when it has no version for that sha yet. It looks for new or changed sources at least once a minute. The audit log shows those as actor tempa:catch-up, channel catch-up. A head that was already ingested is not tried again, except that at startup a head an older tempa-api rejected is validated once more (a newer version may know a setting the old validator did not); with several replicas a Postgres advisory lock per tenant lets one of them do it. The loop waits at most two minutes for one tenant's source and then goes on with the others (TEMPA-235); a catch-up still running is not started again.

EndpointWhoWhat
GET /v1/config/versionsany role with config.readthe last 100 versions with status, errors, source, source_ref, bundle_id, created_by and where each came from (below; MCP list_config_versions)
GET /v1/config/versions/{ref}config.readone version, by its id or its ref (source_ref; for GitHub the sha, and a prefix is enough), with where it came from (MCP get_config_version, with its files)
GET /v1/config/versions/{ref}/filesconfig.readthe version's files: path, blob (sha256), mode, size; never their content
GET /v1/config/versions/{ref}/files/{path}config.readone file as it was published: UTF-8 text as text/plain, anything else as an application/octet-stream attachment; ETag is the blob hash (MCP read_config_version_file)
GET /v1/config/versions/{ref}/diff?against=previous|live|<ref>config.readthe version compared with another: previous (the default: the newest published version made before it; against null when there is none, so everything is added), live, or any version's id or ref. summary, files (as a draft's diff: status, both sides, unified diffs for text files, size-capped) and semantic (nodes, settings per node, assets, packs; null when either version did not validate). A version of another tenant is 404; one from before the config store is 409 (MCP diff_config_versions)

Where a version came from is derived on read, never stored: origin is git (a Git sync, GitHub or any Git URL), draft (a draft published in tempa-web or over MCP), pack (a draft a library pack subscription, update or unsubscribe made), upload (an uploaded package) or import (the config branch's head taken over into managed mode); draft is the draft it was published from (id, title, status, and pack: kind, pack, version for a pack's draft), null for a Git sync or an import; rolled_back is the newest rollback that made the version live again (by, at, from the audit log; a rollback makes an existing version live, it does not create one), or null. | POST /v1/config/rollback {"version": "<id>"} or {"sha": "<ref>"} | platform_owner | point live back to an earlier published version, of any source | | GET /v1/config/source | platform_owner (tenant.settings) | the source as it syncs: the source, syncable, its credentials (metadata; for a deploy key the public key and where to add it), the webhook URL and secret to set up, git_known_hosts, last_sync (this replica) and newest_version (TEMPA-079) | | POST /v1/config/source/test {"config_source": {...}, "token": "..."} | platform_owner | test a source before saving it: reads the branch head with the source's stored credentials (or, for an https_token source, a token typed into the form, used for this test only); saves nothing, not even an SSH host key seen for the first time. Answers ok, sha, host_keys (first seen, pinned by the first sync after saving), or ok: false with git's error and a reason: url, credentials, host_key, branch, access or unreachable (MCP test_config_source, without a token) | | POST /v1/config/sync | platform_owner | sync now, in the background (audited config.sync); answers the queued run; 409 without a source the tenant may sync | | GET /v1/config/source/syncs?limit= | platform_owner | the last syncs, newest first (1 to 100, default 20; MCP list_config_syncs): below | | POST /v1/config/webhook | GitHub (signature) | GitHub's push webhook, see above | | POST /v1/config/source/webhook/{tenant} | the Git host (the tenant's webhook_secret) | a push webhook from GitHub, GitLab, Bitbucket, Azure DevOps or any host: syncs the tenant on a push to its branch (below); /v1/config/webhook/git/{tenant} is the older path, kept as an alias |

A rollback takes effect at each developer's next sync; the next merge to main publishes on top of it.

Git sources: any Git URL (TEMPA-079)

A tenant's config repo can live on any Git server: GitLab, Bitbucket, Azure DevOps, a self-hosted server, or GitHub as plain Git. Tempa reads it with the system git and a read-only deploy key it generates (or an HTTPS token), so nobody hands over a personal token.

{"config_source": {
  "kind": "git",
  "url": "git@gitlab.com:acme/tempa-config.git",
  "branch": "main",
  "path": "",
  "auth": {"type": "ssh_key", "secret": "git_ssh_key"},
  "sync_interval_seconds": 300
}}
KeyValue
urlhttps://host[:port]/path, ssh://user@host[:port]/path or user@host:path (rules below)
branchthe branch that publishes (default main)
paththe folder in the repo that holds tenant.yaml (default "": the root); the bundle is that folder
auth.typessh_key (an SSH URL: the deploy key), https_token (an https URL with a token) or none (a public https repo)
auth.secretthe tenant secret: a git_ssh_key (default git_ssh_key) for ssh_key, a git_token (default git_token) for https_token; git_ssh_key:<label> and git_token:<label> work too
auth.usernamehttps_token only: the user name the token is sent with (default x-access-token); a GitLab deploy token's own user name, x-token-auth for a Bitbucket repository access token
sync_interval_secondshow often the branch head is polled (0 to 86400, default 300; 0: only webhooks and "sync now")

Setting it up in tempa-web (TEMPA-210, platform owners of the whole tenant): Config → Config source → Connect a repository (or Change once one is connected) opens a dialog with the type (git, any URL; github only when the tenant bound the GitHub App, or for the home tenant), the URL, branch, folder and catch-up interval, and the credentials:

  • None for a public https repository.
  • Deploy key (an SSH URL): Generate a deploy key (POST /v1/tenant/secrets/git_ssh_key/generate) shows the public key with a copy button and where each host takes it as a read-only key; Generate a new key replaces it.
  • Token (an https URL): a write-only password field, stored with PUT /v1/tenant/secrets/git_token when the source is saved and never shown again, and an optional user name.

While the installation's secret store is off (GET /v1/tenant/secrets → store.enabled: false) the dialog says private repositories need it and offers only None. Test the connection (TEMPA-272, POST /v1/config/source/test) reads the branch head with what the form holds and saves nothing: it shows the head, an SSH host key seen for the first time (to compare with the one the Git host publishes), or why it failed and what to fix (the URL, missing credentials, a host key that is not the pinned one, a missing branch, a key or token the server refused, or a server Tempa cannot reach). Connect saves with PATCH /v1/tenant/settings (tempa-api's 400, 409, 422 and 503 messages show in the dialog). A managed tenant stays managed: the dialog sends config_mode: "managed" with the source (an unset mode would otherwise become git as soon as there is a source), and the import or the switch to Git is the Source of truth panel's. A git tenant's next sync publishes the new branch head. After saving, the card shows the repository, its deploy key, Sync now, the syncs and the push webhook (below). Disconnect (confirmed) clears config_source; a git tenant becomes managed with it, the live version stays, and the deploy key, token and webhook secret stay in the secret store. The card lists the pinned SSH host keys; Reset the pinned host keys (confirmed) clears them (git_known_hosts: null) after the server's key really changed, and the next sync pins the new one.

Setting it up (platform owner, API or MCP):

  1. POST /v1/tenant/secrets/git_ssh_key/generate (MCP generate_deploy_key): Tempa generates an Ed25519 key pair, keeps the private key encrypted, and answers the public key. Or, for HTTPS, PUT /v1/tenant/secrets/git_token with a read-only token.
  2. Add the public key to the repo as a read-only deploy key (per host below).
  3. PATCH /v1/tenant/settings with the config_source above (MCP update_tenant_settings).
  4. Optionally a webhook: POST /v1/tenant/secrets/webhook_secret/generate (tempa-web: Config → Config source → Generate the secret; the answer's value is shown this once), and the host's push webhook to https://<tempa>/v1/config/source/webhook/<tenant id> with that secret (per host below).
  5. POST /v1/config/sync (MCP sync_config_now), then GET /v1/config/source (MCP get_config_source): the last sync's outcome or error, and the newest version.

Every push to the branch (by webhook, else by the next poll) becomes a version: source git, ref the commit sha (GET /v1/config/versions/<sha>), audited config.publish/config.rejected with the host, branch and folder. A commit whose folder is unchanged still makes a version, of the same bundle. Polling reads the head with git ls-remote first, so nothing is fetched when nothing changed.

Per host

HostURLDeploy keyWebhook
GitLabgit@gitlab.com:group/repo.gitthe project's Settings → Repository → Deploy keys → Add new key, Grant write permissions offSettings → Webhooks: URL as above, Secret token = the webhook secret (sent as X-Gitlab-Token), trigger Push events (optionally only the branch)
Bitbucket Cloudgit@bitbucket.org:workspace/repo.gitRepository settings → Access keys → Add key (read-only by design)Repository settings → Webhooks → Add webhook: URL as above, Secret = the webhook secret (sent as an X-Hub-Signature HMAC), trigger Repository push
Bitbucket Data Centerssh://git@bitbucket.example.com:7999/proj/repo.gitRepository settings → Access keys → Add key, permission ReadRepository settings → Webhooks: URL as above, Secret = the webhook secret, event Repository: Push
Azure DevOpsgit@ssh.dev.azure.com:v3/org/project/repoAzure DevOps has no per-repo deploy keys: add the key under a user (best a dedicated service user with read access to the repo) at User settings → SSH public keys; or use https://dev.azure.com/org/project/_git/repo with a PAT (Code: Read) as https_tokenProject settings → Service hooks → Web Hooks → Code pushed (repo and branch): URL as above, and either Basic authentication with the webhook secret as the password, or the HTTP header X-Tempa-Token: <webhook secret>
GitHub as plain Gitgit@github.com:owner/repo.gitthe repo's Settings → Deploy keys → Add deploy key, Allow write access offthe repo's Settings → Webhooks: URL as above, content type application/json, Secret = the webhook secret, just the push event (GitHub's own kind: github webhook, /v1/config/webhook, also works)
Self-hosted (Gitea, Forgejo, plain sshd)ssh://git@git.example.com:2222/team/repo.gitthe host's deploy key setting (read-only), or the key in the git user's authorized_keysany push webhook that can send X-Tempa-Token: <secret>, an X-Hub-Signature-256 HMAC, or Basic auth with the secret as the password

The generic webhook trusts nothing in the payload for what to publish: a verified push only starts a sync of the tenant, which reads the branch head itself. Every refusal (no such tenant, no secret, a wrong secret) is the same bare 401.

Webhooks and the sync history (TEMPA-081)

POST /v1/config/source/webhook/{tenant}, in this order:

  1. Rate limit per client address (webhook_ip, 300 a minute by default, verified or not), before the tenant is looked up or its secret decrypted (TEMPA-231); 429 with Retry-After.
  2. Verification with the tenant's webhook_secret, in constant time (hmac.compare_digest): GitHub's X-Hub-Signature-256 and Bitbucket's X-Hub-Signature (an HMAC-SHA256 of the exact body), GitLab's X-Gitlab-Token, Basic authentication with the secret as the password (Azure DevOps) or X-Tempa-Token. Anything else is a bare 401 ({"detail": "Unauthorized"}), whoever the tenant is.
  3. Rate limit per tenant (config_webhook, 60 a minute by default), counted only for verified deliveries, so nobody without the secret can use up a tenant's bucket (TEMPA-231); the secret's last_used_at is recorded only now. The catch-up still syncs what a refused delivery would have.
  4. What it is (services/push_events.py): a ping is answered pong; an event that is not a push (ignored, event <name>); the pushed branches from GitHub's and GitLab's ref, Bitbucket's push.changes[].new, Azure DevOps' resource.refUpdates[]. Only a push to the source's branch syncs; another branch, a tag or a deleted branch is answered ignored (and kept in the history). A payload that names no branch we know syncs anyway: the sync reads the configured branch.
  5. Once per delivery: the delivery id (X-GitHub-Delivery, X-Gitlab-Event-UUID, Bitbucket's X-Request-UUID, Azure DevOps' event id, X-Tempa-Delivery) is unique per tenant; a redelivery answers duplicate and syncs nothing.

The sync history (config_sync_runs, migration 0029, a tenant table with forced row-level security, in the export): every "sync now" and webhook delivery (queued, then its outcome), and every catch-up poll that made a version or failed (an unchanged head is not kept); at most 200 per tenant. Each run has its trigger, the sender and delivery id of a webhook, the pushed ref, the outcome (queued, ingested, up_to_date, skipped, ignored, error), the version and its status, the error, who started it and the replica. The replicas share it; GET /v1/config/source's last_sync stays this replica's view for operators.

tempa-web: Config → Config source (platform owners): the last syncs with outcome and error (refreshing while one runs), Sync now, and the webhook URL, the branches that sync, whether the secret is set, Generate the secret (shown once; generating again replaces it at once) and where each host takes it. The MCP tool generate_deploy_key refuses webhook_secret: its value would land in a model's context.

URL rules (api/tempa_api/services/git_url.py; checked when the setting is saved, and the addresses again before every connection):

  • Only https://, ssh:// and user@host:path: no http://, git:// or file://, no local paths, no remote helpers (ext::… would run a command), nothing that starts with -.
  • No credentials in the URL (https://user:token@… is refused: the URL is shown in settings, audit entries and errors). Tokens and keys go in the secret store.
  • The host is a public DNS name (with a dot; not localhost, *.local, *.localhost, *.internal, *.cluster.local, *.svc, *.home.arpa) or a public IP address.
  • Server-side request forgery: before each sync the host is resolved, and every address it resolves to must be public: private (10/8, 172.16/12, 192.168/16, fc00::/7), loopback, link-local (169.254/16 with the cloud metadata service, fe80::/10), shared (100.64/10), multicast, reserved and unspecified addresses are refused, also inside IPv4-mapped, 6to4, Teredo and NAT64 IPv6 forms. git then connects to the address that was checked (SSH: the URL carries the IP and HostKeyAlias keeps the host's name for the host key; HTTPS: http.curloptResolve pins the name to the IP, TLS still checks the name), so a second DNS answer cannot send it elsewhere, and HTTP redirects are off.

How git runs (api/tempa_api/services/git_client.py): in a fresh scratch folder (mode 0700, removed afterwards) with a minimal environment (no proxy, no user or system git config, no terminal prompts, GIT_ALLOW_PROTOCOL=https:ssh) and a timeout per command (60 s). A shallow fetch (--depth 1, no tags, no submodules) of the branch head into a bare repository; the folder's files are read from git's objects (ls-tree, cat-file), never checked out, so no hooks, filters or symlinks apply; the config store's caps (2 MiB a file, 32 MiB and 5000 files) are checked before a file is read, and a symlink or submodule makes the version invalid. The deploy key is written to a 0600 file in that folder for ssh (-i, IdentitiesOnly, BatchMode, no agent, no ssh config); an HTTPS token reaches git only through its environment (GIT_CONFIG_COUNT/http.extraHeader), never on a command line; git's error output is scrubbed of the secret before it is logged or shown.

SSH host keys: first-seen pinning. ssh runs with StrictHostKeyChecking=accept-new and the tenant's own known_hosts: the first connection pins the server's host key in the tenant setting git_known_hosts (audited config.source_change with "git_known_hosts": "pinned", actor tempa:sync). From then on a different host key fails the sync (last_sync says "the SSH host key of … is not the one Tempa pinned") until a platform owner checks the new key with the Git host and resets the pin: PATCH /v1/tenant/settings {"git_known_hosts": null} (the next sync pins the new key), or sets git_known_hosts to the host's published known_hosts lines (pinning before the first connection).

The GitHub App (TEMPA-080)

A tenant whose config repo is on GitHub installs Tempa's GitHub App on that repo instead of storing a token. Tempa then reads the repo with installation tokens (short-lived, limited to the repos the tenant chose), syncs on every push, and gives every pull request to the config branch a tempa/config commit status. Pull requests from forks are only checked when the source says "check_fork_pull_requests": true (TEMPA-221): on a public config repo anyone can open one, and each check downloads their tree with the tenant's installation.

Downloads are bounded (TEMPA-221): a GitHub archive larger than 32 MiB, a file in it above 2 MiB, more than 32 MiB in all or more than 10,000 entries is refused before anything is written; a Git source's git writes no file above 128 MiB.

Off until configured. The app exists only after a person registers it on GitHub and puts its settings in the vault (operations.md): TEMPA_GITHUB_APP_ID, TEMPA_GITHUB_APP_SLUG, TEMPA_GITHUB_APP_PRIVATE_KEY, TEMPA_GITHUB_APP_WEBHOOK_SECRET (and TEMPA_TOKEN_SECRET). Half of them, or a private key that is not RSA PEM, refuses to start. Until then every endpoint below but GET answers 503 and tempa-web shows nothing of it.

Installing (platform owner): Config → Config source → Install the GitHub App, or POST /v1/config/github-app/install (MCP start_github_app_install, audited github_app.install), which answers the install URL. Its state is HMAC-signed with the tenant, the person and 15 minutes, and is single-use. The app asks the person to authorize it during the install, so GitHub sends the browser back to tempa-web (/api/config/github-app/setup?installation_id=…&state=…&code=…), which binds the installation with POST /v1/config/github-app/bind {installation_id, state, code} (TEMPA-228): only for the same tenant and person, before the state expires and once (403); only for an installation the person can see on GitHub, checked with the OAuth code against GET /user/installations (403, the same answer whether the installation exists or not); only for an installation GitHub knows (404); and never one another tenant bound (409). Rate-limited per address (github_app_bind_ip, 10 a minute). Without the app's OAuth client (TEMPA_GITHUB_APP_CLIENT_ID and _SECRET) binding answers 503. Audited github_app.bind. Then point the source at the app:

{"config_source": {"kind": "github", "repo": "acme/tempa-config", "credentials": "github_app"}}

(PATCH /v1/tenant/settings; refused with 422 before the app is bound). Such a source is read with an installation token of the tenant's own installation and nothing else: never TEMPA_GITHUB_TOKEN, also not for the home tenant. DELETE /v1/config/github-app (MCP unbind_github_app) forgets the binding (audited github_app.unbind) and says (uninstall) that the app keeps its access to the repo until it is uninstalled on GitHub; uninstalling the app on GitHub forgets the binding too.

The app's webhook (POST /v1/config/github-app/webhook, one for every tenant): verified with the app's webhook secret in constant time (anything else is a bare 401), then routed by its installation to the tenant that bound it, for that tenant's config repo only.

EventWhat Tempa does
push to the config branchsyncs the tenant, once per delivery (X-GitHub-Delivery; the sync history); another branch is ignored
pull_request opened, synchronize, reopened or ready for review, to the config branchvalidates the head commit and compares it with live, then sets the tempa/config status on it: pending, then success ("valid; 1 changed, 0 added, 0 removed vs live") or failure (the error count and the first error); error when Tempa could not check it
installation deleted or suspendedforgets the tenant's binding

Make tempa/config a required status check on the config branch, and a broken config cannot be merged.

The config store: versions from any source

Tempa keeps every config tree it publishes itself (TEMPA-078, api/tempa_api/services/config_store.py), so history, rollback, the kill list and profiles work the same whatever brought a version in. GitHub is one source, any Git URL another (Git sources), a draft published in the UI or through MCP a third (Drafts, TEMPA-084), an uploaded .zip or .tar.gz a fourth (Upload, TEMPA-082).

  • Bundle: a config tree (tenant.yaml, nodes/, standards/, ...: every regular file, .git left out). Stored per tenant, content-addressed: each file's bytes once as a blob (config_blobs, keyed by sha256:<hex> of the bytes, zlib-compressed), and a manifest per bundle (config_bundles: {path: {"blob": "sha256:…", "mode": "100644" | "100755"}}). The bundle id is the sha256 of the manifest as canonical JSON (sorted keys, no whitespace, UTF-8): the same tree has the same id from any source, and storing it again stores nothing. Blobs are never shared between tenants.
  • Caps (blobs live in Postgres bytea for now; object storage later): a file at most 2 MiB, a bundle at most 32 MiB and 5000 files, paths relative without ./.. and at most 512 characters, no symlinks. A tree over a cap becomes an invalid version with a (bundle) issue.
  • Version: config_versions rows have source (github, git, upload, ui, mcp, import), source_ref (the GitHub version key, a git source's commit sha, an upload or draft id; unique per tenant and source, and the name the API, the audit log and profiles use), bundle_id and created_by. git_sha is kept for GitHub versions. Versions ingested before the store existed have no bundle: their files answer 404 (their snapshot still works for profiles and rollback).
  • One pipeline (publish.publish_bundle): store the bundle → write it to a scratch folder and validate it with the same validator as above → record the version (invalid with its issues, or valid) → make it live (tree sync, catalogue; profiles resolve from the new snapshot). The config's tenant.yaml id must be the tenant's, from any source. GitHub's ingest downloads the commit as before, merges the overlay into it (the bundle is the merged tree), and goes through the same pipeline; webhook, catch-up, commit statuses and overlay error labels are unchanged. Audit entries say source and bundle.
  • Garbage collection: bundles no version and no open draft references (older than a grace period, default 24 hours, so a preview can still be published) and then blobs no bundle references are deleted by the operator's POST /v1/operator/tenants/{id}/config-store/gc (operations.md).

Drafts: change the config in Tempa

A tenant has one source of truth at a time (TEMPA-084), its config_mode tenant setting (below):

ModeThe truth isA change isGit sync
git (default with a config source)the config repoa pull request; drafts can be made and previewed here, publishing one is refused with 409 "publish through a pull request on <repo>"; with pull requests from Tempa turned on a draft opens that pull request (below)on
managed (default without one)Tempaa draft, published here (tempa-web or MCP)off: the webhook and the catch-up skip the tenant, so a push cannot overwrite a publish; the source stays stored for switching back

A draft (config_drafts, a tenant table with forced row-level security; api/tempa_api/services/drafts.py) is a working tree in the config store on top of a base version (the live one by default), with a title, author, timestamps, status open | published | discarded, a revision and the validator's issues. A tenant has at most 50 open drafts.

  • File operations: put (text or base64; executable sets the mode, absent keeps it), delete and rename a file, with the store's path rules and caps. Every change stores the new tree, bumps the revision and runs the validator at once: the answer has valid, errors, warnings and issues, so nobody has to publish to see an error. A request may send the revision it is based on; another revision is 409 with action: "reload" and the current revision (optimistic concurrency; changes to one draft are serialised).
  • Diff against the live version (default) or the base: per file added, removed or changed with both sides' blob, mode and size, and for text files a unified diff (no diff for binary files or files over 256 KiB; at most 64 KiB of diff per file and 1 MiB per answer, then truncated). When the draft is valid, also what it means, from the two validated models: nodes added and removed, per remaining node the fields (name, kind, parent, repos, groups) and settings added, removed and changed, assets per kind added, removed and changed (content hash), the tenant's own fields and whether the step model changed.
  • Preview: the effective profile of a node (and optionally of a user on it) as the draft would make it, next to live: the same resolver on the draft's validated model, with the kill list and the tenant's MCP urls, version draft:<id>; changed says whether the two differ (versions and times aside). With a sample action (tool, input) also the policy decision on both. A node that exists only in the draft (or only live) shows on its side only. Nothing is written: no cached profile, no tree sync.
  • Rebase: when another publish moved live past the draft's base, publishing is 409 with action: "rebase". A rebase merges per file three ways (base, draft, live): a file only one side changed takes that side, a file both changed alike is fine, a file both changed differently is a conflict (409, action: "resolve", conflicts: [{path, draft, live}] with added/changed/deleted). Rebase again with resolutions: {path: "draft" | "live"} for those files.
  • Publish (managed mode, base still live, no errors; else 409 or 422 with action pull_request, rebase or fix): the one pipeline, publish_bundle, as a version with source ui (or mcp through the MCP server, or upload for a draft made from an uploaded package), source_ref the draft id and created_by the person; it goes live like any version (rollback works the same), the audit log has config.publish with details.draft, title and revision, and the draft becomes published with published_version. Publishes of one tenant run one after the other (a row lock on the tenant), so the second sees the first's live version and has to rebase.
  • Discard closes a draft for good; its tree may then be garbage collected (its files answer 410).
  • Library packs (TEMPA-118): a subscription, an update proposal and an unsubscribe are drafts too, made by Tempa with the pack's files under packs/ (origin {"source": "pack", ...} and a description with the changelog). File operations never touch packs/ (422, action: "subscription"). Archive: GET /v1/config/drafts/{id}/archive answers the draft's tree as a zip, for a Git tenant to commit.

Starting, changing and discarding a draft are audited (config.draft_create, config.draft_edit with the operation and the paths but never the content, config.draft_discard; TEMPA-098).

EndpointWhoWhat
GET /v1/config/drafts?status=open|allconfig.readthe drafts (last 100, most recently changed first), the tenant's mode, why a draft cannot be published here (refusal), the live version, and where the config lives in Git for links (source: {kind, repo or url, branch, path} without credentials, or null; source_from: tenant, environment or default)
POST /v1/config/drafts {"title"?, "base"?}config.edit or config.edit_content on the tenant nodestart a draft from live, or from a published version (id or ref); a tenant that never published starts from a tree with only its tenant.yaml (id and name)
GET /v1/config/drafts/{id}config.readthe draft with its files (paths, blob, mode, size), issues and publish: {allowed, action, reason}
GET /v1/config/drafts/{id}/file?path=config.readone file: content (UTF-8) or content_base64
PUT /v1/config/drafts/{id}/file {"path", "content" | "content_base64", "executable"?, "revision"?}config.edit; config.edit_content under the content foldersadd or replace a file
DELETE /v1/config/drafts/{id}/file?path=&revision=config.edit; config.edit_content under the content foldersremove a file
POST /v1/config/drafts/{id}/rename {"path", "to", "revision"?}config.edit; config.edit_content under the content foldersmove a file
GET /v1/config/drafts/{id}/diff?against=live|baseconfig.readtext and semantic diff
POST /v1/config/drafts/{id}/preview {"node", "user"?, "tool"?, "input"?}config.read, profile.read on the nodeeffective profile with the draft next to live, and explain
POST /v1/config/drafts/{id}/rebase {"resolutions"?, "revision"?}config.edit or config.edit_contentmove onto live
POST /v1/config/drafts/{id}/publish {"revision"?, "canary"?}config.publish on the tenant nodemake it the live version, or with canary node ids stage it on those nodes first (below)
POST /v1/config/drafts/{id}/discard {"revision"?}config.edit, or config.edit_content for a draft one startedclose it
POST /v1/config/drafts/{id}/approve {"revision"?, "comment"?}config.approve on the draft's level (below)approve the current revision
POST /v1/config/drafts/{id}/approvals/withdraw {"revision"?}config.approvewithdraw your approval
POST /v1/config/drafts/{id}/pull-request {"revision"?, "title"?}config.publish on the tenant nodea Git tenant's draft as a branch and pull request on the config repo (below)
GET /v1/config/drafts/{id}/treeconfig.readthe draft's tree as data, with issues per node (below)
POST /v1/config/drafts/{id}/tree {"operations": [...], "revision"?}config.editchange nodes, repos, groups and settings without YAML
GET /v1/config/settings-catalogueconfig.readevery setting described (below)
GET /v1/config/drafts/{id}/assets?kind=config.readthe draft's standards, skills, commands, subagents and MCP endpoints with who uses them (below)
GET /v1/config/drafts/{id}/assets/{kind}/{name}config.readone asset for its editor
POST /v1/config/drafts/{id}/assets {"operations": [...], "revision"?}config.edit; config.edit_content for the content kindscreate, change, rename and delete assets without writing files

config.edit and config.publish are platform owners' (and config tokens'); who else must agree before a publish is the tenant's approvals policy (below). tempa-web's Drafts page (web.md) and the MCP tools (mcp-server.md) offer the same.

Standards owners (TEMPA-280) hold config.edit_content on the tenant node: they start drafts and change standards, skills, commands and subagents (the asset operations of those kinds, and files under standards/, skills/, commands/ and agents/), rebase, and discard a draft they started. The tree, node settings, tenant.yaml, policies, MCP endpoints, packages, pack subscriptions and publishing answer them 403 with "You may change only standards, skills, commands and subagents in a draft, not … Ask a platform owner to make that change.", and so does an asset operation that would rewrite a node's setting (renaming or force-deleting an asset a node names; adding an asset to a node is a tree operation). Their draft needs approvals like any other, never their own (auth.md).

Approvals (TEMPA-088)

A managed tenant can require approvals before a draft is published, with the platform setting publish_approvals (PATCH /v1/tenant/settings, MCP update_tenant_settings, or tempa-web's Config, Source and publishing, Publish approvals; api/tempa_api/services/approvals.py):

{"publish_approvals": {"required": 1, "by_kind": {"tenant": 2, "team": 1}}}
KeyValue
requiredapprovals per draft, 0 to 5 (default 1 once the setting is there)
by_kindper kind of the draft's level (tenant, opco, dept, team, project), instead of required

Unset (the default), drafts publish without approvals, as before.

  • The level is the deepest node above everything the draft changes, from its semantic diff against live: a changed node is its own level, an added or removed node its parent's, a moved node both its old and new parent's, and anything tenant-wide (standards, skills and other assets, tenant.yaml, the step model, library packs, a file the diff cannot place) the tenant. A draft that changes one team is that team's; one that also touches a standard is the tenant's. GET /v1/config/drafts/{id} shows it in approvals (level, level_path, level_kind), with required, given, missing, the approvals (approver, comment, time) and the authors.
  • Who approves: anyone with config.approve on the level or above it: platform owners, node admins, team leads and standards owners, by where their role is bound. A team lead approves a change to their team, not one to the tenant's standards.
  • No self-approval: nobody who made or changed the draft (authors: its author and every editor) may approve it (403).
  • Per revision: an approval is for the revision it was given on; any later change (an edit, a rebase) makes it stale, and the draft needs its approvals again. Approving twice is answered as is; an approver can withdraw theirs.
  • Publishing a draft that misses approvals is refused with 409 and action: "approval" (also a staged publish, TEMPA-091; promoting a staged version asks for none). The config.publish entry lists details.approvals, approvals_required and level.
  • Git tenants approve their pull requests on the Git host; approving here answers 409 pull_request.

Approvals are audited config.approve (revision, level, count) and config.approval_withdraw, on the level's path. tempa-web shows them on the draft (Approve revision N, Withdraw my approval); MCP has approve_draft and withdraw_draft_approval.

Editing the tree in the UI (TEMPA-085)

The Tree tab of a draft in tempa-web (and the draft_* MCP tools) changes nodes, their repos, the Entra groups bound to roles, and every setting with its mode, without writing YAML. Each change is an operation that tempa-api writes into the draft's files as YAML; the draft then validates the new tree as for any file change, so the validator stays the one judge, and the raw file editor stays available.

POST /v1/config/drafts/{id}/tree takes a list of operations (at most 50), applied in order as one new revision; the first that cannot be done refuses them all. The answer is the draft (revision, valid, issues) with applied (a line per operation) and the new tree.

OperationFieldsRefused when
add_nodeid, kind (opco, dept, team, project), parent (a node id; the tenant's id for an opco), namethe id is in use (409); the kind does not sit below the parent's (422): tenant → opco → dept → team → project, a level may be skipped; the folder <parent's folder>/<id>/ exists (409)
update_nodenode, and any of name, repos (the whole list), groups (the whole list of {id, role, default})a repo belongs to another node (409, case-insensitive); a value is not a repo (422); the tenant gets repos (422); a group is bound to the same role twice (422)
move_nodenode, parentthe tenant, or a move into its own subtree or under a kind it must sit above (422); the target folder exists (409). The node's folder moves with its subtree; files keep their bytes and modes
delete_nodenode, forceit has child nodes or repos and no force (409); it (or, with force, a node below it) is the tenant's default_node or the node a library pack is subscribed on (packs/<id>/pack.lock, TEMPA-118) (409); the tenant (422)
set_settingnode, key, mode, and per the setting's shape add/remove (lists), add with objects (entries: policy exceptions), allow/ask/deny (permissions) or value; or unset: truean unknown key, a field the shape does not take, a mode the setting does not have, a value the validator refuses (all 422)

Repos are normalised: git@github.com:Devleaps/bridge.git, https://github.com/Devleaps/bridge/ and github.com/Devleaps/bridge are all github.com/Devleaps/bridge (host lowercased, .git dropped). A node's id never changes (roles granted in Tempa and default_node refer to it); its name does. A rule about the levels above (a setting locked at the opco, removing an item an extendable level added) is not a refusal: the operation is written and the draft's issues say what is wrong, on the node it is about.

The YAML. tempa-api writes the files with PyYAML (ruamel.yaml is not a dependency) in one fixed form, so the same tree always gives the same bytes: keys in the order kind, id, name, repos, groups, settings (tenant.yaml: id, name, default_node, profile_ttl, retention_days, groups, settings; unknown keys after them), settings in the catalogue's order, each group binding and each setting that fits on a line as a flow mapping (standards: {mode: open, add: [python-fastapi]}), longer ones as blocks, lists indented. A file an operation writes loses its comments; files it does not write stay byte for byte (a move keeps them as they are). Keep comments out of node.yaml files you edit in the UI, or edit those files in the Files tab. Operations never write under packs/ (library packs change only through a subscription or an update), and the form is one TEMPA-118's subscribe can rewrite the settings: block of, and the editor can read back.

GET /v1/config/drafts/{id}/tree answers the tree as data, also for a draft with errors: per node id, kind, name, parent, path, file, repos, groups, the settings its own file sets (policies' bundles: shown as add:), children and the validator's issues in its file; the node files that do not load (broken, fix them as files); the issues outside node files; and per asset kind the names a list setting can use (from the validated model, else from the files). A tenant.yaml that does not load is 409 with action: "files".

Editing assets in the UI (TEMPA-086)

The Assets tab of a draft in tempa-web (and the draft_*_asset MCP tools) creates, changes, renames and deletes the config's standards, skills (with the other files in their folder), commands, subagents and MCP endpoint registrations, in the layout above. As for the tree, each change is an operation tempa-api writes into the draft's files (api/tempa_config/asset_edit.py), validated like any file change.

POST /v1/config/drafts/{id}/assets takes a list of operations (at most 50), applied in order as one new revision; the first that cannot be done refuses them all. The answer is the draft with applied (a line per operation) and the new assets. kind is standard, skill, command, agent (a subagent) or mcp.

OperationFieldsRefused when
put_assetkind, name, create (the asset must be new), and per kind: frontmatter (all its fields: they replace the old ones) and body for the Markdown kinds, files ({path in the folder: text, or null to remove it}) for a skill, endpoint ({url, auth, tools, description}) for an MCP endpoint; fields left out keep what the asset hasa name that is not lowercase letters, digits and hyphens (at most 64) (422); create and the name is in use, also by a library pack (409); a skill or subagent without a description, or a frontmatter name other than the asset's (422); an endpoint the validator's schema refuses (422); a field the kind does not take (422); a skill file path outside its folder, or SKILL.md (422)
rename_assetkind, name, tothe new name is not valid (422) or in use (409); a node file does not load, so not every reference can be updated (409)
delete_assetkind, name, forcea node's setting still adds or removes it and no force (409, naming those nodes); a node file does not load (409)

Every operation on an asset of a library pack (packs/<id>/) is refused with 422 and action: "subscription": a pack's files change only through a pack update (TEMPA-118).

  • Frontmatter and body. A Markdown asset is edited as its frontmatter's fields and its body. The body is written exactly as given, and the blank lines between frontmatter and body stay; frontmatter whose fields did not change keeps its text (comments too), changed frontmatter is written by PyYAML with name and description first. Without fields there is no frontmatter. A skill's and a subagent's name is the asset's own: tempa-api sets it (Agent Skills requires it to equal the folder; Claude Code names the subagent by it).
  • MCP endpoints are written in one form: name, description, url, auth (tempa-device: the tenant's shared upstream credential for it, if any; per-user: each person signs in to the server once through Tempa, https only, with an optional scope, default read, TEMPA-283, auth.md; none), tools (on one line; absent: every tool the server offers, []: none), checked against the validator's McpFile schema; the form's JSON Schema is in the listing's kinds.
  • Rename moves the file (a skill's whole folder, files keeping their modes), changes the name inside it (a skill's or subagent's frontmatter name, any other Markdown asset's name that was the old one, an endpoint's name), and replaces the old name in the add and remove lists of every node's setting for the kind, all in one revision. The node files it changes are written in the tree editor's form (and lose their comments); the others stay byte for byte. An MCP endpoint's tenant settings go by name: an mcp_urls entry and an mcp_credential:<name> secret do not move along (the answer says so).
  • Delete removes the file or the skill's folder; with force it also takes the asset out of the nodes' lists (a list that becomes empty goes; the setting keeps its mode).

GET /v1/config/drafts/{id}/assets lists the assets (the config's own and the library packs', marked read_only) with their file(s), description, used_by ({node, list: add | remove}, in tree order) and the validator's issues in their files; references is false when tenant.yaml does not load. GET …/assets/{kind}/{name} answers one asset for its editor: frontmatter and body (or why the frontmatter does not parse, in error), a skill's other files with their text (up to 256 KiB each), an endpoint's endpoint fields, and the raw content.

Editing policy bundles in the UI (TEMPA-087)

The Policies tab of a draft (and the draft_*_policy_* MCP tools) edits the policy bundles the same way: operations on policies/<bundle>/ (api/tempa_config/policy_edit.py) through POST /v1/config/drafts/{id}/policies, one revision per call, with a rename or a delete keeping every node's policies setting right (bundles:, add: and remove:) and library pack bundles read-only. Its test runner runs the bundles' Rego unit tests and sample events on the draft next to live. The operations, the checks and the samples.yaml format are in policies.md.

The settings catalogue

One machine-readable description of every setting, in api/tempa_config/catalogue.py: GET /v1/config/settings-catalogue (MCP get_settings_catalogue). tempa-web renders the setting forms from it, the MCP tools check against it, and the resolver takes the single values' defaults from it. A test fails when the validator knows a setting the catalogue does not describe (api/tests/test_settings_catalogue.py).

FieldWhat
key, label, description, docsthe setting's name, a label for forms, a sentence, and its section in this file
shapelist (asset names: add, remove), object (permissions: allow, ask, deny), entries (policy exceptions: add, a list of objects) or value (value)
modes, default_modethe modes it may have (single values: locked or open) and the one it gets when a level does not say
widgethow a form edits it: assets, rules, duration, select, toggle or json
item_kindlists: the asset kind its items name (standard, skill, command, agent, mcp, bundle)
value_schemaJSON Schema of one item (lists, entries), each rule list (permissions) or the value
defaultwhat applies when no level sets it
inheritanceper mode the level above chose, what a lower level may still do (for example policies locked: only add bundles)

The answer also has modes (what each mode means), the node kinds and the roles.

Upload a config package (TEMPA-082)

The whole config tree can also come in as one archive, from any tool that can make a .zip or a .tar.gz (a build job, an export, a folder zipped by hand). An upload becomes a draft: an open draft on the live version whose working tree is the package, validated at once. From there it is the draft flow above: its issues, the diff against live, previews, rebase, publish. Publishing it records a version with source upload, source_ref the draft id, and the archive's sha256 in the audit details. A git-mode tenant may upload to validate and preview; its publish is refused as for any draft.

# The raw archive as the body (the format is told from its bytes, not its name)
curl -X POST "https://tempa.example/v1/config/uploads?filename=config.zip&title=Q4%20standards" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/zip" --data-binary @config.zip
# or as a form's `file` field (with an optional `title` field)
curl -X POST https://tempa.example/v1/config/uploads -H "Authorization: Bearer $TOKEN" -F file=@config.tar.gz

The answer (201) is the draft, as GET /v1/config/drafts/{id}, with its origin: {"source": "upload", "filename", "sha256": "sha256:…", "size", "format": "zip" | "tar.gz", "stripped", "skipped"}.

What a package may hold. The config tree, at the root of the archive or inside one top-level folder, which is stripped (stripped names it): what zipping a folder or git archive --prefix=config/ makes. macOS's __MACOSX/ metadata, .DS_Store files and a .git folder are left out unread (skipped counts them). The store's caps apply: a file at most 2 MiB, at most 5000 files and 32 MiB in all, paths relative without ./... Executable files keep their mode (the zip's or tar's Unix mode); everything else is a regular file.

Safe extraction. The archive is read in memory, entry by entry, and nothing is ever written to disk or extracted by the archive library (api/tempa_api/services/archive.py). The whole package is refused (422 the package was refused: …, no draft is made) when it has:

RefusedWhy
an absolute name (/x, C:x, a backslash) or a .. segmentpath traversal
a symbolic or hard link, a device, a FIFO, any entry that is not a regular file or folderlinks and devices
more than 10000 entries, a file over 2 MiB, more than 32 MiB uncompressed in allthe store's caps, checked from the sizes a zip declares before anything is decompressed, and again while reading
a compression ratio over 100 (a zip entry over 256 KiB, a zip in all, or a tar.gz stream over 8 MiB)zip and gzip bombs; the decompressed tar stream is also capped at 64 MiB
the same path twice (a leading ./ aside)duplicates
an entry name that is not UTF-8names must survive the round trip
an encrypted zip entry, or one compressed other than stored or deflated
no files, or a file that is also a folderthe bundle's rules

The request body itself is read as a stream and refused with 413 as soon as it passes 50 MiB (a larger Content-Length is refused before anything is read); a content type other than application/zip, application/gzip (or x- variants), application/octet-stream or multipart/form-data is 415.

EndpointWhoWhat
POST /v1/config/uploads?filename=&title= (body: the archive, or multipart/form-data with file and title)config.edit on the tenant nodeupload a package: an open draft on live with its tree (201)
POST /v1/config/uploads/check (the same body)config.read on the tenant nodevalidate a package and compare it with live, without a draft (TEMPA-083, tempa config validate and diff): valid, errors, issues, against (the live version), summary, files (with unified diffs) and semantic; nothing is stored; counts against config_upload

A CI pipeline uses these with a config token (TEMPA-083, cli.md): tenant-scoped, config.read, config.edit and config.publish only, expiring, revocable, audited; made by a platform owner (POST /v1/tenant/config-tokens, tempa config token create) and shown once.

Uploads are audited config.upload (target the draft; details the file name, sha256, size, format, base, file count and whether the tree is valid) instead of config.draft_create, and limited per tenant by the config_upload rate limit (10 a minute by default, operations.md), counted only for callers who may upload. tempa-web's Drafts page has Upload package (web.md) and the MCP server upload_config (mcp-server.md, base64, at most 10 MiB).

Staged rollout and kill switches (TEMPA-091)

A change does not have to reach everyone at once, and a bad one can be stopped without a new publish (api/tempa_api/services/rollouts.py, services/rollout_state.py, migration 0042).

Staged rollout. Publishing a draft with canary node ids (POST /v1/config/drafts/{id}/publish {"canary": ["team-data"]}, at most 20, never the tenant itself) validates it like any publish but does not make it live: the version is recorded as valid with its snapshot and policy module, and an active rollout gives it to the canary nodes and everything below them. Every other node keeps the live version. A tenant has one active rollout at a time; publishing another draft is refused with 409 and action: "rollout" until it ends. It ends in one of three ways:

  • Promote (POST /v1/config/rollout/promote): the staged version becomes live for every node, audited config.publish with details.rollout and the canary nodes.
  • Abort (POST /v1/config/rollout/abort {"reason"?}): the canary nodes get the live version again, audited config.rollout_abort.
  • Superseded: another version goes live (a rollback, a Git push, a publish elsewhere): the rollout is aborted with the reason "superseded: version … went live", audited the same.

Staging is for managed tenants (it is a draft publish); a Git tenant's pushes go live as before.

Kill switch. An emergency fallback for the whole tenant or for a node and its subtree, from the next sync of its developers, until it is released:

ModeWhat its developers get
last_goodan earlier published version: the one named (version, any version that was live), by default the one that was live before the current one; 409 when there is none
minimalthe safe minimal profile: the same tenant, node, repo and validity, and nothing from the config: no standards, skills, commands, subagents, MCP endpoints, policy bundles or permission rules, every tool call asked (default_decision: ask), telemetry without prompts or tool details. Its version is kill-switch:<id>

A switch beats a staged rollout. When several cover a node, the strictest wins: minimal before last_good, then the one nearest the tenant. One switch per tenant and node is engaged at a time (409 otherwise). Engaging and releasing need config.kill_switch on that node: platform owners anywhere, node admins in their subtree (the tenant's switch only platform owners). Both are audited, kill_switch.engage (mode, fallback, reason) and kill_switch.release, on the node's path.

Signed profiles, always. Whatever serves a profile (live, a canary version, a fallback version or the minimal profile), GET /v1/profile signs it with the same key as every other profile; without a signing key it answers 503, never an unsigned profile. A kill switch changes what is signed, never whether. The CLI keeps using its last signed profile until it expires when it cannot reach Tempa, so a switch reaches a device at its next successful sync. Cached profiles are forgotten on every rollout and switch change; profiles from a kill switch are never cached.

EndpointWhoWhat
GET /v1/config/rolloutconfig.readper node of the live tree (within the caller's scope) which version it gets and via (live, canary, last_good, minimal), the live version, the active rollout, the engaged kill switches, the default last_good version and the last 10 rollouts
POST /v1/config/rollout/promoteconfig.publish on the tenant nodemake the staged version live everywhere
POST /v1/config/rollout/abort {"reason"?}config.publish on the tenant nodeend the rollout
POST /v1/config/kill-switches {"node"?, "mode", "version"?, "reason"}config.kill_switch on the node (node null: the tenant)engage a kill switch
POST /v1/config/kill-switches/release {"node"?}config.kill_switch on the noderelease it

GET /v1/nodes/{id}/effective shows the profile the node gets, so a canary node shows the staged version and a switched node its fallback. tempa-web: Config → Rollout (per node, Promote, Abort, Engage and Release behind confirmations) and the draft's publish dialog (pick canary nodes). MCP: publish_draft with canary, get_rollout, promote_rollout, abort_rollout, engage_kill_switch, release_kill_switch.

Pull requests from drafts (TEMPA-089)

In a Git tenant the config repo is the truth, so a change made in tempa-web or through MCP (a draft, with the tree, asset and policy editors) reaches it as a pull request. POST /v1/config/drafts/{id}/pull-request (config.publish; MCP open_draft_pull_request; api/tempa_api/routes/pull_requests.py):

  1. Off until configured: the tenant setting config_pull_requests ({"enabled": true, "branch_prefix": "tempa/"}, PATCH /v1/tenant/settings, or tempa-web's Config, Source and publishing, Pull requests from Tempa). Turn it on after giving the source's credentials write access: a deploy key with write access, a token that may push, or the GitHub App with Contents and Pull requests: read and write (operations.md). Until then the call is 409 action: "configure"; a managed tenant gets 409 action: "publish" (publish the draft instead).
  2. The draft must be open and valid (422 fix); its change since its base is merged three ways onto the config branch's head (as a rebase does): a file both changed differently is 409 action: "resolve" with the paths, and a change that leaves the head as it is is 409.
  3. The result is one commit on a new branch <branch_prefix>draft-<first 8 of the draft id>-r<revision>, on top of the head, the repo's other files (outside the source's folder) untouched, pushed with the source's credentials (TEMPA-090's push: no checkout, no hooks, the checked address, the pinned host keys). Tempa never commits to the config branch or the repo's default branch, and never to a branch that exists.
  4. The pull request: with a github source on the GitHub App (credentials: github_app) Tempa opens it through GitHub's API with the installation's token (url, number); otherwise the answer has compare_url, the host's page to open it (GitHub, GitLab, Bitbucket, Azure DevOps), or for a host Tempa does not know only the branch. If GitHub refuses, the branch is still pushed and error says why.

The same revision again answers the pull request it has (opened: false); a later revision gets its own branch. The draft shows its newest pull request (pull_request) and whether pull requests are on (pull_requests). Audited config.pull_request (branch, commit, base, URL, revision, files). The pull request's tempa/config check validates it (the GitHub App), and merging it publishes it like any push. tempa-web: the draft page's Open a pull request.

Switching the source of truth (TEMPA-090)

A tenant can move between git and managed (Drafts) without losing what it has: export what Tempa manages to its Git repo, import the repo into Tempa, and switch config_mode, each with a dry run first (the default: the repo is read and diffed against live, nothing changes). api/tempa_api/routes/source_switch.py, services/source_switch.py.

EndpointWhoWhat
GET /v1/config/export?version=&format=zip|jsonconfig.reada version's whole tree (live by default) as a zip to unpack over a checkout, or every file with its content (MCP get_config_export)
POST /v1/config/export/push {"dry_run"?, "branch"?, "message"?}platform_owner (tenant.settings)the config branch's head (at the source's folder) diffed against live; with dry_run: false live becomes one commit on a new branch on top of that head, the repo's other files untouched, pushed with the source's credentials; the answer has branch, sha and compare_url (MCP export_config)
POST /v1/config/import {"dry_run"?}platform_ownerthe config branch's head validated and diffed against live (valid, issues, files, semantic); with dry_run: false it is published as an import version (ref the commit sha) and the tenant switched to managed (MCP import_config)
POST /v1/config/mode {"mode", "dry_run"?, "force"?}platform_ownermanaged: Tempa is the truth and the Git sync stops. git: the dry run diffs the head against live (head_is_live); the switch is refused with 409 action: "export" while they differ unless force, because the next sync publishes the head (MCP switch_config_mode)
  • Never the config branch. An export pushes a new branch (default tempa/export-<date>-<time>-<live ref>), never the source's branch or the repo's default branch (422) and never one that exists (409); the change reaches the config branch through a pull request. compare_url opens one on GitHub, GitLab, Bitbucket and Azure DevOps; for another host, open it there.
  • Credentials: a git source pushes with its deploy key or token, which then needs write access (a read-only key is refused by the host: 502 with its reason); a github source pushes over HTTPS with its token or a GitHub App installation token (the app needs Contents: read and write). The commit's author is the person, its committer Tempa. git runs as for a sync: no checkout, no hooks, the checked address, the pinned host keys.
  • A source with an overlay repo is refused (422): export or import those by hand.
  • Audited config.export (repo, branch, commit, base, files), config.import (the version, the head, the mode before) and config.mode_change (mode, previous mode, force, the head and whether it was live).

tempa-web: Config → Config source → Source of truth: Download live as a zip, Preview the export then Push live to a new branch, Preview the import then Import and switch to managed, Preview switching to … then Switch, every change behind a confirmation.

Overlay: nodes that only one installation has

Some nodes belong to one installation only, like the demo teams on dev (Devleaps/tempa demo/, epic TEMPA-E3). They do not go in the shared config repo, which prd reads too; they go in an overlay repo (Devleaps/tempa-config-devleaps-demo) that only dev reads:

  • overlay_repo and overlay_branch in the tenant's config_source; for the home tenant without a stored source TEMPA_CONFIG_OVERLAY_REPO (unset: no overlay) and TEMPA_CONFIG_OVERLAY_BRANCH (main).
  • The overlay only adds files under nodes/: new opcos and teams, or new teams under an existing node. It cannot change tenant.yaml, standards, skills, policies or an existing node file; trying is an error. README.md, CODEOWNERS and dotfiles are ignored.
  • A version is the pair: <config sha>+<overlay short sha>. A push to either repo publishes it with the other repo's head; the catch-up reads both heads. Both commits get the tempa/config status. A config sha alone then matches several versions, so GET /v1/config/versions/{sha} wants the full key (the status links to it).
  • Errors in overlay files are reported as Devleaps/tempa-config-devleaps-demo:nodes/..., and overlay nodes in the web UI link to the overlay repo.
  • The token in TEMPA_GITHUB_TOKEN needs the same access on the overlay repo, and the overlay repo needs the same webhook.

Validate an overlay locally against a checkout of the config repo:

cd api
uv run python -m tempa_config validate ../path/to/tempa-config-devleaps --overlay ../path/to/tempa-config-devleaps-demo

Tenant settings

Some settings belong to a tenant but cannot live in its config repo: where that repo is, for a start. They are stored per tenant in the database (TEMPA-052, api/tempa_api/services/tenant_settings.py), with one owner per key:

OwnerWhereKeysChanged by
config repotenants.settingsprofile_ttl_seconds, retention_days, default_node (from tenant.yaml)every publish, which replaces them whole
platformtenants.platform_settingsconfig_source, git_known_hosts, config_mode, mcp_urls, bootstrap_admins, feature_flags, login_policy, min_cli_version, publish_approvals, config_pull_requeststhe tenant's platform_owner, PATCH /v1/tenant/settings (git_known_hosts also by the first sync over SSH)
platform (operator)tenants.platform_settingsentra_tenant_ids, limitsthe installation's operator only, in the operator console or PATCH /v1/operator/tenants/{id}/settings (TEMPA-057, operations.md); a tenant must not claim a directory or raise its own limits
plantenants.planthe plan id from the plan catalogue (null: the catalogue's default plan)the installation's operator, or the tenant's partner for its client (TEMPA-071, plans.md); never the tenant itself
KeyValue
config_sourceGitHub: {kind: github, repo: owner/name, branch: main, overlay_repo: null, overlay_branch: main, sync_interval_seconds: 300, credentials: null} (0 to 86400; 0: no catch-up; credentials: a git_token[:<label>] secret, null: git_token); any Git URL: {kind: git, url, branch, path, auth: {type, secret, username}, sync_interval_seconds} (Git sources). A stored source without kind is a GitHub one
git_known_hoststhe SSH host keys (known_hosts lines) pinned for the tenant's Git server; null resets them after a host key really changed
config_modegit or managed: the tenant's source of truth (Drafts, TEMPA-084). Unset: git with a config source, else managed. managed stops the Git sync of the tenant
mcp_urls{<endpoint name>: <https url>}: replaces the url the config repo gives that MCP endpoint, for example the second brain per environment
bootstrap_adminse-mail addresses that are platform_owner of the tenant before a config with Entra groups is live (an empty list: nobody); only for an interactive Entra sign-in through a directory of the tenant (the home tenant: TEMPA_ENTRA_TENANT_ID), never for Google, GitHub, device tokens or switched requests (auth.md)
feature_flags{<name>: true | false}; an unknown flag is off. plan.session_notice: one neutral line in developers' session summaries while the tenant is over a plan limit (plans.md)
login_policy{allowed_providers: [entra, google, github, email] or null (every login the installation offers), session_hours: 1-24 (12), device_days: 1-90 or null (the refresh token's own lifetime), require_mfa: false}: which logins enter the tenant, how long a web sign-in and a CLI device login last, and whether the login provider must prove MFA (TEMPA-067, auth.md). Unset: every login, TEMPA_USER_SESSION_HOURS, no MFA required. A change that would refuse the sign-in making it, or take Microsoft from the home tenant, is 409
publish_approvals{required: 0-5, by_kind: {<node kind>: 0-5}}: the approvals a draft needs before it is published (TEMPA-088, Approvals). Unset: none
config_pull_requests{enabled: false, branch_prefix: "tempa/"}: whether a Git tenant's drafts become pull requests on its config repo (TEMPA-089, Pull requests from drafts). Unset: off
min_cli_versionmajor.minor.patch: the oldest tempa CLI the tenant supports (TEMPA-114, cli-versions.md). Older CLIs keep working and are told to update. Unset, or lower than the installation's TEMPA_MIN_CLI_VERSION: the installation's minimum applies (source environment, for every tenant)
entra_tenant_idsEntra directory ids whose sign-ins belong to the tenant
limitsrate limit overrides, {<limit>_per_minute: <n>} for decide, decide_device, profile, profile_device, heartbeat, mcp, mcp_session, ingest, config_upload and config_webhook (0: no limit); unset limits keep the installation's TEMPA_LIMIT_* defaults (operations.md)

Precedence, per key: the value stored for the tenant wins. When it has none, the home tenant (TEMPA_TENANT_ID) falls back to today's environment variables: config_source to TEMPA_CONFIG_REPO, TEMPA_CONFIG_BRANCH, TEMPA_CONFIG_OVERLAY_REPO, TEMPA_CONFIG_OVERLAY_BRANCH and TEMPA_CONFIG_SYNC_INTERVAL_SECONDS; mcp_urls to {"second-brain": TEMPA_SECOND_BRAIN_MCP_URL}; bootstrap_admins to TEMPA_BOOTSTRAP_ADMINS. Any other tenant falls back to the default: no config source, no url overrides, no bootstrap admins, no flags. The installation's own Entra directory (TEMPA_ENTRA_TENANT_ID) always belongs to the home tenant, in addition to any stored directories. An installation that stores nothing behaves exactly as before, and moving the home tenant off its environment variables is one PATCH per key. Secrets are never settings: they live in the tenant's encrypted secret store (TEMPA-053, auth.md), /v1/tenant/secrets, with the same precedence: the tenant's secret, else for the home tenant TEMPA_GITHUB_TOKEN, TEMPA_GITHUB_WEBHOOK_SECRET or TEMPA_MCP_CREDENTIALS, else nothing: the config source's git_token, the webhooks' webhook_secret (TEMPA-079) and MCP credentials (mcp_credential:<endpoint>) all apply this way. Changes to config_source and git_known_hosts are also audited as config.source_change (kind, host or repo, branch, folder and the secret's name; never a value).

EndpointWhoWhat
GET /v1/tenant/settingsplatform_owner of the tenanteffective values (platform), where each comes from (sources: tenant, environment or default), the keys a PATCH may change (editable), the config repo's keys (config) and the plan with its entitlements and usage (plan, as GET /v1/tenant/plan, plans.md)
PATCH /v1/tenant/settingsplatform_owner of the tenantper key: absent leaves it, null clears it (back to the fallback), a value replaces it whole; validated (422, also a Git URL Tempa must not reach, and a github source for a tenant other than the home tenant without a git_token), a repo another tenant uses is 409

The caller's tenant is the only one these reach (no tenant in the path; the session is bound to it). Every change is audited as tenant.settings_change with the keys that were set and cleared, never their values; a login policy change also lists which of its keys changed and the policy as it now is (details.login_policy: no secret is in it), and a publishing change the new publish_approvals and config_pull_requests as they now are and the endpoint names in mcp_urls (not the urls) (TEMPA-272). The MCP server offers the same as get_tenant_settings and update_tenant_settings (mcp-server.md). In tempa-web its platform owners change the login policy (Settings, Sign-in and domains), the config source (Git sources, TEMPA-210) and, on Config, Source and publishing, the publishing settings (TEMPA-272): publish approvals (per draft and per kind of node), pull requests from Tempa (on or off and the branch prefix; Git tenants) and the MCP endpoint urls. Each shows where its value comes from (set for the organisation, set by the installation's operator, or the default); Edit opens its form, and Save asks for confirmation with what the change does before it is saved; tempa-api's refusal shows as its message. A key editable does not list stays read-only there. The catch-up picks up a new or changed config source within a minute; mcp_urls, bootstrap_admins and login_policy apply from the next request.

curl -X PATCH https://tempa.devleaps.nl/v1/tenant/settings -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"mcp_urls": {"second-brain": "https://atlas.devleaps.nl/mcp"}, "feature_flags": null}'