Skip to main content

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)

  1. Telegram POSTs an update to /webhooks/telegram; the route verifies the secret, checks the sender allowlist, enqueues a durable job, and returns 200 immediately.
  2. 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.
  3. 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 a human/event_wait node and suspends.
  4. Effectful nodes call back into the Orchestrator: an agent node invokes the Product Owner or Reviewer (which call the pluggable LLM provider for structured output); a tool node calls GitHub; a human node sends a Telegram prompt and suspends.
  5. 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)

SeamInterfaceDefaultSwap for
Orchestration engineWorkflowContext + EngineBuilt-in interpreterLangGraph / Temporal adapter
Orchestration runtimeOrchestrationRuntime (M9 design)Built-in runtimeOpenClaw runtime adapter
LLM backendLLMProviderAnthropic (Claude Opus 4.8)OpenAI-compatible provider; OpenClaw-gateway provider; fake for offline/dev
DurabilityStateStoreIn-memoryPostgres / Supabase
AccountsAccountStoreIn-memoryPostgres / Supabase
Chat channelMessengerTelegram (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.py checks structure, boundaries, coupling, maintainability, and fit with the documented architecture.
  • security_reviewer.py checks auth, input validation, secret handling, least privilege, and dependency risk.
  • qa_reviewer.py checks 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 -> a type:* 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 from BLOOM_COLLABORATORS. This is a generic capability - the designer is its first consumer, but QA, tech-writer, etc. slot in the same way. Routing lives in Coordinator so LangGraphCoordinator inherits it.
  • Bloom Designer. A producer agent (agents/designer.py) that turns a design ticket into a DesignStudySet of >= 2 comparable options. It is a pure structured() 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 to status: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_SKILLS adds optional ones).
  • Deliverable review (no PR). An issue flipped to status:in-review without 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 pluggable EngineerEngine turns one issue into one PR. The default deployment is disabled (external swarm/humans); a production Claude Code engineer implements each ticket in an isolated feat/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 at status:awaiting-client and 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 a DesignSurface protocol with two sibling capabilities: ux_preview (Figma) and asset_render (Blender), each opt-in and off by default, each degrading to text studies when unconfigured and never blocking the publish path. A CompositeDesignSurface lets both run at once. Slow Blender renders run as a durable design_render job 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-shaped DecisionRecord living at docs/decisions/NNNN-slug.md in the project's repository: front-matter carries the typed edges (affects, supersedes, motivates, derives_from) and lifecycle status (proposed -> accepted -> superseded/deprecated, enforced transitions); the body carries the fixed rationale sections. Rendering is deterministic and parse_decision_record is 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_decision runs at the moment the owner approves: PRD approval (FR-4, from freeze_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's draft_decision step 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_log entry is dropped (decision.duplicate). Entries retired by a supersedes edge - 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 same put_file path 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 the decision_log state 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.