Skip to content

Commit f44663f

Browse files
dimavrem22claude
andcommitted
Port shared iMessage-line calling, wizard channel flow, and external webhook injection from the hermes-agent-plugin reference
Brings this bridge to parity with the hermes-agent-plugin reference implementation (its tools.py / adapter.py / setup_wizard.py / realtime.py on main) for the shared iMessage line and external event injection. Two calling lines (dedicated number vs shared iMessage line): - tools.py: inkbox_place_call gains an `origination` argument (dedicated_number / shared_imessage_number) with channel-aware auto-resolution — explicit choice wins; a single enabled line is used as-is; when BOTH lines exist the call follows the CURRENT conversation's channel (iMessage turn -> shared line, SMS/phone turn -> dedicated number; unknown -> dedicated); neither line -> clear error telling the agent to provision a number or enable iMessage. A shared-line call rejected with no_shared_connection returns a legible message (connect over iMessage first, or fall back to the dedicated number). The resolved origination is echoed in the result, with a TypeError retry for SDKs that predate the kwarg. - Channel source: sessions already track the last inbound modality (ContactSession.mode); handle_inbound now mirrors it into ~/.inkbox-codex/channel_hints.json and each session stamps INKBOX_CODEX_CHAT_ID into its MCP tool-server env, so the stdio tool process resolves the current conversation's channel at call time. - inkbox_whoami now returns a "lines" block labelling the dedicated phone line vs the shared iMessage line (whose number is managed by Inkbox and never surfaced), with per-line origination notes. - Outbound call WS URL resolution prefers the identity-scoped incoming-call config row, then the legacy number-scoped field, then the tunnel host — so an iMessage-only identity can place calls. Identity-scoped inbound-call config (gateway): - _patch_identity_objects now registers the incoming-call action via identity.set_incoming_call_action (one row covers the dedicated number AND the shared iMessage line), gated on having a number OR imessage_enabled; the number-scoped phone_numbers.update remains only as a fallback when the SDK lacks the method (and is skipped for iMessage-only identities it cannot express). - _handle_call_ws backfills remote number + direction through an identity-centered calls.get(call_id) round-trip when the upgrade carries no caller metadata (shared-line calls have no owning number). Realtime instructions: - RealtimeCallMeta.agent_imessage_enabled threads the identity's iMessage state into the instruction builder, which now names the dedicated line explicitly and describes the shared iMessage line without ever stating a number for it; calls follow the conversation's channel. Setup wizard channel flow: - iMessage step now runs FIRST (intro copy mentions voice calls over the same shared line) and returns bool(enabled). - Dedicated-number provisioning is a STANDALONE step decoupled from identity creation/signup/API-key paths: prints "Already provisioned: <number>" when one exists, and on provisioning failure points at Inkbox paid tiers (https://inkbox.ai/pricing) plus the raw error and moves on. - Realtime is offered when the identity has a number OR iMessage enabled (flag threaded explicitly since the local identity object may be stale). External webhook injection (new webhook_providers/ package): - Provider registry with drop-in modules (inkbox.py delegating to the SDK's verify_webhook, github.py verifying X-Hub-Signature-256); classify-before-auth in _handle_webhook: the source is identified by its signature header, verified with that source's secret (INKBOX_WEBHOOK_SECRET_<NAME> for third parties), and routing keys off the verified source — never the body's claimed event_type. - Unknown event types wake the agent on a per-source external: session with an act vs do-not-act directive (verified vs unverified), bounded payload surfacing, and request-id dedup; default-off via INKBOX_EXTERNAL_EVENTS_ENABLED, with registered third-party providers bypassing the flag. External-thread replies are never delivered. Also: - inkbox SDK pin gets a floor AND ceiling (>=0.4.15,<1.0.0) in pyproject, the wizard install requirements, and the doctor/gateway hints. - README: "Two calling lines" + "External events" sections, config reference rows, and tool-list updates; channel prompt gains a per-channel "Calling someone" section; .env.example documents the new vars. - Version bump 0.1.0 -> 0.1.1 (pyproject + plugin manifest). - Tests: origination resolution matrix (incl. channel-follow and explicit-wins), place-call handler paths, whoami lines block, identity-scoped incoming-call-action assertions (with legacy fallback), webhook-provider registry/classify/passthrough/dedup suite, wizard order + paid-tier fallback + iMessage-returns-bool + realtime-for-either-line, realtime two-lines instructions, session channel-hint + tool-env stamping, and call-record backfill. Full suite: 235 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d69ac3b commit f44663f

26 files changed

Lines changed: 2360 additions & 175 deletions

.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codex-plugin",
3-
"version": "0.1.0+codex.20260618150542",
3+
"version": "0.1.1+codex.20260618150542",
44
"description": "Inkbox bridge for Codex over email, SMS, iMessage, and voice.",
55
"author": {
66
"name": "Inkbox AI",

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ INKBOX_SIGNING_KEY=whsec_xxxxxxxxxxxx
2121
# INKBOX_REALTIME_VOICE=cedar
2222
# INKBOX_REALTIME_FALLBACK_TO_INKBOX_STT_TTS=true
2323

24+
# --- External webhook events (optional) ---
25+
# INKBOX_EXTERNAL_EVENTS_ENABLED=true # wake the agent on unrecognised webhooks
26+
# INKBOX_WEBHOOK_SECRET_GITHUB=gh_webhook_secret # per-provider verification secret
27+
2428
# --- Codex ---
2529
CODEX_PROJECT_DIR=/path/to/the/repo/codex/should/work/in
2630
# CODEX_MODEL=gpt-5.4

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,24 @@ Calls have two modes, chosen per call:
173173
When the call ends, queued actions run in your session (and any plain "reflect on the call" follow-up if none were queued) — so "after we hang up, open a PR and text me" actually happens. Enable it in `inkbox-codex setup` (it validates your OpenAI key live) or via the `INKBOX_REALTIME_*` env vars below.
174174
- **Inkbox STT/TTS** (default / fallback): Inkbox auto-accepts the call and opens a WebSocket to the bridge; finalized transcripts become turns in your same session and Codex's replies are spoken back. The bridge falls back to this automatically if Realtime is off or OpenAI can't be reached (unless `INKBOX_REALTIME_FALLBACK_TO_INKBOX_STT_TTS=false`).
175175

176+
### Two calling lines
177+
178+
Calls — inbound and outbound — can run over either of two lines, and the agent picks the one that matches the channel it's talking on:
179+
180+
- **The dedicated phone number.** The agent's own number (the same line SMS uses). Outbound calls present this number; inbound calls to it ring the agent.
181+
- **The shared Inkbox iMessage line.** The agent can also place and receive voice calls with a person it's connected to over iMessage, over the same shared line that person already messages. The underlying number is never surfaced — Inkbox resolves it from the iMessage connection — and it only works for people already connected over iMessage (an unknown caller is rejected; an outbound call with no connection is refused).
182+
183+
Inbound answering is configured once per identity (`auto_accept` → open the call bridge WebSocket), so a single setting governs both lines. Outbound, the agent sets `origination` on `inkbox_place_call` (`dedicated_number` / `shared_imessage_number`), or omits it: the bridge then uses the only available line, or — when both exist — the line matching the current conversation's channel. Once someone is connected over iMessage this works even for an agent that has no dedicated phone number.
184+
185+
## External events
186+
187+
Besides Inkbox's own events, the webhook endpoint can inject events from outside systems (e.g. a CI failure) to wake the agent on its own `external:<source>` thread. Routing is by *verified source*, never by the body's claimed event type:
188+
189+
- **Registered providers** (e.g. GitHub via `X-Hub-Signature-256`) are verified with their own secret from `INKBOX_WEBHOOK_SECRET_<NAME>`; registering the provider + setting its secret is the opt-in, and forged signatures are rejected outright.
190+
- **Everything else** (unknown sources, or Inkbox-signed payloads with no handler) is delivered only when `INKBOX_EXTERNAL_EVENTS_ENABLED=true`, and unverified events carry a cautious directive that forbids irreversible action on their say-so.
191+
192+
No human reads an external thread, so the agent is told to act via its tools rather than reply. Adding a source is drop-in: a new module in `inkbox_codex/webhook_providers/` with a `@register_provider` class.
193+
176194
## Media
177195

178196
**Inbound.** When someone sends an MMS image, an iMessage attachment, or an email with files, the gateway downloads them to `~/.inkbox-codex/media/` (override with `INKBOX_CODEX_MEDIA_DIR`) and appends the local paths to the message, so Codex can open them with its Read tool — including viewing images. Media-only messages (no text) still wake the agent.
@@ -208,12 +226,16 @@ Calls have two modes, chosen per call:
208226
| `INKBOX_REALTIME_MODEL` | no | `gpt-realtime-2` | Realtime model id. |
209227
| `INKBOX_REALTIME_VOICE` | no | `cedar` | Realtime voice name. |
210228
| `INKBOX_REALTIME_FALLBACK_TO_INKBOX_STT_TTS` | no | `true` | Fall back to Inkbox STT/TTS if OpenAI connect fails. |
229+
| `INKBOX_EXTERNAL_EVENTS_ENABLED` | no | `false` | Wake the agent on unrecognised (external) webhooks — see [External events](#external-events). |
230+
| `INKBOX_WEBHOOK_SECRET_<NAME>` | per provider | - | Verification secret for a registered third-party webhook provider (e.g. `INKBOX_WEBHOOK_SECRET_GITHUB`). |
211231

212232
## Tools exposed to Codex
213233

214234
The agent reaches you (or third parties) through an in-process MCP server:
215235

216-
- `inkbox_whoami` — its own identity: handle, mailbox, phone, iMessage status.
236+
- `inkbox_whoami` — its own identity: handle, mailbox, iMessage status, and its two calling lines (dedicated number vs shared iMessage line).
237+
- `inkbox_place_call` — place an outbound voice call over either line (`origination`: `dedicated_number` / `shared_imessage_number`) — see [Two calling lines](#two-calling-lines).
238+
- `inkbox_list_calls` · `inkbox_get_call_transcript` — browse call history and transcripts.
217239
- `inkbox_send_email` — send email; attach local files with `attachment_paths`.
218240
- `inkbox_send_sms` — send SMS/MMS; attach local files with `media_paths` (or hosted `media_urls`).
219241
- `inkbox_send_imessage` — send into an iMessage conversation; attach a local file with `media_path`.
@@ -241,7 +263,7 @@ python -m pytest
241263

242264
## Architecture notes
243265

244-
- **Tunnel-first inbound**: with a signing key, the gateway opens an Inkbox tunnel, reconciles mail/text/iMessage webhook subscriptions, and patches the phone number's incoming-call channel (`auto_accept` + call WebSocket) — same shape as hermes-agent-plugin.
266+
- **Tunnel-first inbound**: with a signing key, the gateway opens an Inkbox tunnel, reconciles mail/text/iMessage webhook subscriptions, and sets the identity's incoming-call action (`auto_accept` + call WebSocket) — one identity-scoped row covering both the dedicated number and the shared iMessage line.
245267
- **Contact-keyed sessions**: webhook payloads carry resolved contacts; a single resolved contact id becomes the session key, otherwise the raw address/number does. One human, one session, every channel.
246268
- **Escalation over the active channel**: a pending permission/poll captures the contact's next inbound message as its answer, on whichever text channel they're using.
247269
- **Codex app-server**: each contact session owns one `codex app-server` subprocess, one Codex thread, app-server approval request handling over Inkbox, and a local stdio MCP server for the Inkbox tools.

inkbox_codex/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ def call_contexts_dir() -> Path:
3636
return path
3737

3838

39+
def channel_hints_path() -> Path:
40+
"""File where the gateway records each session's last inbound channel.
41+
42+
The gateway writes ``{chat_id: {"mode": ..., "at": ...}}`` on every inbound
43+
turn; the tool process reads it so an outbound call can follow the
44+
conversation's current channel.
45+
46+
Returns:
47+
Path: ``<home>/channel_hints.json`` (parent directory created).
48+
"""
49+
root = Path(os.getenv("INKBOX_CODEX_HOME") or (Path.home() / ".inkbox-codex"))
50+
root.mkdir(parents=True, exist_ok=True)
51+
return root / "channel_hints.json"
52+
53+
3954
def env_flag(name: str, default: bool = False) -> bool:
4055
raw = os.getenv(name)
4156
if raw is None:
@@ -60,6 +75,9 @@ class BridgeConfig:
6075
allowed_users: List[str] = field(default_factory=list)
6176
allow_all_users: bool = False
6277
require_signature: bool = True
78+
# Wake the agent on unrecognised (external) webhooks. Off by default;
79+
# registered third-party providers bypass it once their secret is set.
80+
external_events_enabled: bool = False
6381
host: str = DEFAULT_HOST
6482
port: int = DEFAULT_PORT
6583
# Codex side
@@ -117,6 +135,7 @@ def read_config(extra: Dict[str, Any] | None = None) -> BridgeConfig:
117135
allowed_users=_csv_env("INKBOX_ALLOWED_USERS"),
118136
allow_all_users=env_flag("INKBOX_ALLOW_ALL_USERS", False),
119137
require_signature=env_flag("INKBOX_REQUIRE_SIGNATURE", True),
138+
external_events_enabled=env_flag("INKBOX_EXTERNAL_EVENTS_ENABLED", False),
120139
host=str(os.getenv("INKBOX_BRIDGE_HOST") or DEFAULT_HOST).strip(),
121140
port=int(os.getenv("INKBOX_BRIDGE_PORT") or DEFAULT_PORT),
122141
project_dir=str(

inkbox_codex/daemon.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
33
`inkbox-codex run` stays in the foreground (what systemd/Docker/debugging
44
want). `start`/`stop`/`status`/`restart` manage a detached background
5-
process with a PID file and a log file under ``~/.inkbox-codex/`` — the
6-
same shape as `hermes gateway start`/`stop`.
5+
process with a PID file and a log file under ``~/.inkbox-codex/``.
76
"""
87

98
from __future__ import annotations

inkbox_codex/doctor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Readiness checks for the bridge, in the spirit of `hermes inkbox doctor`."""
1+
"""Readiness checks for the bridge (`inkbox-codex doctor`)."""
22

33
from __future__ import annotations
44

@@ -33,7 +33,7 @@ def run_doctor() -> List[Tuple[str, bool, str]]:
3333
import inkbox # noqa: F401
3434
checks.append(("inkbox SDK", True, "installed"))
3535
except ImportError:
36-
checks.append(("inkbox SDK", False, "pip install 'inkbox>=0.4.10'"))
36+
checks.append(("inkbox SDK", False, "pip install 'inkbox>=0.4.15,<1.0.0'"))
3737

3838
try:
3939
import aiohttp # noqa: F401

0 commit comments

Comments
 (0)