Health checks
Knowing when your agent is unwell
Every Parlant server answers GET /healthz with one word: healthy, degraded, or unhealthy. Behind that word sit four health checks: recent turns, model calls, the server's own responsiveness, and the Dashboard connection. Each check has cutoffs that decide when a number counts as a problem. The defaults fit most deployments, and you can tune them.
- What the endpoint returns, and who sees the detail behind the status
- The four checks and how they combine into one status
- How the engine check explains a bad verdict
- The default thresholds for each check and route
- How to change the thresholds for your deployment
What the endpoint returns
GET /healthz always answers HTTP 200. Nothing a check reports changes the HTTP code, because a load balancer that drops the server on a non-200 answer would hide every other check at the same time. The status lives in the body:
{"status": "healthy"}
That is the whole response for an unauthenticated caller, so uptime probes and load balancers work with no credentials. A probe that checks only the HTTP code learns that the process answered. Read the JSON status to learn whether the agent can serve conversations.
A caller allowed to read health reports, which by default means an admin, gets the full body. Authentication covers how that caller signs its request: a checks object with one entry per check, and a diagnostic_reports list with links to the most recent turn reports.
{
"status": "degraded",
"checks": {
"engine": {"status": "degraded", "...": "..."},
"nlp": {"status": "healthy", "...": "..."},
"event_loop": {"status": "healthy", "latency_ms": 12.4, "lag_share": 0.0},
"tunnel": {"status": "healthy", "state": "off"}
},
"diagnostic_reports": ["..."]
}
PARLANT_HEALTHZ_REPORT_LINKS sets how many report links appear. The default is three, and 0 removes the list. Debugging issues explains what a report holds, and Authorization covers who counts as an admin.
The four checks
Each check is healthy, degraded, or unhealthy. The top-level status is the worst of the four.
engine looks at recent turns: the share that finished without error, and the p95 of how long a turn took. A p95 is the time that 95% of turns finish within, so one slow outlier doesn't move it.
nlp looks at the model requests the engine makes, grouped by model route: the named kind of work in the models mapping, such as matching.rank or review.message. For each route it reports the success rate, p50 and p95 latency, and a count of recent errors by type, with a breakdown per agent inside the route.
The check's status is the worst route, and a route's status is its worst agent.
event_loop measures how late the server's event loop runs its scheduled work over a rolling window of the last five minutes. Lag means something is blocking the loop. The usual cause is a tool that does slow work synchronously instead of awaiting it.
The status follows how much of the window the loop spent lagging, not the single worst lag. Each late measurement counts for as long as it was late, so one long stall weighs as much as many short ones: a 30 second freeze turns the check unhealthy, while one 600 ms stall leaves it healthy. The entry reports latency_ms, the peak lag in the window, so a brief stall still shows there. It also reports lag_share, the share of the window, from 0 to 1, spent in lags at the degraded cutoff or above.
tunnel says whether the server can reach the Parlant Dashboard. A self-hosted or local server has no tunnel and reports state: "off", which counts as healthy.
A managed project reports state: "on" with the connection's status. connected is healthy, and reconnecting, disconnected, or stopped are unhealthy. A tunnel that is still starting or connecting counts as degraded, not failed.
Why the engine says what it says
The engine's entry carries a reasons list that explains a degraded or unhealthy verdict. Each entry names the code (low_success_rate or high_turn_latency), the metric it looked at (success_rate or p95_latency_ms), its severity, the observed_value, the threshold it crossed, and the comparison used (lt or gte). An alert can quote the exact number that tripped.
The list is empty when the engine is healthy or has no data yet.
{
"status": "degraded",
"reasons": [
{
"code": "high_turn_latency",
"metric": "p95_latency_ms",
"severity": "degraded",
"observed_value": 34210.5,
"threshold": 30000.0,
"comparison": "gte"
}
]
}
The default thresholds
Every cutoff lives in one p.HealthThresholds value. These are the defaults:
| Check | Measure | Degraded | Unhealthy |
|---|---|---|---|
engine | success rate below | 0.95 | 0.80 |
engine | turn p95 latency at or above | 30 s | 60 s |
nlp | success rate below | 0.95 | 0.80 |
nlp | request p50 latency at or above | 20 s | 30 s |
nlp | request p95 latency at or above | 45 s | 60 s |
event_loop | lags at or above this add up to 1% of the window (3 s) | 200 ms | 500 ms |
The nlp row applies to every model route. Four routes on the response path are held to tighter limits, because a slow rule match or review delays the reply directly:
| Route | p50 degraded / unhealthy | p95 degraded / unhealthy |
|---|---|---|
matching.rank | 8 s / 12 s | 15 s / 20 s |
matching.distill | 8 s / 12 s | 15 s / 20 s |
review.message | 10 s / 15 s | 20 s / 25 s |
review.tools | 10 s / 15 s | 20 s / 25 s |
Background routes such as compaction and indexing use the general nlp row.
Tuning the thresholds
Say your agent generates messages on a slow reasoning model, so a 60 second p95 is normal for it, but you want the event loop check to react sooner. Ask your coding agent to bind your own p.HealthThresholds in configure_container, the startup hook that Overriding engine behavior explains. Anything you leave out keeps its default:
import parlant.sdk as p
async def configure_container(container: p.Container) -> p.Container:
container[p.HealthThresholds] = p.HealthThresholds(
nlp=p.NLPHealthThresholds(
degraded_p95_ms=90_000.0,
unhealthy_p95_ms=120_000.0,
routes={
# Keep the default matching cutoffs; only review gets more room.
**p.default_nlp_route_thresholds(),
"review.message": p.RouteThresholds(
degraded_p95_ms=40_000.0,
unhealthy_p95_ms=60_000.0,
),
},
),
event_loop=p.EventLoopHealthThresholds(
degraded_lag_ms=100.0,
unhealthy_lag_ms=250.0,
),
)
return container
async def main() -> None:
async with p.Server(configure_container=configure_container) as server:
...
p.HealthThresholds has three parts: engine (p.EngineHealthThresholds), nlp (p.NLPHealthThresholds), and event_loop (p.EventLoopHealthThresholds). The field names match the tables above, with latencies in milliseconds and success rates as a fraction.
A few rules to keep in mind:
- Passing
routesreplaces the whole per-route table. Spreadp.default_nlp_route_thresholds()first, as above, to keep the defaults for the routes you don't mention. - A field left
Nonein ap.RouteThresholdsoverride falls back to the generalnlpvalue for that route. - Setting a p50 cutoff to
Noneturns that comparison off. The p95 and success rate cutoffs always apply. p.EventLoopHealthThresholds(lag_share=...)sets how much of the window the lags must add up to, from 0 to 1. The default is0.01. Setting it to0.0makes any single lag at a cutoff count.- The thresholds only change how observations are classified. A looser cutoff does not make a slow turn faster or repair a failed model request, and it does not change what is measured or how long observations are kept. Use the logs and traces to find the cause.
Self-hosting covers logs and OpenTelemetry export next to the health probe, and Spend management covers the cost side of the same model calls.