poe-acp

module
v0.17.3 Latest Latest
Warning

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

Go to latest
Published: Jun 2, 2026 License: MIT

README

poe-acp

CI Go Reference Go Report Card

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"

# 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)
--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, access-key env). 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.
  • Status line. poe-acp prepends a compact one-line header to assistant responses and the live "Thinking…" spinner, surfacing the provider emoji plus the agent's current mood / plan progress so mobile users see fir-style indicators they'd otherwise miss without a TUI. The provider emoji is resolved by the relay from the model id; mood / plan are agent-supplied via the dev.acp-kit.status-line/v1 ACP extension on session/update._meta. Agents that don't emit _meta still get a provider-emoji-only header. See docs/ext/status-line.md.
  • 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.
  • 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 + flag wiring
  docs/                    design doc + Poe protocol reference
  internal/authbroker/     interactive OAuth login over Poe chat
  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/router/         conv_id → ACP session map + GC
  internal/skills/         embedded skill bundle (delegates to acp-kit/skills)
  test/smoke.sh            black-box SSE smoke test

Shared ACP primitives (agent process wrapper, debug log, skill loader) live in github.com/kfet/acp-kit.

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
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.
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.
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 owns the poe-acp embedded skill bundle and re-exports the catalog primitives from acp-kit/skills.
Package skills owns the poe-acp embedded skill bundle and re-exports the catalog primitives from acp-kit/skills.
statusline
Package statusline renders the poe-acp side of the dev.acp-kit.status-line/v1 ACP extension: a compact one-line header prepended to assistant responses and the live Thinking… indicator, so mobile users see fir-style mood/plan signals they'd otherwise miss without a TUI.
Package statusline renders the poe-acp side of the dev.acp-kit.status-line/v1 ACP extension: a compact one-line header prepended to assistant responses and the live Thinking… indicator, so mobile users see fir-style mood/plan signals they'd otherwise miss without a TUI.

Jump to

Keyboard shortcuts

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