Skip to content

factory.yml Schema Reference

The factory.yml at the root of each factory is the single source of truth for its structure: the states its data moves through, the agents that run, and the volumes they attach. This document is the authoritative schema — it is what check_factory_yaml validates against.

For Python authoring (tf.on_state, tf.collection, tf.llm, the agent main loop, the agent .py docstring convention), see the tf reference (read_reference_tf). For the default_ui block, see the Composable UI reference (read_reference_ui).

Top-level schema

The folder name under factories/ IS the factory's identity. It's the value of FACTORY_NAME in every agent container, the factory_name column in every factory_data / factory_logs row, the NOTIFY channel prefix, and the URL segment in every /api/factories/:name/... route. There is no second source of truth.

# factory.yml — top-level keys
title: "Human-readable Title"        # required; shown in sidebar + page header
description: "What this factory does" # optional
icon: cog                            # optional; FontAwesome name (no fa- prefix)
base_image: ghcr.io/...              # optional factory-level image override; fallback chain:
                                     #   per-agent `base_image:` → factory `base_image:` →
                                     #   orchestrator's DEFAULT_AGENT_IMAGE env var →
                                     #   ghcr.io/teenyfactories/agent:latest
volumes: [...]                       # optional; see Volumes below
states: {...}                        # "Collection: State" keyed map; see States below
agents: {...}                        # slug-keyed map; see Agents below
default_ui:                          # optional; the Composable UI (read_reference_ui)
  layout: {...}                      #   the single root component tree
  modals: [...]                      #   optional; page-level keyed modals opened by id

default_ui.modals is an array of component: modal nodes, each with a unique id:, mounted page-level and opened by id from any trigger ({ action: open, id }) — see ui-modal. Declaring a modal inside layout still works but is deprecated.

title is the only required key. The schema is strict at the top level — an unknown key (a typo like titel: or icon2:) is rejected.

Banned at top level: - name:deprecated 2026-05-23. The folder name is canonical. If present, factoryDiscovery warn-logs once and ignores the field; every write of the file strips it. New factories generated by the orchestrator do not emit it. Remove from existing factory.yml to silence the warning. - version:, status:, nested docker: block, workers: block — never supported in the canonical schema.

Single-factory mode (ORCHESTRATOR_MODE=none) is the one exception to the folder-name-is-identity rule: the orchestrator mounts the factory at /app/singlefactory with no parent dir to read, so in that mode (and only that mode) factory.yml may still carry name: and it's honoured as the identity. Multi-factory mode ignores it everywhere.

States

states: is a map keyed by the string "Collection: State" — a collection name, a colon, a single space, then a state name (e.g. "Email: Sent", "Prospect: Identified"). The key IS the identity. It encodes both the collection and the state; there are no inner collection: / state: / name: fields (the state-entry schema is strict and rejects them — that was the classic pre-migration mistake).

states:
  "Email: Drafted":
    description: A draft email awaiting human approval.
    manual_transition_states:
      - "Email: Approved"
      - "Email: Rejected"
  "Email: Approved":
    description: An approved email queued to send.
  "Email: Sent":
    description: A sent email.
    schema:                          # optional JSON-schema for the row's data payload
      type: object
      properties:
        subject: { type: string }
        to:      { type: string }

Allowed inner fields (all optional):

Field Type Meaning
description string Human/LLM-facing description of what a row in this state means.
schema object A JSON-schema describing the row's data payload. Accepted as any object — its internals are not validated.
manual_transition_states list of "Collection: State" The states a human may move a row in THIS state to. Presence of this list is what makes a state human-in-the-loop — the UI renders a transition button per target.

manual_transition_states lives on the SOURCE state, not on an agent. A state with a manual_transition_states list is a step a human drives (via the UI's save_data_item action or the set_item_state chat tool) — never an agent. If the lifecycle says "a human approves here", there is no agent for that step.

Naming conventions (enforced as the canonical standard): - Collection names: singular, normal-case nouns — Email, Prospect, Style Prompt. Not plural, not snake_case. A collection is one kind of data. - State names: past-tense / past-participle lifecycle labels — Drafted, Approved, Sent, Rejected, Identified, Researched. Normal casing, not snake_case.

Every input_states / output_states / manual_transition_states cross-reference elsewhere in the file must use the exact "Collection: State" key string.

Display label vs runtime slug (read this — it is the #1 cause of dead factories)

The "Collection: State" key is a Title-Case DISPLAY label for the editor and UI only. Agent Python and UI data-bindings never use the label — they use the lowercase runtime SLUG. The tf store validates every collection and state identifier at RUNTIME against ^[a-z0-9_]+$ (≤40 chars) and raises ValueError on the first write the moment it sees a capital, space, or hyphen — so an agent that hardcodes the display label (tf.collection('Email'), state='Sent') crashes on startup and the whole factory is inert.

Slug rule (identical to the foreman's slugGuard, so the two never disagree): lowercase the label part, replace each run of non-[a-z0-9] characters with a single _, then strip leading/trailing _. It does NOT split camelCase.

yml display key collection slug state slug agent code
"Email: Sent" email sent tf.on_state('email', 'sent'), tf.collection('email').add(state='sent', …)
"Style Prompt: Style Assigned" style_prompt style_assigned tf.on_state('style_prompt', 'style_assigned')
"TeamMember: Active" teammember active camelCase is NOT split → TeamMember becomes teammember. Prefer the spaced label "Team Member: Active" if you want team_member.

Worked example — the yml declares the label; the code uses the slug:

states:
  "Email: Sent":
    description: A sent email.
@tf.on_state('email', 'sent').do          # NOT 'Email' / 'Sent'
def handle(item):
    tf.collection('email').set(item['key'], state='sent')

Why the constraint exists: collection + state compose the Postgres NOTIFY channel identifier ({factory}.{collection}.{state}), which must stay inside Postgres's identifier limit and remain URL / dataRef safe — hence lowercase [a-z0-9_], ≤40 each. check_python flags any Title-Case or spaced string literal in a runtime-identifier position (tf.collection("…"), tf.on_state("…","…"), state="…", .state("…")) before deploy, so this class of bug fails validation instead of crashing a container.

Agents

agents: is a map keyed by slug (lowercase letters, digits, underscores, hyphens). The slug IS the script filename: agents/{slug}.py. There is no script: override — the Python file is the agent.

agents:
  pdf_monitor:
    name: PDF Monitor                # display name (optional)
    description: Watches the volume for new PDFs and loads them.   # optional
    input_states:                    # "Collection: State" keys this agent subscribes to
      - "Document: Uploaded"
    output_states:                   # "Collection: State" keys this agent writes to
      - "Document: Loaded"
    base_image: ghcr.io/...          # optional per-agent image override (see fallback chain above)
    environment:                     # optional per-agent env-var map passed to the container
      SOME_FLAG: "1"
    volumes:                         # optional per-agent volume attachments; see Volumes below
      - name: agreements
        mode: read

Agent entry fields (all optional; the entry schema is strict — no other keys):

Field Type Meaning
name string Human-readable display name shown in the UI and agent listing.
description string What the agent does. Surfaced in the agent listing and read by the chat agent to understand factory topology.
input_states list of "Collection: State" The states this agent reacts to. These are the tf.on_state(collection, state) subscriptions it declares in code — passing the lowercase runtime slugs, not the Title-Case label (see Display label vs runtime slug). The topology's incoming edges.
output_states list of "Collection: State" The states this agent writes rows into — the topology's outgoing edges.
base_image string Per-agent container image override. Fallback chain: agent base_image → factory base_imageDEFAULT_AGENT_IMAGEghcr.io/teenyfactories/agent:latest.
environment object Per-agent environment variables passed into the container.
volumes list Per-agent volume attachments (see Volumes › Attachments).

input_states / output_states are wiring metadata — they document the topology and drive the editor graph. The runtime subscription is whatever the agent's .py actually calls tf.on_state(...) on; keep the two in sync.

There is no agents-vs-workers distinction — every factory component is an agent. Some call LLMs (tf.llm), some don't; that's a property of the code, not a structural category. The folder layout is factory.yml + agents/*.py. No workers/, no common/. For the required agent-file docstring shape and the main loop, see the tf reference (read_reference_tf).

Volumes

A volume is a named file space. There are two separate concepts:

  1. Definitions — a factory-level volumes: registry that names each volume.
  2. Attachments — a per-agent volumes: list that grants an agent read or write access to a named volume.

An agent reaches a volume's files at runtime via tf.bucket_store(name)not by reading /app/volumes/... directly (see the tf reference, read_reference_tf, for the bucket-store API).

Definitions (factory-level)

volumes:
  - name: agreements                          # object form (preferred)
  - inputs                                    # bare-string shorthand ⇒ { name: inputs }
  - { name: shared, source: ../shared }       # factory-relative source override
  - { name: outputs, source: /mnt/nas/out }   # absolute host path
  - { name: cache, target: /var/cache }       # custom mount point (docker)

Fields (object form): name (required; lowercase alphanumeric first character, followed by lowercase alphanumeric / _ / -, ≤64 chars); source / target / readonly are optional docker-only overrides (the k8s backend mounts nothing — see below). Unknown keys are preserved (forward-compatible passthrough), so future per-volume attributes are additive.

Banned: source: ./volumes/X (legacy form). Rejected at container start with a migration hint. Use - X (default location) or an explicit source: override.

Attachments (per-agent)

agents:
  pdf_monitor:
    name: PDF Monitor
    input_states: [...]
    output_states: [...]
    volumes:
      - name: agreements      # MUST reference a factory-level volume
        mode: read            # read | write   (default: read)

An agent accesses only the volumes it explicitly attaches, with its per-attachment mode. An attachment naming an undefined volume is a hard error at container start (matching the legacy-source rejection precedent). mode: read mounts read-only (docker) / denies writes (k8s); mode: write permits writes.

How the two backends differ

docker kubernetes
Mount per-agent bind mount, source → target, ro when mode != write nothing mounted
File access tf.bucket_store reads the bind mount directly (VOLUME_BACKEND=local) tf.bucket_store proxies to the orchestrator :8998 → S3 (VOLUME_BACKEND=remote)
Creds none needed only the orchestrator holds S3 creds; agents send none
Enforcement filesystem ro flag orchestrator re-derives the attachment from factory.yml and enforces read/write

The k8s backend injects the agent's attachment list as TF_VOLUME_ATTACHMENTS (JSON [{name, mode}]) for visibility, but enforcement is always server-side.

Per-factory volumes/ is gitignored. New factories created via the orchestrator UI scaffold a .gitignore containing volumes/, .env, .env.local, __pycache__/. Don't commit runtime data.

Resolution logic: orchestrator/backend/services/orchestratorBackends/docker.js (buildVolumeDefinitions + resolveAgentVolumes) and kubernetes.js (_buildVolumeDefinitions + _resolveAgentVolumes). HOST_FACTORIES_FOLDER env var sets the absolute host path to the factories/ directory (required on docker; no portable default).

Tenant-local overrides (.teenyfactories/)

Each factory has a .teenyfactories/ directory (note the plural) at its root for operator-local, non-shipped state. The folder is gitignored by default (included in DEFAULT_FACTORY_GITIGNORE in factoryEditorService.js) — its contents must never affect factory behaviour for other operators or in CI.

Canonical members:

File Purpose Shape
graph_positions.json UI cache for the factory-editor force-directed graph layout. Persists node coordinates across page reloads. { [nodeId: string]: { x: number, y: number } }
local.yml Operator tenant-field overrides (display label etc.) that customise how this checkout appears in the operator's orchestrator without altering the shipped factory.yml. { label?: string }

Rules: - Anything added here MUST be operator-local cache or operator-tenant override — never source-of-truth factory data. - Add the path to this table when introducing a new member; the table is the authoritative inventory. - Code reading these files must treat them as optional (missing file = empty override). Never error on absence.

Rename semantics

Agent and state renames have side effects beyond the map entry:

  • Renaming an agent slug (via the GUI's Slug field, the rename_agent MCP tool, or POST /api/factories/:name/actions/:actionId/rename) atomically renames agents/{old}.py to agents/{new}.py. The Python file IS the agent — there is no script: override.
  • Renaming a state rewrites every input_states / output_states / manual_transition_states reference across all agents and states in the same factory. The "Collection: State" key is the identity, so every cross-reference is updated in lockstep.

If a rename fails partway, the orchestrator rolls back rather than leaving dangling references.

No automatic cascade on manual edits. When you rename or delete a state by hand-editing factory.yml (rather than via rename_agent / the rename action), nothing updates the references that pointed at it. In the same pass you must fix every input_states / output_states / manual_transition_states entry that used the old key. check_factory_yaml checks the file's SHAPE, not dangling references — a half-finished rename can still parse; use search_in_files to find every reference.