You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: README.md
+24-2Lines changed: 24 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -173,6 +173,24 @@ Calls have two modes, chosen per call:
173
173
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.
174
174
-**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`).
175
175
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
+
176
194
## Media
177
195
178
196
**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:
208
226
|`INKBOX_REALTIME_MODEL`| no |`gpt-realtime-2`| Realtime model id. |
209
227
|`INKBOX_REALTIME_VOICE`| no |`cedar`| Realtime voice name. |
210
228
|`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`). |
211
231
212
232
## Tools exposed to Codex
213
233
214
234
The agent reaches you (or third parties) through an in-process MCP server:
215
235
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.
217
239
-`inkbox_send_email` — send email; attach local files with `attachment_paths`.
218
240
-`inkbox_send_sms` — send SMS/MMS; attach local files with `media_paths` (or hosted `media_urls`).
219
241
-`inkbox_send_imessage` — send into an iMessage conversation; attach a local file with `media_path`.
@@ -241,7 +263,7 @@ python -m pytest
241
263
242
264
## Architecture notes
243
265
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.
245
267
-**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.
246
268
-**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.
247
269
-**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.
0 commit comments