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:
@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_image → DEFAULT_AGENT_IMAGE → ghcr.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:
- Definitions — a factory-level
volumes:registry that names each volume. - 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_agentMCP tool, orPOST /api/factories/:name/actions/:actionId/rename) atomically renamesagents/{old}.pytoagents/{new}.py. The Python file IS the agent — there is noscript:override. - Renaming a state rewrites every
input_states/output_states/manual_transition_statesreference 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 viarename_agent/ the rename action), nothing updates the references that pointed at it. In the same pass you must fix everyinput_states/output_states/manual_transition_statesentry that used the old key.check_factory_yamlchecks the file's SHAPE, not dangling references — a half-finished rename can still parse; usesearch_in_filesto find every reference.