Skip to main content

smooai-smooth-operator-server

Smoo AI license Python ≥ 3.11

Wiring a chat loop is a weekend project. A production agent server is not.

Sessions that survive a reconnect. A wire protocol your clients can actually speak. Streaming turns you can watch token by token. Tools the model can call — and hard limits on the ones it must never call. Human-in-the-loop when a tool wants to write.

smooai-smooth-operator-server is that server, async and native to Python. It speaks the smooth-operator wire protocol (spec/) and consumes the in-process smooai-smooth-operator-core engine — each turn runs a SmoothAgent and maps its stream onto stream_token / stream_chunk / eventual_response. It's the Python sibling of the Rust, Go, TypeScript, and C# servers, all speaking the one protocol.

The client lives in python/src (smooai-smooth-operator). This is the server half.


Spin up a real agent server

python -m smooth_operator_server
# → smooth-operator-server (local flavor, python) listening on ws://127.0.0.1:8787/ws

That's a full agent backend — sessions, streaming turns, tool-calling, citations — on one WebSocket, in-memory, auth off, zero config. Env knobs: SMOOTH_AGENT_BIND / SMOOTH_AGENT_PORT (default 127.0.0.1:8787), SMOOTH_AGENT_SEED_KB=1 for the demo knowledge docs. The gateway is read from SMOOAI_GATEWAY_URL / SMOOAI_GATEWAY_KEY — with no key, send_message returns a clean LLM_UNAVAILABLE error and the rest of the protocol still works.

Set SMOOTH_AGENT_PREAMBLE_MODEL to a fast model id (e.g. groq-gpt-oss-20b) and every streaming turn also runs that small model in parallel, emitting one ephemeral stream_preamble sentence ("what I'm about to do") to cover the reasoning model's time-to-first-token. Same gateway/key as the main turn, capped at 64 output tokens. It is suppressed the moment the real answer starts streaming, is never folded into the reply or persisted, and any failure is swallowed. Unset (the default) ⇒ no extra call, behavior unchanged.

Or embed it in your own async app:

import asyncio
from smooth_operator_server import ServerState, serve
from smooth_operator_server.session_store import InMemorySessionStore

async def main():
    state = ServerState(store=InMemorySessionStore(), chat_client=my_openai_client)
    server = await serve(state, "127.0.0.1", 0)  # port 0 → ephemeral
    print(server.ws_url())
    # ... drive real streaming turns ...
    await server.shutdown()  # graceful drain + clean exit

asyncio.run(main())


Run it as a container

docker build -f python/server/Dockerfile -t smooth-operator-server-py .   # from the repo ROOT
docker run --rm -p 8787:8787 -e SMOOAI_GATEWAY_KEY=sk-... smooth-operator-server-py
# ws://127.0.0.1:8787/ws

Bind and port come from SMOOTH_AGENT_BIND / SMOOTH_AGENT_PORT, the canonical names every server implementation reads. The image keeps the process's 8787 default port but flips the bind host to 0.0.0.0, since the process default of 127.0.0.1 is unreachable from outside a container. Narrow it back with -e SMOOTH_AGENT_BIND=127.0.0.1 when a sidecar fronts it on the pod loopback. Runs non-root (uid 10001); the coding tools are confined to /workspace, so mount your project there: -v "$PWD:/workspace".

This host's pre-parity SMOOTH_OPERATOR_BIND (combined host:port) and SMOOTH_OPERATOR_SEED_KB still work as aliases — the canonical name wins when both are set.

Extensible — and safe by construction

An agent is only useful when it can do things, and only trustworthy when you can say what it may never do. This server gives you both seams.

Give it your tools. Hand engine Tools to ServerState and they merge with the built-ins for every turn:

from smooth_operator_core import Tool  # the engine's tool base

class OpenTicket(Tool):
    name = "open_ticket"
    description = "Open a support ticket for the current customer."
    parameters = {"type": "object", "properties": {"subject": {"type": "string"}}}

    async def execute(self, arguments):
        return f"ticket opened: {arguments}"

state = ServerState(
    store=InMemorySessionStore(),
    chat_client=my_client,
    tools=[OpenTicket()],
)

Or let it gain tools with no redeploy. The server hosts SEP extensions — out-of-process tool providers discovered at runtime, their ui/confirm prompts bridged into the protocol's confirmation frames for HITL. Gated: an extension contributes tools only if you name it in SMOOTH_EXTENSIONS_ALLOW. Nothing loads by default.

Now declare the lines it can't cross. Install an AgentConfigResolver, and every tool — built-in, yours, or from an extension — flows through the same gates:

  • Per-agent allow-list — an agent's tool_config.enabledTools restricts its turn to exactly those tools. Off the list, off the table.
  • The authLevel gate — a tool declaring supports_auth_requirement = True is blocked at call time on a public agent when tagged admin, or when tagged end_user and the session isn't identity-verified (its OTP bit, or your SessionAuthenticator seam). Fail-closed — an absent authenticator means "not authenticated".
  • End-user OTP flow — a refused end_user tool can offer a one-time-code identity flow via the OtpService seam; the server never generates, delivers, or validates a code (the host owns generation, delivery, expiry, attempt counting).
  • Per-user conversation scoping — with auth enabled, a conversation is owned by the authenticated principal's email (the token's email claim, never the client-supplied userEmail frame field). list_conversations returns only the caller's; resuming or acting on someone else's session is reported exactly like an id that never existed, so it can't be used to probe for other users' ids. Fail-closed — an authenticated principal with no email scopes to nothing. Auth disabled (the local single-tenant flavor) is the only unscoped mode.

You decide what the agent can touch; the runner enforces it.


What it does

Piece Module Mirrors
WS transport + per-connection loop + single writer server.py Rust server.rs, C# SmoothOperatorWebSocketExtensions
Frame dispatch (ping / create / get / send_message / cancel) dispatcher.py C# FrameDispatcher, Rust handler.rs
Session + message store session_store.py C# SessionStore, Rust storage adapter
Durable Postgres storage (sessions + admin stores) postgres_store.py Rust adapters/postgres, C# PostgresSessionStore
Streaming turn (engine → protocol events) turn_runner.py C# TurnRunner, Rust runner.rs
Per-agent config (instructions / workflow / persona / tools) agent_config.py monorepo agents schema
Conversation-workflow steps + post-turn judge workflow.py monorepo general-agent/workflow.ts
SEP extension hosting extensions.py Rust extensions.rs
Auth verifier seam (permissive + local HS256 JWT) auth.py C# Auth.cs, Rust verifier seam

Turn cancellation (the "Stop button"). {"action": "cancel", "requestId": "<the send_message requestId>"} cancels the in-flight turn's asyncio.TaskCancelledError fires at its next await, abandoning the LLM/tool call — and emits the terminal cancelled event (status: 499, echoing the turn's requestId) in place of the eventual_response. One active turn per connection: a second send_message mid-turn is rejected with TURN_IN_PROGRESS, never run concurrently. A cancel with no active turn is a silent no-op. Partial output is discarded — the user's message is persisted at the start of the turn so it stays, the assistant reply only at the end, which the cancellation skips. A client disconnect mid-turn aborts the turn too.

Graceful SIGTERM drain. A shared asyncio.Event cancel switch is the single source of truth for "stop". Each connection loop races "cancel set" vs "next inbound frame" — with the turn dispatch awaited inside the frame branch, so an in-flight turn finishes before the loop exits (a drain lets a turn complete; only a client cancel/disconnect aborts one), then a backplane detach always runs.

Per-agent config + conversation workflows. create_conversation_session carries only an agent UUID, so config is resolved server-side per turn: an agent's instructions become its system prompt; personality / greeting are appended (greeting only on the first turn); a conversation_workflow (goal + ordered steps) renders the current step into the prompt and a cheap post-turn judge advances the pointer when the criteria are met. Parsing is tolerant (malformed → server default, never crashes a session) and the judge is failure-tolerant (any error → stay on the current step). With no resolver installed, behavior is unchanged.


Five languages, one protocol

The same server — same wire protocol, same conformance corpus — exists in five languages. Run it where your stack already lives.

Language Server package Registry
Python smooai-smooth-operator-server in-repo (this package)
Rust smooai-smooth-operator-server crates.io
C# / .NET SmooAI.SmoothOperator.Server in-repo
TypeScript @smooai/smooth-operator-server in-repo
Go github.com/SmooAI/smooth-operator/go/server in-repo

Every native client — TypeScript, Go, .NET, Python, Rust — connects to any of them unmodified.


Develop

cd python/server
uv sync
uv run --quiet ruff format .
uv run --quiet ruff check .
uv run --quiet pytest -q

The Postgres store tests spin up a throwaway container (testcontainers) and skip cleanly when Docker is unreachable, so the suite needs no daemon. On OrbStack, testcontainers' Ryuk reaper can hang before the database container starts — prefix with TESTCONTAINERS_RYUK_DISABLED=true if they skip with Docker plainly running.

Durable storage

In-memory is the default. For persistence across restarts:

uv sync --extra postgres
export SMOOTH_AGENT_STORAGE=postgres
export SMOOTH_AGENT_DATABASE_URL=postgresql://user:pass@host/db   # or DATABASE_URL

Sessions, conversations, participants, messages and the three /admin/* stores (connector configs, agent settings, indexing runs) then live in Postgres, on the same tables every other server in this repo uses. asyncpg is an optional dependency — nothing imports it unless SMOOTH_AGENT_STORAGE=postgres selects the backend.


Part of the smooth-operator service — Smoo AI's polyglot AI agent service. smooth-operator powers the Smoo AI platform in production.

License

MIT © 2026 Smoo AI. See LICENSE.

Release files for smooai-smooth-operator-server 1.58.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for smooai-smooth-operator-server 1.58.2
File Size Uploaded
smooai_smooth_operator_server-1.58.2.tar.gz 255.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for smooai-smooth-operator-server 1.58.2
File Interpreter ABI Platform
smooai_smooth_operator_server-1.58.2-py3-none-any.whl Python 3 none any Details

Total release size: 382.9 kB

Release files / smooai_smooth_operator_server-1.58.2.tar.gz

Download URL smooai_smooth_operator_server-1.58.2.tar.gz
Size 255.7 kB
Tags Source
SHA-256 checksum
How to use checksums
cce368a5143983342301ca5de5f7a61058c9178a9ffddd34b612a94eb0c16bf6
BLAKE2b-256 checksum
How to use checksums
27d332d97338549ff717513b604e5dce55c2fcdbe2f8b5285af17d464cfe1fad
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.16

Release files / smooai_smooth_operator_server-1.58.2-py3-none-any.whl

Download URL smooai_smooth_operator_server-1.58.2-py3-none-any.whl
Size 127.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6f9b0687486eccfeb2e932fc9adc0d60904d19228b6e277260b349d7514dbeac
BLAKE2b-256 checksum
How to use checksums
8998389d4e74186001a719a1fe5107af99ea2eb45366c4b46b765c501de4a805
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.16

Release history Release notifications | RSS feed

1.58.9

2 release files

1.58.8

2 release files

1.58.7

2 release files

1.58.6

2 release files

1.58.5

2 release files

1.58.4

2 release files

1.58.3

2 release files

This release

1.58.2 This release

2 release files

1.58.1

2 release files

1.57.2

2 release files

1.57.1

2 release files

1.57.0

2 release files

1.56.7

2 release files

1.56.6

2 release files

1.56.5

2 release files

1.56.4

2 release files

1.56.3

2 release files

1.56.2

2 release files

1.56.1

2 release files

1.56.0

2 release files

1.55.2

2 release files

1.55.0

2 release files

1.54.3

2 release files

1.54.1

2 release files

1.54.0

2 release files

1.53.0

2 release files

1.52.3

2 release files

1.52.2

2 release files

1.52.1

2 release files

1.51.1

2 release files

1.50.3

2 release files

1.50.2

2 release files

1.49.1

2 release files

1.49.0

2 release files

1.48.0

2 release files

1.47.1

2 release files

1.47.0

2 release files

1.46.7

2 release files

1.46.6

2 release files

1.46.5

2 release files

1.46.4

2 release files

1.46.3

2 release files

1.46.1

2 release files

1.46.0

2 release files

1.45.3

2 release files

1.45.1

2 release files

1.45.0

2 release files

1.44.6

2 release files

1.44.5

2 release files

1.44.4

2 release files

1.44.3

2 release files

1.44.1

2 release files

1.44.0

2 release files

1.43.0

2 release files

1.42.0

2 release files

1.41.0

2 release files

1.40.0

2 release files

1.39.0

2 release files

1.36.9

2 release files

1.36.8

2 release files

1.36.7

2 release files

1.36.6

2 release files

1.36.2

2 release files

1.36.1

2 release files

1.36.0

2 release files

1.35.0

2 release files

1.34.0

2 release files

1.33.0

2 release files

1.32.1

2 release files

1.32.0

2 release files

1.30.0

2 release files

1.28.0

2 release files

1.27.2

2 release files

1.27.0

2 release files

1.26.0

2 release files

1.23.3

2 release files

1.23.2

2 release files

1.23.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page