Agents & Workflows
🧩 The control plane and data plane — how a new workflow ships as a graph plus a prompt, not a service. Built bottom-up: ELI5 first, then the system-design view, then each piece in depth with the real code.
The case-study platform supports N independent workflows — discover, enrich, score, outreach, learn — on shared infrastructure, without standing up a service per flow. The design follows from one rule: a new workflow ships as a graph plus a prompt, not as a service.
This page covers how those workflows are defined and reached — the control plane (graph identity) and the data plane (routing and worker pools). The third plane, observability, has its own deep-dive reference.
It is built bottom-up: a plain-language ELI5 first, then the system-design view, then each piece in depth with the real code. Every part below is generated by LlamaIndex, grounded in the real source — the graph registry, the dispatcher's routing table, the typed graph client, and the architecture spec.
Explain it like I'm 5
Think of the case-study platform as a shared workshop, not a row of separate sheds. Each workflow—discover companies, enrich contacts, score leads, compose emails—is a specific task that needs certain tools. Instead of building a dozen tiny apps (one per task) that each require their own setup, the workshop keeps all tools under one roof, but organized into workbenches. Each workbench handles a type of task. If one task goes wrong, only that bench is affected; the rest keep running. Without this shared setup, you'd duplicate effort, scatter debugging across many tiny apps, and let one noisy task crash everything. The platform manages routing, keeps each workflow stable, and contains failures so the whole system stays reliable.
The system-design view
The case-study platform is built on three distinct planes defined in the constitution (specs/case-study/mission.md:17-27). The control plane owns graph identity via a registry and the routing contract (WORKER_ROUTES). Graph identity means every workflow has one stable name that maps to a single graph definition and input shape. The routing contract is the rule that turns an incoming request into a graph invocation: which graph runs, on which worker pool, with which inputs. Every call goes through that contract—"there is no other way in." The registry is deliberately cheap to load: it has no LLM dependencies, no database dependencies, and avoids compiling graph modules at load time. The data plane owns per-capability worker pools: EMAIL, CLASSIFY, DISCOVERY (extendable). DISCOVERY owns the consultancy, GitHub, Ashby, YouTube graphs plus the company/product/contact intelligence surface. A noisy graph stays inside its own pool, so the blast radius is at the pool level, not the platform. The observability plane owns a distributed run tree that crosses the TypeScript ↔ Python hop, ensuring one user action shows up as one debuggable thing across all three planes. Its mechanism is header propagation: the TS-side langgraph-client.ts calls buildTraceCarrier(), which uses propagation.inject(context.active(), carrier) for W3C trace context (traceparent, tracestate) and injectLangSmithHeaders() for LangSmith headers (langsmith-trace, baggage). The Python worker's _tracing.py:parse_inbound(headers) reads the same two LangSmith-family headers to continue the caller's run tree or self-root if absent. Instrumentation is gated by env vars (OTEL_EXPORTER_OTLP_ENDPOINT, LANGSMITH_TRACING); unset means strict no-op—zero network, zero PII egress.
The architecture rejects two alternatives explicitly named in the out-of-scope section of mission.md: "per-flow microservices, monolith-with-branching-prompts patterns—these are the rejected alternatives the audio guide explicitly contrasts against the chosen design." A monolith-with-branching-prompts would couple every workflow into a single codebase with conditional branching, making a single noisy or buggy prompt affect all flows—the blast radius becomes the entire platform. Per-flow microservices would require standing up a separate service per workflow, incurring the operational cost of many redundant runtimes, shared infrastructure duplication, and cross-service orchestration overhead. Instead, the case-study platform uses a shared LangGraph runtime fronted by the control-plane registry plus per-capability worker pools. The deciding axis is failure-domain cost, not lines-of-code saved or simplicity of deployment. The decision is explained in the success criteria: "Failure-domain cost (not lines-of-code saved) is the deciding axis on every architectural trade-off the page narrates." The chosen design quarantines failures at the pool granularity without multiplying the service count linearly with workflow count.
The concrete failure mode that justifies this trade-off is a noisy graph in the DISCOVERY pool—for example, a graph that makes many parallel LLM calls or hits a transient upstream API outage. In a monolith-with-branching-prompts, that graph would consume shared resources (e.g., connection pools, memory) and could degrade or crash the entire platform. In the per-flow microservice alternative, isolating that graph would require a dedicated service, adding operations overhead. With the chosen architecture, the noisy graph stays inside the DISCOVERY worker pool. The other pools (EMAIL, CLASSIFY) remain unaffected because the control plane's routing contract sends requests only to the correct pool. The observability plane's distributed run tree still works across the hop, because the same header families are injected regardless of which pool handles the request. A second failure mode exists in the observability plane itself: if the TS-side has no active LangSmith run tree (the common case, since the app uses OTel, not the LS SDK), injectLangSmithHeaders() returns early and buildTraceCarrier() injects only the W3C headers. The Python worker then self-roots—it creates its own run tree instead of nesting under the caller. This is a documented edge case, not a crash: "No active run tree → inject nothing (the backend self-roots, which is the common case)." The distributed trace is preserved via traceparent even if the LangSmith nesting is absent.
The control plane's registry also has a specific edge case: it must be "cheap to load (no LLM, no DB dependencies, no top-level graph imports)." The implementation enforces this by building the registry from a JSON generator without compiling fifty graph modules. Module-level imports inside graph submodules are FORBIDDEN. If a developer accidentally adds an import that triggers a heavy dependency at registry load time, the control plane could become slow to initialize, increasing cold-start latency for the entire platform—even if only one worker pool is needed. This would violate the constitution's invariant that the registry is cheap to load, and would be caught by the success criteria that depend on that invariant.
In depth, piece by piece
Each piece below: the plain-language take, the system-design detail, then the real code it comes from.
What a workflow is here
In plain terms. Think of each workflow in the case-study platform as a standardized recipe card. The GraphSpec is that card—it assigns the workflow a fixed, public name (its assistant_id) so every part of the system can find it without caring where the recipe file lives on the server. The module field points to the file with the actual steps, but the platform talks to the workflow only by its public name. That decouples the name from the file path: you can move the recipe to a different folder without breaking anything. The resumable flag marks workflows that must survive a crash—like a recipe that can be paused and resumed—so their progress is saved. Without this stable‑name system, renaming a file would silently break every caller, and the platform would be fragile.
System design. A workflow in the case-study platform is a single GraphSpec entry in the GRAPHS list inside registry.py. Each spec holds four explicit fields: assistant_id (the public, stable string that the TypeScript client and the backend use to address the graph), module (the dotted Python import path like "graphs.opportunity_brief_graph"), compiled_attr (default "graph", the attribute on the module that holds the pre‑compiled CompiledGraph), and builder_attr (default "build_graph", a callable that takes a checkpointer and returns a CompiledGraph). A fifth property, resumable, appears in doc‑comments (e.g. drift_monitor is annotated resumable=False) but is not a dataclass field – it is a semantic flag that determines whether the graph is invoked with a stable thread_id for checkpoint persistence. The registry is the single source of truth for graph identity; both the local langgraph dev server and the FastAPI/Containers runtime read from it. The backend imports GRAPHS directly and compiles every spec at lifespan startup, while a companion script generates langgraph.json from the same list without importing any graph module (keeping the registry dependency‑free). Every invocation – whether from the TS client via runGraph("extract_stack", ...) or from a cron job like drift_monitor – addresses the graph by its assistant_id, never by a file path or import symbol.
The trade‑off centralised here is that assistant_id acts as a stable, immutable indirection layer between the control plane (which owns identity and routing) and the data plane (which owns execution). By decoupling the public API from the module structure, the platform can rename, split, or merge graph modules without ever touching the callers: the extract_stack assistant_id can remain the same even if its implementation moves from graphs.sales_tech_feature_graph to graphs.employer_intel_graph (as happened in the registry). This indirection also enables the WORKER_ROUTES table in route_for.py to map each assistant_id to a specific worker pool (CLASSIFY, DISCOVERY, EMAIL) – a noisy or expensive graph stays inside its own pool, limiting blast radius to the pool level rather than the whole platform. The resumable flag further refines the trade‑off: stateful graphs (e.g. those needing mid‑run recovery) are invoked with a stable thread_id and pay the storage cost for checkpoints; stateless graphs like drift_monitor explicitly set resumable=False so that their runs never write checkpoint rows, keeping storage O(1) per tick.
A natural rejected alternative would be to dispense with assistant_id entirely and address graphs by their module path or a file‑derived name. That approach would tie the public contract to internal directory structure, making it brittle under renames and impossible to route across worker pools without parsing import trees. Another alternative would be to embed identity directly inside each graph module (e.g. via a decorator or a module‑level variable), but that would scatter the control plane across dozens of files and force the JSON generator to import every module – violating the “module‑level imports inside graph submodules are forbidden” rule because the generator would need to run graph code to discover their names. The registry centralises all identity in one place, imports nothing, and remains cheap to load.
A concrete failure mode is an assistant_id collision – if two GraphSpec entries share the same assistant_id, the later entry silently overwrites the earlier one during registry loading, causing one graph to be unreachable and all its callers to unexpectedly run the wrong graph. This is especially dangerous because runGraph("contact_discovery", ...) in TypeScript would now invoke the duplicate, and no compile‑time check catches it. Another edge case arises with resumable: if a graph that is inherently stateless (like the drift_monitor scan) is accidentally marked True (or the default is assumed), every cron tick would write checkpoint rows that are never read, silently bloating storage. Conversely, a graph that genuinely needs resumption (e.g. a long‑running discovery with resumable=False) would lose all progress if the worker is killed mid‑run – there is no automatic recovery because the runtime never receives a stable thread_id.
The GraphSpec dataclass defines every workflow by its public assistant_id, importable module path, and optional builder/compiled attributes.
@dataclass(frozen=True)
class GraphSpec:
assistant_id: str # public id used in /runs/wait, langgraph.json, TS client
module: str # dotted import path, e.g. "graphs.email_compose_graph"
compiled_attr: str = "graph" # module-level symbol for langgraph.json
builder_attr: str | None = "build_graph" # callable(checkpointer) -> CompiledGraph
resumable: bool = False # True only for durable checkpointed workflows
version: str = "v1"
candidate_version: str | None = None
canary_percent_env: str | None = None
The graph registry — the control plane
In plain terms. Think of the central list of automated tasks as a single master phonebook for the case-study platform. Each task has one line with its unique name and where to find it. Adding a new task means writing just one line. At startup, the platform automatically checks that no two tasks share the same name — that would be like two employees with the same extension, causing chaos. This phonebook is deliberately kept lean, containing only names and locations, so it can be quickly printed without loading heavy machinery or databases. This prevents expensive slowdowns every time the list is generated.
System design. The case-study platform’s entire graph identity is owned by a single frozen tuple, GRAPHS, defined at module scope in backend/agentic_sales/registry.py. Each element is a GraphSpec frozen dataclass with four fields: assistant_id (the stable public name used in /runs/wait, langgraph.json, and the TypeScript client), module (the dotted import path to the graph’s Python module, e.g. "graphs.opportunity_brief_graph"), compiled_attr (the module-level attribute that holds either a pre‑compiled graph instance or a builder reference), and builder_attr (a nullable string naming a callable that accepts a checkpointer and returns a CompiledGraph; None means the module exports only a pre‑compiled instance). The tuple is the sole registry consumed by both runtimes: the local langgraph dev server compiles graph definitions via a generated langgraph.json, and the FastAPI/Cloudflare Containers backend (backend/app.py) imports GRAPHS directly at startup and compiles each spec in a lifespan hook. Adding a new workflow is a one‑line edit — drop a new GraphSpec row into GRAPHS and run make gen-langgraph-json.
The mechanical flow is a simple routing contract. The TypeScript client (lib/langgraph.ts) constructs a single shared Client from @langchain/langgraph-sdk, configured with apiUrl pointing at the Render FastAPI backend. Every graph invocation calls runGraph(assistantId, input, options), which passes the assistant_id string directly as the assistant_id parameter to Client.runs.wait(). The backend receives that string, looks up the corresponding GraphSpec from the in‑memory GRAPHS tuple, resolves the module path, and invokes the compiled graph (either the pre‑compiled object from compiled_attr or the builder from builder_attr). No dynamic discovery or per‑workflow configuration files exist — the mapping from assistant_id to module is fully deterministic and statically declared. The mission.md’s “control plane” principle is enforced: “every workflow has one stable name that maps to one graph definition and one expected input shape.”
The central trade‑off is simplicity of identity governance against the cost of keeping the registry dependency‑free. By concentrating all graph registration in a single dataclass tuple, the platform avoids any need for lazy loading, dynamic registration endpoints, or per‑graph config files. The cost is a strict import discipline: registry.py “must import nothing from agentic_sales.*_graph at module top level” so that the JSON generator (backend/scripts/gen_langgraph_json.py) can resolve every row without compiling any graph modules — which would otherwise drag in optional LLM or database dependencies that might not be present in the generation environment. The mission.md emphasises that “the control plane is cheap to load — no LLM dependencies, no database dependencies.” This constraint is enforced by developer convention (no stray imports) and protected by the make gen-langgraph-json pipeline, which would fail if a graph module imported something expensive before the spec was read.
The source explicitly implies the rejected alternative: rather than a single registry, the platform could have placed graph configuration in separate per‑workflow files or relied on filesystem scanning to discover graphs. The mission.md’s “one place that owns graph identity and the routing contract” and the comment “To add a graph: drop a row in GRAPHS … That is the only edit needed” contrast with a design where each graph is self‑registering or where the langgraph.json is manually maintained. The single‑tuple approach makes the control plane inspectable by a simple Python import — every graph’s identity, module path, and builder method is visible in one file, which reduces the surface for misconfiguration and makes onboarding trivial.
A concrete failure mode is a duplicate assistant_id introduced by a typo or merge conflict. The module‑level assertion at the tail of registry.py catches this immediately: assert len({g.assistant_id for g in GRAPHS}) == len(GRAPHS), "duplicate assistant_id in GRAPHS". Without this guard, both runtimes would silently route to the last‑registered builder (or, depending on hash‑based lookup, produce undefined behaviour). Another edge case concerns the builder_attr field: if a graph sets builder_attr=None (the pre‑compiled form), but the module does not actually export a graph attribute matching compiled_attr, the backend will raise an AttributeError at startup when it tries to access getattr(module, compiled_attr). The platform depends on developer discipline to keep the two in sync; there is no automated validation that the attributes exist at import time (since the graphs themselves are not imported until the backend lifespan).
The graph registry docstring and duplicate-check assertion, explaining the single source of truth, how to add a graph, the import-time assertion, and why the module stays dependency-free.
"""Single source of truth for the case-study LangGraph registry.
Both runtimes read graph identity from this file.
To add a graph: drop a row in ``GRAPHS`` and run ``make gen-langgraph-json``.
That is the only edit needed.
Keep this module dependency-free — it must import nothing from
``agentic_sales.*_graph`` at module top level so the JSON generator can build
the registry without compiling 50+ graphs (and without dragging in optional
LLM/DB deps that some graph modules import at import time).
"""
GRAPHS: tuple[GraphSpec, ...] = (
# … every assistant GraphSpec row …
)
# Fail loudly at import time if a typo introduces a duplicate id
assert len({g.assistant_id for g in GRAPHS}) == len(GRAPHS), (
"duplicate assistant_id in GRAPHS"
)
Invoking a workflow
In plain terms. Think of the typed client as a mailroom that sends a package to a specific department (a graph) through a single dispatch door (the dispatcher). It writes the recipient’s name (the graph’s stable name), includes a one-time secret badge (the bearer token) for entry, and also staples a tracking label (the W3C trace-context) plus a return-address note (LangSmith headers) so the department can show its work as part of the same delivery chain. Without those headers, the department’s actions would appear as an isolated, orphaned job—disconnected from the original request—making it impossible to trace the full journey.
System design. The case-study platform’s graph invocation flows through a single shared Client instance from the LangGraph JS SDK, constructed in index.ts. The client is configured with apiUrl set to LANGGRAPH_DISPATCHER_URL (which points to a single Render FastAPI backend), apiKey: null to suppress the SDK’s environment-variable API-key lookup, and defaultHeaders containing an Authorization: Bearer <LANGGRAPH_DISPATCHER_SECRET> header sent on every request. The actual call is made via runGraphWithMeta, which calls langgraphClient.runs.wait with the assistantId (used as the graph identifier) and the input payload. A AbortSignal.timeout is combined with any caller-provided signal to enforce a per-graph timeout from the TIMEOUT constant (e.g., 60 s for defaults, 180 s for thinking graphs). The onRequest hook – executed at fetch time inside the current OpenTelemetry span – calls buildTraceCarrier(), which in turn runs propagation.inject(context.active(), carrier) for W3C traceparent/tracestate and then injectLangSmithHeaders(carrier). The latter attempts getCurrentRunTree(true) to obtain a LangSmith run tree; if one is active (e.g., inside a traceable() or wrapOpenAI scope), it injects the langsmith-trace and baggage headers so the Python backend’s run can nest under the caller’s LangSmith tree. The constructed headers are merged into the request’s Headers object before the SDK issues its internal fetch.
The design deliberately reuses one SDK client for all graph invocations rather than constructing a fresh client per call. This is the “idiomatic LangGraph JS pattern” described in the source: it avoids the overhead of rebuilding the HTTP connection pool and auth setup on every request. The onRequest hook provides a per-request customization point – trace headers – without sacrificing the shared client’s efficiency. Additionally, maxRetries: 0 is chosen because the backend’s /runs/wait endpoint has no idempotency key, and the Render Agent Server already retries transient errors internally; a client-side retry could start a duplicate expensive graph run. The maxConcurrency setting (from the MAX_CONCURRENCY constant) applies backpressure so the process does not flood the backend.
The most natural alternative – creating a new Client per invocation – is explicitly rejected by the source’s comment explaining the pattern. Another alternative visible in the codebase is a raw fetch path used by streamGraph for the bespoke POST /runs/stream SSE endpoint, which manually builds headers via buildOutboundHeaders(). That path exists because the standard LangGraph JS SDK client has no streaming method compatible with the platform’s backend protocol. The choice to use the SDK client for the synchronous runs.wait path and a separate raw-fetch path for streaming is a pragmatic split; the streaming interface is not provided by the SDK, so the platform must implement its own header injection using the same buildTraceCarrier() logic to keep trace context consistent.
A concrete failure mode from the observability deep-dive (section 11.1) is “Two separate trace IDs for one user action” – this occurs when a load balancer or proxy strips the traceparent header before it reaches the worker. Because the W3C trace context is injected only if the header is present in the active context (via propagation.inject), a stripped header means the Python worker’s trace becomes a root tree, not a child of the caller’s trace. Another edge case is that the LangSmith run tree injection is “best-effort”: injectLangSmithHeaders silently returns when getCurrentRunTree(true) yields undefined (the typical case, since the app is OTel-first and does not wrap every request in a LangSmith context). In that scenario the Python worker self-roots its LangSmith run, which is acceptable but means the LangSmith UI shows the worker’s tree as a standalone entity rather than nested under a Next.js parent.
The core graph invocation inside runGraphWithMeta sends the request via the LangGraph SDK client with auth, W3C trace context, and LangSmith trace headers.
const langgraphClient = new Client({
apiUrl: LANGGRAPH_DISPATCHER_URL,
apiKey: null,
defaultHeaders: LANGGRAPH_DISPATCHER_SECRET
? { Authorization: `Bearer ${LANGGRAPH_DISPATCHER_SECRET}` }
: {},
callerOptions: { maxRetries: 0, maxConcurrency: MAX_CONCURRENCY },
onRequest: (_url, init) => {
const carrier = buildTraceCarrier();
if (Object.keys(carrier).length === 0) return init;
const headers = new Headers(init.headers);
for (const [k, v] of Object.entries(carrier)) headers.set(k, v);
return { ...init, headers };
},
});
// Inside runGraphWithMeta, after signal creation:
data = (await langgraphClient.runs.wait(null, assistantId, {
input,
signal,
})) as T;
The routing contract
In plain terms. Imagine you’re a receptionist for the case-study platform. When a request arrives with a graph’s stable name, you first check a short list of sub‑worker departments: if a department has a working address and that name is on its internal list, you send the request there with that department’s secret key. If no sub‑worker matches, you route the request to the main default team under a generic label. This structured chain ensures every request reaches exactly one destination, preventing chaos where a request might be lost or handled by the wrong team—a single, predictable path for every call.
System design. The mechanism is a linear scan over an ordered list of WorkerRoute dataclass instances constructed by build_routes. Each WorkerRoute carries a prefix (e.g. "CLASSIFY" or "DISCOVERY"), a url (optional), a secret (optional bearer token), and an assistants frozenset parsed from the comma-separated env‑var strings DEFAULT_CLASSIFY_WORKER_ASSISTANTS / DEFAULT_DISCOVERY_WORKER_ASSISTANTS via _parse_assistants. The route_for function takes the assistant_id, a default_url / default_token pair, and that route list. It iterates in order: for each r, if r.url is truthy and assistant_id in r.assistants, it returns a Decision(url=r.url, token=r.secret, prefix=r.prefix). If no route matches, it returns Decision(url=default_url, token=default_token, prefix="CORE"). The Decision object is a frozen dataclass holding exactly the downstream URL, the bearer token, and a human‑readable prefix — nothing more. This means every incoming assistant_id has exactly one valid destination: either the first sub‑worker whose allowlist contains it and whose URL is active, or the default container.
The trade‑off is deliberate purity to enable parity testing and deployment flexibility. The module is deliberately free of httpx, env reads, or Worker globals — the env‑var values are injected by the caller (entry.py), so the same route_for can be tested with CPython and pytest (tests/test_route_for.py) without mocking infrastructure. Order exists only for explainability — the source explicitly states that each assistant_id lives in exactly one allowlist by construction, so ordering is not used for precedence resolution but rather to make the list inspectable. The design mirrors the TS WORKER_ROUTES array from apps/agentic-sales/src/lib/langgraph-client.ts, but lifts the logic into a shared pure Python module so that non‑TS callers (e.g. a CF Worker, bricks, future Rust binaries) can reuse the same contract without importing TypeScript. Adding a new sub‑worker becomes a wrangler‑vars edit on the dispatcher, not a Vercel redeploy.
The rejected alternative is explicitly the “planned‑but‑never‑written JS dispatcher” that lives in the planned src/dispatcher.js. The source says the path‑level decision function path_decision (for non‑/runs/wait paths) “mirrors the planned src/dispatcher.js” — meaning a separate JS dispatcher service was considered but never built. Instead, the entire routing logic was folded into this single pure Python module, which the dispatcher worker imports. This avoids spinning up a dedicated service, reduces the surface for configuration errors, and keeps the decision logic identical across the legacy TS client and the new Python dispatcher. The earlier implementation lived as an inline routeFor block inside the TS client library; moving it to a standalone Python module allowed the dispatcher to be a thin glue layer rather than a full language bridge.
A concrete failure mode is a mis‑configured allowlist that causes silent fall‑through to the default container. For example, if an assistant_id is accidentally omitted from the DEFAULT_CLASSIFY_WORKER_ASSISTANTS CSV, a request that should hit the CLASSIFY worker will instead match no route and return Decision(prefix="CORE"), sending a graph call to the wrong worker pool. No error is raised — the fallback is silent. Another edge case: if a route’s url is set but its secret is None (a valid but unauthenticated target), the Decision carries token=None, which the downstream may reject. The source also warns via the DEFAULT_* constant comments that “no overlap by construction” is an invariant; if a future edit accidentally puts the same assistant_id into both allowlists, the first route in the list wins due to the linear scan — but since order is only for explainability, that precedence is fragile and depends on the list order, which is not semantically emphasized.
The route_for function implements the sub-worker routing logic, returning a Decision that holds the target URL, bearer token, and prefix for the winning route or the default CORE.
@dataclass(frozen=True)
class Decision:
url: str
token: str | None
prefix: str # "CLASSIFY" | "DISCOVERY" | "CORE"
def route_for(
assistant_id: str,
*,
default_url: str,
default_token: str | None,
routes: list[WorkerRoute],
) -> Decision:
for r in routes:
if r.url and assistant_id in r.assistants:
return Decision(url=r.url, token=r.secret, prefix=r.prefix)
return Decision(url=default_url, token=default_token, prefix="CORE")
Worker pools and blast radius
In plain terms. Think of the case-study platform like a busy restaurant kitchen. Instead of one giant counter where every order is handled, you have two specialized stations: the CLASSIFY station (orders like sorting mail) and the DISCOVERY station (orders like researching new ingredients). Each station has a pre‑approved list of recipes it can cook—every dish belongs to exactly one station. The default CORE counter handles everything else. If a noisy blender at the DISCOVERY station breaks down, only that station’s orders get delayed; the rest of the kitchen keeps running. Without these isolated pools, one jammed task could freeze every single order, bringing the whole platform to a halt.
System design. The case-study platform isolates worker pools by capability—CLASSIFY and DISCOVERY are distinct, named sub-workloads, each with a hard-coded allowlist of assistant IDs (DEFAULT_CLASSIFY_WORKER_ASSISTANTS and DEFAULT_DISCOVERY_WORKER_ASSISTANTS). Routing happens inside route_for(): the dispatcher builds an ordered list of WorkerRoute objects (one per prefix) via build_routes(), passing the URL and secret (or None for inactive) plus a frozenset of allowed assistant_id values. When a POST /runs/wait arrives, route_for(assistant_id, ...) iterates those routes; the first route whose URL is non-None and whose allowlist contains the given assistant_id wins. If no match, the request falls to the CORE default container. By construction, each assistant ID belongs to exactly one allowlist—the docstring in route_for.py states "no overlap by construction"—so the mapping is deterministic and partitionable. The dispatcher itself is a pure Python module with zero HTTP or IO dependencies, making it trivial to test (tests/test_route_for.py mirrors the same logic in parallel across TS and Python).
The trade-off is deliberate: blast radius is at the pool level, not the platform. A noisy or jammed graph inside DISCOVERY (e.g. company_discovery with long timeouts) stays in its pool; the CLASSIFY pool and the CORE container serve their own assistants independently. This is the data-plane invariant from mission.md: "A noisy graph stays inside its own pool. The blast radius is at the pool level, not the platform." The cost is manual allowance management—adding a new assistant requires editing the comma-separated DEFAULT_*_WORKER_ASSISTANTS strings (or overriding them via env vars at deploy time) rather than an automatic registration. There is also no automatic rollback or rebalancing; canary and ramp automation (audio guide chapter 19) is explicitly NOT WIRED, and per-assistant differential sampling (e.g. high-stakes assistants always-on, bulk assistants low-ratio) is enforced manually through WORKER_ROUTES env vars with no in-code switch. The approach values explicit, inspectable configuration over dynamic adaptation.
The rejected alternative is the monolithic-with-branching-prompts pattern that the audio guide explicitly contrasts. The case-study platform rejects a single container that handles all workflows via conditional logic because that would create a shared failure domain—a runaway schema change or memory leak in one assistant could bring down every other. Similarly, the platform does not embed a fully automated canary/rollback system (chapter 19) despite the audio guide teaching the pattern; the decision is to keep the routing layer cheap (no extra agents, no metric regression daemon) and rely on manual env-var swaps for ramp.
A concrete failure mode: pool-wide outage from a single misbehaving graph. Consider a company_discovery graph that enters an infinite LLM fan-out due to a malformed user input. If that assistant ID is in the DISCOVERY allowlist, the entire DISCOVERY pool (including unrelated assistants like contact_discovery or sales_tech_outreach) may become unresponsive. However, CLASSIFY and CORE traffic continues unaffected—that is the intended blast-radius containment. Another edge case: an assistant ID inadvertently omitted from all allowlists falls to CORE, which may not have appropriate timeout or resource budgets (the TIMEOUT constants in index.ts show discovery: 180_000 vs default: 60_000) and could fail silently or overwhelm the default container. The current code has no validation that every assistant ID in the registry is covered by exactly one allowlist—an oversight that would surface only at runtime when that graph is invoked.
Per-capability sub-worker pools with exclusive allowlists — CLASSIFY and DISCOVERY each own a set of assistant_ids, and each id belongs to exactly one pool by construction, so a noisy or jammed graph is isolated to its pool rather than affecting the whole platform (CORE default).
DEFAULT_CLASSIFY_WORKER_ASSISTANTS = (
"classify_paper,classify_recruitment,classify_recruitment_bulk,"
"apply_recruitment_verdicts,classify_ai_intent,classify_country_web,"
"country_classify,country_classify_bulk,remote_classify,"
"text_to_sql,admin_chat,agentic_rag"
)
DEFAULT_DISCOVERY_WORKER_ASSISTANTS = (
"consultancies_discovery,consultancies_enrich,consultancies_features,"
"consultancies_forecasting,consultancies_nl_search,consultancies_classify,"
# … (remaining ids omitted for brevity)
)
def build_routes(...) -> list[WorkerRoute]:
return [
WorkerRoute("CLASSIFY", classify_url or None, classify_secret or None,
_parse_assistants(classify_assistants or DEFAULT_CLASSIFY_WORKER_ASSISTANTS)),
WorkerRoute("DISCOVERY", discovery_url or None, discovery_secret or None,
_parse_assistants(discovery_assistants or DEFAULT_DISCOVERY_WORKER_ASSISTANTS)),
]
See also
- Observability deep-dive — the distributed run tree that ties a single user action together across both planes.
- Read the full transcript · listen to the audio guide.