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
interboxCLI — workspace setup:syncandpin, see CLI. - Claude Code skills (
skills/interbox/*) — procedural knowledge for an AI assistant working in a workspace repo, mirrored into.claude/skills/interbox-*byinterbox sync.
The pipeline model
A pipeline is source → mapper → sender:
- 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).
- A mapper reads the parser’s output and produces the outbound domain model — for Interbox today, FHIR resources.
- 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
interboxCLI 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:
| stage | who writes it | you configure it with |
|---|---|---|
| source | engine | mllpSource({ id, host, port, parser }) |
| parser | engine | hl7v2Parser({ skipZSegments }), bound to a source |
| mapper | you | defineMapper({ type, parser, map }) |
| sender | engine | aidboxSender({ 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 asaidbox_rejectedfor 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];
| group | meaning |
|---|---|
parse | payload-level: not HL7 at all, MSH header missing/broken |
structure | message shape: required segment absent or unusable |
field | segment present, required field empty |
type | field present, value violates its declared HL7 datatype |
code | coded value outside its value set / no concept mapping |
unsupported | valid message the engine has no converter for |
internal | invariant 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
undefinedfrommap()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
- Feed a sample message through the dashboard: Sources → Simulate message runs the real parse → map → send pipeline, no MLLP client needed.
- Fix the mapper.
- 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.
- 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
configand per-sender ConceptMaps, not inif (facility === …)branches. - Deployment values and secrets through
env()— neverprocess.envin 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
| import | what it is |
|---|---|
@health-samurai/interbox | the authoring surface: pipeline, env, defineMapper, error taxonomy (below) |
@health-samurai/interbox/core | shared stage contracts — rarely imported directly, see /core |
@health-samurai/interbox/builtins | typed descriptors for the engine’s built-in stages — /builtins |
@health-samurai/interbox/hl7v2 | generated HL7v2 domain model + parser — /hl7v2 |
@health-samurai/interbox/fhir | FHIR R4 core types + profile helpers — /fhir |
@health-samurai/interbox/fhir/4.0.1 | same, pinned to the 4.0.1 subpath explicitly |
@health-samurai/interbox/fhir/4.0.1/profiles | generated profile classes |
@health-samurai/interbox/fhir/4.0.1/profile-helpers | runtime 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:
| category | types | who cares |
|---|---|---|
| authoring values | Str, Num, Resolved | you, occasionally — see Env & Config |
| mapper contracts | MapperDefinition, MapperContext, MappedCode, ParserOutputMap, ParserType | you, via defineMapper — usually inferred, import only to annotate helpers |
| error taxonomy | domainError, ERROR_GROUPS, ErrorGroup | you — re-exported from the root, see Errors |
| stage runtime contracts | SourceDefinition/SenderDefinition/ParserDefinition + their *Config/*Entry/*Factory/*Instance siblings, Runner | the 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 repeatingFieldValue[], or a component record{ [n]: FieldValue }),HL7v2Segment { segment: string; fields: Record<number, FieldValue> },HL7v2Message = HL7v2Segment[], andgetComponent(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_V04as of this writing — regenerate to add more).parse—parseHl7v2(message: string): HL7v2Message(throws on malformed input) and the re-exportedfindSegmenthelper. This is the parserhl7v2Parserbinds to (see /builtins); it’s the package’s single HL7v2 parsing surface — everything else goes through it rather than the underlying@atomic-ehr/hl7v2dependency 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";
| import | contents |
|---|---|
@health-samurai/interbox/fhir | re-exports both of the below — the convenience default |
@health-samurai/interbox/fhir/4.0.1 | generated FHIR R4 core resource/datatype interfaces (Patient, Observation, HumanName, …) |
@health-samurai/interbox/fhir/4.0.1/profiles | generated profile classes built on the core types |
@health-samurai/interbox/fhir/4.0.1/profile-helpers | hand-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.