Build an AI worker
A Heldar AI worker is any process that can speak four core HTTP endpoints (plus three optional semantic-embedding endpoints — see below). The kernel samples frames and stores results; your worker is a pure HTTP client that discovers work, pulls frames, runs a model, and posts detections back. Because the contract is HTTP, a crashing or slow worker can never stall ingest or recording.
The reference implementation is
apps/ai/worker.py
(a small, dependency-light Python worker). The full integration guide, including
the zone and ANPR analyzers, is
docs/AI-WORKERS.md.
The contract
All endpoints live under /api/v1 and return JSON, except the frame, which is
image/jpeg.
1. Discover work - GET /api/v1/ai/tasks
Returns every enabled task on an enabled camera, each carrying the frame_url
to pull. This is your whole work list. Re-poll every few seconds to pick up newly
enabled or disabled tasks.
[
{
"id": "ai_3f2a9c1b...",
"camera_id": "gate_a",
"task_type": "detection",
"stream_profile": "sub",
"fps": 5.0,
"width": 1280,
"config": { "classes": ["person", "car"], "min_confidence": 0.4 },
"frame_url": "/api/v1/cameras/gate_a/frame"
}
]
task_type is a free-form string you define; the kernel uses fps / width /
enabled only to drive the sampler, and config is an opaque blob your worker
interprets. The fps here is the requested rate; the effective sampled rate
after budgeting is reported by GET /api/v1/ai/samplers.
Running more than one worker? Pass a stable identity as ?worker_id= (e.g.
GET /api/v1/ai/tasks?worker_id=nvr1:4242) and the kernel registers you as a
live worker and returns only your shard of the task list, modulo-sharded
across the live worker set. Omitting it returns everything (single-worker
back-compat) — a custom worker that skips it will double-process every task
next to any other worker. Tasks are rebalanced whenever the live worker set
changes: a worker whose last poll is older than the liveness TTL (60s) is
treated as gone and its tasks reassigned, so poll faster than the TTL (the
default poll interval is 10s).
2. Pull a frame - GET /api/v1/cameras/{id}/frame
Serves the latest sampled JPEG for a camera. Pull it on your own cadence,
typically at or just under the task fps.
200 OK
Content-Type: image/jpeg
Cache-Control: no-store
x-frame-age-ms: 142
x-frame-captured-at: 2026-06-13T08:15:31.120+00:00
<JPEG bytes>
x-frame-age-ms- milliseconds since the frame was written. Use it to skip stale frames if the sampler has gone offline.x-frame-captured-at- the write timestamp. Dedupe on it so you do not re-analyze an unchanged frame, and optionally echo it back as the detectiontimestampso detections align to capture time.
A 404 means no frame exists yet (no enabled AI task for the camera, or the
sampler has not produced its first frame). Treat it as a skipped cycle, not an
error.
3. Post results - POST /api/v1/ai/events
Post a batch of detections for one camera and task, optionally with a single derived event in the same call.
{
"camera_id": "gate_a",
"task_type": "detection",
"timestamp": "2026-06-13T08:15:31.120Z",
"detections": [
{
"label": "person",
"confidence": 0.92,
"bbox": [0.41, 0.30, 0.08, 0.22],
"track_id": "t-17",
"attributes": { "zone": "entry_lane_a" }
}
],
"event": {
"event_type": "person_in_red_zone",
"severity": "warning",
"payload": { "zone": "red_a", "track_id": "t-17" }
}
}
Field rules:
camera_idis required and must exist, else404.task_typeis required and is stored on each detection row.timestampis optional RFC3339 and applies to the whole batch; omitted or unparseable falls back to servernow().detectionsis optional (defaults to[]); send[]to post only an event. Every field inside a detection is optional.eventis optional.event_typeis required when present;severitydefaults toinfo(usewarningorcriticalto trigger the alert webhook);payloaddefaults to an empty object.
The response is { "detections_ingested": N }.
4. Sampler status - GET /api/v1/ai/samplers
Per-camera sampler state (connecting / sampling / offline / error /
stopped) and the effective budgeted fps. Useful for dashboards and for
confirming the kernel is actually producing frames.
5. Optional - semantic embeddings
Three additive endpoints back the semantic-search pipeline. A worker that never
runs an embedding task can ignore them, and GET /api/v1/ai/tasks is
unchanged (still a bare array), so existing workers keep working.
POST /api/v1/ai/embeddings- post a batch of crop embeddings for one camera:{ camera_id, model, dim (1..4096), frame_id?, items: [...] }with 1-128 items, each{ vec (exactly dim finite floats), track_id?, label?, bbox?, timestamp?, thumb_b64? }(thumb_b64is an optional JPEG crop for the search UI, capped at ~96 KB of base64).frame_idis an idempotency key: rows dedupe on(camera_id, frame_id, track_id), so a redelivered batch is skipped silently and the{ "embeddings_ingested": N }response counts only rows actually inserted.GET /api/v1/ai/embed-queries?worker_id=<id>- atomically claim up to 4 pending search queries (text or base64 image) to embed. Returns an object,{ "queries": [ { "id", "kind", "payload" } ] }, not a bare array. Poll it fast (the reference worker polls every ~1 s): a semantic search holds the operator's request open for only ~3 s waiting for the query vector, which the ~10 s tasks poll could never meet - that is why this is a separate endpoint rather than a piggyback on/ai/tasks. Idle polls are read-only, and queries older than 60 s expire undelivered.POST /api/v1/ai/embed-queries/{id}/result- post the query vector back ({ vec, model, dim }) or{ "error": "..." }so the waiting search fails fast instead of timing out. First result wins; a late duplicate is a200no-op ({ "updated": false }).
The bbox convention
bbox is [x, y, w, h] normalized to 0..1, top-left origin. Normalizing
keeps detections resolution-independent, so they survive any later change to the
sampled width and map directly onto normalized zone polygons. The kernel stores
the box as raw JSON and does not validate its shape, so your worker owns
correctness.
A detection with both a track_id and a bbox drives the kernel zone engine
(its ground point is the box bottom-center); detections without them are still
stored but cannot raise zone events.
The worker loop
tasks = GET /api/v1/ai/tasks # refresh every few seconds
for each task (own thread / async task):
loop at ~task.fps:
resp = GET task.frame_url
if resp is 404: sleep, continue # no frame yet
if x-frame-captured-at == last_seen: continue # unchanged frame; skip
dets, event = analyze(task, resp.body)
if dets or event:
POST /api/v1/ai/events { camera_id, task_type, timestamp,
detections: dets, event: event }
Because the served frame is last-value, pulling faster than the sampler writes
returns the same bytes; dedupe on x-frame-captured-at. Pulling slower simply
drops intermediate frames, which is fine for detection and tracking at these
rates.
Plugging a model in
The reference worker defines an Analyzer base class and creates one instance
per task thread, so per-camera state (a previous frame, a tracker) lives on
self. Analyzers register by task_type; an unknown type falls back to a
placeholder that exercises the frame path but never fabricates detections. A
working, model-free MotionAnalyzer is registered for task_type: "motion", so
you can validate the whole sampler to worker to events path with no model and no
GPU.
A real detector slots in as one subclass and one register(...) call, with no
change to the kernel or the HTTP contract:
from worker import Analyzer, AnalysisResult, Detection, FrameContext, register
class YoloAnalyzer(Analyzer):
name = "yolo"
def __init__(self, config, log):
super().__init__(config, log)
from ultralytics import YOLO
self.model = YOLO(config.get("weights", "yolov8n.pt"))
self.conf = float(config.get("threshold", 0.25))
def analyze(self, frame: FrameContext) -> AnalysisResult:
img = frame.image(); w, h = img.size
dets = []
for r in self.model(img, conf=self.conf, verbose=False):
for b in r.boxes:
x1, y1, x2, y2 = b.xyxy[0].tolist()
dets.append(Detection(
label=self.model.names[int(b.cls)],
confidence=float(b.conf),
bbox=[x1/w, y1/h, (x2-x1)/w, (y2-y1)/h])) # normalized 0..1
return AnalysisResult(detections=dets)
register("detection", YoloAnalyzer) # replaces the placeholder for "detection"
The kernel never touches your model. It only routes results to consumers by
task_type: detection results with track ids drive the zone engine, anpr
results feed the access-control engine, embedding tasks index crop vectors
for semantic search, and so on. To add a new pipeline, pick a new task_type,
post its results, and write a consumer for it (see
Build a module).
The reference worker also ships an embedding analyzer (YOLO + ByteTrack +
open_clip; vehicle classes only by default - person is deliberately excluded)
that embeds each track on first sight and then every stride_seconds while it
moves (static suppression is on by default — static_suppression true,
static_epsilon 0.02 the normalized-bbox movement threshold below which a
track counts as parked, and static_refresh_seconds 3600 the slow re-embed
floor that keeps a permanently parked object in the index across the retention
TTL), and
posts the vectors through the optional endpoints above instead of
/api/v1/ai/events - it emits no detections, so it never double-fires the
zone or access-control consumers. A companion EmbedQueryWorker thread polls
the query queue (~1 s, HELDAR_AI_EMBED_POLL_INTERVAL) and answers search
queries with the CLIP text or image tower. Both need the optional CLIP extra
(pip install -r requirements-embed.txt, model via HELDAR_AI_CLIP_MODEL /
HELDAR_AI_CLIP_PRETRAINED, defaults ViT-B-32-quickgelu / openai); without it,
embedding tasks fall back to the placeholder and semantic searches return a
fast 503 instead of hanging.
Running the reference worker
cd apps/ai
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
HELDAR_API=http://localhost:8000 python worker.py
# or: python worker.py --api http://localhost:8000 --log-format json
Worker-side config (CLI flag or env var) covers the API base URL
(--api / HELDAR_API), the task poll interval
(--poll-interval / HELDAR_AI_POLL_INTERVAL), the sharding identity
(--worker-id / HELDAR_AI_WORKER_ID, default <hostname>:<pid> — sent as
?worker_id= so multiple workers split the task list), and
HTTP/backoff/logging knobs.
The full table is in
apps/ai/README.md.