SIP edge modularization and pluggable voice agent runtimes¶
Status: Draft | Last reviewed: 2026-07-22
This doc analyzes carving the SIP/media services into a standalone, centrally-maintained "TrueServe SIP Edge" module — deployable inside the main stack (compose/Helm, unchanged) and independently via CDK — and making the AI runtime behind it pluggable. The flagship scenario: an Amazon Connect external voice transfer lands on the SIP edge, which fronts either the real ai-orchestrator or a voice agent running on Amazon Bedrock AgentCore with Strands bidirectional streaming. It also designs an agentcore-sdk export format so TrueServe voice agents can be packaged for AgentCore. Read it before starting any of the phased work in the roadmap.
Decisions already made: CDK is the first IaC target (CloudFormation/Terraform later); the Connect entry path is SIP external transfer via an Amazon Chime SDK Voice Connector. Non-goals: replacing the existing Helm/compose deployment paths, and non-SIP Connect integrations (see Appendix A).
Current state: Connect SIP ingress already works¶
No new ingress work is needed. The Amazon Connect path is live end to end today:
flowchart LR
C[Amazon Connect<br/>external voice transfer] --> VC[Chime SDK<br/>Voice Connector]
VC -->|"SIP INVITE<br/>X-Amzn-SourceArn"| K[Kamailio]
K -->|NG control| R[rtpengine]
K -->|SIP :5061| A[Asterisk]
A -->|"Stasis(ai-bridge)"| ARI[ari-controller-node]
ARI -->|"POST /session + externalMedia"| MP[media-proxy]
MP -->|"gRPC VoiceAgent/Stream"| O[ai-orchestrator]
- Kamailio admits the call via a dedicated
X-Amzn-SourceArnmatcher (kamailio.cfg:519), after a source-IP ACL againstsip_provider_endpoints.ip_cidrs. Connect calls get media profileRTP/AVP(plain RTP; answering SAVP breaks audio). - Asterisk's
ccaas-inbound-handlerextracts the full Connect header set (X-Amzn-SourceArn,X-VoiceConnector-ID,X-Amzn-ConnectContactId,X-Amzn-ConnectInitialContactId,X-Amzn-SourceAccount) into Stasis args (extensions.conf). - ccaas-detector.ts maps
X-Amzn-SourceArnto anamazon_connectintegration; the default-denyccaas_pipeline_integrationsallow-list gates admission (see ari-controller). - Hand-back to Connect works: connect-attributes.ts writes session-end parameters via
UpdateContactAttributesbefore the BYE, with the chime-sip-attrs Lambda as a CloudWatch-log fallback.
The SIP set is also already a proven seam from the cluster side: values-profile-no-sip.yaml disables exactly this service set, and FEATURE_SIP=false runs the dev stack without it. The work is therefore packaging and the runtime seam — not new call-path code.
Module boundary: TrueServe SIP Edge¶
The module is five services: kamailio, rtpengine, asterisk (+asterisk-worker), ari-controller-node, media-proxy. Janus and coturn are deliberately excluded — they serve browser WebRTC, which a SIP-only edge for Connect transfers does not need (the Helm no-sip profile lists all eight; this module narrows to the SIP-transfer path).
The same container images serve all three deployment shapes (dev compose, EKS Helm, standalone CDK); only configuration and a seed dataset differ.
Coupling cuts per service¶
| Service | Coupling today | Standalone cut |
|---|---|---|
| kamailio | Postgres via sqlops: sip_provider_endpoints, sip_digest_auth, sip_logs, integrations join |
Minimal 4-table schema; the integrations join can be denormalized into sip_provider_endpoints |
| rtpengine | None (env only) | None needed; keep the dual-interface invariant (below) |
| asterisk | ODBC realtime ps_* tables; func_odbc reads agent_versions for extension routing |
Static pjsip.conf endpoints; skip func_odbc — header-routed CCaaS calls never use extension lookup |
| ari-controller-node | integrations, agents, agent_versions, ccaas_pipeline_integrations; Redis (required); ai-manager credential API |
Minimal schema + seed job; small Redis; credential resolution is already fail-soft to the AWS default provider chain (integration-credentials.ts) — standalone mode relies on IAM roles. ari-controller never holds MASTER_ENCRYPTION_KEY (config.ts:106) |
| media-proxy | No Postgres; orchestrator gRPC target; optional Redis allocator | None beyond the routing change in Per-agent orchestrator routing |
The standalone profile ships a small Postgres (RDS or container, tier-dependent) provisioned by a seed job that extends deploy/edge-templates/init-edge.sql: the minimal table set above, plus the ccaas_pipeline_integrations allow-list rows and sip_provider_endpoints rows (Chime SBC CIDRs) derived from deploy-time parameters.
Warning: rtpengine behind EC2 + Global Accelerator must run dual-interface (
pub/${PUBLIC_IP}!${PUBLIC_IP};priv/${LAN_IP}). Single-interface kills inbound RTP silently. This invariant carries into the CDK app unchanged.
Schema ownership¶
The edge schema subset must not drift from the mothership baseline (deploy/init-scripts/000_full_schema.sql). The doc recommends generating init-edge.sql's table DDL from the baseline (script + CI drift check, mirroring schema-drift.yml) rather than hand-maintaining a fork. Open question 6 tracks this.
The TrueServe Voice Agent Contract¶
The entire voice runtime interface is one bidirectional gRPC RPC, defined in voice.agent.proto:
service VoiceAgent {
rpc Stream (stream ClientMessage) returns (stream ServerMessage);
}
This analysis elevates it to a named, versioned contract — TrueServe Voice Agent Contract v1 — that any runtime (the real ai-orchestrator, or an external service) can implement. Normative wire semantics, all already implemented by every client (media-proxy, genesys/five9/webex connectors, conversation-api):
| Requirement | Detail |
|---|---|
| First client message | SessionConfig — session_id, agent_id, modalities, metadata map (session_variables, ani, dnis, custom SIP headers), proposed audio formats |
| First server message | ServerHello — authoritative input/output audio formats; the server may override what the client proposed |
| Audio | AudioChunk raw bytes (never base64); PCM16 mono 16 kHz operational default; G.711 µ-law/A-law supported |
| Barge-in | Server sends Control TYPE_INTERRUPTION when the user interrupts; the client flushes its playout buffer |
| Session end | Server sends Control TYPE_DISCONNECT; client drains pending audio then tears down. TYPE_BOT_END carries disposition |
| Mid-call migration | Server may send Control TYPE_SESSION_MIGRATING (value = {"window_seconds": N}, default 10 s, client caps at 30 s) — the client suppresses its teardown paths for the bounded window, then TYPE_SESSION_ACTIVE disarms it; window expiry resumes the suppressed teardown so a failed migration never strands a leg. Optional: a client that ignores both types keeps today's behavior |
| DTMF | DtmfEvent bidirectional (caller keypresses in; agent-driven IVR digits out) |
| Greeting | Server-initiated on stream connection; no client trigger message exists |
| Health | gRPC health service registered under the name trueserve.VoiceAgent |
Equally important is what an external implementation must not need — these are ai-orchestrator internals, not contract:
- Postgres agent resolution (
resolve_agent_version*) andconversationspersistence - The ConversationBridge gRPC stream to conversation-api (transcripts, agent-assist)
- Redis
mcp:session:{id}registration (A2UI MCP) - The ari-controller HTTP consult API (warm transfer)
- MinIO recording
The caller supplies only agent_id + metadata; all config resolution is server-side. An external runtime resolves its own agent identity (for the AgentCore bridge, the AgentCore runtime ARN is the agent identity — see below).
Per-agent orchestrator routing¶
Today no per-agent routing exists: every connector points at one static address (PIPECAT_GRPC in media-proxy, ORCH_ADDR in the Go connectors), and media-proxy dials a single shared connection (allocator.go:100-165). To front different agents with different runtimes, close this gap.
Recommended: option A — ari-controller resolves the target per session. ari-controller already owns integration resolution (ccaas-detector) and already calls media-proxy POST /session with per-call metadata. Add an optional orchestrator_target (host:port + TLS flag) to the CCaaS mapping (an integrations.config key or a column on ccaas_pipeline_integrations), pass it in the /session body, and have media-proxy keep a small per-target connection cache instead of one global conn. This mirrors the existing per-instance precedent, AgentConfig.orchestrator_target in agent_control.proto, and gives per-call granularity with the smallest change surface.
Alternatives considered and rejected for v1:
- DNIS→target map inside media-proxy — duplicates routing knowledge media-proxy doesn't otherwise have; ari-controller is the natural owner.
- A dedicated gRPC routing proxy — an extra hop and service for a lookup ari-controller already performs.
Note: an
orchestrator_targetpointing off-cluster cannot ride today's plaintext:50051. Off-cluster targets require TLS on the gRPC leg or terminate inside the module at the bridge (the recommended pattern below keeps gRPC in-VPC and lets the bridge speak SigV4-authenticated WSS outward).
agentcore-voice-connector: the gRPC ↔ AgentCore bridge¶
A new, small Go service that implements the VoiceAgent server side of the contract and, per session, dials the AgentCore Runtime bidirectional WebSocket:
wss://bedrock-agentcore.<region>.amazonaws.com/runtimes/<agentRuntimeArn>/ws
AgentCore Runtime natively supports this shape: containers serve a WebSocket on :8080 /ws (plus GET /ping), clients authenticate with SigV4 or OAuth, and AWS ships samples including a Strands BidiAgent voice agent (see runtime bidirectional streaming and the WebSocket samples).
The bridge ships with the SIP Edge module (present in its compose/Helm/CDK profiles) but stays a separate service, never fused into media-proxy. That keeps the gRPC contract the only interface: choosing the real orchestrator versus AgentCore is purely a per-session routing-target decision.
Bridge responsibilities¶
| Concern | Design |
|---|---|
| Agent identity | Bridge maps SessionConfig.agent_id (or a bridge-side table) → AgentCore runtime ARN; one bridge instance serves many agents |
| Session correlation | Derive the ≥33-char X-Amzn-Bedrock-AgentCore-Runtime-Session-Id deterministically from session_id (e.g. tsf-<session_id>-<pad>), so traces correlate across both systems |
| Wire schema over WS | The JSON/binary event schema defined once and shared with the export format (below): session_start (SessionConfig-equivalent payload incl. session_variables), binary audio frames in/out, control (interruption, disconnect, bot_end), dtmf |
| Framing | AgentCore caps WS frames at 32 KB; at 16 kHz PCM16 (~32 KB/s) chunk audio at ≤500 ms — 20–100 ms frames recommended for latency anyway |
| Audio | ServerHello declares PCM16 16 kHz in / provider-native out (e.g. 24 kHz for Nova Sonic / OpenAI Realtime); resample in the bridge only if the far side can't match |
| Interruption | Strands BidiAgent VAD interruption events map to Control TYPE_INTERRUPTION toward media-proxy; client-side interruptions forward as a control event |
| Greeting | The contract expects server-initiated greeting on connect. Cold-start latency on the first session to a runtime is a real risk — the bridge should support optional comfort audio/ring-continuation until the first agent audio arrives |
| Timeouts | Nova Sonic sessions cap at 8 minutes; AgentCore idle timeout defaults to 15 minutes (reset by message activity, including ping/pong). Bridge sends WS keepalives and, on provider cap, either ends gracefully with TYPE_BOT_END or re-dials with context replay (open question 2) |
| Custom context | X-Amzn-Bedrock-AgentCore-Runtime-Custom-* headers carry ani/dnis/tenant metadata into the agent container at connect time |
Fidelity loss versus the real orchestrator¶
| Capability | Status behind the bridge | Mitigation |
|---|---|---|
| Transcripts / conversation persistence (ConversationBridge) | Lost — AgentCore agent doesn't speak it | Optional bridge-side tap: forward transcript events from the WS schema to conversation-api; v2 |
| Warm transfer / consult | Unsupported (and factory.ts has no Amazon Connect CallControlProvider yet regardless) |
Declare unsupported in v1; Connect-side hand-back via contact attributes covers "return to Connect" |
| Recording (MinIO) | Lost | AgentCore observability + Connect-side recording; bridge-side tap possible later |
| A2UI / multimodal | Lost | Out of scope for phone calls |
| Draft testing, preflight, agent versioning | N/A — agent lifecycle lives in AgentCore | The export flow (below) is the authoring path |
agentcore-sdk export format¶
The pipelines export route already supports ?format=strands — a generated, runnable Python package with prompt extraction, tool/MCP bindings, secret scrubbing to env placeholders plus secret_report.json, and unsupported.json for non-portable features (lib/agent-code-export/; sample output in agent-3032-strands/). The route reserves the format name agentcore-sdk for exactly this next step (route.ts:123-125).
?format=agentcore-sdk builds on the strands core to emit:
- A
BedrockAgentCoreAppentrypoint with an@app.websockethandler speaking the same WS wire schema the bridge expects — defined once (a small schema file beside voice.agent.proto) and consumed by both the bridge and the export generator, so an exported agent is callable through the SIP edge with zero adapter code. - A Strands
BidiAgentwiring the pipeline's S2S stage to the matching bidi provider (Nova Sonic / OpenAI Realtime / Gemini Live), tools carried over from the strands export core. - A
linux/arm64Dockerfile + AgentCore project config foragentcore create/agentcore deploy. secret_report.jsonre-binding guidance targeting Secrets Manager / runtime env.
Scope v1: S2S (speech-to-speech) pipelines only. Cascaded STT→LLM→TTS pipelines would need a pipecat-equivalent runtime inside the container — defer; the exporter writes them to unsupported.json as it already does for other non-portable features.
CDK packaging¶
The CDK app lives at infra/cdk/sip-edge/ (TypeScript); shared artifacts (compose profile, init-edge.sql, config templates) stay in deploy/edge-templates/ so all three deployment shapes consume one source. The direct precedent to port is gcp-voice-agent-poc/iac/ — an existing CDK app that already stands up an EC2 Kamailio+rtpengine SBC (bootstrap script + config templates, config-driven via lib/config.ts), a Chime Voice Connector, and Connect flow wiring.
| Construct | Choice | Why |
|---|---|---|
| SBC (kamailio + rtpengine) | EC2 (ASG of 1+, EIPs) | Kernel-level packet handling, host networking, the dual-interface invariant; proven by the PoC and the EC2 dev flavor |
| asterisk, ari-controller-node, media-proxy, agentcore-voice-connector | ECS Fargate | Stateless, no host-network need at this tier |
| Ingress | NLB with EIP allocations (mixed TCP/UDP) | Mirrors infra/stacks/aws-kms (Terraform EIPs on the kamailio NLB) |
| Telephony | Chime SDK Voice Connector + Connect contact-flow wiring + Route53 | Port from the PoC stack |
| Data | RDS Postgres (small) + ElastiCache Redis; container Postgres option for a dev tier | Only the minimal edge schema |
| Seeding | A deploy-time job parameterized with SourceArn → agent → orchestrator_target rows |
Populates sip_provider_endpoints, integrations, ccaas_pipeline_integrations |
| Optional | AgentCore runtime deployment for exported agents (extends the edge-templates terraform AgentCore skeleton, in CDK) | One-command demo path |
| Attributes hand-back | The chime-sip-attrs Lambda + DynamoDB | Already exists; port its Terraform to CDK |
Images come from the existing CI registry (per-service Dockerfiles; kamailio is an upstream vendor image) — the CDK app pulls, it does not build.
Phased roadmap¶
- Contract + standalone profile. Write the Voice Agent Contract v1 doc section into the handbook; build the standalone compose profile (minimal schema + seed job) and prove a Connect transfer terminates on it against a stock ai-orchestrator.
- Routing + bridge. Implement per-session
orchestrator_target(option A); build agentcore-voice-connector and validate against the AWS echo sample, then the Strands BidiAgent sample. - CDK app.
infra/cdk/sip-edge/end to end: Connect call → SIP edge → AgentCore voice agent. - Export.
?format=agentcore-sdk; round-trip test — export a TrueServe S2S agent,agentcore deploy, call it through the edge. - Hardening. Transcript tap to conversation-api, observability correlation, CFN/Terraform ports, Connect
CallControlProviderfor outbound consult if needed.
Risks and open questions¶
- Chime VC media specifics — confirm production codec set and SRTP posture on the Voice Connector leg (today's profile is plain
RTP/AVPfor Connect); G.711 8 kHz on the carrier leg bounds effective audio quality regardless of 16 kHz internally. - Nova Sonic 8-minute cap — decide rollover (re-dial WS mid-call with context replay) vs accepting the cap for v1; OpenAI Realtime (60 min) and Gemini Live differ.
- Keepalive/framing — 15-min idle reset semantics and 32 KB frames need soak testing under real hold music/silence.
- Fidelity gaps — is contact-attribute hand-back a sufficient "transfer" story for the target use cases, and is the transcript tap a launch requirement or v2?
- Allow-list seeding UX — how operators express
SourceArn → agent → orchestrator_targetin CDK parameters without hand-editing SQL. - Edge schema ownership — generate from the mothership baseline with a drift check, or hand-maintain
init-edge.sql? - rtpengine distribution — EC2 AMI/bootstrap maintenance cost, and confirm the GPL distribution posture for a customer-deployable artifact.
- Off-cluster gRPC security — TLS/mTLS for any
orchestrator_targetoutside the VPC (the bridge pattern avoids this by keeping gRPC in-VPC). - Greeting latency — AgentCore session cold start vs the contract's server-initiated greeting; quantify and decide on comfort audio.
- Session-id correlation — finalize the deterministic mapping to the ≥33-char AgentCore session id for cross-system observability.
Appendix A: non-SIP alternatives¶
| Path | Shape | Verdict |
|---|---|---|
| connect-kvs-connector | Connect media streaming → KVS GetMedia → gRPC |
One-way only (no TTS injection back to the caller); useful for transcription/assist, not conversational agents |
| AgentCore WebRTC (KVS TURN) | Browser/mobile ↔ AgentCore direct | No SIP edge at all — right for web/mobile surfaces, irrelevant to Connect telephony transfers |
| AudioHook-style connector for Connect | Would mirror genesys-connector-go | Connect has no equivalent bidirectional media WebSocket for third parties; the SIP transfer path is the supported bidirectional integration |