Building a client
The patterns a production integration needs — scoping, reconciliation, recovery — as implemented in the reference Next.js app.
Core is single-tenant, stateless about ownership, and asynchronous. Those three facts decide the shape of every client built on it. This page documents the patterns the reference frontend uses, and why.
1. Keep the client server-side
const CORE_URL = process.env.CORE_API_URL || 'http://127.0.0.1:8077'
const CORE_TOKEN = process.env.CORE_API_TOKEN || ''Never import a core client into browser code. Core has no per-user auth, so every call must be made by something that has already checked ownership and resolved which video ids the caller may touch.
Wrap failures so "core said no" and "core did not answer" stay distinguishable:
export class CoreApiError extends Error {
constructor(message: string, readonly status: number) { … }
}A connection failure becomes a 503 naming the URL it tried; an HTTP error keeps core's own
detail, which is written to be actionable.
2. Own the identity mapping
Core knows video_id. It does not know users, projects, titles or permissions. Keep a row per
video with all of that, plus the ingest lifecycle.
core_video_id text NULL, -- sha1(bytes)[:16], null until core has the bytes
status text NOT NULL, -- pending|uploading|queued|analyzing|ready|failed
job_id text NULL,
stage text NULL,
ingest_config jsonb NULL, -- replayed verbatim on re-index
analyzers text[] NOT NULL, -- what it can be asked
aggregates text[] NOT NULLcore_video_id must be unique per tenant, never globally. The same file uploaded twice is
one video in core, so a global unique constraint would reject the second tenant's upload, and an
unconditional delete would destroy the first tenant's analysis.
analyzers and aggregates are stored as arrays rather than JSON because the common query is a
predicate: does this video have people?
3. Create the row before core has the bytes
Insert with status: 'pending'
The video's core id is the hash of its contents, so it does not exist yet. Everything identifying arrives later, through the job.
Call POST /videos/url, store the job_id
Set status: 'queued', stage: 'fetching'. If core is unreachable, mark the row failed with
the reason — the row still exists, and re-index is the way out.
Reconcile on read
Core analyses on a background thread and pushes nothing, so the client's existing poll is where progress is discovered. One function turns a job into a row patch, used by both the list route and the single-video route so they cannot drift.
Write the result back
On done, copy video_id, video_url, poster_url, duration, size_bytes, chunk_config,
chunks and analyzers onto the row and mark it ready.
Ingest jobs and aggregate re-runs share the job table. Distinguish them by the presence of
video_url in the result — writing ingest fields from an aggregate result would blank the
playback URL with undefined.
4. Recover from a restarted core
Jobs live in memory. A poll that 404s does not mean the ingest failed:
job 404
├─ row has core_video_id, and GET /videos/{id} succeeds
│ → the ingest finished before the restart. Complete the row from the video itself.
└─ otherwise
→ genuinely lost. Fail the row with a message pointing at re-index.Without this, a restart mid-ingest leaves rows stuck on analyzing forever with no way out.
5. Scope every call explicitly
Omitting video_ids on /query or /ask searches every video in the install. In a
multi-tenant deployment that is a data leak, not a convenience.
Put one resolver between your callers and core, and pass everything through it:
async function resolveScope(context, requested?: string[]) {
// 1. require a project on the request
// 2. load that project's rows for that user (RLS applies)
// 3. keep only rows that are ready and have a core_video_id
// 4. intersect with `requested`, and report what was dropped
}In the reference app every agent tool but the pass-through display one goes through this. A model that hallucinates a video id, or repeats one it saw in another conversation, gets it filtered out here rather than answered from someone else's footage — and the rejected ids are reported back to the model so it corrects itself.
6. Choose detail deliberately
For a UI, detail=standard. For an agent, detail=minimal followed by
GET /videos/{id}/chunks?chunk_ids=… on the few that matter — about 1.6k tokens against 19k for
the same five moments at full detail.
Do not expose core's filters to a language model. Every filter is a hard AND against exact
stored labels, so it can only remove results, and models reliably send speculative ones that
silently empty a good result set.
7. Delete with a reference count
// count other rows pointing at the same core_video_id
// only if none remain: DELETE /videos/{core_video_id}The reference routes
The frontend exposes these; each wraps core with an ownership check.
| Route | Wraps |
|---|---|
GET /api/videos?projectId= | Lists rows, reconciling any in flight |
POST /api/videos | Registers a source and calls POST /videos/url |
GET /api/videos/{id} | Reconciles one row |
DELETE /api/videos/{id} | Deletes the row, and core's copy if unreferenced |
POST /api/videos/{id}/reindex | Replays ingest_config, or a supplied override |
GET /api/videos/{id}/aggregates | GET /videos/{id}/aggregates |
POST /api/videos/{id}/aggregates | POST /videos/{id}/aggregates |
GET /api/videos/{id}/details | Row + core metadata + every chunk + every aggregate |
GET /api/videos/{id}/timeline | Core's output flattened into scenes, transcript, chapters, events |
GET /api/core/capabilities | GET /analyzers + GET /schema, cached 60s |
POST /api/agent | The streaming agent loop over the video tools |
GET /api/core/capabilities exists so the upload dialog builds its analyzer list at runtime
rather than from a constant. Core's contract is that adding an analyzer touches one module and
one registry line — a hardcoded list in the client would quietly make that "…and the frontend".