Reference pipeline for Interbox, an extensible integration engine. Fork this repo, adjust the pipeline for your own data, and deploy.
cp .env.example .env # fill in the license — see that file for options
docker compose upOpen http://localhost:3001 for the dashboard. The image is licensed: the
pipeline stays paused until you set INTERBOX_LICENSE in .env, or activate
through the dashboard itself (portal OAuth) — until then you'll see an
activation screen there.
Send HL7v2 over MLLP to localhost:2575 and watch messages flow through to
the FHIR server.
If you don't have a real upstream to point at it yet, the bundled HL7v2 simulator generates synthetic traffic (ADT / ORU / SIU / MDM / RDE / RAS) from several independent sources at once — each with its own pace, MSH identity and MRN pool, so routing and per-source error handling get exercised rather than a single uniform stream. With the stack up:
bun run hl7v2:simulatorThat installs the simulator and opens its UI on http://localhost:4003, already pointed at the MLLP port compose publishes. Click Start all and watch messages land in the dashboard.
See utils/hl7v2-simulator/ for targets, source types,
fault injection, and the generator CLI. It's a self-contained package with its own
dependencies — deliberately outside the root install, so it costs you nothing if
you never use it.
The dashboard's Repository tab browses this checkout. It can also edit it — saving straight into the directory compose mounts, which the engine picks up within two seconds, so the loop is edit → save → running.
INTERBOX_WORKSPACE_ACCESS=apply in .env is what turns that on, and
.env.example already sets it — so the Quick start above is all it takes. Skip
the .env and the engine falls back to read (browse only) and the editor does
not appear. Worth knowing what apply grants before you run this
somewhere shared: the dashboard has had a login of its own since 1.20, but
compose turns it off and publishes 3001 on every interface, so anyone who can
open it can change the code this engine runs. To turn the login on, set
INTERBOX_AUTH_ENABLED=true and an INTERBOX_AUTH_ADMIN_PASSWORD in .env;
.env.example shows how.
Edits live in your browser until you press Save, and committing what you saved stays yours to do, in your own git. A deployment works the other way round: the engine owns a clone, so the dashboard commits and pushes a branch instead — see The Workspace in the docs for both, and for what each access level allows.
The dashboard has had a login of its own since 1.20; Securing Dashboard
Access in the docs covers it.
For a cluster deployment with the dashboard properly secured — internal ingress,
TLS, and an SSO login gate in front of it — see k8s/:
k8s/k3d/— a runnable, one-command reference on k3d (local). Stands up the full secure stack (ingress-nginx + cert-manager + oauth2-proxy + Dex) so you can see it work before a real cluster.k8s/aks/— production templates for AKS (internal LB, cert-manager DNS-01 / internal CA, Entra ID SSO, managed Postgres).
The Interbox Helm chart itself lives in HealthSamurai/helm-charts.
The dashboard's assistant is a Claude Code agent running inside the container,
which has no ~/.claude of its own — so you hand it credentials through .env.
It's optional: clear all three and the dashboard still works, the assistant just
shows a "no credentials" banner instead of answering.
| Path | Set in .env |
Billing |
|---|---|---|
| Reuse host login (default; Linux/Windows dev) | CLAUDE_CONFIG_DIR=${HOME}/.claude — bind-mounts your existing login into the container |
Your existing session |
| Subscription token (any OS) | CLAUDE_CODE_OAUTH_TOKEN — run claude setup-token on the host (macOS: ./scripts/setup-claude-mac.sh does it for you) |
Claude Pro/Max subscription |
| API key | ANTHROPIC_API_KEY=sk-ant-… |
Pay-per-token |
Notes:
.env.exampleships the reuse host login path enabled, so a fresh copy works on a host that already has Claude Code. It is.env-driven — nodocker-compose.yamledit.- That mount hands the container the whole config dir: your Claude settings,
your session transcripts under
projects/, and any MCP OAuth tokens the login file carries. CommentCLAUDE_CONFIG_DIRout to expose nothing — it then falls back to an empty managed volume — and use a token or API key instead. - A token or API key outranks the mounted login, so a leftover
CLAUDE_CODE_OAUTH_TOKENsilently wins over your host session. - macOS keeps Claude Code creds in the Keychain, not in a file, so the
reuse-host-login path can't work there — use the subscription token instead
(
./scripts/setup-claude-mac.shmints one; needs the Claude Code CLI + a Pro/Max plan).
Pipeline definitions for the Interbox engine. The engine has no built-in topology — it loads the pipelines declared here into its own runtime, and loads them again when they change, without a restart.
Pipelines are authored against @health-samurai/interbox.
A pipeline is a chain: one source, any number of steps, one store, any number
of steps, one sender. Every part is referenced by value, so each step's input
is type-checked against the previous step's output. Secrets and deployment
values are referenced with env() and resolved from the engine's environment,
so definitions stay portable and secret-free.
The template's pipeline, in src/pipelines/hl7-to-aidbox/index.ts:
pipeline("hl7-to-aidbox")
.source(mllpSource({ host: env("MLLP_HOST"), port: env("MLLP_PORT") }))
.step((raws) => raws.withSearchText((raw) => raw))
.store(postgresStore({ codec: "text" }), {
index: { message_type: "btree", event_type: "btree", patient_id: "btree" },
search: "trgm",
})
.step(hl7v2Parser())
.step(v2ToFhir)
.step(fhirItems())
.step(skipUnchanged())
.sender(
aidbox({
url: env("AIDBOX_URL"),
auth: { kind: "basic", user: env("AIDBOX_CLIENT_ID", "root"), password: env("AIDBOX_CLIENT_SECRET") },
}),
);The store keeps each MLLP message as raw text, and only then does the source
answer AA, so a Retry after a parser fix parses the original again. After
the store, hl7v2Parser() parses the message, v2ToFhir maps it to FHIR
resources, fhirItems() keys each one for Aidbox, and skipUnchanged() drops a
resource identical to the version already queued or delivered. v2ToFhir is
the mapping step in src/mappers/v2-to-fhir/: it routes each message to its
converter and adds one Provenance per message, targeting everything the
message produced.
src/index.ts imports every pipeline module (for its registration side effect)
and re-exports the registry the engine reads back:
import "./pipelines";
export { ChainRegistry } from "@health-samurai/interbox/chain";See Pipelines and Steps, store and senders in the docs for the methods a step can use and what each part of the chain does.
The engine takes a single INTERBOX_WORKSPACE_GIT_URL, builds src/index.ts into
one self-contained bundle, and imports that:
- remote (
git@…,https://…,ssh://…) — deploy. Clones the repo andbun installs it.INTERBOX_WORKSPACE_GIT_KEYsupplies a token for private https;INTERBOX_WORKSPACE_GIT_REFpicks the branch. - local — a
file://URL (or bare path) to a checkout. Bundles the live working tree in place (uncommitted edits included), with no clone or install, for a fast edit → hot-reload loop. The checkout must already have its deps installed.
The URL scheme only picks a default. INTERBOX_WORKSPACE_MODE sets the
lifecycle: clone means the engine owns a copy it may fetch, check out and
reset, while worktree, which docker-compose.yaml sets, means it bundles the
directory as it stands and never checks anything out or resets anything.
A change reloads into the engine's own runtime; the engine is not restarted. It
looks for changes every INTERBOX_WORKSPACE_POLL_MS (2 seconds in
docker-compose.yaml), and Sync now in the dashboard's Repository tab
reloads at once instead of waiting for the poll. The new workspace is built
before the running pipelines are swapped for it, so a change that fails to load
leaves the old pipelines running, and the dashboard shows why.
Working on this workspace locally needs Bun 1.4 or newer.
Older Bun cannot read bun.lock: bun install ignores it, resolves every
dependency again and rewrites it, and bun install --frozen-lockfile, as CI
runs it, fails.
bun install
bun run typecheck
bun testTests live in test/ — bun test is scoped to that directory (see
bunfig.toml), so a test file placed next to the code it covers will not run.
The bundled simulator under utils/ is a separate package with its own
dependencies and its own suite; see its README.
bun run e2e checks the pipeline end to end against a running stack: it sends
an ADT^A01 and an ORU^R01 over MLLP, expects AA for each, and waits for the
Patient and the glucose Observation to appear in Aidbox. It needs the stack
from docker compose up with both licenses activated, since the pipeline stays
paused until then. It reaches MLLP at localhost:2575, Aidbox at
http://localhost:8080 as AIDBOX_CLIENT_ID / AIDBOX_CLIENT_SECRET, and the
dashboard API at INTERBOX_PUBLIC_URL. Only those Aidbox credentials and
INTERBOX_PUBLIC_URL are read from .env, like the stack's own settings.
E2E_MLLP_HOST, E2E_MLLP_PORT and E2E_AIDBOX_URL point it elsewhere, and
E2E_TIMEOUT_MS (90 seconds by default) is how long each check waits for its
result.
In a copy of this repo, a daily job keeps one PR open from the upstream-sync
branch that brings main up to the latest upstream release. Set the variable
INTERBOX_UPSTREAM_SYNC=off to stop it. When the PR includes a release with
breaking changes, it is labelled breaking-change and lists the upgrade notes
in its description.
A release that changes CI definitions for the job's own platform, such as
.github/workflows/ on GitHub or azure-pipelines/ on Azure, is never pushed
by the job. On GitHub the job opens an issue instead, and on Azure the run
fails. Both give you the command to run locally once you have looked at those
changes. The jobs run the CLI version pinned in bun.lock, so that version only
changes when you merge a sync PR.
A copy made before these files existed needs one sync from a local checkout,
with the latest @health-samurai/interbox release as <version>:
bunx @health-samurai/interbox@<version> workspace syncIt pushes upstream-sync with your own git credentials and prints the PR link
and description. On Azure, delete that branch once its PR completes, because
the pipeline can only rebuild a branch it created.
.github/workflows/upstream-sync.yml needs:
- Allow GitHub Actions to create and approve pull requests turned on under Settings → Actions → General. If it's greyed out, an organization owner has to allow it in the organization settings first.
- A ruleset on the default branch and on the branch the engine deploys from
(
INTERBOX_WORKSPACE_GIT_REF). It should require a pull request with at least one approval and Require approval of the most recent reviewable push, require thetestandsimulatorchecks, block force pushes and deletion, and give GitHub Actions no bypass.
The PR opens as a draft, and CI starts when you mark it ready for review. GitHub doesn't count checks from runs that the workflow token starts, so this is the run your required checks wait for. When the job pushes a newer release to the branch, the PR goes back to draft. In a repo without draft PRs it opens ready instead; close and reopen it to start CI.
Optional repository variables: INTERBOX_UPSTREAM_SYNC=off turns the job off,
and INTERBOX_UPSTREAM_URL points it at another upstream.
azure-pipelines/ needs:
-
On the repo, Create branch and Contribute to pull requests for
<Project> Build Service (<org>), and nothing more. Whoever creates a branch gets Contribute and Force push on it, which covers the sync branch. -
Limit job authorization scope to current project for non-release pipelines, Protect access to repositories in YAML pipelines and Limit variables that can be set at queue time turned on under Project settings → Pipelines → Settings.
-
Both pipelines, created from a checkout of
main:az pipelines create --name ci --yml-path azure-pipelines/ci.yml az pipelines create --name upstream-sync --yml-path azure-pipelines/upstream-sync.yml
-
Branch policies on
main:cias build validation, at least one reviewer, requestors can't approve their own changes, the most recent pusher can't approve, and votes reset on new pushes.
Here a release with breaking changes opens as a draft PR.
The same two variables work as pipeline variables. Define them as not settable at queue time.