Configure ports and adapters
This recipe binds the platform's hexagonal ports — patient, prescription,
agent, notification — to concrete adapters contributed by loaded plugins, in
the instance manifest's ports: block. It assumes you already have an instance
manifest and know which plugin you want to source each capability from. For the
full field-by-field schema see instance manifest reference;
for the concept behind ports/adapters see plugins concept
and the glossary entry for Port
(this is the hexagonal sense — not the node-level ports wired inside a workflow,
see wire ports for those).
Prerequisites
- An instance manifest file to edit (
INSTANCE_CONFIG, defaulting toinstance.yamlat the repo root — see environment variables). If you don't have one yet, start with your first instance. - The plugin that contributes the adapter you want to select is already loaded:
via
PLUGINS_PATH, or declared under the manifest'splugins:and resolved from a registry — see declare and install plugins and set up a plugin registry. - You know the adapter's
name— the identifier that plugin registered for the port, not the plugin's package name. See bundled plugins for what ships with the platform, or the plugin's own manifest/docs otherwise.
Steps
1. Open the ports: block
ports is a free-form map, keyed by port name, of { adapter, config }
(PortBinding, defined in libs/shared/src/plugin/instance.ts). The key is not
restricted to any fixed list: every port is defined by the plugin that owns its
contract — patient and prescription by @posos/clinical, notification by
@posos/notify — while the SDK types only agent and terminology. A plugin
can define and expose its own port under the same mechanism — it's wired the
same way.
apiVersion: meridian/instance-v1
name: my-instance
ports:
patient: { adapter: <name>, config: { ... } }
prescription: { adapter: <name>, config: { ... } }
agent: { adapter: <name> }
notification: { adapter: <name>, config: { ... } }
2. Bind patient and prescription (for clinical workflows)
No port is required at boot — an instance without a patient record starts fine.
But any workflow touching the record (patient.*, order.* nodes, human
validations published to the record) fails at execution with a configuration
error naming the missing port, and GET /api/records/patient/:id has nothing to
read (the patient adapters declare record: { contextKind: patient }, which is
what wires that route and validation publication).
There is no platform default: pick one adapter name per port and, if the adapter
needs it, a config object.
Development, backed by the seeded in-memory store — the reference adapters of
@posos/clinical (adapter name in-memory):
ports:
patient:
adapter: in-memory
config:
patients:
- demographics: { patientId: patient-42, sex: male, ageYears: 72 }
prescription:
adapter: in-memory
config:
prescriptions:
- id: presc-9001
patientId: patient-42
items: [{ coding: { code: metformine, display: "Metformine 1000 mg" } }]
Production, backed by a FHIR warehouse (@posos/fhir):
ports:
patient: { adapter: fhir, config: { baseUrl: http://fhir.internal:8080/fhir } }
prescription: { adapter: fhir, config: { baseUrl: http://fhir.internal:8080/fhir } }
adapter is matched by the exact (port, name) pair against every loaded
plugin's contributes.adapters entries (AdapterContribSchema in
libs/shared/src/plugin/manifest.ts; matched by findAdapter() in
apps/api/src/plugins.ts). If nothing matches, the host logs a warning and the
port stays unbound; the first behavior that needs it fails at execution with:
Port « patient » non branché — sélectionnez un adapter dans le manifeste
d'instance (ex. ports.patient = { adapter: in-memory }).
(buildPorts() in apps/api/src/instance.ts binds; the guard lives in the
consuming behaviors). For a full FHIR-specific walkthrough
(auth, resource mapping) see
connect a FHIR warehouse.
3. Bind agent (optional — needed for agent nodes)
Only required if the workflow(s) you run use agent nodes. The bundled runner is
@posos/llm:
ports:
agent: { adapter: llm, config: { maxIters: 8 } } # maxIters optional, defaults to 8
The runner reads the LLM provider's API key from the environment
(ANTHROPIC_API_KEY, OPENAI_API_KEY, …, per provider — see
libs/llm-client/src), not from this config. Which provider/model an agent
actually calls is decided per-agent, in the plugin that contributes the agent
(see author an agent) — this
binding only selects which runner implementation executes that call. There is
no mock fallback: an agent node fails loudly at run time if its provider key is
missing.
4. Bind notification (optional)
Selects the outbound diffusion channel for @posos/notify. Two bundled
adapters:
# Development: prints each notification to stdout, no config needed.
ports:
notification: { adapter: console }
# Production: POSTs each notification as JSON to a webhook.
ports:
notification:
adapter: webhook
config:
url: https://hooks.example.org/notify # required (or NOTIFY_WEBHOOK_URL)
headers: { Authorization: "Bearer …" } # optional
timeoutMs: 5000 # optional, default 5000
The webhook adapter fails loudly on a non-2xx response or an unreachable URL —
delivery is part of the workflow's contract, not best-effort (external-plugins/notify/adapters/webhook.ts).
This port only governs outbound diffusion; writing a notification to the patient
record itself goes through the patient port, independently of how (or whether)
it's also pushed out.
5. Set adapter-specific config
config is an opaque object passed straight to the adapter's factory,
(config) => PortImpl (AdapterContribSchema.module/export/config in
libs/shared/src/plugin/manifest.ts) — the platform doesn't validate its shape;
each adapter defines and documents its own fields. The bundled adapters follow
one recurring pattern — a config field overrides an environment-variable
fallback of the same purpose:
| Adapter | config field |
Env fallback |
|---|---|---|
fhir (patient/prescription) |
baseUrl |
FHIR_BASE_URL |
hermes (terminology) |
baseUrl |
HERMES_BASE_URL (default http://localhost:8081) |
webhook (notification) |
url |
NOTIFY_WEBHOOK_URL |
Because instance manifests are versioned in git, prefer the environment-variable
route for anything secret (auth headers, API keys) rather than writing it into
config — see security posture.
terminology: bindings use the same PortBinding shape but route per code
system rather than one adapter per port; see
configure terminology for that recipe.
6. Apply the change
The manifest is parsed and its ports resolved once, at process start
(loadInstance() in apps/api/src/instance.ts, called from
apps/api/src/restate/services.ts and apps/api/src/server/api.ts) — there is
no hot reload. Restart the endpoint and proxy processes to pick up the edit; see
upgrade the platform for the restart order in a
running deployment. An invalid manifest (bad YAML, wrong types) fails the same
Zod parse just as loudly, before the process comes up.
Verify
- On restart, the
instancecomponent logs one info line per port that resolved, withportandadapterfields, or a warning naming the adapter it couldn't find — check the endpoint/proxy logs (see enable observability for log shape and correlation). - If a declared port doesn't resolve, boot continues with the port unbound (a
warning names the adapter); the first behavior that needs it fails with
Port « … » non branché— fix theadaptername or load the missing plugin, then restart. - Exercise the binding end-to-end: send an event that reaches the port (e.g. run a workflow that reads the patient record, or trigger a notification) and confirm it lands where the adapter is supposed to send it — see monitor executions.
Related documentation
- Instance manifest reference — full schema.
- Port contracts — the TypeScript contract each port's adapter must implement.
- Implement a port adapter — write your own adapter instead of using a bundled one.
- Bundled plugins — every adapter shipped with the platform.
- Connect a FHIR warehouse, configure terminology, connect an event source — adjacent instance manifest sections.