Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/live-voice.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ jobs:
echo "INKBOX_VOICE_STACK=inkbox_voice_ai" >> "$GITHUB_ENV"
echo "INKBOX_REALTIME_ENABLED=false" >> "$GITHUB_ENV"
echo "HOSTED_POST_CALL_MARKER=$marker" >> "$GITHUB_ENV"
echo "VOICE_DRIVER_LINE=After we hang up, send me one SMS. Create the post-call action now with this exact SMS body: $marker. Read those five words back to me after the action is saved. Do not send it during the call." >> "$GITHUB_ENV"
echo "VOICE_DRIVER_LINE=After we hang up, send me one SMS. Create one post-call action now with the title Send SMS and put this exact five-word SMS body in the action details: $marker. Wait for the action tool to succeed, then read all five words back to me. Do not paraphrase, omit a word, or send the SMS during the call." >> "$GITHUB_ENV"
# Keep the media peer alive while the test waits for Voice AI to
# persist the open post-call action, then let the test hang up.
echo "VOICE_DRIVER_LISTEN=180" >> "$GITHUB_ENV"
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ The one thing to have ready: be **logged into Claude** — a Claude Pro/Max subs

Flags: `--start` (launch the background gateway when done), `--no-setup` (install only). From a local checkout, run `./install.sh`. Re-running is safe.

### Bootstrap an existing identity without prompts

For unattended agent setup, install without opening the wizard and pass the API key through the environment (or standard input), never a command-line argument:

```bash
curl -fsSL https://raw.githubusercontent.com/inkbox-ai/claude-code-plugin/main/install.sh | bash -s -- --no-setup
export INKBOX_API_KEY="ApiKey_..."
inkbox-claude bootstrap --identity my-agent --project-dir "$PWD" \
--voice-ai --rotate-signing-key --start-gateway
unset INKBOX_API_KEY
```

`bootstrap` validates that the key can access exactly the requested identity, scopes down an admin key before saving it, preserves existing Voice AI settings, and starts or restarts the detached gateway. Signing-key replacement is opt-in because it transfers verified webhook delivery away from any gateway using the previous key. The command prints a secret-redacted JSON result and is safe to resume.

Check it any time:

```bash
Expand Down
224 changes: 224 additions & 0 deletions inkbox_claude/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""Non-interactive bootstrap for an existing Inkbox Claude Code identity."""

from __future__ import annotations

import time
from typing import Any

from . import daemon
from .config import INKBOX_BASE_URL_DEFAULT, VoiceStack, inkbox_client_kwargs
from .setup_wizard import _enum_value, _env, _load_inkbox_symbols, _save


def _handle(value: str) -> str:
return value.strip().removeprefix("@").strip()


def _redact(exc: Exception, secrets: list[str]) -> str:
message = str(exc)
for secret in secrets:
if secret:
message = message.replace(secret, "[redacted]")
return message


def _identity_for_key(client: Any, expected: str) -> Any:
handles = {_handle(str(getattr(item, "agent_handle", ""))) for item in client.list_identities()}
if expected not in handles:
raise ValueError("The API key is not scoped to the requested identity.")
return client.get_identity(expected)


def _resolve_credentials(
api_key: str,
expected: str,
base_url: str,
symbols: dict[str, Any],
actions: list[str],
) -> tuple[str, Any]:
Inkbox = symbols["Inkbox"]
client = Inkbox(**inkbox_client_kwargs(api_key, base_url))
info = client.whoami()
if _enum_value(getattr(info, "auth_type", "")) != "api_key":
raise ValueError("Bootstrap requires an Inkbox API key.")
subtype = _enum_value(getattr(info, "auth_subtype", ""))
claimed = _enum_value(symbols["AGENT_CLAIMED"])
if subtype == claimed:
return api_key, _identity_for_key(client, expected)
if subtype == _enum_value(symbols["AGENT_UNCLAIMED"]):
raise ValueError("The API key is not attached to a claimed identity yet.")
if subtype != _enum_value(symbols["ADMIN_SCOPED"]):
raise ValueError("Use an agent-scoped or admin-scoped Inkbox API key.")

saved_key = _env("INKBOX_API_KEY").strip()
if saved_key and _handle(_env("INKBOX_IDENTITY")) == expected:
try:
saved_client = Inkbox(**inkbox_client_kwargs(saved_key, base_url))
saved_info = saved_client.whoami()
if _enum_value(getattr(saved_info, "auth_subtype", "")) == claimed:
actions.append("reused_saved_agent_key")
return saved_key, _identity_for_key(saved_client, expected)
except Exception:
pass

identity = client.get_identity(expected)
created = client.api_keys.create(
label=f"Claude Code gateway - {expected}",
description="Agent-scoped key created by the Claude Code Inkbox bootstrap.",
scoped_identity_id=identity.id,
)
scoped_key = str(getattr(created, "api_key", "") or "")
if not scoped_key:
raise RuntimeError("Inkbox did not return the new agent-scoped API key.")
actions.append("minted_agent_scoped_key")
scoped_client = Inkbox(**inkbox_client_kwargs(scoped_key, base_url))
return scoped_key, scoped_client.get_identity(expected)


def _default_voice_instructions(identity: Any, client: Any) -> str:
handle = _handle(str(getattr(identity, "agent_handle", "")))
mailbox = getattr(identity, "mailbox", None)
values = []
email = getattr(identity, "email_address", None) or getattr(mailbox, "email_address", None)
phone = getattr(getattr(identity, "phone_number", None), "number", None)
tunnel = getattr(getattr(identity, "tunnel", None), "public_host", None)
dedicated = getattr(getattr(identity, "imessage_number", None), "number", None)
if email:
values.append(f"Email: {email}.")
if phone:
values.append(f"VoIP phone: {phone}.")
if tunnel:
values.append(f"Public address: https://{tunnel}.")
if dedicated:
values.append(f"Dedicated iMessage line: {dedicated}.")
elif bool(getattr(identity, "imessage_enabled", False)):
try:
triage = client.imessages.get_triage_number()
command = str(getattr(triage, "connect_command", "") or f"connect @{handle}")
number = str(getattr(triage, "number", "") or "")
if number:
values.append(f"Shared iMessage: text '{command}' to {number}.")
except Exception:
values.append("Shared iMessage is enabled; use the current Inkbox connection instructions.")
channels = " ".join(values) or "No direct communication channel is currently configured."
return (
f"You are the hosted voice interface for Inkbox agent @{handle}. "
"Help callers understand how to connect with this agent and repeat only these configured channels. "
f"{channels}"
)


def _configure_voice(identity: Any, client: Any, instructions: str | None) -> None:
hosted = identity.get_hosted_agent_config()
desired = instructions if instructions is not None else (
getattr(hosted, "instructions", None) or _default_voice_instructions(identity, client)
)
if len(desired) > 8000:
raise ValueError("Voice AI instructions must be 8,000 characters or fewer.")
if getattr(hosted, "instructions", None) != desired:
identity.set_hosted_agent_config(
voice=getattr(hosted, "voice", None),
model=getattr(hosted, "model", None),
instructions=desired,
)
incoming = identity.get_incoming_call_action()
if (
_enum_value(getattr(incoming, "incoming_call_action", "")) != "hosted_agent"
or getattr(incoming, "client_websocket_url", None) is not None
or getattr(incoming, "incoming_call_webhook_url", None) is not None
):
identity.set_incoming_call_action(
incoming_call_action="hosted_agent",
client_websocket_url=None,
incoming_call_webhook_url=None,
)
_save("INKBOX_VOICE_STACK", VoiceStack.INKBOX_VOICE_AI.value)
_save("INKBOX_VOICE_AI_AUTHORITY_MODE", _enum_value(getattr(hosted, "authority_mode", "contact_scoped")))
_save("INKBOX_REALTIME_ENABLED", "false")


def _configure_signing(identity: Any, client: Any, rotate: bool, same_identity: bool, actions: list[str]) -> str | None:
local_key = _env("INKBOX_SIGNING_KEY").strip()
status_reader = getattr(identity, "get_signing_key_status", None) or getattr(client, "get_signing_key_status")
status = status_reader()
configured = bool(getattr(status, "configured", False))
if local_key and same_identity and configured and not rotate:
_save("INKBOX_REQUIRE_SIGNATURE", "true")
actions.append("reused_local_signing_key")
return None
if configured and not rotate:
return (
"A signing key already exists for this identity but is unavailable in this Claude Code profile. "
"Set INKBOX_SIGNING_KEY or rerun with --rotate-signing-key."
)
creator = getattr(identity, "create_signing_key", None) or getattr(client, "create_signing_key")
created = creator()
key = str(getattr(created, "signing_key", "") or "")
if not key:
raise RuntimeError("Inkbox did not return the new signing key.")
_save("INKBOX_SIGNING_KEY", key)
_save("INKBOX_REQUIRE_SIGNATURE", "true")
actions.append("rotated_signing_key" if configured else "created_signing_key")
return None


def _start_gateway(actions: list[str]) -> bool:
was_running = daemon.running_pid() is not None
code = daemon.restart() if was_running else daemon.start()
actions.append("restarted_gateway" if was_running else "started_gateway_process")
if code != 0:
return False
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
if daemon.running_pid():
return True
time.sleep(0.25)
return False


def bootstrap(
*,
identity_handle: str,
api_key: str,
base_url: str = INKBOX_BASE_URL_DEFAULT,
project_dir: str = "",
voice_ai: bool = False,
voice_ai_instructions: str | None = None,
rotate_signing_key: bool = False,
start_gateway: bool = False,
) -> dict[str, Any]:
handle = _handle(identity_handle)
if not handle:
return {"status": "error", "error": "identity is required"}
if not api_key.strip():
return {"status": "error", "error": "API key is required"}
actions: list[str] = []
secrets = [api_key.strip()]
try:
previous = _handle(_env("INKBOX_IDENTITY"))
symbols = _load_inkbox_symbols()
scoped_key, identity = _resolve_credentials(api_key.strip(), handle, base_url, symbols, actions)
secrets.append(scoped_key)
client = symbols["Inkbox"](**inkbox_client_kwargs(scoped_key, base_url))
_save("INKBOX_API_KEY", scoped_key)
_save("INKBOX_IDENTITY", handle)
if base_url:
_save("INKBOX_BASE_URL", base_url)
if project_dir:
_save("CLAUDE_PROJECT_DIR", project_dir)
_save("INKBOX_ALLOW_ALL_USERS", "true")
actions.append("saved_claude_configuration")
if voice_ai:
_configure_voice(identity, client, voice_ai_instructions)
actions.append("configured_voice_ai")
blocker = _configure_signing(identity, client, rotate_signing_key, not previous or previous == handle, actions)
if blocker:
return {"status": "requires_human", "identity": handle, "actions": actions, "human_actions": [blocker]}
running = False
if start_gateway:
running = _start_gateway(actions)
if not running:
return {"status": "error", "identity": handle, "actions": actions, "error": "Claude Code gateway did not become ready. Check ~/.inkbox-claude/gateway.log."}
return {"status": "configured", "identity": handle, "actions": actions, "gateway_running": running}
except Exception as exc:
return {"status": "error", "identity": handle, "actions": actions, "error": _redact(exc, secrets)}
30 changes: 30 additions & 0 deletions inkbox_claude/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@
from __future__ import annotations

import argparse
import json
import os
import sys

try:
from . import daemon
from .bootstrap import bootstrap
from .config import inkbox_client_kwargs, read_config
from .doctor import print_doctor
from .setup_wizard import interactive_setup
except ImportError: # pragma: no cover - direct local import/test fallback
import daemon
from bootstrap import bootstrap
from config import inkbox_client_kwargs, read_config
from doctor import print_doctor
from setup_wizard import interactive_setup
Expand Down Expand Up @@ -50,6 +54,15 @@ def main(argv: list[str] | None = None) -> int:
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("setup", help="run the interactive setup wizard")
bootstrap_parser = sub.add_parser("bootstrap", help="configure an existing identity without prompts")
bootstrap_parser.add_argument("--identity", required=True)
bootstrap_parser.add_argument("--api-key-stdin", action="store_true")
bootstrap_parser.add_argument("--base-url", default="")
bootstrap_parser.add_argument("--project-dir", default="")
bootstrap_parser.add_argument("--voice-ai", action="store_true")
bootstrap_parser.add_argument("--voice-ai-instructions-file")
bootstrap_parser.add_argument("--rotate-signing-key", action="store_true")
bootstrap_parser.add_argument("--start-gateway", action="store_true")
sub.add_parser("run", help="run the bridge gateway in the foreground")
sub.add_parser("start", help="start the bridge gateway in the background")
sub.add_parser("stop", help="stop the background bridge gateway")
Expand All @@ -67,6 +80,23 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "setup":
interactive_setup()
return 0
if args.command == "bootstrap":
api_key = sys.stdin.read().strip() if args.api_key_stdin else os.getenv("INKBOX_API_KEY", "").strip()
instructions = None
if args.voice_ai_instructions_file:
instructions = open(args.voice_ai_instructions_file, encoding="utf-8").read()
result = bootstrap(
identity_handle=args.identity,
api_key=api_key,
base_url=args.base_url,
project_dir=args.project_dir,
voice_ai=args.voice_ai,
voice_ai_instructions=instructions,
rotate_signing_key=args.rotate_signing_key,
start_gateway=args.start_gateway,
)
print(json.dumps(result, indent=2, sort_keys=True))
return 0 if result.get("status") == "configured" else 2
if args.command == "run":
return daemon.run_foreground()
if args.command == "start":
Expand Down
4 changes: 4 additions & 0 deletions inkbox_claude/setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ def _save(name: str, value: str) -> None:
if not replaced:
lines.append(f"{name}={value}")
path.write_text("\n".join(lines) + "\n")
# This file contains the agent API key and webhook signing key. The
# default umask commonly leaves new files world-readable (0644), and
# write_text preserves an unsafe mode on an existing file.
path.chmod(0o600)
# Mirror into the live env so a doctor run right after sees the change.
os.environ[name] = value

Expand Down
Loading