The OpenAPI document is the canonical source -- visit /openapi.json
or the Swagger UI at /docs on a running instance. This page is the
human-readable catalogue.
Every /api/v1/* request except /api/v1/version requires the
tenant-context headers:
| Header | Grammar | Notes |
|---|---|---|
X-Tenant-Id |
^[a-z0-9][a-z0-9_-]{0,63}$ |
Identifies the calling tenant. |
X-Workspace-Id |
same | Workspace within the tenant. |
Missing or malformed headers -> 400 missing_tenant_context
(RFC 7807 envelope; see Error responses below). The
X-Correlation-Id / X-Request-Id propagation headers from pyfly
are unchanged.
| Header | When | Notes |
|---|---|---|
X-API-Key: <key> or Authorization: ApiKey <key> |
FLYCANON_API_KEYS is non-empty |
Platform key for every /api/v1/* route except GET /api/v1/version, and for the admin dashboard. X-API-Key wins when both are present. Missing -> 401 missing_api_key; unknown -> 401 invalid_api_key. With the setting empty the user tier is OPEN and the boot log says so. |
X-Agent-Token: agt_... |
/api/v1/agent/* |
The agent tier's own credential; a request carrying it does not need the platform key (the route verifies the token: 401 missing_agent_token, 403 invalid_agent_token / agent_scope_denied / ...). |
Authorization: Bearer <jwt> |
optional | Decoded WITHOUT signature verification purely to derive the actor label and cross-check an optional tenant claim (403 tenant_claim_mismatch). It is not an authentication gate; put a verifying gateway in front if you need JWT auth. |
openapi.json carries the matching securitySchemes (ApiKeyHeader,
ApiKeyAuthorization, AgentToken) and the header parameters on every
operation, so generated clients send them.
Workspace scope is enforced on every read-by-id route. A caller who
presents a resource id from a different workspace (same tenant or
different tenant) receives 404 resource_not_found. Applies to both
the user surface and the agent surface (/api/v1/agent/*). This is
true for knowledge, sources, candidates, conversations, ingest-jobs,
and any sub-routes that take an id (e.g., /{id}/history,
/{id}/provenance, /{id}/relations).
The 404 is the wire surface of two cooperating gates: the handler /
repository filter on (tenant_id, workspace_id) and, on Postgres,
row-level security (see architecture.md -> Row-level security).
Either alone would return 404; defence-in-depth keeps the surface
consistent even when a future repository forgets the WHERE clause.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/sources |
Submit a source. Body: SubmitSourceJsonPayload (base64 bytes via content_base64 or uri to fetch). Default sync 201 returns SourceRecord; add ?mode=async for the queued path (returns IngestJob, see /api/v1/ingest-jobs/{id}). Optional ?callback_url=… fires a webhook on terminal state. Same-content submissions dedup on (tenant_id, workspace_id, content_sha256). |
POST |
/api/v1/sources:bulk |
Bulk-submit an array of sources. Returns per-item BulkSourceResults. |
PUT |
/api/v1/sources/{id} |
Replace an existing source's content in place. Body: SubmitSourceJsonPayload (content_base64 required). |
DELETE |
/api/v1/sources/{id} |
Remove a source: BM25 rows + dense vectors purged, chunk rows deleted, the stored original deleted from the object store, the row deleted, source.removed audited and SourceRemoved published. 204. Unknown id -> 404 source_not_found. (26.7.1) |
GET |
/api/v1/sources |
Paginated list. Query: status, kind (csv), limit, offset. |
GET |
/api/v1/sources/{id} |
Fetch a single source. 404 → resource_not_found (RFC 7807). |
uri fetches and callback_url webhooks go through the outbound host
policy: http/https only, no loopback, private (RFC 1918 / ULA),
link-local (instance metadata), multicast or reserved address, whether
given literally or reached through DNS or a redirect. Refusals are
400 url_fetch_forbidden_host (uri) and 400 callback_url_not_allowed
(callback). FLYCANON_URL_FETCH_ALLOW_PRIVATE=true lifts the denylist
for private dev stacks only.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/knowledge |
Create item + version 1. Body: CreateKnowledgeRequest. Returns the new version. |
GET |
/api/v1/knowledge |
Paginated list. Query: status, domain, jurisdiction, limit, offset. |
GET |
/api/v1/knowledge/{id} |
Pointer view. 404 -> knowledge_item_not_found. |
PUT |
/api/v1/knowledge/{id} |
Append a new version. Body: UpdateKnowledgeRequest. |
POST |
/api/v1/knowledge/{id}:supersede |
Body: SupersedeKnowledgeRequest. |
POST |
/api/v1/knowledge/{id}:retire |
Body: RetireKnowledgeRequest. |
GET |
/api/v1/knowledge/{id}/history |
Full version chain (oldest first). |
GET |
/api/v1/knowledge/{id}/diff |
Unified diff between two versions. Query: from_version, to_version. |
GET |
/api/v1/knowledge/{id}/provenance |
Citation graph for the current version. Query: version (optional explicit). |
GET |
/api/v1/knowledge/{id}/relations |
List (outgoing, incoming) typed edges for the item. |
POST |
/api/v1/knowledge/{id}/relations |
Add a typed edge. Body: CreateRelationRequest. |
DELETE |
/api/v1/knowledge/{id}/relations/{relation_id} |
Remove an edge. |
GET |
/api/v1/knowledge:graph |
Whole-canon graph view (JSON or accept: text/vnd.mermaid for Mermaid). Query: domain, kind, include_sources. |
GET |
/api/v1/knowledge:stale |
Per-item staleness scores. Compares published items against fresh sources via cosine. |
POST |
/api/v1/knowledge:detect-conflicts |
Pairwise conflict scan. Returns confirmed conflict pairs as CandidateRows + auto-creates conflicts_with edges. |
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/candidates:propose |
Run the consolidation LLM against a source. Body: ProposeCandidateRequest. Returns the list of proposed candidate records. |
GET |
/api/v1/candidates |
Paginated list. Query: status, source_id, domain, limit, offset. |
GET |
/api/v1/candidates/{id} |
Fetch a single candidate. |
POST |
/api/v1/candidates/{id}:accept |
Materialise as a knowledge version. Body: AcceptCandidateRequest. |
POST |
/api/v1/candidates/{id}:reject |
Discard with a reason. Body: RejectCandidateRequest. |
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/search |
Hybrid retrieval (BM25 + vector + RRF + optional rerank + optional query expansion). Body: SearchRequest. |
POST |
/api/v1/query |
Answer with citations. Engine selected by FLYCANON_ANSWER_MODE (RLM by default; RAG opt-in). Body: AnswerRequest. |
POST |
/api/v1/query/stream |
Same as /query but streams the answer as Server-Sent Events. |
POST |
/api/v1/query/suggest |
Suggest follow-up questions for an existing answer. Body: AnswerRequest. |
A nonempty source_ids selector is intersected with the authenticated tenant and workspace before BM25 and dense candidate ranking, fusion and top_k selection. Sources with no matching chunks return no hits; retrieval never falls back to the global source set. Omitting source_ids, using null, or supplying an empty list retains the existing meaning of no additional source constraint within the authenticated scope. Hydrated source checks remain active after ranking as defense in depth. Other knowledge metadata filters retain their documented hydration behavior.
The answer endpoints (/api/v1/query, /api/v1/query/stream, and the
agent-tier /api/v1/agent/query[/stream]) route to one of two engines,
selected by FLYCANON_ANSWER_MODE:
rlm(default) — the Recursive Language Model engine reasons over whole documents (read from the object store) and returns the sameAnswerResponseshape (answer text + citations +model+elapsed_ms). The request and response contracts are identical to RAG, so callers need no changes. The streaming variant emits a singlestatusframe ({"stage":"reasoning",...}) instead of pre-retrievalhitframes before the terminalfinalframe.rag(opt-in, deprecated) — the legacy hybrid-retrieval answerer. When selected, the server logs a deprecation warning on every RAG-mode answer and returns a client-facingX-Flycanon-Deprecationresponse header (carrying the same message) on the answer endpoints. The path is slated for removal in a future release. The streaming variant still emits per-hitcitation frames first.
The request / response shapes are unchanged across modes; AnswerRequest
in, AnswerResponse out. /api/v1/search (raw retrieval, no LLM) is
independent of the answer mode and always available.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/conversations |
Start a new conversation. Body: CreateConversationRequest. Returns the conversation id. |
GET |
/api/v1/conversations/{id} |
Fetch conversation header + last N turns. The summary field is derived from the turn rows on demand (race-free across concurrent turn appends). |
POST |
/api/v1/conversations/{id}/turn |
Submit a user turn; returns the assistant answer with citations. Body: CreateTurnRequest. Two parallel POSTs serialise via UNIQUE(conversation_id, turn_index) with a bounded retry loop. |
The /api/v1/ingest-jobs/* surface parallels flyradar's
/api/v1/discovery-jobs/*.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/ingest-jobs |
Paginated list. Query: status (csv), limit, offset. |
GET |
/api/v1/ingest-jobs/{id} |
Job header — status, attempts, source_id once succeeded, error_code/error_message on failure. |
GET |
/api/v1/ingest-jobs/{id}/stream |
Server-Sent Events feed of job events. Every event frame carries an id:; resume with ?after_id=N or the Last-Event-ID request header (both honoured since 26.7.1). |
CRUD over the canon_workspaces table. All six endpoints require
the standard tenant and workspace headers. For creation, the body
id must equal X-Workspace-Id. A mismatch returns
400 workspace_scope_mismatch before any row or event is written.
Production RLS restricts row visibility to the tenant and workspace
in those headers; the tenant-wide list exception is described below.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/workspaces |
Create. Body: WorkspaceCreate; id must match X-Workspace-Id. Returns 201 with WorkspaceSpec. |
GET |
/api/v1/workspaces |
List visible workspaces within the tenant, newest first. No filter or pagination arguments. |
GET |
/api/v1/workspaces/{id} |
Fetch one. 404 → workspace_not_found. |
PATCH |
/api/v1/workspaces/{id} |
Sparse update. Body: WorkspaceUpdate. |
POST |
/api/v1/workspaces/{id}:close |
Close (idempotent; sets status=closed + closed_at). Data stays. |
POST |
/api/v1/workspaces/{id}:purge |
Erase everything the workspace holds, then close it: every source through the full removal pipeline (vectors, chunks, stored originals, audit + event each), then knowledge items / versions / citations / relations, candidates, conversations / turns, ingest jobs / events and cost events in one transaction; emits WorkspaceDeleted; audit rows are kept and a workspace.purged row records the counts. X-Workspace-Id must equal the path id (400 workspace_scope_mismatch). Returns WorkspacePurgeResult -- every counter is what this call erased, originals_deleted only objects actually found in the store, closed only when this call closed the row; a repeat is all zeros, closed: false, no event, no audit row. (26.7.1) |
With a separate BYPASSRLS connection configured through
FLYCANON_ADMIN_DATABASE_URL, GET /api/v1/workspaces lists the
tenant's workspaces on that connection. Otherwise it uses the request
connection; under the production non-bypass role, RLS limits the
result to the workspace named in X-Workspace-Id. Both tenant and
workspace headers remain required in either configuration.
Billing endpoints scope by (tenant_id, workspace_id) from the
request headers; there is no actor Query param. actor is a
column on canon_cost_events for audit / forensics, not a query
partition.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/billing |
Aggregated cost report. Query: group_by (csv of date/model/agent_name), since, until. |
GET |
/api/v1/billing/events |
Per-call drill-down (correlation id + subject + latency). Query: agent_name, since, until, limit (1-500), offset. |
GET |
/api/v1/billing/summary |
Rolling-window snapshot (24h / 7d / 30d) with top model per window. |
GET |
/api/v1/billing/top |
Top-N consumers by cost. Query: dimension (model / agent_name), since, until, limit (1-100). |
GET |
/api/v1/billing/by-subject |
Cost attribution per (subject_kind, subject_id). Query: subject_kind, since, until, limit (1-200). |
GET |
/api/v1/billing/latency |
p50 / p95 / p99 latency per group. Query: group_by (csv of model / agent_name), since, until. |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/stats |
One-shot corpus + queue + cost-stream snapshot (sources, knowledge items, versions, candidates, chunks, ingest jobs, cost headline). |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/taxonomy |
Flat list of every node, ordered by depth. |
POST |
/api/v1/taxonomy/nodes |
Append a node. Body: CreateTaxonomyNodeRequest. |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/audit |
Paginated audit-log view. Query: subject_id, subject_kind, event_type, limit, offset. |
Curated subset of the user surface, protected by X-Agent-Token.
Tokens are tenant-scoped, minted via POST /api/v1/agent-tokens
(user tier, JWT-protected), and verified per-request against the
canon_agent_tokens table.
These three routes are mounted under /api/v1/agent-tokens and are
themselves user-tier (operator JWT or anonymous in dev). Agent-tier
callers (whose actor begins with agent:) are refused with
403 agent_cannot_mint.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/agent-tokens |
Mint. Returns 201 + AgentTokenCreated. The raw secret is returned in the token field exactly ONCE. Capture it on the response; there is no recovery path. Subsequent reads expose only the public 12-char prefix. Body: AgentTokenMintRequest. |
GET |
/api/v1/agent-tokens |
List tokens for the current tenant (newest first). Returns AgentTokenSummaryDto[] -- the raw token is never round-tripped on this endpoint, only the prefix + metadata. |
DELETE |
/api/v1/agent-tokens/{token_id} |
Revoke. Idempotent: revoking an already-revoked token is a no-op (still 204). Unknown token_id returns 404 resource_not_found. |
Every agent route requires three headers together: X-Tenant-Id,
X-Workspace-Id, and X-Agent-Token: <secret> (the raw token as
returned by the mint endpoint, shape: agt_<8hex>_<32hex>). A request
carrying X-Agent-Token is exempt from the platform API key, and the
platform key is not accepted in the token's place (a platform-key-only
call to an agent route is 401 missing_agent_token); the OpenAPI
document lists AgentToken as the sole security requirement on these
operations. When an Authorization: Bearer JWT is also present its
sub wins as the actor label and the agent token is still verified
for authorisation.
Idempotency-Key is mandatory on every agent-tier POST, PUT and
DELETE. Missing -> 400 missing_idempotency_key. (The user-tier
routes do not read the header; the agent surface is stricter so
duplicate machine calls can never silently create extra sources,
candidates, or queries.)
| Method | Path | Scope | Description |
|---|---|---|---|
POST |
/api/v1/agent/sources |
agent.sources:ingest |
Ingest a Source (always synchronous -- no mode=async on this tier). Body: SubmitSourceJsonPayload. Idempotency-Key mandatory. Reuses the same handler as the user-tier POST /api/v1/sources. |
GET |
/api/v1/agent/sources/{id} |
agent.sources:read |
Retrieve a Source row. 404 -> resource_not_found. |
PUT |
/api/v1/agent/sources/{id} |
agent.sources:ingest |
Replace in place (content_base64 required). Idempotency-Key mandatory. |
DELETE |
/api/v1/agent/sources/{id} |
agent.sources:ingest |
Remove a source incl. its stored original. 204. Idempotency-Key mandatory; a replay returns the original 204. |
POST |
/api/v1/agent/query |
agent.query:run |
Answer with citations (engine per FLYCANON_ANSWER_MODE; RLM by default). Body: AnswerRequest. Idempotency-Key mandatory. Same dispatcher as the user-tier POST /api/v1/query. |
POST |
/api/v1/agent/query/stream |
agent.query:run |
Streaming answer (Server-Sent Events). Body: AnswerRequest. Idempotency-Key mandatory. Same wire format as the user-tier POST /api/v1/query/stream. |
POST |
/api/v1/agent/search |
agent.query:run |
Hybrid retrieval (BM25 + vector + RRF), no LLM. Body: SearchRequest. Idempotency-Key mandatory. Reuses the user-tier POST /api/v1/search handler. |
GET |
/api/v1/agent/knowledge/{id} |
agent.knowledge:read |
Fetch a KnowledgeItem (pointer view). 404 -> knowledge_item_not_found. |
GET |
/api/v1/agent/knowledge/{id}/provenance |
agent.knowledge:read |
Fetch citation provenance for the current version. Query: version (optional explicit). |
POST |
/api/v1/agent/candidates:propose |
agent.candidates:propose |
Propose Candidate rows from a source. Body: ProposeCandidateRequest. Idempotency-Key mandatory. Does not auto-accept -- candidates land in proposed and must be reviewed via the user-tier POST /api/v1/candidates/{id}:accept route. |
The per-route scope argument the verifier matches against the
token's scopes list:
| Scope | Endpoints it unlocks |
|---|---|
agent.sources:ingest |
POST /api/v1/agent/sources |
agent.sources:read |
GET /api/v1/agent/sources/{id} |
agent.query:run |
POST /api/v1/agent/query, /api/v1/agent/query/stream, /api/v1/agent/search |
agent.knowledge:read |
GET /api/v1/agent/knowledge/{id}, /api/v1/agent/knowledge/{id}/provenance |
agent.candidates:propose |
POST /api/v1/agent/candidates:propose |
* |
Wildcard -- matches every scope above. |
A token with scopes: ["*"] (the mint default) can call every agent
route; a token minted with a narrower list is restricted to those
routes exactly. Cross-scope calls return 403 agent_scope_denied.
In addition to the standard error envelope, the agent surface raises:
| Status | Code slug | Raised when |
|---|---|---|
| 401 | missing_agent_token |
X-Agent-Token header is absent on an /api/v1/agent/* route. |
| 403 | invalid_agent_token |
Token shape is malformed, prefix is unknown, tenant doesn't match, or the hash comparison fails. |
| 403 | agent_token_expired |
The token's expires_at is in the past. |
| 403 | agent_workspace_not_in_allowlist |
X-Workspace-Id is not in the token's workspace_allowlist. |
| 403 | agent_scope_denied |
The per-route scope is not in the token's scopes list (and * is absent). |
| 403 | agent_cannot_mint |
An agent-tier caller (actor begins with agent:) hit a user-tier /api/v1/agent-tokens route. |
| 400 | missing_idempotency_key |
An agent POST was made without the mandatory Idempotency-Key header. |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/version |
VersionInfo -- service identity, models, retrieval / EDA backends. |
GET |
/actuator/health |
Aggregated health (database + EDA). |
GET |
/actuator/health/readiness |
Readiness probe for K8s. |
GET |
/actuator/health/liveness |
Cheap liveness probe. |
GET |
/actuator/metrics |
Prometheus-format metrics. |
GET |
/openapi.json |
The full OpenAPI 3 document. |
GET |
/docs |
Swagger UI. |
GET |
/redoc |
ReDoc UI. |
GET |
/admin/ |
pyfly admin dashboard. Its API requires authentication: a valid platform key (X-API-Key) when FLYCANON_API_KEYS is set; otherwise it answers 401 unless FLYCANON_ADMIN_REQUIRE_AUTH=false. |
Every 4xx / 5xx response is an RFC 7807
application/problem+json document produced by
flycanon.web.conventions.ProblemDetail. The shape:
{
"type": "https://firefly.dev/problems/knowledge_item_already_retired",
"code": "knowledge_item_already_retired",
"title": "Knowledge item already retired",
"status": 409,
"detail": "knowledge item 'add55fda-…' is already retired",
"instance": "/api/v1/knowledge/add55fda-.../:retire",
"correlation_id": "01HV...",
"errors": []
}Programmatic clients dispatch on code (stable snake_case slug);
title is now the human-readable label and detail is the
free-form message. The type URI base is
https://firefly.dev/problems/... (was https://flycanon.dev/...).
Field-level validation details land under errors[].
| Status | Code slug | When |
|---|---|---|
| 400 | missing_tenant_context |
X-Tenant-Id / X-Workspace-Id header missing or malformed. |
| 401 | missing_api_key / invalid_api_key |
FLYCANON_API_KEYS is set and the request carried no key / an unknown key (X-API-Key or Authorization: ApiKey). |
| 400 | callback_url_not_allowed |
callback_url targets a non-globally-routable host (private, loopback, link-local, shared address space 100.64.0.0/10, reserved) or a non-http scheme. |
| 400 | url_fetch_forbidden_host / url_fetch_unsupported_scheme |
uri targets a refused host / a non-http scheme. |
| 413 | url_fetch_too_large |
The origin's document exceeds FLYCANON_MAX_BYTES. |
| 502 | url_fetch_http_error / url_fetch_failed / url_fetch_too_many_redirects |
The origin answered 4xx/5xx, could not be reached, or redirected more than five times. |
| 400 | workspace_scope_mismatch |
Workspace creation with a body id different from X-Workspace-Id, or POST /workspaces/{id}:purge with a different path id. |
| 404 | source_not_found |
DELETE/PUT on an unknown source id (either tier). |
| 400 | invalid_request |
Pydantic validation, missing-field ValueError raised inside a controller helper. |
| 404 | resource_not_found |
Generic missing resource (knowledge item, source, conversation, candidate, job, workspace). |
| 404 | knowledge_item_not_found / knowledge_version_not_found / candidate_not_found |
Typed not-found variants for callers that prefer the specific slug. |
| 409 | knowledge_item_already_retired |
:retire against a retired item, or losing the atomic claim against a concurrent retire. |
| 409 | knowledge_version_conflict |
Concurrent PUT /knowledge/{id} updates collided on UNIQUE(item_id, version). Extensions: attempted_version. |
| 409 | invalid_supersede_target |
:supersede against a non-existent / retired target, OR losing the atomic claim against a concurrent supersede. |
| 409 | candidate_already_decided |
Two operators accepted / rejected the same candidate; the loser sees this. |
| 409 | relation_already_exists |
(from, to, kind) UNIQUE collision on POST /knowledge/{id}/relations. |
| 415 | unsupported_source_kind |
Binary normaliser doesn't recognise the bytes. |
| 422 | empty_source / corrupt_source |
Loader produced no text / pre-flight rejected the file. |
| 422 | invalid_relation |
Self-relation or unknown target on a relation add. |
These 409s are the surface contract for the atomic-claim plumbing in
concurrency.md. Two operators clicking the same
:retire / :supersede / :accept simultaneously produce exactly one
success and one typed 409 — never two writes, never a generic 500.