Shared iMessage-line calling, identity-scoped call config, external webhook injection (SDK 0.4.15 parity)#24
Merged
Conversation
…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>
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
inkbox>=0.4.15,<1.0.0(restores the breaking-major ceiling; wizard/doctor/gateway hint strings aligned).identity.set_incoming_call_action(...)replaces the removed number-scopedphone_numbers.updatecall-config write in the gateway, gated on (dedicated number OR iMessage enabled) with a guarded legacy fallback.origination(dedicated_number|shared_imessage_number) oninkbox_place_callwith channel-aware resolution — explicit wins → single-line auto → both lines follow the current conversation's channel → unknown defaults to dedicated; legible message onno_shared_connection. Two-lines terminology in whoami (linesblock), realtime instructions, prompts, README. The shared line's number is never surfaced.INKBOX_EXTERNAL_EVENTS_ENABLED,INKBOX_WEBHOOK_SECRET_GITHUB; documented in README + .env.example).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 onLIVE_EXTERNAL_EVENTS=1so the broad live-channels pytest skips them. Same tier hermes runs on every ready PR.