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:
- Client sends HTTP POST with
{prompt, session?} - Server checks trust (blacklist/whitelist/policy)
- Server calls
create_agent()for fresh instance - Server calls
agent.input(prompt) - 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:
- Client connects via WebSocket
- Server sends PING every 30s (keep-alive)
- Client sends INPUT message
- Server streams events (thinking, tool_call, tool_result)
- 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:
- Client creates a
session_idand opens WebSocket to relay's/ws/input - Relay forwards messages as-is to the agent through the announce WebSocket
- Agent's relay loop routes by
session_idand opens a local WebSocket to its ownws://127.0.0.1:8000/ws— the same endpoint that handles direct connections - The local
/wshandler processes the full protocol (CONNECT, INPUT, streaming, OUTPUT) as if it were a normal client - Every response carries
session_id(injected by the/wshandler). Responses flow back: local/ws→ relay loop → announce WebSocket → relay routes bysession_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:
- Agent sends ANNOUNCE with address and endpoints every 60s
- Relay stores agent info (now discoverable)
- 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
| Connection | Mechanism | Interval | Purpose |
|---|---|---|---|
| HTTP | None | N/A | Stateless request/response |
| WebSocket /ws (direct or via relay) | Server PING → client | 30s | Keep the client session alive; client replies PONG |
| Agent ↔ relay link | Agent ANNOUNCE | 60s | Stay 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-
ConnectionClosederror 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
| Type | Direction | Purpose |
|---|---|---|
| INIT | Client → Server | Authenticate + new session |
| ATTACH | Client → Server | Authenticate + resume existing session |
| CONNECTED | Server → Client | Session info (session_id, status) |
| INPUT | Client → Server | Send prompt (no auth needed) |
| EXEC | Client → Server | Run one tool directly, no LLM |
| OUTPUT | Server → Client | Final result + session data |
| EXEC_RESULT | Server → Client | Result of an EXEC |
| PING | Server → Client | Keep-alive (every 30s) |
| PONG | Client → Server | Acknowledge keep-alive |
| tool_call | Server → Client | Tool started |
| tool_result | Server → Client | Tool completed |
| thinking | Server → Client | Agent is processing |
| ask_user | Server → Client | Agent needs input |
| approval_needed | Server → Client | Tool approval required |
| ERROR | Server → Client | Error 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:
| Feature | Needs Session Data |
|---|---|
| Activity logging | trace - tool executions, timings |
| Turn tracking | turn - conversation turn count |
| XRay debugging | Full execution context |
| Session replay | Complete 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:
| Pattern | How It Works |
|---|---|
| JWT tokens | Server gives you a token, you send it with each request |
| Game save files | Game gives you save data, you store it, you load it |
| ConnectOnion sessions | Server gives you session, you store it, you send it back |
Benefits:
- Infinitely scalable - No server-side session storage
- Client transparency - You can inspect the session, see exactly what's happening
- Coherent logs - Server logs preserve turn count, trace
- Easy debugging - Session contains full execution history
- No session cleanup - No TTL, no expiry, no "session not found" errors
Comparison
| API | Approach | State Storage |
|---|---|---|
| Anthropic Messages API | messages array | Client |
| OpenAI Chat API | messages array | Client |
| OpenAI Assistants API | thread_id | Server |
| ConnectOnion | session object | Client |
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:
- The file is decoded from base64 and saved to
.co/uploads/{filename} - The agent receives a system reminder with the file path, prompting it to use
read_fileor other available tools to read the contents - 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:
| Feature | HTTP POST /input | WebSocket /ws | Relay |
|---|---|---|---|
| Text | prompt field | prompt field | prompt field |
| Images | images array | images array | images array |
| Files | files array | files array | files array |
| File validation | 400 error | ERROR message | ERROR 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 Level | Required Auth |
|---|---|
open | None (anonymous OK) |
careful | Signature recommended |
strict | Signature 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)
| Level | Behavior |
|---|---|
open | Accept all requests, no verification |
careful | Recommend signature, accept unsigned requests |
strict | Require 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 inputbefore_llm- Before each LLM callafter_llm- After each LLM callbefore_each_tool- Before each tool executionafter_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
| Parameter | Type | Default | Description |
|---|---|---|---|
create_agent | Callable[[], Agent] | required | Function that returns a fresh Agent instance |
trust | str or Agent | "careful" | Trust level, policy, or agent |
blacklist | list | None | Addresses to always reject |
whitelist | list | None | Addresses to always accept |
port | int | 8000 | HTTP port |
workers | int | 1 | Number of uvicorn worker processes |
result_ttl | int | 86400 | How long server keeps results (24h) |
relay_url | str | production | P2P relay server |
reload | bool | False | Auto-reload on changes |
max_file_size | int | 10 | Max file size in MB (both WS and HTTP) |
max_files_per_request | int | 10 | Max 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
| Framework | To Deploy |
|---|---|
| FastAPI | Learn uvicorn, ASGI, workers |
| Django | runserver vs gunicorn |
| LangServe | FastAPI + uvicorn |
| PydanticAI | Build everything yourself |
| ConnectOnion | host(agent) |
We hide the complexity. You just host.