Skip to content

Reuse with Components

Components are reusable, modular pieces of your templates. Define a clause once, use it in ten different agreements. Update it in one place, and the change applies everywhere.

Why Use Components?

  • Reusability: Write a clause once, include it wherever you need it.
  • Modularity: Break a long document into manageable, named sections.
  • Maintainability: Fix wording in one component, and every template that uses it benefits.
  • Consistency: Ensure standard language is applied the same way everywhere.
  • Encapsulation: Components can own their own interview questions, keeping internal details out of the host template.

Terminology & Concepts

To understand components, it helps to distinguish between the "Host" and the "Component":

  • Host Template: The main document you are writing. This is the template that initiates the inclusion of a component using {{ insert() }}. It usually has its own config.yml.
  • Component: The reusable file (or folder of files) being injected into the Host. Complex components have their own component.yml to define their behavior.

Two Approaches to Components Components support different design philosophies. You will typically build them in one of two ways:

  1. Snippets / Fine-Grained Clauses: Simple text files or a library of small, focused clauses. The Host template author selects exactly which provisions to include and controls the document structure.
  2. Self-Contained Sections (Mini-Interviews): A single component that handles an entire complex topic (like a full NDA section). The component owns its own interview questions and internal logic, hiding the complexity from the Host.

1. The Basics: Plaintext Components

The simplest component is just a Markdown (.md) or Word (.docx) file with no variables at all—just text. This is useful for standard boilerplate paragraphs, disclaimers, or formatting blocks.

File Structure:

my_project/
├── template.md                  <-- (Host) You type {{ insert() }} here
└── components/
    └── boilerplate_footer.md    <-- (Component) The reusable text

components/boilerplate_footer.md

This agreement has been executed in two identical copies, one for each party.

template.md (Host)

## Signatures
{{ insert('boilerplate_footer') }}

VIBE finds boilerplate_footer.md in your components/ directory and renders it inline. No configuration, no inputs, no questions.


2. Passing Data to Components

When a component needs data from the host template (like party names or dates), the Host passes it through keyword arguments in the insert() call.

File Structure:

my_project/
├── template.md                  <-- (Host) 
└── components/
    └── signature_block.md       <-- (Component) Uses Jinja variables

components/signature_block.md

**Signed:** ___________________

**By:** {{ signer_name }}
**Title:** {{ signer_title }}
**Date:** {{ signing_date }}

template.md (Host)

{{ insert('signature_block',
    signer_name=client_name,
    signer_title="CEO",
    signing_date=today) }}

This is explicit, readable, and always takes precedence over any other mechanism. You can pass literal values ("CEO"), host template variables (client_name), or Jinja expressions.

Note: By default, components automatically have access to variables defined in your Host template — you only need to pass them explicitly via insert() if you want to rename them or ensure a strict data contract. See Value Precedence for the full resolution order.


3. Smart Components (Inputs vs. Questions)

To make components truly self-contained, you can place them inside their own directory alongside a component.yml file. This allows the component to define a formal interface and even ask its own interview questions.

Before creating one, you must understand the two distinct ways components receive data: Inputs and Questions.

Rule of Thumb:

  • Inputs: Data the Host template provides via insert(). (e.g., Party names, contract type, governing law). The component needs this from the outside world.
  • Questions: Data the Component asks the User during the interview. (e.g., "Do you prefer arbitration?", "What is the liability cap?"). The Host doesn't need to know about these internal details.

Creating a Standalone Component

Here is a component that takes an Input from the Host, but asks its own Question in the VIBE interview.

File Structure:

my_project/
├── template.md                    <-- (Host)
└── components/
    └── contact_block/
        ├── component.yml          <-- (Component config)
        └── template.md            <-- (Component output)

components/contact_block/component.yml

inputs:
  contact_name:
    type: text
    description: Full name of the contact person
  contact_email:
    type: text

questions:
  preferred_contact_method:
    label: Preferred contact method
    type: select
    options:
      - email
      - phone

components/contact_block/template.md

## Contact: {{ contact_name }}
Email: {{ contact_email }}

{% if preferred_contact_method == 'phone' %}
*Please call during business hours.*
{% endif %}

template.md (Host)

{{ insert('contact_block',
    contact_name=client_name,
    contact_email=client_email) }}

Screenshot

The Host provides contact_name and contact_email through the insert() call. During the interview, VIBE also prompts the user with the component's own question: "Preferred contact method". The Host author never had to code that question into their own config.yml.

Input Options

Each input declared in component.yml or collection.yml supports:

  • type: Expected data type (text, number, bool, date, etc.)
  • required: Whether the host must provide a value (defaults to true)
  • description: Human-readable explanation of the input's purpose

Overriding Questions from the Host

Because insert() kwargs have the highest authority, they can also target a component's questions — not just its inputs. When the Host provides a value for a question, VIBE treats it as pre-answered and skips asking the user.

This is useful when the same component is used in different contexts: sometimes the user should be asked, but other times the Host already knows the answer.

{# Normal usage: user is asked for preferred_contact_method #}
{{ insert('contact_block',
    contact_name=client_name,
    contact_email=client_email) }}

{# Override: host pre-answers the question, user is not asked #}
{{ insert('contact_block',
    contact_name=client_name,
    contact_email=client_email,
    preferred_contact_method="email") }}

This works identically for collection-level questions. For example, if a collection.yml declares a language question, the Host can override it for a specific insertion:

{{ insert('clauses.notice', party_name=client, language="sv") }}

Precedence: Explicit insert() mappings always take highest precedence, whether they target inputs or questions. They override component_defaults, collection defaults, and user-provided answers alike. See Value Precedence for the full resolution order.

Computables

A standalone component can also define computables for derived values:

computables:
  contact_summary:
    type: computable
    compute: "contact_name + ' (' + contact_email + ')'"

4. Controlling the Interview Flow

When components have their own questions, you might want to control when and if those questions are asked during the user's interview. You do this using special parameters in the insert() call, prefixed with an underscore (_). The underscore tells VIBE these are instructions for the interview flow, not data variables being passed to the component.

These parameters fall into three categories:

  • Visibility: _ask, _multichoice — control whether the component's questions appear
  • Positioning: _after, _follows — control where in the interview they appear
  • Namespacing: _alias — allow multiple instances of the same component

Conditional Inclusion (_ask)

Use the _ask parameter to create a "gate." VIBE will automatically create a Yes/No question in the interview. The component (and its internal questions) will only be included if the user answers "Yes".

{{ insert("nda", _ask="Do you want an NDA?") }}
questions:
  nda_duration:
    type: number
    label: NDA duration (months)
    default: 12
## Non-Disclosure Agreement

Confidentiality obligations last for {{ nda_duration }} months.

Screenshot

If you don't need a custom label, pass True for an auto-generated one (_ask=True).

The gate question ID is include_<alias>. When using a custom _alias, the gate follows the alias:

{{ insert("nda", _alias="confidentiality", _ask="Include confidentiality clause?") }}
{# creates the question include_confidentiality #}

Since the gate is a normal bool variable, you can reference it elsewhere in your template:

{% if include_confidentiality %}
This agreement includes a confidentiality provision (see Section 8).
{% endif %}

Note: If your template already defines a question with the same ID as the auto-generated gate (e.g., include_nda), VIBE reports a validation error. Remove the manual definition and let _ask handle it.

Grouped Gates (_multichoice)

When you have several optional components from the same collection, presenting each one as a separate Yes/No gate can overwhelm the user. The _multichoice parameter groups them into a single multichoice (checkbox) question instead:

{{ insert("clauses.ai", _ask="Artificial Intelligence", _multichoice="Commitments") }}
{{ insert("clauses.bench", _ask="Benchmarking", _multichoice="Commitments") }}
{{ insert("clauses.sla", _ask="Service Levels", _multichoice="Commitments") }}

All insert() calls that share the same _multichoice label are combined into one multichoice question titled "Commitments" with three checkbox options. Checking an option opens that component's gate; unchecking it closes it.

Requirements:

  • _multichoice requires _ask — the _ask label becomes the checkbox option text.
  • _multichoice cannot be combined with _follows, because a grouped checkbox question is a top-level interview element — it cannot be visually nested under another question. Use _after to position it instead.
  • All members of a group that use _after must agree on the same target.

Placement works the same as for standalone gates: use _after to position the group question after a specific host question, or add a config marker named after the generated question ID (e.g., _mc_commitments: true) to your config.yml.

The generated question ID is _mc_ followed by a slug of the label text (e.g., _multichoice="Commitments" produces _mc_commitments).

Positioning Questions (_after and _follows)

By default, component questions appear at the end of the interview. You can place them exactly where you want them relative to the Host's questions.

_after places the component's questions in the main interview list, directly after a specific Host question:

{{ insert('signature_block', _after='contract_value') }}

When combined with _ask, the gate question also appears at the _after position:

{{ insert('nda', _after='client_name', _ask="Include an NDA?") }}
{# Interview: client_name → "Include an NDA?" → NDA questions (if Yes) → ... #}

_follows makes the component's questions appear as visual sub-questions nested under a Host question:

{{ insert('address_details', _follows='client_city') }}

(Note: _after and _follows are mutually exclusive).

Positioning with Config Markers (_insert_questions_)

As an alternative to _after, you can place explicit markers in your Host's config.yml to control exactly where component questions appear. The marker name is _insert_questions_ followed by the component's _alias value:

# config.yml
questions:
  client_name:
    type: text
    label: Client's Full Legal Name

  contract_value:
    type: number
    label: Total Contract Value

  # Place the signature block's questions here.
  # The suffix must match the _alias used in the insert() call.
  _insert_questions_party_a_signature: null

  final_review_date:
    type: date
    label: Date of Final Review
{{ insert('signature_block', _alias='party_a_signature') }}

The interview flow becomes:

  1. Client's Full Legal Name
  2. Total Contract Value
  3. Component questions from signature_block (placed by the marker)
  4. Date of Final Review

When combined with _ask, the gate question is placed just before the component's questions at the marker position.

Multiple Instances (_alias)

If you need to insert the exact same component twice (e.g., two signature blocks), you must give each instance a unique namespace using _alias:

### Party A:
{{ insert('signature_block', _alias='party_a_sig', signer_name=party_a_name) }}

### Party B:
{{ insert('signature_block', _alias='party_b_sig', signer_name=party_b_name) }}

Each instance will now ask its questions independently.

Accessing Component Data from the Host

Using _alias groups the component's answers into a namespace in the Host's memory. The Host can reach in and read that data using the _components object:

{% if _components.party_a_sig.confirm_authority_to_sign %} ... {% endif %}

5. Component Collections (Clause Libraries)

When you have a set of related templates that share common configuration—like a library of legal clauses—group them in a directory with a collection.yml.

File Structure:

components/
└── legal_clauses/
    ├── collection.yml
    ├── force_majeure.md
    ├── confidentiality.md
    └── governing_law.md

components/legal_clauses/collection.yml

inputs:
  GoverningLaw:
    type: text
  Jurisdiction:
    type: text
  PartyA:
    type: text

questions:
  ArbitrationPreferred:
    label: Do the parties prefer arbitration over litigation?
    type: bool

The host inserts individual templates from the collection using dot-notation:

{{ insert('legal_clauses.confidentiality', PartyA=client_name) }}

{{ insert('legal_clauses.governing_law', GoverningLaw="Swedish law") }}

How Collections Differ from Standalone Components:

  1. Input Scoping: The collection.yml lists all possible inputs for the entire library. However, when you insert force_majeure.md, VIBE only requires you to pass the specific inputs that force_majeure.md actually uses.
  2. Shared Questions: Questions declared in collection.yml (like ArbitrationPreferred) are only asked once during the interview, even if you insert five different clauses from that collection. If you need a different value for a specific insertion, override it explicitly:
{{ insert('legal_clauses.governing_law', Jurisdiction="Gothenburg District Court") }}
  1. Automatic Followup Nesting: When a collection is gated by a _multichoice question, VIBE automatically nests collection questions under the relevant checkbox option if the question is referenced by exactly one component template. Questions used by two or more components (or none) remain standalone since there is no single obvious parent. This keeps the interview tidy — users only see a collection question when they have selected the component that needs it.

Shared Configuration with include

When a component library has many subdirectories that share the same inputs (e.g., party names, contract type), repeating them in every collection.yml is fragile — adding a new option means updating every file. Use include to inherit from a shared base:

components/
└── clauses/
    ├── common-inputs.yml
    ├── confidentiality/
    │   ├── collection.yml
    │   └── mutual_nda.md
    └── dispute/
        ├── collection.yml
        └── arbitration.md

clauses/common-inputs.yml

inputs:
  PartyA:
    type: text
  PartyB:
    type: text
  GoverningLaw:
    type: text

clauses/confidentiality/collection.yml

include: ../common-inputs.yml

inputs:
  ConfidentialityPeriod:
    type: text

The subdirectory inherits PartyA, PartyB, and GoverningLaw from the base, and adds its own ConfidentialityPeriod. If the host template later adds a fourth shared input, only common-inputs.yml needs to change.

Deep merge applies: nested mappings are merged recursively, while scalars and lists are replaced by the overlay. See Config Inheritance in the Configuration Reference for full details.

Nested Collections

Collections can be nested for deeper organization:

components/
└── clauses/
    ├── confidentiality/
    │   ├── collection.yml
    │   ├── mutual_nda.md
    │   ├── unilateral_nda.md
    │   └── bank_secrecy.md
    └── dispute/
        ├── collection.yml
        ├── arbitration.md
        └── litigation.md
{{ insert('clauses.confidentiality.mutual_nda', PartyA=client_name) }}
{{ insert('clauses.dispute.arbitration', GoverningLaw="Swedish law") }}

6. Sharing Questions Without insert()

Everything so far has used {{ insert() }} — you get a component's template content rendered into your document, and optionally its questions added to your interview. But components can also be reused in a second way: importing just the question definitions, without any rendered output.

insert() uses:
Template content Rendered into the document Not used
Questions Namespaced under _components.alias.* Merged directly into host namespace
Use case "Include this section" "Share these question definitions"

When You Need This

Imagine you have a component that defines a detailed set of qualification questions — types, labels, help text, validation rules. Some templates insert() it to get the standard rendering. But another template needs the same questions with a completely different document layout. Duplicating the question definitions means maintaining them in two places.

The uses: declaration solves this. List a component under uses: in your config.yml, and VIBE imports its questions into your interview as if you had defined them directly:

# components/qualification_criteria/component.yml
questions:
  min_revenue:
    type: bool
    label: Require minimum annual revenue?
  min_revenue_amount:
    type: amount
    label: Minimum revenue (per year)
    currencies: [SEK]
# config.yml (Host)
uses:
  - qualification_criteria

questions:
  project_name:
    type: text
    label: Project name
  # min_revenue and min_revenue_amount come from
  # qualification_criteria — no need to redefine them

In your template, reference the imported questions by their original names:

{% if min_revenue %}
The supplier must demonstrate annual revenue of at least {{ min_revenue_amount }}.
{% endif %}

Only questions that your template actually references become relevant during the interview. If the component defines 30 questions but your template only uses 5, the user only sees those 5.

Aliasing (Namespace Prefix)

By default, imported questions keep their original names. If you need them under a different namespace — for example, because your document structure expects a longer path — you can alias the component:

uses:
  - contact_details: billing.contact

This prefixes every question from contact_details with the alias. A component question named email becomes billing.contact.email in your interview and template:

Billing contact: {{ billing.contact.email }}

The syntax is the same as session context aliasing{component_id: alias}.

uses: for Session Context

You may also encounter uses: in Session Context, where it serves a related purpose: declaring that your template depends on data provided before the interview starts (organization settings, user permissions, etc.). Those components use definitions: instead of questions:, and their data comes from external systems rather than from the user.

The mechanism is the same — uses: imports a component's schema into your template without rendering its content — but the data source differs. Session context is pre-filled; questions are asked during the interview.

Same Component, Two Access Patterns

A single component can be insert()ed by one template and uses:d by another. The component doesn't need to change:

# Template A — uses the full component (questions + rendered output)
# template.md: {{ insert("qualification_criteria") }}
# Template B — only wants the questions, writes its own output
# config.yml:
uses:
  - qualification_criteria

The component owns the question definitions and a default rendering. Templates that want a custom layout simply uses: it instead of inserting it.


7. Advanced Features

Component Defaults

If you are inserting dozens of clauses that all require PartyA and GoverningLaw, mapping them in every single insert() call is tedious. You can set global defaults in the Host's config.yml:

config.yml (Host)

component_defaults:
  PartyA: "{{ client_name }}"
  PartyB: "{{ supplier_name }}"
  GoverningLaw: "Swedish law"

Now, any inserted component that expects PartyA will automatically receive client_name. You can still override these defaults manually in a specific insert() call.

Value Precedence (How Variables Are Resolved)

When a component template references a variable like {{ party_name }}, VIBE resolves its value by checking multiple sources in a fixed precedence order. The first source that provides a value wins — later sources are skipped for that variable.

Priority Source Description
1 (highest) Explicit insert() params Values passed as keyword arguments in the insert() call
2 Host component_defaults Jinja expressions from the host's config.yml, rendered against host state
3 Component input defaults Default values from inputs: in the component's component.yml
4 Host passthrough Host variables passed through to the component (see below)
5 Definition inputs For definition-linked components (uses:), values from namespaced host questions
6 Component questions User answers to the component's own questions:
7 (lowest) Collection questions User answers to shared questions from collection.yml

How passthrough works (priority 4):

  • Components with their own questions receive only host variables that already have values, plus session context. This is the "selective" passthrough.
  • Components without questions (plaintext components and input-only components) receive all host question variables including type-aware placeholders for unanswered questions. This is the "full" passthrough — it ensures the component can reference any host variable without causing undefined-variable errors during probing.
  • Session context variables (from uses: declarations) are always available to all components, regardless of encapsulation. They override input defaults because they represent real external data.

Practical implications:

  • insert() kwargs always win. Use them when you need to guarantee a specific value.
  • component_defaults are a convenient middle ground — they apply to all components but can be overridden per-call.
  • A component's inputs: defaults are a last resort before passthrough — they only apply when nothing higher-priority provides a value.
  • User answers to component questions are lower priority than explicit params and defaults. This means the host can pre-answer a question by passing it as an insert() kwarg or setting it in component_defaults.

Example:

# collection.yml: inputs: {currency: {type: text, default: "EUR"}}
# host component_defaults: {currency: "{{ preferred_currency }}"}
# insert("invoice", currency="SEK")

# Result: currency = "SEK" (explicit param wins over component_defaults and input default)

Debugging tip: Use vibe-dev debug <template_id> --json --component-values <alias> to see exactly which precedence step provided each variable for a specific component instance.

Definition-Linked Components

If a component represents structured data (like a contact record), you can link it to a globally defined definition. This automatically provides all fields from the definition as component inputs.

(See Reusable Data with definitions for full instructions, but here is a quick example):

config.yml (Host)

definitions:
  person:
    full_name:
      type: text
    email:
      type: email

components/contact_card/component.yml (Component)

uses: person

Now, inserting contact_card with an _alias="primary_contact" will automatically generate Host interview questions for primary_contact.full_name and primary_contact.email.

Definition-Only Components

Some components define data structures without producing any document output. They have no .md template file, only a component.yml marked with definition_only: true. These are used for Session Context (receiving external API data) and Linked Interviews (passing data between connected templates).

File Format Support

Components support both Markdown and DOCX formats:

  • Components can use .md or .docx files.
  • Host templates can insert components of either format regardless of their own format.
  • VIBE handles format conversions automatically for web preview and DOCX output.

Encapsulation

By default, a component with component.yml can still see host template variables that are not declared as inputs — they are passed through automatically. This is convenient for simple components but can lead to hidden dependencies.

Setting encapsulation: strict prevents this passthrough. The component only sees variables that are explicitly declared as inputs and passed via insert() kwargs or component_defaults:

# component.yml
encapsulation: strict

inputs:
  party_name:
    type: text
    description: Name of the contracting party.

questions:
  include_penalty_clause:
    type: bool
    label: Include penalty clause?

With encapsulation: strict, the component cannot reference {{ client_email }} or any other host variable unless it is declared as an input and explicitly passed.

When to use strict encapsulation:

  • Components shared across many templates — avoids relying on host-specific variable names.
  • Components where you want a clear, self-documenting interface.

When to omit it (the default):

  • File-drop components that are meant to share the host's namespace.
  • Components where listing every possible host variable as an input would be impractical.

Note: Session context variables (from uses: declarations) are always available to all components regardless of encapsulation, because they represent externally-provided data rather than template-specific questions.

Component Sources (Python Packages)

By default, VIBE looks in a components/ directory next to your template. You can add external sources or installed Python packages in the Host's config.yml:

config.yml (Host)

component_sources:
  - shared/clause_library     # A different local directory
  - klausulbiblioteket        # An installed Python package

Resolution order:

  1. Filesystem path (relative to working directory, or absolute) — checked first.
  2. Installed Python package via importlib.util.find_spec() — used as fallback.

A local checkout always wins over an installed package, which is convenient during development:

Environment Resolution
CI (uv sync / pip install) Package from git → find_specsite-packages/
Local (editable install) pip install -e ../klausulbiblioteketfind_spec → repo root
Local (sibling checkout) Filesystem path ../klausulbiblioteket matches first