Skip to content

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

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 --> [*]
  1. 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.
  2. Interpretation (workflows pool). FactoryalRun walks the steps. It renders {{ jq }} templates against {inputs, steps, run, trigger}, evaluates when, and runs control flow (condition, goto, parallel, map) itself.
  3. Side effects (other pools). Each step with I/O becomes an activity on its step's queue (llm, coding or light), with its own timeout and retry policy.
  4. 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.
  5. Waiting for events. A wait step 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 (llm and coding only) 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 _memory is 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. agent steps 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.