Skip to content

Workflow YAML Reference

Complete reference for workflow definition files.

Top-level structure

name: my-workflow                           # required
version: "1.0"                              # optional, default "1.0"
description: "What this workflow does"      # optional

config:
  provider: openai                          # default provider
  model: gpt-4o-mini                        # default model
  max_retries: 3                            # retry attempts per step
  budget_usd: 0.50                          # spending limit (null = unlimited)
  timeout: 300.0                            # workflow timeout in seconds (null = unlimited)
  max_concurrent_steps: 10                  # parallel step limit
  stream: false                             # streaming default

state:
  key: "value"                              # initial state variables
  nested:
    key: "value"

steps:                                      # at least one step required
  - id: step_id
    type: llm_call                          # llm_call | tool | router | subworkflow
    # ... step-specific fields

Config options

Option Type Default Description
provider string openai Default LLM provider
model string gpt-4o-mini Default model for all LLM steps
max_retries int 3 Retry attempts on failure
budget_usd float null Maximum spend in USD
timeout float null Workflow timeout in seconds
max_concurrent_steps int 10 Max parallel steps per layer. Bounded 1 ≤ N ≤ 1024 at parse time — values outside that range raise a Pydantic error instead of deadlocking the limiter or surfacing a cryptic total_tokens must be >= 0.
stream bool false Enable streaming by default
sandbox object disabled Security sandbox config
on_step_failure string skip_downstream Behaviour when a step (or router) ends in FAILED. skip_downstream (default) marks every transitive dependent as SKIPPED with an error field naming the closest failed ancestor; continue keeps the pre-0.5.0 best-effort behaviour where dependents still run against partial state.
strict_outputs bool false Promote the parallel-output collision warning to a parse error. Two parallel-eligible steps writing the same output: key normally trigger a UserWarning listing both step ids; set strict_outputs: true to refuse the workflow at parse time. Sequential overwrite via depends_on is exempt — it's an intentional pattern.
responses_file string null Mock provider recording path (when provider: mock)
latency_model string constant Mock latency mode: constant / normal / replay
latency_ms float 0 Mock provider simulated latency per call
capture_prompts bool false When true, llm_call spans emit an agentloom.prompt.captured event with the rendered prompt + system prompt. Off by default — opt-in for debugging or trusted environments only

Checkpointing

Persist workflow execution state so failed or paused runs can be resumed without re-executing completed steps.

CLI usage

# Run with checkpointing enabled
agentloom run workflow.yaml --checkpoint

# Custom checkpoint directory
agentloom run workflow.yaml --checkpoint --checkpoint-dir /data/checkpoints

# List all checkpointed runs
agentloom runs
agentloom runs --json

# Resume a previous run
agentloom resume <run_id>
agentloom resume <run_id> --lite --json

How it works

When --checkpoint is enabled, the engine:

  1. Generates a unique run ID (printed at startup).
  2. Saves a checkpoint file after the workflow completes (success or failure).
  3. The checkpoint contains the full workflow definition, state, and step results.

On agentloom resume <run_id>:

  1. Loads the checkpoint from disk.
  2. Reconstructs the workflow engine with the saved state.
  3. Skips already-completed steps and continues from where it left off.

Checkpoint files are stored as JSON in .agentloom/checkpoints/ by default (configurable via --checkpoint-dir).

State

State variables are initialized in the state block and accessible in templates:

state:
  question: "What is Python?"
  items:
    - id: 1
      name: "Item A"
  count: 42

Template syntax:

Expression Result
{state.question} "What is Python?"
{question} "What is Python?" (flat access)
{state.items[0].name} "Item A"
{state.count} 42

Steps with output: key update state[key] after execution.

Templates render tool step args through the same renderer as prompt fields, so {state.url} substitutions work uniformly across step types. By default a missing key ({state.does_not_exist}) is logged and rendered as an empty string; to raise TemplateError on missing keys instead, build the namespace with build_template_vars(state, strict=True) (so nested {state.*} lookups also raise) and render with SafeFormatDict(template_vars, strict=True).

state_schema — per-key redaction

Sensitive state values can be flagged so they never land in a checkpoint, webhook body, or trace span. The plaintext stays in memory so the active workflow can still use it.

state:
  api_key: "sk-..."
  password: "hunter2"
  user_id: 42

state_schema:
  api_key: { redact: true }
  password: { redact: true }
  "*token*": { redact: true }

Glob patterns match against the key name; for nested dicts they match against the dotted path (credentials.access_token). The same policy can be applied deployment-wide via AGENTLOOM_REDACT_STATE_KEYS=api_key,password,*token* (env-var and YAML policies are merged). See Security → State redaction for the full surface and the resume contract.


Step types

llm_call

Sends a prompt to an LLM and stores the response.

- id: answer
  type: llm_call
  prompt: "Answer: {state.question}"        # required
  system_prompt: "You are helpful."         # optional
  model: gpt-4o                             # optional, overrides config
  temperature: 0.7                          # optional (0-2)
  max_tokens: 1000                          # optional
  stream: true                              # optional, overrides config
  output: answer                            # state key for result
  timeout: 30.0                             # per-step timeout
  depends_on: [previous_step]               # dependencies
  attachments:                              # multi-modal input
    - type: image
      source: "{state.image_url}"
      fetch: local
  retry:
    max_retries: 3
    backoff_base: 2.0
    backoff_max: 60.0

LLM step fields:

Field Type Default Description
prompt string Required. Template string with {state.*} interpolation
system_prompt string null Optional system message
model string null Override workflow-level model
temperature float null Sampling temperature (0-2), provider default if null
max_tokens int null Output token limit
stream bool null Override workflow-level streaming setting
attachments list[Attachment] [] Multi-modal inputs (see Providers)
thinking ThinkingConfig null Extended-thinking / reasoning config (see Reasoning models)
conversation string null Dotted state path to a Conversation — enables multi-turn history + token-budget trimming (see Conversation history)
speaker string null Multi-agent speaker name (only with conversation). Tags the turn with Message.name and re-asserts system_prompt per turn
output string null State key to store result
timeout float null Per-step timeout in seconds
depends_on list[string] [] Step IDs that must complete first

Thinking config:

Field Type Default Description
enabled bool false Activate provider-side reasoning
budget_tokens int null Anthropic budget_tokens / Gemini thinkingBudget cap. OpenAI infers from model tier and ignores this field
level "low" \| "medium" \| "high" null Gemini thinkingLevel / Ollama think value
capture_reasoning bool true Expose the chain-of-thought trace via ProviderResponse.reasoning_content (Anthropic / Gemini / Ollama). OpenAI o-series keeps the trace server-side regardless

Per-provider translation:

Provider Translation
OpenAI o-series Reasoning is implicit in the model name; the config is accepted for YAML uniformity but not forwarded to the wire
Anthropic thinking: {type: "enabled", budget_tokens: <budget_tokens>}
Google Gemini 2.5+ generationConfig.thinkingConfig: {thinkingBudget, thinkingLevel, includeThoughts}
Ollama 0.9+ top-level think: <level> if level is set, else think: true
- id: complex_reasoning
  type: llm_call
  model: claude-opus-4
  prompt: "Solve: {state.problem}"
  thinking:
    enabled: true
    budget_tokens: 5000
    level: high
    capture_reasoning: true
  output: answer

Reasoning tokens are billed at the output rate. TokenUsage.reasoning_tokens and billable_completion_tokens track the spend; calculate_cost() includes them automatically. See Reasoning models for per-provider details, including the Ollama caveat that eval_count is not split.

Tool calling:

The model can pick tools at runtime. Declare them on the step; the engine dispatches via the workflow's ToolRegistry, feeds results back, and re-prompts until the model stops asking for tools.

- id: ask
  type: llm_call
  prompt: "What is the user's account balance?"
  tools:
    - name: lookup_account
      description: "Retrieve account info by ID."
      parameters:
        type: object
        properties:
          account_id: { type: string }
        required: [account_id]
  tool_choice: auto              # auto | required | none | {name: lookup_account}
  max_tool_iterations: 5         # bound the loop; default 5
  output: answer
Field Type Default Description
tools list[ToolDefinition] [] Tool declarations the model can pick. parameters is JSON Schema. Names resolve against the registered ToolRegistry; an unknown name is reported back as a tool failure rather than aborting the loop.
tool_choice string \| dict "auto" "auto" lets the model decide; "required" forces a call; "none" disables tools for this turn; {"name": "..."} pins to a specific tool. Anthropic has no native "none" mode, so when "none" is set the adapter drops tools from the wire entirely — same observable behavior as the other providers. Ollama ignores tool_choice at the wire level (model-side support decides whether a call fires).
max_tool_iterations int 5 Cap on call→result→re-prompt loops. When hit, finish_reason becomes "max_tool_iterations" so callers can detect runaway behavior.

The dispatched tool runs through the existing sandbox (#105), so http_request, shell_command, file_read, file_write honor the workflow's sandbox: config. Multiple tool calls in one response are dispatched concurrently (anyio task group); results preserve order in the conversation. Cost and tokens accumulate across iterations on the surfaced StepResult.

The legacy tool step (static DAG node, author chooses the tool) keeps working unchanged — tools= on llm_call is the new dynamic, model-driven path.

Structured output:

response_schema constrains the model's reply to a known JSON shape and parses it before writing to state. The step uses the provider's native structured-output API where one exists (OpenAI strict response_format, Google responseSchema, Ollama format) and falls back to prefill + client-side validation on providers that don't (Anthropic). On a validation failure the step retries within the existing retry.max_retries budget, appending the previous bad response plus the validator's error to the next turn so the model can fix its own output without restarting the workflow.

Three modes pick the precision / portability trade-off per step:

# 1. Pydantic — typed output. ``state.classification`` holds a Pydantic instance.
- id: classify
  type: llm_call
  prompt: "Classify the intent: {state.user_input}"
  response_schema:
    type: pydantic
    model: examples.structured_output_schemas.Classification
  output: classification

# 2. Inline JSON Schema — the model must conform, no Python coupling.
- id: extract
  type: llm_call
  prompt: "Extract fields from: {state.text}"
  response_schema:
    type: json_schema
    schema:
      type: object
      properties:
        name: { type: string }
        date: { type: string, format: date }
      required: [name, date]
      additionalProperties: false
  output: extracted

# 3. Free-form JSON — any JSON object, no enforcement.
- id: brainstorm
  type: llm_call
  prompt: "Suggest five ideas for: {state.topic}"
  response_schema:
    type: json_object
  output: ideas
Field Type Default Description
type "pydantic" \| "json_schema" \| "json_object" "json_object" pydantic coerces to a Pydantic model instance; json_schema validates against an inline JSON Schema; json_object enforces "any JSON object" with no schema.
model string Dotted Python path to a Pydantic BaseModel subclass (e.g. examples.schemas.Classification or pkg.module:Classification). Required for type: pydantic. The CLI adds the current working directory to sys.path before parsing so paths resolve from the repo root.
schema dict Inline JSON Schema object. Required for type: json_schema. additionalProperties: true is refused at parse time so the same schema works against OpenAI's strict mode.
name string step id Optional schema name; OpenAI's strict json_schema mode requires one. Defaults to the step id, which is unique within a workflow.
strict bool true Whether the provider's native strict mode is requested (OpenAI sets "strict": true on the json_schema payload).
description string Free-form description forwarded to providers that surface it (OpenAI).

The parsed value lands in state[output] directly — for pydantic mode it's the model instance, for the other modes it's the parsed dict. Downstream templates can navigate it with {state.classification.label} without re-parsing on every read. The raw JSON string is still available on StepResult.output for callers that need it.

Cross-provider matrix:

Provider Native API Behavior
OpenAI response_format={"type": "json_schema", "json_schema": {...}} (strict) or {"type": "json_object"} Server-side schema enforcement; the model can't emit invalid JSON. Requires gpt-4o-2024-08-06+ for strict mode.
Google Gemini generationConfig.responseSchema + responseMimeType: "application/json" Server-side enforcement. The adapter strips Pydantic-emitted title / examples / default keys that Gemini's parser rejects.
Ollama format: "json" (free-form) or format: <schema> (strict, Ollama 0.5+) Model-side enforcement; quality varies by model.
Anthropic No native API. Prefill + system-prompt instructions + client-side validation. Best-effort: the assistant turn is prefilled with "{" so the model continues from there. Validation failures trigger the same retry-with-feedback loop.

response_schema layers cleanly with thinking=. It is not compatible with tools= on the same llm_call step — the parser refuses that combination since the two features compete for the assistant's next turn (tool call vs JSON object) and the validation-retry loop bypasses tool dispatch. Streaming + structured output is supported with one caveat: validation only runs after the stream closes (a mid-stream parse on a partial JSON fragment fails for benign reasons), and a failure surfaces as a step error rather than a retry-with-feedback turn.

Retry config:

Field Type Default Description
max_retries int 3 Number of retry attempts
backoff_base float 2.0 Exponential backoff base (wait = base^attempt)
backoff_max float 60.0 Maximum wait between retries in seconds
jitter bool true Apply ±25% jitter to each backoff so concurrent retries don't cluster
retryable_status_codes list[int] [429, 500, 502, 503, 504] Provider status codes that trigger a retry. Other 4xx (e.g. 400/401/403/404) bail out immediately so the retry budget isn't burned on permanent failures. Status-less exceptions (network errors, generic provider failures) are always treated as transient and retried.

router

Evaluates conditions against state and activates a target step. Steps not activated are skipped.

- id: route
  type: router
  depends_on: [classify]
  conditions:
    - expression: "state.classification == 'question'"
      target: answer_question
    - expression: "state.score > 80"
      target: handle_high
  default: handle_general                   # fallback if no condition matches

Allowed in expressions: comparisons, boolean operators (and, or, not), builtins (len, str, int, float, bool, abs, min, max).

Safety

Router expressions are validated via AST and run inside a strict sandbox. The validator rejects:

  • imports, exec, attribute assignment;
  • any _-prefixed name (__class__, _private) — blocks dunder traversal and access to private attributes;
  • kwargs and starred arguments in calls — closes format_map / **vars() exfiltration;
  • the type builtin — was usable as type(x).__mro__[1].__subclasses__().

Violations raise SecurityError. Only a small audited subset of Python is allowed.

tool

Executes a registered tool with author-chosen arguments — the workflow author decides which tool to call, not the model. For model-driven tool selection, use the tools= field on an llm_call step (see tool calling above).

- id: fetch
  type: tool
  tool_name: http_request                   # registered tool name
  tool_args:
    url: "state.api_url"                    # "state." prefix resolves from state
    method: "GET"
    headers:
      Authorization: "Bearer token"
  output: response
  depends_on: [previous_step]

Argument resolution

String values starting with state. are resolved from workflow state. Other values are passed as literals.

Placeholder grammar in tool_args

tool_args values may also embed {state.foo} / {state[items][0]} / {name} placeholders that get rendered against state via the shared template engine. AgentLoom recognises a placeholder only when the brace is followed by state., state[, or an identifier that ends in }, ![rsa], or : immediately followed by a non-whitespace character:

tool_args:
  greeting: "hello {state.user.name}"            # rendered
  formatted: "cost: {total:.2f}"                 # rendered (format spec)
  raw_inline: '{"k": [1,2,3], "v": true}'        # passed through unchanged
  raw_html: "<style>.x { color: red; }</style>"  # passed through unchanged
  raw_js_obj: '{foo: true, bar: false}'          # passed through (`: ` whitespace)

If a value happens to look like a placeholder but you need it passed through verbatim, use the per-key escape hatch:

tool_args:
  body:
    value: '{"key": "{state.user}"}'             # literal, not rendered
    template: false

Workflows written before 0.5.0 that relied on { triggering template expansion on raw JSON / HTML strings would have failed at runtime (Max string recursion exceeded or Invalid format specifier) — the narrowed grammar removes that footgun.

Mixed content with embedded placeholders

A string that mixes a real placeholder with raw braces — e.g. '{"user": "{state.user}"}' — still hits the underlying Python str.format_map parser, which interprets the outer { as a format field and raises Invalid format specifier. Two supported workarounds: (1) compose the JSON in two steps and use template: false on the literal half, or (2) escape every literal brace as {{ / }}. AgentLoom cannot disambiguate "intended placeholder" from "intended literal brace" inside the same string. Compact CSS / JS-object shapes like {color:red} (no whitespace after :) are also inherently ambiguous with {name:spec} placeholders — use template: false or escape the braces.

Missing keys in templates

The template engine renders missing state references as the empty string in non-strict mode (default), including when the reference chains through several segments, and including chained dunders / conversion flags so a stray {state.missing.__class__} or {state.missing!r} cannot leak object internals:

Template State Renders as
{state.missing} {} ""
{state.x.y.z} {} ""
{state.missing:.20} {} ""
{state.missing!r} {} ""
{state.missing.__class__} {} ""
{state.user:.20} {user: {name: alice}} {'name': 'alice'} (format spec ignored on non-scalar, warning logged)
{{state.x}} any {state.x} (escaped braces)

The template engine also supports an opt-in strict mode at the programmatic API surface (SafeFormatDict(strict=True) / DotAccessDict(strict=True)) that raises TemplateError at the first missing segment. Strict mode is currently not exposed as a per-step YAML toggle; the runtime defaults to non-strict so missing references render gracefully.

Unicode normalisation

State dict lookup is byte-exact. A workflow that stores a key as NFD-form unicode (café) and references it as NFC (café) renders empty — there is no implicit normalisation step. If your workflow accepts user-supplied keys, normalise to NFC at the boundary (unicodedata.normalize("NFC", key)) before writing them to state.

subworkflow

Nests a workflow inside another. By default the child inherits parent state both ways — convenient for trivial helper subworkflows, leaky for anything resembling encapsulation. Set isolated_state: true to opt into a fresh state boundary.

- id: nested
  type: subworkflow
  workflow_path: "./child_workflow.yaml"
  output: child_result
  depends_on: [prepare]
- id: nested
  type: subworkflow
  workflow_inline:
    name: child
    steps:
      - id: inner
        type: llm_call
        prompt: "Process: {state.data}"
        output: processed
  output: child_result
- id: nested
  type: subworkflow
  isolated_state: true                          # child cannot read parent state
  input:                                         # explicit seed for the child
    topic: "{state.user_topic}"
  return_keys: [classification, score]           # only these surface back via `output:`
  workflow_inline:
    name: classifier
    state: { default_threshold: 0.75 }           # child's own state
    steps:
      - id: classify
        type: llm_call
        prompt: "Classify: {state.topic}"
        output: classification
  output: child_result

State contract

Setting Child sees Surfaces back
Default (isolated_state: false) Full parent state + child's own state: block The entire child final state under the parent's output: key
isolated_state: true, no return_keys Child's own state: block + input: mapping The entire child final state under the parent's output: key
isolated_state: true + return_keys: [a, b] Child's own state: block + input: mapping Only a and b from the child final state

Pause / resume through nested approval gates

A subworkflow containing an approval_gate pauses the parent at a fully-qualified path like sub.gate. The parent workflow status becomes paused (not failed), the checkpoint records paused_step_id: sub.gate, and agentloom resume <parent_run_id> --approve continues through to the next layer after the gate clears — no separate child resume command needed.

Step-id namespace across subworkflows

Step ids inside workflow_inline.steps (or in a workflow referenced via workflow_path) live in the child's own namespace — a parent can have id: classify and the child can also have id: classify without collision. Duplicate-id validation is therefore lazy: the parent parse only checks its own top-level steps, and duplicates inside the child are caught when SubworkflowStep executes and re-parses the inline definition (raising Invalid inline subworkflow: ... Duplicate step ids). For workflows where you want eager validation of the entire nested tree, run agentloom validate on the child file separately before referencing it.

embed

Computes vector embeddings for a batch of text strings and writes the result to state. Rides the same fallback chain / circuit breaker / rate limiter as llm_call, and a provider that raises NotImplementedError (Anthropic) is skipped cleanly — no CB penalty, no retry budget consumed.

- id: vectorize
  type: embed
  inputs: state.documents          # dotted state ref → list[str] (or str)
  model: text-embedding-3-small    # inherits config.model if unset
  dimensions: 256                  # optional (OpenAI 3-*, Google 004)
  output: vectors                  # state.vectors = list[list[float]]
Field Type Default Description
inputs string Dotted state reference (e.g. state.documents). Resolves to a list[str] or a single str (auto-batched to one). No template rendering — just a state lookup.
model string inherited Embedding model name (e.g. text-embedding-3-small, text-embedding-004, nomic-embed-text). Falls back to config.model when unset.
dimensions int model's full size Requests truncated vectors. Honoured by OpenAI text-embedding-3-* and Google text-embedding-004; ignored by providers that don't support it.
output string State key that receives the list[list[float]] result.

Cross-provider matrix:

Provider Endpoint Default model Notes
OpenAI POST /v1/embeddings text-embedding-3-small dimensions supported. Auto-batched at 100 inputs per request.
Google POST /models/{model}:batchEmbedContents gemini-embedding-001 dimensionsoutputDimensionality. task_type and title kwargs forwarded. Usage is approximated from word counts (Gemini doesn't return per-token counts on batch embed). Older text-embedding-004 is deprecated and no longer available on v1beta.
Ollama POST /api/embed model-dependent (e.g. nomic-embed-text) Batch native (Ollama 0.1.29+). Cost is 0.0 (local).
Anthropic None. Raises NotImplementedError with a hint pointing to OpenAI / Google / Ollama / Voyage AI. The gateway skips cleanly to the next candidate.

The MockProvider serves embeddings from a recording keyed by step_id (with an embeddings: list[list[float]] field) or synthesises deterministic hash-derived pseudo-vectors when no recording matches (--allow-default-fallback, the default outside agentloom replay). Strict mode raises RecordingMismatchError on a miss.

agentloom_embedding_calls_total{provider, model} counter and agentloom_embedding_dimensions{provider, model} histogram land on the metrics surface whenever observability is enabled.


Conversation history

Multi-turn chats and multi-agent dialogues share history across llm_call steps via the conversation primitive. A Conversation lives under any state key you choose; each step that references it loads the messages, appends the rendered prompt as the next user turn, sends the full list to the provider, and appends the assistant reply (with any tool_calls) back to the same key. Checkpointing serialises the full envelope so a resumed workflow picks the history up unchanged.

state:
  chat:
    messages:
      - role: system
        content: "You are a concise assistant."
    token_budget: 8000       # optional; unbounded when unset
    trim_policy: drop_pairs  # drop_oldest | drop_pairs | summarize_oldest

steps:
  - id: turn_1
    type: llm_call
    conversation: state.chat        # NEW — dotted state ref
    prompt: "Recommend one Python CLI library."
    output: reply_1

  - id: turn_2
    type: llm_call
    depends_on: [turn_1]
    conversation: state.chat        # same key → same history
    prompt: "Any second choice for larger apps?"
    output: reply_2

The Conversation envelope:

Field Type Default Description
messages list[Message] [] Ordered turn list. Each entry has role (system / user / assistant / tool), content, optional name (multi-agent), tool_call_id, tool_calls, metadata.
token_budget int null Soft cap on estimated tokens. Trimming runs before the call when exceeded. null = unbounded (checkpoint size is the effective cap).
trim_policy string drop_oldest drop_oldest (remove from the head, pin system prefix), drop_pairs (remove oldest user+assistant pair together), summarize_oldest (fold oldest turns into conversation.summary).
summary string null Populated by summarize_oldest. Injected on the wire as a labelled system context block after the conversation's own system prefix (never ahead of it), so a summary can't override the real system prompt.
metadata dict {} Free-form annotations that ride along the checkpoint but never reach the provider.

Behaviour on the llm_call step:

  • prompt is optional when conversation is set — but then the conversation must already end on a user or tool turn the provider can answer. Ending on a system or assistant turn raises a StepError instead of shipping a request the model can't continue.
  • system_prompt is prepended once as a system message if none exists yet in the conversation. With a speaker: set (multi-agent), the system prompt is re-asserted as a transient per-turn message instead — never persisted — so each agent gets its own instruction without clobbering the shared history.
  • speaker: (optional) tags this step's user turn and assistant reply with Message.name, letting one conversation carry several distinct agents. See multi-agent below.
  • The step's output still stores the raw assistant text (structured-output mode stores the parsed value); the Conversation carries the semantic turn.
  • Tool loops (tools:) run mechanically inside the step; only the final assistant text answer lands in the conversation. The tool calls and their tool_result turns stay out — persisting a tool-call turn without its paired result would make the next request malformed (OpenAI requires each tool_calls message be followed by its role="tool" results), so keeping only the answer is both semantically clean and replay-safe.
  • Attachments (attachments:) still work with a conversation — the resolved image / PDF / audio blocks ride on the current turn's provider message but are not persisted into the conversation (base64 payloads would balloon the checkpoint).
  • Providers translate the wire shape per API: OpenAI forwards name verbatim (multi-agent friendly); Anthropic, Google, and Ollama have no native name slot and prepend the speaker inline as "[<name>] …".

summarize_oldest triggers an extra LLM call. When the trim policy is summarize_oldest and the budget is exceeded, the step asks the same model to compress the oldest turns into a few sentences (a cheap max_tokens=256 call keyed <step_id>::summarize), storing the result in conversation.summary. The summary rides on the next turn as a synthetic system prefix. If the summariser call fails, the policy degrades to a deterministic "<speaker>: <body>" concatenation so the budget contract still holds — the step never fails because of a summariser error.

Multi-agent conversations

Set a distinct speaker: per step to run several agents over one shared conversation:

state:
  chat:
    messages:
      - role: system
        content: "Alice plans; Bob critiques."
    token_budget: 4000
    trim_policy: drop_pairs

steps:
  - id: alice_plans
    type: llm_call
    conversation: state.chat
    speaker: alice
    system_prompt: "You are Alice, a pragmatic planner."
    prompt: "Draft a migration plan."
    output: plan

  - id: bob_reviews
    type: llm_call
    depends_on: [alice_plans]
    conversation: state.chat
    speaker: bob
    system_prompt: "You are Bob, a security reviewer. One sentence."
    prompt: "Critique the plan."
    output: critique

Each turn is tagged with its speaker's Message.name; OpenAI forwards it natively, the other providers prepend "[alice] …" inline. Because speaker: makes system_prompt a transient per-turn message, Bob never inherits Alice's instructions from the shared history.

Observability wires a on_conversation_turn hook on the workflow observer that emits:

  • agentloom_conversation_trims_total{policy, conversation_key} counter — fires whenever the trim policy actually removed at least one turn.
  • agentloom_conversation_message_count{conversation_key} histogram — the visible-message count after each turn.
  • Span attributes agentloom.conversation.turn_count, agentloom.conversation.token_count, agentloom.conversation.trimmed_messages on the step span.

Examples: examples/38_chatbot_conversation.yaml (single-user chat) and examples/39_multi_agent_conversation.yaml (Alice-vs-Bob dialogue).


Streaming

Enable streaming at the workflow level, per-step, or via CLI:

config:
  stream: true
steps:
  - id: answer
    type: llm_call
    stream: true
    prompt: "Answer: {question}"
agentloom run workflow.yaml --stream

Token usage, cost, and time-to-first-token are tracked during streaming.

Streaming + tools. stream: true is compatible with tools: [...] — the request wire carries the tool spec and the final ProviderResponse returned by StreamResponse.to_provider_response() exposes any tool_calls the model emitted. Per-chunk ToolCallDelta / ToolCallComplete events are not yet surfaced by every adapter (follow-up work); read tool_calls after the stream is exhausted for now.


Sandbox

Restrict tool execution with an allowlist-based sandbox:

config:
  sandbox:
    enabled: true
    allowed_commands: [echo, cat, curl]
    allowed_paths: [/tmp/work]
    readable_paths: [/data]
    writable_paths: [/tmp/output]
    allow_network: true
    allowed_domains: [api.example.com]
    allowed_schemes: [https]                # restrict URL schemes (default: http, https)
    max_write_bytes: 1000000
    danger_opt_in: [bash]                   # opt-in per meta-executable (empty by default)
    allow_internal_webhook_targets: false   # let approval_gate.notify reach loopback/RFC 1918
Option Type Default Description
enabled bool false Enable sandbox restrictions
allowed_commands list[str] [] Shell command whitelist
allowed_paths list[str] [] General file access paths
readable_paths list[str] [] Read-only paths
writable_paths list[str] [] Write-allowed paths
allow_network bool true Allow HTTP/network calls
allowed_domains list[str] [] Domain whitelist
allowed_schemes list[str] ["http", "https"] URL scheme whitelist (rejects file://, gopher://, etc.)
max_write_bytes int \| null null (unlimited) Maximum file write size
danger_opt_in list[str] [] Per-binary opt-in for meta-executables (bash, python, env, xargs, ...). Empty by default — meta-executables defeat the command allowlist by re-launching arbitrary binaries. Add only the names you actually need.
allow_internal_webhook_targets bool false Permit approval_gate.notify.url to reach loopback / link-local (incl. cloud metadata at 169.254.169.254) / RFC 1918 destinations. Off by default — see Webhook destination gate.

Meta-executables

Even when bash is in allowed_commands, the sandbox rejects the call unless bash is also listed in danger_opt_in. The opt-in is per-binary, not a global flag — danger_opt_in: ["bash"] does not also enable python. The same gate applies to sh, python, python3, env, xargs, eval, exec. Relative path arguments are validated against the configured cwd; ../ escapes are rejected.


Complete example

A classify-and-respond workflow with routing:

name: classify-and-respond
config:
  provider: openai
  model: gpt-4o-mini
  budget_usd: 0.50

state:
  user_input: ""

steps:
  - id: classify
    type: llm_call
    system_prompt: "Classify as: question, complaint, or request."
    prompt: "Classify: {state.user_input}"
    output: classification

  - id: route
    type: router
    depends_on: [classify]
    conditions:
      - expression: "state.classification == 'question'"
        target: answer
    default: general_response

  - id: answer
    type: llm_call
    depends_on: [route]
    prompt: "Answer: {state.user_input}"
    output: response

  - id: general_response
    type: llm_call
    depends_on: [route]
    prompt: "Help with: {state.user_input}"
    output: response