Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ iMessage works differently from SMS: the agent does not get its own iMessage num

If a person disconnects the agent, outbound sends to that conversation fail until they reconnect through the router and message the agent again. Conversation rows expose `assignment_status` (`active`/`released`) so the agent can see this, and `inkbox_list_imessage_assignments` lists who is currently connected. Outbound delivery transitions (`imessage.sent`, `imessage.delivered`) arrive as webhooks and are logged by the gateway without waking the agent; `imessage.delivery_failed` wakes the agent to fix and resend, matching the SMS lifecycle handling — where `text.delivery_unconfirmed` (carrier uncertainty, not a failure) is likewise logged without a wake.

Native attachments work in both outbound paths. In a normal channel reply, Hermes `MEDIA:/absolute/path` directives are securely validated, uploaded with the Inkbox SDK, and sent as iMessage media. For explicit `inkbox_send_imessage` calls, use `mediaPaths` for local files; use `mediaUrls` only for already-hosted public HTTP(S) URLs. iMessage supports one attachment of up to 10 MiB per message.
Native attachments work in both outbound paths. In a normal channel reply, Hermes `MEDIA:/absolute/path` directives are securely validated, uploaded with the Inkbox SDK, and sent as iMessage media. Do not also call `inkbox_send_imessage` for that current thread: the final reply is already delivered automatically, and the plugin suppresses a second final bubble if a same-thread explicit send slips through. For explicit sends to a different conversation, use `mediaPaths` for local files; use `mediaUrls` only for already-hosted public HTTP(S) URLs. iMessage supports one attachment of up to 10 MiB per message.

Once someone is connected over iMessage, the agent can also place and receive **voice calls** with them over that same shared line — see [Two calling lines](#two-calling-lines). This works even for an agent that has no dedicated phone number.

Expand Down Expand Up @@ -249,6 +249,21 @@ After the gateway starts:
| `INKBOX_REALTIME_CONSULT_TIMEOUT_S` | no | plugin default | Seconds the Realtime voice agent waits for a Hermes consult before continuing. |
| `INKBOX_REALTIME_FALLBACK_TO_INKBOX_STT_TTS` | no | `true` | Fall back to Inkbox STT/TTS if OpenAI Realtime connect/auth fails before call accept. |

## Companion Plugin Extensions

Standalone Hermes plugins can reuse the Inkbox agent tunnel for authenticated
third-party webhooks and OAuth callbacks without modifying the installed
Inkbox plugin. Import these functions from the loaded `hermes_plugins.inkbox`
module during the companion plugin's `register(ctx)` call:

- `register_webhook_provider(ProviderClass)` registers a `WebhookProvider`
implementation. Verified events wake Hermes as `external:<provider-name>`.
- `register_http_route(method, path, handler)` mounts an aiohttp handler on the
existing Inkbox tunnel when the gateway starts.

Providers may override `event_key(envelope=..., headers=...)` for retry
deduplication and set `skill` to auto-load a companion skill for verified events.

## Channel Overrides

Two optional blocks under the `inkbox:` platform config tailor the agent per
Expand Down
19 changes: 19 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
from .diagnostics import SETUP_HINT
from .setup_wizard import interactive_setup
from .tools import register_tools
from .http_routes import register_http_route
from .reply_guard import (
note_imessage_tool_delivery,
record_inbound_route,
suppress_duplicate_final,
)
from .webhook_providers import WebhookProvider, register_provider as register_webhook_provider
except ImportError: # pragma: no cover - direct local import/test fallback
import importlib
import sys
Expand All @@ -31,6 +38,9 @@
_diagnostics = importlib.import_module(f"{_LOCAL_PACKAGE}.diagnostics")
_setup_wizard = importlib.import_module(f"{_LOCAL_PACKAGE}.setup_wizard")
_tools = importlib.import_module(f"{_LOCAL_PACKAGE}.tools")
_http_routes = importlib.import_module(f"{_LOCAL_PACKAGE}.http_routes")
_reply_guard = importlib.import_module(f"{_LOCAL_PACKAGE}.reply_guard")
_webhook_providers = importlib.import_module(f"{_LOCAL_PACKAGE}.webhook_providers")

InkboxAdapter = _adapter.InkboxAdapter
check_inkbox_requirements = _adapter.check_inkbox_requirements
Expand All @@ -42,6 +52,12 @@
SETUP_HINT = _diagnostics.SETUP_HINT
interactive_setup = _setup_wizard.interactive_setup
register_tools = _tools.register_tools
register_http_route = _http_routes.register_http_route
note_imessage_tool_delivery = _reply_guard.note_imessage_tool_delivery
record_inbound_route = _reply_guard.record_inbound_route
suppress_duplicate_final = _reply_guard.suppress_duplicate_final
WebhookProvider = _webhook_providers.WebhookProvider
register_webhook_provider = _webhook_providers.register_provider

logger = logging.getLogger(__name__)
_unconfigured_warning_emitted = False
Expand Down Expand Up @@ -194,6 +210,9 @@ def register(ctx) -> None:
),
)
register_tools(ctx)
ctx.register_hook("pre_gateway_dispatch", record_inbound_route)
ctx.register_hook("post_tool_call", note_imessage_tool_delivery)
ctx.register_hook("transform_llm_output", suppress_duplicate_final)
ctx.register_cli_command(
name="inkbox",
help="Inkbox plugin commands",
Expand Down
38 changes: 31 additions & 7 deletions adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
from .config import INKBOX_BASE_URL_DEFAULT, inkbox_client_kwargs
from .diagnostics import inkbox_api_error_message, missing_config_message, is_inkbox_auth_error, is_inkbox_identity_error
from .webhook_providers import match_provider
from .http_routes import registered_http_routes
from .realtime import (
DEFAULT_MODEL as REALTIME_DEFAULT_MODEL,
DEFAULT_VOICE as REALTIME_DEFAULT_VOICE,
Expand All @@ -151,6 +152,7 @@
from config import INKBOX_BASE_URL_DEFAULT, inkbox_client_kwargs
from diagnostics import inkbox_api_error_message, missing_config_message, is_inkbox_auth_error, is_inkbox_identity_error
from webhook_providers import match_provider
from http_routes import registered_http_routes
from realtime import (
DEFAULT_MODEL as REALTIME_DEFAULT_MODEL,
DEFAULT_VOICE as REALTIME_DEFAULT_VOICE,
Expand Down Expand Up @@ -233,8 +235,9 @@ def _install_tunnel_log_filter() -> None:
"Only call inkbox_send_sms to text a DIFFERENT conversation or number, never "
"to reply here (that sends your message twice).",
"imessage": "Your reply in this iMessage thread is sent automatically — just "
"write it. Only call inkbox_send_imessage to reach a DIFFERENT conversation or "
"person, never to reply here (that sends your message twice).",
"write it. For an attachment in this thread, include MEDIA:/absolute/path in "
"that one reply. Only call inkbox_send_imessage to reach a DIFFERENT conversation "
"or person, never to reply here (that sends your message twice).",
"email": "Your reply to this email is sent automatically as a threaded reply — "
"just write it. Only call inkbox_send_email to email a DIFFERENT thread or "
"recipient, never to reply here (that sends your message twice).",
Expand Down Expand Up @@ -1673,6 +1676,8 @@ async def connect(self, is_reconnect: bool = False, **kwargs) -> bool:
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_post(self._webhook_path, self._handle_webhook)
self._app.router.add_get(self._ws_path, self._handle_call_ws)
for route in registered_http_routes():
self._app.router.add_route(route.method, route.path, route.handler)
self._runner = web.AppRunner(self._app)
await self._runner.setup()
self._site = web.TCPSite(self._runner, self._host, self._port)
Expand Down Expand Up @@ -2882,6 +2887,13 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":

event_type = envelope.get("event_type")
request_id = request.headers.get("X-Inkbox-Request-Id", "")
if not request_id and provider is not None:
event_key_fn = getattr(provider, "event_key", None)
if callable(event_key_fn):
request_id = event_key_fn(
envelope=envelope,
headers=dict(request.headers),
)
if request_id and self._dedup_begin(request_id):
return web.Response(status=200, text="duplicate")

Expand Down Expand Up @@ -2923,7 +2935,11 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
# That registration is the opt-in, so deliver regardless of the
# external-events flag.
response = await self._on_external_event(
envelope, request_id, verified=True
envelope,
request_id,
verified=True,
provider_name=source,
provider_skill=getattr(provider, "skill", None),
)
elif self._external_events_enabled:
# Everything else the operator opted into with the flag: an
Expand All @@ -2932,7 +2948,11 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
# event family). ``verified`` is True only for the Inkbox-signed
# case; unknown sources get the cautious directive.
response = await self._on_external_event(
envelope, request_id, verified=(source is not None)
envelope,
request_id,
verified=(source is not None),
provider_name=source,
provider_skill=getattr(provider, "skill", None) if provider else None,
)
else:
# Not opted in (flag off) and no handler — drop without waking
Expand Down Expand Up @@ -3225,6 +3245,8 @@ async def _on_external_event(
envelope: Dict[str, Any],
request_id: str = "",
verified: bool = False,
provider_name: Optional[str] = None,
provider_skill: "str | list[str] | None" = None,
) -> "web.Response":
"""Wake the agent on a fresh thread for an externally-injected event.

Expand Down Expand Up @@ -3270,10 +3292,11 @@ def _field(*names: str) -> str:
return ""

# Event name + where it came from (repo for GitHub, else any "source").
event_name = _field("event_type", "event") or "external"
event_name = _field("event_type", "event", "type") or "external"
source_name = (
_field("source")
or str(github.get("repository") or repo.get("full_name") or "").strip()
or str(provider_name or "").strip()
or "external"
)
title = _field("title")
Expand Down Expand Up @@ -3303,7 +3326,8 @@ def _field(*names: str) -> str:
# explicit id (payload id or GitHub run id), fall back to the webhook
# request id, and finally hash the payload so events never collide.
event_key = (
_field("id")
_field("trace_id")
or _field("id")
or str(github.get("run_id") or workflow_run.get("id") or "").strip()
or request_id
)
Expand Down Expand Up @@ -3355,7 +3379,7 @@ def _field(*names: str) -> str:
# Per-source operator overrides (system prompt and/or skills) — this is
# the seam where the "what to do on this event" playbook is attached.
channel_prompt, auto_skill = self._resolve_channel_overrides(
"external", chat_id, None
"external", chat_id, provider_skill
)
# Prepend a directive: no human reads this thread and the agent's reply
# is not delivered, so it must reason and act via tools. A VERIFIED
Expand Down
41 changes: 41 additions & 0 deletions http_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Extension registry for routes served on the Inkbox agent tunnel."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Callable, List


@dataclass(frozen=True)
class HttpRoute:
method: str
path: str
handler: Callable[[Any], Any]


_ROUTES: List[HttpRoute] = []


def register_http_route(method: str, path: str, handler: Callable[[Any], Any]) -> None:
"""Register a callback route on the Inkbox adapter's aiohttp application."""
normalized_method = str(method or "").strip().upper()
normalized_path = str(path or "").strip()
if not normalized_method:
raise ValueError("HTTP route method is required")
if not normalized_path.startswith("/"):
raise ValueError("HTTP route path must start with '/'")
if not callable(handler):
raise TypeError("HTTP route handler must be callable")
for route in _ROUTES:
if route.method == normalized_method and route.path == normalized_path:
if route.handler is handler:
return
raise ValueError(
f"HTTP route collision for {normalized_method} {normalized_path}"
)
_ROUTES.append(HttpRoute(normalized_method, normalized_path, handler))


def registered_http_routes() -> tuple[HttpRoute, ...]:
"""Return an immutable snapshot of companion-plugin routes."""
return tuple(_ROUTES)
Loading
Loading