poe-acp

module
v0.15.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 21, 2026 License: MIT

README

poe-acp

Poe.com server-bot that drives ACP-compliant agents (default: fir --mode acp) as a pure ACP client. One binary, no MCP server surface. Each Poe conversation_id maps 1:1 to an ACP session inside a shared agent process.

See docs/poe-acp-design.md for the full design, scope, and milestones. For the underlying Poe wire protocol see docs/poe-protocol-reference.md.

Module: github.com/kfet/poe-acp. Standalone — not linked into the main fir binary.

Status: M1 complete. End-to-end Poe query → ACP session/prompt → SSE-streamed assistant response is verified against a real fir --mode acp child. See the "Live test" section below.

Quick start

# build
go build -o ./bin/poe-acp ./cmd/poe-acp

# run (requires fir on $PATH, or override via --agent-cmd)
export POEACP_ACCESS_KEY=mysecret        # match the key in your Poe bot dashboard
./bin/poe-acp \
  --http-addr :8080 \
  --agent-cmd "fir --mode acp" \
  --permission allow-all

# smoke test (separate shell)
POEACP_ACCESS_KEY=mysecret ./test/smoke.sh

Point your Poe bot at https://<host>/poe (with any reverse proxy or tailscale funnel fronting the plain HTTP port).

Deployment

The host just needs tailscale funnel enabled. The relay listens on a loopback port; Funnel exposes it publicly with a valid cert.

# 1. Enable funnel for the default path (if not already).
tailscale funnel --bg 127.0.0.1:8080

# 2. Set the Poe bot's Server URL (in the Poe dashboard) to:
#      https://<host>.<tailnet>.ts.net/poe
#    and copy the generated Access Key.

# 3. Start the relay with that key in env.
export POEACP_ACCESS_KEY=<key-from-poe-dashboard>
./bin/poe-acp --http-addr 127.0.0.1:8080 --agent-cmd "fir --mode acp"
Multiple bots on one host (path-based routing)

Several bots (e.g. the MCP bridge at external/poe and this relay) can share a Tailscale node by mapping each to a distinct path. tailscale funnel --set-path=<prefix> routes a URL prefix to a local port and strips the prefix before proxying. Because of the strip, the Poe bot's server URL must include a trailing path that the relay actually registers (/poe by default).

Concrete example used in our deployment:

# Existing MCP bridge on :8080 under /
#   (already set up via `tailscale funnel 127.0.0.1:8080`)

# Add the ACP relay on :8081 under /poe-acp/*
tailscale funnel --bg --set-path=/poe-acp 127.0.0.1:8081

# Start the relay. --poe-path is optional; /poe is always served as a
# fallback so tests keep working regardless.
POEACP_ACCESS_KEY=<key> ./bin/poe-acp \
  --http-addr 127.0.0.1:8081 \
  --poe-path /poe-acp \
  --agent-cmd "fir --mode acp"

Poe dashboard URL for this bot:

https://<host>.<tailnet>.ts.net/poe-acp/poe

That resolves, via Funnel, to the relay's /poe handler. (Without the /poe suffix the URL would become / after the prefix strip, which the relay doesn't serve and returns 404.)

Verify end-to-end:

curl -sS -H "Authorization: Bearer $POEACP_ACCESS_KEY" \
     -H 'Content-Type: application/json' \
     -d '{"type":"settings"}' \
     "https://<host>.<tailnet>.ts.net/poe-acp/poe"
# → {"allow_attachments":false,"introduction_message":"..."}

curl -sS -H "Authorization: Bearer $POEACP_ACCESS_KEY" \
     "https://<host>.<tailnet>.ts.net/poe-acp/debug/sessions"
# → {"count":0,"sessions":[]}
Auto-restart on the host (not yet automated)

v1 runs under nohup / a tmux window and must be restarted by hand on host reboot. A launchd / systemd unit is a straightforward M2 follow-up; a template will land alongside the first production deploy.

Endpoints

Path Auth Purpose
POST /poe Bearer Poe protocol: query/settings/etc.
GET /healthz none ok sessions=N
GET /debug/sessions Bearer JSON dump of conv → session state

Flags

--http-addr            HTTP listen address (default :8080)
--poe-path             HTTP path for the Poe protocol endpoint (default /poe)
--agent-cmd            ACP agent command + args (default "fir --mode acp")
--agent-dir            FIR_AGENT_DIR passed to the child (default inherit)
--state-dir            Per-conv state root (default $XDG_STATE_HOME/poe-acp)
--config               JSON config path (default $XDG_CONFIG_HOME/poe-acp/config.json)
--permission           allow-all|read-only|deny-all (default allow-all)
--access-key-env       Env var holding the Poe bearer secret (default POEACP_ACCESS_KEY)
--introduction         Poe introduction message
--session-ttl          Idle TTL for a conv (default 2h)
--gc-interval          GC sweep interval (default 5m)
--heartbeat-interval   SSE heartbeat tick (default 10s, 0 to disable)
--version              Print version and exit

Configuration

Operator-facing knobs live in a JSON config file (default $XDG_CONFIG_HOME/poe-acp/config.json, override with --config). Missing file = empty config = built-in defaults; zero-config installs keep working.

{
  "bot_name": "your-poe-bot-slug",
  "defaults": {
    "model": "anthropic/claude-sonnet-4-6",
    "thinking": "medium",
    "hide_thinking": true
  },
  "agent": {
    "profile": "fir"
  }
}
  • bot_name — Poe bot slug (the URL-path component on poe.com). When set, the relay auto-invalidates Poe's cached settings response whenever the emitted parameter_controls schema changes between boots, by POSTing https://api.poe.com/bot/fetch_settings/<bot_name>/<key>/1.1. Without it, operators must run that POST manually after schema-affecting changes (model auth flips, edited defaults, …).
  • defaults.model<provider>/<modelId> shown as the default in Poe's Options panel and applied on the first turn of every new conversation. Validated against the agent's probed model list at boot; out-of-list values are dropped (logged) and the relay falls through to the agent's own default. Decoupled from the agent's own current model so it stays stable across restarts.
  • defaults.thinking — one of off, minimal, low, medium, high. Empty = built-in default (medium).
  • defaults.hide_thinking — relay-side filter for agent_thought_chunk. Omitted = built-in default (true); set explicitly to false to stream thoughts as a blockquote.
  • agent.profile — reserved (today the relay only knows fir's set_config_option schema; multi-agent profile selection lands in a follow-up).

Unknown keys fail loudly at boot (DisallowUnknownFields). See docs/config.example.json.

CLI flags only cover ops concerns (listen address, state dir, permission policy). Anything that's "what kind of bot is this" goes in the config file.

Behaviour notes

  • Per-conv cwd. Each conversation_id gets a dedicated working directory under $STATE/convs/<conv_id>/, passed to fir via the ACP NewSessionRequest.Cwd. Fir's session-history, .fir/settings.json, and (optional) .fir/mcp.json are naturally isolated per conv.
  • Heartbeat. While an agent turn is in flight but before any real token has streamed, the relay emits a zero-width-space text event every --heartbeat-interval. This keeps Poe's SSE connection alive during slow first-token scenarios (fir cold start is ~50s with a full extension set). The heartbeat stops on the first real agent chunk.
  • Cancel propagation. If the Poe HTTP client disconnects mid-turn, the relay issues ACP session/cancel so fir stops burning tokens. Fir's StopReasonCancelled is translated to an SSE replace_response + done.
  • Stop reasons. end_turn → clean done; max_tokens / max_turn_requests → a "(truncated)" suffix + done; refusal → an SSE error event + done.
  • Commands. Fir publishes its available slash commands via the ACP available_commands_update session update on every new session. The relay snapshots the latest list and returns the names in the Poe settings.commands response so they show up in the Poe UI autocomplete menu.
  • Permission. allow-all (default), read-only, or deny-all via --permission. The relay answers session/request_permission locally; no prompt to the Poe user in v1.
  • Attachments → agent. Each Poe attachment on the latest user turn is downloaded to $STATE/convs/<conv_id>/.poe-attachments/<message_id>/<name> and forwarded as a file:// ACP ResourceLink, which fir picks up as an @<path> mention. Pre-parsed text (when Poe supplies parsed_content and the agent advertises embeddedContext) takes a zero-fetch fast path. Vision-friendly images (jpeg/png/gif/webp) under 3 MiB get an additive inline ImageBlock after the link so the LLM sees pixels directly. HEIC/PDF/video/octet-stream/oversized images "just work" via the agent's own tools (sips, pdftotext, Read, …) on the file path. Files past AttachmentTTL (30 days, ≥ --session-ttl) are reaped on the GC ticker. Hostile filenames are confined inside the per-message dir via Go 1.24's os.Root. Full design in docs/poe-acp-design.md → internal/router → Attachments.
  • Agent → Poe surface. Agent-emitted attachments, thoughts (when hide_thinking=true), plans, and tool-call updates are not forwarded back to the Poe user in v1 — only AgentMessageChunk text reaches the SSE stream.

Tests

go test ./...
  • internal/router — streaming, session reuse, StopReason translation, idle GC with an injectable clock.
  • internal/httpsrvquery round-trip (meta/text/done), settings JSON, bearer auth pass/fail.
  • test/smoke.sh — black-box curl SSE smoke test for a running relay (no fir patching required).

Live test log (2026-04-19)

Using fir 0.30.0-dev on the host:

  1. POST /healthzok sessions=0
  2. POST /poe {type:"settings"} → JSON with introduction_message
  3. POST /poe {type:"query", …}
    event: meta
    event: text (× N zero-width heartbeats, ~50s of fir boot)
    event: text (real assistant message)
    event: done
    
    curl exited 0.
  4. GET /debug/sessions → the conv's session_id + cwd + last_used.

One gotcha captured along the way: an earlier 30-second probe deadline was too tight for fir's cold start. Production uses the HTTP request context and is fine.

Layout

poe-acp/
  cmd/poe-acp/       entry point
  docs/                    design doc + Poe protocol reference
  internal/acpclient/      acp.Client impl + stdio agent proc wrapper
  internal/config/         JSON config loader (DisallowUnknownFields)
  internal/httpsrv/        /poe handler with heartbeat + cancel plumbing
  internal/paramctl/       parameter_controls schema builder + Resolve
  internal/poeproto/       minimal Poe HTTP+SSE
  internal/policy/         allow-all / read-only / deny-all
  internal/router/         conv_id → ACP session map + GC
  test/smoke.sh            black-box SSE smoke test

Directories

Path Synopsis
cmd
poe-acp command
Helpers extracted from main.go so they can be unit-tested in isolation.
Helpers extracted from main.go so they can be unit-tested in isolation.
internal
acpclient
Package acpclient wraps acp-go-sdk's low-level Connection for use by the Poe relay.
Package acpclient wraps acp-go-sdk's low-level Connection for use by the Poe relay.
authbroker
Package authbroker bridges Poe's turn-based chat into fir's _meta.auth.interactive two-call OAuth protocol.
Package authbroker bridges Poe's turn-based chat into fir's _meta.auth.interactive two-call OAuth protocol.
config
Package config loads the relay's JSON config file.
Package config loads the relay's JSON config file.
debuglog
Package debuglog is a tiny gated logger for opt-in verbose tracing.
Package debuglog is a tiny gated logger for opt-in verbose tracing.
httpsrv
Package httpsrv wires Poe HTTP requests into the router.
Package httpsrv wires Poe HTTP requests into the router.
paramctl
Package paramctl builds the Poe parameter_controls schema from the agent's available models and the operator's resolved defaults.
Package paramctl builds the Poe parameter_controls schema from the agent's available models and the operator's resolved defaults.
poeproto
Package poeproto is a minimal subset of the Poe server-bot protocol needed by the ACP relay: request decoding, SSE response writing, and bearer auth.
Package poeproto is a minimal subset of the Poe server-bot protocol needed by the ACP relay: request decoding, SSE response writing, and bearer auth.
policy
Package policy implements permission policies for session/request_permission calls issued by the ACP agent.
Package policy implements permission policies for session/request_permission calls issued by the ACP agent.
router
Package router: defensive helpers for attachment IO paths the production caller cannot trigger.
Package router: defensive helpers for attachment IO paths the production caller cannot trigger.
skills
Package skills embeds the relay's curated skill bundle, extracts the subset marked builtin to a per-content-hash dir on demand, and formats a fir-style <available_skills> catalog for injection into ACP agents.
Package skills embeds the relay's curated skill bundle, extracts the subset marked builtin to a per-content-hash dir on demand, and formats a fir-style <available_skills> catalog for injection into ACP agents.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL