host()

September 5, 2026 · View on GitHub

Make your agent accessible over the network. One function call.

Looking to deploy? See Deploy Your Agent for production deployment options.

Need an .ics feed or H5 API? See Custom HTTP routes.


Quick Start (60 Seconds)

from connectonion import Agent, host

# Define your agent
def create_agent():
    return Agent("translator", tools=[translate])

# Make it network-accessible
host(create_agent)

Output:

INFO: Loaded environment: /Users/you/my-agent/.env
INFO: Loaded global keys: /Users/you/.co/keys.env

[agent] ─────────────────────────────────────
        translator
        co/gemini-3.8-flash • 12 tools

[host]  ─────────────────────────────────────
        http://localhost:8000
        POST /input · WS /ws · GET /docs

        0x3d4017c3e843895a92b70aa74d1b7ebc9c98...
        ↳ chat.openonion.ai ↗
        ✓ relay

        config: /Users/you/my-agent/.co/host.yaml
        logs: /Users/you/my-agent/.co/logs

Waiting for tasks...

That's it. Your agent is now accessible via HTTP, WebSocket, and P2P relay.

By default, your agent is automatically discoverable. Anyone with your address can connect:

from connectonion import connect

# From anywhere in the world
translator = connect("0x3d4017c3e843895a92b70aa74d1b7ebc9c98...")
result = translator.input("Hello!")

The relay handles all the complexity:

  • Agent registers its endpoints (localhost, local IPs, public IP)
  • Client queries relay by address
  • SDK tries direct connection first (fastest path)
  • Falls back to relay routing if needed

See Agent-Relay Protocol for technical details.


Function Signature

def host(
    create_agent: Callable[[], Agent],  # Factory that returns fresh Agent

    # Trust
    trust: Union[str, Agent] = "careful",
    blacklist: list = None,
    whitelist: list = None,

    # Server
    port: int = 8000,
    workers: int = 1,

    # Storage
    result_ttl: int = 86400,  # 24 hours (how long server keeps results)

    # P2P Discovery
    relay_url: str = "wss://oo.openonion.ai/ws/announce",

    # Development
    reload: bool = False,

    # File Upload Limits
    max_file_size: int = 10,               # MB per file
    max_files_per_request: int = 10,       # Max files in one request

    # Deterministic HTTP resources (optional)
    http: HTTPRouter = None,
) -> None:

How It Works

When you call host(create_agent), your agent becomes accessible via three connection types, all running in the same event loop:

┌─────────────────────────────────────────────────────────────────────────────┐
│                            host(create_agent)                                │
│                                                                              │
│  Single Event Loop (uvicorn)                                                │
│  ═══════════════════════════                                                │
│                                                                              │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────────────────┐ │
│  │   HTTP Client   │  │  WebSocket      │  │  Relay Server               │ │
│  │   (curl, SDK)   │  │  Client (chat)  │  │  (wss://oo.openonion.ai)    │ │
│  └────────┬────────┘  └────────┬────────┘  └──────────────┬──────────────┘ │
│           │                    │                          │                 │
│           ▼                    ▼                          ▼                 │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────────────────┐ │
│  │  POST /input    │  │  WS /ws         │  │  Persistent WebSocket       │ │
│  │  GET /sessions  │  │  INPUT/OUTPUT   │  │  ANNOUNCE (register)        │ │
│  │  GET /health    │  │  PING/PONG      │  │  INPUT → OUTPUT (relay)     │ │
│  │  GET /info      │  │  Real-time      │  │  Heartbeat (60s)            │ │
│  └────────┬────────┘  └────────┬────────┘  └──────────────┬──────────────┘ │
│           │                    │                          │                 │
│           └────────────────────┼──────────────────────────┘                 │
│                                │                                            │
│                                ▼                                            │
│                    ┌───────────────────────┐                               │
│                    │  create_agent()       │  ← Fresh instance per request │
│                    │  agent.input(prompt)  │                               │
│                    │  Return result        │                               │
│                    └───────────────────────┘                               │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Connection Type 1: HTTP (POST /input)

Direct HTTP request/response. Best for simple integrations.

curl -X POST http://localhost:8000/input \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Hello"}'

Flow:

  1. Client sends HTTP POST with {prompt, session?}
  2. Server checks trust (blacklist/whitelist/policy)
  3. Server calls create_agent() for fresh instance
  4. Server calls agent.input(prompt)
  5. Server returns {result, session_id, session}

Connection Type 2: WebSocket (/ws)

Bidirectional real-time connection. Best for chat UIs with streaming.

const ws = new WebSocket("ws://localhost:8000/ws");
ws.send(JSON.stringify({type: "INPUT", prompt: "Hello"}));
ws.onmessage = (e) => console.log(JSON.parse(e.data));

Flow:

  1. Client connects via WebSocket
  2. Server sends PING every 30s (keep-alive)
  3. Client sends INPUT message
  4. Server streams events (thinking, tool_call, tool_result)
  5. Server sends OUTPUT with final result

Connection Type 3: Relay (wss://oo.openonion.ai)

Persistent connection to relay server for discoverability. Runs automatically.

Client                          Relay Server                        Agent
  │                                  │                                │
  │                                  │<── ANNOUNCE (register) ────────│  Every 60s
  │                                  │    {address, endpoints}        │
  │                                  │                                │
  │── CONNECT {session_id} ────────>│── forward as-is ─────────────>│
  │                                  │                                │
  │<── CONNECTED {session_id} ─────│<── route by session_id ────────│
  │                                  │                                │
  │── INPUT ───────────────────────>│── forward as-is ─────────────>│
  │                                  │                                │
  │<── streaming {session_id} ─────│<── route by session_id ────────│
  │    (thinking, tool_call, ...)    │                                │
  │                                  │                                │
  │<── OUTPUT {session_id} ────────│<── route by session_id ────────│

How it works:

On startup, the agent process runs two things in the same event loop:

  • ASGI server on port 8000 — handles direct HTTP and WebSocket connections
  • Relay loop — maintains a long-lived WebSocket to wss://oo.openonion.ai/ws/announce

When a client connects through the relay:

  1. Client creates a session_id and opens WebSocket to relay's /ws/input
  2. Relay forwards messages as-is to the agent through the announce WebSocket
  3. Agent's relay loop routes by session_id and opens a local WebSocket to its own ws://127.0.0.1:8000/ws — the same endpoint that handles direct connections
  4. The local /ws handler processes the full protocol (CONNECT, INPUT, streaming, OUTPUT) as if it were a normal client
  5. Every response carries session_id (injected by the /ws handler). Responses flow back: local /ws → relay loop → announce WebSocket → relay routes by session_id → client

The relay is a pure forwarder — it doesn't parse or modify messages, just routes by session_id. All protocol handling (authentication, session management, IO queues, streaming events) happens in the existing /ws handler. This means relay connections get the exact same features as direct connections: streaming, tool calls, ask_user, session recovery, etc.

Registration:

  1. Agent sends ANNOUNCE with address and endpoints every 60s
  2. Relay stores agent info (now discoverable)
  3. When client calls connect("0xaddress"), SDK tries direct connection first, falls back to relay if needed

Endpoint discovery is best-effort. AGENT_PUBLIC_DOMAIN bypasses automatic discovery. Otherwise the Host tries local interfaces and a public-IP lookup independently; if a container denies interface enumeration, or the public lookup is unavailable, the Host still starts and keeps its relay connection. When neither source yields a publishable address, the announcement carries no direct endpoint and clients use the relay.

Heartbeat & Keep-Alive

ConnectionMechanismIntervalPurpose
HTTPNoneN/AStateless request/response
WebSocket /ws (direct or via relay)Server PING → client30sKeep the client session alive; client replies PONG
Agent ↔ relay linkAgent ANNOUNCE60sStay registered, update endpoints

A relay session has two independent keepalives. The 30s server PING is the client-session keepalive — it is forwarded end-to-end through the relay to the browser/SDK so its 60s-silence monitor doesn't declare the connection dead and reconnect. The 60s ANNOUNCE is a different thing: it only keeps the agent↔relay link alive and registered, and never reaches the client, so it can't substitute for the PING. (This is why run_ws_session runs with enable_ping=True on the relay path too — earlier builds disabled it there and idle relay sessions churned every ~60s.)

Relay cleanup: Agents not announcing for 120s are removed from registry.

Relay connection stability

The relay loop is a long-lived supervisor, not a one-shot connect. A relay registration is meant to live for days, so any transient fault — a network blip surfacing as OSError, the relay redeploying, a DNS hiccup, a malformed frame — is normal operation, not a bug. The supervisor catches everything except cancellation, logs, backs off, and reconnects:

  • Survive any transient fault. Without this, the first non-ConnectionClosed error would escape the loop and silently kill the announce task: the agent keeps serving direct connections but never announces again, so its relay registration goes stale and it becomes unreachable via the relay until the process restarts.
  • Capped exponential backoff with jitter. Reconnect delays follow 1, 2, 4, 8, 16, 30s (capped). The first retry is an exact 1s for fast single-blip recovery; from the second attempt on, up to 1s of random jitter is added so a recovering relay doesn't get a thundering herd. A clean disconnect resets the counter, so the next reconnect is immediate.
  • Escalate persistent failures. After 5 consecutive failures the log line goes loud (red) so a permanent problem — revoked key, decommissioned relay, a real bug — is surfaced instead of buried in a dim 1s loop forever.

On the model side, the OpenOnion LLM client uses a widened 20s connect timeout and 5 retries (600s read budget) so a brief network blip is absorbed rather than aborting the whole agent run with a timeout.

Worker Isolation

Each request calls your function to get a fresh agent instance:

# Request A and B arrive simultaneously
# Each calls create_agent() and gets its own fresh agent
# No interference, no race conditions

State Control

You control what's isolated vs shared via closure:

# Isolated state (default, safest) - create tools inside:
def create_agent():
    browser = BrowserTool()  # Fresh per request
    return Agent("assistant", tools=[browser])

# Shared state (advanced) - create tools outside:
browser = BrowserTool()  # Expensive resource, shared across requests
def create_agent():
    return Agent("assistant", tools=[browser])

For horizontal scaling, use uvicorn workers:

host(create_agent, workers=4)  # 4 OS processes, each with isolated agents

HTTP API

POST /input

Submit input. Creates a session, returns session_id.

curl -X POST http://localhost:8000/input \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Translate hello to Spanish"}'

Response:

{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "done",
  "result": "Hola",
  "duration_ms": 1250,
  "session": {
    "session_id": "550e8400-e29b-41d4-a716-446655440000",
    "messages": [...],
    "trace": [...],
    "turn": 1
  }
}

Multi-turn Conversations

To continue a conversation, pass the session from the previous response:

# First request
curl -X POST http://localhost:8000/input \
  -H "Content-Type: application/json" \
  -d '{"prompt": "My name is John"}'

# Response includes session
# {"result": "Nice to meet you, John!", "session": {...}}

# Second request - pass session back
curl -X POST http://localhost:8000/input \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "What is my name?",
    "session": {...}
  }'

# Agent remembers: "Your name is John"

Request format:

{
  "prompt": "What is my name?",
  "session": {                    // Optional - pass previous session to continue
    "session_id": "abc-123",      // Server-generated, included in session
    "messages": [...],
    "trace": [...],
    "turn": 1
  },
  "images": [                     // Optional - base64 data URLs
    "data:image/png;base64,iVBOR..."
  ],
  "files": [                      // Optional - base64 encoded files
    {
      "name": "document.pdf",
      "data": "data:application/pdf;base64,JVBERi..."
    }
  ]
}

Note: session_id is always generated by the server. For new conversations, omit session. For continuations, pass the entire session object from the previous response.

See Multimodal Input for details on sending images and files.

Response format:

{
  "session_id": "abc-123",
  "status": "done",
  "result": "Your name is John",
  "duration_ms": 850,
  "session": {                    // Always returned - save for next request
    "session_id": "abc-123",
    "messages": [...],
    "trace": [...],
    "turn": 2
  }
}

GET /sessions/{session_id}

Fetch session result anytime.

curl http://localhost:8000/sessions/550e8400-e29b-41d4-a716-446655440000

Response (running):

{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "running"
}

Response (done):

{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "done",
  "result": "Hola",
  "duration_ms": 1250
}

GET /sessions

List recent sessions.

curl http://localhost:8000/sessions

Response:

{
  "sessions": [
    {"session_id": "abc-123", "status": "done", "created": 1702234567},
    {"session_id": "def-456", "status": "running", "created": 1702234570}
  ]
}

GET /health

Health check for load balancers.

curl http://localhost:8000/health

Response:

{
  "status": "healthy",
  "agent": "translator",
  "uptime": 3600
}

GET /info

Agent capabilities and metadata, including accepted input types and file limits.

curl http://localhost:8000/info

Response:

{
  "name": "translator",
  "address": "0x3d4017c3...",
  "tools": ["translate", "detect_language"],
  "model": "co/gemini-3.8-flash",
  "trust": "careful",
  "version": "0.4.1",
  "accepted_inputs": {
    "text": true,
    "images": true,
    "files": {
      "max_file_size_mb": 10,
      "max_files_per_request": 10
    }
  }
}

The accepted_inputs field tells clients what input types the agent supports and any file size limits. File limits are configured in host.yaml (see host.yaml Configuration).

GET /docs

Interactive UI to test your agent in the browser.

http://localhost:8000/docs

GET /admin/logs (Admin authentication)

Fetch agent activity logs (plain text). Prefer a signed identity listed in .co/admins.txt. Non-interactive monitoring may use a distinct, per-deployment CONNECTONION_ADMIN_TOKEN:

export CONNECTONION_ADMIN_TOKEN="$(openssl rand -hex 32)"
curl http://localhost:8000/admin/logs \
  -H "Authorization: Bearer $CONNECTONION_ADMIN_TOKEN"

Response:

2024-01-15 10:23:45 [translator] Processing: Translate hello
2024-01-15 10:23:46 [translator] Tool: translate_text executed (450ms)
2024-01-15 10:23:46 [translator] Result: Hola

GET /admin/sessions (Admin authentication)

Fetch eval sessions from .co/evals as JSON array. It uses the same signed admin or dedicated admin-token authentication as /admin/logs.

curl http://localhost:8000/admin/sessions \
  -H "Authorization: Bearer YOUR_ADMIN_TOKEN"

Response:

{
  "sessions": [
    {
      "name": "translator",
      "created": "2024-01-15T10:23:45Z",
      "updated": "2024-01-15T10:23:46Z",
      "total_cost": 0.0012,
      "total_tokens": 215,
      "turns": [
        {"role": "user", "content": "Translate hello to Spanish"},
        {"role": "assistant", "content": "Hola"}
      ]
    }
  ]
}

Important: OPENONION_API_KEY is only a managed-model billing credential and is never an admin password. If bearer automation is needed, generate a separate random CONNECTONION_ADMIN_TOKEN; configuring it to the billing key fails closed. Signed admin requests need no bearer token.


WebSocket API

WebSocket provides real-time communication with automatic keep-alive and session recovery. The protocol separates connection from messaging — connect first, then send messages.

See WebSocket Protocol for the full specification.

Step 1: Open WebSocket

const ws = new WebSocket("ws://localhost:8000/ws");

Step 2: INIT (authenticate + new session)

ws.onopen = () => {
  ws.send(JSON.stringify({
    type: "INIT",
    to: "0x3d4017c3e843...",
    payload: { to: "0x3d4017c3e843...", timestamp: Date.now() / 1000 },
    from: "0xYourPublicKey",
    signature: "0x..."
  }));
};

Server responds with CONNECTED:

{ "type": "CONNECTED", "session_id": "550e8400-...", "status": "new" }

Step 3: INPUT (send prompts)

ws.send(JSON.stringify({
  type: "INPUT",
  prompt: "Translate hello to Spanish"
}));

No signature needed — the connection is already authenticated by INIT.

Receive Messages

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  switch (msg.type) {
    case "CONNECTED":
      console.log("Session:", msg.session_id, "Status:", msg.status);
      break;
    case "PING":
      ws.send(JSON.stringify({ type: "PONG" }));
      break;
    case "OUTPUT":
      console.log("Result:", msg.result);
      break;
    case "ERROR":
      console.error("Error:", msg.message);
      break;
    default:
      console.log("Event:", msg);
  }
};

Message Types

TypeDirectionPurpose
INITClient → ServerAuthenticate + new session
ATTACHClient → ServerAuthenticate + resume existing session
CONNECTEDServer → ClientSession info (session_id, status)
INPUTClient → ServerSend prompt (no auth needed)
EXECClient → ServerRun one tool directly, no LLM
OUTPUTServer → ClientFinal result + session data
EXEC_RESULTServer → ClientResult of an EXEC
PINGServer → ClientKeep-alive (every 30s)
PONGClient → ServerAcknowledge keep-alive
tool_callServer → ClientTool started
tool_resultServer → ClientTool completed
thinkingServer → ClientAgent is processing
ask_userServer → ClientAgent needs input
approval_neededServer → ClientTool approval required
ERRORServer → ClientError message

Direct tool execution (EXEC)

Besides the LLM loop, a hosted agent exposes a direct execution fast path: clients can run one registered tool with no LLM via EXEC / EXEC_RESULT (remote.call in Python, co call from the shell). It's gated by the same .co/host.yaml permissions whitelist the LLM approval flow uses — nothing to enable, and only whitelisted commands run. See remote-call.md.

Session Recovery

If your WebSocket disconnects, reconnect with ATTACH:

ws.send(JSON.stringify({
  type: "ATTACH",
  to: "0x3d4017c3e843...",
  session_id: savedSessionId,
  payload: { ... },
  from: "0x...",
  signature: "0x..."
}));
// → CONNECTED { status: "running" } + buffered events

If the session expired from memory, poll the HTTP endpoint:

const response = await fetch(`http://localhost:8000/sessions/${sessionId}`);
const data = await response.json();

Session lifecycle:

  • Results stored for 24 hours (configurable via result_ttl)
  • Status: "new" | "running" | "completed" | "expired"
  • Auto-reconnection on page refresh (client sends CONNECT with session_id)

Design: Stateless Sessions

ConnectOnion uses client-managed sessions for multi-turn conversations. This section explains why.

Why Full session Instead of Just messages?

You might expect an API like Anthropic or OpenAI that only passes messages:

// What Anthropic/OpenAI do
{"messages": [...]}

But ConnectOnion passes the full session:

// What ConnectOnion does
{"session": {"messages": [...], "trace": [...], "turn": 2}}

The reason: ConnectOnion is not just an LLM API wrapper. It's an agent framework with:

FeatureNeeds Session Data
Activity loggingtrace - tool executions, timings
Turn trackingturn - conversation turn count
XRay debuggingFull execution context
Session replayComplete session history

If we stripped down to just messages, we'd lose:

  • Execution trace (which tools ran, how long they took)
  • Turn count (for accurate logging)
  • Debugging context (for XRay inspection)

ConnectOnion's value is debugging and observability. The full session preserves that.

Why Client-Managed State?

The server doesn't store your session. You store it, you send it back. Like:

PatternHow It Works
JWT tokensServer gives you a token, you send it with each request
Game save filesGame gives you save data, you store it, you load it
ConnectOnion sessionsServer gives you session, you store it, you send it back

Benefits:

  1. Infinitely scalable - No server-side session storage
  2. Client transparency - You can inspect the session, see exactly what's happening
  3. Coherent logs - Server logs preserve turn count, trace
  4. Easy debugging - Session contains full execution history
  5. No session cleanup - No TTL, no expiry, no "session not found" errors

Comparison

APIApproachState Storage
Anthropic Messages APImessages arrayClient
OpenAI Chat APImessages arrayClient
OpenAI Assistants APIthread_idServer
ConnectOnionsession objectClient

We chose client-managed state like Anthropic/OpenAI's Messages API, but with the full session object to preserve ConnectOnion's debugging features.

When to Use Sessions

# Single request - no session needed
{"prompt": "Translate hello to Spanish"}

# Multi-turn conversation - pass session
{"prompt": "What did I ask you?", "session": {...}}

# Start fresh - omit session
{"prompt": "New conversation"}

Simple rule: Save the session from each response. Pass it back if you want to continue.


Multimodal Input (Images & Files)

Both HTTP and WebSocket endpoints accept images and files alongside text prompts.

Images

Pass base64 data URLs in the images array:

curl -X POST http://localhost:8000/input \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "What do you see in this image?",
    "images": ["data:image/png;base64,iVBORw0KGgo..."]
  }'

Files

Pass files as objects with name and base64 data:

curl -X POST http://localhost:8000/input \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Summarize this document",
    "files": [
      {"name": "report.pdf", "data": "data:application/pdf;base64,JVBERi..."}
    ]
  }'

How files are handled internally: Unlike images (which are passed directly to the LLM as visual content), file data is not inserted into the LLM messages. Instead:

  1. The file is decoded from base64 and saved to .co/uploads/{filename}
  2. The agent receives a system reminder with the file path, prompting it to use read_file or other available tools to read the contents
  3. The agent's tools read the file from disk

This means your agent needs tools that can read files (e.g. read_file, bash, or other file-reading tools) to process uploaded files.

File Size Limits

File uploads are validated against configurable limits (default: 10MB per file, 10 files per request). Configure in host.yaml:

max_file_size: 10                 # MB per file
max_files_per_request: 10         # Max files in one request

Or in code:

host(create_agent, max_file_size=50, max_files_per_request=5)

When limits are exceeded, the server returns a 400 error:

{"error": "File too large: video.mp4 (150.2MB, max: 10MB). Increase max_file_size in host.yaml"}

Client-Side (connect)

from connectonion import connect

agent = connect("0xaddress...")

# Send with images
result = agent.input("Describe this", images=["data:image/png;base64,..."])

# Send with files
result = agent.input("Summarize", files=[
    {"name": "report.pdf", "data": "data:application/pdf;base64,..."}
])

Feature Parity

Images and files work identically across all connection types:

FeatureHTTP POST /inputWebSocket /wsRelay
Textprompt fieldprompt fieldprompt field
Imagesimages arrayimages arrayimages array
Filesfiles arrayfiles arrayfiles array
File validation400 errorERROR messageERROR message

Project Structure

When you run host(agent), these files are used:

your-project/
├── agent.py                  # Your agent code
├── .co/                      # ConnectOnion data folder
│   ├── session_results.jsonl # Session result storage (created by host)
│   ├── logs/                 # Activity logs (existing)
│   └── sessions/             # Session YAML (full conversation history)
└── .env                      # API keys (optional)

On the network:

┌─────────────────────────────────────────────────────────────┐
│                        Your Agent                            │
│                      host(agent)                             │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   HTTP Server (localhost:8000)                               │
│   ├── POST /input          ← Submit prompts                  │
│   ├── GET  /sessions/{id}  ← Fetch results                   │
│   ├── GET  /sessions       ← List sessions                   │
│   ├── GET  /health         ← Health check                    │
│   ├── GET  /info           ← Agent info                      │
│   ├── GET  /docs           ← Interactive UI                  │
│   ├── GET  /admin/logs     ← Activity logs (admin auth)      │
│   ├── GET  /admin/sessions ← Session logs (admin auth)       │
│   └── WS   /ws             ← Real-time WebSocket             │
│                                                              │
│   P2P Relay Connection                                       │
│   └── wss://oo.openonion.ai/ws/announce                      │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Session Result Storage

Results are stored locally in .co/session_results.jsonl (JSON Lines format):

{"session_id":"550e8400","prompt":"Translate hello","status":"running","created":1702234567,"expires":1702320967}
{"session_id":"550e8400","prompt":"Translate hello","status":"done","result":"Hola","created":1702234567,"expires":1702320967}

Why .co/ folder?

  • Consistent with .co/logs/ and .co/evals/ (existing patterns)
  • Project-specific (each project has its own results)
  • Already in .gitignore
  • Easy to find (in project directory)

Why JSON Lines?

  • Human readable (cat session_results.jsonl)
  • Append-only (safe for multiple workers)
  • Single file (easy to manage)
  • Queryable (grep "550e8400" session_results.jsonl)

Benefits:

  • Connection drops? Fetch result later via GET /sessions/{session_id}
  • Client restarts? Results still there
  • Debug issues? Read the file directly
  • Multiple workers? Append-only = no race conditions

TTL Expiry

Each result has an expires timestamp. Default: 24 hours after creation.

host(agent)                     # Default: 24h TTL
host(agent, result_ttl=3600)    # 1 hour
host(agent, result_ttl=604800)  # 7 days

Expired results are automatically cleaned up. Running sessions are never cleaned even if expired.

View Results

# See all results
cat .co/session_results.jsonl

# Find specific session
grep "550e8400" .co/session_results.jsonl | tail -1

# See running sessions
grep '"status":"running"' .co/session_results.jsonl

# Pretty print
cat .co/session_results.jsonl | jq .

Authentication (Signed Requests)

For secure communication, requests can be signed with Ed25519.

Signed Request Format

{
  "payload": {
    "prompt": "Translate hello",
    "to": "0xAgentPublicKey",
    "timestamp": 1702234567
  },
  "from": "0xClientPublicKey",
  "signature": "0x..."
}

How Signing Works

import json
from nacl.signing import SigningKey

# Sign the payload directly
payload = {"prompt": "...", "to": "...", "timestamp": ...}
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
signature = signing_key.sign(canonical.encode()).signature.hex()

Authentication Modes

Trust LevelRequired Auth
openNone (anonymous OK)
carefulSignature recommended
strictSignature required

The trust Parameter

Trust controls who can access your agent. All forms of trust use a trust agent behind the scenes.

See Trust in ConnectOnion for the complete trust system documentation.

1. Trust Level (string)

Pre-configured trust agents for common scenarios:

host(agent, trust="open")      # Accept all (development)
host(agent, trust="careful")   # Admin, whitelisted and contacts (default; every request is signed)
host(agent, trust="strict")    # Require valid signature (production)
LevelBehavior
openAccept all requests, no verification
carefulRecommend signature, accept unsigned requests
strictRequire identity and valid signature

2. Trust Policy (natural language)

Express requirements in plain English - an LLM evaluates each request:

host(agent, trust="""
I trust requests that:
- Come from known contacts with good history
- Have valid signatures
- Are on my whitelist OR from local network

I reject requests that:
- Come from blacklisted addresses
- Have no identity in production
""")

# Or from a file
host(agent, trust="./trust_policy.md")

3. Trust Agent (custom)

Full control with your own verification agent:

guardian = Agent(
    "my_guardian",
    tools=[check_whitelist, verify_identity, check_reputation],
    system_prompt="""
        You verify requests before allowing interaction.
        Return ACCEPT or REJECT with reason.
    """
)

host(agent, trust=guardian)

One place decides: .co/host.yaml

# .co/host.yaml
trust: careful      # open | careful | strict

There is no environment variable for this, on purpose. CONNECTONION_ENV used to be documented as setting trust automatically, with development meaning open. It never actually did anything — and wiring it up would have meant a variable sitting in someone's shell profile could open their host to everyone, at the moment they were least likely to be reading this page.

How open a host is, is written down in a file its operator owns and can read back. Different machines get different files; co deploy copies the one you mean to the machine you mean.


Trust Flow

Request arrives


┌─────────────────┐
│ Blacklist?      │─── Yes ──▶ REJECT (403 forbidden)
└─────────────────┘
     │ No

┌─────────────────┐
│ Whitelist?      │─── Yes ──▶ ACCEPT (bypass trust agent)
└─────────────────┘
     │ No

┌─────────────────┐
│ Signed request? │─── Yes ──▶ Verify signature
└─────────────────┘              │
     │ No                        ├─ Invalid ──▶ REJECT (401)
     │                           │
     ▼                           ▼
┌─────────────────────────────────────┐
│ Trust Agent evaluates request       │
│                                     │
│  - Level?  → Pre-configured agent   │
│  - Policy? → LLM interprets policy  │
│  - Agent?  → Custom agent decides   │
│                                     │
│  Input: prompt, identity, sig_valid │
│  Output: ACCEPT or REJECT           │
└─────────────────────────────────────┘

     ├─ ACCEPT ──▶ Execute agent.input(prompt)
     └─ REJECT ──▶ Return 403 forbidden

Progress Updates (Custom)

The framework provides event hooks. You decide what progress to send.

from connectonion import Agent, host, after_each_tool

def send_progress(agent):
    # Your custom progress logic
    iteration = agent.current_session["iteration"]
    print(f"Progress: iteration {iteration}")
    # Or send via your own WebSocket, webhook, etc.

agent = Agent("worker", on_events=[after_each_tool(send_progress)])
host(agent)

Available events:

  • after_user_input - After receiving input
  • before_llm - Before each LLM call
  • after_llm - After each LLM call
  • before_each_tool - Before each tool execution
  • after_each_tool - After each tool execution

Progressive Disclosure

Level 0: Just Works

host(create_agent)

Level 1: Trust Control

host(create_agent, trust="strict")

Level 2: Access Control

host(create_agent, blacklist=["0xbad..."], whitelist=["0xgood..."])

Level 3: Production Scaling

host(create_agent, workers=4, port=8000)  # 4 uvicorn workers

Each worker is an OS process with isolated memory. Within each worker, each request calls create_agent() for a fresh instance.

Level 4: Custom Trust Logic

host(create_agent, trust=my_guardian_agent)

Accessing Your Agent

HTTP (Simple)

import requests

# Single request
response = requests.post("http://localhost:8000/input", json={
    "prompt": "Translate hello to Spanish"
})
print(response.json()["result"])  # "Hola"

HTTP (Multi-turn)

import requests

# First request
r1 = requests.post("http://localhost:8000/input", json={
    "prompt": "My name is John"
})
session = r1.json()["session"]  # Save session

# Second request - pass session back
r2 = requests.post("http://localhost:8000/input", json={
    "prompt": "What is my name?",
    "session": session  # Continue conversation
})
print(r2.json()["result"])  # "Your name is John"
session = r2.json()["session"]  # Update session for next request

WebSocket (Real-time)

import websockets
import json

async with websockets.connect("ws://localhost:8000/ws") as ws:
    await ws.send(json.dumps({"type": "INPUT", "prompt": "Translate hello"}))

    while True:
        msg = json.loads(await ws.recv())
        if msg["type"] == "OUTPUT":
            print(msg["result"])
            break
        elif msg["type"] == "ERROR":
            raise RuntimeError(msg.get("message", "Unknown error"))
        else:
            print("Event:", msg)

P2P Relay (From Anywhere)

from connectonion import connect

translator = connect("0x3d4017c3...660c")
result = translator.input("Translate hello to Spanish")

Development vs Production

Development

host(create_agent, reload=True, trust="open")
  • Auto-reloads on code changes
  • No authentication required

Production

host(create_agent, workers=4, trust="strict")
  • Multiple workers for parallel requests (OS-level isolation)
  • Each request calls create_agent() for fresh instance (request-level isolation)
  • Strict authentication and limits

Deployment

Direct

python myagent.py

Standard Tooling

# myagent.py
from connectonion import Agent
from connectonion.network import host, create_app

def create_agent():
    return Agent("translator", tools=[translate])

# Export ASGI app for uvicorn/gunicorn
app = create_app(create_agent)

if __name__ == "__main__":
    host(create_agent)
# Uvicorn
uvicorn myagent:app --workers 4

# Gunicorn
gunicorn myagent:app -w 4 -k uvicorn.workers.UvicornWorker

All workers of a create_app() deployment share .co/replay.sqlite3, so a captured CONNECT, v2 command, admin request, or protected HTTP request cannot be accepted once by each process. The ledger contains only short-lived signature digests, retained until the signed timestamp leaves the freshness window. A locked or unwritable ledger fails closed rather than accepting a replay; a ledger whose file was removed under a running host recreates its schema on the next claim instead (#1403). For CONNECT, the claim happens after Ed25519 verification but before trust policy evaluation, so replay rejection cannot repeat policy work or mutations.

host() does not write this file. It runs exactly one worker (uvicorn cannot fork an app object), so its ledger for unsealed clients is in memory. A sealed socket — every 1.8.1 client, direct or relayed — is not held to any ledger: its CONNECT must be signed by the identity that sealed it, and nobody else can put a frame on it. See the sealed-channel section of websocket-protocol.md.

Docker

FROM python:3.11-slim
RUN pip install connectonion
COPY myagent.py .
CMD ["python", "myagent.py"]
# docker-compose.yml
services:
  agent:
    build: .
    ports:
      - "8000:8000"

Trust comes from .co/host.yaml inside the image, not from the environment block — so what a container will accept is visible in the repo, not in the orchestrator's config.

Reverse Proxy (Caddy)

# Caddyfile
agent.example.com {
    reverse_proxy localhost:8000
}

systemd Service

# /etc/systemd/system/myagent.service
[Unit]
Description=My ConnectOnion Agent
After=network.target

[Service]
User=app
WorkingDirectory=/app
ExecStart=/usr/bin/python myagent.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl enable myagent
sudo systemctl start myagent

API Reference

host()

def host(
    create_agent: Callable[[], Agent],
    trust: Union[str, Agent] = "careful",
    blacklist: list = None,
    whitelist: list = None,
    port: int = 8000,
    workers: int = 1,
    result_ttl: int = 86400,
    relay_url: str = "wss://oo.openonion.ai/ws/announce",
    reload: bool = False,
    max_file_size: int = 10,               # MB per file
    max_files_per_request: int = 10,
) -> None
ParameterTypeDefaultDescription
create_agentCallable[[], Agent]requiredFunction that returns a fresh Agent instance
truststr or Agent"careful"Trust level, policy, or agent
blacklistlistNoneAddresses to always reject
whitelistlistNoneAddresses to always accept
portint8000HTTP port
workersint1Number of uvicorn worker processes
result_ttlint86400How long server keeps results (24h)
relay_urlstrproductionP2P relay server
reloadboolFalseAuto-reload on changes
max_file_sizeint10Max file size in MB (both WS and HTTP)
max_files_per_requestint10Max number of files in one request

create_app()

from connectonion.network import create_app

def create_agent():
    return Agent("assistant", tools=[search])

app = create_app(
    create_agent: Callable[[], Agent],
    trust: Union[str, Agent] = "careful",
    blacklist: list = None,
    whitelist: list = None,
    result_ttl: int = 86400,
) -> ASGIApp

Returns ASGI app for use with uvicorn/gunicorn directly.


Examples

Minimal

from connectonion import Agent, host

def create_agent():
    return Agent("helper", tools=[search])

host(create_agent)

With Trust

host(create_agent, trust="strict")

With Access Control

host(create_agent, blacklist=["0xbad..."], whitelist=["0xpartner..."])

Production

host(create_agent, workers=4, trust="strict", reload=False)

Custom Trust Policy

host(create_agent, trust="Only accept from known contacts with >10 successful tasks")

Development

host(create_agent, reload=True, trust="open")

Comparison with Other Frameworks

FrameworkTo Deploy
FastAPILearn uvicorn, ASGI, workers
Djangorunserver vs gunicorn
LangServeFastAPI + uvicorn
PydanticAIBuild everything yourself
ConnectOnionhost(agent)

We hide the complexity. You just host.