Architecture
Bloom is a Python + FastAPI backend that runs an AI Product Owner over a stack-agnostic workflow definition. The core idea: the software development lifecycle is described as data (a JSON graph), and a small engine interprets it, delegating real work to agents and integrations. This keeps the process interchangeable and the code testable.
Repository layout (monorepo)
The repo is a monorepo. The backend lives in apps/server/ (Node + TypeScript + Express,
sources under apps/server/src with tests alongside, and the workflow spec in
apps/server/workflows); the web dashboard SPA lives in apps/web/ (React + Vite + TypeScript).
Repo-root concerns stay at root: docs/, CI/CD under .github/workflows/, the deploy
scripts in scripts/deploy/, and docker-compose.yml. Source paths below use the historical
Python module layout (bloom.<module>); their TS ports live under apps/server/src/.
Layers
Telegram / GitHub webhooks (src/bloom/api/routes)
│ verified, parsed
▼
Orchestrator ── implements ──▶ WorkflowContext (src/bloom/services)
│ ▲
│ drives │ effect seam (run_agent / run_tool /
▼ │ prompt_human / run_mapped)
Workflow Engine ── interprets ──▶ Workflow Spec (data) (src/bloom/workflow)
│ │
│ reads/writes │ references
▼ ▼
Shared State ◀── reducers ── Agents + Integrations (src/bloom/agents,
(channels) (LLM, Telegram, src/bloom/integrations)
│ GitHub)
▼
State Store (durability) (src/bloom/persistence)
Request flow (a user message)
- Telegram POSTs an update to
/webhooks/telegram; the route verifies the secret, checks the sender allowlist, enqueues a durable job, and returns200immediately. - A JobRunner worker claims the job and hands it to the Orchestrator, which loads the run for that chat thread (or starts one), binds the message into shared state, and calls the Engine to resume.
- The Engine advances node-to-node: it runs the current node via the
WorkflowContext, applies the node's writes to shared state through reducers, and evaluates edge guard expressions to choose the next node - until it hits ahuman/event_waitnode and suspends. - Effectful nodes call back into the Orchestrator: an
agentnode invokes the Product Owner or Reviewer (which call the pluggable LLM provider for structured output); atoolnode calls GitHub; ahumannode sends a Telegram prompt and suspends. - The resulting run state is persisted, ready to resume on the next message or webhook.
Durable work: webhooks enqueue, a worker drains
Webhooks must ack within seconds, but the work they trigger (LLM + GitHub calls) takes longer.
So the routes do the minimum synchronously - verify, route, enqueue - then return. An
in-process JobRunner pool drains the queue, calling the orchestrator entry point for each
job kind (message, PR, issue). This means acked-but-unprocessed work is not lost to a crash or
deploy: with the Postgres store the job is persisted before the ack, and delivery is
at-least-once with exponential-backoff retries and a dead-letter state after
BLOOM_JOB_MAX_ATTEMPTS. Claims are atomic and lease-based (FOR UPDATE SKIP LOCKED), so a job
whose worker dies is reclaimed after the lease. Because delivery is at-least-once, each event
carries a stable event_key (Telegram update_id; PR number+action+head-sha; issue
number+action+label) that the orchestrator fences on (RunState.processed_keys, saved
atomically with the run's effects) - so a redelivery or crash-rerun is a no-op for the run's
state machine, leaving only a negligible window for a duplicate external side effect. The queue (persistence/jobs.py) has two
backends - PostgresJobQueue (durable; shares the state store's pool) and InMemoryJobQueue
(dev/tests) - behind one protocol, so nothing else changes between them. The worker starts in
the same process today; because it drains a shared durable queue, it can later be extracted into
a separate process with no model change.
Key design seams (where to extend / swap)
| Seam | Interface | Default | Swap for |
|---|---|---|---|
| Orchestration engine | WorkflowContext + Engine | Built-in interpreter | LangGraph / Temporal adapter |
| Orchestration runtime | OrchestrationRuntime (M9 design) | Built-in runtime | OpenClaw runtime adapter |
| LLM backend | LLMProvider | Anthropic (Claude Opus 4.8) | OpenAI-compatible provider; OpenClaw-gateway provider; fake for offline/dev |
| Durability | StateStore | In-memory | Postgres / Supabase |
| Accounts | AccountStore | In-memory | Postgres / Supabase |
| Chat channel | Messenger | Telegram (or logging fallback) + the dashboard web chat (M20) | Slack / Discord (post-MVP) |
OpenClaw has two distinct integration roles. As an LLMProvider backend, it routes structured
model calls through the OpenClaw gateway and does not change workflow ownership. As an
orchestration runtime, it is a pilot behind BLOOM_ORCHESTRATION_RUNTIME=openclaw_pilot, routed
through a concrete, gateway-backed OpenClawGatewayNodeExecutor. At the end of milestone M10 the
pilot covers exactly two nodes - track_progress (a read-only tool node) and prd_authoring (a
state-only agent node) - enforced by a centralized capability policy
(bloom/workflow/openclaw_policy.py) that gates node type, side-effect posture, and declared
writes independently of operator config, while the built-in runtime remains the default and
rollback path. Before an operator switches an environment onto the pilot, a machine-checkable
flip-readiness command (bloom.workflow.openclaw_readiness, M10-7) verifies config, /health,
executor availability, correlation-store health, and an offline OpenClaw execution harness.
In both roles, Bloom keeps product state, GitHub coordination semantics, account/session data,
webhook durability, and business invariants. See
docs/design/openclaw-orchestration-runtime.md.
The AccountStore (persistence/account_store.py, Postgres in supabase_account_store.py) is the
M3 accounts seam - registered users, OAuth identities, sessions, and numeric-id Telegram links -
mirroring StateStore. It backs self-serve dashboard accounts and registered-user gating; see
docs/milestones/m3-dashboard-and-accounts.md.
Web dashboard & auth (M3)
The dashboard SPA (apps/web) is served from Cloudflare Pages; a Pages Function reverse-proxies
/api/* to the API so the browser talks to a single origin and the session cookie stays
first-party. Auth is Google OIDC (authorization-code + PKCE, id_token verified via JWKS in
auth/oidc.py) behind an OIDCProvider seam; sign-in creates an httpOnly server-side session.
Dashboard routes (api/routes/auth.py, telegram_link.py, projects.py) are cookie-authenticated
and scoped to the user's linked Telegram thread, so a user only sees their own project.
Live updates use a small in-process EventBus (services/events.py): the orchestrator
publishes project events (delegate/unblock, milestone completed, PR reviewed, blocked, escalated),
and GET /api/projects/{id}/events streams them to the browser over SSE. The multi-instance
upgrade path is Postgres LISTEN/NOTIFY behind the same interface.
Workspace redesign & web chat (M20)
M20 reframes the SPA as an AI-chat app: a persistent project sidebar (projects-as-conversations)
and a per-project Project View whose tabs - Chat, Details, Workflow (the SDLC graph), Credentials,
and Settings - are URL-addressable (/p/:threadId/<tab>) via React Router, with a top-level Global
Credentials Store for shared secrets. The dashboard also becomes a chat channel alongside
Telegram. Both channels share each project's per-thread conversation (run.state['conversation'],
keyed by thread_id): Telegram multiplexes many threads through one chat via an active-project
pointer, while the web path targets a project-thread directly. The orchestrator exposes a
thread-scoped handle_thread_message (bypassing the chat-level active pointer), start_project
(new chat = new project), and a project_conversation read model; GET/POST /api/projects/{thread_id}/messages and POST /api/projects are ownership-gated through the
verified-Telegram-link -> chat_id -> project check, and appended turns stream back over the same
EventBus/SSE as chat_typing / chat_message events. The sidebar's New project dialog
(M26) is the web entry point to POST /api/projects: it collects the first message, then lands
the user on the new project's view.
Conversational plan & PRD evolution (M4)
After planning, the run parks at await_project_event. A free-text message there is triaged by
the monitoring assistant into a question (answered conversationally, grounded in live GitHub
status) or a change request. A change request is handled imperatively by the orchestrator
(like the archive/delete lifecycle ops), not by re-running the create-only planning nodes:
ProductOwnerAgent.revise_plan drafts a new PRD revision plus the desired milestone/ticket plan;
coordination/plan_delta.py diffs it against the current plan (keyed on title) into
add/update/retire sets; and the orchestrator proposes that delta for approval (surfacing any
in-progress work a removal would close). On approve, Bloom freezes the new PRD revision,
re-commits PRD.md, reconciles the delta onto GitHub incrementally (create/patch/close issues
and milestones, never touching status:*/eng:* labels), re-runs the coordination reconcile to
delegate newly-ready tickets, notifies concisely, and emits a plan_changed event. The reconciled
title→issue mapping becomes the new source of truth, so a repeat apply is a no-op.
Proactive scheduling (M5)
Bloom is otherwise reactive - it acts only on an inbound webhook. The Scheduler
(services/scheduler.py) is an in-process heartbeat: every BLOOM_SCHEDULER_INTERVAL_SECONDS it
asks the store for active projects (list_active_threads) and enqueues one project_tick per
project onto the durable job queue, deduplicated per thread so a slow tick never piles up. The
JobRunner drains the tick like any other job, so the actual work runs on exactly one worker via the
queue's atomic claim - safe even if several instances each run their own scheduler loop. (The loop
is just the clock; while the process is down no ticks fire, and the idempotent, time-based work
simply catches up on the next heartbeat.)
Orchestrator.handle_project_tick always self-heals first - re-running the idempotent
coordination reconcile, so ready work is delegated and finished milestones close even if a webhook
was missed. Then, if proactive messaging is enabled and it is outside quiet hours, it detects
stalled tickets from GitHub timestamps (nudging once, fenced in notified_stalls; escalating
the long-stuck to status:needs-attention) and may send a digest (suppressed when the project
is unchanged since the last one). Every proactive send is fenced in run state, and the dashboard
sees it through the same EventBus/SSE stream plus last_checked_at/next_check_at on the
overview.
Specialist review pipeline (M7)
Specialist reviewers are advisory actors that run beside the Product Owner review. The roster is
configured with BLOOM_SPECIALIST_REVIEWERS and parsed into typed SpecialistReviewer records:
architecture, security, and QA roles can each be AI-run or human collaborators. This keeps the
domain model symmetric with human teams while using the best GitHub surface each actor type can
actually use.
services/specialist_review.py owns the fan-out and aggregation. The orchestrator builds a
SpecialistReviewContext from the PRD, ticket acceptance criteria, and architecture docs, then
runs enabled specialists through a role-dispatching client. Concrete AI reviewers live under
agents/:
architecture_reviewer.pychecks structure, boundaries, coupling, maintainability, and fit with the documented architecture.security_reviewer.pychecks auth, input validation, secret handling, least privilege, and dependency risk.qa_reviewer.pychecks acceptance coverage, test depth, regression risk, and verification evidence.
The aggregation is fail-soft and deterministic: one specialist error becomes that role's result, not a dropped PR review, and result ordering follows the configured roster. Specialist output is published to GitHub as a COMMENT review, with inline comments only for valid diff lines and a body fallback when GitHub cannot anchor inline feedback. Project detail and proactive digest context surface compact specialist status; full findings stay on the PR.
Specialists do not assign work, mutate status:* or eng:* labels, approve, request changes, or
merge. The Product Owner review remains the merge gate, and the coordinator remains the only
owner of engineer assignment.
The design stage (M11)
Design is modelled as a kind of work within the plan, not a phase the whole project waits on (FR-23). The pieces:
- Role-aware coordination. Tickets carry a
kind(Ticket.kind-> atype:*label); the reconciler routes a ready ticket to a collaborator whose role is eligible for that kind (design/ux -> designer, else engineer). Roster + roles come fromBLOOM_COLLABORATORS. This is a generic capability - the designer is its first consumer, but QA, tech-writer, etc. slot in the same way. Routing lives inCoordinatorsoLangGraphCoordinatorinherits it. - Bloom Designer. A producer agent (
agents/designer.py) that turns a design ticket into aDesignStudySetof >= 2 comparable options. It is a purestructured()call with no side effects; when a design ticket is delegated, the orchestrator runs it and publishes the studies as one issue comment, then flips the issue tostatus:in-review. The Designer declares its baseline knowledge skills in code (the skill registry,bloom/skills/, composed into the cache-stable prompt prefix;BLOOM_AGENT_SKILLSadds optional ones). - Deliverable review (no PR). An issue flipped to
status:in-reviewwithout a PR is reviewed against the ticket's acceptance criteria (ReviewerAgent.review_deliverable), driving the same request-changes / round-cap / escalation loop as the PR path. - Engineer engine (build the software). Execution sits behind a typed one-issue seam
(
bloom/engine/, M6): a pluggableEngineerEngineturns one issue into one PR. The default deployment isdisabled(external swarm/humans); a production Claude Code engineer implements each ticket in an isolatedfeat/issue-<n>worktree and opens exactly one PR (one issue = one branch = one PR), joins the review-driven rework loop on the same PR, and classifies/bounds/ escalates failures; a simulator stays behind the same seam for offline runs. See engineering-engine.md. - Client-approval gate. For client-facing kinds (
BLOOM_CLIENT_APPROVAL_KINDS), Bloom's approval parks the ticket atstatus:awaiting-clientand asks the owner to sign off; the owner finalizes or requests changes in chat. It is free of capacity and stall-detection by construction. - Design surfaces (optional seam).
integrations/design/defines aDesignSurfaceprotocol with two sibling capabilities:ux_preview(Figma) andasset_render(Blender), each opt-in and off by default, each degrading to text studies when unconfigured and never blocking the publish path. ACompositeDesignSurfacelets both run at once. Slow Blender renders run as a durabledesign_renderjob that posts the asset links back, de-duplicated by a content hash of the study set - so the studies comment publishes immediately regardless of render latency.
Decision capture (M14)
Decisions are first-class, repo-owned artifacts (FR-25; design: decision-graph.md, Phase 1). The pieces:
- The record (
domain/decisions.py). An ADR-shapedDecisionRecordliving atdocs/decisions/NNNN-slug.mdin the project's repository: front-matter carries the typed edges (affects,supersedes,motivates,derives_from) and lifecyclestatus(proposed -> accepted -> superseded/deprecated, enforced transitions); the body carries the fixed rationale sections. Rendering is deterministic andparse_decision_recordis its exact inverse; numbering (next_decision_id) is monotonic against what is already on the default branch. Nothing reads a clock - date and id are explicit inputs, so capture is reproducible under the fake provider. - Capture rides the existing approval gates.
_propose_gate_decisionruns at the moment the owner approves: PRD approval (FR-4, fromfreeze_prd), a design-deliverable sign-off (FR-23, the studies on the issue are the artifact), and an applied plan change (FR-19). The PO'sdraft_decisionstep judges decision-worthiness first - a trivial approval yields no draft (the capture-fatigue mitigation) - and returns only the rationale; provenance (derives_from), numbering, date, and status stay deterministic in the flow. Capture is best-effort by design: a drafting or commit failure logs and never blocks the gate it rides. - Worthiness bar + de-duplication (M14-5). Only gates propose - never ordinary turns - and a
plan change whose delta is empty (no milestone or ticket changed) skips the draft step
entirely. Drafts are de-duplicated deterministically: the title's slug is the condition's
identity, and a draft matching a still-current
decision_logentry is dropped (decision.duplicate). Entries retired by asupersedesedge - or being superseded by the proposal at hand - don't count: superseding is not duplicating. The already-recorded titles also ride in the draft prompt so the model avoids re-proposing them in the first place. - The owner decides. The proposal pends in
pending_decision; an explicit "approve the decision" commits the accepted record via the sameput_filepath the PRD uses, a bare rejection discards it, and any other reply drops it silently and is triaged on its own merits. Committed records append to thedecision_logstate field ({id, path, title, gate, supersedes}); a superseding commit links the edge and retires the old record in place (accepted -> superseded), keeping the plan-scope rationale a chain whose head is current.
Why a built-in engine instead of LangGraph
The PRD lists LangGraph as tentative. Rather than couple the process to it, Bloom defines
the workflow declaratively (see apps/server/workflows/) and ships a ~100-line
interpreter that executes that data. This adds no heavy dependency, is fully unit-testable
(tests/unit/test_engine.py), and keeps LangGraph as a drop-in alternative behind the
Engine seam. The declarative spec stays the single source of truth for flow; the
Orchestrator supplies the implementations the spec references, dispatched by node id.
MVP scope vs. the full lifecycle
The workflow spec documents the whole SDLC, but nodes tagged "mvp": false (deploy, test,
feedback) are pruned from the MVP path - matching the PRD's non-goals. The MVP covers
inception → living PRD → approval → milestone & ticket planning → GitHub sync → progress
tracking → PR review.
Testing strategy
- Unit: pure pieces in isolation - guard expressions, reducers, engine control flow (with a fake context and synthetic specs), spec loading, and webhook signature/secret checks.
- Integration: the Orchestrator end-to-end over the real bundled spec using the offline
fake LLM provider, and the FastAPI app via
TestClient. - CI (
.github/workflows/ci.yml) runs ruff (lint + format), mypy (strict), the workflow schema validator, and pytest with coverage. Nothing merges on red.