FalconVQA Docs
API Reference

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

lib/core/client.ts
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 NULL

core_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.

RouteWraps
GET /api/videos?projectId=Lists rows, reconciling any in flight
POST /api/videosRegisters 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}/reindexReplays ingest_config, or a supplied override
GET /api/videos/{id}/aggregatesGET /videos/{id}/aggregates
POST /api/videos/{id}/aggregatesPOST /videos/{id}/aggregates
GET /api/videos/{id}/detailsRow + core metadata + every chunk + every aggregate
GET /api/videos/{id}/timelineCore's output flattened into scenes, transcript, chapters, events
GET /api/core/capabilitiesGET /analyzers + GET /schema, cached 60s
POST /api/agentThe 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".

On this page