Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

@health-samurai/interbox is the workspace authoring SDK for Interbox, an extensible healthcare integration engine. A workspace repo declares pipelines against this package; the Interbox engine loads the resulting PipelineRegistry at boot and runs the pipelines on its typed source → mapper → sender runtime.

What ships in this package

The SDK is a declaration layer only — the pipeline() DSL, the PipelineRegistry, the env() sentinel, and the config-shape contracts for the engine’s built-in stage types. It contains no worker implementations; stages are referenced by a type string (via typed descriptors like mllpSource/aidboxSender/hl7v2Parser) and the engine owns the runtime behavior for each.

Alongside the TypeScript surface, the package also ships:

  • Generated domain models — HL7v2 (/hl7v2) and FHIR R4 (/fhir, /fhir/4.0.1) types, so a workspace’s mappers read/write typed data instead of untyped JSON.
  • An interbox CLI — workspace setup: sync and pin, see CLI.
  • Claude Code skills (skills/interbox/*) — procedural knowledge for an AI assistant working in a workspace repo, mirrored into .claude/skills/interbox-* by interbox sync.

The pipeline model

A pipeline is source → mapper → sender:

  1. A source listens for or polls inbound messages (e.g. an MLLP TCP listener) and hands each one to a parser, which turns raw bytes into a typed, queryable structure (e.g. an HL7v2 segment tree).
  2. A mapper reads the parser’s output and produces the outbound domain model — for Interbox today, FHIR resources.
  3. A sender delivers the mapped output to a destination (e.g. posts FHIR resources to Aidbox).

Each stage is configured by a workspace author through this SDK, but run by the engine — the SDK never imports engine internals (no database driver, no worker threads), which is what keeps a workspace’s bundle small and portable.

Where to go next

  • Getting Started — install the SDK, wire a workspace, build and test it.
  • Concepts — the pipeline DSL, stage contracts, config/env authoring, the error taxonomy, and the CLI.
  • Integration Guidelines — practical rules for building a production integration: spec-first mapping, error classification, terminology, resend-safety, the edit loop.
  • Reference — every exported subpath, symbol by symbol.

The library runs in any ESM environment (Node, Bun, bundlers). The bundled interbox CLI requires Bun.

Getting Started

Start from the workspace template

Don’t start an empty project. interbox-workspace is the reference pipeline template — fork it, adjust for your own data, and deploy:

git clone https://github.com/HealthSamurai/interbox-workspace.git
cd interbox-workspace
docker compose up

No .env to copy first — every setting docker-compose.yaml reads has a built-in default (AIDBOX_CLIENT_SECRET, INTERBOX_VERSION, …), and the image is licensed but there’s no manual key to paste in either: the dashboard’s first-run portal OAuth flow signs both the Interbox and Aidbox licenses for you — the pipeline just stays paused (with an activation screen) until you click through it once.

Open http://localhost:3001 for the dashboard, then send HL7v2 over MLLP to localhost:2575 and watch messages flow through to the FHIR server.

No MLLP client handy? The dashboard’s Sources screen has a “Simulate message” panel on each non-facility source — paste raw HL7v2 (or drag in .hl7/text files) and hit Send. It’s not a dry run: the pasted message goes through the exact same parse → mapper → sender path as a real MLLP write, so it’s a fine way to poke the pipeline before wiring up a real sender.

Copy .env.example to .env only if you want to override a default — pinning INTERBOX_VERSION, setting a real AIDBOX_CLIENT_SECRET, or pointing at a private registry.

# postinstall runs `interbox sync` automatically, mirroring this package's
# skills/interbox/* into .claude/skills/interbox-*
bun install
bun run typecheck
bun test

How a workspace is laid out

src/
  index.ts             # imports ./mappers and ./pipelines (registration side effects),
                        # re-exports { MapperRegistry, PipelineRegistry } for the engine
  mappers/
    index.ts           # imports every mapper module
    v2-to-fhir/…        # your defineMapper() implementations
  pipelines/
    index.ts           # imports every pipeline module
    hl7-to-aidbox/index.ts
// src/index.ts
import "./mappers";
import "./pipelines";
export { MapperRegistry, PipelineRegistry } from "@health-samurai/interbox";
// src/pipelines/hl7-to-aidbox/index.ts
import { env, pipeline } from "@health-samurai/interbox";
import {
  aidboxSender,
  hl7v2Parser,
  mllpSource,
} from "@health-samurai/interbox/builtins";
import { v2ToFhirMapper } from "../../mappers/v2-to-fhir/index.ts";

pipeline("hl7-to-aidbox")
  .source(
    mllpSource({
      id: "mllp-default",
      host: env("MLLP_HOST"),
      port: env("MLLP_PORT"),
      parser: hl7v2Parser({ skipZSegments: false }),
    }),
  )
  .mapper(v2ToFhirMapper())
  .sender(
    aidboxSender({
      url: env("AIDBOX_URL"),
      auth: { kind: "basic", user: env("AIDBOX_CLIENT_ID", "root"), password: env("AIDBOX_CLIENT_SECRET") },
    }),
  );

Every stage is referenced by value (built-in descriptors + your own mapper), so types — including the source↔mapper parser match, see Pipelines — are checked at compile time. Deployment values (MLLP_HOST, AIDBOX_URL, secrets) come from env(), resolved by the engine when it builds the runner config — see Env & Config.

The engine bundles src/index.ts itself — a workspace has no build step of its own. It takes one INTERBOX_WORKSPACE_GIT_URL: a remote URL (clones + bun installs, for deploy) or a local file:///bare path (bundles the live working tree in place, no clone/install, for a fast edit → hot-reload loop). A pipeline change reloads into its own runtime, not by restarting interbox itself: in local dev the engine polls the workspace (INTERBOX_WORKSPACE_POLL_MS, 2s in the template’s docker-compose.yaml) and swaps workers in place; elsewhere, trigger the same reload from the dashboard (“Pull now”).

Pipelines

pipeline(name) both creates and registers a pipeline declaration in one call — the returned builder is the declaration. Chained .source()/ .mapper()/.sender() calls mutate the same registered object, so it doesn’t matter whether the engine reads PipelineRegistry before or after a module finishes evaluating; by the time any workspace module has fully run, every pipeline it declared is in the registry.

import { pipeline, env } from "@health-samurai/interbox";
import {
  mllpSource,
  aidboxSender,
  hl7v2Parser,
} from "@health-samurai/interbox/builtins";
// a workspace-defined mapper (defineMapper)
import { v2ToFhir } from "./mappers/v2-to-fhir";

pipeline("hl7-to-aidbox")
  .source(
    mllpSource({
      id: "mllp-default",
      port: env("MLLP_PORT"),
      parser: hl7v2Parser(),
    }),
  )
  .mapper(v2ToFhir())
  .sender(
    aidboxSender({
      url: env("AIDBOX_URL"),
      auth: { kind: "basic", user: env("AIDBOX_CLIENT_ID", "root"), password: env("AIDBOX_CLIENT_SECRET") },
    }),
  );

The type-level guarantee: mappers can’t outrun their sources

PipelineBuilder tracks, at the type level, the union of parser types produced by the sources added so far (SP). .mapper() requires its mapper’s parser to be a member of SP — added after a matching .source() call. Add a mapper before any source (or before a source with a matching parser), and TypeScript reports it at the .mapper() call site instead of letting it fail silently at runtime:

Property 'interbox: no source in this pipeline produces parser "hl7v2" —
add a matching .source() before .mapper()' is missing …

This is enforced structurally (an intersection type that resolves to a readable sentinel object on mismatch, unknown — a no-op — on match), not by a runtime check, so there’s no cost outside the type checker.

What gets stored

Each stage call appends a plain declaration object to the pipeline — not the descriptor/builder value itself:

interface SourceDecl {
  type: string;
  id: string;
  config: Record<string, unknown>;
  parser: { type: string; config: unknown };
}

interface MapperDecl {
  type: string;
  config: unknown;
}

interface SenderDecl {
  type: string;
  config: unknown;
}

interface PipelineDecl {
  readonly name: string;
  readonly sources: SourceDecl[];
  readonly mappers: MapperDecl[];
  readonly senders: SenderDecl[];
}

This is the shape the engine actually reads back — it’s why you can also build a pipeline from raw object literals (as in Getting Started) instead of the typed descriptors; the descriptors just add compile-time shape-checking and the source/mapper parser-matching guarantee above.

PipelineRegistry

const PipelineRegistry: {
  SDK_VERSION: string;                      // re-exported for the engine's compat check
  pipelines: Map<string, PipelineDecl>;
  register(p: PipelineDecl): void;          // throws on a duplicate name
  get(name: string): PipelineDecl | undefined;
  getAll(): PipelineDecl[];
  clear(): void;                            // test-only: reset all registered pipelines
};

A workspace’s src/index.ts re-exports this object verbatim (export { PipelineRegistry } from "@health-samurai/interbox") so the engine has a single read path regardless of whether the workspace is loaded as a local path or a bundled git clone. Two pipelines can’t share a name — register() throws duplicate pipeline name: <name>.

See Reference → root for the full exported-type list (ConfiguredSource, SourceDescriptor, ConfiguredMapper, etc.).

Stages: source, parser, mapper, sender

A pipeline has three stage kinds plus a parser bound to each source. Each is identified by a type string; the SDK carries the config shape and a handful of built-in descriptors — the engine owns every actual implementation, looked up by type at runtime. This is what keeps a workspace’s bundle free of database drivers, worker-thread code, and network clients.

Who implements what:

stagewho writes ityou configure it with
sourceenginemllpSource({ id, host, port, parser })
parserenginehl7v2Parser({ skipZSegments }), bound to a source
mapperyoudefineMapper({ type, parser, map })
senderengineaidboxSender({ url, auth, batchSize, maxRetries })

Source + parser

A source produces raw messages (e.g. an MLLP TCP listener); its bound parser turns each one into a typed structure and determines which inbound table it lands in — the table is derived from the parser, never configured separately.

You never implement a source or parser; you pick a built-in descriptor and configure it — see /builtins.

Mapper

A mapper reads a parser’s output and produces the outbound domain model (FHIR resources, for Interbox today). This is the one stage kind meant to hold your business logic — the whole point of a workspace repo:

defineMapper({
  type: "v2-to-fhir",
  // by descriptor — types `input` below and ties the mapper to that
  // parser's output table
  parser: hl7v2Parser,
  // input: ParserOutputMap["hl7v2"], ctx: MapperContext
  map(config, input, ctx) {
    // ... return a FHIR resource, an array of them, or undefined to skip
  },
});

See Mapper authoring for the full defineMapper/ MapperRegistry reference, and Errors for how a mapper reports a failure the engine can classify.

Sender

A sender delivers mapped output to a destination (e.g. posts FHIR to Aidbox). Delivery, batching, and retries are engine-owned; you tune them through the sender’s config:

  • batchSize — max rows claimed per delivery loop.
  • maxRetries — consecutive transient failures (destination unreachable, 5xx) to retry before the batch is recorded as errored. Permanent rejections (e.g. Aidbox validation errors) don’t retry — they land in the error queue as aidbox_rejected for triage.

Why the split

The engine implements the stages; the SDK’s builtins/* module supplies typed descriptors (mllpSource, aidboxSender, hl7v2Parser) so a workspace references a built-in stage by value, with full type-checking on its config, instead of by a bare string. The only stage a workspace implements itself is the mapper, via defineMapper. (The underlying engine-side contracts live in /core — you’ll rarely need them.)

Env references & resolved config

env()

A workspace author wraps a value with env("NAME") instead of reading process.env directly at module-eval time. This keeps the pipeline declaration — and anything that introspects PipelineRegistry before the engine resolves it — free of resolved secrets:

import { env, isEnvRef } from "@health-samurai/interbox";

env("AIDBOX_CLIENT_SECRET");        // => { $env: "AIDBOX_CLIENT_SECRET" }
isEnvRef(env("X"));          // => true
isEnvRef("literal-value");   // => false
interface EnvRef { readonly $env: string; readonly $default?: string }
function env(name: string, fallback?: string): EnvRef;  // fallback = default when unset
function isEnvRef(value: unknown): value is EnvRef;

The engine resolves every EnvRef against its own process environment when it builds the runner config — a workspace author never resolves one by hand.

Authoring types: Str / Num

Config fields that may be supplied either literally or via env() are typed with these two unions instead of string/number:

type Str = string | EnvRef;
type Num = number | EnvRef;

You’ll see these on every built-in config — e.g. MllpSourceConfig.port: Num, AidboxSenderConfig.url: Str (see /builtins).

Resolved<T>

A mapped type that strips every EnvRef branch out of an authoring config, recursively — Str collapses to string, Num to number, arrays and nested objects are walked:

type Resolved<T> = T extends EnvRef
  ? never
  : T extends readonly (infer U)[]
    ? Resolved<U>[]
    : T extends object
      ? { [K in keyof T]: Resolved<T[K]> }
      : T;

This lets the engine derive its own factory config type from the SDK’s authoring config type (Resolved<MllpSourceConfig>, for instance) instead of hand-maintaining a second, parallel “resolved” interface that could drift from the authoring one.

Error taxonomy

Every ingestion failure the engine records carries a machine-readable kind of the form <group>/<specific>, persisted to the inbound row’s error_kind column — so failures facet by cause family (a prefix match on group) as well as by exact kind. This is what powers the dashboard’s error-queue faceting and triage.

Groups

const ERROR_GROUPS = [
  "parse",
  "structure",
  "field",
  "type",
  "code",
  "unsupported",
  "internal",
] as const;

type ErrorGroup = (typeof ERROR_GROUPS)[number];
groupmeaning
parsepayload-level: not HL7 at all, MSH header missing/broken
structuremessage shape: required segment absent or unusable
fieldsegment present, required field empty
typefield present, value violates its declared HL7 datatype
codecoded value outside its value set / no concept mapping
unsupportedvalid message the engine has no converter for
internalinvariant violations — bugs, not sender data

Raising one

A mapper (or any stage implementation) raises a classified failure with domainError:

import { domainError } from "@health-samurai/interbox";

throw domainError(
  "unsupported",
  "message_type",
  "no converter registered for ORU^R01 with this OBX pattern",
);
// => Error, .name === "InterboxException", .kind === "unsupported/message_type"
function domainError(group: ErrorGroup, kind: string, message: string): Error;

The engine duck-types on .name === "InterboxException" (not instanceof, since the SDK and engine may be bundled from different module graphs) and persists .kind to error_kind. Throwing a plain Error instead still fails the message, but the engine can’t classify it beyond a generic bucket — always prefer domainError for anything a mapper can anticipate.

CLI

The package ships an interbox bin (bunx interbox …, or bun run interbox-cli … in the workspace template, which aliases it).

interbox sync

# mirror skills/interbox/* -> .claude/skills/interbox-*
interbox sync

Mirrors the Claude Code skills shipped in this package into the workspace’s .claude/skills/. The template runs it from postinstall, so bun install keeps the skills in step with the installed SDK version. It prunes any interbox-* skill directory the installed version no longer ships and never touches skills outside that namespace.

interbox pin

# pin @health-samurai/interbox in package.json (or $INTERBOX_VERSION)
interbox pin <version>

Rewrites the @health-samurai/interbox dependency in the current directory’s package.json. A bare X.Y.Z is written as ^X.Y.Z; anything else (a moving tag like edge, or an explicit range) is written verbatim. With no argument it reads $INTERBOX_VERSION — the same knob the template’s docker-compose uses for the engine image tag, so one variable pins both.

The engine (Docker image) and the SDK (npm package) are versioned in lockstep — pin both to the same number to guarantee they came from the same commit.

The assistant namespace

The CLI also has an interbox assistant … namespace of read-only and dry-run subcommands (spec lookups, message parsing, error-queue access). These exist as tooling for the bundled AI skills and the dashboard’s assistant — they’re not part of the workspace-authoring workflow this book documents. The human-facing equivalents are the dashboard (error queue, message inspection, “Simulate message”) and your editor working against the SDK’s generated types.

Integration guidelines

Practical advice for building a production integration on Interbox — the things the compiler can’t catch.

Scope from your samples, handle per the spec

Real traffic decides what you map — there’s no point converting fields your senders never populate, and no way to test them if you do. Start from representative samples and map what’s actually there.

The spec decides how you map it. Samples under-represent variation within the fields you did choose: a patient with one name in every sample can still arrive with three name repetitions, a component you’ve never seen populated, or a table value your samples didn’t cover — same sender, next month. So for each field you take on, check its datatype components, repetition, and table binding in the standard rather than assuming the sample’s shape is the shape. The generated accessors in /hl7v2 give you names and types in the editor.

And make what you didn’t map explicit. Return undefined for message types the pipeline genuinely shouldn’t process; throw domainError("unsupported", …) for ones that should be visible in the error queue until someone decides. Silently dropping data a sender starts sending is the only wrong option.

Same on the FHIR side: the generated types in /fhir enforce field names and shapes at compile time, but cardinality and code bindings come from the R4 spec — a 1..1 field is required, a bound coded field expects one of its value set’s codes.

Classify failures

Throw domainError(group, kind, message) for anything your mapper can anticipate — a plain throw collapses into a generic bucket in the error queue, while classified errors get bucketed automatically by triage. See Errors for choosing the group.

Two habits that pay off later:

  • Put the offending value and its position in the message — "PV1.2 'X' is not a valid patient class". Error inspection prints the raw message by position, so a good message makes the diagnosis one read.
  • Return undefined from map() for messages you intentionally don’t process. A skip is not an error; reserve throwing for actual failures.

Terminology lives in ConceptMaps, not in mapper code

Resolve local codes through ctx.translate(conceptMapId, code) rather than hardcoding code→LOINC tables in the mapper. Unmapped codes then surface as code/unmapped_* errors and get resolved at runtime — through the dashboard, no code change, no reload.

Two things to know about how this behaves:

  • A message errors on its first unmapped code. An ORU panel with six local codes needs all six resolved before one retry clears it.
  • Each sender has its own ConceptMap, deliberately — two facilities can map the same local code to different targets. Resolving a code for one channel doesn’t affect another.

Design for resend

Delivery is at-least-once: transient sender failures retry, and failed messages get re-queued during triage. So:

  • Key resources on business identifiers (MRN, filler order number, visit number) — stable across resends — never on anything generated per message.
  • After changing identity logic, resend a message and check you updated one resource instead of creating a duplicate.

The edit loop

  1. Feed a sample message through the dashboard: Sources → Simulate message runs the real parse → map → send pipeline, no MLLP client needed.
  2. Fix the mapper.
  3. A code fix needs a reload before retry — the engine runs a bundled copy of your workspace, so retrying immediately re-hits the old code. In local docker-compose dev the reload is automatic (~2s poll); otherwise trigger it from the dashboard (“Pull now”). ConceptMap resolves and queue operations take effect without a reload.
  4. Retry the specific failed messages and verify they clear.

Keep the workspace shaped like the template

  • One module per message type (mappers/v2-to-fhir/messages/oru-r01.ts) and per segment converter (segments/pid-patient.ts). Errors are reported by message type and field position, so this layout makes every diagnosis point at one file.
  • One pipeline per channel/facility. Channel-specific behavior belongs in mapper config and per-sender ConceptMaps, not in if (facility === …) branches.
  • Deployment values and secrets through env() — never process.env in a pipeline module, never a hardcoded host or credential.

Test the mapper, not the engine

Unit-test mappers with bun test: parse a fixture with parseHl7v2, call the mapper’s map() directly, assert on the emitted resources. Call PipelineRegistry.clear() / MapperRegistry.clear() in afterEach — registration is a module side effect and leaks between tests otherwise. The engine’s queueing, batching, and retries aren’t yours to test.

Mind the PHI

Message payloads, error-queue inspection output, and FHIR query results all carry PHI. Keep them out of logs, commits, and fixture files — synthesize test messages instead of copying production ones.

Root — @health-samurai/interbox

Subpaths

importwhat it is
@health-samurai/interboxthe authoring surface: pipeline, env, defineMapper, error taxonomy (below)
@health-samurai/interbox/coreshared stage contracts — rarely imported directly, see /core
@health-samurai/interbox/builtinstyped descriptors for the engine’s built-in stages — /builtins
@health-samurai/interbox/hl7v2generated HL7v2 domain model + parser — /hl7v2
@health-samurai/interbox/fhirFHIR R4 core types + profile helpers — /fhir
@health-samurai/interbox/fhir/4.0.1same, pinned to the 4.0.1 subpath explicitly
@health-samurai/interbox/fhir/4.0.1/profilesgenerated profile classes
@health-samurai/interbox/fhir/4.0.1/profile-helpersruntime helpers profile classes call

Root exports

Pipeline authoring — see Pipelines

// creates AND registers the pipeline; chain .source()/.mapper()/.sender()
function pipeline(name: string): PipelineBuilder;

const PipelineRegistry: {
  SDK_VERSION: string;
  pipelines: Map<string, PipelineDecl>;
  register(p: PipelineDecl): void;          // throws on a duplicate name
  get(name: string): PipelineDecl | undefined;
  getAll(): PipelineDecl[];
  clear(): void;                            // test-only
};

The declaration types (PipelineDecl, SourceDecl, MapperDecl, SenderDecl) and the descriptor types (SourceDescriptor, ConfiguredSource, SenderDescriptor, ConfiguredSender, ParserDescriptor, ConfiguredParser) are also exported. You rarely write these by hand — calling mllpSource(...) / aidboxSender(...) / hl7v2Parser(...) produces correctly-typed values (see /builtins); the decl shapes are shown in Pipelines → What gets stored.

SDK_VERSION

const SDK_VERSION: string; // sourced from this package's own package.json

Carried into the workspace bundle via PipelineRegistry.SDK_VERSION so the engine can check compatibility at load time.

Env — see Env & Config

function env(name: string, fallback?: string): EnvRef;
function isEnvRef(value: unknown): value is EnvRef;

interface EnvRef {
  readonly $env: string;
  readonly $default?: string;
}

type Str = string | EnvRef;
type Num = number | EnvRef;

Errors — see Errors

function domainError(group: ErrorGroup, kind: string, message: string): Error;

const ERROR_GROUPS: readonly [
  "parse",
  "structure",
  "field",
  "type",
  "code",
  "unsupported",
  "internal",
];

type ErrorGroup = (typeof ERROR_GROUPS)[number];

Mapper authoring — see Mapper authoring

function defineMapper<K extends ParserType, Cfg = unknown, Out = unknown>(
  spec: MapperSpec<K, Cfg, Out>,
): MapperDescriptor<K, Cfg>;

const MapperRegistry: {
  mappers: Map<string, MapperDefinition>;
  register(def: MapperDefinition): void;
  get(type: string): MapperDefinition | undefined;
  getAll(): MapperDefinition[];
  clear(): void;
};

plus the re-exported types ConfiguredMapper, MappedCode, MapperContext, MapperDefinition, MapperDescriptor, ParserOutputMap, ParserType.

/core

import type { ... } from "@health-samurai/interbox/core";

Pure stage contracts shared by the SDK (descriptors, authoring) and the engine (factories, runtime) — types + light pure helpers only, no drizzle/pg/multithreading, so a workspace’s bundle stays lean.

A workspace author rarely imports from /core directly. Everything you use day-to-day — defineMapper, MapperContext, env(), the builtin config types — is exported from the package root or /builtins, and map()’s parameter types are inferred for you. This subpath exists so the engine and the SDK share one set of type definitions and can’t drift.

What’s in it, by category:

categorytypeswho cares
authoring valuesStr, Num, Resolvedyou, occasionally — see Env & Config
mapper contractsMapperDefinition, MapperContext, MappedCode, ParserOutputMap, ParserTypeyou, via defineMapper — usually inferred, import only to annotate helpers
error taxonomydomainError, ERROR_GROUPS, ErrorGroupyou — re-exported from the root, see Errors
stage runtime contractsSourceDefinition/SenderDefinition/ParserDefinition + their *Config/*Entry/*Factory/*Instance siblings, Runnerthe engine — a workspace never implements these

One import that does come up: annotating a mapper helper function that takes the context —

import type { MapperContext } from "@health-samurai/interbox/core";

async function resolveObservationCode(ctx: MapperContext, code: string) {
  return ctx.translate("obs-codes", code);
}

For the exact shapes of the mapper contracts, see Mapper authoring. For the engine-side runtime contracts, read src/core/*.ts in the package — they’re small, documented files, and if you’re implementing one you’re working on the engine, not a workspace.

/builtins

import {
  mllpSource,
  aidboxSender,
  hl7v2Parser,
} from "@health-samurai/interbox/builtins";

Typed handles for the engine’s built-in stages, grouped by kind. A pipeline references these by value; each stage’s config is co-located with its descriptor. The engine owns the implementations and resolves them by type at runtime — see Stages.

mllpSource — MLLP TCP listener source

interface MllpSourceConfig {
  host?: Str;
  port: Num;
}

const mllpSource: SourceDescriptor<"mllp", MllpSourceConfig>;
mllpSource({
  id: "mllp-default",
  port: env("MLLP_PORT"),
  parser: hl7v2Parser({ skipZSegments: false }),
});

aidboxSender — Aidbox FHIR sender

type AidboxAuth =
  | { kind: "basic"; user: Str; password: Str }
  | { kind: "bearer"; token: Str };

interface AidboxSenderConfig {
  url: Str;
  auth: AidboxAuth;
  batchSize?: number;  // max rows claimed per loop
  // consecutive transient failures to retry before the batch is recorded errored
  maxRetries?: number;
}

const aidboxSender: SenderDescriptor<"aidbox", AidboxSenderConfig>;
aidboxSender({
  url: env("AIDBOX_URL"),
  auth: { kind: "basic", user: env("AIDBOX_CLIENT_ID", "root"), password: env("AIDBOX_CLIENT_SECRET") },
});

hl7v2Parser — HL7v2 parser

interface Hl7v2ParserConfig {
  skipZSegments?: boolean;
}

const hl7v2Parser: ParserDescriptor<"hl7v2", Hl7v2ParserConfig>;
hl7v2Parser();                          // config optional, defaults to {}
hl7v2Parser({ skipZSegments: true });

Bound to a source’s parser field — see Pipelines for how a source’s parser type propagates to which mappers can attach to it.

Mapper authoring

import { defineMapper, MapperRegistry } from "@health-samurai/interbox";

defineMapper is exported from the root and re-exported (with all its types) from @health-samurai/interbox/core’s mapper types. Unlike sources/senders/parsers, mappers are the one stage kind a workspace defines itself — see Stages for why.

defineMapper

function defineMapper<K extends ParserType, Cfg = unknown, Out = unknown>(
  spec: MapperSpec<K, Cfg, Out>,
): MapperDescriptor<K, Cfg>;

interface MapperSpec<K extends ParserType, Cfg, Out> {
  readonly type: string;
  // pass a parser descriptor, e.g. hl7v2Parser — types `input` below
  readonly parser: { readonly type: K };
  map(
    config: Cfg,
    input: ParserOutputMap[K],
    ctx: MapperContext,
  ): Out | undefined | Promise<Out | undefined>;
}

Calling defineMapper(...) registers the definition into MapperRegistry immediately (for the engine to read back) and returns a callable descriptor — calling that produces a ConfiguredMapper for a pipeline’s .mapper():

const v2ToFhir = defineMapper({
  type: "v2-to-fhir",
  parser: hl7v2Parser,
  map(config, input, ctx) {
    // input: parsed HL7v2 segments
    // return a FHIR resource, an array, or undefined to skip
  },
});

pipeline("hl7-to-aidbox")
  .source(/* ... */)
  .mapper(v2ToFhir(/* config, if Cfg isn't unknown */));

map may return undefined (skip — no resource produced), a single resource, an array of resources, or a Promise of either. Throw a domainError (see Errors) for anything the engine should classify rather than treat as an internal bug.

MapperRegistry

const MapperRegistry: {
  mappers: Map<string, MapperDefinition>;
  register(def: MapperDefinition): void;  // throws on a duplicate `type`
  get(type: string): MapperDefinition | undefined;
  getAll(): MapperDefinition[];
  clear(): void;                          // test-only
};

The engine reads this registry back from the workspace bundle at load time to know which mappers exist and which parser each one consumes.

Supporting types

interface ConfiguredMapper<MK extends ParserType = ParserType> {
  readonly type: string;
  readonly parser: MK;
  readonly config: unknown;
}

interface MapperDescriptor<MK extends ParserType, Cfg> {
  readonly type: string;
  readonly parser: MK;
  // config is optional only when Cfg has no required fields
  (...config: {} extends Cfg ? [config?: Cfg] : [config: Cfg]): ConfiguredMapper<MK>;
}

interface MapperDefinition<
  K extends ParserType = ParserType,
  Cfg = unknown,
  Out = unknown,
> {
  readonly type: string;
  // parser type the engine uses to parse inbound messages before map()
  readonly parser: K;
  map(
    config: Cfg,
    input: ParserOutputMap[K],
    ctx: MapperContext,
  ): Out | undefined | Promise<Out | undefined>;
}

// Runtime services the engine hands a mapper. Terminology lookups resolve against
// the engine-owned ConceptMap store; the workspace never touches the DB directly.
interface MapperContext {
  translate(conceptMapId: string, code: string): Promise<MappedCode | undefined>;
}

// A resolved terminology mapping (ConceptMap target).
interface MappedCode {
  targetCode: string;
  targetDisplay?: string;
}

// Parser type -> the parsed value the engine hands map(). Extend via declaration
// merge if a future built-in parser adds another key.
interface ParserOutputMap {
  hl7v2: HL7v2Segment[];
}

type ParserType = keyof ParserOutputMap;

ParserOutputMap is the type-level link between a source’s parser and what a mapper attached to it receives as input — it’s why .mapper() can type-check input against the exact parser the mapper declared, and why Pipelines’s SP tracking works.

/hl7v2

import {
  parseHl7v2,
  findSegment,
  getComponent,
} from "@health-samurai/interbox/hl7v2";
import type {
  HL7v2Message,
  HL7v2Segment,
  FieldValue,
} from "@health-samurai/interbox/hl7v2";

This subpath is generated (from the HL7v2 message types the workspace’s pipelines target) plus one hand-written parser module — it’s a large surface by symbol count, so this page is a map of what’s here rather than a field-by- field dump. Your editor’s autocomplete over the generated types is the day-to-day reference.

What’s exported

  • types — the core shape: FieldValue (a field’s value — a string, a repeating FieldValue[], or a component record { [n]: FieldValue }), HL7v2Segment { segment: string; fields: Record<number, FieldValue> }, HL7v2Message = HL7v2Segment[], and getComponent(field, ...path) for walking into a field’s components.
  • fields — generated per-segment field accessors (e.g. fromPID, fromMSH) and datatype interfaces, so a mapper reads a segment’s fields by name instead of numeric index.
  • tables — generated HL7v2 coded-value tables (HL7 table 0001, 0004, …), importable as typed constants.
  • messages — generated per-message-type builders/shapes (BAR_P01, ORM_O01, ORU_R01, VXU_V04 as of this writing — regenerate to add more).
  • parseparseHl7v2(message: string): HL7v2Message (throws on malformed input) and the re-exported findSegment helper. This is the parser hl7v2Parser binds to (see /builtins); it’s the package’s single HL7v2 parsing surface — everything else goes through it rather than the underlying @atomic-ehr/hl7v2 dependency directly.

The spec itself

The types cover names and shapes; for optionality, repetition, table bindings, and message structure, consult the HL7v2 standard for the version your channel speaks. The package ships the spec data (v2.4/v2.5/v2.8.2) in hl7v2-reference/ as JSON — it also powers the bundled AI-assistant skills, so an assistant working in your workspace answers spec questions from the same source.

/fhir

import type {
  Patient,
  Observation,
  /* ... */
} from "@health-samurai/interbox/fhir";
import {
  isRecord,
  /* ... */
} from "@health-samurai/interbox/fhir/4.0.1/profile-helpers";
importcontents
@health-samurai/interbox/fhirre-exports both of the below — the convenience default
@health-samurai/interbox/fhir/4.0.1generated FHIR R4 core resource/datatype interfaces (Patient, Observation, HumanName, …)
@health-samurai/interbox/fhir/4.0.1/profilesgenerated profile classes built on the core types
@health-samurai/interbox/fhir/4.0.1/profile-helpershand-written runtime helpers profile classes call: slice match/get/set/default-fill, complex-extension read, choice-type (value[x]) wrap/unwrap, structural validation, deep-match/deep-merge/path utilities

This is a generated, spec-sized surface — hundreds of R4 resource and datatype interfaces — so it isn’t enumerated field-by-field here. Your editor’s autocomplete over the interfaces covers field names and shapes; for cardinality, code bindings, and reference-target semantics, the FHIR R4 specification is the authoritative source. A 1..1 field is required; a bound coded field expects one of its value set’s codes.

Note these are the R4 core types only — profile/US-Core constraints (must-support, slicing, ValueSet URLs) live in the relevant implementation guide, not here.