How to Add a Composition
Add a composition to a PromptPack so a workflow state runs a declarative step graph — classify → branch → extract, or parallel fan-out → synthesize — instead of a single prompt. Compositions express procedural flows (document pipelines, data extraction, request → reason → commit) that don't fit the turn-by-turn dialogue shape.
Prerequisites
- A PromptPack with the prompts/tools you want to orchestrate (v1.5+ schema)
- Understanding of Pack Structure and Workflows
- Familiarity with the Compositions schema reference
When to Use Composition vs internal / external / hybrid
A workflow state's orchestration value chooses the shape of its flow. composition is a fourth value alongside the three event-driven modes:
| Orchestration | Shape | Reach for it when |
|---|---|---|
internal (default) | Conversational, agent emits transitions | Turn-by-turn dialogue, agent loops with cycles |
external | Conversational, system emits transitions | Human-in-the-loop, system-controlled routing |
hybrid | Conversational, both | Mixed control dialogue |
composition | Procedural, step graph | Fixed sequences: classify → branch → extract, parallel fan-out → synthesize, Function-mode packs |
Choose composition when the flow is deterministic and procedural — the steps and their order are known up front, not driven by conversation events. Choose internal/external/hybrid when the flow is dialogue that converges over turns.
Compositions are reached only through a workflow state. A purely procedural pack is just a one-state terminal workflow whose state is in composition mode — see the Function-mode wrapper below.
Step 1: Wire a State to a Composition
Set orchestration: composition on a state and point its composition field at a key in the top-level compositions map. A composition-mode state omits prompt_task (it's required only for the other modes):
{
"workflow": {
"version": 1,
"entry": "main",
"states": {
"main": {
"orchestration": "composition",
"composition": "analyze_document",
"terminal": true
}
}
}
}
Rules:
- A
composition-mode state must setcompositionand may omitprompt_task. - Every other state (including the default
internal) must setprompt_taskand must not setcomposition. - The
compositionvalue must resolve to a key incompositions. compositionis exclusive — don't combine it withinternal/external/hybridsemantics on the same state.
Step 2: Define the Composition
Add a top-level compositions map, keyed by name. Each composition declares a version (currently 1) and an ordered steps array. Order is logical: steps run sequentially by default; branch and parallel alter the flow. The graph must be acyclic.
{
"compositions": {
"analyze_document": {
"version": 1,
"description": "Classify a document and route to a type-specific analyzer.",
"input_schema": "schemas/document.json",
"output_schema": "schemas/analysis.json",
"steps": [ /* … */ ]
}
}
}
Optional fields: input_schema / output_schema (references to JSON Schemas), output (the step ID whose output is the composition's output — defaults to the last step), and engine (opaque runtime config).
Step 3: Add Steps
A composition is a directed acyclic graph of typed steps. v1 defines five kinds. Every step has a unique id (pattern ^[a-zA-Z_][a-zA-Z0-9_]*$) and a kind.
prompt — one-shot LLM call
{
"id": "classify",
"kind": "prompt",
"prompt_task": "doc_classifier",
"input": "${input.text}",
"output_schema": "schemas/document-type.json"
}
agent — bounded LLM-tool loop
An agent step runs an LLM with a scoped tool registry until its required termination predicate is met (max_steps and/or tool_called). Without termination the step is invalid.
{
"id": "synthesize",
"kind": "agent",
"prompt_task": "doc_analyzer",
"input": "${extract_metadata.output.metadata}",
"tools": ["doc.section_lookup", "ref.search"],
"termination": { "max_steps": 10 },
"output_schema": "schemas/analysis.json"
}
tool — deterministic call
The runtime calls the tool directly (not via an LLM tool-call decision):
{
"id": "structure",
"kind": "tool",
"tool": "doc.parse_structure",
"args": { "content": "${input.text}" }
}
branch — conditional
Picks then (required) or else (optional) based on a constrained predicate — see Step 4.
{
"id": "route",
"kind": "branch",
"predicate": { "path": "${classify.output.type}", "op": "equals", "value": "research_paper" },
"then": "extract_paper",
"else": "extract_general"
}
parallel — fan-out + reduce
See Step 5.
Step input bindings
Steps wire together with ${...} references — a strict subset of the template-variable system, with no expressions:
${input.X}— the composition's structured input.${stepId.output.X}— a prior step's structured output.
Step 4: Gate Flow with Predicates
branch predicates use a constrained, declarative vocabulary — not an expression language. A predicate is exactly one of:
- Compare —
{ path, op, value }withop∈equals,not_equals,in,not_in,less_than,less_than_or_equals,greater_than,greater_than_or_equals. - Exists —
{ path, exists: true|false }. - Combinators —
{ all_of: [...] }(AND),{ any_of: [...] }(OR),{ not: ... }.
{
"id": "needs_deep_review",
"kind": "branch",
"predicate": {
"any_of": [
{ "path": "${assess.output.confidence}", "op": "less_than", "value": 0.8 },
{ "path": "${assess.output.complexity}", "op": "greater_than", "value": 7 }
]
},
"then": "deep_review",
"else": "quick_summary"
}
Need a condition the predicate language can't express? Emit a boolean from a prompt step and branch on it: { "path": "${gate.output.proceed}", "op": "equals", "value": true }. This keeps business logic out of the spec while staying declarative.
Step 5: Fan Out with parallel + reduce
A parallel step runs ≥2 branches concurrently and merges them with a required reduce:
{
"id": "extract_metadata",
"kind": "parallel",
"branches": [
{ "id": "title", "kind": "prompt", "prompt_task": "title_extractor", "input": "${input.text}" },
{ "id": "keywords", "kind": "prompt", "prompt_task": "keyword_extractor", "input": "${input.text}" },
{ "id": "structure","kind": "tool", "tool": "doc.parse_structure", "args": { "content": "${input.text}" } }
],
"reduce": { "strategy": "barrier", "into": "metadata" }
}
Reducer strategies:
append— extend lists across branch outputs.replace— last write wins.barrier— collect every branch output into a named map.
The merged result lands under into, read downstream as ${extract_metadata.output.metadata}.
Step 6 (optional): Add Modifiers
Any step can carry modifiers:
{
"id": "synthesize",
"kind": "agent",
"prompt_task": "doc_analyzer",
"termination": { "max_steps": 10 },
"modifiers": {
"retry": { "max_attempts": 2 },
"eval": ["analysis_quality"]
}
}
retry—{ "max_attempts": N }retry budget for the step.eval— references to keys in the pack'sevalsobject (RFC 0006); runtimes may run them inline or post-Send.
Function-Mode: the One-State Wrapper Idiom
A pack that is purely procedural — no dialogue — is just a one-state terminal workflow whose state is in composition mode. This six-line wrapper is the standard idiom for invoking a pack programmatically (input → structured output):
{
"workflow": {
"version": 1,
"entry": "main",
"states": {
"main": { "orchestration": "composition", "composition": "analyze_document", "terminal": true }
}
}
}
The workflow state machine stays the universal entry point; the composition does the procedural work.
Complete Example
A document analyzer that classifies and routes to a type-specific extractor:
{
"$schema": "https://promptpack.org/schema/v1.5.0/promptpack.schema.json",
"id": "document-analyzer",
"name": "Document Analyzer",
"version": "1.0.0",
"template_engine": { "version": "v1", "syntax": "{{variable}}" },
"prompts": {
"doc_classifier": {
"id": "doc_classifier", "name": "Classifier", "version": "1.0.0",
"system_template": "Classify the document. Return JSON: { \"type\": \"research_paper\" | \"general\" }.\n\n{{input}}",
"parameters": { "temperature": 0.0 }
},
"research_paper_extractor": {
"id": "research_paper_extractor", "name": "Paper Extractor", "version": "1.0.0",
"system_template": "Extract title, authors, abstract, findings.\n\n{{input}}",
"parameters": { "temperature": 0.2 }
},
"general_doc_extractor": {
"id": "general_doc_extractor", "name": "General Extractor", "version": "1.0.0",
"system_template": "Extract a summary and main entities.\n\n{{input}}",
"parameters": { "temperature": 0.2 }
}
},
"workflow": {
"version": 1,
"entry": "main",
"states": {
"main": { "orchestration": "composition", "composition": "analyze_document", "terminal": true }
}
},
"compositions": {
"analyze_document": {
"version": 1,
"description": "Classify a document and route to a type-specific analyzer.",
"steps": [
{ "id": "classify", "kind": "prompt", "prompt_task": "doc_classifier", "input": "${input.text}" },
{
"id": "route", "kind": "branch",
"predicate": { "path": "${classify.output.type}", "op": "equals", "value": "research_paper" },
"then": "extract_paper", "else": "extract_general"
},
{ "id": "extract_paper", "kind": "prompt", "prompt_task": "research_paper_extractor", "input": "${input.text}" },
{ "id": "extract_general", "kind": "prompt", "prompt_task": "general_doc_extractor", "input": "${input.text}" }
]
}
}
}
Validation Checklist
- The composition-mode state sets
compositionand omitsprompt_task - Every non-composition state still sets
prompt_taskand omitscomposition - The state's
compositionresolves to a key incompositions - Each
Step.idis unique within the composition (including nestedparallel.branches) and matches^[a-zA-Z_][a-zA-Z0-9_]*$ - Every
prompt_task,tool,eval, andthen/else/depends_onreference resolves - Every
${...}path resolves toinputor a prior step'soutput - Each
agentstep has aterminationpredicate - Each
parallelstep has ≥2 branches and areduce - Each
branchpredicate uses the constrained shape (no free-form expressions) - The composition graph is acyclic
- Pack validates against the v1.5 JSON schema
- Missing
terminationon an agent step: agent steps require it — without it the loop is unbounded and the pack fails validation. - Single-branch
parallel: aparallelneeds at least two branches and areduce. - Expression in a predicate:
${a < 0.8 || b > 7}is not valid. Use a compare/combinator predicate, or lift the logic into apromptstep that emits a boolean. - Cyclic graph: if you need a loop, encode it at the workflow state-machine layer (
on_event+max_visits), not inside a composition.
Next Steps
- How to Add a Workflow — the state machine compositions plug into
- Architecture Patterns — procedural vs conversational orchestration
- Document Analyzer example — full composition pack
- Compositions schema reference — every property table
- RFC 0010: Workflow Composition — design rationale