Kartha SDK API
Kartha Health Agent SDK

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.

4 packages /v1 REST + SSE 7 MCP tools 50 clinical skills ESM · Node ≥ 20 Apache-2.0

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.

PackageWhat it is
@kartha-health/clientSafe 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/agentA self-hosted, CMA-shaped agent runtime — agents, sessions, skills, and the agentic loop — running locally against a cloud-agnostic LlmProvider.
@kartha-health/agent-serverAn HTTP server exposing the runtime as a CMA-shaped REST + SSE API — Kartha's versioned /v1 contract.
@kartha-health/otelOptional 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.

bash
# 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

typescript
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"
Lazy loading keeps your bundle clean.

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.

server.ts
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:

bash
# 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."}'
No credentials? Run the offline demo.

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.

Your cloud · PHI boundary
01Your app · or Exploreropens sessions, streams turnscaller
02Agent serverREST + SSE, app-bearer gateagent-server
03Runtime loopagents · skills · sessionsagent
04Guardrails + signerpatient-scope · temporal · digest · signMcpJwtclient
The signed MCP-JWT crosses to the Kartha MCP (FHIR) server → EMR, which owns per-tenant entitlement and routing. The private signing key never leaves your cloud KMS/Key Vault; the browser never holds a credential — only the BFF does.

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.

1Grace, never hard-fail

A licensing condition degrades to grace — it never hard-fails a clinical request.

2Tenant is a signed claim

tenantId rides inside the MCP-JWT, never a header, never model-chosen.

3Read-only by default

Writes are opt-in per agent and per deploy — double-locked.

4Patient-scope floor

Every fhir_search is rebound to the session patient before it runs.

5Temporal bounds

Date-scoped searches are bounded on both ends, deterministically.

6PHI out of telemetry

No secrets in logs; captureContent:"full" is an explicit, warned opt-in.

7Key stays in the HSM

The signing key never leaves cloud KMS/Key Vault; the browser holds no credential.

8Skills are context

Skills are injected as context, never executed as code.

9No fabrication

No full-note dumps; a repeated identical tool call returns an idempotency stub.

10CMA-shaped, own contract

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.

OptionTypeBehavior
verify(bearer) => boolean | Promise<boolean>Custom verifier seam — overrides tokens. Highest precedence.
tokensstring[]Static allow-list of bearer tokens.
APP_AUTH_TOKENSenvComma-separated static tokens (middleware default source).
publicPathsstring[]Extra exact paths to bypass auth (added to /health, /healthz).
Dev-open default.

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

FieldReqMeaning
issyesCustomer id.
subyesActing principal (clinician or agent id) — audit attribution.
audyesMust match the server's audience (default kartha-health).
tenantIdyesThe verified claim that drives routing — never a header.
ttlSecondsnoToken lifetime; default 300. Produces exp = iat + ttl.

Signer config per cloud

typescript
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.

typescript
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.

typescript
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.

interface KarthaClientOptions
FieldReqMeaning
urlyesThe /mcp endpoint, e.g. https://<alb-host>/mcp.
authyesMcpAuth — mints the MCP-JWT bearer per tenant (JwtAuth satisfies it).
tenantIdnoTenant to operate as; default "" (the auth impl decides).
emrAccessTokennoS2 only: string or async thunk → X-EMR-Access-Token header.
clientInfonoMCP client identity; default { name:"kartha-health-client", version:"0.1.0" }.
fetchImplnoInjectable fetch for tests.
methods
typescript
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
Tenant is never in the args.

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.

interface GuardrailPolicy
FieldDefaultEffect
patientIdrequiredThe session patient every search is rebound to.
allowREAD_ONLY_TOOLSTool allow-list.
readOnlytrueBlock the write tools.
patientScopeFloortrueRebind searches to the session patient.
dateBoundedtrueEnforce both-ended date windows.
defaultLookbackDays90Injected lower-bound lookback.
digestObservationstrueCollapse Observation responses via the digest.
summarizetrueApply the outputFormat summarization layer.
defaultOutputFormat"text"Format injected when the model omits one.
docTypeCodesLOINC codes → notes_load documentType (the speed lever).
ragQueriesFallback notes_search queries when the model supplies none.
notesMinScoreCosine floor for notes_search when omitted.

What each guardrail does — and the badge it emits

GuardrailAdjustment badge(s)
Read-only + allow-list — block write / non-allowed toolsread-only: write blocked · allow-list: tool blocked
Patient-scope floor — force patient= / _id= / patientIdpatient-scope: forced patient · …forced _id · …forced patientId
Temporal bounds — inject missing ge/le endstemporal: bounded ge le injected (2026-04-16..2026-07-15)
Summarization — set outputFormat when omittedsummarize: outputFormat=text
Notes-RAG — inject documentType, query fallback, min-score, auto-loadnotes: documentType=… · notes: ragQueries fallback … · notes: auto notes_load before notes_search
Widen-on-empty — drop the type filter and retry oncenotes: empty → widened (dropped type filter) and retried
Idempotency stub — collapse an identical repeat callidempotent: duplicate call stubbed
Observation digest — summarize an Observation bundledigest: observations summarized
typescript
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."

digest output
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).

typescript
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.

ValueShape
fullRaw FHIR JSON (the default).
textCompact human/LLM text, one line per resource, tables for bundles — ~70–90% fewer tokens.
compact-fhirA reduced FHIR JSON subset.

Search FHIR resources with query parameters. The guardrail layer rebinds the patient and injects date bounds before it runs.

ParamReqDescription
resourceTypeyesFHIR resource type, e.g. Observation, Condition, MedicationRequest.
queryParamsnoFHIR search params, e.g. category=laboratory&date=ge2026-01-01.
outputFormatnofull | compact-fhir | text.
Request args
json
{
  "resourceType": "Observation",
  "queryParams": "patient=p-123&category=laboratory&date=ge2026-04-16&date=le2026-07-15",
  "outputFormat": "text"
}
Result (text)
ToolResult.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]
fhir_readread

Read a single FHIR resource by type and id.

ParamReqDescription
resourceTypeyesFHIR resource type.
idyesResource id.
outputFormatnofull | compact-fhir | text.
request args
{ "resourceType": "DocumentReference", "id": "doc-42", "outputFormat": "full" }
fhir_create · fhir_updatewrite · blocked by default

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.

ToolParams
fhir_createresourceType (req) · resource (object, req) · outputFormat
fhir_updateresourceType (req) · id (req) · resource (object, req) · outputFormat
notes_loadread · index

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.

ParamReqDescription
patientIdyesFHIR Patient id.
dateRangenoFHIR date filter, e.g. ge2024-01-01. Defaults to the last 24 months.
documentTypenoDocumentReference type token(s) — LOINC. See doc-type codes.
encounterIdnoRestrict to one encounter.
docIdsnoSpecific DocumentReference ids.
returnnosummary (default, receipt only) or sections (also return indexed text).
Request args
json
{ "patientId": "p-123", "documentType": "11506-3,18842-5" }
Receipt
json
{
  "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 docIdfhir_read the full note before any clinical decision.

ParamReqDescription
patientIdyesFHIR Patient id.
queriesyesPlain-text query strings (array).
topKnoMax snippets per query; default 5.
minScorenoMinimum cosine similarity 0–1; default from config.
Request args
json
{
  "patientId": "p-123",
  "queries": ["oxygen requirement trend", "discharge barriers"],
  "topK": 5, "minScore": 0.35
}
Result
json
{
  "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
}
skill_referencelocal · in-process

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.

ParamReqDescription
nameyesReference 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.

LabelLOINCMeaning
progress-note11506-3Progress note
consult-note11488-4Consult note
history-and-physical34117-2History and physical note
discharge-summary18842-5Discharge summary
imaging-report18748-4Diagnostic imaging study
operative-note11504-8Surgical operation note
procedure-note28570-0Procedure note
nursing-note34746-8Nursing note
ed-note34111-5Emergency department note
pathology-report11526-1Pathology study
cardiology-report34752-6Cardiology study (echo/EKG)
neurophysiology11522-0Electroencephalogram (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.

POST/v1/agents
GET/v1/agents
GET/v1/agents/:id
POST/v1/agents/:id · new version
POST/v1/agents/:id/archive
POST /v1/agents · request
POST/v1/agents
{
  "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
  }
}
201 · response
201 Created
{
  "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 }.

POST/v1/skills
GET/v1/skills
GET/v1/skills/:id
POST /v1/skills · request (text/markdown)
POST/v1/skills
---
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 …
201 · response
201 Created
{
  "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.

POST/v1/sessions
GET/v1/sessions/:id
POST/v1/sessions/:id/archive
interface SessionInput
FieldReqMeaning
agentIdyesRegistered agent id.
agentVersionnoDefaults to latest.
patientIdyesBinds the patient-scope guardrail floor.
encounterIdnoEncounter context for windowing.
skillIdnoExplicit single-skill selection.
skillIdsnoMulti-skill injection, in order; takes precedence over skillId.
POST /v1/sessions · request
POST/v1/sessions
{
  "agentId": "discharge-summary-agent",
  "agentVersion": 2,
  "patientId": "patient-07",
  "encounterId": "enc-2026-0114",
  "skillId": "discharge-summaries"
}
201 · response
201 Created
{
  "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 statecreated · 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:

POST/v1/sessions/:id/events
POST/v1/sessions/:id/events?stream=true
GET/v1/sessions/:id/stream?from=<cursor>
GET/v1/sessions/:id/events?after=<cursor>&limit=
  • (a) fire-and-streamPOST …/events returns 202 {"accepted":true}; consume GET …/stream for the turn's events. The Explorer relays this.
  • (b) inlinePOST …/events?stream=true with a user_message responds with text/event-stream for that turn only, then closes.
type InboundEvent
typescript
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.

GET/v1/environments → { "data": [{ "id":"default", "type":"self_hosted" }] }
POST/v1/vaults → { "ok": true, "note": "no-op; MCP auth is the inline signer" }
GET/health · /healthz → { "status": "ok" } · no auth

Every error is { "error": { "type": string, "message": string } }:

Statuserror.typeWhere
400invalid_agentPOST /v1/agents · POST /v1/agents/:id
400invalid_skillPOST /v1/skills
400invalid_sessionPOST /v1/sessions
401unauthorizedany authed route
404not_foundGET/POST by-id routes
SSEloop_error · llm_errorerror 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 …/stream sends event: heartbeat with empty data every heartbeatMs (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.
GET /v1/sessions/…/stream — one turn
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.

typePayload
session.createdagentId · agentVersion
turn.startedturnId
statuslabelthinking · continuing · interrupted
skill.selectedskillId · via: "explicit" | "model"
tool.starttoolId · tool · args
tool.resulttoolId · tool · tokens · ms · response · adjustments?
text.deltatext — streamed incrementally
message.completedcontent — the final turn text
turn.completedturnId · trace: TurnTrace
errorerror: { type, message }
Declared but not emitted by the current loop.

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.

care-coordination.agent.yaml
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 }
FieldReqMeaning
nameyesDisplay name; runtime id is slug(name).
model.idyesModel id; KARTHA_MODEL_ID env overrides every agent.
model.speednostandard (default) | fast.
systemyesSystem prompt.
descriptionnoAgent card blurb.
mcp_serversno{ name, type:"url", url }; ${VAR} expanded at load.
toolsno{ type:"mcp_toolset", mcp_server_name, allow: [names] }.
skillsno{ skill_id, version:"latest"|n }; an unresolvable id throws at startup.
kartha.retrievalno{ encounterMaxDays, defaultMedLookbackDays, notesMinScore }.
kartha.guardrailsno{ readOnly, patientScopeFloor, dateBounded } — all default true.
kartha.maxTurnsnoMax loop iterations per turn; default 8 (MAX_ITERS overrides).
Writes are double-locked.

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.

KeyReqMeaning
skill_idyesGlobally unique, kebab-case.
nameyesDisplay name (default = skill_id).
descriptionyesProgressive-disclosure summary.
licenseyescustomer | kartha-premium.
versionyesInteger.
ragQueriesnoSeeds notes_search queries.
docTypesnoFriendly note-type labels → LOINC for notes_load.
source · review_date · reviewed_bygovGovernance 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.

card types
{"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.

typescript
// —— 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.

typescript
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).

Content capture is double-gated.

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 · pathBehavior
GET /healthzLiveness; registered before the auth gate. App Runner health target.
GET /api/healthProbes the MCP URL → { ok, mcp, tenantId, agents, provider, model? }.
GET /api/bootstrapConsole boot payload: agents (with skill posture) + patient catalog.
POST /api/sessionsRelays to agent-server POST /v1/sessions.
POST /api/sessions/:id/message{ content } → inline SSE turn, piped back to the browser.
GET /api/sessions/:id/traceRelays 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

VarMeaning
KARTHA_MCP_URLMCP endpoint; unset → local fixture http://localhost:8848/mcp.
KARTHA_MCP_KMS_KEY_ID + KARTHA_MCP_KIDAWS KMS signer via the credential chain. Both blank → throwaway local key (fixture only).
KARTHA_MCP_CUSTOMER_ID · KARTHA_MCP_TENANT_IDJWT iss and the verified tenant claim.
AI_PROVIDER · ANTHROPIC_API_KEY · AWS_BEARER_TOKEN_BEDROCKProvider select; neither key set → offline DemoProvider.
KARTHA_MODEL_IDOverrides every agent's model (Bedrock inference-profile id on Bedrock).
KARTHA_GUIDELINE_SETRegion pack for reference variants, e.g. in-iap; empty = US defaults.
NOTES_MIN_SCORE · DEFAULT_MED_LOOKBACK_DAYS · ENCOUNTER_MAX_DAYS · MAX_ITERSRetrieval / loop knobs (override agent YAML).

Run it

bash
# 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:lint

Batch 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.

bash
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.

BatchResult
{
  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 }
}
Batch fails loud on entitlement.

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.