Instance manifest
The instance manifest is the concrete configuration of one Meridian deployment. Plugins declare capabilities (named adapters, value sets, event sources and bindings); the instance decides delegation — which adapter serves each port, with what concrete configuration, which plugins are required, and which event bindings are active. The manifest declares no contributions of its own (no value sets, no adapters, no bindings are defined here).
The schema is a Zod object defined in libs/shared/src/plugin/instance.ts and exported as InstanceManifest. This page documents that schema exactly.
For task-oriented instructions, see Create your first instance, Configure ports and adapters, Declare and install plugins, Connect an event source, and Configure terminology.
Loading
The manifest is loaded by loadInstance() in apps/api/src/instance.ts:
| Step | Behavior |
|---|---|
| Source path | INSTANCE_CONFIG (a path) if set, otherwise instance.yaml at the repository root. |
| Relative paths | Resolved against the repository root, not the current working directory. |
| Format | YAML, parsed then validated with InstanceManifest.parse. |
Missing file (INSTANCE_CONFIG set) |
Fails loudly: INSTANCE_CONFIG introuvable. |
Missing file (no INSTANCE_CONFIG) |
Falls back to EMPTY_INSTANCE — a manifest with only apiVersion, i.e. all defaults (dev default: everything internal / in-memory). |
| Invalid manifest | Fails loudly. A schema violation stops boot. |
See Environment variables for INSTANCE_CONFIG, MERIDIAN_REGISTRY, MERIDIAN_PLUGIN_CACHE, and PLUGINS_PATH.
Top-level fields
| Field | Type | Required | Default |
|---|---|---|---|
apiVersion |
"meridian/instance-v1" (literal) |
yes | — |
name |
string |
no | undefined |
locale |
string |
no | "fr" |
timezone |
string |
no | "Europe/Paris" |
registry |
string |
no | undefined (falls back to MERIDIAN_REGISTRY) |
plugins |
Record<string, PluginRequirement> |
no | {} |
seed |
string[] |
no | [] |
ports |
Record<string, PortBinding> |
no | {} |
terminology |
Record<string, PortBinding> |
no | {} |
sources |
Record<string, Record<string, unknown>> |
no | {} |
eventBindings |
string[] |
no | undefined |
apiVersion
z.literal("meridian/instance-v1"). Required. Any other value fails validation. Identifies the schema version of the manifest.
name
Optional free-form string. Used only as a label: loadInstance() logs parsed.name ?? path when the manifest is loaded. It has no functional effect.
locale
Optional string, default "fr". The language of persisted content — record notifications, validation titles, and anything the workflow engine writes to the patient record. An instance is monolingual. Behaviors receive it through ctx.locale / ctx.t. This is distinct from the UI language, which follows the individual user. See Set the instance locale and Instance locale vs UI locale.
timezone
Optional IANA identifier, default "Europe/Paris". The clock of the instance: it resolves the schedule rules of a trigger.schedule workflow whenever the trigger doesn't set its own timezone. Same reasoning as locale — a facility has one clock, the one where care is delivered, so "07:30" in a schedule means 07:30 locally, daylight-saving transitions included.
registry
Optional string. The plugin registry used to resolve required plugins declared by version. Two accepted forms:
gs://bucket[/prefix]— a Google Cloud Storage bucket (ADC auth).- a local directory path.
If omitted, the registry falls back to the MERIDIAN_REGISTRY environment variable (apps/api/src/registry.ts). In apps/api/src/registry.ts, a value starting with gs:// or file:// is used verbatim; any other value is resolved as a path relative to the repository root. Plugins are laid out in the registry under @publisher/name/version/…. See Set up a plugin registry.
plugins
Optional map, default {}. Keys are plugin package names (e.g. @posos/demo); values are PluginRequirement (see below). These are the plugins required by the instance. At boot, ensureInstancePlugins() (apps/api/src/registry.ts) resolves each one before loadPlugins():
- Missing versions are downloaded into the local cache (
MERIDIAN_PLUGIN_CACHE, default<repo>/.data/plugins). - Each resolved directory becomes an additional discovery root for
loadPlugins(), alongside the roots onPLUGINS_PATH. - A listed plugin that cannot be resolved (no registry configured, or no published version satisfying the range) fails boot loudly — it is treated as a configuration error.
Version resolution is delegated to the npm semver package (maxSatisfying, compare, valid in libs/plugin-cli/src/registry/registry.ts); the highest published version satisfying the range is selected. See Declare and install plugins and the plugin package format in Plugin manifest.
PluginRequirement
PluginRequirement is a union with a short string form and a long object form. Each entry declares exactly one of version (resolved on the registry) or path (local override).
| Form | Example | Meaning |
|---|---|---|
| Short string | "@posos/demo": "^0.1.0" |
Equivalent to { version: "^0.1.0" }. The string is a semver range resolved on the registry. |
Object, version |
{ version: "0.1.0" } |
npm semver range (*, 1.2.3, ^, ~, 1.2.x, >= <, …) resolved on the registry. |
Object, path |
{ path: ../vitals/dist } |
Local path to the plugin's compiled dist/ directory. Short-circuits the registry entirely (dev, hotfix). |
Rules:
- The object form is validated by
.refine((v) => !!v.version !== !!v.path): it must declareversionorpath, not both and not neither. Violations produce the message "un plugin requis déclareversion(registre) OUpath(local), pas les deux". - The short string form is transformed to
{ version, path: undefined }. - A
pathis resolved relative to the repository root (unless absolute) and read directly; no registry access occurs for that plugin.
seed
Optional string[], default []. Extra directories of workflow/agent definitions to seed into the definition store at boot, in addition to the host's own SYSTEM definitions (DEFINITION_SEED_DIR, default apps/api — which carries only agent-invoke.workflow.yaml). Relative paths resolve against the repository root. This is how content shipped with a deployment (demos, business workflows) stays instance data rather than host code: the clinical demo workflows and the chat-clinique agent live in content/clinical-demo/{workflows,agents} and are seeded by seed: [content/clinical-demo] (as in instance.dev.yaml and instance.example.yaml). Seeding is non-destructive: it populates the store, which then owns the definitions (draft → publish → rollback). See Manage workflow storage.
ports
Optional map, default {}. Adapter selection for single-implementation hexagonal ports. Keys are port names; values are PortBinding (see below). The set of ports is open: every port is keyed by name, and its contract is carried by the plugin that owns it (patient/prescription by @posos/clinical, notification by @posos/notify; the SDK types only agent and terminology).
Resolution happens in buildPorts() (apps/api/src/instance.ts):
| Port | Requirement |
|---|---|
patient |
Optional at boot. Without it, workflows touching the record fail at execution (Port « patient » non branché) and GET /api/records/patient/:id answers 404. |
prescription |
Optional at boot. Same failure mode: order.* nodes throw Port « prescription » non branché. |
agent |
Optional (pick, may be undefined). Its config accepts maxIters and providers — see LLM providers. |
| any other port | Optional. Every additional key in ports is bound dynamically by its name; its TypeScript contract is carried by the contributing plugin. |
For each port, the selected adapter's factory is called with the binding's config. If the named adapter is not found among loaded plugins, the port falls back to the provided fallback (or is left unresolved for optional ports) and a warning is logged. See Configure ports and adapters, Connect a FHIR warehouse, and Port contracts.
PortBinding
PortBinding is a Zod object with two fields:
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
adapter |
string |
yes | — | Name of the adapter contributed by a plugin (the selected capability). |
config |
Record<string, unknown> |
no | {} |
Concrete configuration passed to the adapter's factory (e.g. baseUrl, secret references). |
LLM providers (ports.agent.config.providers)
Optional map, read by the @posos/llm runner. Without it, provider on an agent must be one of the names built into @meridian/llm-client (anthropic, openai, gemini, mistral, azure-openai, local), each with one default base URL and one environment variable for its key — so an instance can reach at most one endpoint per name.
Declaring providers here gives one name per endpoint, which is what lets several models of the same protocol coexist: an in-house model beside a public vendor, a sandbox beside a production gateway. An agent's provider selects the entry by name.
| Field | Type | Meaning |
|---|---|---|
kind |
"anthropic" | "openai" |
Wire protocol (/v1/messages or /chat/completions). Defaults to the built-in name's protocol, else openai. |
baseUrl |
string |
Endpoint root. Defaults to the built-in name's URL. |
apiKeyEnv |
string |
Name of the environment variable holding the key — never the key itself. Defaults to the built-in name's variable. |
headers |
Record<string,string> |
Extra headers, merged last — so a declared authorization overrides the one built from the key (the header name must match its lowercase form exactly). Values are not a place for secrets: they sit in versioned configuration, unlike apiKeyEnv. |
timeoutMs |
number |
Per-provider network timeout. |
A partial declaration completes the built-in default of the same name rather than replacing it: declaring anthropic: { headers: … } keeps its URL and key variable.
Secrets stay out of this file, which is versioned configuration — hence apiKeyEnv rather than a literal. It is also the only option available to the Restate endpoint, which executes agents and never reaches the credential store (it reads nothing but its journal).
An entry the host cannot read is skipped, never guessed (parseProviderDecls, shared by the proxy and the @posos/llm runner so both hold the same list): a non-object declaration, an unknown kind, a mistyped baseUrl/apiKeyEnv/timeoutMs, or non-string header values are logged with the reason and drop out of the provider list. One bad entry does not take down the others.
A provider naming neither a declared entry nor a built-in is refused when the agent is saved, and logged at boot for agents already stored or contributed by a plugin (validateAgent, warnInvalidAgents) — instead of surfacing on the first run. The same list reaches the engine (makeCatalog({providers})), which is what an agent.inline node's provider is checked against — that node has no schema of its own. GET /api/catalog serves the list as llmProviders, which is what the console's provider selector offers.
Two operational notes. Authentication is expected to carry a key: a gateway that authenticates through headers alone still needs an apiKeyEnv (both callers refuse a provider with no key). And the variable named by apiKeyEnv must exist in both deployments' environments — the Restate endpoint executes agents, the proxy runs the workflow assistant; docker-compose.yml forwards only ANTHROPIC_API_KEY and OPENAI_API_KEY, so a house variable has to be added there for the containerized stack.
terminology
Optional map, default {}. Same PortBinding value type as ports, but keyed by code system: multiple terminology resolvers are active at once, routed by the system of the code being resolved.
buildTerminology() (apps/api/src/instance.ts) builds a RoutingTerminology from these entries. For each [system, binding] pair it resolves binding.adapter in the terminology port namespace and registers the adapter under that system. A binding whose adapter is not found is skipped with a warning. Systems not listed fall through to internal resolution. See Configure terminology.
sources
Optional map, default {}. Connection configuration per event-source mechanism: keys are event-source names (contributed by plugins), values are arbitrary config objects (Record<string, unknown>) passed to the source factory.
A source is instantiated only if at least one of its bindings is active. In startEventSources() (apps/api/src/server/api.ts), a plugin's event binding is active only when instance.sources[binding.source] is defined (and passes the eventBindings whitelist, if present). The config object reaches the source factory as config: instance.sources[sourceName] ?? {}.
The FHIR query and the mapping from raw record to domain event are bindings contributed by plugins, not instance configuration. The instance only supplies the connection. Example: { "fhir-poll": { baseUrl: "…", intervalSeconds: 15 } }. See Connect an event source and The open event model.
eventBindings
Optional string[]; when omitted the field is undefined. An allow-list of event-binding names to activate.
| Value | Effect |
|---|---|
omitted / undefined |
All contributed bindings whose source is configured in sources are active. |
| array of names | Only listed bindings whose source is also configured are active. |
In startEventSources(), the whitelist becomes new Set(instance.eventBindings) and a binding is retained only when instance.sources[b.source] !== undefined && (!allow || allow.has(b.name)). Binding names are defined by plugins; see Plugin manifest.
Complete example
apiVersion: meridian/instance-v1
name: exemple-chu
locale: fr
timezone: Europe/Paris # horloge des workflows planifiés
# Required plugins, resolved on the registry (or overridden by a local path).
registry: gs://meridian-plugins # or a local directory
plugins:
"@posos/demo": "^0.1.0" # short form ≡ { version: "^0.1.0" }
"@posos/geriatrie": { version: "0.1.0" }
"@acme/vitals": { path: ../vitals/dist } # local override, no registry access
# Shipped content seeded into the definition store, on top of the host's
# system definitions.
seed: [content/clinical-demo]
# Port delegation: which adapter serves each port + its concrete config.
ports:
patient: { adapter: fhir, config: { baseUrl: http://localhost:8090/fhir } }
prescription: { adapter: fhir, config: { baseUrl: http://localhost:8090/fhir } }
agent: { adapter: llm }
notification: { adapter: console }
# Terminology routing, keyed by code system.
terminology:
snomed-ct: { adapter: hermes, config: { baseUrl: http://localhost:8081 } }
# Event-source connection config, keyed by source mechanism.
sources:
fhir-poll: { baseUrl: http://localhost:8090/fhir, intervalSeconds: 15 }
# Optional allow-list of active bindings; omit for "all configured".
eventBindings: [fhir-creatinine]
The empty manifest (dev default) is valid with only apiVersion:
apiVersion: meridian/instance-v1
Related
- Environment variables —
INSTANCE_CONFIG,MERIDIAN_REGISTRY,MERIDIAN_PLUGIN_CACHE,PLUGINS_PATH. - Topology — where the manifest sits in the deployment.
- Plugin manifest — the plugin side: adapters, event sources, and bindings the instance selects.
- Bundled plugins — adapters and sources shipped in-repo.