sct serve
Run a lightweight, local, read-only SNOMED CT-focused subset of the FHIR R4 terminology service over a SQLite database. It is designed for development, testing, and small deployments that need the documented operations without Elasticsearch, a JVM, or a remote dependency.
Included by default
sct serve is compiled in by default (the serve Cargo feature is part of default), so it is present in the released binaries, cargo install sct-rs, and s/install. For a minimal build without the async HTTP stack, use --no-default-features.
When to use: a client can use the documented query-parameter forms of $lookup / $validate-code / $subsumes / $expand and you want sub-millisecond, offline, single-binary terminology. The entire server is backed by one inspectable snomed.db file. Standard POST Parameters bodies, inline terminology resources, and arbitrary transient code systems are not yet supported, so this is not currently a drop-in general terminology server or an approved HL7 Terminology Ecosystem server.
Usage
sct serve [--db <FILE>] [--port <PORT>] [--host <HOST>] [--fhir-base <PATH>] [--public-url <URL>] [--codelists <DIR>] [--cors-origin <ORIGIN>]...
| Flag | Default | Description |
|---|---|---|
--db <FILE> |
discovered (see Path resolution) | SQLite database produced by sct sqlite. |
--port <PORT> |
8080 |
TCP port to listen on. |
--host <HOST> |
127.0.0.1 |
Address to bind. Use 0.0.0.0 to accept remote connections. The server has no authentication, so binding beyond a loopback address (127.0.0.0/8, ::1, localhost) prints a startup warning - put your own network or auth controls in front before doing so. |
--fhir-base <PATH> |
/ |
Base path for all routes. Set to /fhir for Ontoserver-compatible URLs. |
--public-url <URL> |
bound listener URL | Externally reachable absolute HTTP(S) FHIR base URL used in metadata, derived ValueSet canonicals, and search-result fullUrl values. Credentials, invalid ports, queries, fragments, and dot path segments are rejected. Set this when binding a wildcard address or running behind a reverse proxy, for example https://fhir.example.org/fhir. |
--codelists <DIR> |
./codelists (or $SCT_CODELISTS / [codelists] dir) |
Directory of .codelist files to serve as named FHIR ValueSets. |
--fst <FILE> |
snomed.fst beside the database, if present |
FST index (from sct fst build) powering the GET /autocomplete endpoint. |
--read-only |
on | The server never writes; the flag documents that intent. |
--cors-origin <ORIGIN> |
none (repeatable) | Send CORS headers for this origin, so a browser page served from it can call this server directly. See CORS for direct browser access. |
# Local dev server
sct serve --db snomed.db
# Ontoserver-compatible base path behind a public reverse proxy
sct serve --db snomed.db --host 0.0.0.0 --port 8080 --fhir-base /fhir \
--public-url https://fhir.example.org/fhir
Responses are application/fhir+json. An Accept header that requests XML exclusively gets a 406 (XML is not supported).
Search-as-you-type endpoint
With an FST index available (--fst, or a snomed.fst beside the database), the server also exposes a non-FHIR GET /autocomplete?q=<partial>&count=<n> endpoint - sub-millisecond, typo-tolerant autocomplete backed by the FST index, for a web front-end to hit per keystroke:
sct serve --db snomed.db --fst snomed.fst
curl 'http://localhost:8080/autocomplete?q=myocard&count=5'
# {"query":"myocard","hits":[{"id":"22298006","display":"Myocardial infarction","score":0.77,"tag":"disorder"}, ...]}
id is a JSON string (SCTIDs exceed 2^53). Without an FST index the endpoint returns 501. It shares its engine with sct sayt, which also offers an interactive TUI and a stdio line protocol over the same index.
Docker Compose
For a full self-host walkthrough - a caddy reverse proxy in front of sct
for automatic HTTPS, optional basic auth, CORS, and the bootstrap/config
reference - see Get your own terminology server.
CORS for direct browser access
sct serve itself sends no CORS headers by default, deliberately - it defaults to loopback, with no authentication, and a real deployment fronts it with the Caddy layer above (Caddyfile, CORS_ORIGINS), which already supplies CORS. That leaves a browser page unable to call sct serve directly without that proxy in front.
--cors-origin <ORIGIN> (repeatable) closes that gap for direct access: each matching request gets that exact origin echoed back in Access-Control-Allow-Origin plus Vary: Origin, and an OPTIONS preflight is answered with Access-Control-Allow-Methods: GET, POST, OPTIONS and Access-Control-Allow-Headers: Content-Type, Accept - the same values the Caddy snippet sends, so a client behind either layer sees the same answer. Pass the literal * to opt in to a bare Access-Control-Allow-Origin: * for every origin instead of naming one. An Origin that matches nothing gets no CORS headers and no error status - the request itself still succeeds; the browser is left to block it client-side, the normal CORS failure mode. Access-Control-Allow-Credentials is never sent, since the server has no authentication.
# Reach sct serve directly from a browser page on https://example.org
sct serve --db snomed.db --cors-origin https://example.org
The Caddy layer remains the right answer for a real deployment; reach for this flag only when a browser client needs to call sct serve directly, without that proxy in front.
Operations (Phase 1)
| Endpoint | What it does |
|---|---|
GET /metadata |
CapabilityStatement declaring the supported operations (add ?mode=terminology for a TerminologyCapabilities statement) |
CodeSystem/$lookup |
Concept details: display, designations (FSN + synonyms), parents, children, ancestors, inactive, moduleId, effectiveTime |
CodeSystem/$validate-code |
Whether a code exists (and an optional display matches) |
CodeSystem/$subsumes |
Subsumption between two codes (subsumes / subsumed-by / equivalent / not-subsumed) |
GET /CodeSystem |
Searchset Bundle wrapping the single SNOMED CT CodeSystem resource this server serves |
GET /CodeSystem/{id} |
The SNOMED CT CodeSystem resource metadata (content: not-present - concepts are reached via $lookup/$expand, not embedded here) |
ValueSet/$expand |
Expand by free-text filter (FTS5), ECL, or a stored .codelist (by canonical URL) |
ValueSet/$validate-code |
Whether a code is a member of a ValueSet (stored .codelist or implicit ECL) |
GET /ValueSet |
Searchset Bundle of the stored .codelist ValueSets |
GET /ValueSet/{id} |
The stored ValueSet resource (with compose) |
GET /ValueSet/{id}/$expand |
Expand a stored ValueSet by id |
ConceptMap/$translate |
Map a code across terminologies when the loaded database has crossmaps data (SNOMED CT ↔ ICD-10 / OPCS-4 / CTV3 / Read v2) |
POST / (batch) |
A FHIR batch Bundle of $lookup, $validate-code, $subsumes, $expand, or $translate operation calls, executed in one request |
FHIR operation endpoints shown without an explicit method accept both GET and POST. The resource routes explicitly marked GET are GET-only. Operation parameters are always read from the query string, including on POST - this server does not accept a Parameters resource as a request body. Both ValueSet/$expand and GET /ValueSet/{id}/$expand reject non-whitespace request bodies with HTTP 400 rather than ignoring them, because discarded inputs can change the requested value set. The stored-ID route remains GET-only.
For the same reason, $expand refuses valueSet (inline definitions), valueSetVersion, context, date, exclude-system, and force-system-version with HTTP 400 instead of ignoring them: each one narrows or redirects an expansion, so silently dropping it widens the result. R4 sanctions this directly for date, which says the server should honour it "or return an error if this is not possible". system-version is treated as equivalent to check-system-version, since an implicit SNOMED ValueSet never specifies its own version.
check-system-version, system-version, $lookup's version, and ValueSet/$validate-code's systemVersion pin the request to a specific SNOMED CT release: a value that disagrees with what's loaded is refused with HTTP 400, naming the parameter the client actually sent. An empty (or whitespace-only) version part - ?version=, or a trailing-pipe canonical such as check-system-version=http://snomed.info/sct| - states no requirement at all and expands or looks up exactly as if the parameter had been omitted.
These refusals apply equally to type-level expansion (including a stored canonical URL), stored-ID expansion, and supported batch expansion entries. A refused parameter is rejected even when its value is empty, before pagination or value-set resolution; the response is an OperationOutcome, never an expansion.
Transitive closure fallback
At startup, sct serve checks whether the database has a usable transitive closure table: a transactionally completed table with the expected schema, indexes, and source/closure invalidation triggers. Ancestor lookup, subsumption, and hierarchy ECL expansion use indexed TCT queries when usable and correct recursive-CTE fallbacks otherwise. A missing, legacy, stale, or damaged TCT produces the shared build-or-repair instruction on server stderr, never inside a FHIR response; run sct tct --db <db> to remove the warning and accelerate those operations.
Batch requests
POST a FHIR batch (or transaction) Bundle to the base path to run many operations in one round trip - handy for a client that would otherwise fire dozens of sequential $lookup / $validate-code / $translate calls. Each entry's request.url is a GET operation URL; the response is a batch-response Bundle with one entry per request (in order), each carrying an HTTP response.status and the result resource (or an OperationOutcome for that entry). Entries succeed or fail independently. The server is read-only, so entries must use GET. Bundles are limited to 100 entries, and every HTTP request has a 30-second response timeout.
An entry containing resource is refused with a per-entry HTTP 400 OperationOutcome, rather than silently discarding its inline input. This applies to every supported operation and leaves valid sibling entries unaffected. Supply parameters in request.url; batch expansion uses ValueSet/$expand?url=..., not the unsupported instance-level batch path.
curl -X POST 'http://localhost:8080/fhir' -H 'Content-Type: application/fhir+json' -d '{
"resourceType": "Bundle", "type": "batch",
"entry": [
{ "request": { "method": "GET", "url": "CodeSystem/$lookup?system=http://snomed.info/sct&code=22298006" } },
{ "request": { "method": "GET", "url": "CodeSystem/$subsumes?system=http://snomed.info/sct&codeA=46635009&codeB=73211009" } }
]
}'
$expand and ECL
$expand accepts the FHIR implicit SNOMED ValueSet URL. The text filter runs over FTS5; the ecl/ form runs the shared sct ECL engine and therefore supports its documented subset: hierarchy (<<, <!, >>, >!), refset membership (^), boolean (AND/OR/MINUS), and ungrouped attribute refinement (:). ECL and filter combine (intersection).
# Subtypes of Diabetes mellitus (URL-encoded ECL "<<73211009")
curl 'http://localhost:8080/ValueSet/$expand?url=http://snomed.info/sct?fhir_vs=ecl/%3C%3C73211009'
# Free-text expansion
curl 'http://localhost:8080/ValueSet/$expand?url=http://snomed.info/sct?fhir_vs&filter=heart+attack&count=10'
# Attribute refinement (finding site)
curl 'http://localhost:8080/ValueSet/$expand?url=http://snomed.info/sct?fhir_vs=ecl/%3C%3C404684003%20:%20363698007%20=%20%3C%3C39057004'
count (default 100, max 1000) and offset paginate; malformed or negative values return HTTP 400, and the expansion.total reflects the full match set. includeDesignations=true adds direct FHIR designation objects for each entry's FSN and synonyms. activeOnly (default true) filters the expansion to active concepts; pass activeOnly=false to also see retired concepts (e.g. to look up a code from an old record). activeOnly applies to the implicit SNOMED ValueSet only - a stored .codelist ValueSet's fixed member list always returns every member regardless of this parameter.
designation (repeatable) selects which designations come back, once designations are included - it doesn't change which concepts are returned. Each value is either system|code or a bare code (900000000000003001 for the FSN, 900000000000013009 for a Synonym), * to request everything, or a BCP-47 language tag. Passing designation at all is honoured as includeDesignations=true, even without that parameter or with it set to false - matching the R4 operation definition. Because sct loads a single English locale per database, an en/en-* language tag matches every designation and any other language tag matches none, since there is no other locale to return.
The supported implicit SNOMED ValueSet URL forms are ?fhir_vs (the whole code system), ?fhir_vs=ecl/[ecl], ?fhir_vs=isa/[sctid] (equivalent to ecl/<<[sctid], i.e. including the concept itself), ?fhir_vs=refset/[sctid] (equivalent to ecl/^[sctid]), and ?fhir_vs=refset - the set of all reference sets. The last is a different kind of query, not an ECL reduction: it is answered from the same query as sct refset list, every reference set with at least one member loaded in this edition, and honours filter, count/offset, and the designation controls like any other expansion (activeOnly does not apply, since the listed set is already restricted to reference sets with loaded membership). A url that is neither a stored ValueSet nor a recognised implicit form returns HTTP 404; an unrecognised fhir_vs form returns HTTP 400 naming the supported forms. Neither case silently falls back to expanding the whole code system.
includeDefinition=true returns the value set's definition alongside its expansion. For a stored .codelist ValueSet that is its own compose (the enumerated concepts); for an implicit SNOMED ValueSet it is the R4 SNOMED template - compose.include.filter with property=constraint, op==, and the ECL as the value, or the whole code system with no filter for the bare ?fhir_vs form. The definition never overwrites the expansion's own fields. No version is emitted: SNOMED's URI specification requires the full http://snomed.info/sct/[sctid]/version/[YYYYMMDD] form and says a bare release date is not safe to publish as one, and sct records the release date but not the edition's module SCTID - so it omits the field rather than emit a non-conformant one. Use /metadata or $lookup to discover the loaded release.
check-system-version=http://snomed.info/sct|<version> lets a client refuse terminology from any other release. sct serves exactly one SNOMED CT release per process, so the pin is compared against the loaded release (its recorded release date, as shown by /metadata and $lookup): a match expands normally, and a mismatch returns HTTP 400 with an OperationOutcome naming both versions instead of an expansion. A pin naming any other code system is ignored, since no other system contributes codes here, as is a bare system with no |version. If the database records no release version the pin cannot be verified, and the request fails rather than silently serving terminology of unknown vintage - which is the failure the parameter exists to prevent. Expansions that don't pass the parameter are unaffected.
excludeNested=true is accepted and has no effect: sct never emits expansion.contains entries nested inside other contains entries (SNOMED CT has no FHIR-style abstract grouping-only concepts - every concept is a real, selectable one), so every expansion is already flat whether or not a client asks for that.
displayLanguage is resolved rather than routed: a loaded database bakes in one English dialect's preferred terms at build time (sct ndjson --locale) and does not record which, so a request whose language is English (en, en-GB, en-US, ...) is honoured verbatim and anything else falls back to bare en - the only language sct ever loads. Either way the resolved value is reported back as an expansion.parameter entry named displayLanguage, so a client can tell whether its request was actually honoured; the parameter is omitted when displayLanguage wasn't requested at all.
Stored ValueSets from .codelist files
Point --codelists <dir> (default ./codelists) at a directory of .codelist files and the server exposes each as a named FHIR ValueSet. Composition is resolved at startup, so a list that includes: others is served as its full effective member set. A list whose front-matter id is not safe as both a FHIR logical id and resource URL segment (1-64 ASCII letters, digits, -, or ., excluding . and ..) is skipped with a startup warning rather than interpolated into a resource URL. Security model: "public by placement" - only files in the served directory are exposed; keep private lists elsewhere. Expansion reconciles each concept's display against the live database (falling back to the stored term for concepts absent from the loaded edition).
sct serve --db snomed.db --codelists ./codelists &
# List the served ValueSets
curl 'http://localhost:8080/ValueSet'
# Read one (full resource with compose.include.concept)
curl 'http://localhost:8080/ValueSet/diabetes'
# Expand it (by id, or by its canonical URL via $expand?url=...)
curl 'http://localhost:8080/ValueSet/diabetes/$expand?count=20'
# Validate membership
curl 'http://localhost:8080/ValueSet/$validate-code?url=http://localhost:8080/ValueSet/diabetes&code=46635009'
The canonical URL of a served list is {server-base}/ValueSet/{id}, unless the .codelist front-matter sets an explicit canonical_url - then that value is used verbatim instead, so a list mirroring a value set already published elsewhere (an NHS or vendor canonical) keeps the same identity regardless of which sct serve instance hosts it. This is the same override sct codelist export --format fhir-json honours, so the exported and served forms never diverge. $validate-code also works against an implicit ECL value set (?url=http://snomed.info/sct?fhir_vs=ecl/...).
GET /ValueSet optionally filters by ?status= (draft | active | retired | unknown, the FHIR ValueSet.status value set) in addition to the existing ?url= and ?_id= filters, so a client can list only published lists or only drafts:
# Only lists not yet promoted out of draft
curl 'http://localhost:8080/ValueSet?status=draft'
Cross-terminology translation (ConceptMap/$translate)
Map a code between SNOMED CT, ICD-10, OPCS-4, CTV3, and Read v2 using the same maps as sct map. $translate is advertised in /metadata only when the loaded SQLite database has the crossmaps table. sct trud download --multi-terminology builds the full map set; manually, ICD-10 / OPCS-4 need sct ndjson --refsets all, and Read v2 needs sct read2 import over TRUD item 9.
# SNOMED CT -> ICD-10
curl 'http://localhost:8080/ConceptMap/$translate?system=http://snomed.info/sct&code=22298006&targetsystem=http://hl7.org/fhir/sid/icd-10'
# Bare names also accepted; reverse works too (ICD-10 -> SNOMED CT).
# ICD-10 input is tolerant of the undotted form (I219 as well as I21.9).
curl 'http://localhost:8080/ConceptMap/$translate?system=icd10&code=I219&targetsystem=snomed'
Returns a Parameters resource with result (boolean) and a match part per mapping. This is a drop-in target for the existing DMWB Excel add-in, which can point at a FHIR server - giving analysts the familiar worksheet workflow on a fast, offline backend.
Each match also carries an equivalence code reflecting the ICD-10/OPCS-4 map's RF2 correlationId, when the source data records one: equivalent (exact match), wider (target broader than source), narrower (target narrower than source), or inexact (partial overlap). relatedto is the fallback for CTV3/Read v2 (which carry no correlation) and for a correlation the release records as unspecified.
Distinct correlation claims for the same SNOMED pivot and target are retained as separate matches; identical claims are deduplicated. The deterministic result order is not a ranking or a choice of the preferred assertion. Code-only mapping results (sct map, SDK, Python and MCP) still return one row per pivot/target pair. Correlation reporting describes the recorded outgoing SNOMED-to-classification map; it does not compose equivalence across multiple mapping steps or invert it for reverse lookups.
Examples
sct serve --db snomed.db &
curl 'http://localhost:8080/metadata'
curl 'http://localhost:8080/CodeSystem/$lookup?code=22298006&property=parent&property=designation'
curl 'http://localhost:8080/CodeSystem/$validate-code?code=22298006'
curl 'http://localhost:8080/CodeSystem/$subsumes?codeA=46635009&codeB=73211009'
curl 'http://localhost:8080/CodeSystem/sct'
Errors are FHIR OperationOutcome resources with the appropriate status (404 unknown code, 400 invalid parameter, 406 XML requested, 500 server error).
Scope and limitations
This is Phase 1. Known boundaries (see spec/commands/serve.md for the full picture):
- Single edition / single version per process - the server serves whatever is in
--db.CodeSystem/$lookup'ssystemandversionparameters are checked against the loaded release rather than logged and ignored: asystemother than SNOMED CT, or aversionthat doesn't match what's loaded, is refused with a400rather than silently answered from whatever is loaded.$expand's equivalentcheck-system-version/system-versionparameters, and$validate-code'surl/systemandversion/systemVersionparameters (both theCodeSystemandValueSetforms), all behave the same way. - Stored ValueSets come from
.codelistfiles (read-only, served from--codelists); there is no write/CRUD API for ValueSets, and no storedConceptMapresources.$closure, multi-version routing, and FHIR R5 are later phases. - FHIR interoperability is deliberately scoped - operation endpoints accept GET or POST, but inputs are currently read from the query string and a non-empty POST
Parametersbody is refused. The repository regression profile is not HL7 certification; see FHIR conformance and benchmarks for the independent evidence layers and current baseline. ^(refset) ECL depends on refsets being loaded (sct ndjson --refsets simple+sct sqlite); attribute refinement depends on the schema-v4concept_relationshipstable (rebuild with a currentsct).- No auth / SMART on FHIR - run it behind your own gateway if exposing it beyond localhost.
- JSON only - XML requests get a
406.