Skip to content

Factories as code

A factory is a folder in a git repo, usually its own repo, that defines everything a software factory runs on: agents, skills, rules, runners, workflows, webhook sources, scorers, benchmarks, catalog blueprints and budgets. Git is the only way to change them. The API and the web UI show this configuration but can't edit it. The dispatcher applies a factory whenever its branch moves.

Each apply records the factory's version (the commit), and every run of the factory's workflows is stamped with the version current when it started. So you can measure the factory as it changes, for example GET /v1/spend?group_by=factory_version, or compare benchmark runs by factoryVersion.

factory.yaml                 name, owner, budgets, vars
agents/<id>.yaml             Agent
skills/<id>.yaml             Skill
rules/<id>.yaml              Rule
runners/<id>.yaml            Environment: backend, setup, allowed hosts, env
workflows/<id>.yaml          Workflow (automations/ works too)
scorers/<id>.yaml            Workflow used as a benchmark scorer (workflow:<id>)
webhooks/<id>.yaml           Webhook source
benchmarks/<suite>/suite.yaml, benchmarks/<suite>/tasks/<task>.yaml
catalog/*.yaml               Blueprint or Entity (these say `kind:`)
projects/<id>/project.yaml   Project: one assembly line of the factory (see Projects below)
projects/<id>/<folder>/...   what the project owns: any of the folders above

The folder decides the kind, so files don't need kind: (a kind: that disagrees with the folder is an error). The file name is the identifier unless the file sets identifier. A file can hold several documents (---). owner in factory.yaml is the default owner of agents, skills, rules and runners. vars in factory.yaml are constants every workflow of the factory reads as .vars (Templates); a workflow's own vars win. Each workflow saves the ones it reads (.vars.<name>; all of them if it reads vars another way, such as .vars as $v), so changing one publishes a new version of those workflows only. They are not secrets: they're stored in the definitions and shown by the API and web UI. Two examples: - examples/factory: Jira issues labelled agent become reviewed pull requests. - examples/demo: the sample catalog, factory config (agents, skills, rules), workflows and benchmark suite.

They share agent names, so apply one or the other.

Projects

A factory can run several assembly lines, each building something different: a checkout team's Jira-to-PR loop, a search team's PR reviewer, the factory's own upkeep. Each one is a project: a folder in projects/ with a project.yaml and the same folders as the factory's root. What sits at the root is shared by every project: the Jira webhook, a reviewer agent, a summarize workflow, the catalog's repo blueprint.

factory.yaml                          name: acme, owner: platform
webhooks/jira.yaml                    shared
agents/reviewer.yaml                  shared
workflows/summarize.yaml              shared
projects/checkout/project.yaml        title: Checkout, owner: payments, budget: { monthly_usd: 300 }
projects/checkout/agents/builder.yaml
projects/checkout/workflows/jira-implement.yaml
projects/search/project.yaml          title: Search
projects/search/workflows/pr-review.yaml
# projects/checkout/project.yaml (the folder's name is the project's identifier)
title: Checkout
description: Jira tickets labelled `agent` become reviewed pull requests on the checkout repos.
owner: payments                # owner of the project's agents, skills, rules and runners (instead of the factory's)
budget: { monthly_usd: 300, warn_at: 0.8, on_exceeded: queue }   # the budget project:checkout
  • Identifiers stay unique across the deployment. A project groups what it owns; it isn't a namespace. A project workflow can use a shared agent, a workflow step can start a workflow of another project, and nothing else about references changes. Moving a file between projects (or to the root) changes only which project it belongs to.
  • Runs count towards a factory and a project. Each run is stamped when it starts with its workflow's factory and version, and its project:
  • the project that owns the workflow;
  • for a shared workflow started by a workflow step of a run of the same factory, the project of that run, so a shared summarize called by checkout counts as checkout's (a workflow of another factory keeps its own factory, and no project; one owned by no factory counts towards the calling run's factory and project, with no factory version, so it shows as - when spend is grouped by factory_version);
  • otherwise none: a shared workflow started on its own counts towards the factory only.
  • Spend filters by factory, project and workflow and groups by factory, project, workflow or step (plus team, agent, model and factory_version). For example, GET /v1/spend?factory=acme&project=checkout&group_by=step is what each of checkout's steps costs this month. project=- selects runs that count towards no project. The web UI's Spend page drills down the same way: factory, project, workflow, step.
  • Budgets can cap a project (budget: in its project.yaml, or project:<id> in factory.yaml) or the whole factory (factory:<name> in factory.yaml). A project's budget only holds that project's runs: queued runs of a shared workflow from other projects still start.
  • Project names are unique across the deployment, like every identifier: a factory can't apply projects/support/ while another factory owns a project named support.
  • GET /v1/projects lists every project with its factory, what it owns and its spend this month. GET /v1/runs?project=checkout lists its runs, and the Runs and Workflows pages show and filter by project.

Removing a project's folder deletes the project and its budget and prunes what it owned, as for any file removed from the factory.

Connecting a factory's repo

One factory per GitHub repo, with factory.yaml at the repo's root. After setting up the GitHub App once:

  1. Create a repo for the factory on GitHub. It can be empty.
  2. In the web UI, open Factories → Connect a repository, and enter the repo's URL (or owner/name) and the branch to apply (default main). Factoryal checks the repo and says what's missing:
Check If it fails
The GitHub App is installed on the repo a link to install it; then Check again
The branch exists pick another branch
factory.yaml is at the root Add a starter factory: an empty repo gets a factory.yaml, a hello-world workflow and a README as its first commit. A repo with code gets them as a pull request, and merging it applies the factory
No other factory has its name or repo each repo holds one factory, and names are unique
  1. Connect. The factory's name comes from its factory.yaml. The first sync applies it within seconds, and every merge after that applies again.

The Factory page's Repositories tab lists each factory's repo, branch, live version (its commit) and recent syncs, applies and pull request plans alike, with links to GitHub. It also has Sync now, Edit (another repo or branch, whose factory.yaml must keep the same name) and Disconnect (syncing stops; what the factory applied stays live and owned by it). The API behind these is POST /v1/factories/check, POST /v1/factories, POST /v1/factories/starter, PUT and DELETE /v1/factories/<name>/source.

Factoryal reads each repo with a one-hour GitHub App token limited to that repo (contents: read). It needs no other credentials, deploy keys or per-repo webhooks: the App's single webhook covers every repo it's installed on.

Set by the deployment. A deployment managed as code (the CDK stack's -c factories=...) can list factories in FACTORYAL_FACTORIES on the API and the dispatcher instead. The UI shows these as "set by deployment" and can't edit them:

FACTORYAL_FACTORIES='{"payments": {"repo": "acme/payments-factory", "branch": "main"}}'

There, path can also point at a factory in a folder of a bigger repo, and repo can be a local git repo (a path or file:// URL) for development, as the quickstart does with examples/demo. The key must match name in factory.yaml.

How changes are applied

flowchart LR
    PR[Pull request<br/>to the factory repo] --> P[dispatcher plans it]
    P --> C[plan posted as a PR comment<br/>+ factoryal/plan status]
    C --> M[merge to main]
    M --> W[push webhook<br/>or the poller]
    W --> A[dispatcher: clone the folder,<br/>plan, apply]
    A --> S[factoryal/apply status<br/>on the commit]
    A --> F[(resources owned by the<br/>factory, history)]
    F --> R[runs stamped with<br/>factory version]
  • Merge to the branch. GitHub's push webhook queues a sync. The dispatcher also polls every factory's branch (git ls-remote, every FACTORYAL_FACTORY_POLL_SECONDS, default 60), so a missed webhook or a local repo is picked up too. A sync does a shallow, sparse clone of just the factory's folder at the branch head. Then it plans the folder against what is live and applies it if anything changed. A commit that doesn't change the factory is recorded as unchanged, and the live version stays at the commit that last changed it.
  • Commit status. On GitHub each synced commit gets a factoryal/apply status: what changed, or why it didn't apply.
  • A broken commit changes nothing. Every file is checked first. An invalid file, a factory.yaml with another name, or a resource another factory owns fails the whole sync, and what is live stays as it was. Polling doesn't retry a failed commit. Push a fix, or run factoryal factory sync <name> after fixing something outside the repo (for example, installing the GitHub App).
  • Pull requests. On a pull request against the branch, the dispatcher plans the PR's head and keeps one comment on the PR up to date with what merging would change. It also sets a factoryal/plan status, which branch protection can require so broken factories can't be merged. A factory in a folder of a bigger repo (path) only plans pull requests that change something in that folder. What counts is the PR's head commit compared with the branch. The others get no comment, their factoryal/plan status is a success saying the folder didn't change (so the status is still safe to require), and their sync is recorded as unchanged. If a later push stops a PR from changing the folder, its plan comment is deleted. When GitHub can't compare the commit, the commit is already on the branch (a sync that runs after the merge), or the change has more files than a compare lists (300), the PR is planned anyway, so a factory change is never missed.
factory payments @ 3f9c2a1b7d4e (live: 91aa0c2e5b10)
  ~ update    Agent           triager
  + create    Webhook         jira
  - delete    Skill           old-style
  - disable   Workflow        legacy-triage
  = keep      BenchmarkSuite  frontend  (removed from the factory (kept: holds history))
  1 create, 1 delete, 1 disable, 1 keep, 1 update, 9 unchanged
Action Meaning
create / update the resource is new / differs from what is live
adopt it exists but no factory owns it yet; applying updates it and takes ownership
delete / disable this factory owns it and the folder no longer has it (workflows are disabled, keeping their history)
keep removed from the folder but kept: catalog documents and benchmarks hold data and history
conflict another factory owns it; the sync fails

Syncs are queued in Postgres and run by the dispatcher, one at a time per factory, even with several dispatchers. An apply reads the branch head, so applies queued before it are marked superseded.

Versions

Git is the only version history, and nothing in a factory's files is versioned by hand:

What Its version Notes
The factory the commit applied last that changed it runs are stamped with it (factoryVersion): spend and benchmarks group by it
Agents, skills, rules, runners the commit that last changed that resource (12 characters) a file with version: fails the sync; runs record the versions they used
Workflows v1, v2, … plus the commit and file each version came from runs pin their version, child steps can too (version: N), and older versions can still be started

A version only changes when the resource changes: a commit that touches one agent gives that agent a new version and leaves the others alone. To roll back, git revert the commit: it applies the old content as a new version (agents get the revert's commit, workflows the next vN), so history only moves forward. The web UI links every version to its commit on GitHub.

Commands

factoryal factory status                # every factory: version, repo, last sync
factoryal factory status payments       # one factory and its recent syncs (applies and PR plans)
factoryal factory sync payments         # apply the branch head now, even a commit that failed
factoryal factory check path/to/factory # validate a folder offline, before you push it, and grade it
                                        # on the features it uses (see catalog.md#the-factory-scorecard)
factoryal factory export ./payments --name payments   # live configuration as a factory folder

The API equivalents are GET /v1/factories, GET /v1/factories/<name>, POST /v1/factories/<name>/sync and GET /v1/export?name=<name>.

What stays writable through the API

Configuration has no write endpoints. Workflows, agents, skills, rules, runners, webhook sources, budgets, blueprints and benchmark suites and tasks all come from factory repos. Agents, skills, rules, runners and webhook sources aren't catalog entities, so neither the entity API nor a workflow's upsert_entity step can write them.

What happens at runtime stays writable: runs (start, cancel, approvals through their channels), catalog entities of your own blueprints, agent memories, tokens, benchmark runs and baselines. Two features now go through git: - Promoting a benchmark configuration opens a pull request on the agent's factory repo. - A benchmark task drafted from a run (POST /v1/benchmarks/<suite>/tasks/from-run) is added by a pull request on the suite's factory repo (POST /v1/benchmarks/<suite>/tasks, or Add to benchmark in the web UI).

Moving an existing deployment to repos

Configuration created before factories were git-only (with factoryal apply or the old write endpoints) keeps working, but nothing can change it until a factory owns it:

  1. factoryal factory export ./my-factory --name my-factory writes everything no factory owns as a factory folder. Generated workflows (factoryal.*) and entities are left out. Comments from your original YAML aren't kept.
  2. Commit it to a new repo (split it into several factories, one repo each, if you like), install the GitHub App on the repo, and connect it on the Factory page.
  3. The first sync adopts every resource, and from then on that repo owns them. Agents, skills, rules and runners then take that commit as their version, replacing labels like "1" from before.

Webhook sources

# webhooks/jira.yaml -> deliveries to https://<factoryal>/v1/webhooks/jira
authMode: signature            # or token (a shared token in tokenHeader, Bearer accepted)
signatureScheme: jira          # jira, github, sentry, hmac-sha256 (+ signatureHeader)
secretName: JIRA_WEBHOOK_SECRET
enabled: true
events: ["jira:issue_created", "jira:issue_updated"]   # optional filter
entity:                        # optional: upsert one entity per delivery
  blueprint: jira_issue
  identifier: "{{ .payload.issue.key }}"
  properties: { status: "{{ .payload.issue.fields.status.name }}" }
Scheme Verified with Event name from Redeliveries detected by
jira X-Hub-Signature: sha256=<HMAC-SHA256 of the body> webhookEvent X-Atlassian-Webhook-Identifier
github X-Hub-Signature-256: sha256=... X-GitHub-Event X-GitHub-Delivery
sentry Sentry-Hook-Signature: <hex> Sentry-Hook-Resource Request-ID
hmac-sha256 signatureHeader (default X-Signature): <hex> event in the body X-Delivery-Id
token tokenHeader (default Authorization) equals the secret event in the body X-Delivery-Id

secretName names a secret; the value is never part of the factory. The API looks it up in FACTORYAL_WEBHOOK_SECRETS (JSON, e.g. {"JIRA_WEBHOOK_SECRET": "..."}; on AWS, a key of a Secrets Manager secret listed in FACTORYAL_SECRETS_ARNS), then in its environment. Verified deliveries become webhook.received events. A webhook trigger starts runs from them, with {webhook, event, delivery, payload, headers} for condition and inputs; signature, token and Authorization headers are not kept. With entity, entity_event triggers work too, as in the example factory.

Jira

The example factory runs the whole loop in Jira: 1. An issue labelled agent and repo:<repo> arrives through the webhook, and the triager posts its plan as a comment. 2. Moving the issue to Approved approves (or Won't Do rejects). 3. The issue moves to In Progress, the builder opens a PR, and the issue moves to In Review with a comment linking the PR.

Inbound. In Jira Cloud: Settings → System → WebHooks → Create, with URL https://<factoryal>/v1/webhooks/jira, a secret (the same value as JIRA_WEBHOOK_SECRET), and the issue events you want (created and updated). For Jira Automation's "Send web request" action, use a token source and send the token in a header instead.

Outbound. Set FACTORYAL_JIRA_BASE_URL (https://<site>.atlassian.net), FACTORYAL_JIRA_EMAIL and FACTORYAL_JIRA_API_TOKEN (an API token of the account Factoryal acts as) on the API. Then:

- { id: tell, type: notify, target: jira_comment, issue: "{{ .inputs.issue }}", text: "..." }
- { id: move, type: notify, target: jira_transition, issue: "{{ .inputs.issue }}", status: In Review,
    text: "optional comment" }

jira_transition takes a status (or transition) name in any case and finds the workflow transition that gets there. An issue already in that status is left alone. An unreachable status fails the step and lists the statuses the issue can move to.

Approvals decided in Jira.

- id: approve
  type: approval
  approvers: [ana]               # optional: who may decide
  jira: { issue: "{{ .inputs.issue }}", approve_on: [Approved], reject_on: ["Won't Do"] }

When a Jira delivery moves the issue to one of those statuses, the approval is decided as the Jira user who moved it. The user is mapped to a Factoryal identity with FACTORYAL_JIRA_USER_MAP={"<accountId or email>": "ana"}, or else jira:<email>. If the step lists approvers, other people moving the issue don't decide it, and the delivery's response says why. The API, /factoryal approve and the expiry still work as before.