poe-acp

module
v0.45.0 Latest Latest
Warning

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

Go to latest
Published: Jul 28, 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.

Updating

poe-acp update self-updates the binary in place: it resolves the latest GitHub release for the host's OS/arch, downloads the raw asset, verifies its sha256 against checksums.txt, and atomically renames it over the running binary. The rename (rather than cp-over-the-running-file, which fails with ETXTBSY) is what makes an in-place swap safe; the live process keeps its old inode until it restarts.

# Is a newer release available?
poe-acp update -check

# Update to the latest release (binary only — restart separately).
poe-acp update

# Pin a specific version.
poe-acp update -version v0.27.0

# Update and restart the supervisor so the new binary goes live.
# (This drops any in-flight conversation — inherent to restarting the relay.)
poe-acp update -restart-cmd "systemctl --user restart poe-acp-sea-fir"   # systemd --user
poe-acp update -restart-cmd "launchctl kickstart -k gui/$UID/<label>"     # launchd

Self-update is refused when the binary lives under a package-manager path (Homebrew, linuxbrew, /usr/bin); use brew upgrade poe-acp there instead. A remote host can be updated with ssh <host> poe-acp update -restart-cmd ....

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

Subcommands

update    Self-update the binary from GitHub Releases (see Updating above).
          Flags: -check, -version <vX.Y.Z>, -repo <owner/name>, -restart-cmd <cmd>

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)
--drain-deadline       Bound on a worker's post-SIGTERM drain (default 45s).
                       In-flight streams complete undisturbed below it; past
                       it the worker force-closes what remains, logs the
                       abandoned streams, and exits — and the supervisor
                       SIGKILLs it 15s later if it still has not. Keep well
                       under the init system's stop timeout (systemd
                       TimeoutStopSec=90s).
--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,
    "show_plans": true,
    "show_tools": 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.
  • defaults.show_plans — render the agent's current plan (ACP plan session update) as a checklist inside the live keepalive frame. Omitted = built-in default (true). Transient: the checklist is replaced on every plan revision and wiped when the final answer lands, so it never accumulates in the answer body.
  • defaults.show_tools — emit one durable blockquote line per ACP tool_call (e.g. > `🔧 go test ./...` ) so a tool-heavy turn leaves a readable trace of what the agent did. Omitted = built-in default (true). Only the START of each call is recorded; tool_call_update stays spinner-only.
  • 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. The relay keeps Poe's SSE connection alive for the WHOLE turn, not just the cold start (fir cold start is ~50s with a full extension set). Every --heartbeat-interval it checks the stream; if no user-visible content has landed within --stall-threshold, it emits a replace_response carrying the answer so far plus a transient status line (spinner, running tool, plan checklist). Replace — not append — so the animation updates in place and the transient region vanishes when the final answer lands. A normally-streaming turn stays on the cheap text-append fast path and emits no frames at all.

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

  • Mid-turn progress. A long tool-heavy turn is not silent. Each tool_call leaves a durable one-line blockquote in the answer (show_tools), and the agent's latest plan renders as a checklist inside the transient keepalive frame below the spinner (show_plans):

    > _🏛️ opus-5 • Running tests.._
    > ✅ wire config knobs
    > ⏳ render tool lines
    > ▫️ render plan checklist
    

    Tool titles are whitespace-collapsed, rune-capped and rendered inside a backtick-free code span, so a hostile or multi-line title cannot break the frame or inject markup. Plan checklists are capped at 8 entries (… +N more) because every keepalive frame re-sends them. Note that a tool line is real output, so on a tool-using turn it flips the disconnect gate (below) early: a client drop after it is treated as a user Stop, not as a pre-output transport drop to absorb and redrive. show_tools: false restores the old window.

  • Agent → Poe surface. Agent-emitted attachments and thoughts (when hide_thinking=true) are not forwarded back to the Poe user; tool_call_update is spinner-only. Beyond AgentMessageChunk text, the SSE stream carries tool-call lines (show_tools) and the transient plan checklist (show_plans).

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/command/        relay chat-commands: login, !help, !status, !model, …
  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
command
Package command implements the relay's chat-command surface: it brokers interactive OAuth login (bridging Poe turns into fir's _meta.auth.interactive two-call protocol) and the session-control commands (!status, !models, !model, !new, !help).
Package command implements the relay's chat-command surface: it brokers interactive OAuth login (bridging Poe turns into fir's _meta.auth.interactive two-call protocol) and the session-control commands (!status, !models, !model, !new, !help).
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.
poemcp
Package poemcp wires poe-acp's self-hosted MCP tools (attach, suggest) onto the generic acp-kit/mcphost Host.
Package poemcp wires poe-acp's self-hosted MCP tools (attach, suggest) onto the generic acp-kit/mcphost Host.
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.
poeupload
Package poeupload implements the Poe server-bot output-attachment upload, used to attach files to the bot's own reply in a Poe conversation.
Package poeupload implements the Poe server-bot output-attachment upload, used to attach files to the bot's own reply in a Poe conversation.
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.
sdnotify
Package sdnotify implements the minimal subset of the sd_notify(3) protocol needed to signal readiness under systemd, with no external dependencies.
Package sdnotify implements the minimal subset of the sd_notify(3) protocol needed to signal readiness under systemd, with no external dependencies.
selfupdate
Package selfupdate implements `poe-acp update`: download the latest release matching the current GOOS/GOARCH from GitHub Releases, verify its sha256 against checksums.txt, and atomically replace the running binary.
Package selfupdate implements `poe-acp update`: download the latest release matching the current GOOS/GOARCH from GitHub Releases, verify its sha256 against checksums.txt, and atomically replace the running binary.
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.
supervisor
Package supervisor implements a master/worker process model that makes zero-downtime relay upgrades structurally safe on BOTH systemd and launchd.
Package supervisor implements a master/worker process model that makes zero-downtime relay upgrades structurally safe on BOTH systemd and launchd.
test
fakeagent command
Command fakeagent is a minimal ACP agent used by the graceful-restart integration test.
Command fakeagent is a minimal ACP agent used by the graceful-restart integration test.

Jump to

Keyboard shortcuts

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