Jarvis is a Rust agent runtime shaped around one design rule:
harness-coreknows nothing about HTTP, providers, storage, or MCP. It only owns the agent loop and the traits everything else implements. Every concrete integration lives in a sibling crate that plugs in.
This document gives the big-picture view: how the crates fit together,
how a request flows through the system, and where to extend it.
For day-to-day working rules and subtle gotchas, see CLAUDE.md.
ββββββββββββββββββββββββββββββββββββββ
transport / composition β apps/jarvis (bin) β
ββββββββββββββββ¬ββββββββββββββββββββββ
β wires everything
ββββββββββββββββββββ¬ββββββββββββΌββββββββββββ¬βββββββββββββββββββ
βΌ βΌ βΌ βΌ βΌ
ββββββββββββββ ββββββββββββββ ββββββββββ ββββββββββββββ ββββββββββββββ
βharness- β βharness- β βharness-β βharness- β βharness- β
βserver β βllm β βtools β βmcp β βstore β
β(axum HTTP) β β(OpenAI) β β(echo, β β(rmcp β β(sqlx β
β β β β β fs,β¦) β β client + β β SQLite / β
β β β β β β β server) β β PG / MySQL)β
βββββββ¬βββββββ βββββββ¬βββββββ ββββββ¬ββββ βββββββ¬βββββββ βββββββ¬βββββββ
β β β β β
β implements LlmProvider β implement Tool β
β β β β implements ConversationStore
ββββββββββββββββββ΄βββββββ¬ββββββββ΄βββββββββββββ΄βββββββββββββββββ
βΌ
βββββββββββββββββββββββββ
β harness-core β
β β
β Agent (run / stream) β
β Conversation β
β Message, ToolCall β
β trait Tool β
β trait LlmProvider β
β trait ConversationStore β
β trait ToolRegistry β
βββββββββββββββββββββββββ
Dependency direction is strictly downward: sibling crates depend on
harness-core; nothing in harness-core depends on them. Adding a new
integration means adding a new crate and wiring it in apps/jarvis β
never adding use harness_server::β¦ inside harness-core or similar.
| crate | owns | depends on |
|---|---|---|
harness-core |
Agent run loop, Conversation, Message, traits |
no sibling crate |
harness-llm |
OpenAiProvider (implements LlmProvider) |
harness-core, reqwest |
harness-tools |
Built-in tools (echo, time.now, http.fetch, fs.*) |
harness-core, reqwest |
harness-mcp |
MCP client (adapts remote tools) + server (exposes local) | harness-core, rmcp |
harness-server |
Axum router, AppState, /v1/chat/* endpoints |
harness-core, axum |
harness-store |
ConversationStore impls (sqlx) + connect(url) |
harness-core, sqlx, chrono |
apps/jarvis |
Composition root: env vars, wiring, process lifecycle | every library crate above |
Three traits form the extension surface. Every sibling crate implements
one (or, in harness-mcp's case, bridges tools in both directions).
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value; // JSON Schema (object)
async fn invoke(&self, args: Value) -> Result<String, BoxError>;
}ToolRegistry is a thin HashMap<String, Arc<dyn Tool>>. The agent loop
only talks to the registry; every tool (built-in, MCP-remote, user code)
lives behind it.
#[async_trait]
pub trait LlmProvider: Send + Sync {
async fn complete(&self, req: ChatRequest) -> Result<ChatResponse>;
async fn complete_stream(&self, req: ChatRequest) -> Result<LlmStream>;
}complete_stream has a default implementation that calls complete and
emits a single Finish chunk β new providers only need the non-streaming
method to start.
#[async_trait]
pub trait ConversationStore: Send + Sync {
async fn save(&self, id: &str, c: &Conversation) -> Result<(), BoxError>;
async fn load(&self, id: &str) -> Result<Option<Conversation>, BoxError>;
async fn list(&self, limit: u32) -> Result<Vec<ConversationRecord>, BoxError>;
async fn delete(&self, id: &str) -> Result<bool, BoxError>;
}Keyed by an opaque id chosen by the caller (e.g. session UUID). See
DB.md for the backends and schema.
Agent has two entry points backed by the same state machine:
Agent::run(&mut Conversation) -> Result<RunOutcome>β blocking.Agent::run_stream(self: Arc<Self>, Conversation) -> AgentStreamβ streaming; yieldsAgentEvents.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Agent::run loop β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β prepend system prompt (if conversation has none) β β
β ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β LlmProvider::complete / complete_stream β β
β ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β append assistant message β Conversation β β
β ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β
β βΌ β
β finish_reason == ToolCalls? β
β βββββββββββ΄ββββββββββ β
β β yes β no β
β βΌ βΌ β
β for each tool_call: return RunOutcome β
β invoke_tool(registry) β
β append Tool message β
β β β
β βββ back to LlmProvider (next iteration) βββ β
β β β
β bounded by AgentConfig::max_iterations ββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key invariants (enforced in agent.rs β preserve when editing):
- The system prompt is prepended once, only if the conversation doesn't already have a system message.
- Tool errors are caught and surfaced as text (
"tool error: {e}") so the model can recover on the next turn. - Streaming emits exactly one terminal event: either
AgentEvent::Done(carrying the finalConversation) orAgentEvent::Error.
Delta { content } // token / chunk from the LLM
AssistantMessage { message, finish } // one complete assistant turn
ToolStart { id, name, arguments } // bracketing a tool invocation
ToolEnd { id, name, content }
Done { conversation } // terminal success
Error { message } // terminal failure
All three HTTP transports are thin serialisation layers over
Agent::run / run_stream. They share AppState, never reimplement the
loop.
Client harness-server harness-core
ββββββ ββββββββββββββ ββββββββββββ
ββββββββββββββ
POST /v1/chat/β¦ ββββΆ β routes.rs β
β β build Conversation from body
β β call Agent::run or run_stream
β β ββββββββββββββββββββββββββββ
β β β Agent loop ββββΆ LlmProvider
β β β ββββΆ Tools
β β β β (possibly
β β β β RemoteTool
β β β β via MCP)
β β ββββββββββββββββββββββββββββ
β β serialise response / stream
ββββ JSON / SSE / WS β β
ββββββββββββββ
| endpoint | shape |
|---|---|
POST /v1/chat/completions |
blocking; returns {message, iterations, history} |
POST /v1/chat/completions/stream |
SSE; each data: is one JSON AgentEvent |
GET /v1/chat/ws |
WebSocket; multi-turn, server-held state |
The WebSocket handler is the only endpoint that keeps conversation state
across turns: it captures AgentEvent::Done { conversation } and carries
it into the next incoming user message. Clients don't resend history.
AppState currently holds:
struct AppState {
agent: Arc<Agent>,
store: Option<Arc<dyn ConversationStore>>, // set if JARVIS_DB_URL
}No handler reads store yet β that's where persistence endpoints plug
in next.
harness-mcp wires the agent into the Model Context Protocol in both
directions on top of the rmcp SDK (stdio transport only, for now):
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Jarvis process β
β β
β ββββββββββββββββββββββββββββββββββββ β
β β ToolRegistry β β
β β ββ built-in tools β β
β β ββ RemoteTool (one per remote) βββββββΌβββ
β ββββββββββββββββββββββββββββββββββββ β β remote MCP server
β β² β β (child process,
β β register_into() β β stdio)
β ββββββββββ΄ββββββββββ β β
β β McpClient (rmcp) ββββββββββββββββ
β ββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββββββββ β
β β McpServer (rmcp::ServerHandler) ββββ β another MCP-aware
β β exposes ToolRegistry over stdioβ β β agent (calls us)
β ββββββββββββββββββββββββββββββββββββ β β
β ββββΌβββββββββββββΆ
ββββββββββββββββββββββββββββββββββββββββββββββββ
- Client (
client.rs):McpClient::connect(&McpClientConfig)spawns a remote MCP server as a child process, handshakes, lists its tools, and inserts aRemoteTooladapter into the localToolRegistryfor each one. Names are namespaced as<prefix>.<tool>so multiple servers don't collide. - Server (
server.rs):McpServer::new(Arc<ToolRegistry>)implementsrmcp::ServerHandlerby hand (the#[tool_router]macro doesn't fit a runtime-known registry).serve_registry_stdiois the one-liner the--mcp-servebinary mode calls.
Driver selection is both compile-time (cargo features) and
runtime (URL scheme picked by connect(url)):
| feature | URL prefixes | backend |
|---|---|---|
sqlite |
sqlite:, sqlite::memory: |
SQLite (default) |
postgres |
postgres://, postgresql:// |
Postgres |
mysql |
mysql://, mariadb:// |
MySQL / MariaDB |
All backends share one table, conversations(id, messages, created_at, updated_at), where messages is the JSON-serialised Conversation and
timestamps are RFC-3339 strings. See DB.md for details.
The binary is the only place that reads std::env, picks default
models, or decides which tools ship on by default. Library crates must
never call std::env::var β put config on their input types and let
main.rs populate them.
Startup order:
- Initialise tracing.
- Build a
ToolRegistry; register built-ins viaharness_tools::register_builtins. - If
--mcp-serve: hand the registry toserve_registry_stdioand return β no LLM or HTTP setup. - Otherwise: build an
OpenAiProviderfromOPENAI_API_KEY/JARVIS_MODEL/OPENAI_BASE_URL. - If
JARVIS_MCP_SERVERSis set: spawn external MCP servers and merge their tools into the registry viaconnect_all_mcp. - Construct the
Agent(provider + registry + system prompt + max iterations). - If
JARVIS_DB_URLis set: callharness_store::connectand stash the store onAppState. serve(addr, state).
Each extension point is a trait implementation in a new (or existing)
sibling crate, plus one wiring line in apps/jarvis.
- New module in
crates/harness-tools/src/with aToolimpl. - Re-export from
crates/harness-tools/src/lib.rs. - (Optional) Register in
register_builtinsif it should be on by default. Otherwise let the binary opt in. - Keep names namespaced:
<group>.<verb>(e.g.fs.read).
- New module in
crates/harness-llm/src/(or a brand-new crate). - Implement
LlmProvider. Start withcomplete; addcomplete_streamwhen you need real-time tokens. - Re-export from
lib.rs. - Wire it in
apps/jarvis/src/main.rsβ likely behind a new env var or CLI flag.
- New module in
crates/harness-store/src/guarded by a cargo feature. - Follow the
sqlite.rspattern: pool wrapper, idempotentmigrate(),ConversationStoreimpl that round-trips JSON + RFC-3339 strings. - Declare the feature in
crates/harness-store/Cargo.toml. - Add a match arm in
connect()insrc/lib.rs.
- Handler in
crates/harness-server/src/routes.rs. - Mount it in
router(AppState). - For streaming, call
Agent::run_streamand serialiseAgentEvents β don't reimplement the loop. - Extend
AppStaterather than threading extra handles through every handler.
All user-facing configuration is read by apps/jarvis and passed as
plain Rust values into the library crates.
| env var | default | purpose |
|---|---|---|
OPENAI_API_KEY |
β | Required (unless --mcp-serve) |
JARVIS_MODEL |
gpt-4o-mini |
OpenAI model id |
OPENAI_BASE_URL |
OpenAI | For OpenAI-compatible gateways |
JARVIS_ADDR |
0.0.0.0:7001 |
Bind address for the HTTP server |
JARVIS_FS_ROOT |
. |
Sandbox root for fs.* tools |
JARVIS_ENABLE_FS_WRITE |
unset | Any value opts into fs.write |
JARVIS_MCP_SERVERS |
unset | prefix=cmd args, β¦ β external MCP servers |
JARVIS_DB_URL |
unset | sqlite:β¦ / postgres://β¦ / mysql://β¦ |
RUST_LOG |
info |
tracing_subscriber filter |
CLI flags consumed by the binary:
--mcp-serveβ expose the localToolRegistryover MCP stdio instead of starting the HTTP server.
- Workspace-only deps: every crate uses
foo.workspace = true; versions live once in the rootCargo.toml[workspace.dependencies]. - No
unwrapin library crates. Returnharness_core::ResultorBoxError; let the binary decide how to surface failure. clippy --workspace --all-targets -- -D warningsis the CI gate.- Streaming is a separate method, not a retrofit on
complete. - Tool name collisions are silent β the second registration wins. Namespace aggressively.