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"
# 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
Single bot behind Tailscale Funnel (recommended)
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)
--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/httpsrv — query 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:
POST /healthz → ok sessions=0
POST /poe {type:"settings"} → JSON with introduction_message
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.
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.