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:
- How to create a YAML file for your product (step by step)
- Every element in the manifest (complete reference)
- How to link the YAML file in your project
Before you start
| You define in YAML | You 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:
| File | Purpose |
|---|---|
examples/sample-saas/manifests/shopco.pipeline.yaml | Full ShopCo manifest |
examples/sample-saas/src/load-manifest.ts | YAML → SDK registration |
examples/sample-saas/src/index.ts | Tools + 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.jsonNaming 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: hardStep 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 accessRules:
initial_stagemust match one of your stage keys exactly.- Use
next:on a stage to auto-advance when that stage's flow completes. - Stages without
floware 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 emailSteps run in YAML key order (top to bottom). Use three step types:
type | Purpose |
|---|---|
content | Assistant delivers information; advances on any user reply |
attribute | Collect a profile field; advances when value is saved |
tool | Call 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: softseverity | Effect |
|---|---|
hard | Strong constraint — assistant must obey |
soft | Guideline — assistant should follow when possible |
Part 2 — Link the YAML file in your project
Install dependencies
npm install @aelio/sdk yaml
npm install -D @types/node # if using TypeScriptThe 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:
| Method | Example |
|---|---|
| Default path in loader | join(__dirname, '../manifests/my-product.pipeline.yaml') |
| Environment variable | AELIO_PIPELINE_MANIFEST=./config/staging.pipeline.yaml |
| Function argument | loadPipelineManifest('./manifests/acme.pipeline.yaml') |
AELIO_PIPELINE_MANIFEST=./manifests/staging.pipeline.yaml \
AELIO_SDK_SECRET=change-me-in-production \
node dist/index.jsUse 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.yamlVerify 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
| Key | Required | Description |
|---|---|---|
product | Yes | Product name, description, assistant persona |
pipeline | Yes | Global stages and transitions |
attributes | No | Profile memory schema (empty {} if none) |
flows | No | Guided step sequences (empty {} if none) |
policies | No | Behavioral rules |
tool_groups | No | Reusable tool access buckets |
lifecycle_states | No | Extra 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."| Field | Type | Required | Maps to SDK |
|---|---|---|---|
name | string | Yes | Informational (not sent separately) |
description | string | Yes | aelio.describe() |
persona | string | Yes | aelio.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 field | Type | Description |
|---|---|---|
tools | string[] | Tool names in this bucket |
intents | string[] | Tool intent values in this bucket |
safety | read | 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 listsblocked_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
| Type | Required | Description |
|---|---|---|
| string | Yes | Stage 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).
| Field | Type | Required | Description |
|---|---|---|---|
description | string | Yes | Injected into LLM prompt — what this stage means |
flow | string | No | Flow id to run (must exist under flows) |
next | string | No | Stage to enter when this stage's flow completes |
content | object | No | Optional opening copy (see below) |
guards | object | No | Entry/exit requirements (see below) |
allowed_tools | string[] | No | Only these tools permitted |
blocked_tools | string[] | No | These tools always blocked |
allowed_intents | string[] | No | Only tools with these intents permitted |
blocked_intents | string[] | No | Tools with these intents blocked |
allowed_safety | string[] | No | Only these safety classes permitted |
blocked_safety | string[] | No | These safety classes blocked |
allowed_groups | string[] | No | Tool group ids to allow (from tool_groups) |
blocked_groups | string[] | No | Tool group ids to block |
content sub-fields:
| Field | Type | Description |
|---|---|---|
greeting | string | Suggested opening message for this stage |
cta | string | Call-to-action text (e.g. "Let's go") |
guards sub-fields:
| Field | Type | Description |
|---|---|---|
requires_fields | string[] | 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: activeStages 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| Field | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | — | Human-readable name shown in memory prompts |
data_type | string | No | string | string, number, boolean, object, array |
sensitivity_tier | string | No | pii | public, pii, sensitive_regulated |
prompts | string[] | No | — | Suggested questions when collecting this field |
enum_values | (string | number)[] | No | — | Allowed values; enables quick-reply matching |
ui_format | object | No | — | Widget UI hint (see below) |
ui_format sub-fields:
| Field | Type | Description |
|---|---|---|
component | string | quick_reply, text_input, or email_input |
config | object | Component-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| Field | Type | Required | Description |
|---|---|---|---|
state | string | Yes | Pipeline stage id this flow belongs to |
description | string | Yes | What this flow accomplishes |
steps | object | Yes | Ordered map of step id → step definition |
flows.{flow_id}.steps.{step_id}
| Field | Type | Required | Description |
|---|---|---|---|
goal | string | Yes | What the assistant should accomplish in this step |
type | string | No | content, attribute, or tool (inferred if omitted) |
attribute | string | For attribute | Attribute id to collect |
tool | string | For tool | SDK tool name (must be registered via aelio.expose()) |
skip_if_present | string | No | Skip this step if attribute id already has a value |
Type inference (when type is omitted):
- Has
tool→tool - Has
attribute→attribute - Otherwise →
content
Advance conditions:
| Type | Advances when |
|---|---|
content | User sends any reply |
attribute | Valid value collected and saved to profile memory |
tool | SDK 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| Field | Type | Required | Default | Description |
|---|---|---|---|---|
description | string | Yes | — | Rule text injected into the harness |
severity | string | No | soft | hard 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:
| From | Must match | To |
|---|---|---|
pipeline.initial_stage | exists in | pipeline.stages keys |
stage.flow | exists in | flows keys |
stage.next | exists in | pipeline.stages keys |
flow.state | equals | a pipeline.stages key |
step.attribute | exists in | attributes keys |
step.tool | registered via | aelio.expose('toolName', ...) |
guards.requires_fields | exists in | attributes keys |
allowed_groups / blocked_groups | exist in | tool_groups keys |
Tool intent in code | listed in | tool_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]- New customer →
initial_stage - Engine finds active flow + current step
- LLM guided by stage/step prompts
- Attribute values saved to
customer_attributestable - Tool steps invoke your handlers directly
- Flow completes →
nextstage 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Pipeline not active | Loader not called before listen() | Call loadPipelineManifest() first |
| Tool "not available" during onboarding | Stage blocks that tool/intent | Check allowed_groups / blocked_groups |
| Step never advances | Attribute value rejected as generic | User said "yes"/"hi" for a name field — need substantive input |
| Flow step treated as tool | Harness confusion | Ensure step has correct type: attribute or content |
| Unknown tool group error at startup | Typo in allowed_groups | Group id must exist in tool_groups |
| Same customer stuck in old stage | Reused customer id | Use 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 studioNext steps
- ShopCo Sample SaaS — full working demo
- Node.js SDK API — all SDK methods
- Local Development — run the stack locally
- SDK Wire Protocol — register frame schema