Skip to content

Shared iMessage-line calling, identity-scoped call config, external webhook injection (SDK 0.4.15 parity)#24

Merged
dimavrem22 merged 5 commits into
mainfrom
imessage-shared-calling-parity
Jul 3, 2026
Merged

Shared iMessage-line calling, identity-scoped call config, external webhook injection (SDK 0.4.15 parity)#24
dimavrem22 merged 5 commits into
mainfrom
imessage-shared-calling-parity

Conversation

@dimavrem22

@dimavrem22 dimavrem22 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Brings this plugin to parity with hermes-agent-plugin main (its #35 shared-line calling + #31 external events), completing the 0.4.15 adoption that #23 only pin-bumped.

What's in here

  • SDK pin: inkbox>=0.4.15,<1.0.0 (restores the breaking-major ceiling; wizard/doctor/gateway hint strings aligned).
  • Identity-scoped inbound-call config: identity.set_incoming_call_action(...) replaces the removed number-scoped phone_numbers.update call-config write in the gateway, gated on (dedicated number OR iMessage enabled) with a guarded legacy fallback.
  • Shared iMessage-line calling: origination (dedicated_number | shared_imessage_number) on inkbox_place_call with channel-aware resolution — explicit wins → single-line auto → both lines follow the current conversation's channel → unknown defaults to dedicated; legible message on no_shared_connection. Two-lines terminology in whoami (lines block), realtime instructions, prompts, README. The shared line's number is never surfaced.
  • Wizard: iMessage step first (copy mentions voice calls over the shared line), standalone dedicated-number step, "Already provisioned" instead of a silent skip, paid-tier fallback pointing at https://inkbox.ai/pricing, realtime offered when the identity has a number OR iMessage.
  • External webhook injection: classify-before-auth webhook pipeline with provider registry (GitHub HMAC + Inkbox) and default-off passthrough waking the agent on unknown webhook types (INKBOX_EXTERNAL_EVENTS_ENABLED, INKBOX_WEBHOOK_SECRET_GITHUB; documented in README + .env.example).
  • Version 0.1.1 → 0.1.2.

Supersedes draft #12 (main already carries inkbox_place_call; this PR adds the origination surface on top).

Tests

Full suite incl. contract (real inkbox 0.4.15 + claude-agent-sdk): 220 passed, 17 skipped. CI-style unit run: 214 passed. New suites: webhook providers (512 lines), origination matrix, wizard flow.

CI parity

Adds live-external-events.yml + tests/live/test_external_event_{github,intelligence}.py — the live proof of the webhook-injection feature (signed escalation webhook → agent places a call), gated on LIVE_EXTERNAL_EVENTS=1 so the broad live-channels pytest skips them. Same tier hermes runs on every ready PR.

…and identity-scoped call config

Ports the two-calling-lines feature set and the external-webhook-injection
subsystem from the hermes-agent-plugin reference into this bridge, and adopts
the identity-scoped inbound-call API surface end to end.

Calling over two lines:
- inkbox_place_call gains an `origination` arg (dedicated_number /
  shared_imessage_number) with channel-aware auto-resolution: an explicit
  choice always wins; a single enabled line is used unambiguously; when BOTH
  the dedicated number and iMessage are enabled, the call follows the CURRENT
  conversation's channel (iMessage turn -> shared line, SMS/voice turn ->
  dedicated number; unknown -> dedicated). The channel source is the contact
  session's live `mode` (last inbound modality), exposed to the in-process MCP
  tools via a contextvar bound in ContactSession._ensure_client — each agent
  client's tool-dispatch tasks inherit their own session, so resolution is
  concurrency-safe across contacts.
- A 409/no_shared_connection failure on a shared-line call returns a legible
  error telling the agent the person must be connected over iMessage first (or
  to fall back to the dedicated number), with the raw error in `detail`. The
  resolved origination is echoed in the tool result. Neither-line identities
  get a clear provision-or-enable error.
- inkbox_whoami now reports 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 hints.
- The realtime voice instructions name the two lines when iMessage is enabled
  (agent_imessage_enabled threaded through RealtimeCallMeta) and forbid ever
  stating a number for the shared line.
- The channel system prompt and README document the two calling paths and the
  per-channel line-matching rule.

Identity-scoped inbound-call config (SDK 0.4.15+):
- _patch_identity_objects now writes identity.set_incoming_call_action
  (auto_accept + call WS + webhook URL) whenever the identity has a dedicated
  number OR iMessage enabled — one row covers both lines — with the legacy
  number-scoped phone_numbers.update kept only as a fallback for SDKs that
  lack the method (which cannot configure a shared-iMessage-only identity).
- The call WS handler backfills the remote party and direction with an
  identity-centered calls.get(call_id) round-trip when the upgrade carries no
  caller metadata — resolving shared-line calls that have no phone_number on
  the identity.

External webhook injection:
- New inkbox_claude/webhook_providers/ package (base registry + Inkbox +
  GitHub providers): every inbound /webhook request is classified by its
  signature header FIRST and verified with that source's scheme, so routing
  keys off who actually signed the request — a forged payload can't
  impersonate an Inkbox event, and an Inkbox-signed forwarded event that isn't
  a known Inkbox shape routes to the external path instead of being swallowed.
- Registered third-party sources (secret via INKBOX_WEBHOOK_SECRET_<NAME>) are
  always delivered; unknown/unverified sources are dropped unless
  INKBOX_EXTERNAL_EVENTS_ENABLED=true opts in. External events wake the agent
  on an external:<source> session as a capture turn with a verified-action or
  unverified-caution directive prepended (its reply is not delivered; it must
  act via tools). Untrusted payload text is bounded and marker-safe.

Setup wizard:
- Channel steps reordered: iMessage FIRST (intro copy now mentions voice calls
  over the same shared line), then a STANDALONE dedicated-number step decoupled
  from identity creation/signup ("Already provisioned: <number>" when one
  exists; on provisioning failure prints the Inkbox paid-tiers/pricing pointer
  plus the raw error and moves on).
- _configure_imessage returns bool(enabled); the realtime step is offered when
  the identity has a number OR iMessage is enabled, with the bool threaded
  through since the local identity object may be stale.

Housekeeping:
- inkbox SDK pin gets a ceiling everywhere it is declared: >=0.4.15,<1.0.0
  (pyproject, wizard INKBOX_REQUIREMENTS + min-version floor, gateway/doctor
  install hints). CI installs resolve via pyproject.
- Version bump 0.1.1 -> 0.1.2 (pyproject + package __version__).

Tests: origination-resolution matrix (single line, explicit-wins,
channel-follow, unknown-channel default, tie-breaks), place-call origination
pass-through + no_shared_connection legibility + no-line error, whoami lines
block, identity-scoped incoming-call-action assertions (identity-level write,
iMessage-only registration, legacy fallback, no-line skip), webhook-provider
registry/verification/classify-before-auth/passthrough matrix, external-turn
directive + bounding + session routing, call-WS identity-centered read
backfill, realtime two-lines instructions, wizard ordering + dedicated-number
step + paid-tier fallback + realtime-for-either-line. Unit suite: 214 passed
(CI-style env), full suite incl. contract: 220 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dimavrem22 dimavrem22 marked this pull request as ready for review July 3, 2026 07:30
dimavrem22 and others added 4 commits July 3, 2026 07:45
… places a call)

New live-external-events workflow boots the bridge exactly like the channels
suite (same secrets, same [bridge] ready gate, same AUT-tunnel concurrency
group), turns on INKBOX_EXTERNAL_EVENTS_ENABLED, mints a per-run
INKBOX_WEBHOOK_SECRET_GITHUB, and drives two live tests against the local
/webhook listener:

- test_external_event_intelligence.py — an Inkbox-signed escalation (the
  forwarded-webhook path, signed with the AUT signing key using the SDK
  verify_webhook scheme) makes the real model phone the driver contact.
- test_external_event_github.py — a GitHub-signed workflow_run failure: a
  forged X-Hub-Signature-256 is 401'd and the agent stays quiet; a valid
  signature wakes the agent, which places the call.

The driver number is parked on auto_reject for the duration — the assertion is
the agent's outbound dial (polled via calls.list), not the call itself. Both
files gate on LIVE_EXTERNAL_EVENTS=1 so the channels workflow's whole-directory
pytest run (which boots the bridge with external events off) skips them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tem prompt

The live escalation legs failed with the webhook accepted (200) but no call
ever placed. The gateway log shows the agent woke, looked up the driver
contact, then went quiet: the action directive was prepended to the injected
user turn, so it read as untrusted webhook text and carried no authority to
place an outbound call — and the agent's clarifying text reply on the
external thread was silently discarded. The second event then queued behind
the first in the same per-source session and never ran.

- Return the directive separately from _build_external_event_turn and bind
  it to the session's SYSTEM prompt (new system_prompt_extra on
  ContactSession / SessionManager.get) so it reads as harness policy.
- Key external sessions per event (external:<source>:<event_key>) so each
  event wakes a clean session and can't be blocked by the previous one.
- Drop send_to_contact text for external: chat ids cleanly — they have no
  mailbox/number behind them, so escalation prompts and error notices must
  not attempt delivery to the synthetic id.
- Log the external turn's discarded reply so a run where the agent talked
  instead of acting is diagnosable from the gateway log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live external-events runs failed because the model refused to act: the
old directive said 'you were woken by an external webhook' but never
established WHY acting on it is authorized, so an urgent 'call now'
payload read as a classic injection pattern and was (reasonably)
declined.

The verified directive now states the full trust chain as harness
policy: the signature was verified against the operator-registered
secret for the named sender (provider plumbed through _handle_webhook →
_on_external_event → _build_external_event_turn), that registration is
the operator's standing pre-authorization to act autonomously (there is
no human on the thread to ask; permission requests are discarded), the
payload's factual content is the verified sender's report, and embedded
credentials/links still get ordinary caution.

Security properties unchanged: the directive still binds to the session
system prompt (never rides in payload text), the unverified directive
and the forged-signature 401 path are untouched, and sender names are
substituted literally so payload braces can't break the template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dimavrem22 dimavrem22 merged commit 43f9ed6 into main Jul 3, 2026
11 of 12 checks passed
@dimavrem22 dimavrem22 deleted the imessage-shared-calling-parity branch July 3, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant