asyncai
Agent runtime ported from the core event loop written in python at
sshai
- typed action/event streams
- dynamic task multiplexer
- agent runtime and thread lifecycle
- waiting rules
- OpenAI-compatible chat completions
- MCP tools over stdio
- atirc bridge
- Claude Code session transcripts
Architecture: docs/adrs/0001-architecture.md
kcp MCP servers: docs/adrs/0003-socialweb-computer-kcp-mcp-servers.md
Build
cargo build --release
cargo install --path .
Binary: target/release/asyncai.
Quick start
Echo backend, no model required:
printf 'hello\n' | cargo run -q -- --agent-name alice
OpenAI-compatible endpoint:
export OPENAI_BASE_URL=https://api.example.com/v1
export OPENAI_MODEL=my-model
export OPENAI_API_KEY=sk-...
printf 'hello\n' | cargo run -q -- --agent-name alice
ASYNC_LLM_BASE_URL, ASYNC_LLM_MODEL and ASYNC_LLM_API_KEY are accepted as
aliases. No endpoint is built into the binary.
atirc
asyncai atirc subscribes and publishes directly through the atirc Rust
library. No atirc sub stdin pipeline, no atirc pub subprocess.
export ATIRC_TOKEN=...
export OPENAI_BASE_URL=https://api.example.com/v1
export OPENAI_MODEL=my-model
cargo run -q -- atirc --channel '#alice'
Bidder KCP watcher agent
cargo install --path . \
&& sudo cp \
~/.cargo/bin/socialweb-computer-kcp-read* \
~/.cargo/bin/asyncai* \
/usr/local/bin/ \
&& sudo -E -u swc-bidder \
asyncai atirc \
--mcp-stdio /usr/local/bin/asyncai-mcp-simple \
--mcp-stdio /usr/local/bin/socialweb-computer-kcp-readonly \
--verbose 2>&1 \
| tee bidder-agent.ndjson \
| yq -p=json -P
TODO iroh.computer peer to peer irc refactor of atirc.
Objective: Remove the need for the long lived token and server side of atirc.
- Traverse owner graph (Leverage policy engine eval maybe?)
- Leverage
atirc send|recvstyle streams- Leverage Event Stream watches for new agents/associated ATproto accounts/repos.
- Keep bounded cache with 10 second ttl.
- Open
atirc send/recvduplex streams to each agent/operator channel stream.- If cache miss on inbound iroh connection wait for event stream of new assocaition, if longer than 10 seconds (ttl) and no associated account with matching stream ticket, drop connection. Otherwise if it comes in counter verify
atirc send/recvstyle, add to mesh of connections.- APIResource definition(s) within socialweb-computer-kcp
- Run as HostProcess - Add submodule then binary to versions.json with build
- Add example kubectl YAML apply based deployment
- Refactor
atirc tunnel/atirc client weechatto use this.- This archetecture should be good for the atirc refactor, plan and implement.
Token resolution:
--token, thenATIRC_TOKEN.- Else
ATP_USERNAME+ATP_PASSWORDauto-mints a long token with--agent-nameas the nick.
ATIRC_PDS overrides the PDS. When unset, it is derived from
ATP_USERNAME. ATIRC_ENDPOINT overrides the default atirc endpoint.
| flag | meaning |
|---|---|
-t, --token <TOKEN> | long-lived atirc token |
-c, --channel <CHANNEL> | channel; repeatable |
--cursor <N> | last sequence seen; -1 = new only |
--agent-name <NAME> | agent nick; also used for auto-mint |
--mention-only | only handle messages mentioning the nick |
--max-turns <N> | stop after N published replies |
--verbose | NDJSON logs: connection, inbound messages, run_code and MCP calls/results, publishes, output records |
Behavior:
- Agent name comes from the connection event;
--agent-nameoverrides. - Own messages are filtered by token identity. For a subhandle token, the owner account handle is not treated as self.
- Every other message is handled by default.
- Message text publishes without a nick prefix.
- Replies are split into IRC-sized messages: each non-empty line is published separately, and lines over 400 bytes wrap at word boundaries. Blank lines are dropped.
- atirc messages are written to sessions as task notifications under a
synthetic
atirc sub ...monitor description, not as user messages.
Multiple agents, one process each:
ATIRC_TOKEN=$(cat token-a) cargo run -q -- atirc --channel '#chan'
ATIRC_TOKEN=$(cat token-b) cargo run -q -- atirc --channel '#chan'
MCP tools
Build the bundled HTTP GET server:
cargo build
Use it with a tool-calling model:
export OPENAI_BASE_URL=https://api.example.com/v1
export OPENAI_MODEL=my-model
./target/debug/asyncai \
--base-url "$OPENAI_BASE_URL" \
--model "$OPENAI_MODEL" \
--mcp-stdio "./target/debug/asyncai-mcp-simple"
asyncai-mcp-simple exposes:
| tool | arguments | result |
|---|---|---|
http_get | {"url": "http://..."} | {"status", "content_type", "body"}; connection failure → error |
--mcp-stdio is repeatable. Each entry is one stdio MCP server command.
Code mode
The model gets one tool, run_code. MCP tools are Python async
functions inside it; Monty
runs the program and only the result reaches the model
(CodeMode pattern).
What the model writes:
import asyncio
cms, events = await asyncio.gather(
list_objects(workspace="root:app", resource="configmaps", all_namespaces=True),
get_events(workspace="root:app"),
)
{"configmaps": names(cms), "events": count_by(events, "type")}
- MCP output: unbounded. JSON →
dict/list, text →str, tool error →RuntimeError. - Stubs carry types from MCP
outputSchema; docstrings end inReturns:. - Bad kwargs →
TypeErrorbefore any MCP call. - Helpers:
items(x),names(x),dig(obj, "a.b"),count_by(x, "a.b").asyncio,json,repre-imported. - Last expression is the result. Variables persist for the conversation
(one REPL per thread, across turns);
"restart": trueresets. - Result > 20 000 chars → rejected with preview; data stays in the variable.
- No files, env, network, subprocesses.
Limits per run_code:
| limit | default |
|---|---|
| Python execution | 30 s |
| wall clock | 300 s |
| each tool call | 120 s |
| tool calls | 100 |
| heap (pool sandbox) | 4 GiB |
Sandbox: pool (default when monty found) = worker subprocess, heap cap,
crash isolation. In-process = fallback, no heap cap, warn logged.
cargo +1.97.0 install monty-runtime --locked # installs `monty`
./target/debug/asyncai \
--mcp-stdio ./target/debug/asyncai-mcp-simple \
--code-sandbox pool \
--code-max-memory $((8 * 1024 * 1024 * 1024))
Preamble run in every new scope:
printf 'WS = "root:app"\n' > preamble.py
./target/debug/asyncai --mcp-stdio ./target/debug/socialweb-computer-kcp-readonly \
--code-preamble preamble.py
Mutating calls (apply/patch/scale/delete) always log warn, no
--verbose needed:
asyncai atirc -c '#alice' --mcp-stdio socialweb-computer-kcp-readwrite 2>&1 \
| jq -c 'select(.mutating)'
Design: docs/adrs/0004-code-mode.md
Watches
Tell the bot to watch something; changes arrive as new turns. Push only: no polling anywhere (ADR 0005).
ATIRC_TOKEN=$(atirc token) atirc pub -c '#alice' \
'alice: watch configmaps in root:app (all namespaces); tell me when one appears or disappears'
What the model writes:
await watch_start(
name="cm",
uri="kcp-events://watch/root:app/configmaps?namespace=*",
on_event="f\"{event['type']} {event['object']['metadata']['name']}\"",
)
Trigger a change from anywhere:
kubectl --kubeconfig "$K" --server "$SERVER/clusters/root:app" \
-n default create configmap watch-probe --from-literal=a=b
About 3 s later the bot gets:
<watch-event name="cm" uri="kcp-events://watch/root:app/configmaps?namespace=*">
ADDED watch-probe
</watch-event>
Built-ins in run_code:
| function | does |
|---|---|
list_resources() | resources and templates (uri, template, example); subscribable ones can be watched |
read_resource(uri=...) | read one resource |
watch_start(name=, uri=, on_event=None) | subscribe; on_event is Python per event (type, object, old, changed), None result drops it |
watch_stop(name=) / watch_list() | manage watches |
- Works with any MCP server on spec 2026-07-28 that exposes subscribable resources. Contract: ADR 0005.
on_eventruns read-only (no mutating tools, no new watches); syntax errors failwatch_start.- Bursts are merged (3 s quiet, at most 15 s); a line repeated for the same object is dropped. Notices during a turn join one follow-up turn.
- Without
on_event, lines show what changed:MODIFIED bid-1 (status.phase: Deciding→Published). - Notices are session task-notifications, never user messages. A reply of
NO_REPLY(or empty) posts nothing. --watch-state watches.jsonkeeps watches across restarts.- Limits: 5 watches, 20 lines per notice, resubscribe with backoff on disconnect.
Generic demo, no Kubernetes needed:
./target/debug/asyncai --mcp-stdio ./target/debug/asyncai-mcp-simple
# model: await watch_start(name="notes", uri="memo://notes")
# await memo_set(key="todo", value="ship") -> notice: ADDED todo
socialweb-computer kcp servers
| binary | access | tools |
|---|---|---|
socialweb-computer-kcp-readonly | minted read-only token per workspace | list_kcps, list_workspaces, get_api_resources, list_objects, get_object, get_logs, get_events, readonly_credentials |
socialweb-computer-kcp-readwrite | admin kubeconfig | read tools + apply_object, delete_object, patch_object, scale_object |
cargo build --bins
./target/debug/asyncai \
--mcp-stdio "./target/debug/socialweb-computer-kcp-readonly --ssh hat-7 --sudo --allow-prod"
./target/debug/socialweb-computer-kcp-readonly \
--kubeconfig /path/to/kcp/admin.kubeconfig
- Output:
structuredContentJSON (kubectl -o json). Arrays wrapped:{"workspaces": [...]},{"resources": [...]}.get_logs,delete_object: text. kcpoptional: the only running kcp is used.- Watchable:
kcp-events://watch/{workspace}/{resource}{?namespace,labelSelector,fieldSelector,kcp}(event-log resource,kubectl get -w;MODIFIEDevents carryold). - Readable:
kcp-api://{workspace}/resources{?kcp}(same JSON asget_api_resources). - Unknown resource names fail with
did you mean: .... - Annotations: read tools
readOnlyHint;delete_objectdestructive. - Read-only server never reads with the admin kubeconfig; tokens last 900 s and are never returned.
--ssh HOSTruns commands remotely;--sudoaddssudo -n.socialweb.computer,hat-7,hat-9need--allow-prod.apply_object(dry_run=True): server-side admission, nothing stored.
Design: docs/adrs/0003-socialweb-computer-kcp-mcp-servers.md
Logs
All stderr is NDJSON. With atirc --verbose:
| event | when |
|---|---|
model.tool_call / model.tool_result | each run_code |
mcp.tool_call / mcp.tool_result ("via": "code_mode") | each MCP call from Python |
model.turn_summary | end of turn: rounds, nested_calls, bytes_in, bytes_out |
code_mode.sandbox | startup: pool or in-process |
watch.started / watch.notice / watch.failed | watch lifecycle (always); watch.event with --verbose |
asyncai atirc -c '#alice' --mcp-stdio socialweb-computer-kcp-readonly --verbose 2>&1 \
| jq -c 'select(.event == "model.turn_summary")'
Sessions
Session transcripts are written by default for asyncai and asyncai atirc.
First stdout line:
{"work_name":"session","result":{"session_id":"<uuid>","session_file":"<path>","harness":"claude-code-cli","agent_name":"<name>","harnesses":[...]}}
Claude Code transcript location:
<root>/projects/<encoded-cwd>/<uuid>.jsonl
Default root for claude-code-cli: $CLAUDE_CONFIG_DIR or ~/.claude.
Other harnesses own their own roots.
Resume:
deepseek-claude -p --resume <uuid> 'Reply with exactly: resumed-ok'
While the bot is running, claude --resume appends to the live file
(session.foreign_append warns). Resume a copy instead:
cp "$F" "${F%/*}/$(uuidgen).jsonl"
Records include user messages, assistant messages, MCP tool calls, and tool results. atirc channel messages are written as queued system notifications, not user messages. Each record is flushed. Existing files are never overwritten; each run allocates a new UUID.
Flags:
--session-dir claude-code-cli=/path/to/claude-root
--session-format claude-code-cli # repeatable
--monitor "tail -f /tmp/events.log" # repeatable
--no-session
Monitor-only interactive mode, no stdin, no user records:
./target/debug/asyncai --no-stdin --monitor "tail -f /tmp/events.log"
Each --monitor stdout line becomes a Claude Code task notification in the
transcript:
queue-operation:enqueue
→ attachment:queued_command
→ queue-operation:remove
Monitor events are captured for the session only; they are not injected into the agent conversation.
Session format details: docs/adrs/0002-claude-code-session-files.md
Prompts
prompts/atirc.md
prompts/interactive.md
prompts/atirc-socialweb-computer.md # appended to atirc.md by --prompt atirc-socialweb-computer
Both are embedded at build time. {agent_name} is substituted.
socialweb-computer node (which objects matter, ready-made monitors):
asyncai atirc -c '#alice' --prompt atirc-socialweb-computer \
--mcp-stdio socialweb-computer-kcp-readonly --watch-state watches.json
Override at runtime:
./target/debug/asyncai --prompt-file ./my-prompt.md
A ## On Start section (any heading level) is cut from the system prompt
and run once as the first turn:
## On Start
Set up monitors for bidder compute contract events.
CLI flags
| flag | meaning |
|---|---|
--model <MODEL> | model name for the OpenAI-compatible backend |
--agent-name <NAME> | agent name; atirc defaults to connection nick |
--base-url <URL> | OpenAI-compatible base URL |
--api-key <KEY> | API key for that endpoint |
--mcp-stdio <COMMAND> | stdio MCP server command; repeatable |
--code-sandbox <auto|in-process|pool> | where run_code runs; default auto |
--monty-worker <PATH> | worker binary; else MONTY_WORKER, else monty on PATH |
--code-max-memory <BYTES> | pool worker heap cap; default 4 GiB |
--code-preamble <PATH> | Python run in every new run_code scope |
--watch-state <PATH> | save watches here; restore them at startup |
--max-output-tokens <N> | cap per model request; default 8192 |
--model-timeout <SECS> | wall-clock limit per model request; default 600 |
--prompt-file <PATH> | markdown prompt override |
--prompt <NAME> | built-in prompt: atirc, interactive, atirc-socialweb-computer |
--session-dir <FORMAT=PATH> | per-format session root; repeatable |
--session-format <FORMAT> | session harness format; repeatable |
--monitor <COMMAND> | capture stdout lines as monitor events; repeatable |
--no-stdin | run monitors only; do not read stdin |
--no-session | disable session transcripts |
Run asyncai --help for the full surface.
Test
cargo test
Mock models, fake atirc bus, fake kubectl. No live services.
Pool sandbox tests need the worker:
MONTY_WORKER=$(command -v monty) cargo test --test code_mode_pool
Documentation
- 0001 — architecture
- 0002 — Claude Code session files
- 0003 — socialweb-computer kcp MCP servers
- 0004 — code mode
- 0005 — MCP watches
Not ported
- policy engine workflow execution / GitHub Actions
- Caddy reverse-proxy management
- tmux attachment, Unix sockets, SSH daemon
- OpenAI Agents SDK integration; backend trait is the seam
- keyring persistence; in-memory KV store only
