---
title: "Focus on a site or a camera, or hold a site"
method: POST
path: "/focus"
tags: ["focus"]
---

# Focus on a site or a camera, or hold a site

`POST /focus`

## Request

- Base URL: `https://api.verdun.ai` (the document declares no server; this is the origin it was published from)
- URL: `https://api.verdun.ai/focus`
- Auth: HTTP bearer

From now on every NEW alarm in scope that enters the operator queue while nobody holds it is reserved for the caller until they release (or the Keep / Release check runs out). It never takes alarms already waiting; anyone can Take over a reserved alarm. Body `{site_id, scope: {kind: site} | {kind: camera, camera_id}}`. 201 for a new focus, 200 (the existing record) when the caller already has this exact one. 409 `focus_exists` (the caller has a focus with another scope; `focus` is it), 409 `focus_conflict` (another operator's focus overlaps; `focus` names them), 409 `focus_draining` (a previous generation is still being cleaned up: retry), 409 CONFLICT (retired site), 403 (not central staff, an owner, no `review_label_alarms`, or the site is out of scope), 404 (unknown site, or a camera not at the site). 404 while `FOCUS_ENABLED` is off. With `origin: "story"` (site scope only) this starts a SITE HOLD: the caller owns the site while its story is open, every alarm there (waiting ones included, with no age limit) is reserved for them, the page keeps it with `POST /focus/{id}/heartbeat`. 201 / 200 as above; 409 `focus_conflict` names whoever already holds or focuses the site (an operator may hold several sites); 404 while `SITE_HOLD_ENABLED` is off.

## Request body

- StartFocusRequest: Body of `POST /focus`.
  - `site_id` string, uuid, required: The site to focus on.
  - `scope` union, required: `{"kind": "site"}` for the whole site or `{"kind": "camera", "camera_id": ...}` for one camera. The scope is frozen at start.
    - FocusScopeSite
      - `kind` 'site', required: Every camera at the site.
    - FocusScopeCamera
      - `kind` 'camera', required: One camera. Its graph neighbours are NOT included.
      - `camera_id` string, uuid, required: The camera; it must belong to `site_id`.
  - `origin` 'focus' | 'story': `focus` (the default, today's Focus) or `story`: a site hold, which owns the whole site while its story is open. A site hold takes `{"kind": "site"}` only.

## Response `200`

The caller already has this exact focus or hold (idempotent).

- union
  - FocusRecord: One Focus generation (`id` IS the generation: a restart is a new id).
    - `id` string, required: The focus generation id; a restart is a new id.
    - `site_id` string, required: The site the focus is on.
    - `scope` FocusScopeOut, required
      - `kind` 'site' | 'camera', required: Whether the focus covers the whole site or one camera.
      - `camera_id` string, nullable: Set iff `kind` is `camera`.
    - `operator_id` string, required: The focuser.
    - `operator_name` string, nullable: Their display name; null if the operator no longer exists.
    - `started_at` string, required: ISO 8601, UTC.
    - `last_input_at` string, required: Last accepted meaningful input or Keep (ISO 8601, UTC).
    - `check_due_at` string, required: `last_input_at` + FOCUS_CHECK_IDLE_SECONDS: when the console asks "Still focusing?".
    - `check_deadline` string, required: `check_due_at` + FOCUS_CHECK_ANSWER_SECONDS: with no explicit Keep by then the server releases the focus (and treats it as released at once, before any sweep).
    - `check_pending` boolean, required: True while the check is due and unanswered (active records only).
    - `check_due_in_seconds` integer, required: Seconds until `check_due_at`, computed server-side: time it from this against the local clock at receipt.
    - `check_deadline_in_seconds` integer, required: Seconds until `check_deadline`, computed server-side.
    - `state` 'active' | 'released', required: `active` while reserving alarms; `released` once it has ended.
    - `released_at` string, nullable: When the focus was released (ISO 8601, UTC); null while active.
    - `release_reason` 'manual' | 'check_timeout' | 'disabled' | 'scope_lost', nullable: Why it was released; null while active.
  - StoryHoldRecord: One site hold (`origin = "story"`, Site Story step 3): one operator owns the site while its story is open. `id` is the hold; `lineage_id` is shared by a Take over's successor, which is how the held alarms stay held for the taker with no gap.
    - `origin` 'story', required: Always `story`.
    - `id` string, required: The hold id (a site Take over creates a successor with a new id in the same lineage).
    - `lineage_id` string, required: Shared by a hold and its Take over successors.
    - `site_id` string, required: The site the hold is on.
    - `scope` FocusScopeOut, required
      - `kind` 'site' | 'camera', required: Whether the focus covers the whole site or one camera.
      - `camera_id` string, nullable: Set iff `kind` is `camera`.
    - `operator_id` string, required: The holder.
    - `operator_name` string, nullable: Their display name; null if the operator no longer exists.
    - `started_at` string, required: ISO 8601, UTC.
    - `last_seen_at` string, nullable: The holder's last heartbeat (ISO 8601, UTC).
    - `left_at` string, nullable: Set by the holder's best-effort leave signal, cleared by a heartbeat inside the grace. After a clean leave the hold returns its alarms about 30 s after this instant (a fixed server constant), or sooner on its idle or new-alert deadline.
    - `open` boolean, required: True while the holder's page is open: no clean leave and a heartbeat within `open_s`. While true there is no deadline of any kind.
    - `return_at` string, nullable: When the unattended hold returns its alarms to the queue (ISO 8601, UTC); null while `open`. The earliest of the idle deadline, the new-alert deadline and (after a clean leave) 30 s after `left_at`, pushed later by a live call. A display, never a decision.
    - `now` string, required: The server clock this record was built against: compute `return_at - now` once and tick it locally.
    - `serious_unopened` integer: Serious alarms the current holder has not opened (visibility only).
    - `oldest_serious_queued_at` string, nullable: When the oldest of those entered the queue (ISO 8601, UTC).
    - `open_seconds_total` integer: Seconds the holder's page was open during the hold (each interval since the last heartbeat, capped at `open_s`, settled at the next heartbeat, a leave or the release).
    - `idle_s` integer, required: Frozen at start: seconds since the last heartbeat before an unattended hold returns. The backstop for a page that vanished without a clean leave; after a clean leave the hold returns about 30 s later instead (a fixed server constant, not frozen).
    - `new_alert_s` integer, required: Frozen at start: seconds after a new alert before an unattended hold returns.
    - `open_s` integer, required: Frozen at start: seconds since the last heartbeat within which the page still counts as open.
    - `state` 'active' | 'released', required: `active` while it holds the site.
    - `released_at` string, nullable: ISO 8601, UTC; null while active.
    - `release_reason` 'idle_2min' | 'new_alert_2min' | 'holder_gone' | 'submit' | 'taken_over' | 'flag_off' | 'scope_lost' | 'disabled', nullable: Why it ended; null while active. `idle_2min` is the unattended return after a clean leave (about 30 s after it, or the idle deadline if that came first; the name is historical, not a measured 2 minutes); `holder_gone` is the idle deadline with no clean leave.

## Other responses

- `201`: Successful Response
- `422`: Validation Error

---

[API](https://skmtc.dev/verdun/apis/verdun-api.md) - [All operations](https://skmtc.dev/verdun/apis/verdun-api/llms.txt) - [OpenAPI document](https://skmtc.dev/verdun/apis/verdun-api/revisions/35a2a6ebedda?raw)
