acp-connector
v0.16.0
Published
Thin bridge connecting messaging platforms to any ACP-compatible coding agent
Maintainers
Readme
acp-connector
🔌 Thin bridge between messaging platforms and any ACP agent — and an A2A node: agents that discover each other and delegate work.
🧠 Overview
acp-connector does two jobs:
- Platform bridge: connects Agent Client Protocol (ACP) coding agents to Telegram and Discord — forwards your messages to the agent and streams responses back.
- A2A node: when
a2a.enabled, the bridge joins an A2A agent network — your agent gets discovered by other agents, and can discover and delegate to them.
The bridge is intentionally thin. It doesn't implement its own agent loop, model provider, or tool ecosystem. It launches your agent, passes prompts through, relays responses back, and exposes a standard A2A surface. That's it.
Key features:
- 🤝 A2A agent network — peer discovery (LAN broadcast, shared file, registry), double opt-in pairing,
message/send+message/stream(SSE) - 🛠 MCP tools for your agent —
list_remote_agentsandsend_messageinjected into the session so agents can discover and delegate on their own - ⏰ Agent reminders — the agent can schedule one-shot self-reminders (
reminder_*tools) and manage its own recurring cron jobs (cron_*tools) — follow-ups, checks, "ping me if they replied" — persisted, late-delivery safe, human time strings. You get/reminderchat commands for your own one-shots - 🤖 Works with any ACP agent (Devin, Claude Code, Codex, OpenCode, pi, Antigravity)
- 💬 Telegram and Discord as messaging interfaces with streaming responses
- 🖥 Headless mode — run without any messaging platform (containers, test rigs)
- ⏰ Cron scheduler and named routines for recurring prompts
- 🌐 Optional HTTP API —
/promptwith webhook callbacks, bearer auth - 🔐 Bearer token auth, rate limiting, body size limits
- 🧠 Agent thoughts forwarding (optional)
- 📋 Permission requests as inline buttons
🚀 Installation
# Run directly with npx (no install needed)
npx acp-connector setup
# Or install globally
npm install -g acp-connector
# or
pnpm add -g acp-connector
# or
yarn global add acp-connectorThen start the bridge:
acp-connectorassistant-setup wizard (experimental)
assistant-setup is a single-file binary (compiled with bun build --compile)
that bootstraps a personal AI assistant from the
galiprandi/assistant template —
no Node, git, or package manager required on the target machine.
What it does:
- Detects agent CLIs in
PATH(devin, claude, opencode, agy, pi) and best-effort flags ones that look logged in. - Asks for an install directory (default
~/my-assistant). - Downloads the template tarball and extracts it there.
- Optionally writes a minimal
acp-connector.jsoncfor a Telegram bot. - Optionally installs autostart on boot — opt-in only — via the native
mechanism:
systemd --user(Linux), launchdLaunchAgent(macOS), or Task Scheduler (Windows). - Hands off to the detected agent with the template's setup prompt
(
claude -p,opencode run; interactive agents launch with the prompt printed to paste).
Build the binaries (requires Bun on the build machine only):
pnpm build:wizard
# → dist-wizard/assistant-setup-linux-x64
# dist-wizard/assistant-setup-macos-arm64
# dist-wizard/assistant-setup-windows-x64.exeOn every push to main that touches src/, the wizard.yml workflow
rebuilds all binaries — wizard and bridge — and attaches them to the latest
release, so downloads always ship the current build.
Container image (server flavor)
container/Dockerfile builds an all-in-one agent image: OpenCode (free
models), acp-connector, the assistant template, Node 22 and Chromium. One
container per agent, state in a /data volume (identity, .browser-profile/,
acp-connector.jsonc):
docker build -f container/Dockerfile -t agent .
docker run -d --name my-agent -v my-agent-data:/data \
-e TELEGRAM_TOKEN=<bot-token> agentcontainer/docker-compose.example.yml shows a multi-agent layout (one
service + volume per agent). Env vars: TELEGRAM_TOKEN, DISCORD_TOKEN,
AGENT_CMD (default opencode acp).
Distribute the binary; end users just run ./assistant-setup.
⚡ Quick start
# 1. Create a Telegram bot via @BotFather, get the token
# 2. Run the setup wizard
npx acp-connector setup
# 3. Start the bridge
npx acp-connectorSend a message to your bot on Telegram. Your agent will respond. That's it.
📚 Table of Contents
- Overview
- Installation
- Quick start
- How it works
- Configuration — cron, routines, HTTP API, agent reminders
- A2A agent network — peer discovery, pairing, delegation
- Chat commands
- Supported agents
- MCP servers
- Protocol support matrix
- Permissions · Session persistence · Status feedback
- Self-hosting
- Troubleshooting
- Contributing
- License
🏗 How it works
┌──────────┐ ┌─────────────────┐ ┌─────────────┐
│ Telegram │────▶│ │ │ │
│ │ │ acp-connector │────▶│ ACP Agent │
│ (you) │◀────│ (bridge) │ │ (any agent) │
│ │ │ │◀────│ │
└──────────┘ └─────────────────┘ └─────────────┘
▲
│
┌────────┴────────┐
│ │
┌────┴────┐ ┌─────┴───┐
│ Cron │ │ HTTP │
│ (jobs) │ │ (API) │
└─────────┘ └─────────┘- Telegram messages are validated against an allowlist and enqueued
- Cron jobs inject prompts on a schedule into the same queue
- HTTP
POST /promptinjects prompts programmatically into the same queue - The bridge processes the queue one prompt at a time (ACP is blocking)
- Agent responses stream back to Telegram with live message edits
- Permissions are forwarded as inline buttons (or auto-approved)
When a prompt arrives while the bot is busy, the bridge reacts to the user's message with a queued indicator (😴 on Telegram, ⏳ on Discord) and clears it once the prompt starts processing.
Replying to a message (Telegram reply_to_message, Discord msg.reference) injects the quoted text as context: the prompt is forwarded as [in reply to @user: "quoted text"] your prompt, truncated at 300 chars. Works on both text and media captions.
⚙️ Configuration
All configuration lives in a single acp-connector.jsonc file in your working directory. The setup wizard creates it for you, or you can write it manually.
See acp-connector.example.jsonc for the full reference.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| agentCmd | string | yes | — | Command to launch the ACP agent (e.g. "devin acp") |
| agentCwd | string | no | cwd | Working directory for the agent subprocess |
| platforms | Platforms | yes | — | Platform configs (Telegram and/or Discord) |
| platforms.telegram | object | no | — | { token, allowedChatIds, allowedUserIds? } — Telegram config |
| platforms.discord | object | no | — | { token, allowedChannelIds, allowedUserIds? } — Discord config |
| sessionId | string | no | — | ACP session ID to load/resume (omit to create new) |
| sessionMode | string | no | — | Initial session mode (e.g. "bypass" for Devin's auto-approve-all) |
| sessionConfigPath | string | no | — | Path to MCP/session config JSONC |
| showThoughts | boolean | no | false | Forward agent thoughts to chat |
| showTools | boolean | no | true | Show tool calls (e.g. "Read file", "Run tests") in chat. Set to false when only the final answer matters |
| showPlan | boolean | no | true | Show agent plan/checklist in chat |
| streaming | boolean | no | true | Stream responses with live message edits |
| echoInjectedPrompts | boolean | no | true | Echo prompts injected via cron, HTTP, routines or reminders into the chat with a source label (⏰ [cron]/[agent-cron]/[reminder]/[agent-reminder], 🌐 [http], 🔁 [routine]) so you can see what triggered the agent's response |
| logLevel | string | no | "info" | Bridge log verbosity: "error" (errors only) | "warn" | "info" | "debug". Does not affect the agent subprocess's own stderr output |
| logPreviewLength | number | no | 150 | Max characters of 👤/🤖 chat previews written to the log (min 20; longer text is cut with …) |
| timezone | string | no | — | IANA timezone for absolute reminder times without an explicit zone ("mañana 18hs", "tomorrow 10am", at values) — e.g. "America/Argentina/Tucuman". Required for those: unset means they're rejected rather than guessed against the host's zone. Pure durations ("30m", "in 45 minutes") and explicit zones (Z, ±HH:MM) don't need it. Written automatically by acp-connector setup |
| cron | CronJob[] | no | [] | Scheduled jobs (see below) |
| routines | Routine[] | no | [] | Named reusable prompts |
| http | HttpConfig | no | off | HTTP server config (see below) |
| media | MediaConfig | no | off | Media handling config (see below) |
Backward compat:
telegramTokenandallowedChatIdsat the root level still work but are deprecated. Migrate toplatforms.telegram.
Cron jobs
Schedule prompts to run automatically. Configure in acp-connector.jsonc or manage via Telegram commands.
{
"cron": [
{
"name": "daily-briefing",
"schedule": "0 9 * * *",
"prompt": "summarize today's calendar and unread emails",
"chatId": 123456789
}
]
}Or from Telegram:
/cron add 0 9 * * * summarize today's calendar and unread emails| Field | Type | Required | Description |
|---|---|---|---|
| name | string | yes | Unique job name |
| schedule | string | yes | Cron expression (e.g. "0 9 * * *") |
| prompt | string | yes | Prompt to send to the agent |
| chatId | number | no | Chat to send the response to (default: first allowed) |
| enabled | boolean | no | true (set false to pause) |
All changes persist to acp-connector.jsonc automatically.
Routines
Named reusable prompts. Define them in config or create via Telegram.
{
"routines": [
{ "name": "briefing", "prompt": "summarize today's calendar and unread emails" },
{ "name": "review", "prompt": "review the latest PRs on my repos" }
]
}Run from Telegram:
/run briefing| Field | Type | Description |
|---|---|---|
| name | string | Unique routine name |
| prompt | string | Prompt text |
Agent reminders & agent-managed cron (MCP tools)
The bridge always injects an agent-reminders MCP server into session/new, giving the agent tools to schedule work for itself — no config needed. Agents without MCP support simply never see the tools (a one-line warning is logged at startup).
Reminders are one-shot. When a reminder fires, its prompt is injected into the queue as [Reminder <id>] <prompt> and the entry is deleted. If the agent isn't connected when it fires, the entry is kept and retried — a late action beats a lost one. Reminders persist across restarts in .acp-connector/reminders.json (max 50 active; entries past firesAt by more than 7 days are discarded).
Every reminder is tagged managed: "agent" | "operator". Agent-created reminders fire as remote content (source agent-reminder): their prompt cannot run / bridge commands even if it starts with one. Operator reminders created via /reminder add keep full operator authority. The agent API can list all reminders but reminder_cancel/reminder_update only touch managed: "agent" entries — operator reminders are read-only, same rule as cron. Entries written before managed existed are migrated as agent-owned on load.
Responses include firesAt (UTC ISO) and firesAtLocal — the fire time rendered in timezone with its GMT offset and IANA zone name (e.g. Nov 12, 2026, 6:00 PM GMT-3 (America/Argentina/Buenos_Aires)), so the agent can confirm the schedule to you and you can spot a wrong-zone setup at a glance.
| Tool | Purpose |
|---|---|
| reminder_create({ prompt, in?, at? }) | Schedule a one-shot reminder. Exactly one of in/at. Accepts human strings ("15m", "2 hours", "tomorrow 10am") and ISO datetimes. Pure durations are relative to now; absolute times without an explicit zone are parsed in timezone (required — unset rejects them) and stored in UTC |
| reminder_list() | List pending reminders with id, firesAt, firesAtLocal and managed owner |
| reminder_cancel({ id }) | Cancel an agent-owned pending reminder (operator reminders are read-only) |
| reminder_update({ id, in?, at?, prompt? }) | Snooze/reschedule or reword an agent-owned reminder |
| cron_create({ every \| expr, prompt }) | Create a recurring job owned by the agent. every accepts "30m", "1h", "1d", "1w" (even divisors); expr is a raw 5-field cron expression |
| cron_list() | List all jobs — operator jobs are flagged readOnly: true |
| cron_update({ id, ... }) / cron_delete({ id }) | Modify/delete only managed: "agent" jobs; operator jobs are read-only |
Agent-created cron jobs are persisted to acp-connector.jsonc with managed: "agent" — they survive restarts and the agent can update/delete them later (capped at 50 active agent-created jobs). Jobs you write in the config or create via /cron chat commands are operator-owned: the agent can list them but cannot modify them.
Agent-created cron prompts are agent input, not bridge administration. When an agent-managed job fires, its prompt is injected as [Agent cron <id>] <prompt> and cannot run / bridge commands (/a2a, /mode, /cron…) even if the prompt starts with one — agent reminders get the same guard. Operator jobs fire verbatim, unchanged.
Operator reminders (/reminder)
You can also schedule one-shot reminders yourself from the chat — symmetric to /cron:
| Command | Purpose |
|---|---|
| /reminder list | List pending reminders with id, owner (👤 operator / 🤖 agent), local time + zone, and prompt |
| /reminder add | Schedule a reminder — e.g. `/reminder add in 20 minutes check the oven` or `/reminder add tomorrow 18hs call mom`. The "when" comes first; absolute times resolve in `timezone` (or an explicit `±HH:MM`/`Z` offset) |
| `/reminder remove `<id> | Cancel a reminder (rm/cancel aliases) |
Operator reminders are stored in the same .acp-connector/reminders.json and confirmed with the local time + GMT offset + IANA zone, so a wrong-zone schedule is visible immediately. Unlike agent reminders they keep operator authority: a prompt starting with / can run bridge commands, exactly like /cron jobs.
HTTP API
Optional. Enable in config:
{
"http": {
"enabled": true,
"host": "127.0.0.1",
"port": 7780,
"auth": {
"token": "your-secret"
},
"forwardHeaders": false,
"maxBodySize": 1048576,
"rateLimit": 60
}
}| Field | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | false | Start the HTTP server |
| host | string | "127.0.0.1" | Bind address (use "0.0.0.0" for LAN access) |
| port | number | 7780 | Port to listen on |
| auth.token | string | null | Bearer token for auth (required for non-loopback hosts — the server refuses to start with an external host and no token) |
| commands | boolean | false | Allow /commands (e.g. /a2a, /mode, /cron) to be run via POST /prompt — default false: prompt text sent over HTTP is agent input, never bridge administration |
| callbackAllow | string[] | [] | Hostnames accepted as callbackUrl targets besides public ones (loopback/private hosts are rejected by default) |
| forwardHeaders | boolean | false | Forward request headers to agent (Authorization always stripped) |
| maxBodySize | number | 1048576 | Max body size in bytes (1MB) |
| rateLimit | number | 60 | Max requests per minute |
GET /health
curl http://localhost:7780/health{ "status": "ok", "agent": true, "session": "abc-123" }POST /prompt
Send a prompt with structured JSON:
curl -X POST http://localhost:7780/prompt \
-H "Content-Type: application/json" \
-d '{"text": "fix the failing tests", "chatId": 123456789}'Or send a raw body with query params as context:
curl -X POST "http://localhost:7780/prompt?origin=outlook&from=boss" \
-H "Content-Type: application/json" \
-d '{"from": "[email protected]", "subject": "URGENT"}'The agent receives: [origin=outlook, from=boss] {"from": "[email protected]", "subject": "URGENT"}
POST /promptsends agent input. To run bridge commands (/a2a,/mode, ...) over HTTP sethttp.commands: truein the config. AchatIdin the body is only honored when it is in the platformallowedChatIds.
With auth:
curl -X POST http://localhost:7780/prompt \
-H "Authorization: Bearer your-secret" \
-H "Content-Type: application/json" \
-d '{"text": "hello"}'{ "ok": true }The prompt enters the same queue as Telegram messages. If chatId is omitted, the first allowed chat ID is used.
Sending files via /prompt
Include files as an array of base64-encoded objects:
curl -X POST http://localhost:7780/prompt \
-H "Content-Type: application/json" \
-d '{
"text": "analiza esta imagen",
"files": [
{ "data": "iVBORw0KGgo...", "mimeType": "image/png", "filename": "screenshot.png" }
]
}'- Images (
image/*) → sent asImageContentbase64 blocks - Other files → sent as
ResourceLinkwith data URI - Multiple files supported — text is appended as a final text block
- Backward compatible: omit
filesfor text-only prompts
Webhook callback
Include callback_url (or callbackUrl) to receive the agent's response asynchronously:
curl -X POST http://localhost:7780/prompt \
-H "Content-Type: application/json" \
-d '{
"text": "fix the failing tests",
"callback_url": "https://your-ci.com/webhook"
}'The bridge returns 200 {ok:true} immediately. When the agent finishes, the bridge POSTs to the callback URL:
{ "response": "I fixed the tests by...", "error": null }If the prompt fails, error contains the error message and response is empty. The callback is fire-and-forget — failures are logged but not retried.
🤝 A2A agent network (experimental)
Enable a2a.enabled to put this bridge on an A2A agent
network — your agent becomes a first-class peer that can discover other agents and
delegate tasks to them (and vice versa). Everything on the wire is standard A2A;
peer discovery and owner approval are local glue the spec deliberately leaves open.
How it works
your agent (any ACP agent)
│ tools: list_remote_agents, send_message ← MCP, injected at session start
▼
acp-connector (this bridge)
│ /.well-known/agent-card.json ← your public card
│ JSON-RPC message/send ← inbound tasks from peers
│ /a2a/join, /a2a/confirm ← pairing (deployment glue)
▼
remote peers ← direct mesh, no brokerTwo directions, one config:
- Inbound: approved peers send
message/send(single response) ormessage/stream(SSE: task → status-update events → completed) → tasks enter the agent's prompt queue prefixed with[A2A from <peer> | task <id>]→ the agent's reply is streamed chunk-by-chunk and completed as the A2A result.contextIdis echoed back on both paths. - Outbound: the agent sees two MCP tools —
list_remote_agents()andsend_message(agent_name, message)— following the reference host-agent pattern from the official A2A samples. Agents without MCP support simply don't see them.
Peer discovery
Peers are discovered by three strategies, all resolving to an Agent Card URL:
| Strategy | When | How |
|---|---|---|
| Shared instances file | Same machine | ~/.acp-connector/instances.json — auto-registered at boot, deregistered on shutdown |
| mDNS broadcast | LAN | _a2a._tcp.local (requires optional bonjour-service dep) |
| Static config | Containers / remote | a2a.registry URL or a2a.trustedPeers |
After discovery, the card is always fetched via the standard well-known URI.
Trust model — double opt-in
Joining the network requires explicit approval from both owners:
- You discover a peer →
/a2a join <id>→ your card is sent to their/a2a/join - Their owner gets notified and runs
/a2a approve <you>→ your side gets a confirm callback - Your owner runs
/a2a approve <them>→ both sides end upapproved
The confirm callback is informational only — it never grants trust by
itself. Each side's operator must run /a2a approve; there is no auto-promotion
(v0.13.0+, audit SEC-01). On approval the bridge also issues a random
per-pairing secret delivered once inside the approve → confirm POST — see
Peer authentication.
Same owner does not imply auto-approval. Pending requests persist in
.acp-connector/network.json and are re-announced on startup until resolved
(approve / reject / ignore / revoke — ignored is silent, rejected is explicit).
Inbound security:
- Only
approvedpeers can send tasks — anything else is rejected before any processing (the prompt is never built) - Peer identity uses the
X-A2A-Peer-Idheader; when a pairing secret exists, requests must also carry anX-A2A-Peer-SignatureHMAC (see Peer authentication below). For exposure beyond a trusted LAN seta2a.secure: true, declaresecuritySchemesin your card, and terminate TLS in front — plain insecure tunnels are not supported - Remote content never triggers bridge commands: A2A-injected text that
starts with
/is treated as agent input, not as a command. Commands work on every operator entry point (Telegram, Discord,/prompt, cron, routines) - Task-id dedup prevents retries re-executing side effects; delegation-chain metadata + a depth limit prevent A→B→A loops
- Approved peers' cards are refreshed periodically from their well-known URI — new skills propagate automatically; if a registry is configured, the instance announces itself on the same interval
- Every membership and task event is appended to
.acp-connector/audit.log(JSONL) — who delegated what, approvals, rejections, discoveries
Peer authentication
Peer ids are public (they appear in agent cards, /a2a/peers, mDNS and
network.json), so the id header alone is not a secret. When a peer is
approved (/a2a approve), the bridge issues a random per-pairing secret,
stores it in its peer record, and delivers it once inside the approve →
confirm POST. Both sides then sign A2A traffic with the same key:
- Outbound:
message/senddelegations and registry announces carryX-A2A-Peer-Signature: hex(HMAC-SHA256(secret, raw-body))whenever a secret for the target peer exists - Inbound: a present signature must verify against the raw body — mismatch is rejected before any processing
a2a.secure defaults to true: signatures are mandatory and unsigned peers
are rejected with a signature required error. Set secure: false only for
mixed-version meshes during migration — unsigned requests are then accepted
but audited as legacy-peer (once per peer). Known tradeoff: the pairing
secret itself crosses the wire exactly once (in the approve → confirm POST),
so a first-message man-in-the-middle on a plain-HTTP LAN could capture it —
accepted for LAN deployments without TLS.
Registry role
a2a.registryMode: true turns the instance into a discovery-only directory:
GET /a2a/registry/agents lists approved peers' cards (optionally filtered by
?skill=<id>), POST /a2a/registry/announce lets peers refresh their card and
liveness. The registry never sees task content — delegation stays peer-to-peer.
/a2a commands
| Command | Description |
|---|---|
| /a2a | Network status: peers grouped by state |
| /a2a pending | Pending join requests |
| /a2a peers | Approved peers |
| /a2a join <id> | Send a join request to a discovered peer |
| /a2a approve\|reject\|ignore\|revoke <id> | Manage membership |
| /a2a card | Show your card endpoint |
Headless mode
The bridge can run with no messaging platform at all: configure only
http.enabled (for /prompt input) and/or a2a.enabled (for peer tasks).
A loopback queue processor drives the ACP session and permissions are
auto-approved (no human in the loop — use only on trusted environments).
{
"agentCmd": "devin acp",
"http": { "enabled": true, "port": 7760 },
"a2a": { "enabled": true, "id": "my-agent", "card": { "name": "...", "description": "..." } }
}Files
.acp-connector/network.json— runtime network state (gitignored): peers, statuses, last-seen, seen task ids. Config is intent; this file is reality.
💬 Chat commands
Both Telegram and Discord support these commands:
The bridge intercepts these commands before forwarding to the agent:
Task control
| Command | Description |
|---|---|
| /stop | Cancel the current task (sends session/cancel to the agent) |
| /queue | List pending prompts waiting in the queue |
| /queue cancel <n> | Remove the pending prompt at position n |
| /queue clear | Remove all pending prompts |
| /status | Show agent state (busy/idle), session ID, mode, queue length and uptime |
Session management
| Command | Description |
|---|---|
| /new | Start a fresh session (clears accumulated context) |
| /sessions | List available sessions (requires agent session/list capability) |
| /session <id> | Switch to an existing session (uses session/resume or session/load) |
| /delete <id> | Delete a session (requires agent session/delete capability) |
| /mode | List available session modes reported by the agent |
| /mode <id> | Switch session mode (e.g. /mode bypass for Devin's auto-approve-all) |
| /config | List session config options reported by the agent (model, thought level, etc.) |
| /config <id> | Show the selectable values for an option |
| /config <id> <value> | Set an option via session/set_config_option (value ID, or true/false for boolean options) |
| /model [value] | Shortcut for /config model [value] — e.g. /model swe-2-high |
/new and /session are refused while the agent is busy — use /stop first. /sessions gracefully degrades with an error message if the agent doesn't support session/list. /delete cannot delete the active session (use /new first). /mode reports "No session modes available" if the agent doesn't expose modes. /config reports "No config options reported by the agent" if the agent doesn't expose configOptions.
/help is dynamic: besides the built-in commands it lists the slash commands the agent advertises via available_commands_update under an Agent commands section (Devin and OpenCode advertise theirs; agents that report none, like pi, simply omit the section). Typing an agent command forwards it to the agent as a prompt.
Initial session mode
Set sessionMode in the config to apply a mode automatically when a session is created or loaded:
{
"agentCmd": "devin acp",
"sessionMode": "bypass"
}This calls session/set_mode after session/new, session/load, or session/resume. The mode ID is agent-specific — Devin supports accept-edits, smart, ask, plan, and bypass. Other agents may expose different modes.
Cron management
| Command | Description |
|---|---|
| /cron list | List all cron jobs |
| /cron add <schedule> <prompt> | Add a new cron job |
| /cron remove <name> | Remove a cron job |
| /cron toggle <name> | Pause/activate a job |
| /cron run <name> | Run a job immediately |
Routine management
| Command | Description |
|---|---|
| /routine list | List all routines |
| /routine add <name> <prompt> | Add a reusable prompt |
| /routine remove <name> | Remove a routine |
Execution
| Command | Description |
|---|---|
| /run <name> | Execute a routine by name |
| /start | Show welcome message with command list |
| /help | Show welcome message with command list |
Any other message (including unknown /commands) is forwarded directly to the agent.
/stop cancels the in-progress prompt turn and clears the pending queue. The agent receives a session/cancel notification and should respond with a cancelled stop reason.
🤖 Supported agents
Any agent that implements the Agent Client Protocol works. Configure it via agentCmd:
| Agent | Example agentCmd |
|---|---|
| Devin | devin acp |
| Claude Code | claude acp |
| Codex | codex acp |
| Gemini CLI | gemini acp |
| OpenCode | opencode acp |
| pi | npx pi-acp (via pi-acp adapter) |
| Antigravity | npx agy-acp (via agy-acp adapter) |
| Any ACP agent | <your-agent> acp |
Verified end-to-end with A2A delegation: devin acp orchestrating opencode acp,
pi-acp and agy-acp peers.
Media handling
Send photos, documents, stickers, voice messages, videos, or files to the bot and they'll be forwarded to the agent:
- Photos + agent supports
imagecapability → sent as base64ImageContent(agent "sees" the image) - Any file (or agent without
imagecapability) → sent asResourceLinkwithfile://URI (agent reads the file from disk) - Voice notes (Telegram
voice, Discord voice messages) → transcribed to plain text locally via audio-transcription (sherpa-onnx Parakeet TDT, on-device) and sent to the agent as if typed — see Voice transcription below - Files are saved to
media.uploadsDir(default:/tmp/acp-connector-uploads) - Captions are included as text alongside the media
- Downloads retry transient failures (3 attempts, linear backoff); HTTP 4xx fails fast. On final failure the chat shows the underlying cause (e.g.
fetch failed: connect ECONNRESET [ECONNRESET])
{
"media": {
"uploadsDir": "/tmp/acp-connector-uploads"
}
}🎙️ Voice transcription
Voice notes are transcribed on the bridge machine — no audio ever leaves
your server, no API keys needed. Powered by the
audio-transcription
package (sherpa-onnx NeMo Parakeet TDT v3, 25 European languages incl.
Spanish, ~3x faster than real time on a modern CPU).
- First voice note lazily installs the runtime (
sherpa-onnx-node+ffmpeg-static, ~150MB) and downloads model weights (~490MB) into a shared per-user cache (~/.cache/audio-transcription). All acp-connector instances on the machine share it; concurrent installs/downloads are serialized with lock files. Expect minutes on first use — a 👀/🎧 reaction shows transcription is in progress. - The agent receives the transcript as plain text — it can't tell (and doesn't need to) that the message was a voice note.
- After transcription the audio file is deleted (success and failure).
Set
deleteAfterTranscribe: falseto keep successful transcriptions. - On failure the user gets
🎙️ Transcription failed — please resend the voice note.and nothing reaches the agent. - Only voice notes are transcribed — audio file attachments (mp3, m4a)
still go through as
resource_linkfiles. - Decoding is serialized machine-wide (one transcription at a time across
all bridges on the host) so CPUs are never saturated;
numThreadsdefaults tomin(8, CPU count).
{
"transcription": {
"enabled": true, // default: true — set false for legacy resource_link behavior
"deleteAfterTranscribe": true, // default: true
"cacheDir": null, // override ~/.cache/audio-transcription
"numThreads": null, // e.g. 2 on weak/shared machines
"lockTimeoutMs": null // default 10min
}
}🔌 MCP servers
The bridge passes sessionConfigPath to the agent unchanged. MCP servers (stdio, HTTP, SSE, or ACP) are configured in that file — the bridge doesn't interpret them.
Stdio
{
"mcpServers": [
{
"type": "stdio",
"name": "filesystem",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"],
"env": []
}
]
}HTTP
Requires the agent to advertise mcp.http capability.
{
"mcpServers": [
{
"type": "http",
"name": "my-api",
"url": "https://api.example.com/mcp",
"headers": [
{ "name": "Authorization", "value": "Bearer your-token" }
]
}
]
}SSE
Requires the agent to advertise mcp.sse capability.
{
"mcpServers": [
{
"type": "sse",
"name": "events",
"url": "https://api.example.com/sse",
"headers": []
}
]
}Mixed
You can combine multiple transports in the same config:
{
"mcpServers": [
{ "type": "stdio", "name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], "env": [] },
{ "type": "http", "name": "api", "url": "https://api.example.com/mcp", "headers": [] }
]
}The bridge does not validate MCP capabilities — if the agent doesn't support a transport, it will reject the session. Check your agent's
mcpCapabilitiesin its docs.
MCP servers not loading
If you configured sessionConfigPath, verify the file exists and is valid JSONC. The bridge logs errors reading it but doesn't crash.
Telegram rate limits
The bridge batches stream edits (800ms) to avoid rate limits. If you still hit limits, disable streaming ("streaming": false) to send one message per response instead.
📋 Protocol support matrix
A2A (agent ↔ agent)
| Feature | Status | Notes |
|---|---|---|
| message/send | ✅ | Inbound tasks become queued agent prompts |
| message/stream (SSE) | ✅ | Task(working) → status-update events → completed |
| contextId | ✅ | Echoed back; used for correlation, not session state |
| taskId dedup | ✅ | Duplicate deliveries rejected with -32002 |
| Delegation-chain / loop prevention | ✅ | Via a2a.delegationChain metadata (extension) |
| /.well-known/agent-card.json | ✅ | Generated from a2a.card config |
| tasks/get, tasks/cancel | ❌ | No long-running task tracking yet |
| Push notifications (tasks/pushNotificationConfig) | ❌ | Long tasks block until done |
| Agent Card signatures (JWS) | ❌ | Phase 2 (remote/internet trust) |
| securitySchemes enforcement | ⚠️ | Declared in card, but inbound auth is LAN trust (X-A2A-Peer-Id) — TLS + real auth is phase 2 |
| Extended Agent Card | ❌ | |
| Discovery: well-known URI | ✅ | Standard fetch after URL resolution |
| Discovery: curated registry | ✅ | a2a.registryMode + a2a.registry |
| Discovery: direct config | ✅ | a2a.trustedPeers |
| mDNS broadcast discovery | ✅ | Optional dep bonjour-service |
| Shared-file discovery | ✅ | ~/.acp-connector/instances.json |
ACP (bridge ↔ agent)
| Feature | Status | Notes |
|---|---|---|
| initialize / capability negotiation | ✅ | |
| session/new + session/load / resume / list / close / delete | ✅ | Session persistence per chat |
| session/prompt with streaming updates | ✅ | agent_message_chunk batched to the chat |
| session/request_permission | ✅ | Routed to Telegram buttons / Discord / auto-approved headless |
| session/set_mode, set_config_option | ✅ | Per-agent capability detection |
| mcpServers in session/new | ✅ | Used to inject the a2a-network MCP tools |
| available_commands | ✅ | Mirrored into /help |
| fs/*, terminal/* client capabilities | ❌ | Not implemented — agents get no fs/terminal via the bridge |
| authenticate | ❌ | Agents are expected pre-authenticated |
| Image / embedded context | ✅ | image + ResourceLink media support |
| Audio | ⚠️ | Voice/audio/video files are downloaded and sent as ResourceLink (agent reads them from disk) — no transcription, no ACP audio content block |
🔐 Permissions
ACP agents may request permission before executing certain actions (file writes, shell commands, etc.). The bridge handles this in two ways:
- Auto-approve: If your
agentCmdincludesdangerous,bypass, oryolo, all permissions are auto-approved silently. - Inline buttons: Otherwise, the permission request is forwarded to Telegram/Discord with "Allow" / "Deny" buttons. Tap to approve or deny.
Who can tap the buttons: private chats and DMs always work — they have a single human member. Inside group chats / guild channels, only users listed in platforms.telegram.allowedUserIds / platforms.discord.allowedUserIds can approve permissions or use the reconnect button; everyone else gets a "Not authorized" notice. If the list is empty, nobody in a group can approve permissions — configure it if you use groups:
{
"platforms": {
"telegram": { "token": "...", "allowedChatIds": [123], "allowedUserIds": [456] }
}
}Find your Telegram user ID via @userinfobot; Discord user IDs are under User Settings → Advanced → Developer Mode (right-click a user → Copy User ID).
🛡 Security configuration (0.13.0)
v0.13.0 is a security release — see the CHANGELOG migration table for the breaking-change summary. The relevant knobs:
- A2A peer auth —
a2a.secure: truemakes HMAC peer signatures mandatory (default on; seta2a.secure: falseonly for mixed-version meshes during migration). Details: Peer authentication. - HTTP API — non-loopback
http.hostrequireshttp.auth.token(refuses to start otherwise);http.commands: trueallows/bridge commands viaPOST /prompt(default: agent input only);http.callbackAllowwhitelists internalcallbackUrlhosts. - Group permissions —
platforms.*.allowedUserIdsgates who can approve agent permissions / reconnect inside groups (private chats and DMs always allowed). - Agent cron — agent-created jobs are remote content:
/-prompts never run bridge commands. - Audit backlog — full findings and statuses live in
docs/security-audit-2026-10-21.md; remediation plans inplans/.
Defense-in-depth hardening
Non-breaking hardening on top of the trust-model fixes:
- File permissions —
acp-connector.jsonc,.acp-connector/*state (network.json,reminders.json,audit.log,instances.json) and media uploads are written0600inside0700directories; pre-existing loose permissions are tightened on the next write. Upload filenames sanitize the extension to a safe charset and are created withO_EXCL, so a pre-planted symlink is never followed. - Resource caps — the prompt queue holds at most 100 items (chat messages and injected sources alike; excess is dropped with a warning), A2A request bodies are capped at 1 MiB (
413), and pending A2A join requests are capped at 100. - Permission buttons —
perm_*buttons are bound to the message that carried the request; stale buttons from older prompts can no longer resolve the current permission request. - Error hygiene — internal failures return a generic
internal errorto HTTP/A2A clients (details stay in logs and the audit trail), and HTTP bearer auth uses a constant-time comparison. - Delegation chains — the A2A delegation chain is propagated across agent-initiated delegations, so multi-hop loops are rejected at
maxDepth(default 3) instead of only direct A→B→A cycles. - Setup hardening — the
assistant-setupwizard downloads the template tarball pinned to an exact commit with SHA-256 verification, reads bot tokens without terminal echo, never interpolatesUSERintologinctlunsanitized, and refuses to overwrite a foreign~/.local/bin/node. Wizard binaries ship as GitHub release assets only — they are excluded from the npm tarball.
💾 Session persistence
To resume a session across restarts, set sessionId in your config:
{
"sessionId": "your-session-id"
}The bridge will call session/load or session/resume (depending on agent capabilities) on startup. Omit this field to create a new session each time.
⚡ Status feedback
- Typing indicator: while the agent works on a prompt, the bridge sends a
typingchat action every ~4s (TelegramsendChatAction, DiscordsendTyping) — visible even whenshowThoughts/showToolsare off. - Long messages: replies that exceed the platform limit (4096 chars on Telegram, 2000 on Discord) are split into multiple messages at paragraph → line → word boundaries (hard cuts only when no boundary fits). Continuation parts are sent as replies to the first part so they read as a single message.
- Agent crash detection: if the agent process exits unexpectedly, all allowed chats get a notification with a 🔄 Reconnect inline button. Tapping it re-spawns
agentCmdand resumes the previous session when possible (viasession/resume/session/load), falling back to a fresh session if resume fails.
🖥 Self-hosting
systemd
[Unit]
Description=acp-connector
After=network.target
[Service]
Type=simple
WorkingDirectory=/path/to/your/project
ExecStart=/usr/bin/npx acp-connector
Restart=on-failure
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.targetpm2
pm2 start npx --name acp-connector -- acp-connector
pm2 save
pm2 startup🐛 Troubleshooting
Conflict: terminated by other getUpdates request
Another bot instance is running with the same token. Kill it:
pkill -f acp-connectorAgent doesn't respond
- Check that
agentCmdlaunches your agent correctly: run it manually - Check the bridge console for errors
- Ensure your chat ID is in
allowedChatIds
🤝 Contributing
See AGENTS.md for development setup, commit conventions, and release process.
- Commits: Conventional Commits enforced by commitlint
- Linting: Biome
- Tests: Vitest —
pnpm test(536 tests) - PRs: CI runs lint + tests + commitlint on every PR
