Signal Channel¶
A feature-gated, per-session Signal messaging channel for amplihack that works for both GitHub Copilot CLI and Claude Code. When enabled, each amplihack session opens a private Signal group once for the whole session, mirrors the entire conversation to it (every user prompt and every assistant turn), and lets an allow-listed operator send messages back into the running session — injected as if typed at the CLI input box.
- Crate:
amplihack-signal - Cargo feature:
signal(default OFF) - Wire protocol: signal-cli JSON-RPC 2.0 over newline-delimited TCP
- Hosts: Copilot CLI and Claude Code (host-aware injection & teardown)
- Trust model: inbound text is surfaced to the agent as context only and is never auto-executed
Status: the channel is compiled out entirely unless you build with
--features signal. With the feature off there is zero runtime cost and no new dependencies pulled into the default build.
Contents¶
- How it works
- Prerequisites
- Onboarding:
amplihack signal setup - In-session onboarding prompt
- Fleet distribution:
amplihack signal distribute - Exit codes
- Quick start
- Configuration
- Host awareness (Copilot vs Claude Code)
- Group naming and lifecycle
- Per-session wiring
- Full conversation mirroring
- Host-aware context injection
- The inbound path (operator → agent)
- The outbound path (agent → operator)
- Security model / trust boundary
- Crate API reference
- Building and testing
- Offline testing (fake JSON-RPC endpoint)
- Troubleshooting
- FAQ
External-service integration. For the injectable seams,
amplihack-remotepublic re-exports, resumable fleet-distribute state, and how the external boundaries (signal-cli,azlin/az, device linking) are tested, see Signal External-Service Integration.
How it works¶
┌────────────────────────┐ JSON-RPC 2.0 (NDJSON/TCP) ┌──────────────┐
│ amplihack session │ ──────────────────────────────► │ signal-cli │
│ (hooks pipeline) │ outbound: mirror EVERY user │ daemon │
│ host = copilot|claude │ prompt + EVERY assistant turn │ (your number) │
│ │ ◄────────────────────────────── └──────┬────────┘
└───────────┬─────────────┘ receive stream │
│ │
│ SessionStart: create group ONCE/session, │
│ post "session started", spawn detached subscriber │
│ ▼
│ ┌────────────────────────┐
│ file inbox (AtomicJsonFile) ◄────────── │ signal-subscriber │
│ ▲ │ (long-lived connection) │
│ │ drain + host-aware inject │ allowlist + gate │
UserPromptSubmit / │ ───────────────────────────► │ + groupId + echo-suppr. │
PostToolUse │ copilot: top-level │ (fingerprint file) │
│ │ additionalContext (string) └────────────────────────┘
│ │ claude: hookSpecificOutput.additionalContext
▼ │
per-turn Stop: OUTBOUND relay only (post assistant turn, NO teardown)
SessionStop: post summary → quitGroup → stop subscriber (once/session)
The channel is wired through amplihack's existing hooks pipeline
(amplihack-hooks), not the recipe-runner. It is host-aware: the same
channel drives both GitHub Copilot CLI and Claude Code, detecting the
active host from AMPLIHACK_AGENT_BINARY and emitting the output shape each
host understands.
- SessionStart loads config (or, on an un-onboarded host, offers the
in-session onboarding prompt), creates a
Signal group once per session, persists its
groupIdin session state, posts a "session started" message, and spawns a detached, long-lived subscriber process whose PID is persisted. The group lives for the entire session, not per turn. - The subscriber holds a single JSON-RPC connection, filters messages to
this session's
groupId, applies the gate (allowlist + setup-aware authorization + echo suppression), and appends accepted operator messages to a per-session file inbox. - UserPromptSubmit and PostToolUse hooks drain the file inbox and inject any queued operator messages into the agent as if typed at the CLI input box, using the host-aware injection shape.
- Full conversation mirroring relays the whole session both ways: every
user prompt (from
UserPromptSubmit/userPromptSubmitted) and every assistant turn (from the per-turnStop/agentStophook, read from thetranscript_path) is posted to the group as it happens. - The per-turn Stop hook is OUTBOUND relay only — it posts the assistant turn but never tears down the channel.
- SessionStop (Claude
SessionEnd, CopilotsessionEnd) posts a session summary, callsquitGroup, and stops the subscriber. Teardown fires exactly once at session end.
Every Signal operation is non-fatal: failures are appended to the hook's
warnings[] and emitted via tracing, and the hook still exits 0. A broken
or unreachable Signal daemon can never crash or block your session.
Prerequisites¶
- A working signal-cli installation with a registered account (a dedicated phone number for the bot is strongly recommended — this account will send and receive messages).
- signal-cli running in JSON-RPC daemon mode over TCP:
- amplihack built with the
signalfeature (see Building).
New in this release — one-command onboarding. You no longer have to install signal-cli, link a device, start a daemon, and hand-write a config file yourself. The
amplihack signal setupcommand does all of it interactively (QR-based device linking), andamplihack signal distributerolls the same onboarding out across an entire fleet of Azure Linux VMs so the per-session channel works out of the box on any host. The manual Quick start below still works and documents exactly whatsetupautomates. See the companion how-to: Signal onboarding.
Onboarding: amplihack signal setup¶
amplihack signal setup is a first-class onboarding command that turns a
bare host into a fully working Signal channel host with a single interactive
run. It performs every step the channel needs and is idempotent — re-running
it repairs only what is missing and never re-links an already-linked device or
clobbers a valid config.
What it does¶
- Detects signal-cli. If it is already installed it is reused. If it is
missing,
setupinstalls it where it safely can, otherwise it prints clear, actionable install guidance and exits non-zero. There is no silent fallback — an unusable signal-cli is always surfaced as an error. - Links this host as a Signal device. It runs
signal-cli link, captures the device-link URI it emits, and renders it as a scannable QR code directly in your terminal, with the raw URI printed underneath as a copy/paste fallback. Open Signal on your phone → Settings → Linked devices → Link new device and scan it.
Link-URI scheme.
setupencodes whatever URI signal-cli emits — it does not assume a scheme. Recent signal-cli (libsignal-based) emitssgnl://linkdevice?uuid=...&pub_key=...; older builds emit the legacytsdevice:/?uuid=...&pub_key=.... Both are handled transparently; the QR renderer is scheme-agnostic.
Scan this QR code with Signal (Settings → Linked devices → Link new device):
█▀▀▀▀▀█ ▄▀ ▄▀█ █▀▀▀▀▀█
█ ███ █ ▀█▄▀▄ █ ███ █
█ ▀▀▀ █ █ ▄▀▀█ █ ▀▀▀ █
▀▀▀▀▀▀▀ █▄▀▄█▀ ▀▀▀▀▀▀▀
... (truncated) ...
Or paste this link into Signal manually:
sgnl://linkdevice?uuid=...&pub_key=...
Waiting for you to approve the link on your phone…
The wait for approval uses liveness / idle detection, not a fixed
wall-clock timeout — take as long as you need to reach your phone. setup
knows linking finished when signal-cli reports the new device is registered.
3. Starts a local JSON-RPC daemon. After linking succeeds it starts
signal-cli in daemon mode bound to loopback only (127.0.0.1:<port>,
default 7583) as a managed background service — a systemd --user
unit when systemd is available, otherwise a detached nohup process. The
daemon must be local to the session host; a shared remote daemon reached
over a tunnel does not work for the low-latency JSON-RPC this channel
requires.
4. Writes the config. It writes ~/.amplihack/signal-config.toml (mode
0600) using the exact existing SignalConfig schema,
with endpoint, account, and allowlist = [account]. It also emits an
explicit reuse_rolling_group = false line (with a short opt-in caveat
comment) so the per-session default is discoverable in the file itself:
each session gets its own fresh group and the file documents that reusing a
single shared group is an explicit opt-in. The generator writes only that
reuse_rolling_group = false line — it never pre-writes a rolling_group_id;
you add that yourself only when opting in (set reuse_rolling_group = true
together with a rolling_group_id). See
the single-number rule for why the account's own number is
allowlisted, and Group naming and lifecycle
for the per-session-vs-rolling behavior. Environment variables and an
explicit AMPLIHACK_SIGNAL_CONFIG still override this file; onboarding relies
on the loader's default-path fallback to ~/.amplihack/signal-config.toml
(see Configuration and
Per-session wiring).
That is the whole onboarding. The next amplihack session on this host will pick up the config automatically — no further steps (see Per-session wiring).
Flags¶
| Flag | Purpose |
|---|---|
--port <PORT> |
Daemon bind port on 127.0.0.1 (default 7583, or AMPLIHACK_SIGNAL_PORT). If the port is held by an amplihack-managed daemon it is reused; if held by an unknown process, setup fails cleanly with guidance. |
--force |
Repair/overwrite even when probes report an existing setup. Use with care — this can re-link the device. |
--json |
Machine-readable status output. The link URI is never emitted in --json (it is a secret, stderr-only). |
--all-vms |
Alias for amplihack signal distribute. |
Idempotency and repair¶
setup reports three independent probes and repairs only the missing pieces:
| Probe | Meaning | If already satisfied |
|---|---|---|
| linked | signal-cli account data / listDevices present |
Never re-links |
| daemon-running | JSON-RPC ping to the endpoint succeeds | Reuses the running daemon |
| config-written | ~/.amplihack/signal-config.toml parses under the schema |
Left untouched (unless --force) |
Running amplihack signal setup a second time on an already-onboarded host is
safe and fast — it verifies all three probes and exits 0.
In-session onboarding prompt¶
You no longer have to remember to run amplihack signal setup ahead of time.
When you launch amplihack (Copilot CLI or Claude Code) on a host that is
not yet configured for Signal, the SessionStart hook offers a fast,
skippable prompt asking whether you want to add this host as a signal-cli
device and mirror your session to Signal:
Signal channel is not configured on this host.
Add this host as a Signal device and mirror your sessions to Signal? [y/N]
When the prompt appears¶
The prompt is shown only when all of these hold:
SignalConfig::load()fails (no valid~/.amplihack/signal-config.tomland no env-var config), and- stdout/stdin is an interactive TTY, and
AMPLIHACK_NONINTERACTIVEis unset (or not1), and- no decline sentinel exists at
~/.amplihack/runtime/signal/.onboarding-declined.
If any condition fails, the prompt is silently skipped and the session
proceeds normally with the channel off. In particular, CI, scripts, and any run
with AMPLIHACK_NONINTERACTIVE=1 never see the prompt.
What each answer does¶
| Answer | Behavior |
|---|---|
Yes (y) |
Records onboarding intent and spawns amplihack signal setup detached (see why it is detached), then returns immediately. Signal stays off for the current session; mirroring activates automatically on the next launch once ~/.amplihack/signal-config.toml exists. |
No / Enter (N) |
Writes the .onboarding-declined sentinel so you are not asked again on this host, and continues with the channel off. |
| No response / non-TTY | Skipped; nothing is written; the channel stays off. |
To be asked again after declining, delete the sentinel:
Or onboard explicitly at any time with amplihack signal setup.
Why onboarding is non-blocking¶
Device linking is user-paced (you scan a QR code with your phone) and can
take far longer than a hook is allowed to run — Copilot CLI enforces a ~30s
hook timeout. The prompt therefore only records your choice and spawns the
real linking flow (run_setup) as a detached process; it never blocks the
SessionStart hook on QR scanning. This guarantees the onboarding UX can
never break, stall, or slow down a session, whether you accept, decline, or
Signal is unreachable.
Fleet distribution: amplihack signal distribute¶
amplihack signal distribute runs the same onboarding across every VM in your
Azure Linux (azlin) fleet, so each host ends up with its own local
signal-cli daemon and its own ~/.amplihack/signal-config.toml.
# Roll onboarding out to every discovered VM in a resource group.
amplihack signal distribute --resource-group <rg>
# Or target an explicit VM list.
amplihack signal distribute --vms vm-a,vm-b,vm-c --resource-group <rg>
# Equivalent alias.
amplihack signal setup --all-vms --resource-group <rg>
Identity model — one number, many linked devices¶
Each VM becomes its own linked device on your single Signal number. This is Signal-native and preserves one chat identity across the whole fleet. The consequences are important:
- Every VM needs its own device-link approval. This is an unavoidable Signal
requirement — you scan one QR code per VM, one at a time.
distributeorchestrates this: it generates a per-host link URI/QR, presents it, waits (idle detection, no wall-clock cap), and moves on once that VM is linked. - signal-cli account data is never cloned between hosts. Cloning one host's account store to multiple concurrently-running hosts causes device-identity / ratchet conflicts and is unsafe. Each VM links independently.
- Signal enforces a linked-device count limit (a small, fixed number of devices per account). For fleets larger than that limit, use the dedicated-number mode extension point (see below).
How the rollout runs¶
- Discovery is generic. VMs are enumerated via the existing azlin CLI
(
azlin list/az vm listwithin the operator's resource group) or an explicit--vmslist. No host is hardcoded. - Remote execution uses the existing azlin transport:
azlin connect <vm> --resource-group <rg> --no-tmux -y -- '<cmd>'. - Onboarding runs one VM at a time. Interactive linking is inherently
sequential (you have one phone, and interleaved QR codes on a single terminal
are unscannable), so the fleet rollout onboards VMs one at a time.
--concurrencyis accepted for forward-compatibility with future non-interactive rollout phases but is not applied to the interactive device-link step; passing a value> 1prints a notice and proceeds sequentially. (There is no arbitrary hard cap — the constraint is the human scan step, not a fixed resource limit.) - Resumable. State is persisted to
~/.amplihack/signal-distribute-state.jsonkeyed by VM name. Re-runningdistributeskips VMs that already succeeded and retries onlypending/failedones. - Failures are isolated and explicit. A failure on one VM (e.g. signal-cli install failed, device-limit reached, port conflict) never aborts the run. It is recorded with a reason and surfaced in the summary — there is no silent degradation.
Per-VM status¶
Each VM moves through these states, all reported at the end of the run:
| Status | Meaning |
|---|---|
pending |
Not yet started (or queued for retry) |
linking |
Waiting for you to approve the device link on your phone |
linked |
Device linked, daemon not yet up |
daemon-running |
Local JSON-RPC daemon is up on 127.0.0.1:<port> |
config-written |
~/.amplihack/signal-config.toml written — terminal success |
failed |
Onboarding could not complete; a human-readable reason is recorded |
Example summary:
Signal fleet distribution — 5 VMs
vm-build-01 config-written
vm-build-02 config-written
vm-gpu-03 failed reason: signal-cli install (no JRE; install guidance printed)
vm-gpu-04 config-written
vm-edge-05 failed reason: link limit reached (Signal linked-device cap)
3 succeeded, 2 failed. Re-run `amplihack signal distribute` to retry the failed VMs.
Flags¶
| Flag | Purpose |
|---|---|
--resource-group <rg> |
Azure resource group to discover / connect VMs in |
--vms <a,b,c> |
Explicit VM list instead of auto-discovery |
--concurrency <N> |
Reserved for future non-interactive phases; interactive linking always runs sequentially (values > 1 print a notice) |
--identity-mode <mode> |
linked-device (default) or dedicated-number (see below) |
--json |
Machine-readable per-VM status output |
--force |
Re-run onboarding on VMs already marked successful |
Dedicated-number mode (extension point)¶
For fleets larger than Signal's linked-device limit, distribute reserves a
config-selectable identity_mode = "dedicated-number" in which each VM would
register its own Signal number instead of linking to a shared one. This mode
is a clean, documented extension point: selecting it today returns an
explicit "not yet implemented" error rather than a partial or silent behavior.
The default linked-device mode is fully implemented.
Exit codes¶
Both signal setup and signal distribute map every failure through a
single source-of-truth taxonomy so results are scriptable (and stable under
--json). Codes are distinct — a caller can branch on why onboarding stopped:
| Code | Name | Meaning |
|---|---|---|
0 |
SUCCESS |
Fully onboarded (or nothing to do — idempotent re-run). |
2 |
USAGE |
Invalid arguments / flag combination (clap-level). |
3 |
UNSUPPORTED |
Built without the signal feature. A clean error, not a hidden no-op (#921). |
4 |
PRECONDITION |
signal-cli missing/uninstallable, or an invalid/unwritable config — a setup precondition failed. |
5 |
PARTIAL |
Fleet run finished but one or more VMs failed (distribute only). Re-run to retry pending/failed VMs. |
6 |
DAEMON |
Local daemon could not start — e.g. 127.0.0.1:<port> held by an unknown process. Never silently rebinds. |
7 |
LINK |
Device linking failed — e.g. approval error or Signal's linked-device cap reached. |
--json emits the same outcome as a structured object (per-VM for
distribute); the link URI is never included (see the security model).
Quick start¶
The fastest path is
amplihack signal setup(see Onboarding), which automates every step below. The manual steps here document exactly whatsetupdoes for you.
# 1. Start signal-cli in JSON-RPC daemon mode (in its own terminal).
signal-cli -a "+15551230000" daemon --tcp 127.0.0.1:7583
# 2. Configure the channel via environment variables.
export AMPLIHACK_SIGNAL_ENDPOINT="127.0.0.1:7583"
export AMPLIHACK_SIGNAL_ACCOUNT="+15551230000"
export AMPLIHACK_SIGNAL_ALLOWLIST="+15551230001" # your personal number
# 3. Build amplihack with the feature enabled.
cargo build --release --features signal
# 4. Run amplihack as usual. On SessionStart you'll be added to a new
# Signal group named "amplihack-<session-id>-<timestamp>" and receive a
# "session started" message.
Reply in that Signal group (from an allow-listed number, from your primary
device) with a short instruction such as "focus on the failing test first".
It will be delivered to the agent at the next UserPromptSubmit /
PostToolUse boundary as additional context — not executed automatically.
Configuration¶
Configuration is resolved env-first, then from an optional TOML file, then explicit error — there are no silent defaults. If a required value is missing from both sources, the loader fails and the channel stays off.
Resolution order for each setting:
The final ~/.amplihack/signal-config.toml step is the onboarding default
path: amplihack signal setup writes the config there, and the loader
consults it when neither environment variables nor an explicit
AMPLIHACK_SIGNAL_CONFIG supply a setting. This default-path fallback is added
by the onboarding feature (a loader change in SignalConfig::load /
load_config_or_disabled) so a freshly-onboarded host needs no exported
variables — see Per-session wiring. Absent onboarding
(and without env vars or AMPLIHACK_SIGNAL_CONFIG) the loader still errors and
the channel stays off; there are no other silent defaults.
Settings¶
| Setting | Env var | TOML key | Required | Format / notes |
|---|---|---|---|---|
| Endpoint | AMPLIHACK_SIGNAL_ENDPOINT |
endpoint |
✅ | host:port of the signal-cli JSON-RPC daemon |
| Account | AMPLIHACK_SIGNAL_ACCOUNT |
account |
✅ | E.164 (+ then digits) — the number amplihack sends as |
| Allowlist | AMPLIHACK_SIGNAL_ALLOWLIST |
allowlist |
✅ | Operator numbers allowed to send inbound. Env = comma-separated E.164. Empty ⇒ fail-closed (deny all inbound). |
| Own device id | AMPLIHACK_SIGNAL_OWN_DEVICE_ID |
own_device_id |
optional | signal-cli's own linked-device id (must be >= 2). Only used to reject the bot's own synced-back echoes explicitly; the primary-phone (device 1) gate is the main loop guard and needs no configuration. Leave unset unless you know your signal-cli device id |
| Reuse rolling group | AMPLIHACK_SIGNAL_REUSE_ROLLING_GROUP |
reuse_rolling_group |
optional | Default false (per-session groups). Opt-in only: a truthy value (1/true/yes/on, case-insensitive) reuses one long-lived shared group across every session instead of creating a fresh per-session group. Absent, empty, or explicit false values (0/false/no/off) resolve to per-session isolation; unknown tokens are rejected |
| Rolling group id | AMPLIHACK_SIGNAL_ROLLING_GROUP_ID |
rolling_group_id |
required when rolling reuse is enabled | Existing group id to bind to when — and only when — rolling reuse is opted into. Ignored while the per-session default is in effect |
| Config file path | AMPLIHACK_SIGNAL_CONFIG |
— | optional | Explicit path to the TOML file below. When unset, the loader falls back to the onboarding default ~/.amplihack/signal-config.toml |
| Pending inbox capacity | AMPLIHACK_SIGNAL_INBOX_CAPACITY |
— | optional | Positive integer number of queued inbound operator instructions retained before overflow evicts the oldest pending instruction. Defaults to 32; tune per host/workflow when operators intentionally send bursts |
Fail-closed allowlist. An empty allowlist is a valid, deliberate configuration meaning "accept no inbound instructions." It is not treated as "allow everyone." Outbound posting still works; only the inbound path is closed.
Single-number setups must allowlist their own number. If signal-cli is a linked device on your own number, your phone replies arrive as the account's own synced messages, so the
accountnumber itself must be on the allowlist. For a dedicated-number setup, allowlist the operator's separate number instead. An account number missing from the allowlist means every reply is silently denied (fail-closed).
Example TOML file¶
See examples/signal-config.toml for a fully
annotated example. Point the loader at it with:
endpoint = "127.0.0.1:7583"
account = "+15551230000"
allowlist = ["+15551230001", "+15551230002"]
# own_device_id = 2
# Per-session groups are the default. `amplihack signal setup` writes exactly
# this line (and nothing about `rolling_group_id`) so the isolation guarantee is
# visible in the generated file. Flip it to `true` AND uncomment/set
# `rolling_group_id` below to opt into one shared rolling group.
reuse_rolling_group = false
# rolling_group_id = "group.aBcDeF0123456789==" # only used when reuse_rolling_group = true
Any value present in the environment overrides the same key in the file.
Host awareness (Copilot vs Claude Code)¶
The channel drives both GitHub Copilot CLI and Claude Code from one code
path. It resolves the active host from the AMPLIHACK_AGENT_BINARY
environment variable (via amplihack_utils::agent_binary::resolve), which
accepts copilot, claude, codex, or amplifier (default: copilot).
Host detection controls two things that genuinely differ between the two CLIs:
| Concern | Copilot CLI | Claude Code |
|---|---|---|
| Inbound injection shape | Top-level additionalContext (string) on the userPromptSubmitted hook output |
Nested hookSpecificOutput.additionalContext (string) |
| Session-end event wiring | sessionEnd → session-stop-event subcommand |
SessionEnd → session-stop-event subcommand |
Everything else — group creation, mirroring, the subscriber, gating, echo suppression, teardown — is host-independent.
Why the injection shape matters. Copilot CLI ignores Claude Code's nested
hookSpecificOutput.additionalContext. Operator messages injected in the Claude shape would silently never reach the Copilot agent. The channel therefore emits the correct shape per host so injected operator text reaches the agent's context on both CLIs. See Host-aware context injection.
To force a host explicitly (for testing or in wrappers):
The installers stage host-appropriate hook wrappers automatically (see Per-session wiring); you normally do not set this by hand.
Group naming and lifecycle¶
Only the top-level operator session gets a group¶
The Signal channel is operator-facing: it exists so a human can watch and
advise the single session they launched. A real amplihack run can spawn nested
sessions — the orchestrator, recipe steps, and sub-agents each have their own
session_id. If every nested session opened Signal, the operator's phone would
fill with empty groups.
SessionStart therefore applies a nesting gate: a Signal group is created
only for the top-level operator session. Nested sessions are an intended
no-op: they create no group, post no session started marker, persist no group
state, and spawn no subscriber.
Nesting is detected from AMPLIHACK_SESSION_DEPTH, which amplihack increments
for child sessions:
AMPLIHACK_SESSION_DEPTH |
Session kind | Signal group created? |
|---|---|---|
unset or 0 |
Top-level operator session | ✅ Yes |
1, 2, … |
Nested recipe / orchestrator / sub-agent | ❌ No |
A non-numeric value is treated as depth 0, failing toward preserving the
visible operator session.
Group name¶
Per-session (default). On the top-level SessionStart a fresh group is created once for the entire session. When running inside tmux the name is:
Outside tmux it falls back to:
Hostname, tmux session name, and session id are sanitized before use in the
display name. The groupId returned by signal-cli is persisted in session
state and reused for every mirrored message throughout the session. Group
creation is idempotent: if a group already exists for the session it is
reused, never recreated.
Teardown happens once, at session end — not per turn. The per-turn
Stop / agentStop hook is outbound relay only and must never close the
group. The group is closed with quitGroup only by the SessionStop
handler, which fires on:
- Claude Code
SessionEnd, and - Copilot CLI
sessionEnd(wired to thesession-stop-eventsubcommand — see Per-session wiring),
both routed to SessionStopHook. This guarantees subscribers and groups do not
leak on Copilot session end.
The bug this fixes. In the pre-R5 wiring, teardown lived in the wrong place: the per-turn
StopHook(stop/agentStop) calledsignal_integration::on_stop, which posts "session complete" and runsquitGroup. Because Claude Code firesStopafter every assistant turn (and Copilot's only wired stop event wasagentStop→stop), the group was torn down after the first turn, while the realSessionStopHook(session-stop-event) did no Signal teardown at all. The fix is a migration, not an addition: move theon_stopteardown call out ofStopHookand intoSessionStopHook, leaveStopHookdoing outbound relay only, and add a CopilotsessionEnd→session-stop-eventwrapper so Copilot still tears down exactly once. Adding that wrapper is required, not optional — without it, a relay-onlyStopHookwould leave Copilot with no teardown path and its groups/subscribers would leak.
Rolling group (opt-in). Per-session groups are the default; nothing needs
to be set to get them. To instead reuse a single long-lived group across all
sessions, explicitly opt in by setting reuse_rolling_group = true (or
AMPLIHACK_SIGNAL_REUSE_ROLLING_GROUP=1) and supplying rolling_group_id.
In this mode the group is not quit at SessionStop, so you keep one persistent
operator thread. Because this trades per-session isolation for a shared thread,
it must be requested deliberately — any absent, empty, or explicit false reuse
flag keeps the per-session default, unknown tokens are rejected, and a truthy
reuse flag without a group id is rejected.
| Phase | Per-session | Rolling |
|---|---|---|
| SessionStart | create group once + post "session started" | reuse group + post "session started" |
| Each user prompt | mirror the prompt to the group | mirror the prompt to the group |
Each assistant turn (per-turn Stop) |
mirror the turn to the group — OUTBOUND relay only, no teardown | mirror the turn — no teardown |
SessionStop (SessionEnd / sessionEnd) |
post summary → quitGroup + stop subscriber |
post summary (group kept), stop subscriber |
Per-session wiring¶
Onboarding output feeds the existing SessionStart integration with zero further steps. The channel loads its configuration with the standard precedence, extended by the onboarding feature with a default-path fallback:
Implementation note. Today
SignalConfig::loadreads a TOML file only viaAMPLIHACK_SIGNAL_CONFIG. The onboarding feature adds the final~/.amplihack/signal-config.tomlstep (inSignalConfig::loador the hook'sload_config_or_disabled). This default-path fallback is the mechanism that makes the "zero further steps" promise hold — it must ship with onboarding.
So after amplihack signal setup (or distribute) has written
~/.amplihack/signal-config.toml on a host, every new amplihack session on
that host automatically opens its own dedicated Signal group — you do not need
to export any environment variables or set AMPLIHACK_SIGNAL_CONFIG. Env vars
still override the file when present, so nothing about the existing precedence
changes; the default path is only consulted when neither env vars nor an
explicit config path supply the settings.
Every Signal operation remains non-fatal: any failure is appended to the
hook's warnings[] and logged via tracing, and the session proceeds
normally. Onboarding does not change this contract — a missing, malformed, or
unreachable configuration can never crash or block a session.
Host wrapper generation and sessionEnd¶
The installers generate host-appropriate hook wrappers and settings:
- Claude Code (
~/.claude/settings.json): theSessionStart,UserPromptSubmit,PostToolUse,Stop, andSessionEndevents map to the matchingamplihack-hookssubcommands.SessionEnd→session-stop-event. - Copilot CLI (
~/.copilotsettings /.github/hooks/): theCOPILOT_HOOK_WRAPPERStable maps camelCase events to subcommands, including thesessionEndwrapper, which is wired to thesession-stop-eventsubcommand (SessionStopHook). This is what makes Copilot teardown fire so subscribers and groups do not leak on Copilot session end.
Dispatch in the hooks binary routes stop-family subcommands to the correct handler:
| Subcommand / alias | Handler | Role |
|---|---|---|
stop, agentStop |
StopHook |
Per-turn outbound relay only (mirror assistant turn) — no teardown |
session-stop-event, session-end, session-stop (Copilot sessionEnd, Claude SessionEnd) |
SessionStopHook |
Teardown: post summary → quitGroup → stop subscriber (once/session) |
Migration hazard — existing
session-end/session-stopaliases. Todaybins/amplihack-hooks/src/main.rsroutesstop | session-end | session-stopall toStopHook, and only the distinctsession-stop-eventsubcommand reachesSessionStopHook. OnceStopHookbecomes relay-only, leaving those aliases onStopHookwould silently drop teardown for any host still emitting asession-end/session-stopevent name. R5 resolves this by re-pointing thesession-endandsession-stopsubcommand aliases atSessionStopHook(the routing shown above), so every session-end event name —session-stop-event,session-end,session-stop— performs teardown, whilestop/agentStopstay per-turn relay-only. The design's own risk list warns this "could change dispatch for other consumers relying on the old StopHook routing," so this is a required, tested change: add a dispatch test asserting no teardown runs on per-turnstop/agentStopand exactly one teardown runs for each session-end alias.
Wrapper ownership detection (is_amplihack_owned) recognizes the
session-stop-event / sessionEnd wrappers, so re-running the installer merges
cleanly and never duplicates amplihack-owned entries in user settings.
Full conversation mirroring¶
The channel mirrors the whole session conversation to the Signal group — not just "session started" / "session complete" markers. Both directions of the conversation are relayed as they happen:
| Source hook | Copilot event | Claude event | Mirrored to group |
|---|---|---|---|
| User prompt | userPromptSubmitted |
UserPromptSubmit |
The user's prompt text |
| Assistant turn | agentStop |
Stop |
The assistant's turn output, read from the run's transcript_path |
Key properties:
- Per-turn Stop is outbound-only. Relaying the assistant turn happens in the
per-turn
Stop/agentStophook, which never tears down the channel. Teardown moves toSessionStop— theon_stop/quitGroupcall is relocated out ofStopHookintoSessionStopHook(see Group lifecycle for the before/after). - Message size is bounded. Every mirrored body — user prompts, assistant
turns, and injected operator context — is truncated at a UTF-8-safe boundary
to 4 KiB, with a
… [truncated N bytes]suffix. This keeps individual Signal messages small and well within the transport's 256 KiB frame cap. - No echo loops. The account's own mirrored messages are never re-injected as inbound operator instructions. Suppression uses two guards:
- Device/
is_syncgating — signal-cli's own synced-back sends (from a linked device,sourceDevice >= 2) are rejected by the gate. - Shared outbound fingerprints — every mirrored outbound body is
fingerprinted (hashed, with a TTL) into an append-only file under
signal_root()(~/.amplihack/runtime/signal/<session-id>/). The detached subscriber process reads these fingerprints before injecting, so echo suppression works across processes even though outbound relay runs in the hook process and inbound runs in the subscriber process. - Non-fatal. If a mirror send fails (daemon down, truncation edge case,
transcript unreadable), the failure is recorded in
warnings[]and the turn proceeds normally.
Host-aware context injection¶
When the inbox is drained (on UserPromptSubmit / userPromptSubmitted and
PostToolUse / postToolUse), queued operator messages are injected into the
agent as if typed at the CLI input box. The output shape is host-aware,
because the two CLIs read additionalContext from different places.
Copilot CLI — top-level additionalContext string on the hook output:
Claude Code — nested under hookSpecificOutput (unchanged, byte-for-byte
compatible with prior releases):
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Operator (via Signal): please also update the changelog"
}
}
The host is resolved from AMPLIHACK_AGENT_BINARY (see
Host awareness). Both the
user_prompt and post_tool_use drain sites route through the same host-aware
merge helper, so operator context reaches the agent's context on both CLIs.
The bug this fixes. Copilot CLI ignores the nested
hookSpecificOutput.additionalContextand reads a top-leveladditionalContextstring (per the Copilot hooks reference). Previously the inbound path always emitted the Claude-nested shape, so operator messages drained from the inbox never reached the Copilot agent. Host-aware injection makes end-to-end delivery work on Copilot while leaving Claude Code output identical.
The inbound path (operator → agent)¶
- The subscriber (
amplihack-hooks signal-subscriber, spawned detached at SessionStart) holds one long-lived JSON-RPC connection to signal-cli. - For each incoming envelope it validates the group envelope shape —
handling both
dataMessage.groupInfo.groupIdandsyncMessage.sentMessage.message.groupInfo— and keeps only messages for this session'sgroupId. - It applies the gate: allowlist membership, setup-aware
authorization,
groupIdmatch, and echo suppression (recently-sent outbound bodies are ignored within a bounded TTL window so the bot never re-ingests its own synced-back messages). Setup-aware authorization supports both deployment shapes: on a single-number linked-device setup the operator types on their primary phone, so the message arrives as the account's ownsyncMessagefromsourceDevice == 1and is accepted; on a dedicated-number setup the operator commands from a separate allowlisted number via a normaldataMessage. signal-cli's own sends sync back from a linked device (>= 2) and are rejected. - Accepted instruction text is appended to a per-session file inbox, a
JSON document managed by
AtomicJsonFile(crash-safe, lock-guarded). The inbox path is derived throughamplihack_types::paths::sanitize_session_idto prevent path traversal. The inbox is bounded and tunable: it holds at mostAMPLIHACK_SIGNAL_INBOX_CAPACITYpending instructions, defaulting to32when unset or invalid. When full, the oldest queued instruction is dropped to make room for the newest and the subscriber logs a warning — a flood of inbound messages can never grow memory or disk without limit (backpressure by bounded queue), and the budget is explicit rather than buried in code. - On the next UserPromptSubmit or PostToolUse hook, the inbox is
drained and its queued operator messages are emitted to the agent as if
typed at the CLI input box, using the
host-aware injection shape — a top-level
additionalContextstring for Copilot CLI, or the nestedhookSpecificOutput.additionalContextfor Claude Code. Injected bodies are truncated to 4 KiB. Draining is one-shot: each message is delivered once.
If the subscriber cannot start, the failure is recorded in warnings[] and via
tracing; the session continues normally with no inbound channel.
Reconnect resilience. Once a connection has been established at least once, a transient drop (daemon restart, stream close, receive error) does not end the channel: the subscriber reconnects with bounded exponential backoff (1s → 2s → … capped at 30s), preserving its echo-suppression/de-dup state and file inbox across reconnects so no instruction is lost or re-delivered. Any inbound message resets the backoff. To avoid spinning against a permanently-down daemon it gives keeps retrying at the capped backoff once a connection has succeeded; inbound mirroring is not silently abandoned after an arbitrary failure count. A cold-start connect failure (no connection ever established) stays fast and non-fatal — SessionStart spawns the subscriber best-effort and is never stalled by an absent daemon.
The outbound path (agent → operator)¶
amplihack mirrors the whole conversation to the group (see Full conversation mirroring) — every user prompt and every assistant turn — plus lifecycle markers:
- SessionStart — "session started".
- Each user prompt — mirrored from
UserPromptSubmit/userPromptSubmitted. - Each assistant turn — mirrored from the per-turn
Stop/agentStophook, read from the run'stranscript_path(outbound relay only, no teardown). - SessionStop — a session summary, then
quitGroup(per-session mode).
Every outbound body is truncated to 4 KiB at a UTF-8-safe boundary,
minimized, and redacted before sending. Each posted body is fingerprinted
(hashed, TTL-bounded) into a shared file under signal_root() so the
subscriber process will not treat the synced-back copy as an operator
instruction — echo suppression that works across processes. See
Full conversation mirroring for the echo-loop
guards.
Security model / trust boundary¶
The channel is designed around one hard rule:
Inbound Signal text is data, never commands.
Concretely:
- Never auto-executed. Accepted instructions are surfaced only as
additionalContext. amplihack never turns inbound text into a shell command, file write, or any other mutating action on its own. The agent may choose to act on the advice, subject to all normal amplihack safety hooks. - Fail-closed gate. Inbound requires all of: sender on the allowlist,
matching session
groupId, and setup-appropriate authorization — an accountsyncMessageis accepted only from the primary phone (sourceDevice == 1) and never from signal-cli's own linked device, while a separate allowlisted number is accepted via a normaldataMessage. An empty allowlist denies everything. - No self-ingestion. Echo suppression prevents the bot from re-processing
its own mirrored messages that Signal syncs back to the account. It combines
device/
is_syncgating (reject the account's own linked-device sends,sourceDevice >= 2) with a shared, TTL-bounded outbound-fingerprint file undersignal_root()that the subscriber consults before injecting — so suppression holds across the hook and subscriber processes. - Bounded egress (mirroring). Full conversation mirroring
relays user prompts and assistant turns outbound. Every mirrored body is
truncated to 4 KiB (UTF-8-safe), minimized, and redacted before sending;
egress is gated by the same loaded config and the
SIGNAL_ENABLEDprocess gate, so an un-onboarded or feature-off host sends nothing. - Feature default OFF. No
signalfeature ⇒ no code, no dependencies, no network sockets. In-process tests additionally keep theSIGNAL_ENABLEDprocess gate off, so no test performs real Signal I/O. - No silent config defaults. Missing required config is an explicit error, never a guessed value.
- Per-session group isolation by default.
reuse_rolling_groupdefaults tofalse, so each session gets its own group that is closed withquitGroupat SessionStop — no operator thread outlives the session that created it. Sharing one long-lived group across sessions is a deliberate opt-in (reuse_rolling_group = true/AMPLIHACK_SIGNAL_REUSE_ROLLING_GROUP=1) plus arolling_group_id; only non-empty truthy values enable it, and a missing group id is rejected instead of creating an untracked shared thread. - Path safety. Every per-session file path (inbox, PID, decline sentinel,
outbound-fingerprint file) is run through
sanitize_session_id; files are written atomically with restrictive permissions. - Least privilege on shutdown. Teardown runs only from
SessionStopand kills only the recorded subscriber PID, never a name-matched sweep. The per-turnStophook never tears down the channel. - Bounded inbox (flood resistance). The file inbox has a tunable capacity
(
AMPLIHACK_SIGNAL_INBOX_CAPACITY, default32); under an inbound flood the oldest instruction is evicted and the subscriber logs a warning rather than allowing unbounded memory/disk growth. - Non-fatal contract. Every Signal operation that fails is logged to
warnings[]+tracingand the hook still exits0.
Onboarding-specific boundaries¶
signal setup / signal distribute add their own hardening on top of the
runtime channel:
- Loopback-only daemon. The JSON-RPC daemon binds
127.0.0.1:<port>only; non-loopback / wildcard binds are refused and the port is never forwarded. - Link URI is High-sensitivity. The device-link URI (
sgnl://linkdevice?...or legacytsdevice:/?..., whichever signal-cli emits) is written to stderr only — never logged, persisted, or emitted under--json. - Injection-safe fan-out. VM / resource-group names are validated and
rejected at the boundary (E.164 account,
1..=65535port, charset-checked names) before shell-escaping; secrets travel via base64-over-stdin, never onargv. Validation is fail-closed, not silent stripping. 0600on disk.signal-config.tomlandsignal-distribute-state.jsonare written atomically (temp-then-rename) with0600permissions.- Allowlist integrity preserved. The writer emits only
allowlist = [account]— never empty or wildcard — keeping the fail-closed gate intact.gating.rsis untouched.
Crate API reference¶
amplihack-signal is organized as a small "brick" with a pure core
(config / wire helpers / gating / session_channel logic — no network or
filesystem I/O, unit-testable in isolation) plus a gated I/O shell
(transport and the SignalSession I/O owner, which require the async
tokio net stack). The crate is pulled into amplihack-hooks only under
--features signal; with the feature off it is neither compiled nor linked.
config¶
Env-first loader with explicit errors and no silent defaults.
use amplihack_signal::config::SignalConfig;
// Resolves env > TOML(AMPLIHACK_SIGNAL_CONFIG) > error.
let cfg = SignalConfig::load()?;
assert!(cfg.allowlist.iter().all(|n| n.starts_with('+')));
| Field | Type | Meaning |
|---|---|---|
endpoint |
String |
host:port of the daemon |
account |
String |
E.164 sending account |
allowlist |
Vec<String> |
Permitted E.164 senders (empty ⇒ deny all inbound) |
own_device_id |
Option<u32> |
signal-cli's own linked-device id (>= 2) for explicit echo rejection |
reuse_rolling_group |
bool |
Opt-in: reuse one shared rolling group. Defaults to false (per-session groups) |
rolling_group_id |
Option<String> |
Existing group id to bind rolling reuse to (required when reuse_rolling_group is true) |
transport¶
Newline-delimited JSON-RPC 2.0 client over tokio TCP.
| Method | Purpose |
|---|---|
connect(addr) -> SignalTransport |
Open a JSON-RPC connection. addr is the daemon endpoint in production, or a FakeSignalEndpoint address in tests |
create_group(name) -> GroupId |
Create a group (wraps the updateGroup create-by-name RPC) |
send_group(group_id, body) |
Post a message (wraps the send RPC) |
quit_group(group_id) |
Leave/close a group (quitGroup) |
receive() stream |
Async stream of parsed inbound envelopes |
RPC method names track the signal-cli JSON-RPC surface.
create_groupis expected to map toupdateGroup(creating a group by supplying a name and members); if the signal-cli daemon version in use names it differently, update this table to match the actual method invoked.
FakeSignalEndpoint(test-only) implements the same newline-delimited JSON-RPC 2.0 surface on a loopback127.0.0.1:0listener, soconnectcan be pointed at it for hermetic offline tests. See Offline testing.
Pure wire helpers (no I/O, unit-tested in isolation):
use amplihack_signal::transport::{build_send_request, parse_incoming};
// Build a JSON-RPC request frame for an outbound message.
let frame = build_send_request(&group_id, "hello");
// Parse one inbound NDJSON line into a typed envelope (tolerant / fail-safe).
let envelope = parse_incoming(line)?;
parse_incoming validates both group envelope shapes
(dataMessage.groupInfo.groupId and
syncMessage.sentMessage.message.groupInfo) and is covered by fixture-driven
unit tests over realistic JSON.
gating¶
Fail-closed decision function combining allowlist + groupId match +
setup-aware authorization (accept the account's own syncMessage only from the
primary phone sourceDevice == 1; accept a separate allowlisted number via
dataMessage) + bounded-TTL echo suppression.
use amplihack_signal::gating::Gate;
let mut gate = Gate::new(&cfg, session_group_id);
gate.record_outbound("session started"); // seed echo-suppression window
match gate.evaluate(&envelope) {
Some(instruction) => { /* append to inbox */ }
None => { /* dropped: not allow-listed / wrong device / echo / other group */ }
}
session_channel¶
SignalSession owns one per-session group and a file-backed inbox.
| Method | Purpose |
|---|---|
announce() |
Create/reuse the per-session group (idempotent) and post "session started" |
post(update) |
Post a throttled outbound update |
relay_outbound(kind, body) |
Mirror a user prompt or assistant turn to the group (4 KiB UTF-8-safe truncation + outbound fingerprint) |
poll() / drain() |
Read (and clear) queued inbound operator messages from the file inbox |
The inbox is an AtomicJsonFile (from amplihack-state) at a
sanitize_session_id-derived path, so writes by the subscriber process and
reads by the hook process are safe across processes.
Feature modules (host integration)¶
The amplihack-hooks signal_integration module ties the crate into the hooks
pipeline. Key host-aware entry points:
| Item | Role |
|---|---|
signal_integration::on_session_start |
Create the group once, spawn the subscriber, and offer the in-session onboarding prompt |
signal_integration::drain_into_context |
Drain the inbox and produce the host-aware injection output for the current host |
signal_integration::relay_outbound |
Full conversation mirroring for user prompts and assistant turns |
signal_integration::on_stop |
SessionStop teardown (quitGroup + stop subscriber) — never called from the per-turn Stop hook |
signal_integration::set_process_enabled |
Toggle the SIGNAL_ENABLED process gate (ON in the real binary, OFF in tests) |
FakeSignalEndpoint |
Test-only loopback JSON-RPC fake — see Offline testing |
Building and testing¶
The feature must build and test cleanly both ways — this is a hard quality gate:
# Default build: feature OFF (Signal fully compiled out).
cargo build
cargo test
# Feature ON — build the hooks binary and run the hooks test-suite.
cargo build --release -p amplihack-hooks-bin --features signal
cargo test --release -p amplihack-hooks --features signal
Integration tests are registered as explicit [[test]] targets and resolve the
hooks binary via env!("CARGO_BIN_EXE_amplihack-hooks"), so they exercise the
real signal-subscriber subcommand rather than an in-process stub. Pure
wire/gating tests run with no network or filesystem I/O.
All tests are hermetic and offline. cargo test --release -p amplihack-hooks
--features signal creates no real Signal groups and touches no real Signal
network — see Offline testing. The
existing hook and golden-snapshot suites are preserved green, with two new
Copilot golden cases added for the top-level additionalContext injection
shape (Claude Code golden output is unchanged, byte-for-byte). The regression
bar is "the full pre-existing suite still passes plus the two new Copilot
goldens" — run cargo test for the current counts rather than relying on a
number pinned in this doc.
Offline testing (fake JSON-RPC endpoint)¶
Signal channel tests never hit the real Signal network. Two independent mechanisms guarantee this:
1. The SIGNAL_ENABLED process gate¶
In-process hook tests run with Signal I/O disabled by default. The hooks
binary opts in explicitly at startup (signal_integration::set_process_enabled(true)),
while tests leave the gate off so exercising on_session_start,
drain_into_context, and on_stop performs no real Signal I/O and creates
no groups.
2. FakeSignalEndpoint — a local JSON-RPC fake¶
For tests that must exercise the transport and subscriber loop end-to-end,
amplihack-signal ships a fake signal-cli JSON-RPC endpoint modeled on
signal-cli's own JSON-RPC daemon (--tcp) socket mode. It is a
tokio::net::TcpListener bound to 127.0.0.1:0 (an ephemeral loopback
port) that speaks newline-delimited JSON-RPC 2.0, the same protocol the real
daemon uses. SignalTransport::connect is pointed at the fake's address, so the
full method surface can be driven deterministically:
| RPC method | Exercised behavior |
|---|---|
updateGroup (create) |
Returns a synthetic groupId; asserts create-group requests |
send |
Records outbound bodies for assertions (mirroring, truncation, fingerprints) |
receive |
Streams scripted inbound envelopes into the subscriber loop |
quitGroup |
Records teardown; asserts SessionStop (not per-turn Stop) closes the group |
Because the endpoint binds loopback-only and returns synthetic ids, tests can cover send / create-group / receive / quit-group and the inbound subscriber loop — including gating, echo suppression, host-aware injection, reconnect/backoff, and full-conversation mirroring — with zero external dependencies and no real Signal account, group, or network traffic.
// Illustrative test wiring (offline, deterministic, no real Signal).
let fake = FakeSignalEndpoint::start().await; // binds 127.0.0.1:0
let transport = SignalTransport::connect(fake.addr()).await?;
let group = transport.create_group("amplihack-test").await?; // synthetic id
transport.send_group(&group, "session started").await?;
fake.push_inbound(/* scripted operator envelope */); // drive receive()
// ... assert the message was injected in the host-aware shape ...
transport.quit_group(&group).await?;
assert_eq!(fake.groups_created(), 1); // never a REAL group
CI guarantee. The combination of the default-OFF
SIGNAL_ENABLEDgate and the loopback-onlyFakeSignalEndpointmeans no test path can reach the real Signal service. CI runs the full--features signalsuite with no signal-cli installed and no network access.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
| No "session started" message | Feature not built / daemon down | Build with --features signal; confirm signal-cli ... daemon --tcp; check warnings[] |
| Warning: config error at SessionStart | Missing required setting | Set AMPLIHACK_SIGNAL_ENDPOINT / _ACCOUNT / _ALLOWLIST (or the TOML file) |
| Your replies are ignored | Not allow-listed, or sent from a linked (non-primary) device | Add your number to AMPLIHACK_SIGNAL_ALLOWLIST; reply from your primary device (device 1) |
| Nothing ever accepted | Allowlist is empty (fail-closed) | Populate the allowlist |
| Bot seems to "hear itself" | (Should not happen) echo window too short | Instructions equal to a recent outbound body are suppressed by design |
| Subscriber not running | Spawn failed | Check warnings[]/tracing; the persisted PID file records the detached process |
| Some instructions never arrive | Inbox overflowed under a burst | The inbox is bounded and logs overflow warnings; raise AMPLIHACK_SIGNAL_INBOX_CAPACITY or send fewer, more deliberate instructions |
signal setup can't install signal-cli |
No package/JRE available non-interactively | Follow the printed install guidance, install signal-cli manually, then re-run amplihack signal setup |
signal setup fails on port |
127.0.0.1:<port> held by an unknown process |
Free the port or pass --port <other> / set AMPLIHACK_SIGNAL_PORT |
A VM shows failed: link limit reached |
Signal linked-device cap hit | Unlink an unused device in Signal, or use --identity-mode dedicated-number for very large fleets |
distribute stopped part-way |
Interrupted / a VM failed | Re-run amplihack signal distribute; it resumes from ~/.amplihack/signal-distribute-state.json and retries only pending/failed VMs |
| Operator messages never reach the Copilot agent | Wrong injection shape (nested instead of top-level) | Fixed by host-aware injection. Confirm AMPLIHACK_AGENT_BINARY=copilot; Copilot needs the top-level additionalContext string |
| Conversation not mirrored (only start/stop) | Old behavior / feature off | Full mirroring relays every user prompt + assistant turn; confirm --features signal and that transcript_path is readable |
| Group is torn down after one turn | Teardown wired to per-turn Stop | Per-turn Stop/agentStop is outbound-only; teardown must be on SessionStop (SessionEnd/sessionEnd → session-stop-event) |
| Subscriber/group leaks after Copilot exits | Copilot sessionEnd not wired |
Re-run the installer so the sessionEnd → session-stop-event wrapper is generated (see Host wrapper generation) |
| In-session onboarding prompt never appears | Non-TTY, AMPLIHACK_NONINTERACTIVE=1, already configured, or previously declined |
Expected. Delete ~/.amplihack/runtime/signal/.onboarding-declined to be asked again, or run amplihack signal setup |
| Bot re-injects its own mirrored messages | Echo suppression gap | Should not happen: device/is_sync gating + shared outbound-fingerprint file suppress echoes across processes |
Because every Signal operation is non-fatal, none of the above can break your
amplihack session — worst case the channel is unavailable, with the failure
surfaced through hook warnings and/or tracing, and the run proceeds normally.
FAQ¶
Does the Signal channel work with GitHub Copilot CLI, or only Claude Code?
Both. The channel is host-aware (AMPLIHACK_AGENT_BINARY): it emits a top-level
additionalContext string for Copilot and the nested
hookSpecificOutput.additionalContext for Claude Code, and wires teardown to
Copilot's sessionEnd as well as Claude's SessionEnd. See
Host awareness.
Does enabling Signal add dependencies to the default build?
No. With the feature off, amplihack-signal and its tokio-net dependencies
are not compiled or linked.
Is the whole conversation mirrored, or just start/stop? The whole conversation — every user prompt and every assistant turn — bounded to 4 KiB per message. See Full conversation mirroring.
Do the tests hit the real Signal network?
Never. The default-OFF SIGNAL_ENABLED gate plus the loopback-only
FakeSignalEndpoint guarantee no real groups or network traffic. See
Offline testing.
Can an operator make amplihack run a command by texting it?
No. Inbound text is delivered only as additionalContext. The agent decides
whether to act, and all normal safety hooks still apply.
Per-session vs rolling group — which should I use?
Per-session is the default and needs no configuration: each session creates
its own fresh group and cleans it up at Stop via quitGroup, giving clean
isolation and no cross-session message disclosure. Rolling is a deliberate
opt-in that keeps one persistent operator thread across runs; enable it by
setting reuse_rolling_group = true with a rolling_group_id.
Prefer the per-session default unless you specifically want one shared thread.
Where do inbound instructions get stored?
In a per-session, atomically-written JSON inbox whose path is derived through
sanitize_session_id. The inbox is bounded (oldest entries evicted under a
flood) and is drained (delivered once) on the next
PostToolUse / UserPromptSubmit.
See also¶
- Signal onboarding how-to —
setupanddistributewalkthrough - Signal external service integration — seam architecture, resumable fleet state, and
amplihack-remotere-exports examples/signal-config.toml— annotated config- Signal onboarding — performance notes — hot paths & allocation trims
- Hook configuration guide
- Security recommendations