npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

acp-connector

v0.16.0

Published

Thin bridge connecting messaging platforms to any ACP-compatible coding agent

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:

  1. Platform bridge: connects Agent Client Protocol (ACP) coding agents to Telegram and Discord — forwards your messages to the agent and streams responses back.
  2. 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_agents and send_message injected 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 /reminder chat 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 — /prompt with 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-connector

Then start the bridge:

acp-connector

assistant-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:

  1. Detects agent CLIs in PATH (devin, claude, opencode, agy, pi) and best-effort flags ones that look logged in.
  2. Asks for an install directory (default ~/my-assistant).
  3. Downloads the template tarball and extracts it there.
  4. Optionally writes a minimal acp-connector.jsonc for a Telegram bot.
  5. Optionally installs autostart on boot — opt-in only — via the native mechanism: systemd --user (Linux), launchd LaunchAgent (macOS), or Task Scheduler (Windows).
  6. 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.exe

On 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> agent

container/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-connector

Send a message to your bot on Telegram. Your agent will respond. That's it.


📚 Table of Contents


🏗 How it works

┌──────────┐     ┌─────────────────┐     ┌─────────────┐
│ Telegram │────▶│                 │     │             │
│          │     │  acp-connector  │────▶│  ACP Agent  │
│  (you)   │◀────│   (bridge)      │     │ (any agent) │
│          │     │                 │◀────│             │
└──────────┘     └─────────────────┘     └─────────────┘
                          ▲
                          │
                 ┌────────┴────────┐
                 │                 │
            ┌────┴────┐      ┌─────┴───┐
            │  Cron   │      │  HTTP   │
            │ (jobs)  │      │  (API)  │
            └─────────┘      └─────────┘
  1. Telegram messages are validated against an allowlist and enqueued
  2. Cron jobs inject prompts on a schedule into the same queue
  3. HTTP POST /prompt injects prompts programmatically into the same queue
  4. The bridge processes the queue one prompt at a time (ACP is blocking)
  5. Agent responses stream back to Telegram with live message edits
  6. 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: telegramToken and allowedChatIds at the root level still work but are deprecated. Migrate to platforms.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 /prompt sends agent input. To run bridge commands (/a2a, /mode, ...) over HTTP set http.commands: true in the config. A chatId in the body is only honored when it is in the platform allowedChatIds.

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 as ImageContent base64 blocks
  • Other files → sent as ResourceLink with data URI
  • Multiple files supported — text is appended as a final text block
  • Backward compatible: omit files for 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 broker

Two directions, one config:

  • Inbound: approved peers send message/send (single response) or message/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. contextId is echoed back on both paths.
  • Outbound: the agent sees two MCP tools — list_remote_agents() and send_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:

  1. You discover a peer → /a2a join <id> → your card is sent to their /a2a/join
  2. Their owner gets notified and runs /a2a approve <you> → your side gets a confirm callback
  3. Your owner runs /a2a approve <them> → both sides end up approved

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 approved peers can send tasks — anything else is rejected before any processing (the prompt is never built)
  • Peer identity uses the X-A2A-Peer-Id header; when a pairing secret exists, requests must also carry an X-A2A-Peer-Signature HMAC (see Peer authentication below). For exposure beyond a trusted LAN set a2a.secure: true, declare securitySchemes in 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/send delegations and registry announces carry X-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 image capability → sent as base64 ImageContent (agent "sees" the image)
  • Any file (or agent without image capability) → sent as ResourceLink with file:// 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: false to 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_link files.
  • Decoding is serialized machine-wide (one transcription at a time across all bridges on the host) so CPUs are never saturated; numThreads defaults to min(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 mcpCapabilities in 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:

  1. Auto-approve: If your agentCmd includes dangerous, bypass, or yolo, all permissions are auto-approved silently.
  2. 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: true makes HMAC peer signatures mandatory (default on; set a2a.secure: false only for mixed-version meshes during migration). Details: Peer authentication.
  • HTTP API — non-loopback http.host requires http.auth.token (refuses to start otherwise); http.commands: true allows / bridge commands via POST /prompt (default: agent input only); http.callbackAllow whitelists internal callbackUrl hosts.
  • Group permissions — platforms.*.allowedUserIds gates 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 in plans/.

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 written 0600 inside 0700 directories; pre-existing loose permissions are tightened on the next write. Upload filenames sanitize the extension to a safe charset and are created with O_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 error to 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-setup wizard downloads the template tarball pinned to an exact commit with SHA-256 verification, reads bot tokens without terminal echo, never interpolates USER into loginctl unsanitized, 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 typing chat action every ~4s (Telegram sendChatAction, Discord sendTyping) — visible even when showThoughts/showTools are 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 agentCmd and resumes the previous session when possible (via session/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.target

pm2

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-connector

Agent doesn't respond

  1. Check that agentCmd launches your agent correctly: run it manually
  2. Check the bridge console for errors
  3. 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

📄 License

MIT