Aelio
Installation

Pipeline YAML Manifest

Create a pipeline YAML file for your product, understand every field, and wire it into your backend.

Aelio lets you define conversational pipelines in a YAML manifest file. You describe stages, profile fields, and guided steps in YAML; your backend implements business actions as SDK tools in TypeScript.

Required for every integration

Creating and linking a pipeline YAML file is a mandatory part of installation. Your SDK backend loads the file at startup and registers it with Aelio — the server never reads the YAML directly.

This guide covers three things:

  1. How to create a YAML file for your product (step by step)
  2. Every element in the manifest (complete reference)
  3. How to link the YAML file in your project

Before you start

You define in YAMLYou define in code (aelio.expose())
Product description and persona
Pipeline stages and transitions
Profile attribute schema
Guided flow steps
Tool access rules
Conversation policies
Tool handlers (database queries, API calls)

The server does not read your YAML file

Aelio Server never opens your YAML file directly. Your SDK backend loads it at startup and registers the parsed config over WebSocket. You need a small loader script (shown below).

Reference example in the open-source repo:

FilePurpose
examples/sample-saas/manifests/shopco.pipeline.yamlFull ShopCo manifest
examples/sample-saas/src/load-manifest.tsYAML → SDK registration
examples/sample-saas/src/index.tsTools + boot sequence

Part 1 — Create your YAML file

Step 1: Add the file to your project

Create a manifests/ folder in your backend and add your manifest:

my-saas-backend/
├── manifests/
│   └── my-product.pipeline.yaml    ← create this
├── src/
│   ├── load-manifest.ts            ← loader (Part 2)
│   └── index.ts                    ← entry point
└── package.json

Naming convention: {product}.pipeline.yaml. Any name works as long as your loader points to it.

Step 2: Start with the skeleton

Copy this empty skeleton and fill in your product details:

# my-product.pipeline.yaml

product:
  name: MyProduct
  description: >
    One paragraph describing what your product does.
  persona: >
    How the assistant should sound and behave.

tool_groups: {}

pipeline:
  initial_stage: new_user
  stages:
    new_user:
      description: First-time visitor.
      flow: welcome
      next: active

attributes: {}

flows:
  welcome:
    state: new_user
    description: Greet the user.
    steps:
      greet:
        type: content
        goal: Welcome them and explain what the product does

policies:
  stay-in-lifecycle:
    description: Stay within the customer's current pipeline stage.
    severity: hard

Step 3: Design your stages

List the lifecycle phases your customers go through. Each stage becomes a key under pipeline.stages:

pipeline:
  initial_stage: unverified        # where NEW customers start
  stages:
    unverified:   { ... }          # first visit
    verified:     { ... }          # identity confirmed
    onboarding:   { ... }          # profile + product tour
    active:       { ... }          # full product access

Rules:

  • initial_stage must match one of your stage keys exactly.
  • Use next: on a stage to auto-advance when that stage's flow completes.
  • Stages without flow are free-chat stages (no guided steps).

Step 4: Define profile attributes

List every field you want to collect during onboarding:

attributes:
  verified_email:
    label: Email address
    data_type: string
    sensitivity_tier: pii
    prompts:
      - What email should we use for your account?

  display_name:
    label: Display name
    data_type: string
    sensitivity_tier: pii
    prompts:
      - What should I call you?

Attribute ids (the keys: verified_email, display_name) are referenced in flow steps and stage guards.

Step 5: Write flows (ordered steps)

Each stage with a flow: field needs a matching entry under flows::

# Stage references this:
pipeline:
  stages:
    verified:
      flow: verify_identity
      next: onboarding

# So define this:
flows:
  verify_identity:
    state: verified                    # must match stage key
    description: Collect email before product access.
    steps:
      collect_email:
        type: attribute
        goal: Ask for their email address
        attribute: verified_email      # must match attributes key
      email_ack:
        type: content
        goal: Confirm you received their email

Steps run in YAML key order (top to bottom). Use three step types:

typePurpose
contentAssistant delivers information; advances on any user reply
attributeCollect a profile field; advances when value is saved
toolCall an SDK tool; advances when the tool succeeds

Step 6: Register tools in code (not YAML)

For every type: tool step, implement the tool in your backend:

# YAML — references tool by name
steps:
  review_orders:
    type: tool
    goal: Show their existing orders
    tool: listOrders          # ← must match aelio.expose() name
// index.ts — implements the tool
aelio.expose('listOrders', async ({ status }, ctx) => {
  return db.listOrders(ctx.customerId, status);
}, {
  description: "List the customer's orders",
  params: { status: { type: 'string', optional: true } },
  safety: 'read',
  intent: 'order_inquiry',    // align with tool_groups if used
});

Step ids ≠ tool names

collect_email, pitch, and review_orders are flow step labels in YAML — not functions.

listOrders, syncProfile, and cancelOrder are SDK tools registered with aelio.expose().

Step 7: Add tool access rules (optional)

If some stages should block certain tools (e.g. no cancellations during onboarding), define tool_groups and reference them from stages:

tool_groups:
  account_read:
    intents: [onboarding, order_inquiry]
  writes:
    safety: [write, destructive]

pipeline:
  stages:
    onboarding:
      allowed_groups: [account_read]
      blocked_groups: [writes]

Each tool's intent and safety in aelio.expose() must match what your groups filter on.

Step 8: Add policies

Policies are behavioral rules injected into every turn:

policies:
  stay-in-lifecycle:
    description: Stay within the customer's current pipeline stage. Do not skip ahead.
    severity: hard
  no-invented-pricing:
    description: Never invent prices; only state amounts returned by tools.
    severity: soft
severityEffect
hardStrong constraint — assistant must obey
softGuideline — assistant should follow when possible

Install dependencies

npm install @aelio/sdk yaml
npm install -D @types/node   # if using TypeScript

The yaml package parses your manifest at startup.

Create the loader

Create src/load-manifest.ts. This reads your YAML and calls the SDK registration APIs:

import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'yaml';
import { aelio, type ToolGroupDefinition } from '@aelio/sdk';

/** Map stage access fields from snake_case YAML → camelCase SDK */
function mapAccess(fields: {
  allowed_tools?: string[];
  blocked_tools?: string[];
  allowed_intents?: string[];
  blocked_intents?: string[];
  allowed_safety?: Array<'read' | 'write' | 'destructive'>;
  blocked_safety?: Array<'read' | 'write' | 'destructive'>;
  allowed_groups?: string[];
  blocked_groups?: string[];
}) {
  return {
    ...(fields.allowed_tools ? { allowedTools: fields.allowed_tools } : {}),
    ...(fields.blocked_tools ? { blockedTools: fields.blocked_tools } : {}),
    ...(fields.allowed_intents ? { allowedIntents: fields.allowed_intents } : {}),
    ...(fields.blocked_intents ? { blockedIntents: fields.blocked_intents } : {}),
    ...(fields.allowed_safety ? { allowedSafety: fields.allowed_safety } : {}),
    ...(fields.blocked_safety ? { blockedSafety: fields.blocked_safety } : {}),
    ...(fields.allowed_groups ? { allowedGroups: fields.allowed_groups } : {}),
    ...(fields.blocked_groups ? { blockedGroups: fields.blocked_groups } : {}),
  };
}

export function loadPipelineManifest(manifestPath?: string): void {
  const __dirname = dirname(fileURLToPath(import.meta.url));
  const path =
    manifestPath ??
    process.env.AELIO_PIPELINE_MANIFEST ??
    join(__dirname, '../manifests/my-product.pipeline.yaml');

  const raw = parse(readFileSync(path, 'utf8'));

  // 1. Product context
  aelio.describe(raw.product.description.trim());
  aelio.persona(raw.product.persona.trim());

  // 2. Tool groups (optional)
  let toolGroups: Record<string, ToolGroupDefinition> | undefined;
  if (raw.tool_groups) {
    toolGroups = Object.fromEntries(
      Object.entries(raw.tool_groups).map(([id, group]) => [
        id,
        {
          ...(group.tools ? { tools: group.tools } : {}),
          ...(group.intents ? { intents: group.intents } : {}),
          ...(group.safety ? { safety: group.safety } : {}),
        },
      ]),
    );
    aelio.toolGroups(toolGroups);
  }

  // 3. Pipeline stages
  aelio.pipeline({
    initialStage: raw.pipeline.initial_stage,
    ...(toolGroups ? { toolGroups } : {}),
    stages: Object.fromEntries(
      Object.entries(raw.pipeline.stages).map(([id, stage]) => [
        id,
        {
          description: stage.description.trim(),
          ...(stage.content ? { content: stage.content } : {}),
          ...(stage.flow ? { flow: stage.flow } : {}),
          ...mapAccess(stage),
          ...(stage.guards?.requires_fields
            ? { guards: { requiresFields: stage.guards.requires_fields } }
            : {}),
          ...(stage.next ? { next: stage.next } : {}),
        },
      ]),
    ),
  });

  // 4. Profile attributes
  for (const [id, attr] of Object.entries(raw.attributes ?? {})) {
    aelio.attribute(id, {
      label: attr.label,
      dataType: attr.data_type ?? 'string',
      sensitivityTier: attr.sensitivity_tier ?? 'pii',
      ...(attr.prompts ? { prompts: attr.prompts } : {}),
      ...(attr.enum_values ? { enumValues: attr.enum_values } : {}),
      ...(attr.ui_format
        ? {
            uiFormat: {
              component: attr.ui_format.component,
              ...(attr.ui_format.config ? { config: attr.ui_format.config } : {}),
            },
          }
        : {}),
    });
  }

  // 5. Flows
  for (const [flowId, flow] of Object.entries(raw.flows ?? {})) {
    aelio.flow(flowId, {
      state: flow.state,
      description: flow.description.trim(),
      steps: Object.fromEntries(
        Object.entries(flow.steps).map(([stepId, step]) => [
          stepId,
          {
            goal: step.goal,
            ...(step.type ? { type: step.type } : {}),
            ...(step.tool ? { tool: step.tool } : {}),
            ...(step.attribute ? { attribute: step.attribute } : {}),
            ...(step.skip_if_present ? { skipIfPresent: step.skip_if_present } : {}),
          },
        ]),
      ),
    });
  }

  // 6. Policies
  for (const [id, policy] of Object.entries(raw.policies ?? {})) {
    aelio.policy(id, {
      description: policy.description.trim(),
      severity: policy.severity ?? 'soft',
    });
  }
}

You can copy the production-ready version from examples/sample-saas/src/load-manifest.ts in the Aelio-Convox repo.

Wire the loader in your entry point

Boot order matters: load manifest → register tools → connect to server.

// src/index.ts
import { aelio } from '@aelio/sdk';
import express from 'express';
import { loadPipelineManifest } from './load-manifest.js';

const app = express();

// 1. Load YAML — registers pipeline, attributes, flows, policies
loadPipelineManifest();

// 2. Register tools referenced by flow steps in the YAML
aelio.expose('listOrders', async (args, ctx) => { /* ... */ }, {
  description: "List orders",
  params: {},
  safety: 'read',
  intent: 'order_inquiry',
});

// ... more aelio.expose() calls ...

// 3. Connect to Aelio server
await aelio.listen({
  secret: process.env.AELIO_SDK_SECRET!,
  url: process.env.AELIO_SERVER_URL ?? 'ws://127.0.0.1:3010',
});

app.listen(8080);

Point to a custom manifest path

Three ways to select which YAML file to load:

MethodExample
Default path in loaderjoin(__dirname, '../manifests/my-product.pipeline.yaml')
Environment variableAELIO_PIPELINE_MANIFEST=./config/staging.pipeline.yaml
Function argumentloadPipelineManifest('./manifests/acme.pipeline.yaml')
AELIO_PIPELINE_MANIFEST=./manifests/staging.pipeline.yaml \
AELIO_SDK_SECRET=change-me-in-production \
node dist/index.js

Use different manifests per environment (staging vs production) without code changes.

Project structure (complete)

my-saas-backend/
├── manifests/
│   ├── my-product.pipeline.yaml       # production manifest
│   └── my-product.staging.yaml        # optional staging variant
├── src/
│   ├── load-manifest.ts               # YAML parser + SDK registration
│   └── index.ts                       # tools + aelio.listen()
├── package.json
└── .env
    AELIO_SDK_SECRET=change-me-in-production
    AELIO_SERVER_URL=ws://127.0.0.1:3010
    AELIO_PIPELINE_MANIFEST=./manifests/my-product.pipeline.yaml

Verify it works

Start Aelio server and your backend.

Check registration:

curl -s http://127.0.0.1:3010/ready | jq '.sdk'

You should see pipeline: true and your flow ids listed.

Open the chat widget with a new customer id (each new id starts at initial_stage):

<script
  src="http://localhost:3010/widget.js"
  data-customer-id="test-user-001"
></script>

Walk through your pipeline stages and confirm attributes are collected and tools run.


Part 3 — Complete YAML element reference

Top-level keys

KeyRequiredDescription
productYesProduct name, description, assistant persona
pipelineYesGlobal stages and transitions
attributesNoProfile memory schema (empty {} if none)
flowsNoGuided step sequences (empty {} if none)
policiesNoBehavioral rules
tool_groupsNoReusable tool access buckets
lifecycle_statesNoExtra lifecycle states not tied to pipeline stages

product

Grounds the LLM in your product context.

product:
  name: ShopCo
  description: >
    Multi-line description of what your product does.
    Used by the harness planner to decide what is possible.
  persona: >
    Standing instructions for tone, naming, and behavior.
    Example: "You are ShopCo's assistant. Be warm and concise."
FieldTypeRequiredMaps to SDK
namestringYesInformational (not sent separately)
descriptionstringYesaelio.describe()
personastringYesaelio.persona()

Use YAML > for multi-line strings without breaking indentation.


tool_groups

Named buckets that expand into allow/block lists at register time.

tool_groups:
  public:
    intents: [public]
  account_read:
    intents: [onboarding, order_inquiry, subscription, billing]
  writes:
    safety: [write, destructive]
  upsell:
    tools: [upgradePlan]
Group fieldTypeDescription
toolsstring[]Tool names in this bucket
intentsstring[]Tool intent values in this bucket
safetyread | write | destructive[]Safety classes in this bucket

A group can combine all three. Referenced from stages via allowed_groups / blocked_groups.

Expansion rules:

  • allowed_groups: [account_read] → union of all tools/intents/safety in that group added to allow lists
  • blocked_groups: [writes] → union added to block lists
  • Block rules take precedence over allow rules
  • Unknown group id → SDK throws at startup (fail fast)

pipeline

Global customer lifecycle.

pipeline:
  initial_stage: unverified
  stages:
    unverified: { ... }
    active: { ... }

pipeline.initial_stage

TypeRequiredDescription
stringYesStage id assigned to every new customer. Must exist in stages.

pipeline.stages.{stage_id}

Each stage is keyed by a unique id (e.g. unverified, onboarding, active).

FieldTypeRequiredDescription
descriptionstringYesInjected into LLM prompt — what this stage means
flowstringNoFlow id to run (must exist under flows)
nextstringNoStage to enter when this stage's flow completes
contentobjectNoOptional opening copy (see below)
guardsobjectNoEntry/exit requirements (see below)
allowed_toolsstring[]NoOnly these tools permitted
blocked_toolsstring[]NoThese tools always blocked
allowed_intentsstring[]NoOnly tools with these intents permitted
blocked_intentsstring[]NoTools with these intents blocked
allowed_safetystring[]NoOnly these safety classes permitted
blocked_safetystring[]NoThese safety classes blocked
allowed_groupsstring[]NoTool group ids to allow (from tool_groups)
blocked_groupsstring[]NoTool group ids to block

content sub-fields:

FieldTypeDescription
greetingstringSuggested opening message for this stage
ctastringCall-to-action text (e.g. "Let's go")

guards sub-fields:

FieldTypeDescription
requires_fieldsstring[]Attribute ids that must be collected before leaving this stage

Example — block writes during onboarding:

onboarding:
  description: Profile intake. No cancellations yet.
  flow: shopco_onboarding
  allowed_groups: [account_read]
  blocked_groups: [writes]
  guards:
    requires_fields:
      - display_name
      - shopping_preference
  next: active

Stages without flow are free-conversation stages (no guided steps). Stages without next do not auto-advance.


attributes

Profile memory schema. Values are stored per customer in Aelio's database when collected during attribute flow steps.

attributes:
  verified_email:
    label: Email address
    data_type: string
    sensitivity_tier: pii
    prompts:
      - What email should we use for your account?
    ui_format:
      component: email_input

  shopping_preference:
    label: Shopping preference
    data_type: string
    sensitivity_tier: public
    prompts:
      - Personal, business, or both?
    enum_values:
      - Personal
      - Business
      - Both
    ui_format:
      component: quick_reply
      config:
        options:
          - Personal
          - Business
          - Both
FieldTypeRequiredDefaultDescription
labelstringYesHuman-readable name shown in memory prompts
data_typestringNostringstring, number, boolean, object, array
sensitivity_tierstringNopiipublic, pii, sensitive_regulated
promptsstring[]NoSuggested questions when collecting this field
enum_values(string | number)[]NoAllowed values; enables quick-reply matching
ui_formatobjectNoWidget UI hint (see below)

ui_format sub-fields:

FieldTypeDescription
componentstringquick_reply, text_input, or email_input
configobjectComponent-specific config (e.g. options for quick_reply)

The attribute key (e.g. verified_email) is the id used in flow steps (attribute: verified_email) and guards (requires_fields).


flows

Ordered step sequences attached to a pipeline stage.

flows:
  shopco_onboarding:
    state: onboarding
    description: Profile intake then product tour.
    steps:
      collect_name:
        type: attribute
        goal: Ask for their name
        attribute: display_name
      sync_profile:
        type: tool
        goal: Save profile to account
        tool: syncProfile
FieldTypeRequiredDescription
statestringYesPipeline stage id this flow belongs to
descriptionstringYesWhat this flow accomplishes
stepsobjectYesOrdered map of step id → step definition

flows.{flow_id}.steps.{step_id}

FieldTypeRequiredDescription
goalstringYesWhat the assistant should accomplish in this step
typestringNocontent, attribute, or tool (inferred if omitted)
attributestringFor attributeAttribute id to collect
toolstringFor toolSDK tool name (must be registered via aelio.expose())
skip_if_presentstringNoSkip this step if attribute id already has a value

Type inference (when type is omitted):

  1. Has tooltool
  2. Has attributeattribute
  3. Otherwise → content

Advance conditions:

TypeAdvances when
contentUser sends any reply
attributeValid value collected and saved to profile memory
toolSDK tool invocation succeeds

After the last step completes, if the stage has next, the customer moves to that global stage.


policies

Behavioral rules enforced on every conversation turn.

policies:
  stay-in-lifecycle:
    description: Stay within the customer's current pipeline stage. Do not skip ahead.
    severity: hard
  pricing-clarity:
    description: Be transparent about pricing; never invent amounts.
    severity: soft
FieldTypeRequiredDefaultDescription
descriptionstringYesRule text injected into the harness
severitystringNosofthard or soft

Policy keys (e.g. stay-in-lifecycle) are unique ids — use kebab-case.


lifecycle_states (optional)

Extra lifecycle states not tied to pipeline stages. Rarely needed — pipeline stages auto-register as lifecycle states.

lifecycle_states:
  churn_risk:
    description: Customer may be leaving. No upsells.
    blocked_groups: [upsell]

Same access fields as pipeline stages (allowed_tools, blocked_groups, etc.) but no flow, next, or content.

Use when you need states you push manually via aelio.setCustomerState() without a guided flow.


Cross-reference checklist

Before deploying, verify these links are consistent:

FromMust matchTo
pipeline.initial_stageexists inpipeline.stages keys
stage.flowexists inflows keys
stage.nextexists inpipeline.stages keys
flow.stateequalsa pipeline.stages key
step.attributeexists inattributes keys
step.toolregistered viaaelio.expose('toolName', ...)
guards.requires_fieldsexists inattributes keys
allowed_groups / blocked_groupsexist intool_groups keys
Tool intent in codelisted intool_groups.*.intents (if using intent groups)

How it runs at runtime

flowchart TD
  A[User sends message] --> B[Evaluate pipeline]
  B --> C{Current step type?}
  C -->|content| D[LLM delivers goal]
  C -->|attribute| E[LLM asks + save value]
  C -->|tool| F[Invoke SDK tool]
  D --> G[Advance step]
  E --> G
  F --> G
  G --> H{Flow complete?}
  H -->|No| B
  H -->|Yes| I{Stage has next?}
  I -->|Yes| J[Move to next stage]
  I -->|No| K[Stay in stage]
  1. New customer → initial_stage
  2. Engine finds active flow + current step
  3. LLM guided by stage/step prompts
  4. Attribute values saved to customer_attributes table
  5. Tool steps invoke your handlers directly
  6. Flow completes → next stage if defined

Widget integration

Attribute steps with ui_format.component: quick_reply send chip buttons to the widget. Tapping a chip sends a normal message.

Use a unique customerId per fresh pipeline test:

<script
  src="https://your-aelio-server/widget.js"
  data-server-url="https://your-aelio-server"
  data-customer-id="user-unique-id-here"
></script>

Troubleshooting

SymptomLikely causeFix
Pipeline not activeLoader not called before listen()Call loadPipelineManifest() first
Tool "not available" during onboardingStage blocks that tool/intentCheck allowed_groups / blocked_groups
Step never advancesAttribute value rejected as genericUser said "yes"/"hi" for a name field — need substantive input
Flow step treated as toolHarness confusionEnsure step has correct type: attribute or content
Unknown tool group error at startupTypo in allowed_groupsGroup id must exist in tool_groups
Same customer stuck in old stageReused customer idUse a new customerId for fresh pipeline state

Inspect persisted data:

cd packages/db
AELIO_DATABASE_PATH="../../server/data/aelio-openai.db" pnpm exec drizzle-kit studio

Next steps

On this page