feat(platform): openai-compat agent webhook for meetily integration#1587
Conversation
Adds a per-agent OpenAI ChatCompletion endpoint at
`POST /api/agents/wh/<TOKEN>/chat/completions` that reuses the existing
agent webhook path. Meetily (or any OpenAI-compat client) configures the
webhook URL as its Base URL and gets a real agent conversation back —
with the agent's system prompt, tools, conversation thread, governance,
audit log, and usage ledger all applied.
Why a new sub-path rather than extending `/api/v1/chat/completions`:
that endpoint is a strict provider pass-through and must stay 100%
OpenAI-compatible. Agent identity lives in the URL token, never in the
`model` field, so there is no split-brain semantics between the two
endpoints.
Highlights:
- `agentWebhookHandler` branches on path suffix; token parsed at fixed
position and validated against `^[0-9a-f]{64}$`.
- Client `system` message(s) concatenated after the agent's own
`systemInstructions` with `\n\n---\n\n`; client text capped at 50k
chars (413 on overflow).
- Errors use the OpenAI `{error:{message,type,code,param}}` envelope
via the shared `openAIErrorResponse` helper.
- `user` (legacy) and `safety_identifier` (current OpenAI spec) are
both honored as a stable caller identifier; sha256-hashed into a new
`agentWebhookUserThreads(webhookId, userHash) -> threadId` table so
repeated POSTs with the same identifier land in the same thread.
- Lookup-or-create runs inside a single internal mutation
(`getOrCreateUserThread`) — atomic under Convex OCC.
- Cascade-deletes mapping rows on webhook delete and thread delete.
- Webhook records a `createdByUserId` at creation so that
webhook-generated threads surface in the creator's chat history.
- New `agentType: 'openai_webhook'` (added to the AGENT_CONTEXT_CONFIGS
map) distinguishes the wire format in audit logs without changing
existing per-agent analytics.
- Usage dialog in the agent Webhook tab now shows OpenAI-compat curl
and Meetily config snippets; i18n strings added for en/de/fr.
- Meeting-transcription tutorial (en/de/fr) rewritten to point at the
per-agent webhook URL, add a webhook-creation step, drop the stale
"Advanced preset" language, and correct the client-timeout / conv-
thread wording.
Security fix (also affects the legacy `{message, threadId?}` path):
`startWebhookChat` now validates that a client-supplied `threadId`
belongs to the webhook's organization before using it. Closes a
pre-existing cross-org bug where a token holder in org A could post
into a known threadId from org B.
Tests: 32 new unit tests cover path parsing, token regex, content
extraction (string/array/null), system-prompt merge, thread-deletion
cascade. Typecheck, lint, and Mintlify broken-links all clean.
Terminology linter flags unlocalized "Settings" in DE and FR docs.
These references point at Meetily's (external) UI label, not Tale's,
so they should be treated as literal strings rather than translated.
Wrap in backticks (the existing exemption style for external UI refs)
instead of bold. Also refactor one compound word in DE to use the
localised term ("Summary-Einstellungen").
📝 WalkthroughWalkthroughThis PR restructures agent webhook integration from a Tale API key-based model to a webhook URL-based architecture supporting OpenAI-compatible endpoints. Changes include: updating documentation to reflect Meetily configuration via webhook URLs instead of API keys; expanding webhook routing to handle both legacy ( Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/de/tutorials/admin/meeting-transcription.md`:
- Line 83: The docs currently state a disabled or invalid webhook returns 401;
update the sentence in meeting-transcription.md to distinguish cases: say a
disabled webhook returns 403 (Forbidden) while an invalid token returns 401
(Unauthorized), matching the behavior implemented in http_actions.ts (the OpenAI
webhook handling path). Adjust the troubleshooting advice to check for a 403
when the webhook is disabled and for 401 when the token is invalid.
In `@docs/fr/tutorials/admin/meeting-transcription.md`:
- Line 83: Update the documentation to reflect the correct HTTP status for a
disabled webhook: change the troubleshooting entry that currently lists **401
Unauthorized** for a disabled webhook to **403 Forbidden**; reference the
behavior implemented in services/platform/convex/agents/webhooks/http_actions.ts
(the OpenAI webhook handling path) which returns 401 only for invalid tokens and
returns 403 when a webhook is disabled so readers are guided to check webhook
activation rather than token validity.
In `@docs/tutorials/admin/meeting-transcription.md`:
- Line 83: The docs entry incorrectly groups disabled webhooks under "401
Unauthorized"; update the sentence in
docs/tutorials/admin/meeting-transcription.md to document disabled webhooks as
"403 Forbidden" instead of 401 and adjust the troubleshooting hint accordingly
(mention Settings > agent > Webhook tab: toggle active, or delete and
regenerate) so it matches the actual mapping implemented in
services/platform/convex/agents/webhooks/http_actions.ts (where 401 is only
returned for invalid tokens on the OpenAI path and disabled webhooks are
returned as 403). Ensure the wording clearly differentiates invalid token (401)
vs. disabled webhook (403).
In `@services/platform/convex/agents/webhooks/http_actions.ts`:
- Around line 461-486: fetchUsageForThread currently queries getMessageMetadata
with messageId: '' which returns the latest metadata for a thread and can return
the wrong turn when threads are reused; change the flow so chatViaWebhook
returns the generated assistant messageId (or metadata id) and update
fetchUsageForThread to accept that messageId and call
api.message_metadata.queries.getMessageMetadata with the concrete messageId
(instead of '') to fetch prompt/output/total and model for that specific turn;
update callers of fetchUsageForThread/chatViaWebhook to pass and propagate the
returned messageId accordingly (references: fetchUsageForThread, chatViaWebhook,
api.message_metadata.queries.getMessageMetadata, messageId).
In `@services/platform/convex/agents/webhooks/internal_mutations.ts`:
- Around line 211-219: The current helper collects all matching
agentWebhookUserThreads into memory via .collect(), which can OOM on large
datasets; replace the collect() + for loop with an async stream and delete rows
one-by-one. Specifically, remove the .collect() call and iterate using for await
over ctx.db.query('agentWebhookUserThreads').withIndex('by_webhookId_userHash',
q => q.eq('webhookId', args.webhookId')).stream(), calling await
ctx.db.delete(row._id) for each streamed row so deletion is done incrementally.
In `@services/platform/convex/agents/webhooks/mutations.ts`:
- Around line 88-98: The code currently collects the entire
agentWebhookUserThreads query result with .collect(), which can OOM for large
tables; change it to stream results using an async iterator (for await) over
ctx.db.query('agentWebhookUserThreads').withIndex('by_webhookId_userHash', q =>
q.eq('webhookId', args.webhookId)) and delete each row as it is yielded via
ctx.db.delete(row._id) instead of materializing into mappings; ensure you remove
the .collect() call and use a for await (const row of ...) loop around
ctx.db.delete(row._id).
In `@services/platform/convex/threads/delete_chat_thread.ts`:
- Around line 36-42: The code currently uses .collect() on the Convex query
result (webhookMappings = await
ctx.db.query('agentWebhookUserThreads').withIndex('by_threadId', (q) =>
q.eq('threadId', threadId)).collect()), which pulls the entire set into memory;
change this to iterate the query result with a for await loop over
ctx.db.query('agentWebhookUserThreads').withIndex('by_threadId', (q) =>
q.eq('threadId', threadId)) and inside the loop call ctx.db.delete(row._id) for
each row (keep the same delete call and variable name row._id) so rows are
processed lazily and memory usage stays bounded.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: c5e05937-a628-4b4e-9473-512c77638c07
📒 Files selected for processing (19)
docs/de/tutorials/admin/meeting-transcription.mddocs/fr/tutorials/admin/meeting-transcription.mddocs/tutorials/admin/meeting-transcription.mdservices/platform/app/features/agents/components/agent-webhook-section.tsxservices/platform/convex/agents/webhooks/http_actions.test.tsservices/platform/convex/agents/webhooks/http_actions.tsservices/platform/convex/agents/webhooks/internal_actions.test.tsservices/platform/convex/agents/webhooks/internal_actions.tsservices/platform/convex/agents/webhooks/internal_mutations.tsservices/platform/convex/agents/webhooks/mutations.tsservices/platform/convex/agents/webhooks/schema.tsservices/platform/convex/lib/context_management/constants.tsservices/platform/convex/openai_compat/response_format.tsservices/platform/convex/schema.tsservices/platform/convex/threads/delete_chat_thread.test.tsservices/platform/convex/threads/delete_chat_thread.tsservices/platform/messages/de.jsonservices/platform/messages/en.jsonservices/platform/messages/fr.json
| - **Leere Zusammenfassung** — das Transkript ist in Tale angekommen, aber der Agent hat abgelehnt. Den Konversations-Thread in der Tale-UI auf die tatsächliche Modell-Antwort prüfen; wurde der Agent durch [Richtlinien](/de/platform/admin/governance) gesperrt, steht der Grund dort. | ||
| - **401 Unauthorized** — API-Schlüssel widerrufen oder vertippt; in **Einstellungen > API-Schlüssel** neu erzeugen. | ||
| - **Leere oder abgelehnte Zusammenfassung** — den Konversations-Thread des Agents in Tale prüfen; die vollständige Modell-Antwort (inklusive Ablehnungs- oder Governance-Meldungen) steht dort. | ||
| - **401 Unauthorized** — die Webhook-URL ist ungültig oder der Webhook ist deaktiviert. Prüfe Einstellungen > Agent > **Webhook**-Tab; aktiv schalten oder löschen und neu erstellen. |
There was a problem hiding this comment.
Dokumentiere deaktivierte Webhooks als 403 statt 401.
services/platform/convex/agents/webhooks/http_actions.ts gibt im OpenAI-Pfad nur für ungültige Tokens 401 zurück; ein deaktivierter Webhook wird mit 403 beantwortet. So wie es hier steht, landet das Troubleshooting beim falschen Fehlerbild.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/de/tutorials/admin/meeting-transcription.md` at line 83, The docs
currently state a disabled or invalid webhook returns 401; update the sentence
in meeting-transcription.md to distinguish cases: say a disabled webhook returns
403 (Forbidden) while an invalid token returns 401 (Unauthorized), matching the
behavior implemented in http_actions.ts (the OpenAI webhook handling path).
Adjust the troubleshooting advice to check for a 403 when the webhook is
disabled and for 401 when the token is invalid.
| - **Résumé vide** — la transcription est arrivée à Tale mais l'agent a refusé. Regarde le thread de conversation dans l'UI Tale pour voir la vraie réponse du modèle ; si l'agent a été bloqué par les [règles de gouvernance](/fr/platform/admin/governance), la raison y apparaît. | ||
| - **401 Unauthorized** — clé API révoquée ou mal saisie ; régénérer dans **Paramètres > Clés API**. | ||
| - **Résumé vide ou refusé** — inspecte le thread de conversation de l'agent dans Tale ; la réponse complète du modèle (y compris les refus ou messages de gouvernance) y apparaît. | ||
| - **401 Unauthorized** — l'URL du webhook est invalide ou le webhook est désactivé. Vérifie Paramètres > agent > onglet **Webhook** ; bascule en actif, ou supprime et régénère. |
There was a problem hiding this comment.
Documente le webhook désactivé en 403, pas en 401.
Dans services/platform/convex/agents/webhooks/http_actions.ts, le chemin OpenAI renvoie 401 uniquement pour un token invalide ; un webhook désactivé renvoie 403. Tel quel, ce dépannage oriente vers le mauvais statut HTTP.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/fr/tutorials/admin/meeting-transcription.md` at line 83, Update the
documentation to reflect the correct HTTP status for a disabled webhook: change
the troubleshooting entry that currently lists **401 Unauthorized** for a
disabled webhook to **403 Forbidden**; reference the behavior implemented in
services/platform/convex/agents/webhooks/http_actions.ts (the OpenAI webhook
handling path) which returns 401 only for invalid tokens and returns 403 when a
webhook is disabled so readers are guided to check webhook activation rather
than token validity.
| - **Empty summary** — the transcript reached Tale but the agent refused. Check the conversation thread in the Tale UI for the actual model response; if the agent was gated by [governance rules](/platform/admin/governance), the reason appears there. | ||
| - **401 Unauthorized** — API key was revoked or mistyped; regenerate in **Settings > API Keys**. | ||
| - **Empty or refused summary** — inspect the agent's conversation thread in Tale; the full model response (including any refusal or governance message) appears there. | ||
| - **401 Unauthorized** — the webhook URL is invalid or the webhook is disabled. Check Settings > agent > **Webhook** tab; toggle active, or delete and regenerate. |
There was a problem hiding this comment.
Document disabled webhooks as 403, not 401.
services/platform/convex/agents/webhooks/http_actions.ts returns 401 only for invalid tokens on the OpenAI path; a disabled webhook is mapped to 403. Keeping both cases under “401 Unauthorized” will send users down the wrong troubleshooting path.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/tutorials/admin/meeting-transcription.md` at line 83, The docs entry
incorrectly groups disabled webhooks under "401 Unauthorized"; update the
sentence in docs/tutorials/admin/meeting-transcription.md to document disabled
webhooks as "403 Forbidden" instead of 401 and adjust the troubleshooting hint
accordingly (mention Settings > agent > Webhook tab: toggle active, or delete
and regenerate) so it matches the actual mapping implemented in
services/platform/convex/agents/webhooks/http_actions.ts (where 401 is only
returned for invalid tokens on the OpenAI path and disabled webhooks are
returned as 403). Ensure the wording clearly differentiates invalid token (401)
vs. disabled webhook (403).
| async function fetchUsageForThread( | ||
| ctx: ActionCtx, | ||
| threadId: string, | ||
| ): Promise<{ usage: OpenAIUsage; model: string | null }> { | ||
| try { | ||
| const meta = await ctx.runQuery( | ||
| api.message_metadata.queries.getMessageMetadata, | ||
| { | ||
| messageId: '', | ||
| threadId, | ||
| }, | ||
| ); | ||
| if (!meta) { | ||
| return { usage: ZERO_USAGE, model: null }; | ||
| } | ||
| const prompt = meta.inputTokens ?? 0; | ||
| const completion = meta.outputTokens ?? 0; | ||
| const total = meta.totalTokens ?? prompt + completion; | ||
| return { | ||
| usage: { | ||
| prompt_tokens: prompt, | ||
| completion_tokens: completion, | ||
| total_tokens: total, | ||
| }, | ||
| model: meta.model ?? null, | ||
| }; |
There was a problem hiding this comment.
Lookup usage by turn, not by thread.
The OpenAI path now intentionally reuses a thread per userHash, but fetchUsageForThread() calls getMessageMetadata with messageId: '', which forces the query to fall back to “latest metadata in this thread”. On a reused or concurrent thread, that can return the previous or next turn’s inputTokens, outputTokens, and model, so the ChatCompletion response can report usage/model for the wrong completion. Return the generated assistant messageId (or metadata id) from chatViaWebhook and query metadata for that specific turn instead.
Possible fix direction
- let chatResult: { threadId: string; streamId: string };
+ let chatResult: { threadId: string; streamId: string; messageId: string };
- async function fetchUsageForThread(
- ctx: ActionCtx,
- threadId: string,
- ): Promise<{ usage: OpenAIUsage; model: string | null }> {
+ async function fetchUsageForMessage(
+ ctx: ActionCtx,
+ messageId: string,
+ threadId: string,
+ ): Promise<{ usage: OpenAIUsage; model: string | null }> {
const meta = await ctx.runQuery(
api.message_metadata.queries.getMessageMetadata,
{
- messageId: '',
+ messageId,
threadId,
},
);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/agents/webhooks/http_actions.ts` around lines 461 -
486, fetchUsageForThread currently queries getMessageMetadata with messageId: ''
which returns the latest metadata for a thread and can return the wrong turn
when threads are reused; change the flow so chatViaWebhook returns the generated
assistant messageId (or metadata id) and update fetchUsageForThread to accept
that messageId and call api.message_metadata.queries.getMessageMetadata with the
concrete messageId (instead of '') to fetch prompt/output/total and model for
that specific turn; update callers of fetchUsageForThread/chatViaWebhook to pass
and propagate the returned messageId accordingly (references:
fetchUsageForThread, chatViaWebhook,
api.message_metadata.queries.getMessageMetadata, messageId).
| const rows = await ctx.db | ||
| .query('agentWebhookUserThreads') | ||
| .withIndex('by_webhookId_userHash', (q) => | ||
| q.eq('webhookId', args.webhookId), | ||
| ) | ||
| .collect(); | ||
|
|
||
| for (const row of rows) { | ||
| await ctx.db.delete(row._id); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check if the file exists and read the relevant section
fd -t f "internal_mutations.ts" services/platform/convex/Repository: tale-project/tale
Length of output: 2269
🏁 Script executed:
# Get file size first
wc -l services/platform/convex/agents/webhooks/internal_mutations.tsRepository: tale-project/tale
Length of output: 127
🏁 Script executed:
# Read lines around 211-219 to verify the issue
sed -n '205,225p' services/platform/convex/agents/webhooks/internal_mutations.tsRepository: tale-project/tale
Length of output: 567
🏁 Script executed:
# Check for all .collect() usages in the file
rg "\.collect\(\)" services/platform/convex/agents/webhooks/internal_mutations.ts -nRepository: tale-project/tale
Length of output: 82
Avoid .collect() in the webhook-thread cascade helper.
This cleanup path is unbounded by webhook usage, so collecting all rows into memory first can cause deletion to fail on large datasets. Stream the index and delete row-by-row with for await.
♻️ Proposed fix
- const rows = await ctx.db
- .query('agentWebhookUserThreads')
- .withIndex('by_webhookId_userHash', (q) =>
- q.eq('webhookId', args.webhookId),
- )
- .collect();
-
- for (const row of rows) {
+ for await (const row of ctx.db
+ .query('agentWebhookUserThreads')
+ .withIndex('by_webhookId_userHash', (q) =>
+ q.eq('webhookId', args.webhookId),
+ )) {
await ctx.db.delete(row._id);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const rows = await ctx.db | |
| .query('agentWebhookUserThreads') | |
| .withIndex('by_webhookId_userHash', (q) => | |
| q.eq('webhookId', args.webhookId), | |
| ) | |
| .collect(); | |
| for (const row of rows) { | |
| await ctx.db.delete(row._id); | |
| for await (const row of ctx.db | |
| .query('agentWebhookUserThreads') | |
| .withIndex('by_webhookId_userHash', (q) => | |
| q.eq('webhookId', args.webhookId), | |
| )) { | |
| await ctx.db.delete(row._id); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/agents/webhooks/internal_mutations.ts` around lines
211 - 219, The current helper collects all matching agentWebhookUserThreads into
memory via .collect(), which can OOM on large datasets; replace the collect() +
for loop with an async stream and delete rows one-by-one. Specifically, remove
the .collect() call and iterate using for await over
ctx.db.query('agentWebhookUserThreads').withIndex('by_webhookId_userHash', q =>
q.eq('webhookId', args.webhookId')).stream(), calling await
ctx.db.delete(row._id) for each streamed row so deletion is done incrementally.
| // Cascade: remove any user→thread mapping rows for this webhook so the | ||
| // table doesn't accumulate orphans referencing a deleted webhookId. | ||
| const mappings = await ctx.db | ||
| .query('agentWebhookUserThreads') | ||
| .withIndex('by_webhookId_userHash', (q) => | ||
| q.eq('webhookId', args.webhookId), | ||
| ) | ||
| .collect(); | ||
| for (const row of mappings) { | ||
| await ctx.db.delete(row._id); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, locate and examine the file
fd -t f "mutations.ts" services/platform/convex/agents/webhooks/Repository: tale-project/tale
Length of output: 177
🏁 Script executed:
# Check file size and read the relevant section
wc -l services/platform/convex/agents/webhooks/mutations.tsRepository: tale-project/tale
Length of output: 118
🏁 Script executed:
# Read lines 80-105 to see the context
sed -n '80,105p' services/platform/convex/agents/webhooks/mutations.tsRepository: tale-project/tale
Length of output: 766
🏁 Script executed:
# Search for all .collect() usages in this file
rg "\.collect\(\)" services/platform/convex/agents/webhooks/mutations.ts -nRepository: tale-project/tale
Length of output: 81
Stream mapping cleanup instead of collecting it.
The code materializes the entire agentWebhookUserThreads result set with .collect() before deletion. Since this table grows with every distinct caller, webhook deletion becomes an unbounded in-memory load. Use for await to stream and delete rows as they arrive.
♻️ Proposed fix
- const mappings = await ctx.db
- .query('agentWebhookUserThreads')
- .withIndex('by_webhookId_userHash', (q) =>
- q.eq('webhookId', args.webhookId),
- )
- .collect();
- for (const row of mappings) {
+ for await (const row of ctx.db
+ .query('agentWebhookUserThreads')
+ .withIndex('by_webhookId_userHash', (q) =>
+ q.eq('webhookId', args.webhookId),
+ )) {
await ctx.db.delete(row._id);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Cascade: remove any user→thread mapping rows for this webhook so the | |
| // table doesn't accumulate orphans referencing a deleted webhookId. | |
| const mappings = await ctx.db | |
| .query('agentWebhookUserThreads') | |
| .withIndex('by_webhookId_userHash', (q) => | |
| q.eq('webhookId', args.webhookId), | |
| ) | |
| .collect(); | |
| for (const row of mappings) { | |
| await ctx.db.delete(row._id); | |
| } | |
| // Cascade: remove any user→thread mapping rows for this webhook so the | |
| // table doesn't accumulate orphans referencing a deleted webhookId. | |
| for await (const row of ctx.db | |
| .query('agentWebhookUserThreads') | |
| .withIndex('by_webhookId_userHash', (q) => | |
| q.eq('webhookId', args.webhookId), | |
| )) { | |
| await ctx.db.delete(row._id); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/agents/webhooks/mutations.ts` around lines 88 - 98,
The code currently collects the entire agentWebhookUserThreads query result with
.collect(), which can OOM for large tables; change it to stream results using an
async iterator (for await) over
ctx.db.query('agentWebhookUserThreads').withIndex('by_webhookId_userHash', q =>
q.eq('webhookId', args.webhookId)) and delete each row as it is yielded via
ctx.db.delete(row._id) instead of materializing into mappings; ensure you remove
the .collect() call and use a for await (const row of ...) loop around
ctx.db.delete(row._id).
| const webhookMappings = await ctx.db | ||
| .query('agentWebhookUserThreads') | ||
| .withIndex('by_threadId', (q) => q.eq('threadId', threadId)) | ||
| .collect(); | ||
| for (const row of webhookMappings) { | ||
| await ctx.db.delete(row._id); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Replace .collect() with for await iteration.
The coding guidelines explicitly prohibit .collect() in Convex files because it pulls the entire result set into memory. Use for await to iterate over the query results instead.
♻️ Proposed fix
- const webhookMappings = await ctx.db
+ const webhookMappings = ctx.db
.query('agentWebhookUserThreads')
- .withIndex('by_threadId', (q) => q.eq('threadId', threadId))
- .collect();
- for (const row of webhookMappings) {
+ .withIndex('by_threadId', (q) => q.eq('threadId', threadId));
+ for await (const row of webhookMappings) {
await ctx.db.delete(row._id);
}As per coding guidelines: "No .collect() in Convex. Iterate with for await. collect pulls the whole result set into memory and scales badly."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const webhookMappings = await ctx.db | |
| .query('agentWebhookUserThreads') | |
| .withIndex('by_threadId', (q) => q.eq('threadId', threadId)) | |
| .collect(); | |
| for (const row of webhookMappings) { | |
| await ctx.db.delete(row._id); | |
| } | |
| const webhookMappings = ctx.db | |
| .query('agentWebhookUserThreads') | |
| .withIndex('by_threadId', (q) => q.eq('threadId', threadId)); | |
| for await (const row of webhookMappings) { | |
| await ctx.db.delete(row._id); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@services/platform/convex/threads/delete_chat_thread.ts` around lines 36 - 42,
The code currently uses .collect() on the Convex query result (webhookMappings =
await ctx.db.query('agentWebhookUserThreads').withIndex('by_threadId', (q) =>
q.eq('threadId', threadId)).collect()), which pulls the entire set into memory;
change this to iterate the query result with a for await loop over
ctx.db.query('agentWebhookUserThreads').withIndex('by_threadId', (q) =>
q.eq('threadId', threadId)) and inside the loop call ctx.db.delete(row._id) for
each row (keep the same delete call and variable name row._id) so rows are
processed lazily and memory usage stays bounded.
When the OpenAI-compat webhook request body sets `model`, we now forward it as `resolveAgentConfig`'s `modelId` arg instead of silently ignoring it. The existing `applyModelOverride` helper validates the value against the agent's `supportedModels` allowlist (stripping the provider qualifier, so `openrouter:openai/gpt-4o` and `openai/gpt-4o` both match). Unrecognised values silently fall back to the agent's default (`supportedModels[0]`), preserving the safe behavior we had before. This makes the endpoint behave like a well-mannered OpenAI-compatible server: clients that care can pick a specific model from a known allowlist; clients that don't (Meetily, which sends arbitrary placeholders) still get something sensible. Docs and Usage dialog copy updated for EN/DE/FR so Meetily users know to enter a real model ID if they want a specific one.
Summary
Adds a per-agent OpenAI ChatCompletion endpoint at
POST /api/agents/wh/<TOKEN>/chat/completionsthat reuses the existing agent webhook path. Meetily (and any other OpenAI-compatible client) can now configure the webhook URL as its Base URL and get a real agent conversation back — system prompt, tools, conversation thread, governance, audit log, and usage ledger all applied.We explicitly chose NOT to extend
/api/v1/chat/completions: that endpoint stays a strict provider pass-through so its 100% OpenAI compatibility is preserved. Agent identity travels in the URL token, never in themodelfield — no split-brain semantics between the two endpoints.Highlights
agentWebhookHandleron path suffix; token parsed at fixed position and validated against^[0-9a-f]{64}$.systemmessage(s) concatenated after the agent's ownsystemInstructionswith\n\n---\n\n; client text capped at 50k chars (413 on overflow).{error:{message,type,code,param}}envelope via the sharedopenAIErrorResponsehelper.user(legacy) andsafety_identifier(current OpenAI spec) both honored as stable caller identifier; sha256-hashed into a newagentWebhookUserThreads(webhookId, userHash) -> threadIdtable so repeated POSTs with the same identifier land in the same thread.getOrCreateUserThread) — atomic under Convex OCC.createdByUserIdat creation so webhook-generated threads surface in the creator's chat history.agentType: 'openai_webhook'(added toAGENT_CONTEXT_CONFIGS) distinguishes the wire format in audit logs without changing existing per-agent analytics.Security fix (applies to both legacy and new paths)
startWebhookChatnow validates that a client-suppliedthreadIdbelongs to the webhook's organization before using it. Closes a pre-existing cross-org bug where a token holder in org A could post into a known threadId from org B.Test plan
npx tsc --noEmit) clean.npm run lint --workspace=@tale/platform) clean.usagefrommessageMetadata.{message}body on base URL unchanged;/api/v1/chat/completionspassthrough unchanged.user→ same thread with history; differentuser→ distinct threads.Summary by CodeRabbit
Release Notes
New Features
Documentation