Configure ports and adapters
This recipe binds the platform's hexagonal ports — patient, prescription,
agent, notification, decision — 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, decision by @posos/decision — 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: { ... } }
decision: { 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), reached through a
connection of type fhir-r4 (see Manage connections):
connections:
entrepot: { type: fhir-r4, baseUrl: http://fhir.internal:8080/fhir }
connectionDefaults:
fhir-r4: entrepot
ports:
patient: { adapter: fhir } # default connection of the type
prescription: { adapter: fhir, connection: entrepot }
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 of the notify.send nodes that designate no
connection. A node that designates one (Slack, SMTP, webhook) uses it instead; see
Send notifications. Two adapters of @posos/notify:
# Development: prints each notification to stdout, no config needed.
ports:
notification: { adapter: console }
# POSTs each notification as JSON to the URL of a notification-webhook connection.
connections:
relais-bips:
type: notification-webhook
baseUrl: https://bips.chu.local/notify
auth: { scheme: bearer, token: env:BIPS_TOKEN }
ports:
notification: { adapter: webhook, connection: relais-bips }
The webhook adapter retries a network error, a 429 or a 5xx up to three attempts,
then fails the node; another error status fails it at once — delivery is part of the
workflow's contract, not best-effort (external-plugins/notify/adapters/webhook.ts). Its
former configuration (config.url, config.headers, config.timeoutMs, or
NOTIFY_WEBHOOK_URL) is still read, with a warning.
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. Bind decision (optional — needed for Décision intelligente nodes)
Selects the decision model answering the Décision intelligente nodes
(decision.ask) that designate no connection. @posos/decision owns the port and
ships no adapter; the typesafe adapter of @posos/typesafe calls
POST {baseUrl}/v1/systemone through a connection of type typesafe-systemone. Each
server is a connection; a node designates one, else it uses the port bound here, on the
connection it names or the default connection of the type:
connections:
typesafe:
type: typesafe-systemone
baseUrl: https://api.typesafe.ai
defaultModel: jev-latest # parameter of the type, default jev-latest
auth: { scheme: bearer, token: env:TYPESAFE_API_KEY }
jev-local: # compatible server inside the network, no key
type: typesafe-systemone
baseUrl: http://localhost:8089
defaultModel: jev
timeoutMs: 30000
connectionDefaults:
typesafe-systemone: typesafe
ports:
decision: { adapter: typesafe } # or { adapter: typesafe, connection: jev-local }
The connection carries the URL, the key (bearer, header, or none), the timeout of
each attempt (timeoutMs, 30 s by default) and extra headers; the model is the node's,
else the connection's defaultModel. Transient failures (network, timeout, 408, 429,
529, 5xx) are retried up to four attempts, following retry-after
(external-plugins/typesafe/adapters/typesafe.ts). Another provider protocol takes
another adapter of the same port, contributed by its own plugin with its own connection
type.
6. Set adapter-specific config
config is an opaque object passed straight to the adapter's factory,
(config, deps?) => 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) |
none: connection of type fhir-r4 (baseUrl still read, deprecated) |
FHIR_BASE_URL (deprecated) |
hermes (terminology) |
none: connection of type hermes (baseUrl, languageRefsetId still read, deprecated) |
HERMES_BASE_URL, HERMES_LANGUAGE_REFSET_ID (deprecated) |
webhook (notification) |
none: connection of type notification-webhook (url still read, deprecated) |
NOTIFY_WEBHOOK_URL (deprecated) |
typesafe (decision) |
none: connection of type typesafe-systemone |
none |
An adapter that declares connection: { type } in its plugin manifest receives
deps.connection: ports.<port>.connection names the connection, else the default
connection of the type is used. Its URL and authentication live in the connection,
not in config.
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.
7. Apply the change
The manifest is parsed and its ports resolved once, at process start
(loadInstance() in apps/api/src/instance.ts, called from
the entry points apps/api/src/restate/server.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.