This is the official reference implementation of the A2C (Agent-to-Computer) Protocol v0.2 in Python.
It provides a minimal, modular, and fully functional implementation of both sides of the protocol:
| Component | Location | Role |
|---|---|---|
| Protocol library | src/a2c/ |
Messages, session state, JSON storage — no LLM, no UI |
| Brain | src/brain/ |
gRPC server + pydantic-ai/Gemini agent + HTTP admin API |
| Body (interactive) | src/body/tui.py / cli.py |
gRPC client + Textual TUI or plain CLI |
| Body (daemon) | src/body/cli.py --daemon |
Background service, no user input, auto-executes tasks |
| Operator | src/brain/operator_tui.py |
Textual C2 panel that talks to the admin API |
- Python 3.10+
- uv (
pip install uvorcurl -LsSf https://astral.sh/uv/install.sh | sh) - A Google AI Studio API key (Gemini)
- Docker + Docker Compose (optional, for the containerised Brain)
cd implementations/python
uv synccp .env.example .env
# Edit .env and set GOOGLE_API_KEY=your_key_hereOption A — locally (dev mode)
./scripts/run-brain.sh
# or: PYTHONPATH=src uv run a2c-brainOption B — in Docker
docker compose up # builds the image on first runThe Brain listens on localhost:50051 in both cases. With Docker the port is
bound only to 127.0.0.1 so it is not exposed to the local network.
Interactive mode — user is present at the terminal:
./scripts/run-cli.sh # plain CLI — easiest for testing
./scripts/run-body.sh # Textual TUIDaemon mode — headless background service, Brain pushes tasks:
PYTHONPATH=src uv run a2c-daemon
# or with the CLI flag:
PYTHONPATH=src uv run a2c-cli --daemonWhile one or more daemon Bodies are connected, enable the Brain admin API and launch the C2 panel:
A2C_ADMIN_ENABLED=true ./scripts/run-brain.sh
PYTHONPATH=src uv run a2c-operator
# or targeting a remote Brain:
PYTHONPATH=src uv run a2c-operator --api http://myserver:50052| Command | Entry point | Description |
|---|---|---|
a2c-brain |
brain.server:main |
Start the Brain gRPC server |
a2c-body |
body.tui:main |
Start the Body Textual TUI (interactive) |
a2c-cli |
body.cli:main |
Start the Body plain CLI (interactive) |
a2c-daemon |
body.cli:main_daemon |
Start the Body in daemon mode (headless) |
a2c-operator |
brain.operator_tui:main |
Start the Operator C2 TUI |
| Key | Action |
|---|---|
Enter |
Send prompt |
Ctrl+Q |
GOODBYE — graceful shutdown |
Ctrl+I |
INTERRUPT — emergency stop (kills all running tools) |
Escape |
Deny current tool consent dialog |
| Key | Action |
|---|---|
Enter (task input) |
Push task to selected device |
Enter (conv input) |
Switch to that conversation thread |
R |
Refresh device list |
Tab |
Cycle focus |
Ctrl+Q |
Quit |
Body (gRPC client, outbound) Brain (gRPC server)
┌──────────────────────────┐ ┌──────────────────────────┐
│ body/tui.py (TUI) │ │ brain/server.py │
│ body/cli.py (CLI) │◄────────►│ A2CServicer │
│ │ gRPC │ DeviceRegistry │
│ body/client.py │ │ │
│ A2CClient │ │ brain/agent.py │
│ HANDSHAKE + consent │ │ pydantic-ai + Gemini │
│ PENDING/INTERRUPT │ │ execute_local_command │
│ │ │ │
│ body/tools/shell.py │ │ brain/admin.py │
│ async subprocess │ │ HTTP API :50052 │
└──────────────────────────┘ └──────────────────────────┘
Operator TUI Brain (gRPC + HTTP) Body Daemon
┌──────────────────┐ ┌───────────────────┐ ┌────────────────┐
│ operator_tui.py │ │ brain/server.py │ │ cli.py │
│ │ │ DeviceRegistry │ │ --daemon │
│ device list │ │ │ │ │
│ conv threads │─HTTP──►│ brain/admin.py │◄────│ HANDSHAKE │
│ task input │◄────── │ POST /tasks │gRPC │ mode=daemon │
│ result polling │ │ GET /tasks │ │ yolo=true │
└──────────────────┘ │ GET /devices │ │ auto-executes │
│ GET /convs │ └────────────────┘
└───────────────────┘
a2c/
messages.py Pydantic models for all message types + wire encode/decode
session.py State machine (HANDSHAKE → ACTIVE → DISCONNECTED) + seq counters
storage.py JSON persistence: sessions/, devices.json, tasks/, device.json
All sessions and device data are stored under ~/.a2c/ (override with A2C_STORAGE_DIR):
~/.a2c/
├── device.json # Body's persistent device_id
├── devices.json # Brain's device registry (last_seen, sessions, mode)
├── sessions/
│ └── {session_id}.json # Full session state + message history
└── tasks/
└── {turn_id}.json # Task records with status + response text
To inspect stored data:
cat ~/.a2c/devices.json
cat ~/.a2c/tasks/*.json | python -m json.toolBy default (A2C_YOLO_MODE=false), every tool call from the Brain triggers a consent dialog in the TUI. The Body immediately sends a PENDING message to the Brain while waiting for user approval, so the Brain does not time out.
To enable auto-approval (yolo mode), set in .env:
A2C_YOLO_MODE=true
| Variable | Default | Description |
|---|---|---|
GOOGLE_API_KEY |
— | Google Gemini API key (required for Brain) |
A2C_BRAIN_HOST |
localhost / [::] |
Brain host (Body: connect to; Brain: listen on) |
A2C_BRAIN_PORT |
50051 |
gRPC port |
A2C_STORAGE_DIR |
~/.a2c |
Storage directory for sessions, devices, and tasks |
A2C_YOLO_MODE |
false |
Auto-approve all tool calls (forced true in daemon mode) |
A2C_DEVICE_ID |
(auto-generated UUID) | Override the Body's device identifier |
A2C_MODE |
interactive |
Body operating mode: interactive or daemon |
A2C_ADMIN_ENABLED |
false |
Enable the HTTP admin API on the Brain |
A2C_ADMIN_HOST |
127.0.0.1 |
Admin API listen host (set 0.0.0.0 inside Docker) |
A2C_ADMIN_PORT |
50052 |
Admin API port |
The Brain can run as a container while the Body/Operator continue to run locally.
| File | Purpose |
|---|---|
Dockerfile.brain |
Multi-stage image — uv builds in stage 1, lean python:3.12-slim runtime in stage 2 |
docker-compose.yml |
Starts the Brain, maps gRPC :50051 and admin :50052 to localhost |
# Build and start
docker compose up
# Rebuild after code changes
docker compose up --build
# Run in background
docker compose up -d
# View logs
docker compose logs -f
# Stop
docker compose downThe Body CLI or TUI connects to localhost:50051 as usual — no configuration change needed.
The admin API is available at http://localhost:50052 when A2C_ADMIN_ENABLED=true.
docker-compose.yml forces A2C_BRAIN_HOST=[::] inside the container so the
gRPC server listens on all container interfaces. It also sets A2C_ADMIN_HOST=0.0.0.0
so the HTTP admin API is reachable via Docker NAT. Your .env can keep the defaults
for local-process runs; the compose file overrides only when running in Docker.
Sessions, the device registry, and task records are stored in the named Docker volume
a2c_data (mounted at /data inside the container). The volume survives
docker compose down. To wipe it: docker volume rm python_a2c_data.
Daemon mode lets the Brain initiate tasks on a Body that is running as a persistent headless service — no local user required. This is declared in the HANDSHAKE message via mode: "daemon" and aligns with the daemon mode spec.
# Environment variable
A2C_MODE=daemon PYTHONPATH=src uv run a2c-cli
# Dedicated entry point (always daemon)
PYTHONPATH=src uv run a2c-daemon
# With explicit Brain address
A2C_BRAIN_HOST=my-brain.example.com PYTHONPATH=src uv run a2c-daemonThe daemon Body:
- Sends
mode: "daemon"in theHANDSHAKEmessage. - Defaults
yolo_modetotrue(all tool calls auto-approved). - Registers itself in the Brain's
DeviceRegistryunder itsdevice_id. - Stays connected and waits for
ACTIONmessages from the Brain. - Logs all activity to stdout; no interactive prompts.
The Brain's HTTP admin API must be enabled to push tasks to daemon devices:
# .env
A2C_ADMIN_ENABLED=true
A2C_ADMIN_PORT=50052Or via Docker:
environment:
- A2C_ADMIN_ENABLED=true
- A2C_ADMIN_HOST=0.0.0.0
- A2C_ADMIN_PORT=50052| Method | Path | Description |
|---|---|---|
GET |
/devices |
List all known devices. Add ?live=1 for connected daemons only. |
GET |
/devices/{device_id} |
Details and active conversations for one device. |
POST |
/tasks |
Push a task to a connected daemon Body. |
GET |
/tasks |
List task records. Filter with ?device_id=, ?session_id=, ?conversation_id=. |
GET |
/tasks/{turn_id} |
Fetch a specific task record including full response. |
GET |
/conversations |
List active in-memory conversation threads per live device. |
Push a task:
# Push a task to any live device
curl -X POST http://localhost:50052/tasks \
-H 'Content-Type: application/json' \
-d '{"device_id": "<device_id>", "task": "List files in the home directory"}'
# Push a task in a named conversation thread
curl -X POST http://localhost:50052/tasks \
-H 'Content-Type: application/json' \
-d '{"device_id": "<device_id>", "task": "What is in /tmp?", "conversation_id": "ops-1"}'Check the result:
# List all tasks
curl http://localhost:50052/tasks
# Get a specific task result
curl http://localhost:50052/tasks/<turn_id>Task records have the following shape:
{
"turn_id": "abc123",
"session_id": "...",
"device_id": "...",
"conversation_id": "default",
"task": "List files in the home directory",
"status": "done",
"response": "Here are the files: ...",
"started_at": "2026-04-27T10:00:00Z",
"finished_at": "2026-04-27T10:00:03Z"
}Status values: running → done or error or cancelled.
Each daemon device maintains independent conversation histories keyed by conversation_id. Omitting conversation_id in a POST /tasks call uses "default". Pass the same ID in subsequent calls to continue the same thread with full context:
curl -X POST http://localhost:50052/tasks \
-d '{"device_id": "...", "task": "Now summarise what you found", "conversation_id": "ops-1"}'The Operator TUI (a2c-operator) is a Textual-based command-and-control interface for managing daemon Bodies via the admin API.
┌─────────────────────────────────────────────────────────────────────────────────┐
│ A2C Operator API: http://localhost:50052 │
├─────────────────┬───────────────────────────────────────────────────────────────┤
│ Devices │ Conversation: default │
│ │ │
│ ● device-abc │ [10:01] You: List files in /tmp │
│ (live) │ [10:01] Agent: Found 3 files: foo.txt bar.txt baz.log │
│ │ │
│ ○ device-xyz │ [10:02] You: What is the size of foo.txt? (running...) │
│ (offline) │ │
│ │ │
├─────────────────┴───────────────────────────────────────────────────────────────┤
│ Conv: [default ] Task: [____________________________________] [Send] │
│ API: http://localhost:50052 · device-abc (live) · 1 pending │
└─────────────────────────────────────────────────────────────────────────────────┘
# Default — connects to localhost:50052
PYTHONPATH=src uv run a2c-operator
# Remote Brain
PYTHONPATH=src uv run a2c-operator --api http://my-brain.example.com:50052- Device list — left panel shows all known devices, live ones marked with
●. - Conversation view — right panel shows the task/response history for the selected device and conversation thread. Running tasks show a live "running..." indicator.
- Task input — type a task and press
Enterto push it to the selected device. - Conversation selector — change the
Conv:field and pressEnterto switch threads (history is loaded from the API). - Auto-polling — pending tasks are polled every 1.5 s and their entries update in place when results arrive.
- Refresh — press
Rto reload the device list.
If you modify src/a2c/proto/a2c.proto:
./generate_proto.sh- Create
src/body/tools/mytool.pywith anasync def execute(...)function. - Add a
ToolDescriptorfor it inbody/client.py(alongsideSHELL_TOOL). - Handle the new tool name in
_handle_action()inbody/client.py.
Edit brain/agent.py — replace GoogleModel with any pydantic-ai compatible model
(OpenAI, Anthropic, Mistral, etc.). The rest of the Brain is model-agnostic.
The a2c/ protocol library is transport-agnostic. The Pydantic models and session
state machine work with any transport; only brain/server.py and body/client.py
contain gRPC-specific code.