Implement a port adapter
Back a Meridian port with your own code so the engine, nodes and workflows talk to
a different external system without any change to the business layer. An adapter is a
factory (config) => PortImpl that a plugin contributes and the deployment's instance
manifest selects by name.
This recipe assumes you already have a plugin (see Scaffold a plugin) and know the port you are backing. For the exact method signatures of each contract, keep Port contracts open alongside this page.
Before you start
- Decide which port you implement. Every port contract lives with its
owning plugin:
patient(PatientProfilePort) andprescription(PrescriptionPort) are owned by@posos/clinical(@posos/clinical/ports),notification(NotificationPort) by@posos/notify(@posos/notify/ports). The SDK itself only types the ports the core calls —agent(AgentRunnerPort) andterminology(TerminologyPort) — plus theSubjectRecordPortplatform capability. The bundle is open: you can also define an entirely new port (see Define a new port). - To back an existing port,
import typeits contract from the owning plugin (and declare that plugin independsOn); the import is erased at runtime. - There is no built-in fallback: a port that no instance selects simply does not exist. Your adapter only runs once an instance binds it.
1. Write the factory module
Create a module under adapters/ that default-exports a factory. The factory
receives the instance-supplied config and returns an object implementing the port
interface.
For a notification adapter that POSTs each outbound notification to a webhook:
// adapters/webhook.ts
import type { NotificationPort, OutboundNotification } from "@posos/notify/ports";
export default function webhookNotifier(config: Record<string, unknown> = {}): NotificationPort {
const url = typeof config.url === "string" ? config.url : process.env.NOTIFY_WEBHOOK_URL;
if (!url) throw new Error("[notify/webhook] `url` required (instance config or NOTIFY_WEBHOOK_URL).");
const headers = (config.headers ?? {}) as Record<string, string>;
const timeoutMs = Number(config.timeoutMs ?? 5000);
return {
async send(n: OutboundNotification): Promise<void> {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(n),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`[notify/webhook] ${res.status} ${res.statusText} on POST ${url}`);
},
};
}
Rules that keep an adapter well-behaved:
- Validate required config eagerly and throw a clear, prefixed error — a misconfigured instance must fail loudly at wiring time, not silently at runtime.
- Fail loudly on downstream errors (non-2xx, unreachable host). Adapter calls run inside durable workflow steps; a thrown error lets the engine retry the step rather than swallow a "best effort" send.
- Implement every required method of the interface. Optional members (e.g.
PatientProfilePort.publishValidationRequest) may be left off — the platform tolerates their absence. - Reuse the owning plugin's pure helpers for filtered reads where they apply. A
patientadapter'sgetMedications/getConditions/getObservationscan delegate tofilterMedications,filterConditions,filterObservationsfrom@posos/clinical/domainso filter semantics match the reference in-memory adapter and the simulation sandbox.
2. Declare the adapter in your plugin manifest
Add an entry under contributes.adapters in plugin.yaml. Each entry names the port
it backs, the selection name (unique per port), and the module:
contributes:
adapters:
- port: notification
name: console
module: ./adapters/console.ts
- port: notification
name: webhook
module: ./adapters/webhook.ts
Optional fields:
export— the named export to use when the factory is not the default export (defaults todefault).config— default config baked into the plugin (the instance can override it).systems— forterminologyadapters, the code systems this resolver handles (informative; routing is per-system in the instance manifest).record: { contextKind: <kind> }— declares the adapter the record port of a context kind (e.g. thepatientadapters of@posos/clinicaland@posos/fhirdeclarerecord: { contextKind: patient }). The implementation must satisfySubjectRecordPort(SDK): the host then servesGET /api/records/<kind>/:idfrom itssnapshot, and publishes human-validation requests to the record on suspension via its optionalpublishValidationRequest.
The full schema is in the Plugin manifest reference.
3. Build the plugin
Bundle the plugin so its adapter modules resolve at load time:
meridian-plugin build
This esbuild-bundles the plugin (there is no build-manifest.mjs). See
Build and bundle for details, and
Publish to the registry once you want it installable via
meridian-plugin publish / MERIDIAN_REGISTRY.
4. Select the adapter from the instance manifest
An adapter does nothing until an instance selects it. In the instance manifest, bind the
port to your adapter's name and pass its runtime config:
ports:
notification:
adapter: webhook
config:
url: https://hooks.example.org/notify
headers: { Authorization: "Bearer …" }
timeoutMs: 5000
buildPorts (apps/api/src/instance.ts) looks up the adapter by (port, name) across
the loaded plugins and calls factory(config). Every port is bound by name and
left undefined when the instance omits it; a behavior whose port is missing
throws a configuration error naming the port at execution time
(requirePort). For the operator's view, see
Configure ports and adapters
and the Instance manifest reference.
Define a new port
The port bundle is open — a plugin can introduce a port the platform has never
heard of. Defining a port is the owning plugin's doctrine: the plugin that
owns the port exports its contract, augments the SDK's open Ports interface,
and (usually) ships the reference adapter and the simulation sandbox.
Export a TypeScript contract from your plugin (an
interface, e.g. in aports.ts). This is the port's contract; your plugin owns it. Type it on your own domain model.Augment the SDK's
Portsinterface so your dependents getports.<name>strongly typed from a plain type import:declare module "@meridian/shared/domain/ports" { interface Ports { "my-port"?: MyPort; } }Contribute an adapter for it exactly as above (
port: <your-port-name>, aname, amodulewhose factory returns your contract). If the port is the record of a context kind, declarerecord: { contextKind }on the adapter.Consume it from a node behavior via
ctx.ports. Types are erased at runtime, so guard the access with the SDK'srequirePort:import { requirePort } from "@meridian/shared/domain/ports"; const myPort = requirePort<MyPort>(ports, "my-port");It throws a clear configuration error naming the port when the instance does not bind it. See Implement a node behavior.
Optionally contribute a simulation sandbox (
contributes.portSandboxes, a factory(seed, record) => impl) so simulations can exercise the port from an opaque seed with captured writes — without one, the port is absent in simulation. See the simulation engine.The instance selects it under
ports.<your-port-name>like any other.
Verify
- Point an instance manifest at your adapter and start the instance
(Configure ports and adapters).
The API logs
port lié(port bound) with the adapter name when the factory resolves. - Run a workflow whose nodes use the port. For a
patientadapter, exercise the filtered reads and writes; fornotification, trigger anotify.sendsink and confirm the downstream channel receives it.
Related
- Port contracts — the method-by-method interface each port must satisfy.
- Plugins — where adapters sit among the plugin contribution kinds.
- Connect a FHIR warehouse —
operating a real
patient/prescriptionadapter backed by FHIR. - SDK surface and CLI.