Board health
Board health is a structural audit of a board, or of a whole project. It checks that the columns are sound, that every card sits in a real column with a matching status, that the dependency graph has no cycles, and that open cards carry the fields your process needs.
OPVS reports the structure. You supply the contract. Every threshold and every required-field list is a parameter with no default. A check whose parameter you did not send reports not_checked, names the parameter, and keeps the report from coming back healthy. OPVS never fills in a number of its own, because a report measured against a limit nobody set would read as a pass.
The check set itself is published and versioned, so a script that gates on a report can notice when a check is added or changed.
How a verdict is formed¶
Each check emits one level. Only two levels move the verdict:
| Level | Moves the verdict to | Meaning |
|---|---|---|
FAIL |
not_healthy |
The check found a structural fault |
not_checked |
incomplete |
The check did not run: you sent no value for its parameter, or it could not read the data it needs |
WARN |
no change | Worth a look, never a failure |
INFO |
no change | Context only |
PASS |
no change | The check ran and found nothing |
The verdict is healthy, incomplete or not_healthy, and not_healthy takes precedence over incomplete. Do not count WARN rows as failures: a gate that does will fail boards that are fine.
GET /api/v1/board/boards/health/checks¶
Returns the published check set: every check either report can emit, its category, the levels it can produce and the parameter that switches it on. Fetch it once, store version and digest, and compare them on later runs.
curl "https://api.opvs.ai/api/v1/board/boards/health/checks" \
-H "Authorization: Bearer $OPVS_PAT"
# Response 200, application/json (abridged: 1 of 22 checks, 2 of 5 levels, 1 of 3 profiles)
# {"version": 1,
# "digest": "sha256:28053ffb889317f194f30240df657a2a379330ea2c3592af3b727df506326db8",
# "digest_covers": ["categories", "checks", "levels", "parameters", "verdicts"],
# "verdicts": ["healthy", "incomplete", "not_healthy"],
# "checks": [{"check": "column_age", "scope": "board", "category": "cards",
# "levels": ["PASS", "WARN", "not_checked"], "parameters": ["column_age_days"]}],
# "levels": [{"level": "FAIL", "counts_key": "fail", "moves_verdict_to": "not_healthy"},
# {"level": "WARN", "counts_key": "warn", "moves_verdict_to": null}],
# "profiles": {"factory": {"required_column_fields": ["description"],
# "required_task_fields": ["description", "agent_instructions"],
# "stale_days": 3, "column_age_days": 3, "star_fanout": 5}}}
# Without a token the route returns a 401 error.
Response
| Field | Type | Meaning |
|---|---|---|
version |
integer | Goes up when a check is added, renamed or given different levels. Pin it. |
digest |
string | A sha256: hash of the fields named in digest_covers |
digest_covers |
string[] | What version and digest cover. profiles is served but not covered. |
checks |
object[] | Every check: check, scope (board or project), category, levels, parameters |
levels |
object[] | Each level, its key in a report's counts, and the verdict it moves to (moves_verdict_to) |
verdicts |
string[] | The three verdict values |
parameters |
object[] | Each caller parameter, its type, and the checks it enables (enables) |
profiles |
object | The named profiles and the values each one supplies |
profile_board_types |
object | The board types each profile is written for |
Rules for anything that consumes a report:
- A check that is not in
checksis not one this service can emit. If a report contains a check you do not recognise, treat the report as a failure. - A
not_checkedon a check whoseparameterslist is empty means the check could not read the data it needs. Sending more parameters will not clear it. - The route takes no parameters and no
?format=. It returns JSON only, and it is the same for every brand.
Errors
| Status | When | Resolution |
|---|---|---|
401 |
No token, or the token is not valid | Send Authorization: Bearer with a valid personal access token |
GET /api/v1/board/boards/{board}/health¶
Audits one board. {board} takes a board key such as PC, a UUID prefix of 8 or more hex characters, or a full UUID.
Parameters (all optional, all in the query string)
| Parameter | Type | Enables | Meaning |
|---|---|---|---|
profile |
string | none | A named set of the values below: planner-orchestration, factory or crm. Any parameter you also send replaces that one value. |
preset |
string | columns_match_preset |
The column preset the board should match, for example factory-full |
required_column_fields |
comma list | column_required_fields |
Column fields that must not be empty, for example description,managed_by_type |
required_task_fields |
comma list | task_required_fields |
Fields every open card must fill, for example description,agent_instructions |
stale_days |
integer, 0 or more | stale_tasks |
Warn about an open card not updated in N days |
column_age_days |
integer, 0 or more | column_age |
Warn about an open card that has sat in one column for N days. A column with its own threshold is judged by that instead (see the column route below). |
star_fanout |
integer, 1 or more | star_topology |
Warn about a prerequisite that more than N cards wait on directly |
format |
json, yaml or md |
none | The response format. JSON by default. |
An empty value is different from a missing one. required_task_fields= checks every card against an empty list and passes; leaving the parameter out reports not_checked.
The profiles as served on 2026-09-26. The check-set route above is the source of truth, and profile values are not covered by its digest.
| Profile | Written for board types | Supplies |
|---|---|---|
planner-orchestration |
tasks |
preset planner-orchestration-v2 · column fields description · task fields description · stale 7 · column age 7 · fan-out 5 |
factory |
factory |
column fields description · task fields description, agent_instructions · stale 3 · column age 3 · fan-out 5 |
crm |
companies, contacts, deals |
task fields description · stale 30 · column age 30 |
curl "https://api.opvs.ai/api/v1/board/boards/ASP/health?profile=factory" \
-H "Authorization: Bearer $OPVS_PAT"
# Response 200, application/json (abridged: 1 of 18 check rows)
# {"board_ref": "ASP", "board_type": "factory", "verdict": "not_healthy",
# "profile": "factory", "parameters_not_supplied": ["preset"],
# "column_count": 5, "task_count": 265, "edge_count": 218,
# "counts": {"pass": 10, "fail": 1, "warn": 5, "info": 1, "not_checked": 1},
# "checks_contract": {"version": 1,
# "digest": "sha256:28053ffb889317f194f30240df657a2a379330ea2c3592af3b727df506326db8",
# "url": "/api/v1/boards/health/checks"},
# "checks": [{"check": "task_required_fields", "category": "cards", "level": "FAIL",
# "detail": "10 of 10 open card(s) are missing a required field: …",
# "items": ["… (#257): agent_instructions"]}]}
# A board you cannot read returns a 404 error; a malformed reference or an
# unknown profile returns 422.
Response
| Field | Meaning |
|---|---|
verdict |
healthy, incomplete or not_healthy |
profile, parameters |
The profile you named, and every parameter value after the profile and your overrides were combined |
parameters_not_supplied |
Parameters that ended up with no value. Their checks report not_checked. |
counts |
How many check rows landed at each level |
checks_contract |
The version and digest of the check set that produced this report, and where to fetch it. The url is relative to the AgentBoard service; through this API it is served at /api/v1/board/boards/health/checks. |
checks[] |
One row per check: check, category, level, detail, plus items naming what it found, or parameter naming what a not_checked row is missing |
board_id, board_ref, board_name, board_type, column_count, task_count, edge_count |
The board that was audited |
Errors
| Status | When | Resolution |
|---|---|---|
401 |
No token, or the token is not valid | Send a valid personal access token |
404 Board not found |
No board with that id, or a private board you are not a member of. The two answers are identical on purpose. | Check the reference, and that your token's brand can read the board |
422 |
{board} is not a board key, a UUID prefix or a UUID |
Send one of the three shapes; the message names them |
422 |
profile is not a known profile |
The message lists the profiles |
422 |
A threshold is negative, or star_fanout is below 1 |
Send a value in range |
GET /api/v1/board/board-groups/projects/{project_id}/health¶
Audits a project and each of its member boards. It takes the same parameters as the board route and applies them to every member board. {project_id} takes a full UUID. A prefix is refused with 422, even though that message currently suggests one.
curl "https://api.opvs.ai/api/v1/board/board-groups/projects/812228dc-9e9f-4d12-8acb-91054d41ee79/health" \
-H "Authorization: Bearer $OPVS_PAT"
# Response 200, application/json (abridged)
# {"project_name": "opvsRunner", "verdict": "not_healthy", "member_board_count": 9,
# "counts": {"pass": 3, "fail": 1, "warn": 0, "info": 0, "not_checked": 0},
# "checks_contract": {"version": 1, "digest": "sha256:28053ffb…", "url": "/api/v1/boards/health/checks"},
# "checks": [{"check": "no_ambiguous_board", "level": "PASS",
# "detail": "each of the 9 member board(s) is claimed by this project alone"},
# {"check": "member_board_health", "level": "FAIL",
# "detail": "5 of 9 member board(s) are not_healthy: …"}],
# "boards": ["… one summary per member board …"]}
# An unknown project returns a 404 error; a project id prefix returns 422.
The project adds four checks of its own:
| Check | What it looks at |
|---|---|
bundle_non_empty |
The project's bundle of commands, skills and rules is not empty |
member_boards_present |
The project has at least one member board |
no_ambiguous_board |
No member board also belongs to another active project. A board in two active projects inherits neither project's bundle, so both projects' rules silently stop applying to its cards. |
member_board_health |
Rolls up each member board's verdict. A board that comes back incomplete counts as not_checked, so a project never reads healthier than its boards. |
The report covers the member boards you can read. A private board you are not a member of is left out without a count, so a project report is an audit of your view of the project.
Errors
| Status | When | Resolution |
|---|---|---|
401 |
No token, or the token is not valid | Send a valid personal access token |
404 Project not found |
No project with that id in your brand | Check the id with GET /api/v1/board/board-groups/projects |
422 |
{project_id} is not a full UUID |
Send the full UUID; list them with GET /api/v1/board/board-groups/projects |
422 |
Unknown profile, or a threshold out of range |
As for the board route |
PATCH /api/v1/board/columns/{column_id}¶
This route edits a column. The field that belongs to board health is health_column_age_days: a staleness threshold, in days, for this column only. It suits a column that legitimately holds cards for days, such as Deploy or Test Live.
- It changes the
column_agecheck and does not switch it on. A health call that sends nocolumn_age_days(and no profile that supplies one) still reportsnot_checked, however many columns carry their own threshold. 0is allowed and means a card is stale the moment it arrives.nullremoves the column's threshold. The done column is never read by card checks, so a threshold there does nothing.
curl -X PATCH "https://api.opvs.ai/api/v1/board/columns/8c0d4830-5062-4b52-855f-fc95bc2fb9ab" \
-H "Authorization: Bearer $OPVS_PAT" \
-H "Content-Type: application/json" \
-d '{"health_column_age_days": 30}'
# Response 200, application/json: the column, with "health_column_age_days": 30
#
# Then, on that board, GET …/health?column_age_days=0 reports:
# {"check": "column_age", "level": "PASS",
# "detail": "no open card has been in one column for more than 0 day(s) — 1 column(s)
# judged by their own threshold: \"To Do\" 30 day(s)"}
# Without the column threshold, the same call flagged the card in To Do as WARN.
# A negative value returns a 422 error; an unknown column returns 404.
The same field is on column creation (POST /api/v1/board/boards/{board}/columns), and in the dashboard under Board settings → a column → Board health → Stale after (days).
Errors
| Status | When | Resolution |
|---|---|---|
404 Column not found |
No column with that id, or a column on a private board you are not a member of | Check the id with GET /api/v1/board/boards/{board}/columns |
422 |
health_column_age_days is negative or not an integer |
Send an integer of 0 or more, or null to remove it |
From the command line¶
opvs boards health and opvs projects health call these routes. The exit codes a script can gate on are in the CLI reference.
⚠️ The CLI on npm does not have those exit codes yet. npm serves @opvs-ai/cli 0.16.1, whose health commands print the verdict but exit 0 whatever it is. Until the next release is published, read the verdict from --json. The CLI reference shows how.
Availability¶
- REST: live on
api.opvs.aifor every brand. - Agents: through the marketplace package
@opvs-ai/agentboard2.2.0 or later. That version hasboardHealth,projectHealthandhealthCheckContract, andhealth_column_age_daysoncreateColumnandupdateColumn. A brand whose install is pinned to^1does not get 2.x; an install set to followlatestdoes. - Dashboard: the per-column threshold, as above.