agentphone-convex
v0.4.0
Published
A production-ready Convex component for AgentPhone messaging, calls, reactive resources, durable outbound work, and webhooks.
Maintainers
Readme
AgentPhone for Convex
agentphone-convex is a Convex component for building messaging and voice
workflows on AgentPhone. It combines a typed API
client with Convex-native state and operations:
- Direct actions for messages, calls, agents, numbers, conversations, usage, and webhook management.
- Scoped, reactive mirrors of agents, numbers, conversations, messages, calls, and call transcripts.
- A durable outbound queue with idempotency, retries, cancellation, status queries, and a provider-free test mode.
- Sub-account provisioning for multi-tenant apps: one tenant, one AgentPhone sub-account, one Convex scope.
- Signed, replay-protected, deduplicated webhooks with synchronous voice responses and retryable asynchronous callbacks.
- Indexed event history and webhook delivery diagnostics.
- Explicit sync helpers for backfills and reconciliation.
request()as a forward-compatible escape hatch for AgentPhone JSON APIs.
Install
npm install agentphone-convexMount the component in convex/convex.config.ts:
import { defineApp } from "convex/server";
import agentphone from "agentphone-convex/convex.config.js";
const app = defineApp();
app.use(agentphone);
export default app;Set the API key on each Convex deployment that will call AgentPhone:
npx convex env set AGENTPHONE_API_KEY=your_keyCreate one shared client in the app's convex/ directory:
// convex/agentphone.ts
import { AgentPhone } from "agentphone-convex";
import { components } from "./_generated/api.js";
export const agentphone = new AgentPhone(components.agentphone, {
defaultAgentId: process.env.AGENTPHONE_AGENT_ID,
defaultNumberId: process.env.AGENTPHONE_NUMBER_ID,
scope: "default",
});The optional scope isolates all component records, delivery IDs, webhook secrets, and queue idempotency keys. Use a stable tenant or workspace ID when a single Convex app serves multiple AgentPhone projects.
Authorization boundary
Component scopes isolate records from one another, but they do not authorize your application's users. Any public Convex function that returns AgentPhone data must authenticate the caller and verify access to the configured scope.
For applications that put workspace access in JWT claims, the package includes a
fail-closed helper. By default it expects an agentphone_scopes claim
containing one scope or an array of scopes:
export const recentEvents = query({
args: { limit: v.optional(v.number()) },
returns: v.array(storedEventValidator),
handler: async (ctx, args) => {
await requireAgentPhoneScopeAccess(ctx, { scope: agentphone.scope });
return await agentphone.listEvents(ctx, args);
},
});If your app stores memberships or roles in Convex, replace the claim helper with an indexed application-level membership lookup. Checking only that a user is signed in is not sufficient when multiple users or tenants share a deployment.
Webhooks and inbound callbacks
Register the HTTP route in convex/http.ts:
import { httpRouter } from "convex/server";
import { agentphone } from "./agentphone.js";
const http = httpRouter();
agentphone.registerRoutes(http);
export default http;Configure AgentPhone once from an internal action:
import { v } from "convex/values";
import { internalAction } from "./_generated/server.js";
import { agentphone } from "./agentphone.js";
export const configureAgentPhoneWebhook = internalAction({
args: {},
returns: v.any(),
handler: async (ctx) =>
await agentphone.configureWebhook(ctx, {
contextLimit: 10,
timeout: 30,
}),
});configureWebhook uses
https://YOUR_CONVEX_SITE/agentphone/webhook?scope=default and stores the
rotating signing secret inside the component. configureProjectWebhook is an
alias. Use configureAgentWebhook(ctx, { agentId }) for an agent override, or
setWebhookSecret if the webhook was configured manually. Passing agentId to
setWebhookSecret stores the secret under the same agent scope that
configureAgentWebhook uses, so agent deliveries verify against it.
Passing AGENTPHONE_WEBHOOK_SECRET directly to the client binds that webhook
route to the client's configured scope and the scopes derived from it;
requests naming another scope receive a 403 response. To serve multiple scopes
from one route — which is what a multi-tenant deployment needs — omit the global
override and store a separate secret for every scope with configureWebhook or
setWebhookSecret.
Callbacks are internal mutations. They receive the event and the scope it arrived on, so one handler can serve every scope a deployment hosts:
import { v } from "convex/values";
import { eventCallbackArgs, voiceResponseValidator } from "agentphone-convex";
import { internal } from "./_generated/api.js";
import { internalMutation } from "./_generated/server.js";
import { agentphone } from "./agentphone.js";
agentphone.incomingEventCallback = internal.agentphone.handleEvent;
export const handleEvent = internalMutation({
args: eventCallbackArgs,
returns: v.union(voiceResponseValidator, v.null()),
handler: async (_ctx, { event, scope }) => {
if (event.event === "agent.message" && event.channel === "voice") {
return { text: "Let me check that for you." };
}
return null;
},
});Voice callbacks run synchronously in the event-insert transaction so their JSON
response can be returned to AgentPhone; a thrown callback rolls the event back
so the provider can retry. Non-voice callbacks run asynchronously and are
retried with exponential backoff before becoming a dead letter. Use
incomingMessageCallback, reactionCallback, or callEndedCallback to
override the catch-all for a specific event family.
Inspect and operate deliveries with listWebhookDeliveries,
listFailedWebhookDeliveries, replayWebhookDelivery, and
cleanupWebhookDeliveries.
Direct messages and calls
Provider calls belong in Convex actions:
export const textCustomer = internalAction({
args: { toNumber: v.string(), body: v.string() },
returns: v.any(),
handler: async (ctx, args) => await agentphone.sendMessage(ctx, args),
});
export const callCustomer = internalAction({
args: { toNumber: v.string() },
returns: v.any(),
handler: async (ctx, { toNumber }) =>
await agentphone.createOutboundCall(ctx, {
toNumber,
initialGreeting: "Hi! Is now still a good time?",
}),
});Successful writes update the local resource mirror and append an API event on a best-effort basis. Local bookkeeping never turns a successful provider side effect into a failed action that an app might accidentally retry.
Durable outbound work
Queue work from a mutation when the app needs idempotency, retries, observable status, or separation from the user-facing transaction:
export const queueReminder = internalMutation({
args: {
appointmentId: v.string(),
toNumber: v.string(),
},
returns: v.any(),
handler: async (ctx, args) =>
await agentphone.enqueueMessage(ctx, {
toNumber: args.toNumber,
body: "Your appointment is tomorrow at 10:00 AM.",
idempotencyKey: `appointment:${args.appointmentId}:reminder`,
maxAttempts: 3,
}),
});The queue stores request data, never the API key. Its scheduled actions read
AGENTPHONE_API_KEY from the Convex environment. The same model supports
enqueueOutboundCall and enqueueWebCall. Query or manage work with
getOutboundStatus, listOutboundRequests, and cancelOutboundRequest.
idempotencyKey deduplicates enqueues in Convex. Retries are additionally
protected end to end: only send failures that can succeed later are retried—
transport errors, 408, 429, and 5xx—while rejected, unauthorized, and
forbidden requests fail immediately. Every attempt for a queued request sends
the same Idempotency-Key header, so a retry after an ambiguous network failure
is not treated as a new send.
An accepted send always reaches a terminal state. If AgentPhone returns a
success status whose body cannot be read, or the result cannot be stored, the
request is still marked sent and carries the error, so no accepted request is
resent or left stuck in sending.
Set testMode: true on a direct or queued request—or on the client—to exercise
the component without an API key or provider call.
Multi-tenant sub-accounts
An AgentPhone sub-account isolates a tenant's agents, numbers, conversations,
calls, and webhooks under one master account and API key. forSubAccount
derives a client bound to that sub-account and to its own component scope, so
the AgentPhone and Convex isolation boundaries always agree:
export const provisionTenant = internalAction({
args: { tenantId: v.id("tenants"), name: v.string() },
returns: v.any(),
handler: async (ctx, args) => {
const subAccount = await agentphone.createSubAccount(ctx, {
name: args.name,
key: args.tenantId, // one sub-account per tenant, however often this runs
});
const tenant = agentphone.forSubAccount(subAccount);
await tenant.configureWebhook(ctx, { contextLimit: 10 });
return await tenant.provisionNumber(ctx, { country: "US" });
},
});key makes provisioning idempotent: the component claims the key in a
transaction before calling AgentPhone, so concurrent signups and retried actions
return the sub-account that already belongs to that tenant. When an attempt ends
with an unknown outcome, the claim is kept and later calls fail loudly rather
than create a second sub-account; syncSubAccounts, adoptSubAccount, and
releaseSubAccountClaim resolve it explicitly.
Derived clients store their records under <scope>:sub:<subAccountId>. Keep
registerRoutes on the master client: the one webhook route resolves each
delivery's scope from its URL and verifies it against that scope's secret.
Sub-account management stays on the master client too, matching AgentPhone's
single level of nesting. Read the registry with listLocalSubAccounts and
getLocalSubAccount.
Reactive resource mirrors
Webhooks, direct sends/calls, queued work, transcript fetches, and explicit syncs update scoped component tables. Query them from normal Convex queries:
import { v } from "convex/values";
import { query } from "./_generated/server.js";
import { requireAgentPhoneScopeAccess } from "agentphone-convex";
import { agentphone } from "./agentphone.js";
export const conversationState = query({
args: { conversationId: v.string() },
returns: v.any(),
handler: async (ctx, args) => {
await requireAgentPhoneScopeAccess(ctx, { scope: agentphone.scope });
return await agentphone.getLatestConversationState(ctx, {
...args,
messageLimit: 25,
});
},
});The client exports validators and inferred types for every mirrored resource. Available reads include:
listLocalAgents,getLocalAgent,listLocalNumbers,getLocalNumberlistLocalConversations,getLocalConversation- messages by conversation, agent, number, or counterparty
- calls by agent or number, plus
getLocalCallTranscript getLatestConversationStatefor one conversation and its recent messages
All reads are index-backed, default to 50 records, and reject limits above 100.
Sync and backfill
Use syncSubAccounts, syncAgents, syncNumbers, syncConversations,
syncMessages, and syncCalls from actions. Each helper fetches one bounded provider page,
normalizes it into the component, and returns { synced, response }.
const { synced } = await agentphone.syncMessages(ctx, {
conversationId: "conv_123",
limit: 100,
});Pagination stays explicit so an app can schedule its own backfill cadence and respect provider rate limits.
Event history
The immutable event log is separate from the latest-state resource mirrors. Use
listEvents, listIncoming, listOutgoing, listEventsByType,
listEventsByAgent, listEventsByNumber, listEventsByConversation,
listEventsByCall, or getEventByDeliveryId.
AgentPhone API coverage
The client includes helpers for agents, voices, agent calls/conversations, numbers, number messages/calls, conversations, typing indicators, messages, reactions, calls, transcripts, recordings, usage breakdowns, sub-accounts, project and agent webhooks, and provider delivery statistics.
For a new or less common JSON endpoint:
const result = await agentphone.request<MyResponse>(ctx, {
method: "GET",
path: "usage/daily",
query: { days: 30 },
});Streaming transcripts and large binary recording responses should use an app-owned HTTP action rather than passing a stream through Convex values.
Development
npm ci
CONVEX_AGENT_MODE=anonymous npx convex dev --once --typecheck-components
npm run verify
npm pack --dry-runSee example/convex/ for a complete backend setup and the
AgentPhone API docs for provider
behavior.
