The construction seam is the one place in a system where all the abstractions are real
simultaneously. Every seam you declared in core/contracts.ts exists as a type; the construction
root is where they become objects, wired to each other in the only order the dependency graph allows.
If you can hold the construction root in your head, you can hold the whole system in your head. This
chapter looks at how v1 and v2 construct themselves — what each wires, what each exposes, and why
v2's construction root has sharper separation between wiring and runtime even as it adds new explicit seams.
A construction root is a function (or class, or module) that has one job: instantiate every
component and wire the dependencies. It is the only place in the system where imports touch
implementations rather than interfaces. Outside the construction root, a module that needs a
SandboxApi receives one — it never constructs one. Inside the construction root, you call
new LocalSandbox(). The discipline matters because it means that every other module is testable
with a stub: you pass a different implementation at construction time and the rest of the system
does not know or care.
This is dependency injection without a framework: the function signature is the injection point. The
v2 header makes this explicit: "Every dependency is injectable so a test can drive it with no network
(inject model + a scripted stream_fn)." The defaulting pattern reinforces it: create_runtime()
with no arguments yields a locally drivable runtime; create_runtime(store=test_store, model=stub_model)
yields a fully instrumented one. The construction seam is also the testing seam.
Both v1 and v2 call this function create_runtime. Both return a Runtime object that is the entry
point for everything that happens at runtime. The differences are in what Runtime exposes and what
create_runtime wires — and those differences reveal a design evolution.
v1's harness.ts is described in its header as playing "the same role for Cy" as flue's own
Harness: it "wires the REAL component modules into a drivable Runtime." The wiring is:
v1 create_runtime wires:
AgentExecutionStore ← in-memory by default, injectable
ModelProvider ← resolve_model / resolve_api_key / stream_fn
SandboxApi ← LocalSandbox by default, injectable
SessionEnv ← cwd-scoped view of the sandbox per session
PersonDirectory ← in-memory stub by default
ObservableRootSink ← the event bus (root fan-out + subscribeEvents)
ChatRuntime ← the adapter registry (Slack / CLI / web)
Session factory ← get_session(key) → cached durable Session
The Runtime handle v1 returns carries those subsystems as fields and provides lifecycle methods:
initialize() and shutdown() for the chat adapters, get_session() and drop_session() for the
session map. The runtime is a long-lived object that manages all active sessions in an internal map.
Notice what that means: the session map is inside the runtime. When a new message arrives, the
router calls runtime.get_session(key) and gets back a Session — constructed on the first call,
retrieved from the internal map on subsequent calls. This is simple and it works, but it binds two
concerns together: the runtime is both "where components are wired" and "where sessions are tracked."
v1 Runtime interface (selected fields):
tenant_id: str
store: AgentExecutionStore
model: Model[Api]
sandbox: SandboxApi
session_env: SessionEnv
tools: tuple[AgentTool, ...]
model_provider: ModelProvider
sink: ObservableRootSink ← the event bus (v2 drops this)
get_session(key, on_event=None) ← builds or retrieves a cached Session
drop_session(key): None
initialize(): Coroutine[None] ← starts chat adapters
shutdown(): Coroutine[None] ← stops chat adapters
The runtime has eight concerns. Some — initialize, shutdown, get_session, drop_session — are
session-lifecycle and adapter operations. Others — store, model, sandbox — are infrastructure
the sessions use. They coexist in the same object because the v1 design did not yet distinguish "the
wired component graph" from "the thing that drives conversations."
The consequence is coupling at the boundary: to test a session's behavior, you need a runtime, which means you need adapters initialized. To test adapter initialization, you need a session factory. The construction root is entangled with the operational lifecycle.
v2's runtime.ts is described in its header as "the minimal analog of v1's harness.ts." What is
dropped: "the chat-adapter registry, the people directory, the event bus / sink fan-out +
subscribeEvents, per-session model/credential override complexity."
What v2 adds: Coordinator, WorkflowRegistry, ToolRegistry, and SessionFactory — four
primitives that did not exist as explicit seams in v1. Here is the actual dependency graph from the
construction root:
Runtime ← create_runtime(config)
├─ AgentExecutionStore durable { sessions, submissions }
├─ ModelProvider → Model + StreamFn the LLM seam (one shared default model)
├─ Coordinator(store.submissions) drives a Session's turn FSM; resume-with-repair
├─ WorkflowRegistry registered workflows → the `workflow` tool
├─ ToolRegistry name → ToolFactory (a session selects tools by name)
├─ sandbox_resolver(tenant_id) → SandboxApi ONE sandbox backend per tenant
└─ SessionFactory(all of the above)
└─ Session = f(SessionContext, SessionSpec)
• tools: list[AgentTool] = ToolRegistry.build(spec.tools, ToolContext)
The key structural change isn't that get_session disappears — it's that the session cache moves out of the Runtime and into SessionFactory. get_session survives as a thin delegate to factory.get_or_create_root; the factory is the seam, the runtime method is just the edge that calls it. Every session origin — a human prompt to the root,
a workflow dispatching a node, a subagent being delegated to — flows through SessionFactory. There
is one path for session construction, not three separate call sites.
Here is the v2 Runtime interface:
# apps/runtime/src/runtime.ts
from typing import Protocol
from collections.abc import Callable
class Runtime(Protocol):
tenant_id: str
store: AgentExecutionStore
model: Model[Api]
sandbox: SandboxApi
session_env: SessionEnv
tools: tuple[AgentTool, ...]
model_provider: ModelProvider
coordinator: Coordinator # new in v2; drives all turns
registry: WorkflowRegistry # new in v2; registered workflow defs
def session_key_for(self, thread_id: str) -> str: ...
def create_workflow_executor(self, storage_key: str, depth: int = 0) -> WorkflowExecutor: ...
async def drain_runnable(self) -> None: ...
async def resume_pending(self) -> None: ...
async def get_session(self, session_key: str, on_event: Callable[[RunEvent], None] | None = None) -> Session: ...
def drop_session(self, session_key: str) -> None: ...get_session still exists on v2's Runtime, but it is a thin wrapper over factory.get_or_create_root
— not the session-map logic it was in v1. The session cache lives inside SessionFactory, not inside
the runtime. The runtime is a component graph that delegates session operations to the factory.
No initialize / shutdown for adapters. The chat adapter is not part of the construction root —
it is passed in by the router at the point where a session is started. This means you can construct
a Runtime, run create_runtime(), and drive a session from a test without touching any platform
adapter code.
The construction code in runtime.ts names SessionFactory explicitly as the system's single
construction seam:
# apps/runtime/src/runtime.ts
# The GLOBAL session factory — the SINGLE construction seam for every session origin
# (a root chat thread, a workflow anchor/node, a dispatched subagent). It owns the
# per-process session cache, the context-bound toolset (tools are FACTORIES run over a
# ToolContext), lineage (deriving + persisting a child's SessionContext), and
# resume-with-repair on a live root build.
factory = SessionFactory(
store=store,
stream_fn=stream_fn,
system_prompt=system_prompt,
model=default_model,
get_api_key=get_api_key,
sandbox_resolver=sandbox_resolver,
sandbox_root=sandbox_root,
registry=registry,
coordinator=coordinator,
tenant_id=tenant_id,
tool_registry=tool_registry,
)Everything the factory needs is injected at construction time: the store, the stream function, the
model, the key resolver, the sandbox resolver, the workflow registry, the Coordinator, the tenant,
and the tool registry. The SessionFactory is the point where all the pieces converge. After this
line, every new session is a function call on factory, not a fresh wiring exercise.
In v1, session construction was implicit: get_session(key) built a Session inline, reaching
directly into the runtime's own model, sandbox, and tools. The construction logic was repeated at
every call site. In v2, construction is explicit and named — the SessionFactory is the seam.
The layered architecture makes this visible:
EDGE Chat (Web | Slack | Discord) inbound → session key
RUNTIME create_runtime(config) → Runtime component graph
CONSTRUCT SessionFactory ← the single construction seam
EXECUTE Coordinator drives submissions to settlement
STATE AgentExecutionStore sessions + submissions
MODEL ModelProvider LLM seam
TOOLS execute_code · workflow · ...
SessionFactory sits between the runtime (which wires the graph) and the executor (which drives
turns). It is the seam where "I have a component graph" becomes "I have a session."
One subtle design in v2 that the construction root makes visible: tools are not static values. In
v1, the toolset was built once per runtime from a profile and a context. In v2, tools are factories
registered under names. A session's spec.tools is a list of names; the ToolRegistry builds the
actual list[AgentTool] from those names at construction time, passing a ToolContext to each factory.
# apps/runtime/src/runtime.ts
tool_registry = create_default_tool_registry(
include_workflow=len(registry.names()) > 0
)
for tool in config.tools or []:
tool_registry.register(tool.name, lambda t=tool: t) # static tool → context-free factory
for name, factory_fn in (config.tool_factories or {}).items():
tool_registry.register(name, factory_fn) # dynamic tool → context-bound factoryThis matters for context binding. An execute_code tool closes over ctx.sandbox — it needs to
know which sandbox this session uses. A post_message tool closes over ctx.chat — it needs the
platform binding. Static tools (those that don't depend on context) get wrapped as context-free
factories. Context-bound tools get registered as proper (ToolContext) => AgentTool functions.
The consequence: every session gets a toolset assembled from its own context. If two sessions run
with different sandbox backends, their execute_code tools point at different sandboxes. The
ToolRegistry enforces this without any session needing to know about the others.
One more piece the construction root owns: the session key derivation. Every durable session has a
storage key — the stable address in AgentExecutionStore where its transcript lives. The v2 runtime
exposes:
def session_key_for(self, thread_id: str) -> str:
return f"{self.tenant_id}:{DEFAULT_HARNESS}:{thread_id}"These are two levels: session_key_for derives the session name from the inbound thread id; create_session_storage_key wraps a session name into the durable storage key. The name feeds the key — it is not a wrapper of it:
# apps/runtime/src/durable/session-identity.ts
import json
SESSION_STORAGE_PREFIX = 'agent-session:'
def create_session_storage_key(
instance_id: str,
harness: str,
session: str,
) -> str:
return f"{SESSION_STORAGE_PREFIX}{json.dumps([instance_id, harness, session])}"
# yields: 'agent-session:["tenantId","default","threadId"]'The key format is part of the durable contract — the store tests pin the exact string. The runtime owns the mapping from the inbound identity (a thread ID from the chat platform) to the durable address (the storage key). This derivation happens once per session, at construction time, and the same derivation function works for root sessions, child task sessions, and workflow anchor sessions:
def create_task_session_name(parent_session: str, task_id: str) -> str:
return f"task:{parent_session}:{task_id}"
# child key: 'agent-session:["tenantId","default","task:threadId:task123"]'The task: prefix is reserved — assertPublicSessionName rejects any attempt to create a public
session with that prefix. This structural enforcement is part of what makes the construction seam
trustworthy: the wrong identity is unrepresentable, not just discouraged.
A construction root that does too much is a design smell. If create_runtime also manages chat
adapter lifecycle (initialize, shutdown), that is two responsibilities in one function. The
consequence is subtle: it becomes harder to test runtime construction separately from adapter
initialization, and it becomes harder to see what the runtime actually depends on.
v2 separates these concerns precisely. create_runtime wires the component graph and returns it. The
chat adapter is not part of that graph — it is passed in by the router at the point where a session
is started. This means you can construct a Runtime without any chat adapter and drive it directly
from a test. The construction seam is also the testing seam.
The practical payoff appears in the test patterns: a test that wants to drive a session with a fake
stream function calls create_runtime(stream_fn=fake_stream_fn). A test that wants to inspect the
durable store calls create_runtime(store=test_store). No adapter infrastructure, no event
bus setup, no platform-specific initialization. The construction function's optional parameters are
the injection points, and every one of them defaults to something useful.
Once you have one construction seam for every session origin, you get an invariant for free: every session is built the same way. That means:
- Every session gets the same tools (the
ToolRegistrydecides which are available; the session spec selects from that set by name). - Every session's durability works the same way (all state is in
AgentExecutionStore; none is in process memory outside the factory's session cache). - Every session can be resumed by a fresh process (the
Coordinatorreconstructs it from the store; theSessionFactoryre-hydrates the transcript).
In v1, some of these invariants held for root sessions but not for subagents, which were constructed
inline inside the RunAgent primitive with a narrowed context but not via the same factory path as
root sessions. v2 makes them hold everywhere, because "everywhere" now means "one path through
SessionFactory."
The resume_pending method on Runtime shows this in action:
async def resume_pending(self) -> None:
await register_pending_anchor_runners()
await coordinator.resume_pending(lambda key: factory.resolve(key))When a process restarts, resume_pending asks the Coordinator to resume every unsettled
submission. For each one, it calls factory.resolve(key) — the same factory that builds new
sessions builds resumed ones. The resumed session is indistinguishable from a newly constructed one;
the construction seam is also the recovery seam.
You now understand what a construction root is, how v1 and v2 differ in what they wire and expose,
and why SessionFactory as a single construction seam makes the system's invariants hold uniformly.
The contracts from Chapter 3 are the vocabulary; the construction root is where that vocabulary
becomes a running system. The next chapter descends into the turn itself: what happens between "the
prompt arrives" and "the result is posted."