Documentation
From question to cited answer
DermLiving serves dermatology reference knowledge over REST, typed TypeScript and Python clients, and an MCP server for agents. Start in the sandbox on synthetic data, with no key: every answer arrives with its sources, or abstains with a reason.
Quickstart
Every call below runs in the sandbox, on synthetic data, and needs no key: send it without an Authorization header. Three steps take you to an answer whose every claim cites the passage behind it.
Pick a client.
Every route is plain HTTPS, so curl works today. The TypeScript and Python clients below aren't on npm or PyPI yet.
Ask a reference question.
One call returns an answer with claim-level citations, pinned to knowledge version
kv_2026_10_01.1curl https://api.dermliving.com/v1/knowledge/ask \ 2 -H "DermLiving-Version: 2026-10-01" \ 3 -H "Content-Type: application/json" \ 4 -d '{ 5 "question": "Where does atopic dermatitis usually appear in adults?", 6 "knowledge_version": "kv_2026_10_01" 7 }'Read the citations.
Each citation names its passage, the source version, a locator and a checksum, so your product can show or verify the exact text behind every claim.
Response200 · OK, grounded { "data": { "status": "grounded", "answer": "In adults, atopic dermatitis favors flexural skin, the neck, face and hands. [1] Chronic scratching at these sites commonly causes lichenification. [2]", "citations": [ { "id": "cit_1", "evidence_id": "ev_7Hq2", "source": { "id": "src_sbx_ad_overview", "version": "sv_2026_09_14" }, "locator": "Distribution, para 2", "checksum": "sha256:b2bb063ee133", "license": "lic_sbx_synthetic" }, { "id": "cit_2", "evidence_id": "ev_9Kd4", "source": { "id": "src_sbx_ad_overview", "version": "sv_2026_09_14" }, "locator": "Morphology, para 1", "checksum": "sha256:fa16ed7145ce", "license": "lic_sbx_synthetic" } ], "claims": [ { "id": "clm_1", "text": "In adults, atopic dermatitis favors flexural skin, the neck, face and hands.", "citations": ["cit_1"] }, { "id": "clm_2", "text": "Chronic scratching at these sites commonly causes lichenification.", "citations": ["cit_2"] } ], "removed_claims": [] }, "meta": { "request_id": "req_CZiWeuISsO", "knowledge_version": "kv_2026_10_01" } }
Out of scope, it abstains. A question no passage supports, or one that asks for a diagnosis or a treatment, returns "status": "abstained" with a reason and no citations. That is a 200, not an error: clinical decisions stay with qualified professionals.
Authentication
The sandbox needs no key. A request without an Authorization header reads synthetic data, rate limited per client address.
curl https://api.dermliving.com/v1/conditions/cond_atopic_dermatitis
A key, when you have one, goes in the Authorization header as a bearer token and carries its own scopes. Keys are secrets: call DermLiving from your server, never from a browser or a mobile app.
curl https://api.dermliving.com/v1/conditions/cond_atopic_dermatitis \
-H "Authorization: Bearer $DERMLIVING_API_KEY"
A key you send must be valid. A malformed, unknown or revoked key gets 401 INVALID_API_KEY, never the keyless sandbox; to use the sandbox, omit the header. Each route also requires one scope. A request without it gets 403 INSUFFICIENT_SCOPE, and the message names the scope it needed.
| Scope | Grants |
|---|---|
conditions:read | List and retrieve conditions. |
terminology:read | Map text and codes to concepts. |
evidence:read | Search and retrieve evidence and sources. |
evidence:full_text | Full passage text. A licensed entitlement on paid plans. |
knowledge:query | Ask questions and list knowledge versions. |
agents:read | List the agent tool catalog. |
agents:execute | Execute catalog tools. Each tool also checks its own scope. |
Keyless requests hold every scope except evidence:full_text. Full passage text needs that entitlement, which isn't issued yet; without it, include_full_text: true gets 403 INSUFFICIENT_SCOPE.
Environments
One host, https://api.dermliving.com/v1, serves both environments. The key decides which data a request reads, and no key reads the sandbox. The same code runs in both, so switching is a change of key, not of URL.
| Environment | Keys | Data |
|---|---|---|
| Sandbox | None, or dl_test_ | Synthetic records from the DermLiving Sandbox Reference. Free. |
| Production | dl_live_ | The licensed sources your plan includes. |
Test and live data never mix: a request is answered only from its own environment, and every response's meta says which knowledge version answered it.
Send reference questions, never patient records. A question that contains identifiers abstains with patient_data, and nothing is stored.
TypeScript SDK
@dermliving/sdk is generated from the same OpenAPI contract as the API reference. Each route is one typed method. Fields are camelCase in TypeScript and snake_case on the wire.
Not on npm yet: until it is, call the routes over HTTPS.
Create a client and call it
With no key, the client reads the keyless sandbox. To send a key, pass apiKey or set DERMLIVING_API_KEY; its prefix decides the environment.
import { DermLiving } from "@dermliving/sdk";
const derm = new DermLiving();
const condition = await derm.conditions.get("cond_atopic_dermatitis");
console.log(condition.name, condition.codes); // ICD-10-CM L20.9, ICD-11 EA80.Z
const term = await derm.terminology.map({ text: "eczema" });
if (term.ambiguous) console.log(term.note); // flagged, never guessed
const answer = await derm.knowledge.ask({
question: "Where does atopic dermatitis usually appear in adults?",
knowledgeVersion: "kv_2026_10_01",
});
console.log(answer.status, answer.citations.length);
const run = await derm.agents.execute({ tool: "map_terminology", arguments: { text: "AD" } });
console.log(run.traceId);
Methods
| Method | Route |
|---|---|
derm.conditions.list() | GET/conditions |
derm.conditions.get() | GET/conditions/{id} |
derm.terminology.map() | GET/terminology/map |
derm.evidence.search() | POST/evidence/search |
derm.evidence.get() | GET/evidence/{id} |
derm.sources.get() | GET/sources/{id} |
derm.knowledge.ask() | POST/knowledge/ask |
derm.knowledge.versions() | GET/knowledge/versions |
derm.agents.tools() | GET/agent-tools |
derm.agents.execute() | POST/agent-tools/execute |
Handle errors
A failed call throws DermLivingError with code, status and requestId. Retryable errors (429 and 500) are retried with backoff and jitter first, so what reaches your code needs a decision: branch on code.
import { DermLivingError } from "@dermliving/sdk";
try {
await derm.conditions.get("cond_unknown");
} catch (err) {
if (!(err instanceof DermLivingError) || err.code !== "NOT_FOUND") throw err;
console.log(err.status, err.requestId); // 404 req_…
}
Paginate
Await a list call for one page. Iterate it with for await to walk every page; the client follows nextCursor for you.
// One page: data, hasMore and nextCursor
const page = await derm.conditions.list({ query: "eczema", limit: 20 });
console.log(page.data.length, page.hasMore, page.nextCursor);
// Every page: the client follows nextCursor for you
for await (const condition of derm.conditions.list({ limit: 100 })) {
console.log(condition.id, condition.name);
}
Python SDK
dermliving mirrors the TypeScript client: the same resources and the same methods, with snake_case arguments and fields.
Not on PyPI yet: until it is, call the routes over HTTPS.
Create a client and call it
DermLiving() with no key reads the keyless sandbox. To send a key, pass api_key or set DERMLIVING_API_KEY; its prefix decides the environment.
from dermliving import DermLiving
derm = DermLiving()
condition = derm.conditions.get("cond_atopic_dermatitis")
print(condition.name, condition.codes) # ICD-10-CM L20.9, ICD-11 EA80.Z
term = derm.terminology.map(text="eczema")
if term.ambiguous:
print(term.note) # flagged, never guessed
answer = derm.knowledge.ask(
question="Where does atopic dermatitis usually appear in adults?",
knowledge_version="kv_2026_10_01",
)
print(answer.status, len(answer.citations))
run = derm.agents.execute(tool="map_terminology", arguments={"text": "AD"})
print(run.trace_id)
Handle errors
A failed call raises DermLivingError with code, status and request_id, after retryable errors have been retried with backoff.
from dermliving import DermLivingError
try:
derm.conditions.get("cond_unknown")
except DermLivingError as err:
if err.code != "NOT_FOUND":
raise
print(err.status, err.request_id) # 404 req_…
Paginate
A list call returns its first page. Iterating it walks every page.
# One page: data, has_more and next_cursor
page = derm.conditions.list(query="eczema", limit=20)
print(len(page.data), page.has_more, page.next_cursor)
# Every page: iterating follows next_cursor for you
for condition in derm.conditions.list(limit=100):
print(condition.id, condition.name)
MCP server
The MCP server gives agents the same knowledge as 5 read-only tools. It's a remote server at https://api.dermliving.com/mcp (streamable HTTP), and it needs no key for the sandbox. Add it to your MCP client's config; to send a key, add an Authorization header there.
{
"mcpServers": {
"dermliving": {
"type": "http",
"url": "https://api.dermliving.com/mcp"
}
}
}
| Tool | Scope | Returns |
|---|---|---|
search_conditions | conditions:read | Find conditions by name, synonym or code. |
get_condition | conditions:read | Fetch a structured condition record. |
map_terminology | terminology:read | Resolve text or codes to concepts. |
search_evidence | evidence:read | Retrieve ranked passages with provenance. |
ask_knowledge | knowledge:query | Answer with claim-level citations, or abstain. |
Every call is checked against the caller's scopes before it runs. A call outside them is refused with INSUFFICIENT_SCOPE and written to the trace, like every call. No tool has side effects.
The same tools are REST routes: list them with GET /agent-tools and run one with POST /agent-tools/execute.
Errors
Every error has the same envelope. Branch on code, which is stable; message is written for people and can change.
{
"error": {
"type": "not_found",
"code": "NOT_FOUND",
"message": "No condition with id cond_unknown.",
"request_id": "req_IhSMcEaAyU",
"retryable": false,
"status": 404
}
}
| Field | Meaning |
|---|---|
type | The code in lowercase, for logs and metrics. |
code | A stable, machine-readable code. Branch on this. |
message | What went wrong, in one sentence. |
request_id | This request's id, also sent as the x-request-id header. |
retryable | true when the same request can succeed later. |
status | The HTTP status, repeated in the body. |
| Status | Code | Retry | When |
|---|---|---|---|
| 400 | VALIDATION_ERROR | No | A parameter is missing, the wrong type or out of range. |
| 401 | INVALID_API_KEY | No | A key was sent but is malformed, unknown or revoked. Send no Authorization header for the keyless sandbox. |
| 403 | INSUFFICIENT_SCOPE | No | The caller (keyless, or a key) lacks the route's scope or an entitlement such as evidence:full_text. |
| 404 | NOT_FOUND | No | No record with that id exists in the requested knowledge version. |
| 404 | ROUTE_NOT_FOUND | No | No route matches the method and path. |
| 404 | TOOL_NOT_FOUND | No | The tool is not in the catalog. |
| 404 | VERSION_NOT_FOUND | No | The pinned knowledge_version does not exist. |
| 429 | RATE_LIMITED | Yes | Too many requests. Wait for Retry-After seconds. |
| 500 | INTERNAL_ERROR | Yes | Something failed on our side. Retry with backoff; the request id is in the body. |
Pagination
List routes are cursor-paginated. Pass limit, then send each page's next_cursor back as cursor until has_more is false.
curl "https://api.dermliving.com/v1/conditions?limit=20&cursor=$NEXT_CURSOR"
| Field | Type | Meaning |
|---|---|---|
data | array | This page's records. |
has_more | boolean | Whether another page follows. |
next_cursor | string or null | The cursor for the next page; null on the last. |
Cursors are opaque: pass them back unchanged, never parse or build them. The TypeScript and Python clients follow them for you.
Rate limits
Limits depend on your plan; keyless requests are limited per client address. Every response carries the current budget, so a client can slow down before it is refused.
| Header | Meaning |
|---|---|
ratelimit-limit | Requests allowed in the current window. |
ratelimit-remaining | Requests left in the current window. |
retry-after | On a 429 only: seconds to wait before retrying. |
Over the limit, a request gets 429 RATE_LIMITED with a retry-after header. Wait that many seconds, then retry; the SDKs do this for you, with jitter. Read your limits from the headers rather than hard-coding them.
Versioning
Two versions keep a response stable. The API version fixes the shape of requests and responses; the knowledge version fixes the sources and passages behind them.
| Version | Set with | Looks like |
|---|---|---|
| API | DermLiving-Version header | 2026-10-01 |
| Knowledge | knowledge_version field | kv_2026_10_01 |
API version
Send DermLiving-Version: 2026-10-01 with each request, and the response echoes it in its own dermliving-version header. Breaking changes ship only under a new dated version. The SDKs send the version they were built for.
Knowledge version
Knowledge ships as immutable snapshots, each published only after it passes the release gates. Pin one with knowledge_version and the same question keeps the same sources. Every response names the snapshot it used in meta.knowledge_version, and pinning one that does not exist returns 404 VERSION_NOT_FOUND.
curl https://api.dermliving.com/v1/knowledge/versions \
-H "DermLiving-Version: 2026-10-01"
{
"data": [
{
"id": "kv_2026_10_01",
"published_at": "2026-10-01",
"status": "current",
"sources": 4,
"passages": 12,
"gates": {
"citation_validity": "pass",
"claim_grounding": "pass",
"curated_recall": "pass",
"abstention": "pass",
"tenant_isolation": "not_run",
"tool_authorization": "pass"
}
},
{
"id": "kv_2026_09_01",
"published_at": "2026-09-01",
"status": "pinnable",
"sources": 2,
"passages": 4,
"gates": {
"citation_validity": "not_run",
"claim_grounding": "not_run",
"curated_recall": "not_run",
"abstention": "not_run",
"tenant_isolation": "not_run",
"tool_authorization": "not_run"
}
}
],
"has_more": false,
"next_cursor": null,
"meta": {
"request_id": "req_WxqkKCMW85",
"knowledge_version": "kv_2026_10_01"
}
}