20 KiB
Pluggable Core — openhuman_core as an Embeddable Library
Status: In progress — CoreBuilder, CoreRuntime, CoreContext, the
first per-context store plumbing, and the openhuman-fleet MVP are implemented
in this branch. Remaining production work is tracked in the phase docs.
Goal: make the Rust core pluggable into arbitrary hosts — the Tauri shell
(today), a plain CLI, a stdio MCP server, cloud/team servers managing many
users, and other programs consuming it as a library — via a first-class
CoreBuilder → CoreRuntime API instead of the current "library entry is a
CLI arg vector" surface.
Why: teams and programmatic use both need the same thing: the ability to
compose the core's pieces (dispatcher, stores, background services,
transports) differently per host. Today that composition is fixed inside one
700-line function. The client side already solved this problem with the
CoreTransport strategy layer (app/src/services/transport/); the core side
has no equivalent seam.
Anchor precedents: docs/tinyagents-port-plan.md
and docs/tinycortex-migration-spec.md —
the established "seam + staged migration + drift ledger" doctrine. This plan
restructures the host, not the engines: tinyagents / tinycortex seams
are untouched.
1. Where we are
1.1 What is already right
The hard parts of pluggability are, surprisingly, already done:
| Asset | Where | Why it matters |
|---|---|---|
| Transport-agnostic dispatch | ControllerSchema + invoke_method (src/core/jsonrpc.rs:230) shared by HTTP /rpc, CLI call, generic namespace dispatch, and MCP stdio |
The contract is host-neutral; only the bootstrap is not |
| Pluggable client | app/src/services/transport/ — CoreTransport interface, LocalTransport / LanHttpTransport / TunnelTransport / CloudHttpTransport, ConnectionProfile, TransportManager |
Any backend that answers POST /rpc JSON-RPC with a Bearer token is already reachable from every client, including cloud |
| Agent loop as a crate | tinyagents (vendored, vendor/tinyagents) via the seam src/openhuman/tinyagents/ (run_turn_via_tinyagents_shared) |
Programmatic harness use does not require extracting the loop — it's extracted |
| Memory engine as a crate | tinycortex via src/openhuman/tinycortex/ |
Same |
| Headless server mode | openhuman-core run/serve (src/core/cli.rs:66), --jsonrpc-only, Bearer token via OPENHUMAN_CORE_TOKEN (src/core/auth.rs:160) |
Cloud deployment of a single core already works |
| Host discrimination | HostKind { TauriShell, Cli, Docker } (src/core/types.rs:167) threaded into bootstrap_core_runtime |
The natural seam for the refactor already exists |
| Tools as trait objects | Box<dyn Tool> on Agent (src/openhuman/agent/harness/session/types.rs:31) |
No handler-style fn-pointer problem in the tool layer |
1.2 The coupling points (what blocks embedding)
| # | Concern | Location | Nature |
|---|---|---|---|
| 1 | Bootstrap fused to transport | run_server_inner (src/core/jsonrpc.rs:1738) + bootstrap_core_runtime (:2428) |
One function: controller registration, master key, token seeding, Config::load_or_init, ~10 global store inits (:1807-1892), port bind, env mutation, router, ready signal, inline tokio::spawn of every background service. You cannot get "just the dispatcher" |
| 2 | Handlers are bare fn pointers |
ControllerHandler (src/core/all.rs:21) |
No captured state possible; every handler reads process globals |
| 3 | Three hand-maintained registries | src/core/all.rs — handlers (:105-344), schemas (:375-509), namespace descriptions (:530-690) |
Parallel lists in OnceLock statics, panic-on-drift validation |
| 4 | Process singletons | RPC_TOKEN (src/core/auth.rs:75), GLOBAL_BUS (src/core/event_bus/bus.rs:20), NativeRegistry (src/core/event_bus/native_request.rs:329), every *::global::init(workspace_dir) store |
One of each per process |
| 5 | Single active user per process | ~/.openhuman/active_user.toml (src/openhuman/config/schema/load_user_state.rs:21), resolution chain in dirs.rs:299 |
One workspace resolved once; all global stores bound to it |
| 6 | Background services spawned inline | cron (jsonrpc.rs:~2124), channels (:~2162), heartbeat/subconscious (:~2083), update scheduler |
Flags exist (config.cron.enabled, OPENHUMAN_DISABLE_CHANNEL_LISTENERS, --jsonrpc-only) but the caller cannot compose a service set |
| 7 | Runtime env mutation | set_var OPENHUMAN_CORE_RPC_URL (jsonrpc.rs:2010) |
Process-global side effect; needed by spawned child tools |
| 8 | Once-guarded subscribers |
register_domain_subscribers (jsonrpc.rs:2232, std::sync::Once) |
Second context in one process cannot re-register |
| 9 | Dead dispatch tier | src/rpc/dispatch.rs:10 always returns None |
Removable noise |
1.3 Teams today
src/openhuman/team/ is a pure thin proxy to tinyhumansai/backend
(session JWT via crate::api::jwt, URL via
effective_backend_api_url, src/api/config.rs:154). All membership, roles,
invites, and authorization are enforced server-side. That is the right
division and this plan keeps it: we make the core hostable by a team
server; we do not reimplement team logic locally.
2. Target architecture
Bootstrap splits into three layers that are fused today:
CoreBuilder::build() ── layer 1: context init (pure — no
└─ CoreContext sockets, no spawns): config,
stores · event bus · registries workspace, master key, stores,
security policy · approval gate subscribers, policy
CoreRuntime::serve() ── layer 2: services (each opt-in):
├─ rpc_http (axum /rpc + Bearer) selected by ServiceSet
├─ socketio
├─ cron · channels · heartbeat · update
runtime.invoke(method, params) ── layer 3: transports are thin
CLI `call` · MCP stdio · HTTP handler adapters over the same dispatch
2.1 Public API (sketch)
pub struct ServiceSet {
pub rpc_http: bool,
pub socketio: bool,
pub cron: bool,
pub channels: bool,
pub heartbeat: bool,
pub update_scheduler: bool,
}
impl ServiceSet {
pub fn desktop() -> Self; // everything on (Tauri today)
pub fn headless_api() -> Self; // rpc_http only (cloud single-core)
pub fn none() -> Self; // library / harness-only
}
pub struct CoreBuilder { /* config, host_kind, token, bind, services */ }
impl CoreBuilder {
pub fn new(host_kind: HostKind) -> Self;
pub fn token(self, t: TokenSource) -> Self; // Fixed | EnvOrFile
pub fn services(self, set: ServiceSet) -> Self;
pub async fn build(self) -> anyhow::Result<CoreRuntime>; // init only, no spawns
}
pub struct CoreRuntime { /* Arc<CoreContext>, CancellationToken, bound_addr */ }
impl CoreRuntime {
pub async fn serve(&self, ready: Option<oneshot::Sender<EmbeddedReadySignal>>, shutdown: Option<CancellationToken>)
-> anyhow::Result<()>; // spawn selected services
pub async fn invoke(&self, method: &str, params: Map<String, Value>)
-> Result<Value, RpcError>; // same path as /rpc
pub fn events(&self) -> broadcast::Receiver<CoreEvent>;
pub fn agent_runtime(&self) -> AgentRuntime; // harness-only slice
pub async fn shutdown(self) -> anyhow::Result<()>;
}
src/lib.rs re-exports CoreBuilder, CoreRuntime, ServiceSet,
HostKind, AgentRuntime as the documented public surface;
run_core_from_args remains for the binary.
2.2 Existing entry points become thin consumers
| Entry | Today | After |
|---|---|---|
run_server (jsonrpc.rs:1682) |
wraps run_server_inner |
shim over CoreBuilder (deprecated, kept one release) |
run_server_embedded_with_ready (:1717) |
same | Tauri core_process.rs calls CoreBuilder directly: TokenSource::Fixed(in-memory), ServiceSet::desktop(); readiness is passed to CoreRuntime::serve |
CLI call / namespace dispatch (src/core/cli.rs) |
invoke_method(default_state(), …) |
ServiceSet::none() build → runtime.invoke() — no port bound for one-shot calls |
MCP stdio (src/core/cli.rs mcp) |
same funnel | transport adapter over runtime.invoke() |
| Cloud/team host | n/a | fleet supervisor composing one core per user (phase 4) |
2.3 Multi-tenancy: fleet of processes first
Decision: one supervised core process per user/workspace first — not in-process multi-tenancy. Rationale:
- It is the shape the architecture already has (Tauri = one embedded core for one user); a supervisor reuses it with zero correctness risk.
- The blockers to in-process tenancy are real and slow: env-var mutation for
child tools (#7), keyring/master-key process scope,
Once-guarded subscribers (#8), Sentry. Separate processes improve blast-radius and lifecycle control, but production multi-tenant security still requires distinct OS users or containers because agents run arbitrary tools. - The wire contract is unchanged, so
CloudHttpTransportworks as-is against a per-user base URL.
In-process multi-workspace (a CoreContext per tenant) is deferred behind
phase 3 and is an optimization, not a prerequisite, for the team story.
2.4 Globals strategy: staged, bounded
Full DI through ~110 handler registration sites in one PR is unreviewable. Three stages (detail in phase-2 / phase-3):
- Stage A (facade):
CoreContextowns initialization order and hands out handles; existing*::global::initcalls move inside it; handlers keep reading globals. Zero behavior change. - Stage B (mechanical): registered RPC dispatch installs an ambient
task-local
CoreContextwhile handlers remain plainfnpointers. Domains then migrate off globals opportunistically by readingCoreContext::current(), tracked in a drift ledger. - Stage C (bounded): only what multi-context isolation actually needs.
Exit criterion: two
CoreContexts in one test process serve memory/people/config reads without cross-talk — not "zeroOnceLocks". Keyring, Sentry,NativeRegistry, and env vars stay process-scoped and are documented as such.
2.5 Storage abstraction
Backend-level pluggability also requires the stores to be swappable, not
just the transports. Today every store is constructed from a workspace
directory (*::global::init(workspace_dir)) and is concretely
SQLite/JSON-on-disk under ~/.openhuman/users/<id>/workspace. That is
invisible in the desktop app but load-bearing for other backends:
- Fleet hosting (phase 4) works without abstraction — each per-user core process gets its own workspace volume. This is why storage abstraction is not on the phase-4 critical path.
- Managed/cloud storage (Postgres, object store for attachments, shared-nothing replicas) and in-process multi-tenancy both require stores behind traits.
The seam is the CoreContext handle layer introduced in Stage A/B: handlers
stop touching *::global() and go through ctx.<store>(). Those accessors
return trait objects (Arc<dyn MemoryStore>, Arc<dyn PeopleStore>, …)
rather than concrete types, and CoreBuilder gains a StorageBackend
selector whose only shipped implementation is WorkspaceFs (the current
behavior, byte-for-byte). Store traits are carved per domain as that domain
migrates onto the context (phase 2 drift ledger gets a "trait extracted?"
column) — not as one big up-front storage rewrite. Note the engines already
model this: tinyagents has StoreRegistry/checkpointer abstractions and
tinycortex owns its own store interfaces; the traits here cover the
host-owned stores (people, attachments, cost/x402 ledgers, config state,
run ledgers) that sit outside those crates.
Remote implementations (e.g. Postgres) are explicitly out of scope for this
plan; the deliverable is that adding one is a new StorageBackend impl, not
a refactor.
3. Non-goals
- Wire contract:
POST /rpcJSON-RPC,openhuman.<ns>_<fn>naming, Bearer auth — unchanged. Client transports untouched. - tinyagents / tinycortex seams: unchanged; this plan restructures the host around them.
- Security policy semantics:
security::live_policy, approval gate, autonomy tiers — unchanged (only who installs them moves intoCoreBuilder::build). - Local team logic: membership truth stays in
tinyhumansai/backend. - Not attempted: replacing axum, changing controller schema format,
in-process tenant security isolation,
inventory/linkme distributed registration (explicit registration lists fit this repo's ledger culture).
4. Phases
| Phase | File | Deliverable | Depends on |
|---|---|---|---|
| 0 | phase-0-defusion.md | Delete dead tier-3 shim; extract inline service spawns + store-init block into named fns. Pure motion | — |
| 1 | phase-1-corebuilder.md | CoreBuilder/CoreRuntime/ServiceSet; run_server* become shims; Tauri + CLI + MCP ported; embed examples |
0 |
| 2 | phase-2-corecontext.md | CoreContext Stage A + B; registry collapse to per-domain DomainRegistration; store traits + StorageBackend::WorkspaceFs; drift ledger |
1 |
| 3 | phase-3-multi-context.md | Bounded Stage C; two-context isolation test; process-scoped inventory | 2 |
| 4 | phase-4-fleet-host.md | openhuman-fleet supervisor: per-user cores, token minting, /:user/rpc proxy, backend membership sync |
1 (not 2/3) |
Value ordering: phases 0+1 alone deliver the headline goal — embeddable builder, programmatic harness, thin CLI/Tauri. Phase 4 can start immediately after phase 1, in parallel with 2/3, because the fleet model needs process-per-user, not context threading.