Spring-Boot-native Java client for the flycanon Operational Knowledge Repository service.
- Java 25 (LTS) / Spring Boot 3.5.9 / Spring Framework 6.2.
- Two clients side by side:
CanonClient-- synchronous, blocking; built on Spring'sRestClient(the new default that replacesRestTemplatein Spring 6.1+) and Jackson. Use from plain Spring MVC / Servlet stacks.ReactiveCanonClient-- non-blocking; built onWebClient- Reactor Netty. Returns
Mono<T>for unary methods andFlux<StreamFrame>for the SSE streams. Use from Spring WebFlux or any reactive chain.
- Reactor Netty. Returns
- Carries the firefly four-header wire contract
(
X-Tenant-Id/X-Workspace-Id/X-Correlation-Id/X-Agent-Token) on every outbound request. - Ships an
@AutoConfigurationfor each variant; the blocking bean is wired by default, the reactive bean is opt-in viaflycanon.reactive-auto-configure=true. groupId = com.firefly. Apache-2.0.
Compatible with flycanon service version 26.5.x.
| SDK | Service |
|---|---|
26.5.7 |
26.5.x |
<dependency>
<groupId>com.firefly</groupId>
<artifactId>flycanon-sdk</artifactId>
<version>26.5.7</version>
</dependency>That's the blocking client. The SDK declares spring-boot-starter
and spring-web as compile dependencies, so a Spring Boot 3.5.x
application picks up everything it needs transitively.
To use the reactive client too, add Spring WebFlux:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>The SDK declares spring-webflux + reactor-netty-http as
<optional>true</optional> so consumers that don't want the
reactive variant don't pay for the reactor / netty fat.
With Spring Boot, set the four wire-contract properties in
application.yml:
flycanon:
base-url: https://canon.example.com
tenant-id: acme
workspace-id: ws-prod
agent-token: ${FLYCANON_AGENT_TOKEN}Then inject the bean and call:
import com.firefly.flycanon.sdk.CanonClient;
import com.firefly.flycanon.sdk.model.Models;
import org.springframework.stereotype.Service;
@Service
public class CopilotService {
private final CanonClient canon;
public CopilotService(CanonClient canon) {
this.canon = canon;
}
public String askAgent(String question) {
Models.AnswerResponse resp = canon.agent().query(
new Models.AnswerRequest(question, 8, null, null),
"example-001");
return resp.answer();
}
}Without Spring Boot, use the builder:
CanonClient canon = CanonClient.builder()
.baseUrl("https://canon.example.com")
.tenantId("acme")
.workspaceId("ws-prod")
.agentToken("canon_xxxxx")
.build();
Models.AnswerResponse resp = canon.agent().query(
new Models.AnswerRequest("What's our retention policy?", 8, null, null),
"example-001");
System.out.println(resp.answer());See QUICKSTART.md for the longer tour, including a
multi-tenant pattern (one CanonClient per tenant via
flycanon.auto-configure=false and an explicit @Bean).
Required
| Setter | Type | Description |
|---|---|---|
baseUrl(String) |
required | Service base URL. |
Optional wire-contract headers
| Setter | Header sent | Notes |
|---|---|---|
tenantId(String) |
X-Tenant-Id |
Required by the service on every route. |
workspaceId(String) |
X-Workspace-Id |
Required by the service on every route. |
correlationId(String) |
X-Correlation-Id |
Usually rotated per call by the caller. |
agentToken(String) |
X-Agent-Token |
Only meaningful on /api/v1/agent/* routes. |
The SDK only emits a header when you set a non-blank value -- it does not pre-validate; the service rejects missing tenant / workspace at the boundary as a typed exception.
Advanced
| Setter | Description |
|---|---|
apiKey(String) |
Bearer token sent as Authorization: Bearer .... |
timeout(Duration) |
Read/connect timeout (default 60s). |
restClientBuilder(...) / webClientBuilder(...) |
Plug filters, observation hooks, or a custom connector. |
objectMapper(ObjectMapper) |
Override the default Jackson mapper. |
| Property | Default | Notes |
|---|---|---|
flycanon.base-url |
(required) | Root URL of the service. |
flycanon.api-key |
(empty) | Sent as Authorization: Bearer .... |
flycanon.tenant-id |
(empty) | Sent as X-Tenant-Id. |
flycanon.workspace-id |
(empty) | Sent as X-Workspace-Id. |
flycanon.correlation-id |
(empty) | Sent as X-Correlation-Id. |
flycanon.agent-token |
(empty) | Sent as X-Agent-Token. |
flycanon.timeout |
60s |
ISO-8601 duration. Applies to both clients. |
flycanon.auto-configure |
true |
Wire the blocking CanonClient bean. |
flycanon.reactive-auto-configure |
false |
Wire the ReactiveCanonClient bean. Requires spring-webflux. |
| Method | Description |
|---|---|
createWorkspace(WorkspaceCreate spec) -> WorkspaceSpec |
Create a workspace under the caller's tenant. |
listWorkspaces() -> List<WorkspaceSummary> |
List every workspace owned by the caller's tenant (created_at DESC). |
getWorkspace(String workspaceId) -> WorkspaceSpec |
Fetch a single workspace by id. |
updateWorkspace(String workspaceId, WorkspaceUpdate patch) -> WorkspaceSpec |
Sparse PATCH; only present fields are applied. |
closeWorkspace(String workspaceId) -> WorkspaceSpec |
Terminal lifecycle transition (status=closed). Idempotent. |
On ReactiveCanonClient the same five methods return Mono<...> /
Mono<List<...>>.
| Method | Description |
|---|---|
mintAgentToken(AgentTokenMintRequest req) -> AgentTokenCreated |
Mint a new token. Raw token is returned once. |
listAgentTokens() -> List<AgentTokenSummary> |
List tokens for the caller's tenant. Secret omitted. |
revokeAgentToken(String tokenId) -> void |
Revoke a token. Idempotent. |
Agent-tier callers (authenticated with X-Agent-Token) are refused
on the mint path with 403 agent_cannot_mint.
Every POST mandates a non-empty idempotencyKey. The SDK rejects
empty / whitespace values with IllegalArgumentException locally,
before the request goes out.
| Blocking method | Description |
|---|---|
agent().ingestSource(spec, idempotencyKey) -> SourceRecord |
Submit a source for intake. Scope: agent.sources:ingest. |
agent().getSource(sourceId) -> SourceRecord |
Read a source. Scope: agent.sources:read. |
agent().query(request, idempotencyKey) -> AnswerResponse |
Grounded RAG answer with citations. Scope: agent.query:run. |
agent().queryStreamUrl(idempotencyKey) -> String |
Validates the key + returns the SSE URL; stream from the reactive client or your own SSE consumer. |
agent().search(request, idempotencyKey) -> SearchResponse |
Hybrid retrieval, no LLM. Scope: agent.query:run. |
agent().getKnowledge(itemId) -> KnowledgeItem |
Fetch a knowledge item. Scope: agent.knowledge:read. |
agent().getProvenance(itemId) -> Provenance |
Citation graph for (itemId, current version). Scope: agent.knowledge:read. |
agent().proposeCandidates(request, idempotencyKey) -> List<CandidateRecord> |
Propose candidates from a source. Scope: agent.candidates:propose. |
The reactive client mirrors the same surface; queryStream(...)
replaces queryStreamUrl(...) and returns Flux<StreamFrame>
directly.
// Bulk + async + replace intake
Models.BulkSourcesResponse bulk = canon.submitSourcesBulk(List.of(p1, p2));
Models.IngestJob job = canon.submitSourceAsync(payload);
String sseUrl = canon.jobStreamUrl(job.id(), 0);
Models.SourceRecord updated = canon.replaceSource(sourceId, newPayload);
// Knowledge graph + diff
Models.KnowledgeDiff diff = canon.getDiff(itemId, 1, 2);
Models.RelationsList rels = canon.listRelations(itemId);
canon.addRelation(itemId, new Models.CreateRelationRequest(
otherId, "depends_on", null, null, null));
Models.KnowledgeGraph graph = canon.getGraph(Map.of("domain", "compliance"));
String mermaid = canon.getGraphMermaid(Map.of("domain", "compliance"));
// Conversations
Models.Conversation conv = canon.createConversation(
new Models.CreateConversationRequest("Onboarding", null));
Models.ConversationTurn turn = canon.addTurn(conv.id(),
new Models.CreateConversationTurnRequest(
"What about scope?", null, null, null));
Models.SuggestionsResponse suggestions = canon.suggestQuestions(conv.id());
// Quality scans
Models.StaleReport stale = canon.scanStale();
Models.ConflictScanResponse conflicts = canon.detectConflicts(
new Models.ConflictScanRequest("compliance", 0.85, 50, null));
// Billing + corpus inventory
Models.BillingSummary summary = canon.billingSummary(Map.of());
Models.TopConsumersReport top = canon.billingTop(Map.of("dimension", "model"));
Models.LatencyReport lat = canon.billingLatency(Map.of("group_by", "model"));
Models.CorpusStats snapshot = canon.stats();Every return is a Jackson-deserialised record (@JsonIgnoreProperties
keeps the SDK forward-compatible with new fields the service ships on
a minor version).
Same method names as CanonClient, but every unary call returns
Mono<T> and every SSE-backed call returns Flux<StreamFrame>. Use
from Spring WebFlux applications, or from anywhere you'd rather
compose retrievals into a reactive chain.
Streaming is the killer feature this variant unlocks:
// Live job progress.
canon.streamJob(jobId, 0)
.doOnNext(frame -> log.info("stage={} data={}", frame.event(), frame.data()))
.takeUntil(f -> "completed".equals(f.event()) || "failed".equals(f.event()))
.blockLast(); // or compose into a larger pipeline
// Token-by-token user-tier answer.
canon.streamAnswer(new Models.AnswerRequest(
"Summarise the scope section.", 8, null, null))
.map(frame -> (String) frame.data().getOrDefault("text", ""))
.doOnNext(System.out::print)
.blockLast();
// Agent-tier streamed answer (hit + final frames).
canon.agent()
.queryStream(new Models.AnswerRequest(
"What's our retention policy?", 8, null, null),
"stream-2026-05-22-001")
.doOnNext(frame -> log.info("event={}", frame.event()))
.blockLast();Manual construction stays available via
ReactiveCanonClient.builder(). Pass your own WebClient.Builder
via .webClientBuilder(...) to plug filters, retries, observation
hooks, or a custom ClientHttpConnector.
The five agent-tier POSTs (ingestSource, query, queryStream,
search, proposeCandidates) require the Idempotency-Key
header on the wire. The SDK exposes that as a mandatory positional
argument:
canon.agent().ingestSource(spec, "ingest-2026-05-22-001");Contract. The first call with a given key persists the response. Subsequent calls with the same key + same body return the same response body byte-for-byte (server-side dedup). Pass a different body with the same key and the service rejects the retry as a collision.
Local validation. The SDK rejects empty / whitespace keys with
IllegalArgumentException before the request goes out so you don't
pay a round-trip to discover you forgot to pass one.
Every non-2xx response is parsed as RFC 7807 ProblemDetails and
surfaced as a typed CanonAPIException subclass keyed by the
service's stable code field. All subclasses inherit from
CanonAPIException, so generic catch (CanonAPIException) keeps
working; catch the subclass to handle a specific failure mode.
| Exception | HTTP | code (RFC 7807) |
|---|---|---|
MissingIdempotencyKey |
400 | missing_idempotency_key |
ValidationError |
400 | invalid_request (carries errors()) |
MissingAgentToken |
401 | missing_agent_token |
InvalidAgentToken |
403 | invalid_agent_token |
AgentTokenExpired |
403 | agent_token_expired |
AgentWorkspaceNotInAllowlist |
403 | agent_workspace_not_in_allowlist |
AgentScopeDenied |
403 | agent_scope_denied |
AgentCannotMint |
403 | agent_cannot_mint |
CanonAPIException |
any | catch-all for unrecognised codes |
try {
canon.agent().ingestSource(spec, "k-001");
} catch (MissingIdempotencyKey ex) {
// ...
} catch (AgentScopeDenied ex) {
// ...
} catch (CanonAPIException ex) { // catch-all
log.error("flycanon error: {} {} -- {}",
ex.status(), ex.code(), ex.title());
}ValidationError (and any other typed exception that carries an
errors array) exposes per-field Models.FieldError(code, path, message) records via ex.errors() parsed from the
ProblemDetail.errors payload.
With the reactive client, the error is signalled through the
Mono / Flux -- compose with onErrorResume / onErrorReturn:
canon.agent().getKnowledge("missing-id")
.onErrorResume(CanonAPIException.class, ex ->
"knowledge_item_not_found".equals(ex.code())
? Mono.empty()
: Mono.error(ex));flycanon emits three event types on the canon.workspaces.v1 topic.
The SDK ships the typed records so consumers can deserialise event
payloads directly; the SDK itself does not subscribe -- wire the
topic with your own EDA stack (Kafka / RabbitMQ / etc.) and use the
records to parse payload bodies.
import com.firefly.flycanon.sdk.model.Models;
assert "canon.workspaces.v1".equals(Models.CANON_WORKSPACES_TOPIC);
// Inside your EDA consumer:
void handle(String json) throws Exception {
JsonNode node = mapper.readTree(json);
String eventType = node.get("event_type").asText();
switch (eventType) {
case "workspace.created" -> {
Models.WorkspaceCreated evt =
mapper.treeToValue(node, Models.WorkspaceCreated.class);
// ...
}
case "workspace.updated" -> {
Models.WorkspaceUpdated evt =
mapper.treeToValue(node, Models.WorkspaceUpdated.class);
// ...
}
case "workspace.deleted" -> {
Models.WorkspaceDeleted evt =
mapper.treeToValue(node, Models.WorkspaceDeleted.class);
// ...
}
default -> { /* ignore */ }
}
}Every event carries tenantId, workspaceId, occurredAt,
eventType, plus per-event payload fields. workspace.deleted
corresponds to POST /api/v1/workspaces/{id}:close -- flycanon's
terminal lifecycle transition (the row is preserved for audit; the
canonical event name matches the lifecycle vocabulary other services
use).
The SDK pins its version to the service's CalVer (YY.MM.PP), so
flycanon-sdk@26.5.7 is the matching client for service version
26.5.x. Upgrade the SDK in lockstep with the service. Both
clients (CanonClient, ReactiveCanonClient) ship from the same
artifact -- no separate dependency to manage.
- CHANGELOG.md -- wire-contract history per release.
openapi.json-- full route list (served by the running service at/openapi.json).- Consumers guide -- TBD, will live at
flycanon/docs/consumers.md.
Apache-2.0 -- see LICENSE.
