Architecture¶
Factoryal has two halves. The control plane (the API and the dispatcher) owns all state and every credential. The execution plane (Temporal and stateless workers) runs the workflows. Workers never touch the database. They call the API back, with tokens scoped to the step they run.
- Components
- Concepts
- How a run executes
- Flows: manual run with an approval · catalog change to automation · agent step · code step and pull request · PR review to revision · schedules · child workflows · webhook sources
- Security model
- Scaling and durability
Components¶
flowchart TB
subgraph clients[Clients]
UI[Web UI]
CLI[factoryal CLI]
GIT[Factory repos<br/>applied on merge]
end
subgraph sources[Event sources]
GH[GitHub App webhooks]
SL[Slack events and /factoryal]
LN[Linear webhooks]
WH[Webhook sources<br/>Jira · Sentry · HMAC · token]
end
subgraph control[Control plane]
API[API · FastAPI<br/>/v1/*]
DISP[Dispatcher]
end
subgraph data[State]
PG[(Postgres)]
OUT[[outbox events]]
end
subgraph temporal[Temporal]
TQW[queue: workflows]
TQL[queue: light]
TQM[queue: llm]
TQC[queue: coding]
SCH[Schedules]
end
subgraph workers[Workers]
W1[workflows pool<br/>FactoryalRun interpreter]
W2[light pool<br/>http · upsert · notify · reports]
W3[llm pool<br/>llm calls · agent loop]
W4[coding pool<br/>coding sessions]
end
clients --> API
sources --> API
API --> PG
PG --- OUT
DISP -- drain --> OUT
DISP -- regrade, match triggers --> PG
API -- start · signal · cancel --> TQW
DISP -- start runs --> TQW
SCH -- fire --> TQW
TQW --> W1
W1 -- activities --> TQL & TQM & TQC
TQL --> W2
TQM --> W3
TQC --> W4
W2 & W3 & W4 -- reports · sessions · catalog tools<br/>scoped tokens --> API
W3 --> CLAUDE[Claude API]
W4 --> CODE[Managed Agents ·<br/>Claude Code · Codex]
API -- open PRs, comments --> GHAPI[GitHub · Slack · Linear · Jira APIs]
| Component | Role | Code |
|---|---|---|
| API | Validates and stores the catalog, factory config and workflow definitions. Starts, signals and cancels runs. Mints scoped tokens. Receives every webhook. Sends every outbound message. Serves the web UI. | entrypoints/api.py (app factory), contexts/<ctx>/api/routes.py, auth.py |
| Dispatcher | Drains the outbox: matches triggers, starts runs. Every minute it reconciles: runs whose final report was lost, PENDING runs whose start was lost, queued runs that can start, schedule sync. Locally it is a polling loop (factoryal dispatcher); on AWS it is a Lambda, woken by the API after a commit that gives it work and swept on a schedule (operations). |
contexts/runs/application/automations.py, entrypoints/dispatch_handler.py |
| Postgres | Catalog, factory config, workflow versions, runs, budgets, benchmarks, factories, and the transactional outbox. | models.py, migrations/ |
| Temporal | One durable execution per run. Timers for approvals and delays, signals for decisions and child results, Schedules for cron triggers. | contexts/runs/adapters/temporal.py |
workflows workers |
Run FactoryalRun, the interpreter that executes any workflow definition, and the benchmark and scheduled-start workflows. Deterministic, with no I/O of their own. |
contexts/runs/application/interpreter.py |
light workers |
Cheap activities: http, upsert_entity, notify, run reports, child-run starts. |
contexts/runs/application/activities.py |
llm workers |
llm steps (one Claude call) and agent steps (the agent loop with catalog tools). |
contexts/agents/application/ |
coding workers |
code steps: drive a coding backend, then ask the API to open the PR. |
contexts/agents/adapters/ |
All roles ship in one Docker image. The command picks the role: factoryal api,
factoryal dispatcher, or factoryal worker --queues ....
Concepts¶
| Term | Meaning |
|---|---|
| Blueprint | A type in the catalog: a JSON schema for properties, plus relations to other blueprints. |
| Entity | An instance of a blueprint, validated against it (e.g. repo billing-api). Every change is an outbox event. |
| Workflow | A versioned definition: inputs, triggers, steps, output, concurrency. |
| Run | One execution of one workflow version. Status: PENDING, QUEUED, RUNNING, WAITING_APPROVAL, WAITING, then SUCCESS, FAILURE, CANCELLED or SKIPPED. |
| Trigger | What starts runs: manual, entity_event, schedule, webhook, budget_event, benchmark_event, run_event. |
| Agent | Factory config (a row in config.agents): model, system prompt, skills, owner, version and scopes. |
| Skill / Rule | Reusable instructions. A skill is listed on an agent. A rule applies on its own, by org, team or repo. |
| Environment | Where a coding agent runs: backend, setup commands, allowed hosts. |
| Factory | A directory that defines all of the above, applied as one versioned unit. |
How a run executes¶
stateDiagram-v2
[*] --> PENDING: started, slot free
[*] --> QUEUED: over concurrency limit or budget
[*] --> SKIPPED: on_limit / on_exceeded = skip
QUEUED --> PENDING: slot frees / budget allows
PENDING --> RUNNING: interpreter starts
RUNNING --> WAITING_APPROVAL: approval step
WAITING_APPROVAL --> RUNNING: approved / rejected / expired
RUNNING --> WAITING: wait step
WAITING --> RUNNING: matching event / expired
RUNNING --> SUCCESS
RUNNING --> FAILURE: step failed, rejected
RUNNING --> CANCELLED
WAITING_APPROVAL --> CANCELLED
WAITING --> CANCELLED
SUCCESS --> [*]
FAILURE --> [*]
CANCELLED --> [*]
SKIPPED --> [*]
- Admission (API). The API validates the inputs and checks the budget. Under the
workflow's concurrency lock (a Postgres advisory lock per workflow and key), it then
writes the run row and either starts the Temporal workflow or leaves the run
QUEUED. - Interpretation (
workflowspool).FactoryalRunwalks the steps. It renders{{ jq }}templates against{inputs, steps, run, trigger}, evaluateswhen, and runs control flow (condition,goto,parallel,map) itself. - Side effects (other pools). Each step with I/O becomes an activity on its step's
queue (
llm,codingorlight), with its own timeout and retry policy. - Reporting. After each step the interpreter reports the run's state (step outputs, usage, pending approvals) to the API. A finished run frees its slot, and the next queued run starts.
- Waiting for events. A
waitstep registers its matchers with the API (run_waits). The dispatcher checks each outbox event against the waits on its source (blueprint and entity, or webhook) and signals the first match to the run. Registering replays the events after the run's cursor, so an event that arrived while earlier steps ran still ends the wait. The cursor is a position in the order the dispatcher handled events, which runs one batch at a time (dispatch is single-writer: a second dispatcher skips its poll while a batch runs), so an event that commits late is still after it. Registering takes the same lock just long enough to read the latest position: a batch either finished before that (the replay finds its events) or sees the wait and delivers its events itself. Delivery is at least once, and the interpreter keeps the first signal per wait.
Flows¶
Manual run with an approval¶
sequenceDiagram
autonumber
actor User
participant API
participant DB as Postgres
participant T as Temporal
participant WF as workflows worker
participant L as light worker
participant M as llm worker
participant C as Claude
User->>API: POST /v1/workflows/release/runs {inputs}
API->>API: validate inputs, check budget
API->>DB: lock slot, insert run (PENDING)
API->>T: start FactoryalRun(run id)
API-->>User: {id, status}
T->>WF: workflow task
WF->>L: report_run(RUNNING)
L->>API: PATCH /v1/runs/{id}
WF->>M: llm_call (step notes)
M->>C: messages.create
C-->>M: release notes
M-->>WF: output, usage
WF->>L: report_run(WAITING_APPROVAL, pending approval)
L->>API: PATCH /v1/runs/{id}
Note over WF,T: durable wait, up to expires (72h)<br/>no worker is held
User->>API: POST /v1/runs/{id}/approval {approved: true}
API->>T: signal approve
T->>WF: workflow task
WF->>L: http_request (step deploy)
WF->>L: report_run(SUCCESS, output)
L->>API: PATCH /v1/runs/{id}
API->>DB: finish run, free slot, promote next queued run
Approvals can also be decided from Slack (/factoryal approve) or by moving a Jira issue.
Each of these ends up as the same approve signal. The web UI shows pending approvals but
doesn't decide them.
Catalog change to automation¶
sequenceDiagram
autonumber
participant Src as Writer<br/>(user · agent · webhook)
participant API
participant DB as Postgres
participant D as Dispatcher
participant T as Temporal
Src->>API: PUT /v1/blueprints/repo/entities/billing-api
API->>DB: one transaction: entity + outbox event entity.updated
API-->>Src: 200
loop every poll interval
D->>DB: SELECT pending events FOR UPDATE SKIP LOCKED
end
D->>D: match entity_event triggers<br/>(blueprint, events, condition)
D->>DB: insert run, id derived from (event, workflow)
D->>T: start FactoryalRun
D->>DB: mark event processed
The entity and its event are committed together, so an event can't get lost. Run ids are derived from the event and the workflow, so if the dispatcher crashes and processes the event again, it finds the existing run and starts no duplicate.
Agent step¶
sequenceDiagram
autonumber
participant WF as workflows worker
participant M as llm worker
participant API
participant C as Claude
WF->>M: agent_run {agent: triager, prompt}
M->>API: POST /v1/runs/{id}/agent-sessions
API->>API: resolve agent, skills, rules, lessons<br/>mint token limited to the agent's scopes
API-->>M: agent config + scoped token
loop until end_turn or max_turns
M->>C: messages.create (tools filtered by scopes)
C-->>M: tool_use search_catalog / upsert_entity
M->>API: catalog call with scoped token
API->>API: enforce scopes (403 outside them)
API-->>M: result
M-->>WF: heartbeat
end
M-->>WF: output, usage, agent and skill versions
Code step and pull request¶
sequenceDiagram
autonumber
participant WF as workflows worker
participant K as coding worker
participant API
participant GH as GitHub
participant B as Coding backend<br/>(Managed Agents / CLI)
WF->>K: code_run {agent, repo, task, rubric, budget}
K->>API: POST /v1/runs/{id}/code-sessions
API->>API: check repo:write scope for this repo
API->>GH: installation token for this one repo (1h)
API-->>K: agent config, repo url, scoped token
K->>B: start session: repo mounted on branch factoryal/run-step,<br/>task + outcome rubric, budget
loop until the rubric is met, max_iterations, or budget
B->>B: edit, test, commit, push branch
B->>B: grader checks the change against the rubric
K-->>WF: heartbeat (session id, for reattach)
end
B-->>K: outcome, summary, cost
K->>API: POST /v1/runs/{id}/pull-requests
API->>GH: verify branch has commits, open PR as the App's bot
API-->>K: pr_url, pr_number
K-->>WF: output {pr_url, branch, summary, outcome}
On Managed Agents, Anthropic's git proxy injects the GitHub token, so the token never enters the sandbox. CLI backends pass it in a per-command HTTP header and push for the agent. If a worker dies, Temporal retries the activity and the heartbeat lets it reattach to the same session.
PR review to revision¶
sequenceDiagram
autonumber
actor Reviewer
participant GH as GitHub
participant API
participant T as Temporal
participant K as coding worker
Reviewer->>GH: review "request changes" on a Factoryal PR
GH->>API: pull_request_review webhook (HMAC-signed)
API->>API: find the run and code step that opened the PR<br/>check revision limit
API->>T: start revision run<br/>(concurrency key = the PR, so one at a time)
T->>K: code_run on the PR's branch (continue_branch)<br/>task + review body + inline comments
K->>GH: push new commits to the same PR
K->>API: comment on the PR: what changed
Merges close the loop. A push to the default branch updates the repo's facts in the catalog
(has_readme, has_ci, ...), which entity_event triggers can act on. Pushes to other
branches are ignored.
Scheduled runs¶
sequenceDiagram
autonumber
participant S as Temporal Schedule
participant WF as workflows worker
participant L as light worker
participant API
S->>WF: FactoryalScheduledStart (cron fired)
WF->>L: start_scheduled_run
L->>API: POST /v1/workflows/{id}/scheduled-runs
API->>API: same admission as any run:<br/>budget, concurrency, idempotent id per firing
Publishing a workflow syncs its schedules, and the dispatcher re-syncs all of them on every reconcile pass. A firing missed by more than 10 minutes is dropped, not caught up.
Child workflows¶
sequenceDiagram
autonumber
participant P as parent FactoryalRun
participant L as light worker
participant API
participant C as child FactoryalRun
P->>L: start_child_run {workflow, inputs}
L->>API: POST /v1/runs/{parent}/children
API->>C: admit (may queue behind its own limit)
Note over P: waits on signal child_finished<br/>(polls every 5 min as a fallback)
C->>API: final report
API->>P: signal child_finished {status, output}
Webhook sources¶
sequenceDiagram
autonumber
participant J as Jira / Sentry / GitHub
participant API
participant DB as Postgres
participant D as Dispatcher
J->>API: POST /v1/webhooks/{source}
API->>API: verify signature or token, drop redeliveries
API->>DB: outbox webhook.received (+ optional entity upsert)
opt approval waiting on this Jira issue
API->>API: decide the approval as the mapped user
end
D->>DB: match webhook / entity_event triggers, start runs
Security model¶
flowchart LR
subgraph API[API: holds every secret]
K1[GitHub App private key]
K2[Slack · Linear · Jira credentials]
K3[token secret]
end
subgraph W[Workers]
AG[agent step]
CS[coding session]
end
K3 -- short-lived JWT,<br/>agent scopes only --> AG
K1 -- 1h installation token,<br/>one repository --> CS
AG -- catalog calls --> API
CS -- push branch --> GH[(GitHub)]
API -- opens PRs, posts messages --> GH
- Credentials stay in the control plane. Only the API and the dispatcher (which reads
factory repos) hold the GitHub App key, and only the API holds the Slack, Linear and Jira
credentials. Workers hold an Anthropic key (
llmandcodingonly) and the API URL. - Agents get scoped, short-lived tokens. The API enforces the scopes on every request.
Filtering the tools offered to the model is a convenience on top of that, not the guard.
Agents can never hold
admin. Factory config (agents, skills, rules, ...) has no write endpoints, only factory repos change it, and_memoryis admin-only, so an agent can't widen its own scopes or approve its own lessons. - The platform opens PRs, not the agent. A coding agent can push one branch to one repo. The API opens the PR after checking that the branch has commits.
- Inbound requests are verified by each service's signature (GitHub HMAC, Slack signing secret, Linear signature, webhook source schemes), not by a Factoryal token.
Scaling and durability¶
- Independent runs. Every run is its own Temporal execution, so runs never block each other.
- Independent pools. Each queue has its own worker pool, and each pool scales on its own
backlog. Locally:
docker compose up -d --scale worker-llm=4. On AWS, the scaler Lambda sets each pool's count from its Temporal backlog, and every pool scales down to zero when idle (deploy-aws.md). - Waiting is free. Approvals, delays and child waits are timers and signals. They hold no worker and survive restarts and deploys.
- Safe retries. Activities have retry policies.
agentsteps default to one attempt, because they have side effects. Coding sessions reattach after a worker crash. - No over-admission. Concurrency slots are taken under a Postgres advisory lock, so several API instances and the dispatcher can't admit too many runs. The reconcile pass closes runs whose final report was lost.
- Graceful shutdown. On SIGTERM, workers stop polling and finish in-flight activities. A worker running activities protects itself from scale-in on ECS.