Clinical AI agents, where PHI never leaves your cloud.
One declarative format — agent YAML plus SKILL.md — over a signed, cloud-agnostic runtime that calls the Kartha MCP FHIR server. This is the reference for the REST + SSE contract, the MCP tool schemas, the guardrail layer, and MCP-JWT signing on AWS, GCP, and Azure.
Overview
The SDK is a self-contained pnpm/turbo workspace rooted at sdk/. It gives you a self-hosted, PHI-clean, cloud-agnostic runtime for building clinical agents on Kartha Health's MCP FHIR server. The contract is CMA-shaped — agent YAMLs port to Anthropic's Claude Managed Agents — but the wire schema is Kartha's own versioned /v1, not byte-compatible with any beta protocol.
| Package | What it is |
|---|---|
@kartha-health/client | Safe clinical access: cloud-agnostic MCP-JWT signing (signMcpJwt), a Streamable-HTTP MCP client, the guardrail executor, the observation digest, deterministic temporal windowing, and the SMART-on-FHIR helpers. Usable with any agent loop. |
@kartha-health/agent | A self-hosted, CMA-shaped agent runtime — agents, sessions, skills, and the agentic loop — running locally against a cloud-agnostic LlmProvider. |
@kartha-health/agent-server | An HTTP server exposing the runtime as a CMA-shaped REST + SSE API — Kartha's versioned /v1 contract. |
@kartha-health/otel | Optional OpenTelemetry adapter mapping loop spans to the OTel GenAI semantic conventions. |
The accelerators/ workspace ships 10 interactive agents, the canonical 50-skill catalog, a batch chart-abstraction runner, and the Clinical Agent Console (Explorer) reference app — a React UI plus a thin BFF built strictly on the public SDK.
Installation
All packages are ESM-only and require Node ≥ 20. The core client's only hard dependency is jose; each cloud signer SDK is an optional peer dependency you install only for the cloud you sign in.
# core client — pulls in NO cloud SDK
npm install @kartha-health/client
# add exactly one cloud signer SDK (optional peer deps):
npm install @aws-sdk/client-kms # AWS → signer { type: "kms" }
npm install @google-cloud/kms # GCP → signer { type: "gcp-kms" }
npm install @azure/keyvault-keys @azure/identity # Azure → signer { type: "azure-kv" }
# the local dev signer needs nothing extra
# runtime + server + telemetry
npm install @kartha-health/agent @kartha-health/agent-server
npm install @kartha-health/otel @opentelemetry/api # optional
Imports
import {
signMcpJwt, JwtAuth, KarthaClient,
GuardrailExecutor, READ_ONLY_TOOLS,
DOC_TYPE_CODES, mapDocTypes, observationDigest,
computeWindow, resolveEncounter, toFhirDate,
SmartAuthClient, SmartSession,
} from "@kartha-health/client";
// optional: eagerly register a cloud signer (side-effect subpath)
import "@kartha-health/client/aws"; // registers "kms"
import "@kartha-health/client/gcp"; // registers "gcp-kms"
import "@kartha-health/client/azure"; // registers "azure-kv"
Importing @kartha-health/client pulls in no cloud SDK. signMcpJwt lazy-imports the adapter for your signer type on first use, so an AWS deploy never bundles the GCP or Azure SDKs.
Quickstart
Boot the agent-server against the Kartha MCP endpoint. The runtime mints the signed MCP-JWT per request; the app-level bearer guards the caller↔server boundary.
import { serve } from "@hono/node-server";
import { Runtime, SkillRegistry, EntitlementGate, AnthropicProvider } from "@kartha-health/agent";
import { JwtAuth } from "@kartha-health/client";
import { KarthaAgentServer } from "@kartha-health/agent-server";
const skills = new SkillRegistry();
// Layer-1 auth: mints a short-lived ES256 MCP-JWT per tenant, KMS-signed.
const mcpAuth = new JwtAuth({
iss: "cust_bcm",
signerFor: (tenantId) => ({
sub: "clinician_42",
signer: { type: "kms", kmsKeyId: process.env.KMS_KEY_ID!, kid: "bcm-2026" },
}),
});
const runtime = new Runtime({
mcpUrl: process.env.KARTHA_MCP_URL!, // https://<alb-host>/mcp
auth: mcpAuth,
tenantId: "t_bcm",
llm: new AnthropicProvider(), // reads ANTHROPIC_API_KEY
skills,
entitlement: EntitlementGate.fromGrant(["sepsis-qsofa"]),
cache: true, // prompt caching on
});
// Layer-2 auth: the caller↔server app bearer.
const server = new KarthaAgentServer({
runtime, skills,
auth: { tokens: [process.env.APP_AUTH_TOKEN!] },
});
serve({ fetch: server.fetch, port: 8850 });
Then register an agent, open a session, and stream a turn:
# 1 · register an agent (returns { id, version })
curl -sX POST localhost:8850/v1/agents \
-H "Authorization: Bearer $APP_KEY" -H "Content-Type: application/json" \
--data @discharge-agent.json
# 2 · open a session bound to a patient
curl -sX POST localhost:8850/v1/sessions \
-H "Authorization: Bearer $APP_KEY" -H "Content-Type: application/json" \
-d '{"agentId":"discharge-summary-agent","patientId":"patient-07","skillId":"discharge-summaries"}'
# 3 · send a message and read the turn's SSE inline
curl -N -X POST "localhost:8850/v1/sessions/$SID/events?stream=true" \
-H "Authorization: Bearer $APP_KEY" -H "Content-Type: application/json" \
-d '{"type":"user_message","content":"Draft the discharge summary for this admission."}'
From the SDK root, pnpm demo boots a fixture MCP over synthetic data, runs one headless turn with an offline DemoProvider, and serves the Explorer at http://localhost:8850 — no keys, no cloud. See Accelerators & Explorer.
Architecture
Requests flow from your app through the agent-server to the runtime loop, which reaches FHIR only through the client's guardrail layer. Tenancy, FHIR licensing, and endpoint routing are enforced by the Kartha MCP server — the SDK inherits that gate through the signed MCP-JWT and never re-implements it.
Patient-safety invariants
These hold across the whole SDK and are exercised by tests. They shape the API you see — for example, why the tenant is never a request field and why a fhir_search is always rebound to the session patient.
A licensing condition degrades to grace — it never hard-fails a clinical request.
tenantId rides inside the MCP-JWT, never a header, never model-chosen.
Writes are opt-in per agent and per deploy — double-locked.
Every fhir_search is rebound to the session patient before it runs.
Date-scoped searches are bounded on both ends, deterministically.
No secrets in logs; captureContent:"full" is an explicit, warned opt-in.
The signing key never leaves cloud KMS/Key Vault; the browser holds no credential.
Skills are injected as context, never executed as code.
No full-note dumps; a repeated identical tool call returns an idempotency stub.
Agent YAMLs port; the wire schema is Kartha's own versioned /v1.
App bearer — agent-server
The caller↔server boundary. Every /v1 request needs Authorization: Bearer <app-key>. Health endpoints bypass it. This bearer is distinct from the MCP-JWT the runtime mints to reach the FHIR server.
| Option | Type | Behavior |
|---|---|---|
verify | (bearer) => boolean | Promise<boolean> | Custom verifier seam — overrides tokens. Highest precedence. |
tokens | string[] | Static allow-list of bearer tokens. |
APP_AUTH_TOKENS | env | Comma-separated static tokens (middleware default source). |
publicPaths | string[] | Extra exact paths to bypass auth (added to /health, /healthz). |
If no custom verifier is set and zero tokens are configured, all requests are allowed. Always configure tokens, a verify seam, or APP_AUTH_TOKENS before exposing the server. A failed check returns 401 {"error":{"type":"unauthorized","message":"invalid app bearer"}}.
MCP-JWT signing
signMcpJwt(claims, signer) mints the short-lived ES256 JWT that authenticates every MCP call. For cloud signers the signing input is SHA-256'd in-process and only the digest is sent to the HSM — the private key never leaves your cloud. The DER signature is normalized to JOSE R‖S automatically.
Claims — McpJwtClaims
| Field | Req | Meaning |
|---|---|---|
iss | yes | Customer id. |
sub | yes | Acting principal (clinician or agent id) — audit attribution. |
aud | yes | Must match the server's audience (default kartha-health). |
tenantId | yes | The verified claim that drives routing — never a header. |
ttlSeconds | no | Token lifetime; default 300. Produces exp = iat + ttl. |
Signer config per cloud
import { signMcpJwt } from "@kartha-health/client";
const claims = { iss: "cust_bcm", sub: "clinician_42", aud: "kartha-health", tenantId: "t_bcm", ttlSeconds: 300 };
// AWS KMS — IAM role needs only kms:Sign on an ECC_NIST_P256 key
const jwt = await signMcpJwt(claims, {
type: "kms", kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/abc-123", kid: "bcm-2026", region: "us-east-1",
});
// GCP Cloud KMS — full key-version resource name, EC_SIGN_P256_SHA256
await signMcpJwt(claims, {
type: "gcp-kms",
keyName: "projects/p/locations/us/keyRings/kartha/cryptoKeys/mcp-jwt/cryptoKeyVersions/1",
kid: "bcm-2026",
});
// Azure Key Vault — the key identifier URL
await signMcpJwt(claims, {
type: "azure-kv", keyId: "https://myvault.vault.azure.net/keys/mcp-jwt/<version>", kid: "bcm-2026",
});
// Local PKCS8 P-256 PEM — dev and tests only
await signMcpJwt(claims, { type: "local", pem: devPem, kid: "dev-1" });
The produced JWT header is { alg:"ES256", typ:"JWT", kid } and the payload is { iss, sub, aud, tenantId, iat, exp }. The kid must appear in the tenant's signed entitlement (customerMcpAuthPublicKeys) or the real MCP server rejects it.
JwtAuth — the caching wrapper the runtime consumes
JwtAuth implements the McpAuth interface (getToken(tenantId) / remint(tenantId)). It caches one token per tenant and re-mints remintSkewSeconds (default 60) before expiry; the client force-re-mints on a 401.
import { JwtAuth } from "@kartha-health/client";
const auth = new JwtAuth({
iss: "cust_bcm",
aud: "kartha-health", // default
ttlSeconds: 300, // default
remintSkewSeconds: 60, // default — re-mint this early
signerFor: (tenantId) => ({
sub: "clinician_42",
signer: { type: "kms", kmsKeyId: "arn:aws:kms:…", kid: "bcm-2026" },
}),
});
SMART on FHIR — passthrough tenants
For a scenario: passthrough ("S2") tenant, the host app obtains a user-context EMR token via standalone SMART App Launch with S256 PKCE, and the client forwards it per request as X-EMR-Access-Token alongside the MCP-JWT. Works against any SMART-conformant R4 endpoint — Epic, Oracle Health/Cerner, athenahealth, MEDITECH, OpenEMR.
import { SmartAuthClient, SmartSession, KarthaClient } from "@kartha-health/client";
const smart = new SmartAuthClient({ /* issuer, clientId, redirectUri, scopes? */ });
await smart.discover(); // /.well-known/smart-configuration
const { url, state, codeVerifier } = smart.authorizationRequest();
// … redirect the user, then on callback:
const tokens = await smart.exchangeCode({ code, codeVerifier });
const session = new SmartSession(tokens, { onTokens: persist }); // auto-refresh, 60s buffer
const client = new KarthaClient({
url: process.env.KARTHA_MCP_URL!,
auth: mcpAuth, // Layer-1 MCP-JWT
tenantId: "t_epic_passthrough",
emrAccessToken: () => session.getAccessToken(), // Layer-2 → X-EMR-Access-Token
});
Default scopes are openid fhirUser offline_access user/*.rs (read + search only). Tokens are never logged or persisted by the module, and the browser never sees a credential.
KarthaClient
A Streamable-HTTP MCP client: JSON-RPC 2.0 POSTed to a single /mcp URL, accepting either application/json or text/event-stream responses. It signs every request with the tenant's MCP-JWT and re-mints once on a 401.
| Field | Req | Meaning |
|---|---|---|
url | yes | The /mcp endpoint, e.g. https://<alb-host>/mcp. |
auth | yes | McpAuth — mints the MCP-JWT bearer per tenant (JwtAuth satisfies it). |
tenantId | no | Tenant to operate as; default "" (the auth impl decides). |
emrAccessToken | no | S2 only: string or async thunk → X-EMR-Access-Token header. |
clientInfo | no | MCP client identity; default { name:"kartha-health-client", version:"0.1.0" }. |
fetchImpl | no | Injectable fetch for tests. |
const client = new KarthaClient({ url, auth, tenantId: "t_bcm" });
await client.connect(); // MCP initialize + notifications/initialized; fails loud on 401
const tools = await client.listTools(); // ToolDef[] { name, description?, inputSchema }
const aTools = await client.anthropicTools();// AnthropicToolDef[] { name, description?, input_schema }
const res = await client.callTool("fhir_search", {
resourceType: "Observation", queryParams: "patient=p-123&category=laboratory",
}); // ToolResult { text, isError }
await client.close(); // best-effort DELETE teardown
The client never sets X-Tenant-Id — the tenant is always the signed tenantId JWT claim, in dev too. It sends MCP-Protocol-Version: 2025-06-18 and captures the Mcp-Session-Id after initialize.
Guardrail executor
The GuardrailExecutor is the only thing the agent loop may call to reach a tool. It wraps a ToolCaller (KarthaClient satisfies it) and returns { result, adjustments } — the adjustments are the badge strings the Explorer renders. Guardrail failures surface as isError results; they are never thrown.
| Field | Default | Effect |
|---|---|---|
patientId | required | The session patient every search is rebound to. |
allow | READ_ONLY_TOOLS | Tool allow-list. |
readOnly | true | Block the write tools. |
patientScopeFloor | true | Rebind searches to the session patient. |
dateBounded | true | Enforce both-ended date windows. |
defaultLookbackDays | 90 | Injected lower-bound lookback. |
digestObservations | true | Collapse Observation responses via the digest. |
summarize | true | Apply the outputFormat summarization layer. |
defaultOutputFormat | "text" | Format injected when the model omits one. |
docTypeCodes | — | LOINC codes → notes_load documentType (the speed lever). |
ragQueries | — | Fallback notes_search queries when the model supplies none. |
notesMinScore | — | Cosine floor for notes_search when omitted. |
What each guardrail does — and the badge it emits
| Guardrail | Adjustment badge(s) |
|---|---|
| Read-only + allow-list — block write / non-allowed tools | read-only: write blocked · allow-list: tool blocked |
Patient-scope floor — force patient= / _id= / patientId | patient-scope: forced patient · …forced _id · …forced patientId |
Temporal bounds — inject missing ge/le ends | temporal: bounded ge le injected (2026-04-16..2026-07-15) |
Summarization — set outputFormat when omitted | summarize: outputFormat=text |
| Notes-RAG — inject documentType, query fallback, min-score, auto-load | notes: documentType=… · notes: ragQueries fallback … · notes: auto notes_load before notes_search |
| Widen-on-empty — drop the type filter and retry once | notes: empty → widened (dropped type filter) and retried |
| Idempotency stub — collapse an identical repeat call | idempotent: duplicate call stubbed |
| Observation digest — summarize an Observation bundle | digest: observations summarized |
import { GuardrailExecutor } from "@kartha-health/client";
const guarded = new GuardrailExecutor(client, {
patientId: "p-123",
encounterId: "enc-2026-0114",
docTypeCodes: ["11506-3", "18842-5"], // progress + discharge notes
ragQueries: ["oxygen requirement", "discharge barriers"],
notesMinScore: 0.35,
});
const { result, adjustments } = await guarded.execute("fhir_search", {
resourceType: "Observation",
queryParams: "subject=p-999&date=ge2026-01-01", // subject stripped, patient forced, le injected
});
// adjustments → ["patient-scope: forced patient", "temporal: bounded le injected (…)", "digest: observations summarized"]
Observation digest & temporal windowing
Two deterministic helpers the guardrail layer uses — usable standalone. Both fail open: they never fabricate and return the input unchanged on any parse failure.
observationDigest(bundleJson) → string
Collapses an Observation bundle into one compact line per code — count, latest value + date, reference range, abnormal count. An empty bundle returns "No observations."
Hemoglobin A1c: 4× · latest 7.2 % @ 2026-05-01 · ref 4 %–5.6 % · ⚠ 3 abnormal
Systolic blood pressure: 6× · latest 148 mm[Hg] @ 2026-05-05 · ref 90–130 · ⚠ 4 abnormal
Creatinine: 3× · latest 1.0 mg/dL @ 2026-06-22 · ref 0.6–1.2
computeWindow & resolveEncounter → Window
Bounds are never left to the model. Window = { ge, le, days, encounterResolved } with ge/le as FHIR YYYY-MM-DD (UTC).
import { computeWindow, resolveEncounter, toFhirDate } from "@kartha-health/client";
computeWindow({ lookbackDays: 90 });
// → { ge: "2026-04-16", le: "2026-07-15", days: 90, encounterResolved: true }
resolveEncounter({ encounterPeriod: { start: "2026-07-01" }, encounterMaxDays: 30 });
// caps an open encounter at start + 30d; no period → [now − 14d, now], encounterResolved: false
MCP tools
The FHIR and notes tools are defined by the Kartha MCP (Go) server and arrive at the client via tools/list; skill_reference is a local tool resolved in-process by the runtime. Four FHIR tools are always registered; the two notes tools appear when notes are enabled.
outputFormat — the shared response-shape param
Every FHIR tool takes an outputFormat of "full" | "compact-fhir" | "text". Resolution is explicit arg → deployment default → full. Summaries are lossy by design but always retain the resource id — re-read a record with outputFormat:"full" before acting on it clinically.
| Value | Shape |
|---|---|
full | Raw FHIR JSON (the default). |
text | Compact human/LLM text, one line per resource, tables for bundles — ~70–90% fewer tokens. |
compact-fhir | A reduced FHIR JSON subset. |
Search FHIR resources with query parameters. The guardrail layer rebinds the patient and injects date bounds before it runs.
| Param | Req | Description |
|---|---|---|
resourceType | yes | FHIR resource type, e.g. Observation, Condition, MedicationRequest. |
queryParams | no | FHIR search params, e.g. category=laboratory&date=ge2026-01-01. |
outputFormat | no | full | compact-fhir | text. |
{
"resourceType": "Observation",
"queryParams": "patient=p-123&category=laboratory&date=ge2026-04-16&date=le2026-07-15",
"outputFormat": "text"
}Observation/obs-a1c · final
LOINC 4548-4 "Hemoglobin A1c" = 7.2 % · 2026-05-01
· interpretation H "High" · ref 4 %–5.6 %
[compacted: 1.2KB→286B; ids retained —
fhir_read any id for full record]Read a single FHIR resource by type and id.
| Param | Req | Description |
|---|---|---|
resourceType | yes | FHIR resource type. |
id | yes | Resource id. |
outputFormat | no | full | compact-fhir | text. |
{ "resourceType": "DocumentReference", "id": "doc-42", "outputFormat": "full" }Write tools, blocked by the default read-only guardrail. Enabling them is double-locked (per agent and per deploy). With a non-full format they return a one-line ack — created <Type>/<id> (version <v>) — instead of echoing the resource.
| Tool | Params |
|---|---|
fhir_create | resourceType (req) · resource (object, req) · outputFormat |
fhir_update | resourceType (req) · id (req) · resource (object, req) · outputFormat |
Load (index) a patient's clinical notes so they can be searched. Synchronously fetches DocumentReferences in scope, extracts and embeds them, and returns a coverage receipt — not note text — unless return:"sections". Re-running is incremental. Call this before notes_search.
| Param | Req | Description |
|---|---|---|
patientId | yes | FHIR Patient id. |
dateRange | no | FHIR date filter, e.g. ge2024-01-01. Defaults to the last 24 months. |
documentType | no | DocumentReference type token(s) — LOINC. See doc-type codes. |
encounterId | no | Restrict to one encounter. |
docIds | no | Specific DocumentReference ids. |
return | no | summary (default, receipt only) or sections (also return indexed text). |
{ "patientId": "p-123", "documentType": "11506-3,18842-5" }{
"docsInScope": 12, "alreadyCurrent": 9,
"newlyIndexed": 3, "reIndexed": 0, "failed": 0,
"sectionsTotal": 87, "dateRange": "ge2024-07-15",
"capped": false, "message": "…"
}Semantic search over the notes notes_load indexed. Returns ranked snippets with a score and provenance. Every snippet carries a docId — fhir_read the full note before any clinical decision.
| Param | Req | Description |
|---|---|---|
patientId | yes | FHIR Patient id. |
queries | yes | Plain-text query strings (array). |
topK | no | Max snippets per query; default 5. |
minScore | no | Minimum cosine similarity 0–1; default from config. |
{
"patientId": "p-123",
"queries": ["oxygen requirement trend", "discharge barriers"],
"topK": 5, "minScore": 0.35
}{
"snippets": [{
"content": "Patient reports improved dyspnea on 2L NC…",
"score": 0.83, "docId": "doc-42",
"noteDate": "2026-07-10",
"noteType": "Progress note", "section": "Assessment"
}],
"unranked": false
}Serves the active skill's bundled references/*.md — static clinical reference (scoring tables, criteria), never patient data. It sits outside the MCP path; nothing leaves the process, and it's only offered when a selected skill bundles references.
| Param | Req | Description |
|---|---|---|
name | yes | Reference name as cited, e.g. references/sofa-scoring.md. Leading .//references/ and the .md suffix are stripped; use <skill_id>/<name> to disambiguate. |
Document-type codes
DOC_TYPE_CODES maps friendly labels to the LOINC DocumentReference.type code that notes_load's documentType expects. mapDocTypes(labels) drops unknown labels rather than guessing — widen-on-empty is the safety net.
| Label | LOINC | Meaning |
|---|---|---|
progress-note | 11506-3 | Progress note |
consult-note | 11488-4 | Consult note |
history-and-physical | 34117-2 | History and physical note |
discharge-summary | 18842-5 | Discharge summary |
imaging-report | 18748-4 | Diagnostic imaging study |
operative-note | 11504-8 | Surgical operation note |
procedure-note | 28570-0 | Procedure note |
nursing-note | 34746-8 | Nursing note |
ed-note | 34111-5 | Emergency department note |
pathology-report | 11526-1 | Pathology study |
cardiology-report | 34752-6 | Cardiology study (echo/EKG) |
neurophysiology | 11522-0 | Electroencephalogram (EEG) study |
Agents
Agents are versioned and archived, never deleted. The agent id is slug(name); the version auto-increments per id from 1. A referenced premium skill that is unlicensed and not in grace fails registration with 400 invalid_agent.
{
"name": "Discharge Summary Agent",
"description": "Drafts discharge summaries for clinician review",
"model": { "id": "claude-sonnet-4-6", "speed": "standard" },
"system": "You draft discharge summaries. Never finalize without sign-off.",
"mcp_servers": [
{ "name": "kartha", "type": "url", "url": "https://mcp.example.com/mcp" }
],
"skills": [{ "skill_id": "discharge-summaries", "version": "latest" }],
"kartha": {
"guardrails": { "readOnly": true, "patientScopeFloor": true, "dateBounded": true },
"maxTurns": 8
}
}{
"id": "discharge-summary-agent",
"version": 1,
"archived": false,
"name": "Discharge Summary Agent",
"model": { "id": "claude-sonnet-4-6", "speed": "standard" },
"ungranted_premium_skills": []
}GET/v1/agents returns { "data": [ … ] } with the latest version per id. Archive is a silent no-op for unknown ids (always 200 {"archived":true}). POST /v1/agents/:id ignores the path id — the body's name is what slugs to the target id.
Skills
A SKILL.md registry with progressive disclosure — list returns summaries only (id + description), never the body. Upload accepts raw markdown or JSON { md }.
---
skill_id: sepsis-qsofa
name: Sepsis qSOFA Screen
description: Compute qSOFA and frame sepsis risk for clinician review.
license: kartha-premium
version: 2
ragQueries: ["altered mental status", "hypotension"]
docTypes: ["progress-note", "ed-note"]
---
For the current encounter, compute qSOFA …{
"skill_id": "sepsis-qsofa",
"version": 1,
"license": "kartha-premium"
}GET/v1/skills → { "data": SkillSummary[] } where SkillSummary = { skill_id, name, description, license, version }. GET/v1/skills/:id returns the full SkillDef including the body, ragQueries, docTypes, and any references.
Sessions
A session binds an agent to a patient. The tenant is never in the request — it is the signed tenantId claim the runtime mints. Sessions are process-local (in-memory); the by-id routes 404 for ids not created on that server process.
| Field | Req | Meaning |
|---|---|---|
agentId | yes | Registered agent id. |
agentVersion | no | Defaults to latest. |
patientId | yes | Binds the patient-scope guardrail floor. |
encounterId | no | Encounter context for windowing. |
skillId | no | Explicit single-skill selection. |
skillIds | no | Multi-skill injection, in order; takes precedence over skillId. |
{
"agentId": "discharge-summary-agent",
"agentVersion": 2,
"patientId": "patient-07",
"encounterId": "enc-2026-0114",
"skillId": "discharge-summaries"
}{
"session_id": "b7e4c2a9-3f1d-4e8a-9c56-2d8f0a1b6e33",
"status": "created"
}GET/v1/sessions/:id returns the session record plus the latest RunTrace: { session_id, state, trace } where state ∈ created · running · awaiting_input · completed · archived.
Events
Post an InboundEvent to drive a turn, then read OutboundEvents from the stream or the persisted history. Two client patterns:
- (a) fire-and-stream —
POST …/eventsreturns202 {"accepted":true}; consumeGET …/streamfor the turn's events. The Explorer relays this. - (b) inline —
POST …/events?stream=truewith auser_messageresponds withtext/event-streamfor that turn only, then closes.
type InboundEvent =
| { type: "user_message"; content: string }
| { type: "interrupt" }
| { type: "tool_permission_response"; requestId: string; decision: "allow" | "deny" };Stubs & errors
Two CMA-shape stubs exist for shape compatibility, plus a uniform error envelope.
Every error is { "error": { "type": string, "message": string } }:
| Status | error.type | Where |
|---|---|---|
400 | invalid_agent | POST /v1/agents · POST /v1/agents/:id |
400 | invalid_skill | POST /v1/skills |
400 | invalid_session | POST /v1/sessions |
401 | unauthorized | any authed route |
404 | not_found | GET/POST by-id routes |
| SSE | loop_error · llm_error | error thrown inside a turn / provider stream |
SSE streaming protocol
Each event is one OutboundEvent JSON on a data: line, with an event: type and an id: cursor. The cursor is "<sessionId>:<seq>" where seq is a per-session monotonic integer from 1 — the same value appears inside the JSON.
- Heartbeats — the persistent
GET …/streamsendsevent: heartbeatwith empty data everyheartbeatMs(default 15000). The inline stream sends none. - Resume — reconnect with
?from=<last-seen-id>. The server replays persisted history strictly after that cursor, then continues live. An unknown cursor replays from event 1. The stream never terminates on its own.
event: session.created
id: b7e4c2a9-…:1
data: {"id":"b7e4c2a9-…:1","seq":1,"sessionId":"b7e4c2a9-…","type":"session.created","agentId":"discharge-summary-agent","agentVersion":2}
event: skill.selected
id: b7e4c2a9-…:2
data: {"…":"…","type":"skill.selected","skillId":"discharge-summaries","via":"explicit"}
event: turn.started
id: b7e4c2a9-…:3
data: {"…":"…","type":"turn.started","turnId":"6c1f9a02-…"}
event: tool.start
id: b7e4c2a9-…:5
data: {"…":"…","type":"tool.start","toolId":"toolu_01XkQ2","tool":"fhir_search","args":{"resourceType":"MedicationRequest","patient":"patient-07"}}
event: tool.result
id: b7e4c2a9-…:6
data: {"…":"…","type":"tool.result","toolId":"toolu_01XkQ2","tool":"fhir_search","tokens":412,"ms":238,"response":"{…Bundle…}","adjustments":["patient-scope: forced patient","temporal: bounded le injected"]}
event: text.delta
id: b7e4c2a9-…:8
data: {"…":"…","type":"text.delta","text":"## Discharge Summary\n\nMs. …"}
event: message.completed
id: b7e4c2a9-…:9
data: {"…":"…","type":"message.completed","content":"## Discharge Summary …"}
event: turn.completed
id: b7e4c2a9-…:10
data: {"…":"…","type":"turn.completed","turnId":"6c1f9a02-…","trace":{ /* TurnTrace */ }}
event: heartbeat
data: Event reference
Every outbound event carries the envelope { id, seq, sessionId, type, createdAt } plus its payload.
| type | Payload |
|---|---|
session.created | agentId · agentVersion |
turn.started | turnId |
status | label — thinking · continuing · interrupted |
skill.selected | skillId · via: "explicit" | "model" |
tool.start | toolId · tool · args |
tool.result | toolId · tool · tokens · ms · response · adjustments? |
text.delta | text — streamed incrementally |
message.completed | content — the final turn text |
turn.completed | turnId · trace: TurnTrace |
error | error: { type, message } |
session.completed and tool.permission_request exist in the type union but aren't emitted today (read-only default). Use turn.completed + GET /v1/sessions/:id for the run trace.
Agent YAML
The declarative agent format. Only name, model.id, and system are required; ${VAR} expands in mcp_servers[].url at load. The runtime owns the MCP client — the mcp_servers block is retained for CMA shape and env substitution, never a static bearer.
name: "Care Coordination"
model: { id: claude-sonnet-4-6, speed: standard }
description: >
Coordinates transitions and follow-up: discharge planning,
referrals, care-gap closure, transition-of-care summaries.
system: |
You are a clinical AI agent for care coordination and transitions.
## What the runtime enforces (not you)
The Kartha SDK guardrail layer — not this prompt — guarantees the
patient-scope floor, both-ended temporal bounds, read-only by default,
server-side FHIR summarization, and notes_load-before-notes_search.
## Grounding
Cite every clinical claim to a retrieved resource id or a note docId.
Never fabricate a value; re-read a record with outputFormat="full".
mcp_servers:
- { name: kartha, type: url, url: "${KARTHA_MCP_URL}" }
skills:
- { skill_id: discharge-planning, version: latest }
- { skill_id: referrals, version: latest }
- { skill_id: care-gaps, version: latest }
kartha:
retrieval: { encounterMaxDays: 30, defaultMedLookbackDays: 90 }
guardrails: { readOnly: true, patientScopeFloor: true, dateBounded: true }| Field | Req | Meaning |
|---|---|---|
name | yes | Display name; runtime id is slug(name). |
model.id | yes | Model id; KARTHA_MODEL_ID env overrides every agent. |
model.speed | no | standard (default) | fast. |
system | yes | System prompt. |
description | no | Agent card blurb. |
mcp_servers | no | { name, type:"url", url }; ${VAR} expanded at load. |
tools | no | { type:"mcp_toolset", mcp_server_name, allow: [names] }. |
skills | no | { skill_id, version:"latest"|n }; an unresolvable id throws at startup. |
kartha.retrieval | no | { encounterMaxDays, defaultMedLookbackDays, notesMinScore }. |
kartha.guardrails | no | { readOnly, patientScopeFloor, dateBounded } — all default true. |
kartha.maxTurns | no | Max loop iterations per turn; default 8 (MAX_ITERS overrides). |
Writes activate only when both KARTHA_ALLOW_WRITES=1 (deploy env) and the agent is writes-capable. Even then, authored artifacts are preliminary/draft only.
SKILL.md
A skill is --- YAML frontmatter plus a markdown body. The description is all the model sees until the skill is selected. Skills are injected as context — never executed as code.
| Key | Req | Meaning |
|---|---|---|
skill_id | yes | Globally unique, kebab-case. |
name | yes | Display name (default = skill_id). |
description | yes | Progressive-disclosure summary. |
license | yes | customer | kartha-premium. |
version | yes | Integer. |
ragQueries | no | Seeds notes_search queries. |
docTypes | no | Friendly note-type labels → LOINC for notes_load. |
source · review_date · reviewed_by | gov | Governance metadata (lint warns if missing/stale). |
Body conventions: ## Overview → ## FHIR Resources Used → ## Token-Efficient Retrieval & Clinical Notes → ## Instructions (### Step N with fenced tool-call blocks) → ## Examples → ## Related Skills → ## Structured output. Interactive skills must include the structured-output section (lint error if missing).
Result cards
After the prose answer, an interactive skill appends one fenced json block: {"cards":[ … ]}. The Explorer validates each card, drops malformed ones, and repairs truncated blocks; a card is included only when computed from retrieved evidence.
{"type":"score","title":"…","value":7,"max":9,"band":"High risk","tone":"critical|warning|good|info","subtitle":"…","source":"…"}
{"type":"factors","title":"…","rows":[{"label":"…","points":2}]}
{"type":"trend","title":"…","unit":"…","points":[{"label":"2026-01","value":150}],"goal":130,"note":"…"}
{"type":"table","title":"…","columns":["…"],"rows":[["…"]]}
{"type":"checklist","title":"…","items":[{"label":"…","status":"done|pending|blocked|na","note":"…"}]}
{"type":"callout","tone":"warning","title":"…","body":"…"}
{"type":"sources","refs":[{"resource":"Condition/<id>","label":"…"}]}The final sources card cites the FHIR resource ids actually read. Answers without a valid block fall back to markdown rendering.
Core schemas
The load-bearing types you'll serialize against.
// —— AgentConfig ————————————————————————————————————————————————
type LlmProviderName = "bedrock" | "vertex" | "foundry" | "anthropic";
interface AgentConfig {
name: string; // REQUIRED
model: { id: string; speed?: "standard" | "fast" }; // REQUIRED (model.id)
description?: string;
system: string; // REQUIRED
mcp_servers?: { name: string; type: "url"; url: string }[];
tools?: { type: "mcp_toolset"; mcp_server_name: string; allow: string[] }[];
skills?: { skill_id: string; version?: "latest" | number }[];
kartha?: {
llm?: { provider?: LlmProviderName };
retrieval?: { encounterMaxDays?: number; defaultMedLookbackDays?: number; notesMinScore?: number };
guardrails?: { readOnly?: boolean; patientScopeFloor?: boolean; dateBounded?: boolean };
maxTurns?: number; // default 8
};
}
// —— TurnTrace / RunTrace (token meter + cost) ——————————————————————
interface TraceToolCall { tool: string; ms: number; tokens: number; isError: boolean; local?: boolean }
interface TurnTrace {
turnIndex: number; model: string;
systemPromptTokens: number;
inputTokens: number; outputTokens: number;
cacheReadTokens: number; cacheWriteTokens: number;
costUsd: number;
toolCalls: TraceToolCall[];
iterations: {
iteration: number; inputTokens: number; outputTokens: number;
cacheReadTokens: number; cacheWriteTokens: number;
costUsd: number; modelMs: number; toolCalls: TraceToolCall[];
}[];
}
interface RunTrace {
sessionId: string; agentId: string; agentVersion: number;
skillId?: string; model: string; turns: TurnTrace[];
fullChartBaselineTokens: number; // token-meter framing (presentation estimate)
selectiveTokens: number; // chart-retrieval tokens only
actualTokens: number; // input+output across turns
totalCostUsd: number;
graceFlags: string[]; // e.g. "premium (grace): sepsis-qsofa"
}
// —— SkillDef ————————————————————————————————————————————————————
interface SkillDef {
skill_id: string; name: string; description: string;
license: "kartha-premium" | "customer"; version: number;
ragQueries?: string[]; docTypes?: string[];
body: string;
references?: { name: string; content: string }[];
}Telemetry — OpenTelemetry
@kartha-health/otel maps loop spans to the OTel GenAI conventions. You own the TracerProvider and exporter — the adapter only creates spans, and the exporter must stay inside the PHI boundary.
import { KarthaOtelTelemetry } from "@kartha-health/otel";
const runtime = new Runtime({
// …
telemetry: new KarthaOtelTelemetry({
tracerName: "kartha-health", // default
captureContent: false, // default — no prompt/completion content
}),
});Span names: session, turn, llm.call (→ chat <model>), tool.call (→ tool <name>). Attributes follow gen_ai.* plus kartha.* (cache_read_tokens, cost_usd, latency_ms, sessionId, agentId, skillId).
With captureContent off (default), no prompt/completion content is emitted at all. Turning it on and the loop's own captureContent:"full" routes message content — potentially PHI — into span events. It's an explicit, warned opt-in. Env mirror: OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true.
Accelerators & Explorer
The accelerators/ workspace ships 10 interactive agents, the 50-skill catalog, and the Clinical Agent Console (Explorer) — a React UI over a thin BFF built strictly on the public SDK.
Explorer BFF — the browser-facing API
The BFF (default port 8850) holds all credentials — the browser holds none. It boots the full accelerators deployment and relays to the in-process agent-server.
| Method · path | Behavior |
|---|---|
GET /healthz | Liveness; registered before the auth gate. App Runner health target. |
GET /api/health | Probes the MCP URL → { ok, mcp, tenantId, agents, provider, model? }. |
GET /api/bootstrap | Console boot payload: agents (with skill posture) + patient catalog. |
POST /api/sessions | Relays to agent-server POST /v1/sessions. |
POST /api/sessions/:id/message | { content } → inline SSE turn, piped back to the browser. |
GET /api/sessions/:id/trace | Relays GET /v1/sessions/:id (session + RunTrace). |
GET * | Serves the built Vite SPA (path-traversal-safe). |
Set both BASIC_AUTH_USER and BASIC_AUTH_PASS to require HTTP Basic auth on every request except /healthz (including the SSE stream).
Key environment variables
| Var | Meaning |
|---|---|
KARTHA_MCP_URL | MCP endpoint; unset → local fixture http://localhost:8848/mcp. |
KARTHA_MCP_KMS_KEY_ID + KARTHA_MCP_KID | AWS KMS signer via the credential chain. Both blank → throwaway local key (fixture only). |
KARTHA_MCP_CUSTOMER_ID · KARTHA_MCP_TENANT_ID | JWT iss and the verified tenant claim. |
AI_PROVIDER · ANTHROPIC_API_KEY · AWS_BEARER_TOKEN_BEDROCK | Provider select; neither key set → offline DemoProvider. |
KARTHA_MODEL_ID | Overrides every agent's model (Bedrock inference-profile id on Bedrock). |
KARTHA_GUIDELINE_SET | Region pack for reference variants, e.g. in-iap; empty = US defaults. |
NOTES_MIN_SCORE · DEFAULT_MED_LOOKBACK_DAYS · ENCOUNTER_MAX_DAYS · MAX_ITERS | Retrieval / loop knobs (override agent YAML). |
Run it
# zero-credential demo: fixture MCP + offline provider + Explorer at :8850
pnpm demo
# run one agent headless
pnpm --filter @kartha-health/accelerators run-agent \
--agent clinical-decision-support --patient patient02-clinical-decision-support-sepsis-qsofa \
--skill sepsis-qsofa --prompt "Screen this patient for sepsis."
# skill factory + lint
pnpm new:skill --agent lab-diagnostics --id lactate-clearance --name "Lactate Clearance"
pnpm skills:lintBatch chart-abstraction
A second execution model: code drives retrieval, the LLM only transforms. The extraction worker gets zero tools — note text is untrusted input — and every extracted field must quote the note verbatim or it's dropped.
pnpm --filter @kartha-health/accelerators extract-batch \
--rubric medication-extraction --patient p-ava \
[--date-range ge2026-01-01] [--doc-type 11506-3] [--encounter enc-9] \
[--summary] [--out records.json] [--model claude-sonnet-4-6] [--concurrency 4]A rubric is a normal SKILL.md under skills/chart-abstraction/ with an extra fields: [...] frontmatter array. The pipeline is pull (one notes_load … return:"sections") → transform (one zero-tool LLM call per note, bounded concurrency) → validate (verbatim-quote check, extra keys dropped) → aggregate.
{
patientId, rubric, model,
records: [{ docId, fields: { [field]: { value, quote, section? } | null } }],
docs: [{ docId, status: "ok"|"invalid-json"|"llm-error"|"no-content", entities, rejected, reason? }],
receipt: { docsInScope, docsExtracted, entities, rejectedEntities, failedDocs },
summary?: string,
usage: { inputTokens, outputTokens }
}An ungranted premium rubric throws — unlike in-session skills, which degrade to grace. Batch is explicit, so a licensing gap is a hard error, not a silent skip.