Gateway Hot-Reload

Edit gateway.yaml live — restart only what changed

python
from praisonaiagents import Agent

agent = Agent(name="reload-agent", instructions="Hot-reload gateway configuration without downtime.")
agent.start("Reload the gateway configuration without restarting the service.")

The gateway diffs gateway.yaml against the running config and restarts only affected agents or channels. The WebSocket server keeps running.

yaml
# gateway.yaml
agents:
  assistant:
    instructions: "You are a helpful assistant."
bash
praisonai gateway run gateway.yaml
# Edit agents.assistant.instructions and save — only agents reload (~5s)

The user edits gateway.yaml on disk; the watcher diffs changes and reloads only affected agents or channels while the WebSocket server stays up.

Diagram

watchdog available

fallback

HOT_APPLIABLE_KEYS

agents.*

channels.name.*

routes/scheduler

✏️ Edit gateway.yaml

👁️ File watcher

⚡ Event-driven

🔄 mtime polling

⏱️ Debounce 1s

🔍 Diff paths

⚡ Hot-apply in place

🔄 Recreate agents

🔄 Restart one channel

🔄 Full channel restart

How It Works

Diagram
HealthGateway Hot-ReloadWatcherOperatorHealthGateway Hot-ReloadWatcherOperatorEdit gateway.yamlDiff + apply reloadRecord ReloadStatusapplied_config_revision + driftgateway status / health()reload outcome + drift

Quick Start

  1. Run the gateway
    bash
    praisonai gateway run gateway.yaml
  2. Edit live

    Change agent instructions or a single channel token in gateway.yaml and save. The watcher applies a selective reload within ~5 seconds (1s debounce). Credential fields can also be secret references ({source, id}) instead of plaintext.

  3. Trigger reload manually with SIGHUP

    Send SIGHUP to reload without editing a file — useful for orchestration scripts and systemd:

    bash
    # Find the gateway pid, then:
    kill -HUP $(pgrep -f "praisonai gateway")
    
    # Or under systemd:
    systemctl reload praisonai-gateway

Event-driven vs Polling

The watcher prefers event-driven file notifications via the optional watchdog package and falls back gracefully to mtime polling when watchdog is unavailable or an observer cannot start.

ModeHow it worksWhen used
Event-drivenOS file-system events via watchdogwatchdog>=3.0.0 installed
mtime pollingPeriodic stat check every 5swatchdog not installed or observer fails

Both modes apply the same 1s debounce to coalesce rapid saves.

Install watchdog for faster reload detection:

bash
pip install "praisonai[gateway]"
# or
pip install "praisonai[all]"

Operator-triggered Reload via SIGHUP

start_with_config installs a SIGHUP handler that runs the same reload_config path as a file-change reload — no shutdown, no dropped connections.

Diagram

📡 SIGHUP

🔧 SIGHUP handler

reload_config

🔍 Diff + apply

⏳ Drain in-flight turns

🔄 Restart affected channels

Reload via SIGHUP is best-effort — it is silently skipped on platforms without SIGHUP support (e.g. Windows).


Drain-coordinated Channel Restart

When a reload triggers a channel restart, the gateway drains in-flight turns before bouncing the channel — no mid-conversation cuts.

The drain window for reload-triggered restarts is controlled by a new YAML key:

yaml
gateway:
  reload_drain_timeout: 10   # seconds; falls back to drain_timeout if unset

Hot-Apply (no restart)

Some config paths are safe to apply in place — the gateway mutates the live subsystem without restarting agents or channels. When the diff only touches these paths, no drain runs, no client sees a blip.

An operator raising gateway.reload_drain_timeout at 3am does not disconnect a live conversation:

python
from praisonaiagents import Agent

# The agent whose transport is served by the gateway
agent = Agent(
    name="ops-bot",
    instructions="Answer ops questions. Stay connected — I'll be tuning drain timings live.",
)

agent.start("Under the hood: operators can now raise gateway.reload_drain_timeout "
            "from 10s to 30s in gateway.yaml without dropping this connection.")

Editing the key in gateway.yaml triggers the hot-apply on the next SIGHUP / file save — no restart, no client disconnect, no drain:

yaml
# gateway.yaml — before
gateway:
  reload_drain_timeout: 10

# gateway.yaml — after (hot-applied on the next SIGHUP / file save)
gateway:
  reload_drain_timeout: 30

The health check confirms the change landed without a restart — the only changed path is hot-applied:

python
payload = gateway.health()
print(payload["reload"]["changed_paths"])
# ('gateway.reload_drain_timeout',)  ← the only changed path, hot-applied

The full registry of hot-appliable paths:

PathLive effectWhat restarts
gateway.logging.levelRoot/gateway logger level applied immediatelyNothing
gateway.drain_timeoutNew drain window used on the next graceful drainNothing
gateway.reload_drain_timeoutNew reload-drain window used on the next reload-triggered channel restartNothing

Anything not in this registry falls through to the existing restart-based plans — restart is always the safe default for unknown or structural changes. Invalid values (e.g. a non-numeric timeout) are ignored so one bad key never aborts an otherwise-good reload; the applied paths appear in the reload summary line as hot[...] and in health().reload.changed_paths.

Diagram

HOT

AGENTS

CHANNEL

FULL

✏️ Config change

classify_reload(path)?

⚡ Hot-apply in place

🔄 Recreate agents only

🔄 Restart one channel

🔄 Full channel restart

✅ Nothing restarts


Restart Scope

Each Effect is prefixed with the canonical ReloadScope value that classify_reload(path) returns for that section.

Changed sectionEffect
gateway.logging.level, gateway.drain_timeout, gateway.reload_drain_timeoutHOT — hot-apply in place, no channel restart, no agent recreate (see Hot-Apply)
agents.*AGENTS — recreate agents only; channels keep running
provider., guardrails.AGENTS — recreate agents
channels.<name>.*CHANNEL — restart only that channel
channels.<name>.unknown_user_policyCHANNEL — re-read policy and rebuild BotConfig — re-fires the startup warning
channels.<name>.owner_user_idCHANNEL — re-read owner and rebuild BotConfig
scheduler., routes., routing.*FULL — full channel restart; the candidate is validated first and the previous config is restored if channels fail to start
lifecycle.*Rebuild scale-to-zero / drain-marker policies; cancel or relaunch only the loops whose enablement changed (_reconcile_lifecycle) — no channel or process restart
Entire channels section (bare)FULL — full channel restart; the candidate is validated first and the previous config is restored if channels fail to start
Invalid YAML on saveKeep last-known-good config

Full restart stops and starts all channels but does not restart the WebSocket server — connected clients stay connected.


Full-restart safety: validated cutover & rollback

A structural edit to gateway.yaml is now an atomic, validated cutover — a schema-valid but runtime-invalid edit is a rejected reload with the old gateway still up, not an outage.

python
from praisonaiagents import Agent

agent = Agent(
    name="ops-bot",
    instructions=(
        "Answer ops questions about safe gateway edits. Stay connected — "
        "even a bad structural edit to gateway.yaml no longer takes me down."
    ),
)

agent.start(
    "Under the hood: on a full-restart edit (e.g. gateway.port or channels "
    "added/removed), the gateway validates the new config first. If it fails, "
    "the previous config keeps serving. If channels fail to start after the "
    "cutover, the previous config is automatically restored."
)

The candidate config is built and proven before any live channel is drained; only a passing candidate is swapped in.

Diagram

ok=false

ok=true

success

raise or all-degraded

✏️ Save gateway.yaml

🔍 Diff detects FULL

🧪 Validate candidate

❌ Reject reload

✅ Previous config keeps serving

⏳ Drain live channels

⚡ Activate candidate agents

🚀 start_channels

✅ New config live

🛟 Restore previous config

How it works

The candidate agents stay off the live registry until after the drain, so in-flight turns keep resolving the previous agents right up to the cutover.

Diagram
New channelsLive channelsGatewayOperatorNew channelsLive channelsGatewayOperatoralt[Start ok and at least one channel live][Start raises OR every channeldegraded]alt[Candidate build fails][Candidate ok]Save gateway.yaml (structural)validate_candidate(new_cfg)reload=failed, live untouchedstop_channels (drain)activate candidate agentsstart_channelsreload=okrestore previous configrebuild previous agents + startreload=failed, previous config restored

Failure modes and outcomes

Every outcome below is visible through the existing health().reload payload — grep for your scenario.

ScenarioLive channels drained?Live agents mutated?Final statehealth().reload.last_resulthealth().reload.error
Agent build fails (unreachable model, adapter throws on load, bad wiring)❌ No❌ NoPrevious config still serving"failed"agent build failed: <exception>
Agent build succeeds, all channels start OK✅ Yes (proven candidate first)✅ Yes (atomically, after drain)New config live"ok"None
Agent build succeeds, start_channels raises✅ YesRestored to previousPrevious config brought back up"failed"<start_channels exception>
Agent build succeeds, every new channel is degraded (no live bot)✅ YesRestored to previousPrevious config brought back up"failed"all channels failed to start (<name>: <reason>; …)
Selective (non-FULL) reloadUnchangedUnchangedUnchangedUnchangedUnchanged

The selective-reload paths (AGENTS, CHANNEL, HOT) are untouched — the safety guarantees add to FULL only.

Observability of a rejected or rolled-back reload

A rejected candidate and a rolled-back cutover both surface as last_result="failed" with a specific error.

python
# After a rejected candidate:
print(gateway.health()["reload"])
# {'watcher': 'active', 'last_result': 'failed', 'last_at': 1731801234.5,
#  'changed_paths': ['gateway.port', 'channels.discord.token'],
#  'error': 'agent build failed: unreachable model'}

# After a rolled-back cutover:
print(gateway.health()["reload"])
# {'watcher': 'active', 'last_result': 'failed', 'last_at': 1731801240.1,
#  'changed_paths': ['gateway.port', 'channels.discord.token'],
#  'error': 'all channels failed to start (discord: credential unavailable)'}

See ReloadStatus fields for the full schema.

Programmatic pre-flight

Any orchestrator can await validation before writing a file — validate_candidate does not touch the live runtime, so it is safe to call from a canary or deploy script before saving gateway.yaml.

python
from praisonaiagents.gateway import ReloadValidationProtocol, CandidateReport

assert isinstance(gateway, ReloadValidationProtocol)

report: CandidateReport = await gateway.validate_candidate(candidate_cfg)
if not report.ok:
    raise RuntimeError(f"Candidate rejected: {'; '.join(report.failures)}")
# Only reached when the candidate is safe to apply.

CandidateReport carries the three fields the wrapper returns:

FieldTypeDefaultMeaning
okbool(required)Whether the candidate built and passed pre-flight — only then may the live runtime be drained and the candidate swapped in.
failureslist[str][]Human-readable reasons the candidate was rejected (empty when ok); joined into health().reload.error.
candidateOptional[Any]NoneOpaque handle to the already-built candidate runtime activated on success; None when the runtime validates in place.

ReloadValidationProtocol is runtime_checkable, so isinstance(gateway, ReloadValidationProtocol) holds for every gateway build — the “no cutover to an unvalidated candidate” guarantee is canonical, not per-entry-point.


Observability

The gateway records every reload outcome so operators can confirm the last edit took effect without scraping logs.

Reloads still log a concise summary line on completion:

reload applied: agents; restart[telegram]

The format is reload applied: <changed-sections>; restart[<channels>]. Grep for reload applied to trace all reloads in your log stream.

Reload status in health()

health() surfaces the reload outcome and config revisions when the gateway runs from a config file:

python
from praisonaiagents import Agent

# Operator polls health from a running gateway
payload = gateway.health()
print(payload["reload"])
# {'watcher': 'active', 'last_result': 'ok', 'last_at': 1731801234.5,
#  'changed_paths': ['agents.assistant.instructions'], 'error': None}
print(payload["applied_config_revision"], payload["on_disk_config_revision"])
print(payload["config_drift"])   # True if a restart is still owed

These four keys are additive — they only appear when the gateway runs from a config file, so existing health() consumers see no change:

KeyTypeDescription
reloaddictFull reload status from ReloadStatus.to_dict() — outcome + watcher liveness.
applied_config_revisionstr (12-char)Revision id of the config the gateway is actually running.
on_disk_config_revisionstr (12-char)Revision id of the current gateway.yaml on disk.
config_driftboolTrue when applied_config_revision != on_disk_config_revision.

The pressure block is a sibling additive health() key that follows this exact to_dict() pattern — present only when the back-pressure machinery is wired.

ReloadStatus fields

ReloadStatus is a frozen dataclass describing the most recent reload attempt:

FieldTypeDefaultMeaning
watcher"active" \"disabled""disabled""active" while the watcher runs; "disabled" once it has genuinely given up. Detects silent degradation.
last_result"ok" \"failed" \"no_changes" \"never""never"Outcome of the most recent reload attempt.
last_atfloat \NoneNoneUnix timestamp of the last reload attempt.
changed_pathstuple[str, ...]()Config paths that changed on the last successful reload.
errorstr \NoneNoneHuman-readable reason on "failed"; None otherwise.

Compare configs offline

compute_config_revision returns the same 12-character revision id used by health(), so you can check an on-disk config before deploying it:

python
from praisonaiagents.gateway import compute_config_revision
import yaml

with open("gateway.yaml") as f:
    revision = compute_config_revision(yaml.safe_load(f))
print(revision)   # e.g. 'a1b2c3d4e5f6'

Identical logical configs produce identical revisions regardless of key order or whitespace; an empty or None config returns the sentinel "000000000000".

Inspect the hot-apply registry

HOT_APPLIABLE_KEYS and is_hot_appliable are public exports from praisonaiagents.gateway, so you can query which paths are zero-restart before an edit:

python
from praisonaiagents.gateway import HOT_APPLIABLE_KEYS, is_hot_appliable

# The whole registry (frozenset[str], safe to iterate)
print(sorted(HOT_APPLIABLE_KEYS))
# ['gateway.drain_timeout', 'gateway.logging.level', 'gateway.reload_drain_timeout']

# Ask about a specific path — matches exact paths AND leaves under a registered key
is_hot_appliable("gateway.logging.level")          # True
is_hot_appliable("gateway.logging.level.extra")    # True  (leaf under a registered key)
is_hot_appliable("agents.assistant.instructions")  # False → falls through to restart plan

The SupportsHotReload protocol is runtime_checkable — a gateway implementation opts in by defining an apply_hot_reload(paths, new_config) -> None method. Third-party gateway subclasses that want the wrapper’s hot-apply behaviour implement this method; core owns the classification so every runtime reloads identically.

Programmatic reload-scope classification

ReloadScope and classify_reload are public exports from praisonaiagents.gateway that tell you exactly what an edit to gateway.yaml will do — before you save it.

python
from praisonaiagents import Agent
from praisonaiagents.gateway import classify_reload, ReloadScope

agent = Agent(
    name="ops-bot",
    instructions="Answer ops questions about safe gateway reloads.",
)

# Before saving gateway.yaml, ask what the edit will actually do:
scope = classify_reload("gateway.logging.level")
if scope == ReloadScope.HOT:
    agent.start("The change is hot-apply — no channel restart, no client disconnect.")
elif scope == ReloadScope.CHANNEL:
    agent.start("Only the affected channel will restart. Other channels stay connected.")
elif scope == ReloadScope.AGENTS:
    agent.start("Agents recreate in place. Channels keep running.")
else:  # ReloadScope.FULL
    agent.start("Full channel restart — expect a brief drain across all channels.")

The four canonical scopes are plain strings, so wrapper and runtime code can compare against them without importing the class:

python
from praisonaiagents.gateway import ReloadScope, classify_reload

ReloadScope.HOT       # "hot"     — apply in place, no restart
ReloadScope.CHANNEL   # "channel" — restart only channels.<name>
ReloadScope.AGENTS    # "agents"  — recreate agents only, channels keep running
ReloadScope.FULL      # "full"    — full restart (fail-safe default)

# Ask what a specific path would do before saving:
classify_reload("gateway.logging.level")            # 'hot'
classify_reload("channels.telegram.token")          # 'channel'
classify_reload("agents.assistant.instructions")    # 'agents'
classify_reload("guardrails.jailbreak.enabled")     # 'agents'
classify_reload("routes.discord")                   # 'full' — structural fallback
classify_reload("channels")                         # 'full' — bare section, fail-safe

classify_reload is canonical and side-effect-free — every runtime (core, wrapper, third-party gateway subclasses) uses the same classifier, so a pre-flight check reflects exactly what happens at reload time. Anything not explicitly recognised falls through to ReloadScope.FULL, keeping full restart the fail-safe default.

Reload observability flow

Diagram

changed

unchanged

success

error

✏️ Edit gateway.yaml

👁️ Watcher

🔍 Diff

🔄 Apply reload

⏸️ no_changes

✅ last_result=ok

❌ last_result=failed

💾 applied_config_revision

📊 health.reload / drift

Inspect reload from the CLI

praisonai gateway status prints the reload result, watcher state, and config drift alongside the existing status:

bash
praisonai gateway status
Reload: OK  2m ago  changed=agents.assistant.instructions
Watcher: active
Config: a1b2c3d4e5f6 (up to date)

When the running config no longer matches disk, the drift is shown with both revisions:

Reload: FAILED  30s ago  invalid YAML at agents.assistant
Watcher: active
Config: on-disk 9f8e7d6c5b4a  !=  applied a1b2c3d4e5f6   (change not in effect)

When config_drift is true

A drift means the config on disk has not taken effect — walk this path to recover:

Diagram

no

yes

config_drift=true

📡 Retry SIGHUP

Still drifted?

✅ In effect

🔍 Check reload.error

✏️ Fix gateway.yaml

🔄 Full restart


Tuning

SettingDefaultDescription
Poll interval5.0sHow often the mtime watcher checks the file
Debounce1.0sWait after last write before applying
gateway.reload_drain_timeoutfalls back to gateway.drain_timeoutBounded drain window before a reload-triggered channel restart. Hot-appliable — edits take effect on the next reload-triggered drain without restarting anything (see Hot-Apply).


Best Practices

Prefer agent-only edits for prompt tweaks

Changing agents.* avoids dropping live Telegram/Discord sessions.

Scope channel edits narrowly

Edit one channel block to restart only that platform.

Validate YAML before saving

Invalid saves are ignored — the previous config keeps running.

Use SIGHUP in systemd for zero-downtime config pushes

Add ExecReload=kill -HUP $MAINPID to your systemd unit so systemctl reload triggers a drain-coordinated reload without stopping the process.


Related