This service is Beta for early access testing.

← portal API documentation

status
On this page

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:

Concrete example. Given the AU eRequesting ServiceRequest.code element for imaging requests, which has a preferred binding to the RANZCR Radiology Referral ValueSet:

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:

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:

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"
}
FieldTypeRequiredDescription
textstringyesClinical text to encode
contextstringone of context/url requiredFHIR profile element with binding (e.g. StructureDefinition…#Condition.code)
urlstringone of context/url requiredValueSet canonical URL
systemstringnoCode system the result should be drawn from (e.g. http://snomed.info/sct, http://loinc.org). Default http://snomed.info/sct.
system_versionstringnoPin a specific code-system version, forwarded to the terminology server as system-version.
display_languagestringnoBCP-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_candidatesintnoMaximum 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_limitintnoMaximum 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_candidatesboolnoAlso 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"noHow 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"noWhat 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.

FieldDescription
matches[]Ranked candidates. matches[0] is the primary suggestion. May be empty if no plausible code exists.
matches[].confidence0.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[].reasoningHuman-readable explanation of why this code was selected.
matches[].fsnFully 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[].relevance0.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_reasoningWhy 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:

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:

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"
}
FieldTypeRequiredDescription
textstringyesThe original search text that was mapped
contextstringone of context/url requiredFHIR profile element with binding (same as find-code)
urlstringone of context/url requiredValueSet canonical URL (same as find-code)
systemstringnoThe 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_codestringunless no_correct_codeThe 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_systemstringnoCode system of chosen_code; defaults to system. Lets a correction cross code systems.
chosen_displaystringnoDisplay term for the chosen code
supplied_codestringnoThe code the service originally returned. When present and different from chosen_code, recorded as a negative example.
supplied_displaystringnoDisplay term for the supplied code
no_correct_codebooleannoAssert 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.

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

StatusBodyMeaning
200Result objectSuccess (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 context (<StructureDefinition>#<elementPath>) could not be resolved to a ValueSet binding. The response includes:

  • context — the context string as supplied
  • cause — one of:
    • sd_not_found — the StructureDefinition URL could not be fetched
    • element_not_found — the element path does not exist in the StructureDefinition's snapshot (common for datatype sub-elements; see below)
    • no_binding_at_element — the element exists but carries no ValueSet binding
  • detail — human-readable explanation
  • suggestion — recommended fix

Common case — datatype sub-element path. An element such as MedicationRequest.dosageInstruction.route is a sub-element of the Dosage datatype; its binding lives on the datatype definition, not on MedicationRequest directly. The resolver returns cause: "element_not_found" because that path doesn't appear in the MedicationRequest StructureDefinition snapshot. Fix: pass the bound ValueSet URL directly via the url parameter, or use an element path that carries a binding on the profile you are targeting.

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.

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.

EndpointReturnsUse 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:

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.