Skip to content

Add an example for watching calls from your own call-start webhook - #2

Merged
buildbyjithu merged 1 commit into
mainfrom
jithu/example-elevenlabs-webhook
Sep 12, 2026
Merged

buildbyjithu merged 1 commit into
mainfrom
jithu/example-elevenlabs-webhook

Conversation

@buildbyjithu

@buildbyjithu buildbyjithu commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds examples/elevenlabs-webhook/, the path a customer actually has: they already
receive ElevenLabs' conversation-initiation webhook at a URL they own, and they want that
event to start a watched call. Their process holds the monitor socket with their
ElevenLabs key, and DeepTrust never connects to ElevenLabs.

The integration is three lines, and the README opens with them:

@app.post("/calls")
async def call_started(body: dict):
    await monitor.watch(body["conversation_id"], user=User(id=body["caller_id"]))
    return {"type": "conversation_initiation_client_data", "dynamic_variables": {...}}

Why not just point at main.py serve

serve in the elevenlabs example is the right shape but not usable as written. It
returns no dynamic_variables, so any agent whose prompt interpolates variables renders
with unresolved placeholders. It logs one line per call, which is not enough to tell a
delivered nudge from a dropped one. And the tunnel.sh beside it launches a live.py
that does not exist, on a different port from the one serve binds.

What is in it

examples/elevenlabs-webhook/
├── README.md            four steps, real output from a real run
├── customer_webhook.py  the receiver
├── pyproject.toml       resolves deeptrust-ai from this checkout
└── .env.example

Everything the run does is logged: the raw webhook body, the reply handed back at call
setup, the socket opening, every turn appended, every analysis with its findings, and
every nudge delivered back into the live conversation. The socket and the client are
wrapped only for logging, through the public connect and on_analysis arguments, so the
example exercises the shipped Monitor rather than a variant of it.

The README

Four steps: install and set keys, turn on the two agent switches, run the receiver and
expose it, point ElevenLabs at it.

Step two is the one a correct-looking setup gets wrong. monitoring_enabled and
enable_conversation_initiation_client_data_from_webhook are both off by default, neither
is discoverable from either product's screen, and an agent missing the second one takes
calls whose webhook is never called, not even to be rejected. Both are set in one PATCH.

Two limits are stated rather than left to be discovered:

  • A contextual update shapes the next turn; it does not interrupt. Already true of the
    adapter, repeated here because it is what a reader of this example will expect to see.
  • ElevenLabs acknowledges nothing when an update is delivered, and the update does not
    appear in the conversation record afterwards. The README gives the canary trick for
    anyone who needs proof in their own setup.

Verified

Run end to end against a real inbound phone call: ElevenLabs called the receiver, the
socket opened 232 ms later before the agent's first word, 9 caller turns and 10 agent
turns were captured, four analyses returned risk=high, and the nudge was delivered on
the same socket each time. The log in the README is that run.

Also verified from the new directory: uv sync resolves the local SDK, the receiver boots
clean, and it answers through a public tunnel.

Not touched

The root README.md and the existing examples/elevenlabs/ example are unchanged. The
only edit outside the new directory is one row in the examples/README.md index.

🤖 Generated with Claude Code


Devin Review

A customer who already receives ElevenLabs conversation-initiation webhooks had
no worked path from that event to a watched call. main.py serve in the
elevenlabs example is the shape of it, but it returns no dynamic_variables, so
it breaks any agent whose prompt uses them, and it prints one line per call,
which is not enough to see what the SDK did.

This is the same flow as a self-contained example: take the conversation_id out
of the webhook, hand it to Monitor.watch, and the monitor socket is held by the
customer process with the customer key. Every step logs, so a run can be read
afterwards: the raw webhook body, the reply handed back at call setup, the
socket opening, each turn, each analysis with its findings, and each nudge
delivered back into the live conversation.

The README is four steps and carries real output from a real inbound phone
call. Step two is the part a correct-looking setup gets wrong: monitoring_enabled
and enable_conversation_initiation_client_data_from_webhook are both off by
default and neither is discoverable from either product's screen, so an agent
missing the second one produces calls whose webhook is never called.

Two limits are written down rather than left to be discovered. A contextual
update shapes the next turn instead of interrupting, and ElevenLabs
acknowledges nothing when one is delivered, so the README gives the canary
trick for anyone who needs proof in their own setup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

3 bugs not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

if conversation_id and monitor is not None:
await monitor.watch(conversation_id, user=User(id=str(caller), role="MEMBER"))
log("WATCH", f"SDK now watching {conversation_id}")
task = monitor._watching.get(conversation_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Example depends on private state

Both observability paths read Monitor._watching. A compatible SDK release can change that private mapping and break this example.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

```python
@app.post("/calls")
async def call_started(body: dict):
await monitor.watch(body["conversation_id"], user=User(id=body["caller_id"]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Opening snippet drops caller fallback

The advertised integration requires caller_id, unlike the runnable handler’s fallback chain. Copied code can reject payloads the complete example accepts.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +181 to +182
@app.post("/calls")
async def call_started(request: Request) -> dict[str, Any]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Unauthenticated requests can monitor calls

Any caller can submit a conversation_id to /calls. The service then uses its privileged key to read and modify that conversation.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +225 to +227
@app.get("/health")
def health() -> dict[str, Any]:
return {"ok": True, "watching": list((monitor._watching if monitor else {}).keys())}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Public health check leaks call identifiers

Unauthenticated /health requests return every active conversation ID. Public tunnel users expose identifiers for live calls to arbitrary visitors.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +190 to +195
log("", f" {json.dumps(payload)[:900]}")

conversation_id = str(payload.get("conversation_id") or "").strip()
agent_id = str(payload.get("agent_id") or "").strip()
caller = payload.get("caller_id") or payload.get("called_number") or "unknown"
log("WEBHOOK", f"conversation={conversation_id or '(none)'} agent={agent_id or '(none)'} caller={caller}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Webhook logs expose caller data

Every call logs its payload, caller identifier, and configured dynamic variables. Sensitive customer data becomes retained in application logs by default.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@buildbyjithu
buildbyjithu merged commit 29302c5 into main Sep 12, 2026
7 checks passed
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