Skip to content

Latest commit

 

History

History

README.md

A2C — Python Reference Implementation

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

Prerequisites

  • Python 3.10+
  • uv (pip install uv or curl -LsSf https://astral.sh/uv/install.sh | sh)
  • A Google AI Studio API key (Gemini)
  • Docker + Docker Compose (optional, for the containerised Brain)

Quick Start

1. Install dependencies

cd implementations/python
uv sync

2. Configure the Brain

cp .env.example .env
# Edit .env and set GOOGLE_API_KEY=your_key_here

3. Start the Brain

Option A — locally (dev mode)

./scripts/run-brain.sh
# or: PYTHONPATH=src uv run a2c-brain

Option B — in Docker

docker compose up          # builds the image on first run

The 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.

4. Start the Body

Interactive mode — user is present at the terminal:

./scripts/run-cli.sh       # plain CLI — easiest for testing
./scripts/run-body.sh      # Textual TUI

Daemon mode — headless background service, Brain pushes tasks:

PYTHONPATH=src uv run a2c-daemon
# or with the CLI flag:
PYTHONPATH=src uv run a2c-cli --daemon

5. (Optional) Open the Operator TUI

While 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

Commands

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

TUI Controls

Body TUI (a2c-body)

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

Operator TUI (a2c-operator)

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

Architecture

Interactive mode

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       │
└──────────────────────────┘          └──────────────────────────┘

Daemon mode + Operator TUI

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      │     └────────────────┘
                            └───────────────────┘

Shared protocol library

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

Session Storage

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.tool

Consent Model

By 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

Environment Variables

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

Docker

The Brain can run as a container while the Body/Operator continue to run locally.

Files

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

Usage

# 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 down

The 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.

How the port binding works

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.

Persistent storage

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

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.

Running a daemon Body

# 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-daemon

The daemon Body:

  • Sends mode: "daemon" in the HANDSHAKE message.
  • Defaults yolo_mode to true (all tool calls auto-approved).
  • Registers itself in the Brain's DeviceRegistry under its device_id.
  • Stays connected and waits for ACTION messages from the Brain.
  • Logs all activity to stdout; no interactive prompts.

Enabling the admin API

The Brain's HTTP admin API must be enabled to push tasks to daemon devices:

# .env
A2C_ADMIN_ENABLED=true
A2C_ADMIN_PORT=50052

Or via Docker:

environment:
  - A2C_ADMIN_ENABLED=true
  - A2C_ADMIN_HOST=0.0.0.0
  - A2C_ADMIN_PORT=50052

Admin API endpoints

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: runningdone or error or cancelled.

Conversation threads

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"}'

Operator TUI

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                     │
└─────────────────────────────────────────────────────────────────────────────────┘

Running the Operator TUI

# 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

Features

  • 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 Enter to push it to the selected device.
  • Conversation selector — change the Conv: field and press Enter to 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 R to reload the device list.

Regenerating Proto Bindings

If you modify src/a2c/proto/a2c.proto:

./generate_proto.sh

Extending

Adding a new tool to the Body

  1. Create src/body/tools/mytool.py with an async def execute(...) function.
  2. Add a ToolDescriptor for it in body/client.py (alongside SHELL_TOOL).
  3. Handle the new tool name in _handle_action() in body/client.py.

Swapping the LLM

Edit brain/agent.py — replace GoogleModel with any pydantic-ai compatible model (OpenAI, Anthropic, Mistral, etc.). The rest of the Brain is model-agnostic.

Using a different transport

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.