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
workflowstep 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
workflowstep of a run of the same factory, the project of that run, so a sharedsummarizecalled 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 byfactory_version); - otherwise none: a shared workflow started on its own counts towards the factory only.
- Spend filters by
factory,projectandworkflowand groups byfactory,project,workfloworstep(plusteam,agent,modelandfactory_version). For example,GET /v1/spend?factory=acme&project=checkout&group_by=stepis 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 itsproject.yaml, orproject:<id>infactory.yaml) or the whole factory (factory:<name>infactory.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 namedsupport. GET /v1/projectslists every project with its factory, what it owns and its spend this month.GET /v1/runs?project=checkoutlists 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:
- Create a repo for the factory on GitHub. It can be empty.
- In the web UI, open Factories → Connect a repository, and enter the repo's URL (or
owner/name) and the branch to apply (defaultmain). 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 |
- 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:
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
pushwebhook queues a sync. The dispatcher also polls every factory's branch (git ls-remote, everyFACTORYAL_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 asunchanged, and the live version stays at the commit that last changed it. - Commit status. On GitHub each synced commit gets a
factoryal/applystatus: what changed, or why it didn't apply. - A broken commit changes nothing. Every file is checked first. An invalid file, a
factory.yamlwith 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 runfactoryal 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/planstatus, 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, theirfactoryal/planstatus is a success saying the folder didn't change (so the status is still safe to require), and their sync is recorded asunchanged. 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:
factoryal factory export ./my-factory --name my-factorywrites everything no factory owns as a factory folder. Generated workflows (factoryal.*) and entities are left out. Comments from your original YAML aren't kept.- 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.
- 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.