What the service does
The code-search service finds the right code (SNOMED CT, LOINC, etc) for clinical text,
constrained by a FHIR context parameter (e.g. StructureDefinition#element) or a
ValueSet URI. It layers intelligent matching over a FHIR terminology server's
ValueSet/$expand: deterministic fast-path matching for common cases, with LLM
evaluation and iterative search-term expansion for harder cases.
The intended use is to take clinical text whose meaning needs to be coded — for example, from a clinical note — and produce a code that satisfies the binding required by a FHIR resource element or your application's value set.
Meaning fidelity
The service picks the code whose meaning matches the input as closely as possible without adding meaning that isn't in the input. If the input says "thyroid scan" the service will not return a code that means "iodine-123 thyroid scan" — it would be inserting a method the clinician didn't write. If no exact-meaning code exists, the service falls back to the closest broader code that captures everything the input does say, and never to a narrower one. Returning a broader code is honest under-coding; returning a narrower code is fabrication.
Where a single code can't capture the full meaning, the response includes
intersection_codes — secondary codes whose meanings, combined with the
primary, encode what the text actually said. These are separate clinical concepts,
not alternate codings of the same concept; under FHIR all Coding entries within
a single CodeableConcept must represent the same concept (different terminology,
same meaning). Callers should map intersection codes to the appropriate FHIR element for each
— e.g. body site to Condition.bodySite, supporting evidence to
Condition.evidence, secondary findings to a separate Condition
resource — or, when the bound terminology supports it (SNOMED CT in particular), use a
post-coordinated expression to encode the compound meaning in a single Coding.
FHIR binding awareness
The service understands FHIR's binding strength
(required, extensible, preferred, example)
and additional bindings on the bound element. It respects what each strength
allows:
-
A
requiredbinding means "use a code from this ValueSet" — the service will not return a code outside it. If no in-VS match exists, the service returns no match rather than fabricating one. -
extensible/preferredbindings mean "use a code from this ValueSet if one fits; otherwise pick something appropriate from the same code system". The service falls back to a sensible broader hierarchy in that case — e.g. for a SNOMEDpreferredimaging-procedure binding, it'll search the SNOMED imaging-procedure hierarchy when the bound VS has nothing. -
binding.additionalentries (R5) are honoured by purpose:maximumcaps the fallback,requiredmeans the result must intersect with that VS too,preferred/extensibleare augmenting suggestions. The service merges candidates from all relevant bindings and ranks accordingly.
Concrete example. Given the AU eRequesting ServiceRequest.code
element for imaging requests, which has a preferred binding to the RANZCR
Radiology Referral ValueSet:
-
Input "Radionuclide thyroid scan" → the term isn't in the bound RANZCR VS.
A naive
$expandagainst that VS returns nothing. This service detects thepreferredstrength permits fallback, falls through to the SNOMED imaging hierarchy, finds385443001 Radionuclide thyroid imaging, and returns it — without choosing the more specific763810005 Iodine-123 radionuclide thyroid imaging, because the input never said iodine-123. - Input "Chest X-ray" on the same element → in-VS match is found directly; no fallback needed.
-
Same input on an element with a
requiredbinding to a small enum (e.g.Condition.clinicalStatus) → the service returns a code from the bound enum or no match at all.
Why use this instead of $expand directly?
A naive client can call ValueSet/$expand?filter=... and pick the first result.
That works for clean inputs against well-curated ValueSets. The cases this service handles
that $expand on its own does not:
-
Abbreviations and shorthand:
T2DM,HbA1c,FBE U&E LFT,EpiPen,Ventolin 100mcg. The service recognises these and resolves to the proper coded concept. -
Consumer language: "sugar diabetes" →
Diabetes mellitus; "ticker trouble" →Cardiac disorder; "blood thinners" → the anticoagulant concept. -
AU and UK spelling, regional terminology:
haemoglobinmatchesHemoglobin; "Pred" in an AU prescribing context resolves to Prednisolone (not Prednisone, the US convention). - Multi-code-system support: SNOMED CT, LOINC (RCPA SPIA pathology, AU Core diagnostic-result bindings), and small required-binding enums (status / criticality / intent) for AU Core and FHIR core profiles — all behind a single API.
- Binding-aware fallback (described above): respects the FHIR binding strength to decide whether to widen the search beyond the bound ValueSet.
- No overstepping: the result will not add subtype, method, laterality, or other detail the input didn't carry. Better to return a broader honest code than a narrower fabricated one.
- Returns nothing rather than guessing: when the input describes a patient theory or non-clinical etiology that no code can faithfully capture (e.g. "wifi triggering seizures", "tap water making me feel unwell"), the service returns an empty result rather than coding the symptom alone and mis-representing the input.
- Reasoning on every match: each returned code carries a human-readable explanation of why it was selected. Useful for audit, for clinician review, and for training data.
Authentication
All requests require a JWT bearer token issued by our authorization server.
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Since you're reading this you already have an account and are signed in. There are three routes to making authenticated calls, depending on the use case:
- Use the demo app. The interactive demo forwards the bearer token from your portal session on every request. It's the easiest way to experiment with the API against the bindings and ValueSets the portal pre-populates — no token handling on your part.
- Use your portal-session token directly for ad-hoc exploration or scripts run from your own machine. The same token your browser holds after signing in to the portal works as the bearer for direct REST requests. You can read it from the page's session storage if you want to drop it into a curl one-liner. Don't paste it into Claude Desktop or another MCP client — tokens shouldn't be persisted as plain config; the next route covers that case properly.
-
Connect an OAuth-aware MCP client (Claude Desktop, Claude Code, Cursor,
Cline) by pointing it at
https://code-search.australiaeast.cloudapp.azure.com/mcpBETA URL — the client will discover the auth requirements via the Protected Resource Metadata at/.well-known/oauth-protected-resourceand run its own OAuth flow against the same authorization server you signed into. You'll get a "log in" prompt the first time; from then on the MCP client carries its own token, separate from the portal session. No copy-paste. -
Request OAuth client credentials from
ontoserver-support@csiro.au for
system-to-system integrations. We'll issue a confidential client (client_id +
client_secret) so your service can mint its own tokens via the
client_credentialsgrant against the authorization server's token endpoint. Use this when the caller isn't a human in a browser session.
REST API
POST /api/v1/find-code
Find the best code for a clinical-text query.
Request body:
{
"text": "type 2 diabetes",
"context": "http://hl7.org.au/fhir/core/StructureDefinition/au-core-condition#Condition.code",
"max_candidates": 3,
"effort": "balanced"
}
| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | Clinical text to encode |
context | string | one of context/url required | FHIR profile element with binding (e.g. StructureDefinition…#Condition.code) |
url | string | one of context/url required | ValueSet canonical URL |
system | string | no | Code system the result should be drawn from (e.g. http://snomed.info/sct, http://loinc.org). Default http://snomed.info/sct. |
system_version | string | no | Pin a specific code-system version, forwarded to the terminology server as system-version. |
display_language | string | no | BCP-47 tag or weighted list (fr-CA, es;q=1,*;q=0) for returned displays and matching; overrides the Accept-Language header. Independent of system_version — language selects which descriptions are preferred, the edition selects which description sets exist at all, so a language the pinned edition lacks returns a language_not_honoured warning. Append *;q=0 for 422 instead of a substitute language. See docs/multi-language.md. |
max_candidates | int | no | Maximum number of entries in matches[] — the answer. Top-N by confidence, with ties at the boundary pulled in. Default 3. It does not bound candidates[]; use candidate_limit for that. Since the service settles on a single match for nearly every query, raising this rarely changes the response. |
candidate_limit | int | no | Maximum number of entries in candidates[] — the shortlist, and the other half of the pair above: max_candidates bounds the answer, candidate_limit bounds the alternatives. Unset means the service cap of 5. A cap, not a floor: a precise query returns fewer because few candidates clear the relevance bar, so a limit above what is available simply returns what exists rather than erroring. Applied after ranking, so it changes nothing about which codes are chosen or ranked, and two callers asking the same question with different limits still share one cached answer. |
include_candidates | bool | no | Also return the other candidates the search considered, ranked by relevance and filtered to genuinely plausible ones, each with a short reason it was not chosen. Off by default: it costs one extra call on a small model (about +700 ms p50). Does not affect which code is selected — the ranking runs after the answer is decided. |
effort | "fast" | "balanced" | "best" | no | How hard to try. fast = quick lookup, may bail early on hard cases. balanced (default) = full evaluation pipeline. best = more iterations on hard cases at the cost of latency. |
on_unknown_parameter | "ignore" | "error" | no | What to do with a request key this API does not recognise. ignore (default) drops it and lists it in ignored_parameters on the response. error rejects the request with 400 unknown_parameter. See Unknown request parameters. |
Response:
{
"matches": [
{
"code": "44054006",
"system": "http://snomed.info/sct",
"display": "Diabetes mellitus type 2",
"confidence": 0.95,
"reasoning": "exact match on preferred term"
}
],
"candidates": [
{
"code": "46635009",
"system": "http://snomed.info/sct",
"display": "Type 1 diabetes",
"relevance": 0.8,
"relevance_reasoning": "input did not state the type"
}
]
}
candidates is present only when include_candidates is set.
| Field | Description |
|---|---|
matches[] | Ranked candidates. matches[0] is the primary suggestion. May be empty if no plausible code exists. |
matches[].confidence | 0.0 to 1.0. Values ≥ 0.9 are typically usable without human review; values below 0.7 should be treated as suggestions and confirmed. |
matches[].reasoning | Human-readable explanation of why this code was selected. |
matches[].fsn | Fully Specified Name of the matched concept. Present when the code system defines one (SNOMED CT); absent for systems that do not (e.g. LOINC). When present, stable. |
intersection_codes[] | When a single code can't capture the full meaning, additional codes whose intersection with matches[0] represents the complete meaning. |
candidates[] | Only with include_candidates. Other candidates considered, most relevant first, never including the code already in matches. At most 5, or candidate_limit when set. Empty or absent when nothing else cleared the relevance bar — a short list is the honest answer, not a failure, and no setting lengthens it. |
candidates[].relevance | 0.0 to 1.0, the service's own judgement of how close this candidate was. Entries below 0.65 are dropped rather than returned. |
candidates[].relevance_reasoning | Why this candidate was not chosen, stated contrastively against the query (e.g. "input did not specify 'acute'", "family history, not the patient's condition"). |
ignored_parameters[] | Present only when the request carried one or more keys this API does not recognise, naming them. They had no effect on the result. See Unknown request parameters. |
warnings[] | Present only when something went wrong that didn't stop the search. Each entry has a stable type plus a human-readable message. On a 200 these mean the answer was computed over a reduced search space — weigh them before recording the result. A run where nothing could be evaluated is a 502, not a warning — see below. |
Empty matches[]: verdict or failure?
An empty matches[] on a 200 is an answer: the search space was complete
and nothing in it matched. That result is safe to record and is cached, so a repeat request
returns it immediately. A 200 may still carry warnings[] — those mean
the answer was computed over a reduced search space (a bound ValueSet that couldn't be
reached, a language that couldn't be honoured). Weigh those before recording, and note they are
never cached.
When nothing was evaluated at all, the response is 502
agentic_no_completion, not a 200. The model returned no parseable
answer, so no candidate was judged and there is no verdict to report — an empty
matches[] would be indistinguishable from "no code exists", which is a false and
auditable claim about clinical terminology. Retry the request; the failure is not cached, so a
retry re-runs the search rather than replaying it. The body carries the
agentic_no_completion warning for callers that branch on warning types. Over MCP the
same outcome comes back as a tool error rather than a result.
GET /api/v1/find-code
Same handler as POST, parameters in the query string. The endpoint accepts both forms because:
- POST is the canonical form for integrations: a JSON body, no URL length limits, easy to construct programmatically.
- GET exists for ad-hoc exploration and shareable debug links — paste a
curl --data-urlencodeline into a chat and the receiver can run it as-is, or open the URL in a browser.
Behaviour is identical between the two; choose by use case, not by capability.
curl examples BETA URL
The hostname below is for early-access testing only and will change before general availability.
# Replace $TOKEN with your bearer token
curl -s "https://code-search.australiaeast.cloudapp.azure.com/api/v1/find-code" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "type 2 diabetes",
"context": "http://hl7.org.au/fhir/core/StructureDefinition/au-core-condition#Condition.code"
}' | jq .
# GET equivalent
curl -s -G "https://code-search.australiaeast.cloudapp.azure.com/api/v1/find-code" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode "text=type 2 diabetes" \
--data-urlencode "context=http://hl7.org.au/fhir/core/StructureDefinition/au-core-condition#Condition.code" | jq .
Reporting feedback
After automapping clinical text to a code, callers can report the outcome so it can become training and evaluation data. Four actions fall out of the same endpoint:
- Accept — the service's code was correct; supply the same code as
chosen_code(omitsupplied_codeor set it equal). - Correct — the service returned a code but it was wrong; supply both
supplied_code(what the service said) andchosen_code(the right code). - Supply — the service returned nothing and a human provided the code; supply only
chosen_code. - Reject — the service returned a code but no code is correct for this binding; supply
supplied_codeand"no_correct_code": true, and omitchosen_code.
The fourth is the one to reach for when a confident answer should have been a refusal.
Reporting it as a correction naming some other code would be worse than silence
— it asserts a mapping you do not believe, into a store with no delete endpoint.
Because context/url is required, the claim stays scoped to one
ValueSet: “nothing in this binding is right”, not
“nothing anywhere is right”.
POST /api/v1/feedback
Authenticated (same bearer-token auth as find-code).
Request body:
{
"text": "T2DM",
"context": "http://hl7.org.au/fhir/core/StructureDefinition/au-core-condition#Condition.code",
"system": "http://snomed.info/sct",
"chosen_code": "44054006",
"chosen_display": "Diabetes mellitus type 2",
"supplied_code": "73211009",
"supplied_display": "Diabetes mellitus"
}
| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | The original search text that was mapped |
context | string | one of context/url required | FHIR profile element with binding (same as find-code) |
url | string | one of context/url required | ValueSet canonical URL (same as find-code) |
system | string | no | The code system that was searched (the supplied code’s system). Optional — defaults to chosen_system if omitted, else http://snomed.info/sct. Send it explicitly (alongside chosen_system) to record a genuine cross-system correction. |
chosen_code | string | unless no_correct_code | The human-confirmed correct code. Required except on a rejection; sending it together with no_correct_code is a 400, since the two contradict each other. |
chosen_system | string | no | Code system of chosen_code; defaults to system. Lets a correction cross code systems. |
chosen_display | string | no | Display term for the chosen code |
supplied_code | string | no | The code the service originally returned. When present and different from chosen_code, recorded as a negative example. |
supplied_display | string | no | Display term for the supplied code |
no_correct_code | boolean | no | Assert that no code in this binding is correct for text. Omit chosen_code when setting it. Yields kind: "no-code". |
Rejection example — a confident answer that should have been a refusal:
{
"text": "No",
"url": "http://snomed.info/sct?fhir_vs=isa/404684003",
"supplied_code": "232209000",
"supplied_display": "Nasal obstruction",
"no_correct_code": true
}
Response — 201 Created:
{
"id": "fb_01j8z...",
"kind": "correction"
}
kind is one of confirmed (supplied == chosen),
correction (supplied present but differs from chosen),
novel (no supplied code — human supplied where the service had nothing), or
no-code (the caller asserts no code in this binding is correct).
no-code is deliberately not spelled rejected: the
status field already uses that word for a curator's verdict on the row, and
both are query parameters on GET /api/v1/feedback.
MCP (Model Context Protocol)
The service exposes an MCP streamable-HTTP endpoint at /mcp with one tool:
find_code. Use it from any MCP-aware client (Claude Desktop, Claude Code, Cline, etc.).
Claude Desktop configuration BETA URL
Claude Desktop talks to MCP servers over stdio. To bridge that to our HTTP endpoint we use
the mcp-remote npm package — it runs in-process, handles OAuth Protected
Resource Metadata discovery, and pops a browser the first time you connect. Add to your
claude_desktop_config.json:
{
"mcpServers": {
"code-search": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://code-search.australiaeast.cloudapp.azure.com/mcp",
"--static-oauth-client-info",
"{"client_id":"code-search-mcp"}"
]
}
}
}
Save, fully quit Claude Desktop (⌘Q on macOS, not just close the window) and reopen.
The first time the model invokes find_code a browser tab opens against the
code-search portal for sign-in; after that the token is cached by mcp-remote
locally and refreshed automatically. No bearer token in the config file, ever.
The --static-oauth-client-info flag tells mcp-remote to use our
pre-registered code-search-mcp public client instead of attempting Dynamic
Client Registration (which the authorisation server doesn't expose to the public internet).
Other OAuth-aware MCP clients (Claude Code, Cursor, Cline) have native HTTP MCP support and
can point directly at https://code-search.australiaeast.cloudapp.azure.com/mcp
without the mcp-remote bridge — check each client's docs for the exact config
shape.
Try it
After Claude Desktop has connected and you've logged in, paste this into a new chat:
Use the code-search find_code tool to find a code for the following clinical text,
in the context of an AU Core Condition resource:
T2DM with diabetic retinopathy
The binding context for the resource element is
http://hl7.org.au/fhir/core/StructureDefinition/au-core-condition#Condition.code.
Show me the primary code, any intersection codes, and the reasoning.
Claude calls find_code, the service expands the abbreviation
(T2DM → type 2 diabetes), recognises that "diabetic retinopathy" is a
secondary clinical concept that doesn't fit in the same Condition.code, and
returns it as an intersection code. You'll see Claude reflect both back to you with the
SNOMED codes and a short explanation of how to model them on the FHIR resource.
The find_code tool
Same inputs as POST /api/v1/find-code:
text, context/url, system,
system_version, max_candidates, effort,
include_candidates, candidate_limit.
Output comes back in two parallel forms on the same MCP tool result. Clients pick whichever they prefer — there's no "mode" toggle.
-
Prose in
content[].text— what an LLM-driven client (Claude Desktop, Cursor, Cline) reads into its context window. Compact and direct. -
Structured JSON in
structuredContent— the samematches[]/intersection_codes[]shape as the REST response, for programmatic clients that prefer typed data.
Example tool result for "type 2 diabetes":
// content[0].text (what the LLM reads)
Found 44054006 — Diabetes mellitus type 2 (95% confidence)
Reasoning: exact match on preferred term
// structuredContent (programmatic access)
{
"matches": [
{
"code": "44054006",
"system": "http://snomed.info/sct",
"display": "Diabetes mellitus type 2",
"confidence": 0.95,
"reasoning": "exact match on preferred term"
}
]
}
Example for an empty-match case:
// content[0].text
No suitable code found for "wifi triggering seizures".
// structuredContent
{ "matches": [] }
The MCP endpoint advertises its protected-resource metadata at
/.well-known/oauth-protected-resource per RFC 9728 — clients that support
auth discovery will pick this up automatically.
For raw ValueSet/$expand browsing without LLM evaluation, use Ontoserver's
own MCP tools — this service deliberately doesn't duplicate that surface.
Error responses
| Status | Body | Meaning |
|---|---|---|
| 200 | Result object | Success (matches may be empty if no plausible code exists) |
| 400 | {"error":"validation_error","detail":...} | Invalid request shape |
| 400 | {"error":"unknown_parameter","unknown_parameters":["top_n"],"detail":"…","suggestion":"…"} | The request carried a key this API does not recognise, and set on_unknown_parameter: "error". Only returned when the caller opted in — see below. |
| 401 | {"error":"Unauthorized"} | Missing / invalid bearer token |
| 403 | {"error":"forbidden_no_role"} | Token is valid but doesn't grant access to this service |
| 404 | {"error":"valueset_not_found"} | The terminology server returned 404 for the resolved ValueSet URL — the ValueSet is genuinely unknown or unavailable. Distinct from 422 binding_not_resolved, where the problem is the context path not carrying a binding rather than the ValueSet itself being missing. |
| 422 | {"error":"binding_not_resolved","context":"…","cause":"…","detail":"…","suggestion":"…"} |
A profile
Common case — datatype sub-element path.
An element such as |
| 502 | {"error":"agentic_no_completion"} | The evaluator model returned no parseable answer, so no candidate was judged. Retryable, and not cached — a retry re-runs the search. Distinct from a 200 with empty matches[], which is a genuine no-match verdict. |
| 503 | {"error":"upstream_unavailable"} |
The terminology server was momentarily unreachable — a 502/503/504 from it, or a
network failure. Retryable, and nothing to do with your request. Carries a
Retry-After header (seconds), plus retryable: true,
upstream (the host) and upstream_status (what it returned) so you
can report whose outage it is. Branch on retryable rather than on the status
code if you want one test that covers future transient codes.
|
| 504 | {"error":"ontoserver_error"} | The terminology server answered, but with a non-transient server error (e.g. a bare 500). Not retryable in the way a 503 is — it usually recurs, and generally means a genuine fault rather than a blip. |
The bound ValueSets must be reachable.
When a request supplies a context or url, the service will not quietly answer
from whichever ValueSets happened to load. If any ValueSet named by the binding cannot be expanded —
the StructureDefinition isn't on the terminology server, or a ValueSet its binding names isn't loaded —
the request fails with 422 binding_not_resolved or 404 valueset_not_found rather
than returning a match. A result that the caller's binding never constrained is not a constrained result:
for an extensible LOINC binding whose ValueSet was missing, the broadening search alone would happily
return a SNOMED code, and nothing in a 200 would say so.
Set on_missing_valueset: "warn" to opt into a partial answer instead: the service searches
the ValueSets it can reach and reports the gap in warnings[]. This governs
additional and fallback ValueSets only — a missing primary ValueSet is
always an error, because without it nothing constrains the result. Transient failures (5xx, network) on
non-primary ValueSets stay warnings under either setting: they say nothing about whether the binding is
well-formed, and the right response is to retry.
Unknown request parameters
A request key this API does not recognise is dropped, not honoured. That has
always been true, but it used to be invisible: the request succeeded exactly as if the key had
never been sent. One integration sent top_n: 5 for months, believing it was getting
five results back.
Both halves of the fix are now available, and they serve different callers.
-
ignored_parameterson the response. When a request carries unrecognised keys, the response names them — on a200and on error responses alike. Present only when there is something to report, so its mere presence is the signal; there is no empty array to test for. Useful to a person debugging interactively. -
on_unknown_parameter: "error". Rejects the request with400 unknown_parameter, naming the offending keys underunknown_parameters. This is the one to set if nothing in your stack reads response bodies for fields it was not expecting — a new key in the JSON would sit unread for exactly as long as the silent drop did. Failing the call puts the signal where you are already looking. Safe to turn on in every environment; it is checked before any search runs, so a rejected request costs nothing.
The default is ignore, and deliberately so: rejecting unknown keys unconditionally
would fail every existing caller carrying a stray one on the day it shipped — including
the one that reported the problem. Note also that unknown parameters are not reported
in warnings[]: a result carrying warnings is never cached, so a single typo'd key
would quietly cost you the resolution cache on that query.
This applies to the REST API, on both POST (body keys) and GET (query
parameters). Over MCP there is nothing to report: the tool's own input schema is validated by
the MCP layer before the request reaches this service, so an unrecognised argument never
arrives.
Service status & health
Four public endpoints, none of which require a token — a status endpoint you need credentials for is no use to someone deciding whether it is worth signing in.
Which one you want
To decide whether a particular search succeeded, don't poll status at all.
Send the request and handle 503 upstream_unavailable with its
Retry-After. That is per-request truth; a status check taken beforehand can be
stale by the time your request lands, and adds a round-trip to every call. Poll
/status/summary only to render a health badge in your own UI.
| Endpoint | Returns | Use for |
|---|---|---|
GET /status/summary |
JSON: state ("ok" | "degraded"),
reason when degraded, and dependencies[] of
{name, label, status}. |
Programmatic checks and UI badges. Branch on state. |
GET /health/upstreams |
JSON: per-dependency status, latency_ms, target
host and a detail when down. |
Diagnosis — which dependency, how slow, what it said. |
GET /status |
HTML page. | A human looking at it. Link support tickets here. |
GET /health |
{"status":"ok","version":"..."} |
Liveness only. See the warning below. |
GET /metrics |
Prometheus text format. | Scraping. code_search_upstream_up{dependency} is 1 up / 0 down. |
Do not use /health as a readiness signal
/health answers 200 whenever the process can serve a request. It
deliberately checks no dependency, because the service's own Kubernetes liveness,
readiness and startup probes all point at it — making it dependency-aware would let an
upstream blip restart the pods and discard warm caches, turning someone else's brief outage
into a longer one here. A 200 from /health therefore means "the
process is alive", not "searches will work". Use /status/summary for the latter.
Both status endpoints always return 200
Including when degraded — the state is in the body, not the status code, for the same reason as above. Treat a non-200 or a timeout as unknown, not as healthy.
Polling and caching
Upstream probes are cached for about 10 seconds server-side, so polling faster than that
returns the same answer and gains nothing; 30 seconds is a sensible interval.
checked_at tells you how old the underlying probe is. A recovery can therefore
take a few seconds to show up here, while a real request would already be succeeding —
another reason to treat the per-request 503 as authoritative.
Stability
state, reason, and each dependency's name and
status are stable and safe to branch on. Dependency name values are
currently terminology, auth and llm; more may be added,
so treat the list as open and do not assume a fixed length. label,
latency_ms and detail are for display and may change wording.
Example
curl -s https://code-search.australiaeast.cloudapp.azure.com/status/summary
{
"state": "degraded",
"reason": "Upstream unavailable: Terminology server.",
"checked_at": "2026-09-15T03:44:00.000Z",
"dependencies": [
{ "name": "terminology", "label": "Terminology server", "status": "down" },
{ "name": "auth", "label": "Authentication", "status": "ok" },
{ "name": "llm", "label": "LLM provider", "status": "ok" }
]
}
Reporting issues
Email ontoserver-support@csiro.au. Include:
- Your token
sub(not the token itself) - Approximate timestamp of the request
- The full request body (or query string)
- What you got back vs. what you expected
For unexpected codes specifically, include the FHIR context URL plus what you'd consider the correct code — that's the most useful form of feedback.