# asyncai Agent runtime ported from the core event loop written in python at [`sshai`](https://github.com/publicdomainrelay/sshai/blob/main/src/sshai/agi.py) - 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](docs/adrs/0001-architecture.md) kcp MCP servers: [docs/adrs/0003-socialweb-computer-kcp-mcp-servers.md](docs/adrs/0003-socialweb-computer-kcp-mcp-servers.md) ## Build ```bash cargo build --release cargo install --path . ``` Binary: `target/release/asyncai`. ## Quick start Echo backend, no model required: ```bash printf 'hello\n' | cargo run -q -- --agent-name alice ``` OpenAI-compatible endpoint: ```bash 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. ```bash 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|recv` style streams > - Leverage Event Stream watches for new agents/associated ATproto > accounts/repos. > - Keep bounded cache with 10 second ttl. > - Open `atirc send/recv` duplex 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/recv` style, 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 weechat` to use this. > - This archetecture should be good for the atirc refactor, plan and implement. Token resolution: 1. `--token`, then `ATIRC_TOKEN`. 2. Else `ATP_USERNAME` + `ATP_PASSWORD` auto-mints a long token with `--agent-name` as 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 ` | long-lived atirc token | | `-c, --channel ` | channel; repeatable | | `--cursor ` | last sequence seen; -1 = new only | | `--agent-name ` | agent nick; also used for auto-mint | | `--mention-only` | only handle messages mentioning the nick | | `--max-turns ` | 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-name` overrides. - 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: ```bash ATIRC_TOKEN=$(cat token-a) cargo run -q -- atirc --channel '#chan' ``` ```bash ATIRC_TOKEN=$(cat token-b) cargo run -q -- atirc --channel '#chan' ``` ## MCP tools Build the bundled HTTP GET server: ```bash cargo build ``` Use it with a tool-calling model: ```bash 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](https://pydantic.dev/docs/monty/quickstart/rust/) runs the program and only the result reaches the model ([CodeMode](https://pydantic.dev/docs/ai/harness/code-mode/) pattern). What the model writes: ```python 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 in `Returns:`. - Bad kwargs → `TypeError` before any MCP call. - Helpers: `items(x)`, `names(x)`, `dig(obj, "a.b")`, `count_by(x, "a.b")`. `asyncio`, `json`, `re` pre-imported. - Last expression is the result. Variables persist for the conversation (one REPL per thread, across turns); `"restart": true` resets. - 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. ```bash 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: ```bash 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: ```bash asyncai atirc -c '#alice' --mcp-stdio socialweb-computer-kcp-readwrite 2>&1 \ | jq -c 'select(.mutating)' ``` Design: [docs/adrs/0004-code-mode.md](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](docs/adrs/0005-mcp-watches.md)). ```bash 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: ```python 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: ```bash 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: ```text ADDED watch-probe ``` 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](docs/adrs/0005-mcp-watches.md#server-contract-what-an-mcp-server-must-expose). - `on_event` runs read-only (no mutating tools, no new watches); syntax errors fail `watch_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.json` keeps watches across restarts. - Limits: 5 watches, 20 lines per notice, resubscribe with backoff on disconnect. Generic demo, no Kubernetes needed: ```bash ./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` | ```bash 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: `structuredContent` JSON (`kubectl -o json`). Arrays wrapped: `{"workspaces": [...]}`, `{"resources": [...]}`. `get_logs`, `delete_object`: text. - `kcp` optional: the only running kcp is used. - Watchable: `kcp-events://watch/{workspace}/{resource}{?namespace,labelSelector,fieldSelector,kcp}` (event-log resource, `kubectl get -w`; `MODIFIED` events carry `old`). - Readable: `kcp-api://{workspace}/resources{?kcp}` (same JSON as `get_api_resources`). - Unknown resource names fail with `did you mean: ...`. - Annotations: read tools `readOnlyHint`; `delete_object` destructive. - Read-only server never reads with the admin kubeconfig; tokens last 900 s and are never returned. - `--ssh HOST` runs commands remotely; `--sudo` adds `sudo -n`. - `socialweb.computer`, `hat-7`, `hat-9` need `--allow-prod`. - `apply_object(dry_run=True)`: server-side admission, nothing stored. Design: [docs/adrs/0003-socialweb-computer-kcp-mcp-servers.md](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` | ```bash 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: ```json {"work_name":"session","result":{"session_id":"","session_file":"","harness":"claude-code-cli","agent_name":"","harnesses":[...]}} ``` Claude Code transcript location: ```text /projects//.jsonl ``` Default root for `claude-code-cli`: `$CLAUDE_CONFIG_DIR` or `~/.claude`. Other harnesses own their own roots. Resume: ```bash deepseek-claude -p --resume '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: ```bash 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: ```bash --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: ```bash ./target/debug/asyncai --no-stdin --monitor "tail -f /tmp/events.log" ``` Each `--monitor` stdout line becomes a Claude Code task notification in the transcript: ```text 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](docs/adrs/0002-claude-code-session-files.md) ## Prompts ```text 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): ```bash asyncai atirc -c '#alice' --prompt atirc-socialweb-computer \ --mcp-stdio socialweb-computer-kcp-readonly --watch-state watches.json ``` Override at runtime: ```bash ./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: ```markdown ## On Start Set up monitors for bidder compute contract events. ``` ## CLI flags | flag | meaning | |---|---| | `--model ` | model name for the OpenAI-compatible backend | | `--agent-name ` | agent name; atirc defaults to connection nick | | `--base-url ` | OpenAI-compatible base URL | | `--api-key ` | API key for that endpoint | | `--mcp-stdio ` | stdio MCP server command; repeatable | | `--code-sandbox ` | where `run_code` runs; default `auto` | | `--monty-worker ` | worker binary; else `MONTY_WORKER`, else `monty` on `PATH` | | `--code-max-memory ` | pool worker heap cap; default 4 GiB | | `--code-preamble ` | Python run in every new `run_code` scope | | `--watch-state ` | save watches here; restore them at startup | | `--max-output-tokens ` | cap per model request; default 8192 | | `--model-timeout ` | wall-clock limit per model request; default 600 | | `--prompt-file ` | markdown prompt override | | `--prompt ` | built-in prompt: `atirc`, `interactive`, `atirc-socialweb-computer` | | `--session-dir ` | per-format session root; repeatable | | `--session-format ` | session harness format; repeatable | | `--monitor ` | 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 ```bash cargo test ``` Mock models, fake `atirc` bus, fake `kubectl`. No live services. Pool sandbox tests need the worker: ```bash MONTY_WORKER=$(command -v monty) cargo test --test code_mode_pool ``` ## Documentation - [0001 — architecture](docs/adrs/0001-architecture.md) - [0002 — Claude Code session files](docs/adrs/0002-claude-code-session-files.md) - [0003 — socialweb-computer kcp MCP servers](docs/adrs/0003-socialweb-computer-kcp-mcp-servers.md) - [0004 — code mode](docs/adrs/0004-code-mode.md) - [0005 — MCP watches](docs/adrs/0005-mcp-watches.md) ## 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