Extension model
Roder's primary extension seam is the RoderExtension trait in
roder-api. Extensions publish a manifest, then install services into an
ExtensionRegistryBuilder.
impl RoderExtension for MyExtension {
fn manifest(&self) -> ExtensionManifest { /* id, version, provided services */ }
fn install(&self, registry: &mut ExtensionRegistryBuilder) -> anyhow::Result<()> {
registry.inference_engine(Arc::new(MyInferenceEngine::new()));
registry.tool_contributor(Arc::new(MyTools::new()));
Ok(())
}
} Architecture in one pass
Roder treats extensions as typed contributors to a runtime registry. The extension
does not get handed the turn loop. Instead, it declares what it provides, registers
implementations behind roder-api traits, and lets the core decide when those
implementations are used.
- The distribution creates an
ExtensionRegistryBuilder. - Each extension returns an
ExtensionManifestwith identity, API version, provided services, and requested capabilities. - The extension installs one or more service objects into the builder.
- The builder validates manifests, duplicate services, API compatibility, installed-service matches, tool registration, and capability status.
- The runtime receives an immutable
ExtensionRegistryand consumes services through typed contracts. - Clients discover installed extensions through
extensions/list, which reports manifests and capability statuses.
let mut builder = ExtensionRegistryBuilder::new();
builder.install(MockProviderExtension)?;
builder.install(OpenAiResponsesExtension::new(openai_key))?;
builder.install(MemoryExtension::new(roder_home.join("memory")))?;
builder.install(BuiltinCodingToolsExtension { workspace, path_scope, command_shell })?;
// Optional host decisions before build:
builder.grant_capability("roder-ext-memory", CapabilityGrant::new("fs.readwrite.roder-home"));
// builder.deny_capability("some-ext", CapabilityDenial::new("network.web", "offline profile"));
let registry = builder.build()?; // validate, then freeze
let runtime = Runtime::new(registry, runtime_config); Manifest contract
The manifest is the public identity of an extension. It is what app-server clients,
desktop clients, and debugging tools can show before they know implementation details.
It also acts as a consistency check: if the manifest declares
ToolProvider("web-search"), the extension must actually install a tool
contributor whose id() is web-search.
ExtensionManifest {
id: "roder-ext-web-search".to_string(),
name: "Web Search Router".to_string(),
version: Version::new(0, 1, 0),
api_version: "0.1.0".to_string(),
description: Some("Canonical web_search router".to_string()),
provides: vec![ProvidedService::ToolProvider("web-search".to_string())],
required_capabilities: vec![CapabilityRequest::new("network.web")],
} Capability requests are deliberately visible. A host can mark a requested capability as granted, leave it in requested state, or deny it. Denying a required capability fails registry construction, which gives distributions a simple way to build strict profiles such as offline, eval, enterprise, or hosted-runner-only.
What can be provided
- Inference engines and provider wire dialects.
- Context providers and planners.
- Session stores, checkpoint stores, and memory backends.
- Tool contributors and task executors.
- Subagent dispatchers and notification sinks.
- Policy contributors and event sinks.
- TUI status segments and command-palette sources.
Service categories
The service type determines where the extension joins the system:
- Inference:
InferenceEnginetranslates a model API into canonical Roder inference events. - Context:
ContextProviderandContextPlanneradd retrieved context, memory, code index results, or prompt-shaping decisions before model calls. - State:
ThreadStore,CheckpointStore,MemoryStore, andEmbeddingProviderown persistence and retrieval backends. - Actions:
ToolProvider,TaskExecutor,SubagentDispatcher, andRemoteRunnerProvideradd capabilities the agent can invoke. - Policy and observation:
PolicyContributor,EventSink, andNotificationSinkreview, record, or notify without owning the turn loop. - Surfaces:
StatusSegment,PaletteSource, andInteractiveRegionHandlerextend the TUI or other clients without changing runtime semantics.
Runtime consumption
After build(), the registry is immutable. Runtime systems select services by
interface and id, then call only the trait methods exposed by roder-api. That
keeps extension code outside lifecycle ownership: cancellation, event ordering,
approvals, transcript writes, and client notifications still stay in the core.
async fn run_turn(runtime: &Runtime, request: TurnRequest) -> anyhow::Result<()> {
let registry = runtime.registry();
let engine = registry
.inference_engine(&request.provider_id)
.or_else(|| registry.default_inference_engine())
.context("no inference engine configured")?;
let mut prompt_context = Vec::new();
for provider in ®istry.context_providers {
prompt_context.extend(provider.load_context(&request.context_query).await?);
}
let mut tools = ToolRegistry::default();
for contributor in ®istry.tools {
contributor.contribute(&mut tools)?;
}
let events = engine.stream_turn(
InferenceTurnContext::new(runtime, &request),
AgentInferenceRequest {
messages: request.messages,
context: prompt_context,
tools: tools.specs(),
model: request.model,
},
).await?;
runtime.drive_provider_events(events, tools).await
} The important point is directional: extensions provide objects, and the runtime orchestrates those objects. A tool provider can define tools, but the runtime decides when a tool call is approved, how it is cancelled, and how its result is appended to the thread. An inference provider can stream model output, but it does not own the agent loop.
Client discovery
App-server clients do not need Rust access to understand what is installed. They call
extensions/list and receive the exact manifests from the registry plus the
computed capability statuses.
{
"extensions": [
{
"id": "roder-ext-builtin-coding-tools",
"name": "Built-in coding tools",
"version": "0.1.0",
"api_version": "0.1.0",
"description": "Workspace file, search, patch, edit, and shell tools",
"provides": [
{ "ToolProvider": "builtin-coding-tools" }
],
"required_capabilities": [
{ "id": "fs.read.workspace" },
{ "id": "fs.write.workspace" },
{ "id": "process.spawn.shell" }
]
}
],
"capability_statuses": {
"roder-ext-builtin-coding-tools": [
{ "id": "fs.read.workspace", "decision": "requested" },
{ "id": "process.spawn.shell", "decision": "requested" }
]
}
} This is why the manifest is kept deliberately boring and serializable. Desktop, app-server, eval harnesses, hosted runners, and custom distributions can all inspect the same contract without linking against each extension crate.
Capability profiles
Capabilities are not a hidden permission model buried inside each extension. They are registry-level data that a distribution can evaluate before startup. The same extension can be installed in a developer profile, denied in an eval profile, or granted in a managed enterprise profile.
fn apply_profile(builder: &mut ExtensionRegistryBuilder, profile: RuntimeProfile) {
match profile {
RuntimeProfile::Developer => {
builder.grant_capability("roder-ext-builtin-coding-tools", CapabilityGrant::new("fs.read.workspace"));
builder.grant_capability("roder-ext-builtin-coding-tools", CapabilityGrant::new("fs.write.workspace"));
builder.grant_capability("roder-ext-builtin-coding-tools", CapabilityGrant::new("process.spawn.shell"));
}
RuntimeProfile::EvalSandbox => {
builder.deny_capability(
"roder-ext-web-search",
CapabilityDenial::new("network.web", "benchmark runs must be offline"),
);
builder.deny_capability(
"roder-ext-builtin-coding-tools",
CapabilityDenial::new("process.spawn.shell", "shell disabled for this harness"),
);
}
}
} Denying a required capability is fail-fast: the registry will not build. That is intentional. It prevents a distribution from accidentally advertising an extension whose core assumptions have been disabled.
Example: a tool provider
A tool extension installs a ToolContributor. The contributor registers one
or more ToolExecutor values into a ToolRegistry. The runtime still
owns policy checks, approval events, execution context, tool-result routing, and the
transcript.
struct IssueLookupExtension {
api_token: String,
}
impl RoderExtension for IssueLookupExtension {
fn manifest(&self) -> ExtensionManifest {
ExtensionManifest {
id: "acme-issue-lookup".to_string(),
name: "Acme issue lookup".to_string(),
version: Version::new(0, 1, 0),
api_version: "0.1.0".to_string(),
description: Some("Search internal issue tracker".to_string()),
provides: vec![ProvidedService::ToolProvider("acme-issues".to_string())],
required_capabilities: vec![
CapabilityRequest::new("network.issues.acme.internal"),
CapabilityRequest::new("secret.read.ACME_ISSUE_TOKEN"),
],
}
}
fn install(&self, registry: &mut ExtensionRegistryBuilder) -> anyhow::Result<()> {
registry.tool_contributor(Arc::new(IssueToolContributor {
tool: Arc::new(SearchIssuesTool::new(self.api_token.clone())),
}));
Ok(())
}
}
impl ToolContributor for IssueToolContributor {
fn id(&self) -> ToolProviderId {
"acme-issues".to_string()
}
fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
registry.register(self.tool.clone())
}
}
#[async_trait]
impl ToolExecutor for SearchIssuesTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "search_issues".to_string(),
description: "Search Acme issue tracker by query.".to_string(),
parameters: json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"],
"additionalProperties": false
}),
}
}
async fn execute(&self, ctx: ToolExecutionContext, call: ToolCall) -> anyhow::Result<ToolResult> {
// ctx contains thread/turn ids, policy mode, command shell, deadline, and scoped handles.
// The tool does not bypass policy; it only runs after the runtime allows this call.
let args: SearchArgs = serde_json::from_value(call.arguments.clone())?;
let matches = self.client.search(args.query).await?;
Ok(ToolResult {
id: call.id,
name: call.name,
text: render_issue_summary(&matches),
data: serde_json::to_value(matches)?,
is_error: false,
})
}
} Example: an inference provider
Provider extensions implement InferenceEngine. They map provider-specific
request and streaming formats into canonical Roder events such as message deltas,
tool calls, reasoning, provider metadata, usage, and completion. The core keeps the
transcript, tool loop, retry/deadline policy, and app-server notifications provider-neutral.
struct AcmeModelExtension {
client: AcmeModelClient,
}
impl RoderExtension for AcmeModelExtension {
fn manifest(&self) -> ExtensionManifest {
ExtensionManifest {
id: "acme-models".to_string(),
name: "Acme Models".to_string(),
version: Version::new(0, 1, 0),
api_version: "0.1.0".to_string(),
description: Some("Acme hosted coding models".to_string()),
provides: vec![ProvidedService::InferenceEngine("acme".to_string())],
required_capabilities: vec![
CapabilityRequest::new("network.api.acme.ai"),
CapabilityRequest::new("secret.read.ACME_API_KEY"),
],
}
}
fn install(&self, registry: &mut ExtensionRegistryBuilder) -> anyhow::Result<()> {
registry.inference_engine(Arc::new(AcmeInferenceEngine {
client: self.client.clone(),
}));
Ok(())
}
}
#[async_trait]
impl InferenceEngine for AcmeInferenceEngine {
fn id(&self) -> InferenceEngineId {
"acme".to_string()
}
fn metadata(&self) -> InferenceProviderMetadata {
InferenceProviderMetadata {
name: "Acme".to_string(),
auth_type: ProviderAuthType::ApiKey,
auth_label: Some("ACME_API_KEY".to_string()),
auth_configured: Some(self.client.has_key()),
recommended: false,
sort_order: 80,
description: Some("Acme hosted model API".to_string()),
}
}
async fn stream_turn(
&self,
ctx: InferenceTurnContext,
request: AgentInferenceRequest,
) -> anyhow::Result<InferenceEventStream> {
let wire_request = to_acme_request(request)?;
let wire_stream = self.client.stream(wire_request).await?;
Ok(Box::pin(wire_stream.map(|event| {
// Translate provider wire events into Roder events.
match event? {
AcmeEvent::Text(delta) => Ok(InferenceEvent::MessageDelta(delta.into())),
AcmeEvent::ToolCall(call) => Ok(InferenceEvent::ToolCall(call.into())),
AcmeEvent::Usage(usage) => Ok(InferenceEvent::Usage(usage.into())),
AcmeEvent::Done => Ok(InferenceEvent::Completed),
}
})))
}
} Example: one extension, multiple services
An extension can install more than one service when the behavior is naturally coupled. The memory extension does this: it contributes a SQLite memory store, a context provider that retrieves memories into turns, and tools that let the agent manage memory.
impl RoderExtension for MemoryExtension {
fn manifest(&self) -> ExtensionManifest {
ExtensionManifest {
id: "roder-ext-memory".to_string(),
name: "Local Memory".to_string(),
version: Version::new(0, 1, 0),
api_version: "0.1.0".to_string(),
description: Some("SQLite-backed project and global memory store".to_string()),
provides: vec![
ProvidedService::MemoryStore("sqlite-memory".to_string()),
ProvidedService::ContextProvider("memory-context".to_string()),
ProvidedService::ToolProvider("memory-tools".to_string()),
],
required_capabilities: vec![CapabilityRequest::new("fs.readwrite.roder-home")],
}
}
fn install(&self, registry: &mut ExtensionRegistryBuilder) -> anyhow::Result<()> {
let factory = Arc::new(SqliteMemoryStoreFactory::new(self.base_path.clone()));
let store = factory.create();
registry.memory_store_factory(factory);
registry.context_provider(Arc::new(MemoryContextProvider::new(store.clone())));
registry.tool_contributor(Arc::new(MemoryToolContributor::new(store)));
Ok(())
}
} Validation rules
Registry build is intentionally strict because the app server and clients rely on extension metadata being true. The builder checks:
- Extension ids are non-empty and unique.
- Manifest
api_versionmatches the supported extension API range. - No two manifests declare the same
ProvidedService. - Every declared service has a matching installed implementation.
- No two installed implementations expose the same service id.
- Tool contributors can register their tools without duplicate names or invalid specs.
- Required capabilities are not denied by the host profile.
Default distribution
roder-extension-host composes the default CLI registry from config. That
includes provider extensions, JSONL session storage, local memory, built-in tools,
subagents, web search, plan mode, and TUI integrations.
pub fn build_default_registry(config: DefaultRegistryConfig) -> anyhow::Result<ExtensionRegistry> {
let mut builder = ExtensionRegistryBuilder::new();
builder.install(FakeProviderExtension)?;
builder.install(CodexOAuthProviderExtension)?;
if let Some(openai_key) = config.openai_api_key {
builder.install(OpenAiResponsesExtension::new(openai_key))?;
}
if let Some(gemini_key) = config.gemini_api_key {
builder.install(GeminiExtension::new(gemini_key))?;
}
builder.install(roder_ext_plan_mode::PlanModeExtension::new(config.policy_mode))?;
builder.install(roder_ext_task_ledger::TaskLedgerExtension)?;
builder.install(UnixLocalRunnerExtension)?;
builder.install(DockerRunnerExtension)?;
builder.install(DefaultTuiExtension)?;
builder.install(BuiltinCodingToolsExtension { workspace, path_scope, command_shell })?;
builder.install(JsonlThreadStoreExtension::new(thread_dir))?;
builder.install(MemoryExtension::new(roder_home.join("memory")))?;
builder.build()
} Why this shape
Labs and product teams can build their own Roder distribution without forking the core: choose provider crates, install their internal tools, replace session storage, add event sinks for evals, or expose a different UI while keeping the runtime invariants.
Design rules for extension authors
- Keep provider wire formats inside provider extensions; expose canonical Roder events to the core.
- Prefer one extension per deployable concern, but allow multiple services when they share state.
- Declare every sensitive access as a capability request so clients can explain what is installed.
- Use scoped runtime handles from
ToolExecutionContextinstead of reaching around policy or workspace boundaries. - Emit or handle typed events for anything that must be replayed, audited, evaluated, or rendered by clients.
- Keep app-server and TUI code as consumers of the registry, not as places where extension-specific logic accumulates.
Testing an extension
Extension tests should prove both sides of the contract: the manifest says what the extension installs, and the installed implementation behaves through the shared traits.
#[test]
fn issue_lookup_manifest_matches_installed_tool_provider() {
let mut builder = ExtensionRegistryBuilder::new();
builder.install(IssueLookupExtension::for_test()).unwrap();
let registry = builder.build().unwrap();
assert!(registry.provided_services().contains(
&ProvidedService::ToolProvider("acme-issues".to_string())
));
let mut tools = ToolRegistry::default();
for contributor in ®istry.tools {
contributor.contribute(&mut tools).unwrap();
}
assert!(tools.get("search_issues").is_some());
}