Documentation / Développement / Guides pratiques / Author an LLM agent

Author an LLM agent

An agent is a prompt + an LLM provider/model + typed input/output ports + a list of catalog nodes it may call as tools, declared entirely inside plugin.yaml — there is no behavior module to write for the agent itself. This recipe adds one to an existing plugin and gets it loaded and runnable end to end.

It assumes a scaffolded plugin (see Scaffold a plugin) with the types your ports will reference already registered (see Add vocabulary and types and TypeRef). For the manifest schema in full, see Plugin manifest; for wiring the resulting node into a workflow, see Use agents in a workflow.

Choose the ports and tools

  1. Pick a type for each input and output port. The base type name must resolve to one of seven primitives — String, Number, Decimal, Integer, Boolean, Date, DateTime — or the name of a registered object type from your (or a dependsOn) plugin's contributes.types (libs/shared/src/domain/agent-spec.ts: PRIMITIVES, resolveTypeName). Time, Duration, Code, Uri, coded, and quantity are not resolvable directly on an agent port — wrap them inside an object type first if you need one. Add list: true on a port that carries a list of that type.
  2. Pick the tools: any node id already in the catalog whose kind is not trigger — a node without a behavior, or a trigger, cannot be invoked as a tool (libs/engine-core/src/engine/agents.ts, validateAgent). A tool always runs with an empty config (libs/engine-core/src/engine/catalog.ts: runTool({ inputs: args, config: {}, context })), so don't pick a tool whose behavior needs its own node config to do anything useful.

Declare the agent in plugin.yaml

  1. Add an entry under contributes.agents with a name, a provider, a model, a prompt, at least one input and one output port, and a tools list (libs/shared/src/plugin/manifest.ts, AgentContribSchema):

    contributes:
      agents:
        - name: Renal triage
          description: Estimates renal function from a creatinine result.
          provider: anthropic
          model: claude-sonnet-4-5
          maxIterations: 6
          inputs:
            - { name: result, type: Observation, description: The creatinine result. }
          outputs:
            - { name: assessment, type: Condition, description: The proposed condition, if warranted. }
          tools: [patient.load-demographics, acme.egfr.ckd-epi-2021]
          prompt: |
            You are a nephrologist. From the creatinine result, estimate renal
            function (use the tools) and propose a Condition if warranted.
    

    provider is a plain string — anthropic, openai, gemini, mistral, azure-openai and local are built in (libs/llm-client/src/index.ts, providerConfig), and an instance may declare its own names, one per endpoint, in ports.agent.config.providers (see Instance manifest). A name that is neither is refused when the agent is saved and logged at boot, naming the providers the instance does know. list: true on a port carries a list of the base type — the @posos/demo-softway agent uses it for a list of extracted strings (external-plugins/demo-softway/plugin.yaml).

  2. The older single-type shorthand — input: Observation / output: Condition, one implicit port named input/output — is still accepted and normalized into the array form above (libs/shared/src/plugin/manifest.ts, the AgentContribSchema preprocess step); external-plugins/geriatrie/plugin.yaml still uses it. Prefer the named-ports form for anything new: it supports more than one input or output, each with its own description.

  3. Mind what name becomes: the node type id is agent.<slug(name)> (lower-cased, accents stripped, non-alphanumerics collapsed to -) (apps/api/src/plugins.ts, slug and the agents-loading loop) — "Renal triage" becomes agent.renal-triage. Renaming the agent later changes this id and breaks any workflow already wired to it.

Write the prompt

prompt is sent to the model as-is; the runner appends its own instructions describing the available tools and a terminal submit_result tool whose schema is generated from your outputs (external-plugins/llm/adapters/runner.ts, systemPrompt/buildTools). Write the prompt as pure domain guidance — what to look for, when to call which tool, when to abstain — and don't tell the model to answer in free text: only a submit_result call is accepted as the final answer.

maxIterations caps the number of model↔tool round-trips (default 8 if omitted — external-plugins/llm/adapters/runner.ts, DEFAULT_MAX_ITERS). A run that exhausts the budget without calling submit_result fails explicitly (pas de réponse finale après N tours.) instead of returning a partial answer — raise the value for agents that legitimately need many tool calls, keep it low for anything cost- or latency-sensitive.

Build, and check what the loader won't

  1. Run meridian-plugin gen-types only if this change also touched contributes.types or a node's behavior — an agent contributes neither, so on its own it has nothing to generate (see Generate types from the manifest).
  2. Run meridian-plugin build. It bundles behavior/adapter/event-source modules and copies plugin.yaml/i18n/ into dist/ (libs/plugin-cli/src/commands/build.ts) — an agent has no module of its own, so adding one never adds anything to bundle, but you still need this step whenever the plugin has other nodes.
  3. meridian-plugin validate <workflow.yaml> does not know about agent nodes: it only registers metadata from the manifests you pass it, so a workflow node of kind agent.* is reported as unknown (libs/plugin-cli/src/commands/validate.ts, header comment). Check a workflow that uses your agent by loading the plugin into a running instance instead, and using its own validate/simulate — see Validate a workflow and Simulate a workflow.
  4. Two mistakes the loader accepts silently, so verify them by eye: a tools entry that doesn't resolve to a runnable catalog node id is dropped from the tool list rather than rejected (libs/engine-core/src/engine/catalog.ts: if (!toolNode?.run) return [];), and a port type that doesn't resolve (see the type list above) falls back to the untyped any rather than failing (libs/shared/src/domain/agent-spec.ts, agentPortType/resolveTypeName). A typo in either place fails silently, not loudly.

Select the runner adapter on the target instance

Executing an agent node needs the agent port bound to an adapter — normally @posos/llm's agent/llm adapter, or another one implementing the same port (external-plugins/llm/plugin.yaml; the contract is AgentRunnerPort in Port contracts). This is instance configuration, not something your plugin declares:

ports:
  agent: { adapter: llm }

Without a matching provider API key in the environment, running the node fails explicitly rather than falling back to a mock (external-plugins/llm/adapters/runner.ts: Provider « … » non configuré (clé API en variable d'environnement manquante).). See Configure ports and adapters for wiring the port, Declare and install plugins for getting @posos/llm onto the instance in the first place, and Environment variables for the per-provider key/base-URL variables (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY, MISTRAL_API_KEY, AZURE_OPENAI_API_KEY, LOCAL_LLM_API_KEY, …).

Localize it, if needed

The agent's description and each port's description are ordinary translatable manifest strings (an i18n/<locale>.yaml overlay) — but name is not: it determines the node type id, so translating it would change that id and break wiring (libs/shared/src/plugin/i18n.ts). See Translate a plugin.

Confirm it loaded

Restart the host process, check the boot log lists your plugin, then look for agent.<slug> in /api/catalog or the editor palette — agent nodes are grouped under their own category (libs/engine-core/src/engine/agents.ts, agentToNodeMeta). From there, wiring it into a workflow is Use agents in a workflow.

75 documents8 sectionssource : /docs · généré au build