A project connects Factorial to one repository. You make the project, point it at the issues, give the agents their credentials, put the config on a branch, and then switch admission on. The project starts inactive, and nothing runs until you do.
Merging a .factorial/ folder into a repository changes nothing. The folder is inert until an active project names that repository. A pause stops new tickets without stopping the tickets in flight.
Before you start
- Start the factory. Run the factory ends with the dashboard at
http://localhost:5173and the API athttp://localhost:8000. - Have your
FACTORIAL_API_TOKENfrom.env. The dashboard asks for it, and each API call sends it. An unset token makes the API refuse every call with a 503. - Install the GitHub App on the repository. Set up the GitHub App has the steps.
Each step shows the dashboard path first. Where an API call does the same work, it follows. Set two shell variables once, and every call below uses them:
export SF=http://localhost:8000
export TOK=<your FACTORIAL_API_TOKEN>
The examples use acme/app for the repository.
The config lives on a branch named factorial, not on the default branch. A config change then does not wait behind the CI checks and the code reviews of the default branch, which say nothing about config. An unpinned sync reads this branch. Step 2 makes it. The code, the CI, the spec pull requests and the final merge stay on the default branch.
1. Create the project
- Open Projects in the dashboard, and push Create project.
- Fill in Name, which must be unique in the factory, and GitHub repository, as
owner/name, for exampleacme/app. Description is optional. - Push Create project. The dashboard opens the new project.
The same step with the API:
curl -sS -X POST "$SF/api/projects" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{
"name": "app",
"github_repo": "acme/app",
"description": "The Acme application",
"settings": {"mcp_allowlist": []}
}'
The answer carries the new id. Keep it for the other calls:
export PID=<id from the answer>
A name that exists already answers 409. The project header shows Paused until you activate it in step 10.
The project page has two tabs, Tickets and Configuration. A project with a blocker opens on Configuration. Steps 2 to 9 use its sections.
2. Create the config branch
In Configuration, open the Process config section. When the repository has no factorial branch, the section offers a choice of starting points:
- From <default branch> shows when the default branch has a
.factorial/folder. It copies the folder unchanged and syncs it. - A template, for example Simple lane, opens the editor with that template. You write it in step 5.
Select a card to see its process and its agents. Then push Use. Or push Create the branch in the notice above the section to open these choices. The same step with the API:
# copy the config folder of the default branch
curl -sS -X POST "$SF/api/projects/$PID/config-branch" -H "Authorization: Bearer $TOK"
# or start with an empty branch
curl -sS -X POST "$SF/api/projects/$PID/config-branch" -H "Authorization: Bearer $TOK" \
-H "Content-Type: application/json" -d '{"from": "empty"}'
A 201 answer returns {"branch": "factorial", "sha": "<sha>"}. A 409 means the branch exists already, or a branch under factorial/ blocks the name. Delete those branches first. A 503 means the GitHub App is not set up.
Do not make the factorial branch by hand. Sync refuses a commit that Factorial did not write, unless a person merged it through a pull request. If you made the branch by hand, delete it in GitHub and use the starting points.
Protect the branch
The App cannot protect the branch, because it never holds the administration: write permission. A repository admin runs this command once:
gh api -X POST repos/acme/app/rulesets --input - <<'JSON'
{"name":"factorial-config","target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["refs/heads/factorial"],"exclude":[]}},"rules":[{"type":"deletion"},{"type":"non_fast_forward"}],"bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"always"}]}
JSON
The ruleset forbids force pushes and deletion. The bypass is for the repository admin role (actor_id 5), never for the App. It lets an admin reset the branch. The Process config section shows this command, with a copy button, while the rules are missing.
Do not add a pull_request, update, required_status_checks, required_deployments, merge_queue or required_signatures rule. The App commits to the branch directly, and each of these rules stops its commits.
What guards the branch
Agents push as the App too, so an agent can also push to factorial, and the author of a commit proves nothing. Sync therefore accepts a commit only in two cases:
- Factorial wrote it. The App commits only when a signed-in operator pushes Commit and sync, and that press is the approval.
- A person merged a pull request into
factorial, and the commit under the merge is also a commit that sync accepts. Use a merge or a squash merge. A rebase merge of more than one commit leaves commits that no merge made.
Any other commit answers 422 with its sha. A direct push never syncs, from an agent or from an admin. When an agent pushed to the branch, the editor answers 409 and does not commit on top of the push. The section then shows unknown head and the command that resets the branch. Only an admin with the ruleset bypass can run that command. If no known commit is below the head, delete the branch and create it again.
A missing branch, an unknown head or missing rules shows as Needs attention, never as Blocks activation. Admission reads stored snapshots, not the branch.
3. Set the ticket source
The ticket source is the repository whose issues become tickets. In Configuration, open Ticket source. Enter Issues repository as owner/name, and push Set source. After the first save the button reads Update source, and the section shows when the source was last polled. The same step with the API:
curl -sS -X PUT "$SF/api/projects/$PID/ticket-source" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"kind": "github", "external_ref": "acme/app"}'
A project has one ticket source, and the call replaces it. The issues can live in a different repository from the branches and pull requests. Here they are the same repository.
This step blocks activation until you do it.
4. Give the agents their credentials
Two stores hold the credentials, and one rule decides which one an agent reads. The runtime that the process names holds the model key: an API key for an API runtime, or a login for a subscription runtime. Every other key that an agent manifest or an MCP server declares belongs to the project.
- Create the runtime. Open Runtimes and create one. Enter Name and Slug, and choose its Type and Sandboxing isolation. Runtimes explains the choices. The API call is
POST /api/runtimes. - Store the key on the runtime page, in the Model provider group: for example Anthropic API key or OpenAI API key. A subscription runtime connects with a login on the same page. Through the API:
curl -sS -X PUT "$SF/api/runtimes/$RID/credentials/ANTHROPIC_API_KEY" \ -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \ -d '{"value": "<key>"}'$RIDis theidof the runtime. - Name the runtime in the process file with
runtime: <slug>. You write the file in step 5. - Store the project keys that the runtime does not hold, for example a token that an MCP server uses. In Configuration, open Secrets. After step 5, a key that a manifest declares and the project lacks shows as missing, with a field. Enter the value and push Store. For another key, enter its Name and Value, and push Store secret. Through the API:
curl -sS -X PUT "$SF/api/projects/$PID/secrets/PRODUCT_DOCS_TOKEN" \ -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \ -d '{"value": "<token>"}'
Both stores give back names only. A value leaves the store once, into an agent run. To change a value, store it again.
A process that names a runtime takes the model key from the runtime alone. A project key of the same name is never read by that process. The Secrets section shows such a copy with Remove copy. A ticket from before the process named the runtime keeps reading the project copy until it closes, and the section says so.
GH_TOKEN is in neither store. Factorial makes it for each run, limited to the repository and to what the agent asked for. See Security.
Activation checks the project keys. A key that a manifest declares and that has no stored value blocks activation, and the answer names the key. The check cannot read a manifest in some cases, for example when GitHub is unreachable. It then shows a warning and does not block. The check also does not test that a stored value works. A wrong value fails at the start of each run.
5. Fill .factorial/
The process builder writes the config to the factorial branch without any git work on your side. It needs the project and the branch from steps 1 and 2. It does not need the ticket source or the secrets. Without the branch, the preview and the commit answer 409.
- Draft. In Configuration, open Process config and push Edit, or push New lane to add a lane from a template. The drawer opens on the whole
.factorial/folder: the explorer lists the files on the left, and the process file opens on the right. In the actions menu of the editor, choose Simple lane under Replace with template, or paste a process file. The template is the sample lane, with its comments. Processes explains the file, and Agents explains the agent folders. - Preview. Each time the text is valid, the page asks the server what a commit would add, and the explorer shows it. A preview writes nothing.
- Inspect, select and edit. You can read every file before you accept it. The explorer ticks a file unless the repository owns it already. The commit never writes over a file that you did not edit. When you edit the process text, the ticks clear, because a tick belongs to the exact text that you saw. Your edits to other files stay.
- Press Commit and sync. The server writes the config as one commit on
factorial, and then syncs that commit as step 6 describes.
The answer gives the sha, the commit URL and the sync result. There is no pull request to merge and no separate sync.
The preview gives a file body only for the agents that Factorial has a prompt set for. For any other agent, the commit writes a stub agent.yaml that holds only comments. The preview does not show it. The sync answer lists these stubs as agent_warnings. Fill in each stub before you activate, because a step whose agent is still a stub fails when it starts.
Five answers need an action:
- 409, the branch moved. Someone changed
factorialafter the page read it. The commit did not happen. Reload the page and try again. - 409, a file you edited changed on the branch. The answer names the file. Reload the page, keep or revert the edit, and try again.
- 422, a runtime that the lane cannot use. Correct the
runtimeline and try again. - 422, an edit the server refuses. The answer names the file. Either the file is not on the branch, or you sent it twice, or it is the process file of the lane. That file comes only from the process text. Revert the edit and try again.
- 201 with
sync: null. The commit is on the branch but the sync failed.sync_errorsays why. Correct the cause and push Sync from repo.
A stale edit blocks the commit
When you edit the process text after a preview, the page refreshes a file you did not touch without a message. A file you did edit is never discarded and never trusted again without a check. It carries a stale badge when a fact that the file used has changed. An example is a prompt that you tuned by hand and that names the branch prefix, after you moved the prefix. The file pane says The config changed since this was edited, and offers Use this version anyway. A file that changed on the branch gets this message: This file changed on the factorial branch since you started editing it. It offers Use my edit anyway.
A stale file that is still ticked blocks Commit and sync until you revert it or confirm it. A stale file that you unticked does not block the commit. The gate exists because a prompt that still names spec/ after the prefix became release/ still parses, and still looks finished. It would give pull requests that satisfy no condition, with CI green and no signal that anything is wrong.
6. Snapshot the config
Commit and sync in step 5 already syncs. Sync reads the config at one commit and stores it as a snapshot. Use it by itself when the config changed in some other way, for example when a person merged a pull request into factorial. In Process config, leave Sync from branch empty to read factorial. Or enter a branch, a tag or a sha. Then push Sync from repo. The same step with the API:
curl -sS -X POST "$SF/api/projects/$PID/process-versions/sync" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{}'
The answer carries the sha and one row per lane in results. Each row has the process, and an outcome of created, unchanged or retired. unchanged is the usual answer for a lane that the commit did not touch.
A 422 answer names the file and the reason. The reasons are: no .factorial/ folder, a ref that does not exist, config that is not valid, or a repository that has both layouts. Another reason is a commit that Factorial did not write and no person merged. A 409 means that another sync is running, so try again.
A commit can retire the lane that default_process names. The sync does not fail then. The default moves to a lane that the commit offers, and the answer shows default_process_moved. A rename does this, so check that the new default is the lane you meant.
A sync makes a new version and never edits an old one. A ticket pins the version that was current at its admission, and keeps it until it is done. A config change therefore reaches new tickets only, and the tickets in flight finish on the config they started with. When a commit no longer offers a lane, sync retires the lane and nobody can choose it. If the file returns, so does the lane.
Sync reads the agent manifests only lightly. A broken agent.yaml does not fail the sync. The answer lists agent_warnings instead. It has one line for each agent whose manifest does not load at that commit. The file is missing, or it does not parse. The file explorer shows the same result. Read the warnings. They do not stop the sync or an activation, and they do not say if a prompt is any good.
7. Set the project settings
These are the operator-owned limits of the project. The repository only requests, and these settings give the grant. The repository can never write them. Decide each one on purpose.
In the dashboard, the Permits section of Configuration sets the first two. It saves each change on its own and offers Undo. Through the API, PATCH replaces the whole settings object, so send every key that you want to keep:
curl -sS -X PATCH "$SF/api/projects/$PID" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"settings": {"mcp_allowlist": [],
"runtime_allowlist": ["claude-e2b"],
"egress_allowlist": ["registry.npmjs.org"]}}'
runtime_allowlist
A runtime that runs in an E2B sandbox sends the code of the repository to E2B. A project can use such a runtime only when this list names its slug. A host runtime needs no entry. In the dashboard, tick the runtime under E2B runtimes. The section warns when a lane names an E2B runtime that is not permitted.
egress_allowlist
The hosts that an E2B run may reach, in addition to GitHub, the model API and MCP servers. An example is a package registry such as registry.npmjs.org or *.pypi.org. Write hosts only, with no scheme and no path. In the dashboard, type the host in the field under Egress hosts and push Add. The server refuses a malformed host.
mcp_allowlist
An empty list, or no key, permits no MCP server. Agents write to the repository that holds .factorial/. A server that the folder declares is thus only a request, and this list is the grant. The dashboard shows the list under MCP servers and cannot change it. Set it through the API.
Name and transport must match exactly. For a stdio entry, command and args must match exactly too, because such a server is code that runs beside a live GH_TOKEN. For an http or sse entry, the match ignores a trailing slash and the case of the scheme and host in the url. tools is a coverage check. A pattern in the entry must cover every tool pattern that the manifest asks for. An entry with no tools covers them all. MCP servers has the entry format.
permission_ceiling
The most that any agent manifest in the project may request from GitHub. No key means that the shared default profile of the App is the ceiling. It does not mean no limit, and it does not mean no access. A manifest with no permissions: key asks for exactly that profile. Set a ceiling when a manifest asks for more and you want a written limit on how much more. The value maps a scope to read or write. The dashboard does not set it, so use the API. A value that is not well formed raises an error and is never read as no ceiling.
Nobody can grant the workflows permission at any level, and neither a manifest nor a ceiling can write it. CI runs agent-written code with the secrets of the repository before a person sees it, so the App never holds workflows: write. A change to .github/workflows/ and a change to .factorial/ therefore cannot both come from the factory. A person merges the pair.
8. Create the labels
Factorial writes labels onto tickets, and admission reads one. Create the labels in the repository before you activate. In Configuration, the Ticket labels section lists the labels that the pinned processes use. It marks each one as present or missing, and filters them with Missing, Present and All. Push Create missing labels to create exactly the missing ones. A label that exists is never changed, so a colour that you chose stays. The colours and descriptions come from the role of each label in the pinned processes. The same step with the API:
curl -sS -X POST "$SF/api/projects/$PID/labels" -H "Authorization: Bearer $TOK"
The call answers with the list of the labels it created. There are three families:
| Family | Names | Where it comes from |
|---|---|---|
| Named in the process files, and the state labels | For example factorial:new and factorial:spec-review. Also five state labels: factorial:blocked, factorial:interrupted, factorial:waiting, factorial:exit, factorial:done | Each ticketLabel: of an outcome or of a gate's waiting:, and the label that entry.when reads. The set is the union over every lane of the project. Every project needs the five state labels, also when no file names them |
| Derived from step names | factorial:<step>, for example factorial:plan and factorial:validate_ci. Beside factorial:blocked, a blocked ticket also wears the label of its stuck step, for example factorial:pr_merge_gate | The steps of the process, spelled exactly as in the file, underscores included. Rename a step and you need a new label |
| Lane choice | factorial:process:<name>, one for each lane | A person puts it on an issue to choose that lane. It exists only when a project runs more than one lane |
factorial:new is the admission trigger: entry.when needs it and an assignee. Without it the pipeline looks dead. factorial:blocked is the way a ticket asks for a person, and removing it by hand un-blocks the ticket.
Three more labels resume or re-enter a ticket when you remove them. Remove factorial:interrupted to resume the run that you stopped, in the workspace that the stop kept. Comments that you left on the ticket meanwhile become the next instruction of the agent. Remove factorial:waiting, or push Resume in the dashboard, to resume a session that sat idle. Remove factorial:exit, with the issue open, to give the ticket a new try at the step it left. A session that reached its time limit parks under factorial:interrupted too, but only Resume in the dashboard releases it. Create all three labels even if you never expect to use them. Without them you cannot release a parked ticket.
An issue with an unknown factorial:process: label, or with two of them, does not enter and does not use the default lane. The Tickets tab lists every declined issue, with the reason.
The labels do not block activation. A missing label shows as a warning only.
9. Protect the default branch
Branch protection on the default branch stops agent-written code from reaching that branch without a person. Factorial does not set it, because the App never holds the administration: write permission. A repository admin sets it once, with this profile:
- One approval before a merge.
- A review from the code owners that
CODEOWNERSnames. - The check
verifymust pass, on a branch that is up to date. Your CI must report a check with this name. - A new push dismisses the old approvals.
- No bypass for the App. The rules also apply to admins.
The Branch protection row on the Configuration page compares the default branch with this profile. When the branch does not meet the profile, the row shows the command that applies it, with a copy button. The row also shows the command when it cannot read the branch. The command has this shape:
gh api -X PUT repos/acme/app/branches/main/protection --input - <<'JSON'
{"required_status_checks":{"strict":true,"contexts":["verify"]},"enforce_admins":true,"required_pull_request_reviews":{"required_approving_review_count":1,"require_code_owner_reviews":true,"dismiss_stale_reviews":true},"restrictions":null}
JSON
Run it under your own GitHub credentials. The row never blocks activation, so do this step yourself. To read the protection, the App needs the Administration Read-only permission. Without it, the row shows Not checked. See Set the permissions.
10. Check readiness and activate
The header of the project page shows Paused and a button. While something blocks activation, the page shows a warning, Finish configuration to activate, that lists each blocking row. Push View configuration in it, or View pending configuration step in the header, to jump to the first row to fix.
The Configuration tab lists one row for each requirement. Each row has a state:
| State | Meaning |
|---|---|
| Ready | The requirement is met |
| Blocks activation | Activation fails until you fix it |
| Needs attention | A real defect that does not stop admission |
| Not checked | Nobody could check it: the App is not set up, GitHub is unreachable, or the App lacks the permission |
Not checked is not a quiet block. A check that reads GitHub gives up and shows this state rather than fail, so an outage does not look like a defect.
These are the rows: Ticket source, GitHub App access, Process config, Runtime, Secrets, Ticket labels, Branch protection, CI workflows and Gate approvers. Ticket source, Process config and Secrets can block activation. A Runtime row can show Cannot run, which stops that lane and never the activation. The other rows never block it. The rows that read the repository load after the page, because each costs a GitHub call. The GitHub App access row tells apart an App that is not installed from an App that cannot see this repository.
To confirm a new permission of the App, read the row that the permission applies to, for example Branch protection. GitHub holds an added permission as a request that the account owner must accept. Until then the App keeps the old permissions, with no error anywhere.
When no row blocks activation, push Activate project. The dashboard asks Activate this project? and says that the worker starts to admit matching issues and run agents, which spend tokens. Push Activate project again to confirm. The header then shows Active and a Pause admission button. The same step with the API:
curl -sS "$SF/api/projects/$PID" -H "Authorization: Bearer $TOK" # read blockers and warnings
curl -sS -X PUT "$SF/api/projects/$PID/active" \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"active": true}'
An empty blockers list means that activation will succeed. Read warnings also. It holds the findings that do not block but predict trouble, for example a manifest that the secrets check could not open. A 409 answer lists exactly what is missing. Fix it and repeat.
To stop admission, push Pause admission, or send {"active": false} in the same call. A pause always works and asks nothing. It stops new tickets only. Tickets in flight keep running, because dropping one would strand its branch and its pull request.
An empty blockers list means that the worker can read the project, not that the project will work. Check the steps above, and run one ticket through the lane.
The worker
In the container the worker already runs. It spawns agents, makes a GH_TOKEN for each run, and writes labels, comments and merges on the repository. It admits issues only for an active project. It checks GitHub on a short interval.
To stop admission, pause the project as step 10 shows. To stop the worker, stop the container with docker stop factorial. Run the factory has the details. The worker finishes its current work before it stops.
Two org settings CI needs
CI decides whether a pull request passes, so CI must run on every pull request. If it cannot, every lane stops at its first CI condition. Two settings outside the repository decide this:
- Actions billing must be healthy. An organisation with a billing problem still makes a check run. The run has the result
failure, runs no steps, and carries a billing note. The CI condition then reads false, and the red check looks like a bad implementation. Fix the billing of the organisation, then run the check again. - The App must hold
Checks: Read-only, and the installation must accept it. Without it the check read answers 403 and the CI condition can never pass. Two acts grant it: the App owner adds the permission, and the installation accepts it. Read-only is the limit on purpose, since write would let an agent token forge check runs.
Check the config before you commit
The process editor validates the text each time you change it, and Commit and sync writes only valid config. Check that it parses explains the Valid config and Invalid config label, and what a sync refuses.