Run make dev in the terminal and a Web UI pops up — the model is responding, tools are executing, and the approval flow is waiting for confirmation. Switch to mock mode, and by swapping a single cordis.yml, the same binary becomes the CLI version of make chat. Switch models, add or remove tools, insert approval nodes — no core code changes, no re-releases. Behind this experience lies an architectural decision that treats the DI container as an assembly line and pushes every point of variability to the configuration layer.
Looking back at how this path was forged, the core tension boils down to a single pair: simplicity vs. flexibility, hardcoding vs. configuration-driven design.
Starting Point — The DI Container Is an Assembly Line, Not a Runtime Library
The intuitive first reaction when building an Agent framework is to package all services into a "black-box launcher": LLM backends, tool registries, skill indexes, and approval services all hard-coupled together, with external access only through an API. This approach is indeed simple at the start — one function handles everything. But the cost is that every time you want to swap a model, add a shell approval flow, or switch from CLI to Web, you must modify the core code.
resolve-studio's actual choice was to treat the Cordis DI container as an assembly line, not a runtime library. The core entry packages/core/src/index.ts is only about 30 lines and does three things: creates the root Context, parses YAML, and assembles the system.
import { Context } from 'cordis'
import { parseArgs } from 'node:util'
const root = new Context()
const configPath = typeof values.config === 'string' ? values.config : './cordis.yml'
root.logger('boot').info('loading composition from %s', configPath)
root.logger('boot').info('composition ready — agent harness is running')
Not a single tool or LLM backend is hard-coded here. The core responsibility is simply "read the configuration and mount plugins from the config onto the assembly line in order." Everything that is variable — LLM backends, tools, approval flows, skills, frontends — exists as Cordis plugins with zero coupling between them. Swapping a model means only changing the name field on the llm entry in cordis.yml.
The most direct benefit of this design is zero-code multi-configuration composition. The same core can produce four completely different runtime profiles through four YAML files:
cordis.yml: CLI mode, mock LLMcordis.web.yml: Web UI mode, mock LLMcordis.openai.yml: CLI mode, real OpenAI modelcordis.openai.web.yml: Web UI mode, real OpenAI model
The difference between make dev and make chat is simply that the --config flag points to different YAML files. Not a single line of core code was changed.
Pitfalls — Two-Path Resolution for Plugin Registries and npm Package Names
Intuitive Approach: Put Everything in src/plugins/, Hard-Code Imports
The easy early path is to dump all plugins into the src/plugins/ directory, then load every required plugin with a long chain of import statements in the entry or loader. This works quickly during the demo phase, but its limitations become apparent fast:
- Want to add a new tool? Modify code, recompile, redeploy.
- Want to integrate a cross-project plugin (e.g., someone else's
@resolve-studio/plugin-pse)? Either shove it into your localsrc/plugins/orimportit directly — but then the demo project acquires a code-level dependency on the production environment. - Want to integrate a pure Cordis ecosystem plugin (like
@cordisjs/plugin-timer)? Same thing — requires modifying core code.
Actual Approach: Two-Path Resolution in resolvePlugin
loader.ts implements a resolvePlugin(name) function that follows two resolution paths:
async function resolvePlugin(name: string): Promise<Plugin | null> {
const local = PLUGINS[name]
if (local) return local
try {
const mod = (await import(name)) as {
default?: Plugin
plugin?: Plugin
[key: string]: unknown
}
const plugin = mod.default ?? mod.plugin
if (plugin) return plugin
for (const value of Object.values(mod)) {
if (value && typeof value === 'object' && ('apply' in value || 'name' in value)) {
return value as Plugin
}
}
} catch {
return null
}
return null
}
Path 1: Look up the short-name dictionary PLUGINS in registry.ts. Local demo project plugins like llm-mock, agent, tool-echo, etc., are all mapped here to their actual plugin objects.
Path 2: import(name) dynamically loads by npm package name. When the short name isn't found in the local registry, name is treated as an npm package name and dynamically imported. This design lets the production environment integrate cross-ecosystem plugins by simply adding one line name: '@resolve-studio/plugin-pse' to cordis.yml, while pure Cordis plugins like @cordisjs/plugin-timer can be installed with zero resolve-studio dependencies.
Why This Is Better
Two-path resolution resolves the tension between demo self-containment and production scalability. The PLUGINS dictionary maintained in registry.ts preserves zero-configuration runnability for demos while leaving an extension channel open for production.
export const PLUGINS: Record<string, Plugin> = {
tools: ToolRegistry as unknown as Plugin,
agent: AgentService as unknown as Plugin,
'llm-mock': llmMock as unknown as Plugin,
'llm-openai': llmOpenAi as unknown as Plugin,
'tool-shell': toolShell as unknown as Plugin,
'cli-chat': cliChat as unknown as Plugin,
'web-server': webServer as unknown as Plugin,
// ... remaining tools and services
}
The name field in cordis.yml can be either a short name (path 1) or a full npm package name (path 2) — the YAML itself is unaware of this distinction. The resolution logic is encapsulated in the loader. This design makes configuration-driven operation truly possible: swapping models, adding tools, or switching frontends is just a YAML-level replacement.
Design Trade-offs — Abstract Points of Variability Early
Back to the original scenario: a developer writes an Agent toolchain, integrates DeepSeek for the first time, wants to switch to Claude for the second, and needs to add a shell approval flow for the third. If points of variability aren't abstracted early, every switch requires modifying core code. resolve-studio's solution: make every point of variability a plugin at the architecture level, then assemble with YAML.
This choice carries a cost: system complexity shifts from the code layer to the configuration layer. But on balance, this is the more worthwhile trade — configurations can be versioned, diffed, and distributed per environment, while code changes mean recompilation and redeployment.
sandbox.ts is a concrete example. It isolates tool execution at the OS level using Seatbelt (macOS) or bubblewrap (Linux), controlled by the SANDBOX_ENABLED environment variable as a master switch. This service itself is a Cordis plugin, mounted to the root Context via ctx.registry.plugin. Enabling or disabling the sandbox requires only changing one line enabled: true in the YAML configuration — no code touched.
this.enabled = config.enabled ?? process.env.SANDBOX_ENABLED === 'true'
this.allowNetwork = config.allowNetwork ?? process.env.SANDBOX_ALLOW_NETWORK !== 'false'
The fitContext function in context.ts illustrates another design trade-off: when truncating overly long conversations, it preserves a continuous tail rather than randomly deleting. This is because the adjacency between tool calls and tool results must not be broken — if intermediate messages are deleted, the model loses visibility into its own previously issued tool calls, and the entire conversation logic fractures. This decision directly shapes the truncation algorithm's implementation, but to external callers, it's simply a maxChars parameter.
Final Form: Four YAMLs, One Core
Looking back at the entire system, the Web UI launched by make dev and the CLI run by make chat use the same packages/core/src/index.ts, the same Cordis assembly logic. The only differences lie in the trade-offs between cli-chat and web-server in cordis.yml, and the switch between llm-mock and llm-openai.
Four configuration combinations, zero code changes — this isn't marketing fluff in documentation, but the result of two-path resolution in loader.ts and the PLUGINS registry working in concert. Treating the DI container as an assembly line and pushing every point of variability to the plugin layer is the design decision that determines the entire system's scalability and maintenance cost.
Source Code Navigation
packages/core/src/index.ts— Entry point, 30 lines of core assembly logicpackages/core/src/loader.ts—resolvePlugintwo-path resolutionpackages/core/src/plugins/registry.ts—PLUGINSshort-name registrypackages/core/src/context.ts—fitContextconversation truncation logicpackages/core/src/plugins/sandbox.ts— OS-level sandbox servicepackages/core/src/plugins/skills.ts— Skill index servicecordis.yml— CLI + mock default configurationcordis.web.yml— Web UI + mock configurationcordis.openai.yml— CLI + OpenAI configurationcordis.openai.web.yml— Web UI + OpenAI configuration- Repository: https://github.com/erishen/resolve-studio
- Published plugins:
@resolve-studio/plugin-hello/@resolve-studio/plugin-pse/@resolve-studio/plugin-system-infoare on npm — install and reference them bynameincordis.yml
How exactly does the two-path resolution in resolvePlugin work?
It first looks up short names in the PLUGINS registry (local plugins); if not found, it treats the name as an npm package name and performs a dynamic import to load pure Cordis ecosystem plugins.
What's the difference between the four YAML configuration combinations?
The difference lies in which frontend plugin is enabled (cli-chat or web-server) and which LLM backend is used (llm-mock or llm-openai), while the core code remains identical.
Why does fitContext preserve a continuous tail instead of randomly deleting old messages?
Because the adjacency between tool calls and tool results must not be broken — random deletion would disrupt the model’s tool-call/result pairing.
How is the sandbox functionality controlled?
Through the SANDBOX_ENABLED environment variable master switch. macOS uses Seatbelt to generate a profile, Linux uses bubblewrap, and when conditions aren’t met, it degrades to direct execution.
What role does the HARNESS_SKILLS_DIR environment variable play in the skills service?
It points to a shared skills repository directory that is merged with the local skills/ directory, with lower priority than the local directory, solving the problem of cross-project skill sharing.
Why can pure Cordis plugins (like @cordisjs/plugin-timer) be installed with zero resolve-studio dependencies?
Because the second path in resolvePlugin dynamically imports by npm package name. Such plugins only need to depend on the Cordis standard API to be loaded, without going through the resolve-studio service layer.
Leave a reply