Skip to content

Brain

Brain is the NestJS service for generation records, character metadata, provider integrations, and Prisma-owned data in the fennec schema. It also provides reference image nudification (synthetic NSFW variant generation from SFW images) and a body-parts reference library used during character creation.

Under T-Bone, brain owns the workflow engine — the content framework that replaces hand-coded GenerationType branches: workflows, subworkflows, nodes, executions, contracts, and the per-provider inference adapters all live here and are served over an HTTP API (see Workflow engine below).

Brain runs as a NestJS HTTP service. Sirloin consumes its OpenAPI-generated HTTP client, Brain calls Round over gRPC for model-serving work, and completed workflows can notify Sirloin over HTTP.

  • apps/brain/src/modules/
  • apps/brain/src/modules/domain/workflow/ (T-Bone workflow engine: engine, nodes, contracts, executions)
  • apps/brain/src/generated/
  • apps/brain/prisma/schema.prisma
  • Brain HTTP bootstrap and OpenAPI setup live in apps/brain/src/main.ts.
  • Round generated client code lives under apps/brain/src/generated/round/.
  • Sirloin consumes Brain through apps/sirloin/pkg/brain-client/.

The T-Bone workflow engine lives under apps/brain/src/modules/domain/workflow/. It is the content framework that turns one-off, hand-coded generation types into framework-driven workflows. Core vocabulary:

  • Workflow — a named, versioned graph (stored as JSON) describing end-to-end how one piece of media is generated, from raw inputs through provider inference to a stored result. Declares an input contract and an output contract, carries embedded pricing rules, and normally maps media execution to one Generation row. Sandbox execution creates no Generation unless explicitly requested and applicable. Workflows are drafts until published, which freezes a numbered version. Identified by name + version. See Lifecycle and versioning.
  • Subworkflow — a reusable graph fragment that runs only inside a workflow (one-level nesting, enforced by the save validator). A workflow pins a specific subworkflow version. Each subworkflow node persists its inline run as a child execution linked back to the parent and the node that spawned it. Siblings in a parallel group claim their child concurrently and without a lock: a partial unique index (idx_workflow_execution_one_running_child_per_parent_node) admits at most one RUNNING attempt per parent node, so a losing claimant reuses the winner’s attempt instead of starting a second run. Only a missing or CANCELLED attempt starts fresh; RUNNING and terminal attempts are authoritative and get resumed or replayed.
  • Node — the atomic step: a backend method wrapped by a base class that records start/end events, timing, inputs, outputs, and errors so each step is independently inspectable. Nodes live inside a workflow’s graph and are versioned with it.
  • Engine — the runtime that interprets the graph. It auto-parallelizes independent nodes by topological depth, persists a per-node execution trace (for profiling and crash resume), and enforces cross-cutting behavior an author can’t skip (the moderation wrap on inference nodes). On boot it re-enqueues recoverable top-level executions left RUNNING by a crash. Linked executions and executions with node progress are recoverable; sandbox and non-media executions retain their existing recovery behavior. Zero-node, unlinked production media attempts are skipped because they never completed Generation mapping.
  • Execution — a single run: RUNNING → COMPLETED, FAILED, or CANCELLED, with a per-node trace. The pinned graph is captured at start so a mid-flight edit can’t corrupt an in-progress run. CANCELLED is recorded by the executor on abort-signal propagation (supersession, parent deadline abort into a nested run); whole-run budget expiry fails the execution. HTTP API-triggered cancellation of in-flight executions is out of scope for v1.

A node’s config values reference the workflow’s inputs and upstream node outputs through {{...}} placeholders, resolved at execution time. Two dialects coexist: the original path-and-fallback parser, which is feature-frozen, and the {{= ...}} expression dialect that adds arithmetic and rounding. See Workflow Template Expressions.

Status lifecycle: DRAFT → PUBLISHED → SUPERSEDED (replaced by a newer publish) → ARCHIVED (per-name, explicit). Publishing atomically retires the previously published version of the same name to SUPERSEDED, so exactly one version per name is PUBLISHED at any time — enforced by a partial unique index (idx_workflow_one_published_per_name). A superseded version is out of the external-executor path (external executors always resolve the latest published version by name), but it is not dead: pins that point at it keep executing it — subworkflow pins, and character recipe pins through execute-by-id, which accepts PUBLISHED or SUPERSEDED so republishing a workflow cannot break live character sessions (brain logs a warning; the graph_digest check is the integrity gate, and DRAFT and ARCHIVED stay rejected). It can also still be sandbox-run by id (brain logs a warning so testing an outdated version is a deliberate act). Only drafts are deletable; published or superseded versions must be archived first, and archived versions are retained — they keep the name reserved and preserve history.

A workflow’s contract (input_schema, pricing, output_schema) is HTTP-served and is the single source of truth that the UI surfaces render from and that sirloin re-evaluates pricing against:

  • brisket — user-facing forms, dynamic pricing, result presentation.
  • fennec — admin: the same forms plus executing DRAFT workflows, internal-only inputs, and per-node logs.
  • flank — the visual workflow editor, re-pointed to read/write brain over HTTP.
  • Foxy360 / AgentOS — the agent tool surface forwards the caller’s Clerk token to the same Brain endpoints.

Workflows also carry an optional estimated_duration_seconds — roughly how long one run takes, shown to users as the generation timer. It is a display hint, never an execution budget: nothing schedules, times out, or bills against it. Null means “not set” and consumers fall back to their per-type defaults; null on update clears it. Like the rest of a published row it is shut to the generic update path; the only write a published row takes is the backfill below.

Two HTTP surfaces: the “Beef” surface (@Controller('api/*'), API-key auth) is used by sirloin for service-to-service calls; the Clerk-authenticated /workflow/* controllers serve the human surfaces. Authoring, drafts, sandbox execution, and administrative lists default to ADMIN. Published discovery, contracts, execution, and polling permit CREATOR and above on the explicitly decorated endpoints.

Ordinary sirloin service-to-service calls use the api module (Beef surface) with an API key. The explicit exception is sirloin’s Foxy360 / AgentOS tool surface: those tools call Clerk-authenticated workflow endpoints and forward the caller’s token rather than impersonating an admin. Do not point other sirloin backend flows at Clerk-authenticated controllers.

The OpenAPI spec served at /api-json is the source for two committed, generated clients: the Go client sirloin consumes (apps/sirloin/pkg/brain-client/, regenerated with make generate-brain) and the fennec zod client (apps/fennec/src/lib/backend/generated/brain/, regenerated with make generate-brain-fennec-client). They do not regenerate automatically. After any Swagger-visible controller or DTO change, restart Brain so the live spec refreshes, regenerate both clients from the repo root, inspect the generated diffs, and validate affected consumers. See apps/brain/AGENTS.md for the sequence.

  • Adapters — one generic integration per inference provider (FAL, WaveSpeed, RunPod, OpenRouter, Hive); the model is an input on the inference node, not its own adapter. A model that breaks the per-provider assumption can get a model-specific adapter.
  • Moderation wrap — Hive visual moderation on an inference node’s inputs and outputs, enforced by the engine; a workflow disables it only via an explicit flag when its semantics require it (e.g. is_nsfw = true).
  • Pricing — rules are embedded in the contract, but the charge authority stays in sirloin; brisket shows the live price, sirloin re-evaluates server-side before deducting credits.
  • Generation mapping — normal media executions produce a Generation with generation_type = WORKFLOW, workflow_name, and workflow_version, keyed by the media_id in the trigger inputs. For a top-level production execution, media_id is an idempotency key within the workflow name: retries reuse the linked execution instead of creating or enqueueing another one. Sandbox runs omit Generation mapping unless create_generation is explicitly requested and supported.

Every sirloin-dispatched MEDIA_GENERATION execution also receives inputs.character_ids: the primary character first, then any characters the user @mentioned in the prompt, de-duplicated. This does not hold for Brain’s own execute endpoints, which accept caller-supplied inputs verbatim, or for a Flank editor test run, which only fills a default character_ids when the caller did not supply one. The workflow declares the cap as maxItems on character_ids (primary included, default 2), and a workflow accepts mentions only when its prompt input carries x-ui: "mention-editor". Sirloin validates and injects it; publishing a MEDIA_GENERATION workflow declares it in the input schema as an optional string array. Mentions stay in the prompt text as <@character:UUID> tokens. See Character mention tokens.

gen:call_model declares its config and output fields in apps/brain/src/modules/domain/workflow/nodes/gen/call-model.node.ts. Flank renders those fields from the live node catalogue through NodeConfigPanel and SchemaForm.

Config Behavior
provider Required provider selector. Remains required in moderation-only mode to preserve the shared schema and form validation, but is unused there.
model / endpoint Optional provider model and route. May be omitted in moderation-only mode.
payload Required object; its detected media targets go through the existing PRE_GENERATION moderation.
qa_skip_generation Optional boolean, default false; only allowed in sandbox Test runs. When true, an execution without the trusted sandbox Test run marker throws qa_skip_generation is only allowed in sandbox test runs before inference or downstream media delivery, even with capture_error. Sandbox test runs return without inference, scheduling, polling or provider retries. Publishing a workflow or subworkflow containing this literal flag is rejected. Upload provenance, skip rules, moderation blocks and Hive-unavailable errors are unchanged.
poll_interval_ms, max_poll_ms, timeout_ms Inference timing controls; see Execution timeouts. The executor’s timeout still applies to moderation-only execution.
capture_error Captures eligible provider failures for fallback; cannot capture moderation blocks. Unnecessary in moderation-only mode.

Use Flank Test run (sandbox), never Run or Publish. Brain persists sandbox: true for Test runs in WorkflowExecutionService.startDraft, passes it through the execution runner, and records it as ExecutionContext.isSandboxTestRun in the executor. Subworkflows inherit that trusted marker. The node checks that marker after input moderation and before calling the provider. Existing checkpoint resume and completed-child replay behavior is unchanged. The broader isSandbox flag can also be enabled by QA nodes in a non-test execution and does not authorize skipping inference. See apps/brain/src/modules/domain/workflow/services/workflow-execution.service.ts, engine/executor.service.ts, engine/context.ts, and nodes/flow/subworkflow.node.ts under the workflow module.

Sandbox moderation-only success returns { qa_skip_generation: true, moderated: true, outputs: [], output_url: "", output_urls: [], provider }, with the configured provider and no provider task ID. moderated means the existing moderation gate completed, including its normal skip rules and payloads with no media targets. Empty media fields support downstream templates without claiming a generated result. With the flag absent or false, inference behavior and output are unchanged. The new catalogue fields use existing generic config/schema DTOs; the Swagger-visible DTO and OpenAPI shapes are unchanged.

The executor layers three budgets. Every wrapper is clamped to the remaining whole-run deadline so a single node cannot outlive the execution.

Budget Default Notes
Whole-run (totalTimeoutMs) 60 min Hard ceiling for the execution (apps/brain/src/modules/domain/workflow/engine/executor.service.ts:144-145).
Ordinary per-node (nodeTimeoutMs) 5 min Default executor wrapper for nodes that do not declare their own budget.
call_model poll / wrapper 10 min Owned by the node’s timeout_ms / max_poll_ms inputs. Unset max_poll_ms follows timeout_ms. Shared helpers live in apps/brain/src/modules/domain/workflow/nodes/gen/call-model-timeouts.ts:1-16.

Long-running nodes opt in via BaseNode.executionTimeoutMs:

  • call_model — returns its own poll budget so the ordinary 5 min node ceiling cannot preempt a documented 10 min (or author-raised) inference wait.
  • subworkflow — uses the parent’s remaining deadline and passes totalTimeoutMs + signal into the nested ExecutorService.execute, so child inference and persistence cancel when the parent budget ends (not a fresh 60 min child window).

Not in scope for v1: HTTP API-triggered cancellation of in-flight executions (engine deadline enforcement and abort-signal propagation, including into subworkflows, are supported), a first-class Templates concept, and brain-owned secrets management.

These are standing architectural decisions; follow them when adding or reviewing code.

  • Domain services are DTO-unaware. Services under modules/domain/*/services/ operate on domain interfaces (e.g. IExample) and throw transport-agnostic domain exceptions (see Brain error handling). They never import request/response DTO classes.
  • Mapping happens at the edge. Controllers translate request DTOs into domain-shaped inputs inline and build responses via static, synchronous Response.toDto(entity) methods on the DTO class. Canonical example: apps/brain/src/modules/domain/generation/controllers/examples.http.controller.ts.
  • The api module is a presentation layer, and is a deliberate deviation. modules/domain/api/ owns the Beef (sirloin-facing, API-key) surface. When building a wire DTO requires async cross-module reads — something a static toDto cannot do — the mapping lives in an injectable presenter/assembler service inside the api module (e.g. BeefExampleService, which decorates examples with their workflow contract and family variant, memoizing lookups per request). This keeps domain services DTO-unaware and controllers thin; the DTO-awareness is confined to the layer whose only job is producing the wire format. Do not replicate this pattern inside domain modules — if a domain module needs composition, compose domain objects there and map in the controller or api layer.
  • Cross-module access goes through exported services, not repositories. A module reaches into another module via that module’s service layer (e.g. WorkflowService.getLatestPublished), never by injecting the other module’s repository directly. Repositories are module-private so caching, validation, and access rules stay in one place.

Workflow examples: input visibility/editability

Section titled “Workflow examples: input visibility/editability”

Per-input visible/editable flags for workflow-backed examples are not stored on the example. They live in WorkflowExampleConfig (one row per workflow, edited in fennec’s Workflow Example Config page) and are resolved live when brain serves examples to sirloin: BeefExampleService ships a top-level workflows: { [name]: { contract, input_overrides } } dictionary covering each example’s own workflow plus all of its family’s member workflows. Consequences:

  • Config edits apply retroactively to every example of that workflow, including sirloin’s hidden-value strip (retroactive IP protection) and authored-value injection at dispatch.
  • Family-backed examples get per-variant flags: the frontend picks the entry for the workflow actually resolved by the user’s axis selection.
  • A missing config means all inputs are visible and editable.
  • There is no per-example snapshot: the former generation_example.input_overrides column was dropped before the feature reached production, and the config is the single source of truth.

Generation and provider decisions live under docs/src/content/docs/decisions/ as they are migrated.

Brain uses BullMQ/Redis for async generation workflows and PostgreSQL via Prisma for the fennec schema.

Media generation paths that handle provider videos and source media use disk-backed temporary files instead of whole-file buffers where practical. The storage module owns temporary directory cleanup and bounded file I/O through TempFileService and FileIoLimiterService; video persistence, provider preupload, reference-video trimming, and first-frame extraction use path/stream APIs in apps/brain/src/modules/application/storage/services/ and apps/brain/src/modules/application/video/services/.

File I/O limiter state is exported as OpenTelemetry gauges for operations checks; see Observability.

Every command under apps/brain/src/cli/commands/ reports failure through its exit code, so an ops one-shot can be wrapped and checked ($?). A non-zero exit means the run did not do all of its work, and the log line CLI application finished with errors accompanies it.

Two cases exit non-zero without anything throwing, because the command finishes having quietly lost work: backfill-onboarding-metadata when a record failed or a storage read broke (an object that is merely absent is not a failure), and cleanup-video-example-models when the CSV names example IDs that do not exist. backfill-onboarding-metadata also counts reference paths whose type it cannot recognize and reports them as Unrecognized paths; those do not fail the run, so read that number before concluding a run did anything.

No command accepts positional arguments. backfill-onboarding-metadata dry-run — dashes omitted — is rejected rather than silently treated as a live run.

node dist/cli.js backfill-workflow-estimated-duration fills null estimated_duration_seconds values with the p75 of recent completed top-level, non-sandbox runs (defaults: 30-day window, ≥5 samples; --dry-run, --window-days, --min-samples). It runs against whichever database the Brain instance is configured for — “backfill prod from prod history” means executing it on the prod deployment. The newest draft of each name is filled alongside the published row, so the next publish cannot carry null over the value just written. Only nulls are ever written, so reruns are idempotent and an author-set value is never overwritten. Per-row durations are capped at 3600s and rows with completed_at <= created_at dropped, so crash-recovery re-enqueues and clock skew cannot inflate the estimate. A non-zero exit means some names failed or lost a write race — rerun; the report tells apart “already set” from “history but no live rows”.

  • cd apps/brain && pnpm lint
  • cd apps/brain && pnpm tsc
  • cd apps/brain && pnpm test
  • cd apps/brain && pnpm start:dev