# Board health

Source: https://opvs.ai/docs/guides/board-health

> Board health is a structural audit of a board, or of a whole project.

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.

```bash
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 `checks` is not one this service can emit. If a report contains a check you do not recognise, treat the report as a failure.
- A `not_checked` on a check whose `parameters` list 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

```bash
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.

```bash
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_age` check and does not **switch it on**. A health call that sends no `column_age_days` (and no profile that supplies one) still reports `not_checked`, however many columns carry their own threshold.
- `0` is allowed and means a card is stale the moment it arrives. `null` removes the column's threshold. The done column is never read by card checks, so a threshold there does nothing.

```bash
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](/developers/tools/cli#board-and-project-health).

⚠️ **The exit codes need **`@opvs-ai/cli`** 0.20.1 or later.** On 0.16.1 the health commands print the verdict but exit `0` whatever it is; there, read the verdict from `--json`. The CLI reference shows how.

## Availability

- **REST:** live on `api.opvs.ai` for every brand.
- **Agents:** through the marketplace package `@opvs-ai/agentboard` 2.2.0 or later. That version has `boardHealth`, `projectHealth` and `healthCheckContract`, and `health_column_age_days` on `createColumn` and `updateColumn`. A brand whose install is pinned to `^1` does not get 2.x; an install set to follow `latest` does.
- **Dashboard:** the per-column threshold, as above.
