> ## Documentation Index
> Fetch the complete documentation index at: https://docs.yourhq.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Secrets management

> Store API keys, channel tokens, and OAuth credentials securely. Encrypted at rest, synced to gateways, and injected into agent tools as environment variables.

Agents need credentials to interact with external services — Telegram bot tokens, Notion API keys, Salesforce credentials, webhook secrets. HQ's secrets system stores these encrypted in the database and delivers them to gateways as environment variables, without the values ever appearing in logs, API responses, or LLM context.

***

## How it works

```
┌─────────────────────────────────────────────────────────────────┐
│  User types value in UI (Settings → Secrets or Agent → Secrets) │
└─────────────────────────┬───────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│  Server action encrypts with AES-256-GCM                        │
│  Stores ciphertext in `secrets` table (value never in plaintext)│
└─────────────────────────┬───────────────────────────────────────┘
                          │  Supabase Realtime
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│  secrets_sync daemon (on gateway)                                │
│  • Decrypts in memory                                           │
│  • Writes ~/.openclaw/secrets/gateway.env (gateway-scoped)       │
│  • Writes ~/.openclaw/secrets/agents/<slug>.env (per-agent)      │
│  • Marks secret as sync_status = 'active'                       │
└─────────────────────────┬───────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│  Agent tool reads os.environ.get("SECRET_KEY")                   │
└─────────────────────────────────────────────────────────────────┘
```

The value is **never** in: API responses, command payloads, audit logs, LLM context, git history, or stdout/stderr.

***

## Categories

| Category      | Created by                      | Example                                                      |
| ------------- | ------------------------------- | ------------------------------------------------------------ |
| `user`        | Operator, via the Secrets UI    | `SALESFORCE_API_KEY`, `STRIPE_WEBHOOK_SECRET`                |
| `channel`     | Automatically at agent creation | `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `SLACK_APP_TOKEN` |
| `integration` | Automatically by OAuth flows    | `NOTION_SOURCE_A1B2C3D4` (Notion access token)               |

All three categories use the same encryption and sync mechanisms. The distinction is for display and management — channel and integration secrets are auto-created and show their origin in the UI.

***

## Scoping

Secrets are scoped at two levels:

* **Gateway-scoped** (`agent_id = NULL`): available to every agent on the gateway. Shown under "Available to all agents" in the UI.
* **Agent-scoped** (`agent_id` set): available only to that specific agent. Shown under "Only for this agent."

When both exist with the same key, the **agent-scoped value wins**. The per-agent `.env` file is generated by merging gateway defaults with agent overrides.

***

## Managing secrets in the UI

### Settings → Secrets

The workspace-level secrets page. Here you manage credentials that all agents on a gateway can use.

1. Click **Add secret**.
2. Enter a human-readable name (e.g. "Notion API Key") — the variable name is auto-derived (`NOTION_API_KEY`).
3. Paste the value. It's encrypted immediately on save.
4. Optionally add a note for your future self.

The list shows sync status for each secret: **Active** (on the gateway), **Updating...** (pending sync), or **Sync error**.

### Agent → Secrets tab

Each agent's detail page has a Secrets tab showing:

* **Agent-specific secrets** — credentials only this agent uses.
* **Shared from Settings** — gateway-level secrets this agent inherits, with the option to override with an agent-specific value.

From here you can add agent-scoped secrets, edit values, or remove overrides (the agent falls back to the shared value).

***

## Using secrets in agent tools

Agent skills and scripts read secrets from the environment. The `hq_base.py` module (loaded by every HQ skill) automatically sources the agent's `.env` file at import time:

```python theme={null}
import os

api_key = os.environ.get("SALESFORCE_API_KEY")
if not api_key:
    print("Error: SALESFORCE_API_KEY not configured")
```

No special imports or decryption code is needed in agent tools — secrets arrive as standard environment variables.

***

## Encryption details

### Algorithm

AES-256-GCM (authenticated encryption with associated data). Ciphertext format:

```
enc:v1:<iv-base64url>.<tag-base64url>.<ciphertext-base64url>
```

* **IV**: 12 bytes, randomly generated per encryption
* **Tag**: 16 bytes (GCM authentication tag)
* **Ciphertext**: variable length

### Key derivation

**Self-hosted:** The 32-byte encryption key is derived from `SUPABASE_SERVICE_ROLE_KEY` using HKDF-SHA256:

* Salt: `yourhq-secrets-v1` (fixed)
* Info: `aes-256-gcm` (fixed)
* Output: 32 bytes

This means anyone with your service role key can derive the encryption key. The secrets system protects against database-layer exposure (dumps, SQL injection, unauthorized Supabase dashboard access) but not against service-role-key compromise.

**Hosted:** Uses `HOSTED_SECRETS_KEY` directly (32 bytes, base64url-encoded in the worker environment). Independent from the service role key.

### Cross-language interoperability

The same encryption format is used by:

* **Node.js** (UI server actions): `apps/ui/src/lib/secrets/crypto.ts`
* **Python** (gateway decryption): `gateway/daemons/secrets_sync.py`

Both implementations produce and consume the same `enc:v1:` format, ensuring secrets encrypted by the UI can be decrypted by the gateway.

***

## Gateway sync protocol

The `secrets_sync` daemon runs as a background thread inside the runner container:

1. **On boot**: performs one synchronous sync before the runner processes any commands.
2. **On Realtime event**: any INSERT, UPDATE, or DELETE on the `secrets` table (filtered by `gateway_id`) triggers an immediate re-sync.
3. **Every 5 minutes**: safety re-sync catches anything Realtime may have missed.

Each sync:

1. Fetches all secrets for this gateway (encrypted values included — uses service role).
2. Resolves agent UUIDs to slugs.
3. Decrypts each value in memory.
4. Writes `~/.openclaw/secrets/gateway.env` with gateway-scoped keys.
5. Writes `~/.openclaw/secrets/agents/<slug>.env` for each agent (merged: gateway defaults + agent overrides).
6. Removes stale `.env` files for agents that no longer have secrets.
7. PATCHes `sync_status = 'active'` and `last_synced_at` on successfully synced rows.

All files are written with `chmod 0600`; directories with `chmod 0700`.

A `threading.Lock` prevents concurrent syncs (Realtime event + periodic timer) from corrupting files.

***

## Channel tokens

When you create an agent with a messaging channel, the wizard writes the channel token to the `secrets` table:

| Channel  | Secret key                           | Category  |
| -------- | ------------------------------------ | --------- |
| Telegram | `TELEGRAM_BOT_TOKEN`                 | `channel` |
| Discord  | `DISCORD_BOT_TOKEN`                  | `channel` |
| Slack    | `SLACK_APP_TOKEN`, `SLACK_BOT_TOKEN` | `channel` |

The provision command payload **does not** carry tokens. Instead, `add-agent.sh` reads them from the per-agent `.env` file after `secrets_sync` has written it.

You can rotate a channel token from the agent's Secrets tab — update the value, and the gateway picks up the new token on the next sync cycle (within seconds).

***

## Source connection tokens

OAuth integrations (e.g. Notion) store their access tokens as encrypted secrets:

* Key pattern: `{PROVIDER}_SOURCE_{connection_id[:8].upper()}` (e.g. `NOTION_SOURCE_A1B2C3D4`)
* Category: `integration`
* Created automatically by the OAuth callback

The `source_sync` daemon reads the token from the gateway secrets `.env` file on each sync cycle. If the secret isn't available (e.g. no gateway configured), it falls back to `source_connections.credentials.api_key`.

***

## Troubleshooting

### Secret shows "Updating..." but never becomes Active

The gateway's `secrets_sync` daemon hasn't picked up the change. Check:

1. Is the runner container running? (`docker compose logs runner`)
2. Does the runner have Supabase Realtime connectivity? Look for WebSocket errors in logs.
3. Is the gateway registered with the correct `gateway_id`?

The 5-minute safety re-sync will eventually catch it, but if Realtime is broken, investigate the connection.

### Agent doesn't see the secret

1. Verify the secret's scope — is it gateway-wide or scoped to a different agent?
2. Check the `.env` file exists: `docker compose exec gateway cat ~/.openclaw/secrets/agents/<slug>.env`
3. If using a custom skill, ensure it imports `hq_base` (which sources the `.env` file) or reads from `os.environ` after the file has been sourced.

### Decryption errors in runner logs

```
[secrets_sync] Decryption failed: ...
```

This usually means the encryption key doesn't match. Causes:

* Service role key was rotated without re-encrypting secrets.
* `HOSTED_SECRETS_KEY` was changed in the worker but old ciphertexts remain in the database.
* Database was restored from a backup taken with a different key.

Fix: re-save each affected secret from the UI (the server action will re-encrypt with the current key).

### Rotating the service role key

If you rotate `SUPABASE_SERVICE_ROLE_KEY`:

1. All existing secrets become undecryptable (they were encrypted with the old derived key).
2. The UI will still work (it encrypts with the new key on write).
3. Old secrets will show decryption errors on the gateway.

To fix: open each secret in the UI, enter a new value, and save. There is no bulk re-encryption tool yet.
