smooth-operator-core · the engine

The agent brain you can point at production —
because you decide what it must never do.

One observe → think → act engine: typed tools, streaming, checkpointing, memory, cost budgets, and a permission gate with hard lines the model cannot cross. Native in Rust, TypeScript, Python, Go, and C#. MIT-licensed, bring your own model.

It is the deepest layer of the Smoo AI platform — a library, not a product. Smoo AI's AI teammates run on this loop; the sixteen product areas around them are a much larger story than what is on this page.

MIT licensedFive native portsAny OpenAI-compatible endpointDeny-policy, not a prompt
The whole loop, and the brakes

Most frameworks hand the model a pile of tools and hope.

smooth-operator-core gives you the loop — and a way to say what it may never do, declared once and enforced on every tool call. It is the runtime the smooth-operator service actually ships on, which is in turn what Smoo AI's own agents run on in production. Not a reference design and not a notebook demo — but also not the platform itself: this crate knows nothing about a CRM, an inbox, or a customer. It runs a loop, safely.

Inspired by LangGraph, CrewAI, and Agno, with one hard difference: every surface is covered by hundreds of fast offline tests built on a deterministic mock LLM, so the loop is verified rather than asserted. The Rust implementation is the source of truth; the other four are native ports at parity, not bindings over FFI.

Five registries

Write your agent where your stack already lives.

Each port publishes to its own language's registry with its own README. The engine has zero hosted dependencies — it is a library.

LanguageInstallAgent class
Rust (reference)crates.iocargo add smooai-smooth-operator-coreAgent
TypeScriptnpmnpm install @smooai/smooth-operator-coreSmoothAgent
PythonPyPIpip install smooai-smooth-operator-coreSmoothAgent
Gopkg.go.devgo get github.com/SmooAI/smooth-operator-core/go/coreSmoothAgent
C# / .NETNuGetdotnet add package SmooAI.SmoothOperator.CoreSmoothAgent

Two naming details that will bite you once. In Rust the crate you install (smooai-smooth-operator-core) and the library you import (smooth_operator_core) are different names. And the top-level agent type is called Agent in Rust only — the four ports all name it SmoothAgent. There is no alias.

One tool, one model, one run

A whole agent, in about forty lines.

Declare a tool with a JSON-Schema parameter shape, register it, hand the agent an LLM, and run. The TypeScript and Python samples use the deterministic mock provider, so they run with no credentials at all.

cargo add smooai-smooth-operator-core
use smooth_operator_core::{Agent, AgentConfig, LlmConfig, Tool, ToolRegistry, ToolSchema};
use async_trait::async_trait;

struct GetWeather;

#[async_trait]
impl Tool for GetWeather {
    fn schema(&self) -> ToolSchema {
        ToolSchema {
            name: "get_weather".into(),
            description: "Get current weather for a city".into(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": { "city": { "type": "string" } },
                "required": ["city"]
            }),
        }
    }

    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
        let city = args["city"].as_str().unwrap_or("unknown");
        Ok(format!("Weather in {city}: 72F, sunny"))
    }
}

// The client is OpenAI-compatible: point api_url at OpenAI, an
// Anthropic-compatible endpoint, or your own gateway.
let llm = LlmConfig::openrouter(std::env::var("OPENROUTER_API_KEY")?)
    .with_model("openai/gpt-4o");

let config = AgentConfig::new("assistant", "You are a helpful assistant.", llm)
    .with_max_iterations(10)
    .with_parallel_tools(true);

let mut registry = ToolRegistry::new();
registry.register(GetWeather);

// run() returns the whole Conversation — the answer is its last assistant message.
let agent = Agent::new(config, registry);
let conversation = agent.run("What's the weather in Tokyo?").await?;

run() returns the completed conversation. For live token deltas, tool-call events, and tool-result events, use run_with_channel(message, tx) and consume the AgentEvent stream off the receiver — that is the same event stream the service turns into protocol frames.

The agent loop

Every edge on this diagram is a swappable trait.

That is the design in one sentence. The LLM, the tools, the confirmation surface, the cost tracker, the checkpoint store, memory, and knowledge are all seams — which is why the same loop runs in a Lambda, a container, or your test suite with nothing mocked but the provider.

Observecontext windowThinkLlmProvider::chatDonefinal AgentEventThe gatemode · breakers · denyActexecute toolsCharge + enforce budgetCostTrackerCheckpoint stepCheckpointStoreUpdate memoryMemory · KnowledgeBasetext answertool callsalloweddenied → told whyunder max iterationsmax reached · budget exceeded → done
What you get

Nine seams, one crate.

Read them cool to hot: the loop's quiet machinery, then the moment the agent reaches out, then the moment it is stopped.

An agent loop you can trust

observe → think → act with iteration caps, parallel tool calls, and a typed AgentEvent stream you can drive a UI from.

Stateful graphs

Workflow<S> and WorkflowBuilder<S> with typed state and conditional edges — plus sub_workflow_node, which makes a whole child graph a first-class vertex run to completion inside one parent step.

Resume after a crash

CheckpointStore, with in-memory and file stores built in and SQLite or Postgres behind a cargo feature. Each step of a turn is saved, so a dead process resumes instead of restarting.

Typed tools with guardrails

A Tool trait and ToolRegistry with pre/post hooks — the seam surveillance, secret detection, and prompt-injection guards attach to.

RAG and memory

KnowledgeBase and Memory as clean traits with in-memory implementations, so you can start without infrastructure and swap in yours later.

Spend control

Per-model ModelPricing, a CostBudget of dollars or tokens, and a CostTracker that refuses to exceed it rather than reporting the overrun afterwards.

Humans in the loop

ConfirmationHook plus human channels: name the tools that need a yes, and the loop blocks on a real answer with a timeout.

To deny what must never run

A permission gate with four modes, hard circuit-breakers that fire even in bypass, and a declarative DenyPolicy with a predicate seam for the rules strings cannot express.

Offline, deterministic tests

Every LLM call goes through an LlmProvider seam that tests satisfy with a mock — script responses, assert on the requests the agent actually sent, no network.

Permissions & deny-policy

Draw lines the agent can't cross.

This is the thing that makes an agent safe to point at real infrastructure: you decide what it can never do, and no prompt, jailbreak, or model mistake can talk it out of that. Every tool call passes through a gate before it runs.

Ask

The default. Read-only calls run; anything mutating stops for a human answer.

AcceptEdits

Edits proceed without asking. For loops you have already watched behave.

DenyUnmatched

Only what you explicitly allowed runs. Everything else is refused.

Bypass

No asking — and still not a free pass: circuit-breakers and deny-policy matches fire here too.

Circuit-breakers fire in every mode

rm -rf /, credential paths, pipe-to-shell, dangerous domains — these do not consult the mode. They fire in Bypass too. On top of that you attach a DenyPolicy: declarative TOML rules for the lines you can name, plus semantic predicates for the ones you cannot. A deny-policy match is a hard deny of circuit-breaker tier — no stored grant waives it, no mode downgrades it.

That is the difference between “we asked the model nicely” and “it structurally cannot.”

a policy with a rule you can write down, and one you can't
use smooth_operator_core::{Agent, AutoMode, DenyPolicy, DenyPredicate, DenyReason, ToolCall};

// The check strings can't express: is this AWS call the *prod account*?
// Is this DB connection the *writer* endpoint? Some(reason) denies.
struct DenyDbWriter;
impl DenyPredicate for DenyDbWriter {
    fn evaluate(&self, call: &ToolCall) -> Option<DenyReason> {
        (call.name == "db_query" && call.arguments.to_string().contains("writer"))
            .then(|| DenyReason::new("DB writer is off-limits — reads go to the replica"))
    }
}

// Declarative rules: never the prod AWS profile, never a prod host.
let policy = DenyPolicy::from_toml(r#"
    schema_version = 1
    [bash]
    deny_patterns = ["aws * --profile prod"]
    [network]
    deny_hosts = ["*.prod.internal"]
"#)?.with_predicate(Arc::new(DenyDbWriter));

let agent = Agent::new(config, registry)
    .with_permission_mode(AutoMode::Ask)
    .with_deny_policy(Arc::new(policy));

The deny-policy surface — TOML rules, the predicate seam, and the permission gate — is ported to all five languages. The deepest hardening around extensionsspecifically (subprocess sandboxing, integrity gates) is furthest along in Rust; the repo's docs/Polyglot-Engines.md keeps the honest per-language picture.

Composed, checkpointed, capped

The loop is the front door. Underneath it composes.

Persist progress so a crashed turn resumes, cap spend so a runaway loop cannot bill you into next quarter, and gate the irreversible tools behind a real human answer — all from the same crate, all traits with ready-made implementations.

checkpoints + a budget + a human gate
use smooth_operator_core::{
    Agent, AgentConfig, ToolRegistry, MemoryCheckpointStore,
    ConfirmationHook, human_channel, HumanResponse, CostBudget,
};

// 1. Persist progress so a crashed turn resumes instead of restarting.
//    (Swap in the sqlite or postgres store for durable, multi-process resume.)
let checkpoints = Arc::new(MemoryCheckpointStore::default());

// 2. Cap spend per session — the tracker refuses to exceed it.
let budget = CostBudget { max_cost_usd: Some(0.50), max_tokens: None };

// 3. Gate write/irreversible tools behind a human "yes". The hook fires for
//    any tool whose name contains one of these substrings.
let channels = human_channel();
registry.add_hook(ConfirmationHook::new(
    vec!["delete_".into(), "send_".into()],
    channels.request_tx,
    channels.response_rx,
    Duration::from_secs(300),
));

// Your UI drives the human loop: read each request, answer it.
let mut requests = channels.request_rx;
let responses = channels.response_tx;
tokio::spawn(async move {
    while let Some(req) = requests.recv().await {
        // Surface req to a human (Slack, dashboard, CLI) and answer.
        let _ = responses.send(HumanResponse::Approved);
        // or: HumanResponse::Denied { reason: "not allowed".into() }
    }
});

let agent = Agent::new(config.with_budget(budget), registry)
    .with_checkpoint_store(checkpoints);

Checkpoint stores

  • MemoryCheckpointStore · FileCheckpointStore — no feature needed
  • SqliteCheckpointStoresqlite feature, SQLite compiled in
  • PostgresCheckpointStorepostgres feature, pooled

Both database stores run the same conformance suite against real engines under Testcontainers, so “resume” means the same thing on each.

Cargo features

Exactly two, and default is empty. Both gates are consumed in one file — the checkpoint module — so a lean build is genuinely lean.

[features]
default  = []
sqlite   = ["rusqlite"]                # bundled SQLite
postgres = ["dep:postgres", "dep:r2d2",
            "dep:r2d2_postgres"]
Verified, not vibe-coded

The mock is the whole trick.

Every LLM call goes through the LlmProvider seam, so tests satisfy it with a mock that replays scripted text, tool-calls, errors, and streaming events in order — and records every request. A test can assert on the exact messages and tool schemas the agent sent, not just the final string.

assert on what the agent actually sent
use smooth_operator_core::llm_provider::{LlmProvider, MockLlmClient};

#[tokio::test]
async fn agent_uses_the_tool_then_answers() {
    let mock = MockLlmClient::new();
    mock.push_tool_call("call_1", "get_weather", serde_json::json!({ "city": "Tokyo" }));
    mock.push_text("It's 72F and sunny in Tokyo.");

    // ... drive the agent with mock injected as its LlmProvider ...

    assert_eq!(mock.call_count(), 2);
    let first = &mock.calls()[0];
    assert!(first.tools.iter().any(|t| t.name == "get_weather"));
}

Run them

cd rust/smooth-operator-core
cargo test                              # offline, seconds
cargo test --features sqlite,postgres   # + store conformance
cargo clippy --all-targets -- -D warnings

Clones of the mock share state, so the copy handed to the agent and the handle the test holds see the same script and the same recordings.

One import gotcha

MockLlmClient and LlmProvider are not re-exported at the crate root — reach for them through the module: smooth_operator_core::llm_provider::{LlmProvider, MockLlmClient}. The Rust README's snippets are mirrored by a test file, so they cannot drift; the module path is the one thing worth memorising.

Above the unit tier: the SQLite and Postgres checkpoint stores run the same suite against real engines, the service drives a real streamed, knowledge-grounded answer through a live gateway, and multi-turn conversation quality is scored by a judge model. That last tier caught a real defect a substring assertion would have missed — a multi-turn context regression that scored 1/5, was fixed, and went back to 5/5. The gateway-backed tiers are gated on credentials rather than run on every commit.

What “parity” actually means

Behavioral parity, verified. Symbol parity, not claimed.

The ports follow a protocol-first strategy: a stable wire spec each language implements natively, so the loop, tool system, permission gate, checkpointing, and cost accounting behave the same everywhere. The mechanism that actually holds them together lives in the service repo — a shared ten-scenario conformance corpus driven by this engine's deterministic mock, which every language's CI runs on any change to the spec.

What is not claimed is that the five APIs read identically. They are idiomatic ports, and the names diverge. Worth knowing before you port code between them:

ConceptRustTypeScript · Python · Go · C#
Top-level agentAgentSmoothAgent
ConfigurationAgentConfigAgentOptions
Test doubleMockLlmClientMockLlmProvider
In-memory checkpointsMemoryCheckpointStoreInMemoryCheckpointStore
Result of run()ConversationAgentRunResponse

Go omits an in-memory checkpoint store of that name, and C# spells the RAG seam IKnowledgeBase. Install commands and a hello-agent in every language live in the repo's docs/Polyglot-Engines.md.

Engine and service

The service is thin on purpose.

smooth-operator terminates a WebSocket protocol, resolves who is asking and what they are entitled to, and hands the turn to this engine. The engine's AgentEvent stream is what becomes stream_token and stream_chunk on the wire; its confirmation hook is what becomes write_confirmation_required. All the agent intelligence lives here.

One rung further up, the service is itself wrapped by Smoo AI's sixteen product areas — that is where an agent stops being a loop and starts being a teammate with a CRM, an inbox, and entitlements. None of that lives in this crate, which is the point of the split.

You do not need the service to use the engine. It is one crate with no hosted dependencies — embed it in a Lambda, a container, a CLI, or a test. Point api_urlat OpenAI, an Anthropic-compatible proxy, vLLM, or Ollama's OpenAI shim; or at llm.smoo.ai for unified billing, model routing, and cost tracking — the same gateway Smoo AI runs this engine against in production.

Start with one tool.

Install the crate, write a tool, run the loop against the mock. When it does what you want, swap the mock for a real gateway and add the deny-policy before you point it anywhere that matters.

$ cargo add smooai-smooth-operator-core
$ npm install @smooai/smooth-operator-core
$ pip install smooai-smooth-operator-core